From 90e12c26c72dd10aa3d2a09c9e31a908bddf3c69 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Apr 2026 10:33:02 +0800 Subject: [PATCH] fix: harden learning auth flow --- .../auth/LearningAuthController.java | 44 +++++++++++------ .../demo/security/LearningJwtFilter.java | 24 +-------- .../demo/security/LearningRoutePolicy.java | 30 ++++++++++++ .../demo/security/LearningSecurityConfig.java | 49 +++++++++---------- src/main/resources/static/learning-shell.js | 14 +++--- .../example/demo/controller/AuthFlowTest.java | 18 +++++++ 6 files changed, 111 insertions(+), 68 deletions(-) create mode 100644 src/main/java/com/example/demo/security/LearningRoutePolicy.java 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 07a6c0b..4166773 100644 --- a/src/main/java/com/example/demo/controller/auth/LearningAuthController.java +++ b/src/main/java/com/example/demo/controller/auth/LearningAuthController.java @@ -8,7 +8,9 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -31,25 +33,30 @@ public class LearningAuthController { } @PostMapping("/login") - public ApiResponse> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) { + public ResponseEntity>> login( + @Valid @RequestBody LoginRequest request, + HttpServletRequest servletRequest, + 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()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.fail(401, "Invalid demo credentials")); } String token = jwtUtil.generateToken(request.username()); - response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token).toString()); - return ApiResponse.ok(Map.of( + response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token, servletRequest).toString()); + return ResponseEntity.ok(ApiResponse.ok(Map.of( "token", token, "type", "Bearer", "username", request.username(), "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()); + public ApiResponse> logout(HttpServletRequest request, HttpServletResponse response) { + response.addHeader(HttpHeaders.SET_COOKIE, clearAuthCookie(request).toString()); return ApiResponse.ok(Map.of( "cleared", true, "tip", "Frontend local storage should be cleared too." @@ -67,24 +74,26 @@ public class LearningAuthController { } @GetMapping("/introspect") - public ApiResponse> introspect( + public ResponseEntity>> introspect( @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization, HttpServletRequest request ) { String token = resolveToken(authorization, request); if (!StringUtils.hasText(token)) { - return ApiResponse.fail(401, "Missing bearer token"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.fail(401, "Missing bearer token")); } if (!jwtUtil.validate(token)) { - return ApiResponse.fail(401, "Token is invalid or expired"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.fail(401, "Token is invalid or expired")); } - return ApiResponse.ok(Map.of( + return ResponseEntity.ok(ApiResponse.ok(Map.of( "subject", jwtUtil.username(token), "claims", jwtUtil.claims(token), "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) { @@ -105,21 +114,28 @@ public class LearningAuthController { return ""; } - private ResponseCookie authCookie(String token) { + private ResponseCookie authCookie(String token, HttpServletRequest request) { return ResponseCookie.from(LearningJwtUtil.AUTH_COOKIE_NAME, token) .httpOnly(true) .sameSite("Lax") + .secure(isSecureRequest(request)) .path("/") .maxAge(Duration.ofMillis(jwtUtil.expirationMillis())) .build(); } - private ResponseCookie clearAuthCookie() { + private ResponseCookie clearAuthCookie(HttpServletRequest request) { return ResponseCookie.from(LearningJwtUtil.AUTH_COOKIE_NAME, "") .httpOnly(true) .sameSite("Lax") + .secure(isSecureRequest(request)) .path("/") .maxAge(Duration.ZERO) .build(); } + + private boolean isSecureRequest(HttpServletRequest request) { + String forwardedProto = request.getHeader("X-Forwarded-Proto"); + return request.isSecure() || "https".equalsIgnoreCase(forwardedProto); + } } diff --git a/src/main/java/com/example/demo/security/LearningJwtFilter.java b/src/main/java/com/example/demo/security/LearningJwtFilter.java index 8283a6b..aff0a75 100644 --- a/src/main/java/com/example/demo/security/LearningJwtFilter.java +++ b/src/main/java/com/example/demo/security/LearningJwtFilter.java @@ -28,14 +28,7 @@ public class LearningJwtFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); - return !(isProtectedPage(uri) - || uri.startsWith("/api/secure/") - || uri.equals("/api/users") - || uri.startsWith("/api/users/") - || "/aop".equals(uri) - || uri.startsWith("/aop/") - || uri.startsWith("/api/lab/") - || isLearnRoute(uri)); + return !(LearningRoutePolicy.isProtectedPage(uri) || LearningRoutePolicy.isProtectedApi(uri)); } @Override @@ -54,7 +47,7 @@ public class LearningJwtFilter extends OncePerRequestFilter { SecurityContextHolder.getContext().setAuthentication(authToken); } - if (isProtectedPage(request.getRequestURI()) + if (LearningRoutePolicy.isProtectedPage(request.getRequestURI()) && SecurityContextHolder.getContext().getAuthentication() == null) { response.sendRedirect("/access.html"); return; @@ -63,19 +56,6 @@ public class LearningJwtFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } - private boolean isProtectedPage(String uri) { - return "/".equals(uri) - || "/home".equals(uri) - || "/index.html".equals(uri) - || "/users.html".equals(uri) - || "/aop.html".equals(uri) - || "/events.html".equals(uri); - } - - private boolean isLearnRoute(String uri) { - return "/learn".equals(uri) || uri.startsWith("/learn/"); - } - private String resolveToken(HttpServletRequest request) { String authorization = request.getHeader("Authorization"); if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) { diff --git a/src/main/java/com/example/demo/security/LearningRoutePolicy.java b/src/main/java/com/example/demo/security/LearningRoutePolicy.java new file mode 100644 index 0000000..81660e6 --- /dev/null +++ b/src/main/java/com/example/demo/security/LearningRoutePolicy.java @@ -0,0 +1,30 @@ +package com.example.demo.security; + +public final class LearningRoutePolicy { + + private LearningRoutePolicy() { + } + + public static boolean isProtectedPage(String uri) { + return "/".equals(uri) + || "/home".equals(uri) + || "/index.html".equals(uri) + || "/users.html".equals(uri) + || "/aop.html".equals(uri) + || "/events.html".equals(uri); + } + + public static boolean isProtectedApi(String uri) { + return uri.startsWith("/api/secure/") + || uri.equals("/api/users") + || uri.startsWith("/api/users/") + || "/aop".equals(uri) + || uri.startsWith("/aop/") + || uri.startsWith("/api/lab/") + || isLearnRoute(uri); + } + + public static boolean isLearnRoute(String uri) { + return "/learn".equals(uri) || uri.startsWith("/learn/"); + } +} diff --git a/src/main/java/com/example/demo/security/LearningSecurityConfig.java b/src/main/java/com/example/demo/security/LearningSecurityConfig.java index d258a1c..860a887 100644 --- a/src/main/java/com/example/demo/security/LearningSecurityConfig.java +++ b/src/main/java/com/example/demo/security/LearningSecurityConfig.java @@ -25,36 +25,35 @@ public class LearningSecurityConfig { @Bean public SecurityFilterChain learningSecurityFilterChain(HttpSecurity http) throws Exception { 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( - "/access.html", "/api/auth/**", "/actuator/health", "/error", - "/learning-shell.js", "/favicon.ico" - ).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); + .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( + "/access.html", "/api/auth/**", "/actuator/health", "/error", + "/learning-shell.js", "/favicon.ico" + ).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")); + return LearningRoutePolicy.isProtectedPage(path) + || path.endsWith(".html") + || (accept != null && accept.contains("text/html")); } } diff --git a/src/main/resources/static/learning-shell.js b/src/main/resources/static/learning-shell.js index a4b6286..6d942d7 100644 --- a/src/main/resources/static/learning-shell.js +++ b/src/main/resources/static/learning-shell.js @@ -19,7 +19,7 @@ unauthorized: "未登录或认证已失效,请先打开登录页重新登录。", requestFailed: "请求失败,请稍后再试。", authRequiredTitle: "登录后即可使用", - authRequiredMessage: "请先去登录页获取 token,随后再访问总控台和实验页。", + authRequiredMessage: "请先去登录页获取 token,然后再访问总控台和实验页。", authRequiredButton: "前往登录" }, en: { @@ -44,13 +44,13 @@ zh: { jwt: { label: "JWT 路线", - summary: "浏览器先调用 /api/auth/login 获取 Bearer Token,之后通过 Authorization 头访问受保护接口,重点学习无状态鉴权。", + summary: "浏览器先调用 /api/auth/login 获取 Bearer Token,再通过 Authorization 头访问受保护接口,重点学习无状态鉴权。", learnMore: "可以观察 LearningJwtFilter 如何从 Header 或 Cookie 中提取认证信息。" }, satoken: { label: "Sa-Token 对照", - summary: "这里先用可视化说明 Sa-Token 常见的会话、注解与拦截器思路,帮助你和 JWT 的链路做对比。", - learnMore: "适合后续扩展成 Sa-Token 章节,比较登录态持久化、注解鉴权和续期机制。" + summary: "这里保留 Sa-Token 的学习对照位,方便把会话式鉴权思路和 JWT 路线放在一起比较。", + learnMore: "后续可以继续扩展成独立章节,对比登录态持久化、注解鉴权和续期机制。" }, filters: ["LearningJwtFilter", "AuthenticationEntryPoint", "AuthorizationFilter"], tokenTips: "当前实现会同时下发 Bearer Token 和认证 Cookie。这样既能继续学习 Header 鉴权,也能让普通页面跳转真正受登录保护。", @@ -69,8 +69,8 @@ }, 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." + summary: "This keeps a session-based auth comparison point beside the JWT route for teaching purposes.", + learnMore: "You can extend it later into a dedicated chapter about annotations, sessions, and renewal." }, 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.", @@ -114,7 +114,7 @@ } function isLoggedIn() { - return Boolean(getToken()) || Boolean(getUsername()); + return Boolean(getToken()) && Boolean(getUsername()); } function saveAuth(token, username) { diff --git a/src/test/java/com/example/demo/controller/AuthFlowTest.java b/src/test/java/com/example/demo/controller/AuthFlowTest.java index a6c3652..4b9ae9f 100644 --- a/src/test/java/com/example/demo/controller/AuthFlowTest.java +++ b/src/test/java/com/example/demo/controller/AuthFlowTest.java @@ -64,6 +64,24 @@ class AuthFlowTest { .andExpect(jsonPath("$.data.claims.username").value("admin")); } + @Test + void loginShouldReturnHttp401ForInvalidCredentials() throws Exception { + String loginReq = objectMapper.writeValueAsString(Map.of("username", "admin", "password", "wrong-pass")); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginReq)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void introspectShouldReturnHttp401WhenTokenIsMissing() throws Exception { + mockMvc.perform(get("/api/auth/introspect")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + @Test void shouldServeLearningShellWithoutToken() throws Exception { mockMvc.perform(get("/learning-shell.js"))