fix: harden learning auth flow

This commit is contained in:
Codex
2026-04-01 10:33:02 +08:00
parent 923302ca78
commit 90e12c26c7
6 changed files with 111 additions and 68 deletions

View File

@@ -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<Map<String, Object>> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
public ResponseEntity<ApiResponse<Map<String, Object>>> 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 <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());
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> introspect(
public ResponseEntity<ApiResponse<Map<String, Object>>> 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);
}
}

View File

@@ -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 ")) {

View File

@@ -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/");
}
}

View File

@@ -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"));
}
}