diff --git a/src/main/java/com/example/demo/controller/auth/LearningAuthController.java b/src/main/java/com/example/demo/controller/auth/LearningAuthController.java index 8e07158..07a6c0b 100644 --- a/src/main/java/com/example/demo/controller/auth/LearningAuthController.java +++ b/src/main/java/com/example/demo/controller/auth/LearningAuthController.java @@ -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> login(@Valid @RequestBody LoginRequest request) { + public ApiResponse> 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 when calling protected lab APIs." + "tip", "Attach Authorization: Bearer when calling protected lab APIs or rely on the demo auth cookie for page access." + )); + } + + @PostMapping("/logout") + public ApiResponse> 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> 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> 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(); + } } diff --git a/src/main/java/com/example/demo/security/LearningJwtFilter.java b/src/main/java/com/example/demo/security/LearningJwtFilter.java index 6c50136..f261747 100644 --- a/src/main/java/com/example/demo/security/LearningJwtFilter.java +++ b/src/main/java/com/example/demo/security/LearningJwtFilter.java @@ -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,33 +48,39 @@ 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; + SecurityContextHolder.clearContext(); + String token = resolveToken(request); + if (StringUtils.hasText(token) && jwtUtil.validate(token)) { + String username = jwtUtil.username(token); + var authToken = new UsernamePasswordAuthenticationToken( + username, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); } - String token = auth.substring(7); - if (!jwtUtil.validate(token)) { - writeUnauthorized(response, "Token is invalid or expired"); - return; - } - - String username = jwtUtil.username(token); - var authToken = new UsernamePasswordAuthenticationToken( - username, - null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); - 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 ""; } } diff --git a/src/main/java/com/example/demo/security/LearningJwtUtil.java b/src/main/java/com/example/demo/security/LearningJwtUtil.java index f1c6960..46455e0 100644 --- a/src/main/java/com/example/demo/security/LearningJwtUtil.java +++ b/src/main/java/com/example/demo/security/LearningJwtUtil.java @@ -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(); } diff --git a/src/main/java/com/example/demo/security/LearningSecurityConfig.java b/src/main/java/com/example/demo/security/LearningSecurityConfig.java index 9a525c9..d258a1c 100644 --- a/src/main/java/com/example/demo/security/LearningSecurityConfig.java +++ b/src/main/java/com/example/demo/security/LearningSecurityConfig.java @@ -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")); + } } diff --git a/src/main/resources/static/access.html b/src/main/resources/static/access.html index 2101683..e4d2cfd 100644 --- a/src/main/resources/static/access.html +++ b/src/main/resources/static/access.html @@ -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"; } diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index b467ea1..cf386ca 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -3,755 +3,194 @@ - Spring Boot Learning Cockpit + Spring 学习总控台 +
-
-
+
+

- - - - - + + + +
-
5
-
6
+
JWT + Cookie
+
6
4
-
2+
-
-
-
-
-
-

-
-
- -

-
-
- -

-
-
- -

-
-
+
12+
+
-
-
-

-

-
-
- -

- -
-
- -

- -
-
-
- -
-
-
- -
-
-
- -
-
-

-
-
-
-
-
-
-
+
+
+

+

+
-
-
- -
-
-
-

-

- -
- - - - - - - -
-

- -
-
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-
-
- - -
- -
-
-

-                
-
- -
-
-

-

-
- UserController - UserService - GlobalExceptionHandler -
-
- -
-
-

-

-
- PerformanceAspect - @Around - Cross-cutting -
-
- -
-
-

-

-
- Publisher - Listener - @Async -
-
-
diff --git a/src/main/resources/static/learning-shell.js b/src/main/resources/static/learning-shell.js index 62fe152..2535d85 100644 --- a/src/main/resources/static/learning-shell.js +++ b/src/main/resources/static/learning-shell.js @@ -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 @@ ' ', " ", '
', - ' ', + ' ', ' ', ' ', ' ', @@ -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 || []; } }; })(); diff --git a/src/test/java/com/example/demo/controller/AuthFlowTest.java b/src/test/java/com/example/demo/controller/AuthFlowTest.java index 8370e24..7019b9b 100644 --- a/src/test/java/com/example/demo/controller/AuthFlowTest.java +++ b/src/test/java/com/example/demo/controller/AuthFlowTest.java @@ -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); + } }