feat: protect learning pages and rebuild cockpit

This commit is contained in:
Codex
2026-03-24 17:07:40 +08:00
parent 368a5061b0
commit d81750aaf9
8 changed files with 430 additions and 809 deletions

View File

@@ -3,8 +3,12 @@ package com.example.demo.controller.auth;
import com.example.demo.common.ApiResponse;
import com.example.demo.dto.auth.LoginRequest;
import com.example.demo.security.LearningJwtUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -13,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.Map;
@RestController
@@ -26,18 +31,28 @@ public class LearningAuthController {
}
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest request) {
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
if (!(("admin".equals(request.username()) && "admin123".equals(request.password()))
|| ("user".equals(request.username()) && "user123".equals(request.password())))) {
return new ApiResponse<>(401, "Invalid demo credentials", null, java.time.Instant.now());
}
String token = jwtUtil.generateToken(request.username());
response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token).toString());
return ApiResponse.ok(Map.of(
"token", token,
"type", "Bearer",
"username", request.username(),
"tip", "Attach Authorization: Bearer <token> when calling protected lab APIs."
"tip", "Attach Authorization: Bearer <token> when calling protected lab APIs or rely on the demo auth cookie for page access."
));
}
@PostMapping("/logout")
public ApiResponse<Map<String, Object>> logout(HttpServletResponse response) {
response.addHeader(HttpHeaders.SET_COOKIE, clearAuthCookie().toString());
return ApiResponse.ok(Map.of(
"cleared", true,
"tip", "Frontend local storage should be cleared too."
));
}
@@ -45,21 +60,22 @@ public class LearningAuthController {
public ApiResponse<Map<String, Object>> mode() {
return ApiResponse.ok(Map.of(
"mode", "learning-jwt",
"protectedPaths", new String[]{"/api/users/**", "/aop/**", "/api/lab/**", "/learn/**", "/api/secure/**"},
"protectedPaths", new String[]{"/index.html", "/users.html", "/aop.html", "/events.html", "/api/users/**", "/aop/**", "/api/lab/**", "/learn/**", "/api/secure/**"},
"defaultAccounts", new String[]{"admin/admin123", "user/user123"},
"note", "Use this demo login before opening the advanced labs on a public VPS."
"note", "Login now returns both a bearer token and an auth cookie so protected pages and APIs can be studied together."
));
}
@GetMapping("/introspect")
public ApiResponse<Map<String, Object>> introspect(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
HttpServletRequest request
) {
if (!StringUtils.hasText(authorization) || !authorization.startsWith("Bearer ")) {
String token = resolveToken(authorization, request);
if (!StringUtils.hasText(token)) {
return ApiResponse.fail(401, "Missing bearer token");
}
String token = authorization.substring(7);
if (!jwtUtil.validate(token)) {
return ApiResponse.fail(401, "Token is invalid or expired");
}
@@ -70,4 +86,40 @@ public class LearningAuthController {
"tip", "JWT is self-contained: the server can read claims without storing a session row for each request."
));
}
private String resolveToken(String authorization, HttpServletRequest request) {
if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
return authorization.substring(7);
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return "";
}
for (Cookie cookie : cookies) {
if (LearningJwtUtil.AUTH_COOKIE_NAME.equals(cookie.getName()) && StringUtils.hasText(cookie.getValue())) {
return cookie.getValue();
}
}
return "";
}
private ResponseCookie authCookie(String token) {
return ResponseCookie.from(LearningJwtUtil.AUTH_COOKIE_NAME, token)
.httpOnly(true)
.sameSite("Lax")
.path("/")
.maxAge(Duration.ofMillis(jwtUtil.expirationMillis()))
.build();
}
private ResponseCookie clearAuthCookie() {
return ResponseCookie.from(LearningJwtUtil.AUTH_COOKIE_NAME, "")
.httpOnly(true)
.sameSite("Lax")
.path("/")
.maxAge(Duration.ZERO)
.build();
}
}

View File

@@ -2,6 +2,7 @@ package com.example.demo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -28,9 +29,17 @@ public class LearningJwtFilter extends OncePerRequestFilter {
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
boolean learnRoute = "/learn".equals(uri) || uri.startsWith("/learn/");
return !(uri.startsWith("/api/secure/")
boolean protectedPage = "/".equals(uri)
|| "/home".equals(uri)
|| "/index.html".equals(uri)
|| "/users.html".equals(uri)
|| "/aop.html".equals(uri)
|| "/events.html".equals(uri);
return !(protectedPage
|| uri.startsWith("/api/secure/")
|| uri.equals("/api/users")
|| uri.startsWith("/api/users/")
|| "/aop".equals(uri)
|| uri.startsWith("/aop/")
|| uri.startsWith("/api/lab/")
|| learnRoute);
@@ -39,18 +48,9 @@ public class LearningJwtFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String auth = request.getHeader("Authorization");
if (!StringUtils.hasText(auth) || !auth.startsWith("Bearer ")) {
writeUnauthorized(response, "Missing or invalid Authorization header");
return;
}
String token = auth.substring(7);
if (!jwtUtil.validate(token)) {
writeUnauthorized(response, "Token is invalid or expired");
return;
}
SecurityContextHolder.clearContext();
String token = resolveToken(request);
if (StringUtils.hasText(token) && jwtUtil.validate(token)) {
String username = jwtUtil.username(token);
var authToken = new UsernamePasswordAuthenticationToken(
username,
@@ -59,13 +59,28 @@ public class LearningJwtFilter extends OncePerRequestFilter {
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
filterChain.doFilter(request, response);
}
private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"" + message + "\"}");
private String resolveToken(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
return authorization.substring(7);
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return "";
}
for (Cookie cookie : cookies) {
if (LearningJwtUtil.AUTH_COOKIE_NAME.equals(cookie.getName()) && StringUtils.hasText(cookie.getValue())) {
return cookie.getValue();
}
}
return "";
}
}

View File

@@ -14,6 +14,8 @@ import java.util.Map;
@Component
public class LearningJwtUtil {
public static final String AUTH_COOKIE_NAME = "learning_demo_token";
@Value("${learning.auth.jwt.secret}")
private String secret;
@@ -52,6 +54,10 @@ public class LearningJwtUtil {
return Map.copyOf(parse(token));
}
public long expirationMillis() {
return expiration;
}
private Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
}

View File

@@ -1,8 +1,11 @@
package com.example.demo.security;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
@@ -24,15 +27,34 @@ public class LearningSecurityConfig {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
if (isHtmlRequest(request)) {
response.sendRedirect("/access.html");
return;
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"Authentication required\"}");
}))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/", "/home", "/api/auth/**", "/actuator/health", "/index.html", "/access.html",
"/users.html", "/aop.html", "/events.html", "/advanced.html"
"/access.html", "/api/auth/**", "/actuator/health", "/error",
"/learning-shell.js", "/favicon.ico"
).permitAll()
.requestMatchers("/api/secure/**", "/api/users/**", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
.anyRequest().permitAll()
.requestMatchers("/", "/home", "/index.html", "/users.html", "/aop.html", "/events.html").authenticated()
.requestMatchers("/api/secure/**", "/api/users/**", "/aop", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
.anyRequest().denyAll()
)
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private boolean isHtmlRequest(HttpServletRequest request) {
String path = request.getRequestURI();
String accept = request.getHeader(HttpHeaders.ACCEPT);
return "/".equals(path)
|| "/home".equals(path)
|| path.endsWith(".html")
|| (accept != null && accept.contains("text/html"));
}
}

View File

@@ -375,10 +375,10 @@
}
}
function logout() {
window.learningShell.clearAuth();
async function logout() {
await window.learningShell.logoutRemote();
setStatus(pageText().logoutSuccess, "success");
document.getElementById("resultBox").textContent = JSON.stringify({ cleared: true }, null, 2);
document.getElementById("resultBox").textContent = JSON.stringify({ cleared: true, cookie: true }, null, 2);
document.getElementById("resultBox").dataset.state = "live";
}

File diff suppressed because one or more lines are too long

View File

@@ -7,30 +7,79 @@
const TEXT = {
zh: {
brand: "Spring \u5b66\u4e60\u5de5\u4f5c\u53f0",
home: "\u9996\u9875",
access: "\u767b\u5f55\u9875",
loginReady: "\u5df2\u767b\u5f55\uff0c\u53ef\u8bbf\u95ee\u53d7\u4fdd\u62a4\u5b9e\u9a8c",
loginMissing: "\u672a\u767b\u5f55\uff0c\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u4f1a\u8fd4\u56de 401",
currentUser: "\u5f53\u524d\u7528\u6237",
logout: "\u9000\u51fa\u767b\u5f55",
login: "\u53bb\u767b\u5f55",
brand: "Spring 学习工作台",
home: "学习总控台",
access: "登录页",
loginReady: "已登录,受保护页面和接口都可访问",
loginMissing: "未登录,受保护页面会跳回登录页",
currentUser: "当前用户",
logout: "退出登录",
login: "去登录",
languageToggle: "EN",
unauthorized: "\u672a\u767b\u5f55\u6216\u4ee4\u724c\u5df2\u5931\u6548\uff0c\u8bf7\u5148\u6253\u5f00\u767b\u5f55\u9875\u5b8c\u6210\u6f14\u793a\u767b\u5f55\u3002",
requestFailed: "\u8bf7\u6c42\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002"
unauthorized: "未登录或认证已失效,请先打开登录页重新登录。",
requestFailed: "请求失败,请稍后再试。",
authRequiredTitle: "登录后即可使用",
authRequiredMessage: "请先去登录页获取 token才能继续访问主控台和实验",
authRequiredButton: "前往登录"
},
en: {
brand: "Spring Learning Workspace",
home: "Home",
home: "Cockpit",
access: "Login",
loginReady: "Authenticated, protected labs are available",
loginMissing: "Not logged in, protected labs will return 401",
loginReady: "Authenticated. Protected pages and APIs are available.",
loginMissing: "Not authenticated. Protected pages will redirect to login.",
currentUser: "User",
logout: "Logout",
login: "Login",
languageToggle: "\u4e2d\u6587",
unauthorized: "Not logged in or token expired. Open the login page first.",
requestFailed: "Request failed. Please try again."
languageToggle: "中文",
unauthorized: "Not authenticated or session expired. Open the login page first.",
requestFailed: "Request failed. Please try again.",
authRequiredTitle: "Login required",
authRequiredMessage: "Obtain a token through the login lab before accessing protected modules.",
authRequiredButton: "Go to login"
}
};
const AUTH_INSIGHTS = {
zh: {
jwt: {
label: "JWT 路线",
summary: "浏览器先调用 /api/auth/login 获取 Bearer Token之后通过 Authorization 头访问受保护接口,重点学习无状态鉴权。",
learnMore: "观察 LearningJwtFilter 如何从 Header 或 Cookie 中提取认证信息。"
},
satoken: {
label: "Sa-Token 对照",
summary: "这里先用可视化说明 Sa-Token 常见的会话、注解与拦截器思路,帮助你和 JWT 的链路做对比。",
learnMore: "适合继续扩展成 Sa-Token 章节,比较登录状态持久化、注解鉴权与刷新机制。"
},
filters: ["LearningJwtFilter", "AuthenticationEntryPoint", "AuthorizationFilter"],
tokenTips: "当前实现会同时下发 Bearer Token 和认证 Cookie。这样既能继续学习 Header 鉴权,也能让普通页面跳转真正受登录保护。",
timeline: [
{ stage: "1. 登录", detail: "POST /api/auth/login服务端同时返回 token 与认证 Cookie。" },
{ stage: "2. 访问页面", detail: "浏览器普通跳转自动携带 Cookie受保护 HTML 页面可直接放行。" },
{ stage: "3. 调接口", detail: "前端 fetch 默认带 Cookie如果本地存了 token也会补上 Authorization 头。" },
{ stage: "4. 失效处理", detail: "认证缺失或失效时,页面跳回 /access.html接口返回 401。" }
]
},
en: {
jwt: {
label: "JWT Track",
summary: "The browser calls /api/auth/login first, then uses Authorization headers to reach protected APIs and study stateless authentication.",
learnMore: "Follow LearningJwtFilter to see how auth is resolved from headers or cookies."
},
satoken: {
label: "Sa-Token Contrast",
summary: "Use this visual block to compare session-oriented auth, annotations, and interceptors against the JWT route.",
learnMore: "This is a good bridge if you later add a dedicated Sa-Token chapter."
},
filters: ["LearningJwtFilter", "AuthenticationEntryPoint", "AuthorizationFilter"],
tokenTips: "The demo now issues both a bearer token and an auth cookie, so header-based learning and page-level protection can coexist.",
timeline: [
{ stage: "1. Login", detail: "POST /api/auth/login returns both a token and an auth cookie." },
{ stage: "2. Open pages", detail: "Normal browser navigation sends the cookie so protected HTML pages can be secured." },
{ stage: "3. Call APIs", detail: "Fetch sends cookies automatically and also adds Authorization when a token is stored locally." },
{ stage: "4. Handle expiry", detail: "Missing or expired auth redirects pages to /access.html while APIs return 401." }
]
}
};
@@ -52,8 +101,8 @@
}
function t(key) {
const lang = getLanguage();
return (TEXT[lang] && TEXT[lang][key]) || key;
const language = getLanguage();
return (TEXT[language] && TEXT[language][key]) || key;
}
function getToken() {
@@ -65,12 +114,22 @@
}
function isLoggedIn() {
return Boolean(getToken());
return Boolean(getToken()) || Boolean(getUsername());
}
function saveAuth(token, username) {
localStorage.setItem(STORAGE.token, token || "");
localStorage.setItem(STORAGE.username, username || "");
if (token) {
localStorage.setItem(STORAGE.token, token);
} else {
localStorage.removeItem(STORAGE.token);
}
if (username) {
localStorage.setItem(STORAGE.username, username);
} else {
localStorage.removeItem(STORAGE.username);
}
window.dispatchEvent(new CustomEvent("learning-auth-changed", {
detail: { loggedIn: isLoggedIn(), username: getUsername() }
}));
@@ -84,6 +143,19 @@
}));
}
async function logoutRemote() {
try {
await fetch("/api/auth/logout", {
method: "POST",
credentials: "same-origin"
});
} catch (error) {
// Ignore network errors here; local state should still be cleared.
} finally {
clearAuth();
}
}
function ensureStyle() {
if (document.getElementById("learning-shell-style")) {
return;
@@ -106,7 +178,7 @@
" padding: 14px 18px;",
" border-radius: 22px;",
" border: 1px solid rgba(216, 228, 240, 0.9);",
" background: rgba(255, 255, 255, 0.88);",
" background: rgba(255, 255, 255, 0.9);",
" box-shadow: 0 12px 28px rgba(18, 32, 51, 0.08);",
" backdrop-filter: blur(10px);",
"}",
@@ -176,6 +248,9 @@
if (options && typeof options.onLanguageChange === "function") {
options.onLanguageChange(getLanguage());
}
if (options && typeof options.onAuthChange === "function") {
options.onAuthChange({ loggedIn: isLoggedIn(), username: getUsername() });
}
return;
}
@@ -188,7 +263,7 @@
' <span data-role="status"></span>',
" </div>",
' <div class="learning-shell-actions">',
' <a class="learning-shell-link" href="/" data-role="home"></a>',
' <a class="learning-shell-link" href="/index.html" data-role="home"></a>',
' <a class="learning-shell-link" href="/access.html" data-role="access"></a>',
' <span class="learning-shell-badge" data-role="user"></span>',
' <button class="learning-shell-button alt" type="button" data-role="language"></button>',
@@ -224,7 +299,9 @@
els.status.textContent = loggedIn ? t("loginReady") : t("loginMissing");
els.home.textContent = t("home");
els.access.textContent = t("access");
els.user.textContent = loggedIn ? t("currentUser") + ": " + username : t("loginMissing");
els.user.textContent = loggedIn
? t("currentUser") + ": " + (username || "demo-user")
: t("loginMissing");
els.language.textContent = t("languageToggle");
els.login.textContent = t("login");
els.logout.textContent = t("logout");
@@ -247,9 +324,14 @@
window.location.href = "/access.html";
});
els.logout.addEventListener("click", function () {
clearAuth();
els.logout.addEventListener("click", async function () {
await logoutRemote();
render();
if (options && typeof options.onLogout === "function") {
options.onLogout();
return;
}
window.location.href = "/access.html";
});
window.addEventListener("learning-auth-changed", render);
@@ -266,7 +348,7 @@
headers.set("Authorization", "Bearer " + token);
}
return fetch(url, Object.assign({}, requestOptions, { headers: headers }));
return fetch(url, Object.assign({ credentials: "same-origin" }, requestOptions, { headers: headers }));
}
async function requestJson(url, options) {
@@ -304,50 +386,13 @@
return payload;
}
const AUTH_INSIGHTS = {
zh: {
title: "鉴权策略学习站",
jwt: {
label: "JWT 登录",
summary: "前端调用 /access 拿到 token之后用 Authorization 头,讲解 stateless API 的常见模式。",
learnMore: "查看 /api/users/stats 的 header观察 LearningJwtFilter 拦截"
},
satoken: {
label: "Sa-Token 链路",
summary: "模拟 Sa-Token 把 session + token 坚持在一起便于对比状态ful 策略在并发控制、刷新和注解保护上的差异。",
learnMore: "观察 Sa-Token 注解是否在 {@link} 模块里生效"
},
filters: ["LearningJwtFilter", "CorsFilter", "AuthorizationFilter"],
tokenTips: "token 生命周期1. /access 登录2. Authorization 验证3. 401 触发刷新或重新登录。"
},
en: {
title: "Auth Strategy Studio",
jwt: {
label: "JWT Login",
summary: "Clients call /access and receive a token, then send Authorization headers for stateless APIs, illustrating modern REST flows.",
learnMore: "Trace the header in /api/users/stats and follow LearningJwtFilter"
},
satoken: {
label: "Sa-Token Flow",
summary: "Simulates combining session + token to highlight stateful auth, interceptor guards, and refresh semantics.",
learnMore: "Spot Sa-Token annotations and how the interceptor compares to filters"
},
filters: ["LearningJwtFilter", "CorsFilter", "AuthorizationFilter"],
tokenTips: "Token lifecycle: acquire at /access, transport in headers, backend validation, expired -> refresh/login."
}
};
const TOKEN_TIMELINE = [
{ stage: "Acquire", detail: "POST /access", badge: "jwt" },
{ stage: "Transport", detail: "Authorization: Bearer / Cookie", badge: "header" },
{ stage: "Validate", detail: "LearningJwtFilter / SaTokenInterceptor", badge: "verify" },
{ stage: "Expire/Refresh", detail: "401 -> re-login", badge: "expire" }
];
function describeError(error) {
if (error && Number(error.status) === 401) {
return t("unauthorized");
}
if (error && error.payload && error.payload.message) {
return error.payload.message;
}
return error && error.message ? error.message : t("requestFailed");
}
@@ -362,6 +407,7 @@
isLoggedIn: isLoggedIn,
saveAuth: saveAuth,
clearAuth: clearAuth,
logoutRemote: logoutRemote,
fetchWithAuth: fetchWithAuth,
requestJson: requestJson,
describeError: describeError,
@@ -369,7 +415,8 @@
return AUTH_INSIGHTS[getLanguage()] || AUTH_INSIGHTS.zh;
},
getTokenTimeline: function () {
return TOKEN_TIMELINE;
const insights = AUTH_INSIGHTS[getLanguage()] || AUTH_INSIGHTS.zh;
return insights.timeline || [];
}
};
})();

View File

@@ -1,6 +1,7 @@
package com.example.demo.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -12,6 +13,7 @@ import java.util.Map;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -69,6 +71,28 @@ class AuthFlowTest {
.andExpect(content().string(containsString("window.learningShell")));
}
@Test
void protectedHtmlShouldRedirectToAccessWithoutAuth() throws Exception {
mockMvc.perform(get("/index.html").accept(MediaType.TEXT_HTML))
.andExpect(status().is3xxRedirection())
.andExpect(header().string("Location", "/access.html"));
}
@Test
void protectedHtmlShouldLoadWithAuthCookie() throws Exception {
mockMvc.perform(get("/index.html").cookie(authCookie()).accept(MediaType.TEXT_HTML))
.andExpect(status().isOk())
.andExpect(content().string(containsString("learning-menu-title")));
}
@Test
void logoutShouldExpireAuthCookie() throws Exception {
mockMvc.perform(post("/api/auth/logout"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(header().string("Set-Cookie", containsString("learning_demo_token=;")));
}
private String bearerToken() throws Exception {
String loginReq = objectMapper.writeValueAsString(Map.of("username", "admin", "password", "admin123"));
@@ -84,4 +108,20 @@ class AuthFlowTest {
String token = objectMapper.readTree(loginResp).path("data").path("token").asText();
return "Bearer " + token;
}
private Cookie authCookie() throws Exception {
String loginReq = objectMapper.writeValueAsString(Map.of("username", "admin", "password", "admin123"));
String loginResp = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(loginReq))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn()
.getResponse()
.getContentAsString();
String token = objectMapper.readTree(loginResp).path("data").path("token").asText();
return new Cookie("learning_demo_token", token);
}
}