fix: harden learning auth flow
This commit is contained in:
@@ -8,7 +8,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseCookie;
|
import org.springframework.http.ResponseCookie;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -31,25 +33,30 @@ public class LearningAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@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()))
|
if (!(("admin".equals(request.username()) && "admin123".equals(request.password()))
|
||||||
|| ("user".equals(request.username()) && "user123".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());
|
String token = jwtUtil.generateToken(request.username());
|
||||||
response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token).toString());
|
response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token, servletRequest).toString());
|
||||||
return ApiResponse.ok(Map.of(
|
return ResponseEntity.ok(ApiResponse.ok(Map.of(
|
||||||
"token", token,
|
"token", token,
|
||||||
"type", "Bearer",
|
"type", "Bearer",
|
||||||
"username", request.username(),
|
"username", request.username(),
|
||||||
"tip", "Attach Authorization: Bearer <token> when calling protected lab APIs or rely on the demo auth cookie for page access."
|
"tip", "Attach Authorization: Bearer <token> when calling protected lab APIs or rely on the demo auth cookie for page access."
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public ApiResponse<Map<String, Object>> logout(HttpServletResponse response) {
|
public ApiResponse<Map<String, Object>> logout(HttpServletRequest request, HttpServletResponse response) {
|
||||||
response.addHeader(HttpHeaders.SET_COOKIE, clearAuthCookie().toString());
|
response.addHeader(HttpHeaders.SET_COOKIE, clearAuthCookie(request).toString());
|
||||||
return ApiResponse.ok(Map.of(
|
return ApiResponse.ok(Map.of(
|
||||||
"cleared", true,
|
"cleared", true,
|
||||||
"tip", "Frontend local storage should be cleared too."
|
"tip", "Frontend local storage should be cleared too."
|
||||||
@@ -67,24 +74,26 @@ public class LearningAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/introspect")
|
@GetMapping("/introspect")
|
||||||
public ApiResponse<Map<String, Object>> introspect(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> introspect(
|
||||||
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||||
HttpServletRequest request
|
HttpServletRequest request
|
||||||
) {
|
) {
|
||||||
String token = resolveToken(authorization, request);
|
String token = resolveToken(authorization, request);
|
||||||
if (!StringUtils.hasText(token)) {
|
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)) {
|
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),
|
"subject", jwtUtil.username(token),
|
||||||
"claims", jwtUtil.claims(token),
|
"claims", jwtUtil.claims(token),
|
||||||
"tip", "JWT is self-contained: the server can read claims without storing a session row for each request."
|
"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) {
|
private String resolveToken(String authorization, HttpServletRequest request) {
|
||||||
@@ -105,21 +114,28 @@ public class LearningAuthController {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseCookie authCookie(String token) {
|
private ResponseCookie authCookie(String token, HttpServletRequest request) {
|
||||||
return ResponseCookie.from(LearningJwtUtil.AUTH_COOKIE_NAME, token)
|
return ResponseCookie.from(LearningJwtUtil.AUTH_COOKIE_NAME, token)
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.sameSite("Lax")
|
.sameSite("Lax")
|
||||||
|
.secure(isSecureRequest(request))
|
||||||
.path("/")
|
.path("/")
|
||||||
.maxAge(Duration.ofMillis(jwtUtil.expirationMillis()))
|
.maxAge(Duration.ofMillis(jwtUtil.expirationMillis()))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseCookie clearAuthCookie() {
|
private ResponseCookie clearAuthCookie(HttpServletRequest request) {
|
||||||
return ResponseCookie.from(LearningJwtUtil.AUTH_COOKIE_NAME, "")
|
return ResponseCookie.from(LearningJwtUtil.AUTH_COOKIE_NAME, "")
|
||||||
.httpOnly(true)
|
.httpOnly(true)
|
||||||
.sameSite("Lax")
|
.sameSite("Lax")
|
||||||
|
.secure(isSecureRequest(request))
|
||||||
.path("/")
|
.path("/")
|
||||||
.maxAge(Duration.ZERO)
|
.maxAge(Duration.ZERO)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSecureRequest(HttpServletRequest request) {
|
||||||
|
String forwardedProto = request.getHeader("X-Forwarded-Proto");
|
||||||
|
return request.isSecure() || "https".equalsIgnoreCase(forwardedProto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,7 @@ public class LearningJwtFilter extends OncePerRequestFilter {
|
|||||||
@Override
|
@Override
|
||||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
String uri = request.getRequestURI();
|
String uri = request.getRequestURI();
|
||||||
return !(isProtectedPage(uri)
|
return !(LearningRoutePolicy.isProtectedPage(uri) || LearningRoutePolicy.isProtectedApi(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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -54,7 +47,7 @@ public class LearningJwtFilter extends OncePerRequestFilter {
|
|||||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isProtectedPage(request.getRequestURI())
|
if (LearningRoutePolicy.isProtectedPage(request.getRequestURI())
|
||||||
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
response.sendRedirect("/access.html");
|
response.sendRedirect("/access.html");
|
||||||
return;
|
return;
|
||||||
@@ -63,19 +56,6 @@ public class LearningJwtFilter extends OncePerRequestFilter {
|
|||||||
filterChain.doFilter(request, response);
|
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) {
|
private String resolveToken(HttpServletRequest request) {
|
||||||
String authorization = request.getHeader("Authorization");
|
String authorization = request.getHeader("Authorization");
|
||||||
if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
|
if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
|
||||||
|
|||||||
@@ -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/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,36 +25,35 @@ public class LearningSecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain learningSecurityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain learningSecurityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
|
.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
|
||||||
if (isHtmlRequest(request)) {
|
if (isHtmlRequest(request)) {
|
||||||
response.sendRedirect("/access.html");
|
response.sendRedirect("/access.html");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
response.setContentType("application/json;charset=UTF-8");
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
response.getWriter().write("{\"code\":401,\"message\":\"Authentication required\"}");
|
response.getWriter().write("{\"code\":401,\"message\":\"Authentication required\"}");
|
||||||
}))
|
}))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/access.html", "/api/auth/**", "/actuator/health", "/error",
|
"/access.html", "/api/auth/**", "/actuator/health", "/error",
|
||||||
"/learning-shell.js", "/favicon.ico"
|
"/learning-shell.js", "/favicon.ico"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
.requestMatchers("/", "/home", "/index.html", "/users.html", "/aop.html", "/events.html").authenticated()
|
.requestMatchers("/", "/home", "/index.html", "/users.html", "/aop.html", "/events.html").authenticated()
|
||||||
.requestMatchers("/api/secure/**", "/api/users/**", "/aop", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
|
.requestMatchers("/api/secure/**", "/api/users/**", "/aop", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
|
||||||
.anyRequest().denyAll()
|
.anyRequest().denyAll()
|
||||||
)
|
)
|
||||||
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isHtmlRequest(HttpServletRequest request) {
|
private boolean isHtmlRequest(HttpServletRequest request) {
|
||||||
String path = request.getRequestURI();
|
String path = request.getRequestURI();
|
||||||
String accept = request.getHeader(HttpHeaders.ACCEPT);
|
String accept = request.getHeader(HttpHeaders.ACCEPT);
|
||||||
return "/".equals(path)
|
return LearningRoutePolicy.isProtectedPage(path)
|
||||||
|| "/home".equals(path)
|
|| path.endsWith(".html")
|
||||||
|| path.endsWith(".html")
|
|| (accept != null && accept.contains("text/html"));
|
||||||
|| (accept != null && accept.contains("text/html"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
unauthorized: "未登录或认证已失效,请先打开登录页重新登录。",
|
unauthorized: "未登录或认证已失效,请先打开登录页重新登录。",
|
||||||
requestFailed: "请求失败,请稍后再试。",
|
requestFailed: "请求失败,请稍后再试。",
|
||||||
authRequiredTitle: "登录后即可使用",
|
authRequiredTitle: "登录后即可使用",
|
||||||
authRequiredMessage: "请先去登录页获取 token,随后再访问总控台和实验页。",
|
authRequiredMessage: "请先去登录页获取 token,然后再访问总控台和实验页。",
|
||||||
authRequiredButton: "前往登录"
|
authRequiredButton: "前往登录"
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
@@ -44,13 +44,13 @@
|
|||||||
zh: {
|
zh: {
|
||||||
jwt: {
|
jwt: {
|
||||||
label: "JWT 路线",
|
label: "JWT 路线",
|
||||||
summary: "浏览器先调用 /api/auth/login 获取 Bearer Token,之后通过 Authorization 头访问受保护接口,重点学习无状态鉴权。",
|
summary: "浏览器先调用 /api/auth/login 获取 Bearer Token,再通过 Authorization 头访问受保护接口,重点学习无状态鉴权。",
|
||||||
learnMore: "可以观察 LearningJwtFilter 如何从 Header 或 Cookie 中提取认证信息。"
|
learnMore: "可以观察 LearningJwtFilter 如何从 Header 或 Cookie 中提取认证信息。"
|
||||||
},
|
},
|
||||||
satoken: {
|
satoken: {
|
||||||
label: "Sa-Token 对照",
|
label: "Sa-Token 对照",
|
||||||
summary: "这里先用可视化说明 Sa-Token 常见的会话、注解与拦截器思路,帮助你和 JWT 的链路做对比。",
|
summary: "这里保留 Sa-Token 的学习对照位,方便把会话式鉴权思路和 JWT 路线放在一起比较。",
|
||||||
learnMore: "适合后续扩展成 Sa-Token 章节,比较登录态持久化、注解鉴权和续期机制。"
|
learnMore: "后续可以继续扩展成独立章节,对比登录态持久化、注解鉴权和续期机制。"
|
||||||
},
|
},
|
||||||
filters: ["LearningJwtFilter", "AuthenticationEntryPoint", "AuthorizationFilter"],
|
filters: ["LearningJwtFilter", "AuthenticationEntryPoint", "AuthorizationFilter"],
|
||||||
tokenTips: "当前实现会同时下发 Bearer Token 和认证 Cookie。这样既能继续学习 Header 鉴权,也能让普通页面跳转真正受登录保护。",
|
tokenTips: "当前实现会同时下发 Bearer Token 和认证 Cookie。这样既能继续学习 Header 鉴权,也能让普通页面跳转真正受登录保护。",
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
},
|
},
|
||||||
satoken: {
|
satoken: {
|
||||||
label: "Sa-Token Contrast",
|
label: "Sa-Token Contrast",
|
||||||
summary: "Use this visual block to compare session-oriented auth, annotations, and interceptors against the JWT route.",
|
summary: "This keeps a session-based auth comparison point beside the JWT route for teaching purposes.",
|
||||||
learnMore: "This is a good bridge if you later add a dedicated Sa-Token chapter."
|
learnMore: "You can extend it later into a dedicated chapter about annotations, sessions, and renewal."
|
||||||
},
|
},
|
||||||
filters: ["LearningJwtFilter", "AuthenticationEntryPoint", "AuthorizationFilter"],
|
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.",
|
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() {
|
function isLoggedIn() {
|
||||||
return Boolean(getToken()) || Boolean(getUsername());
|
return Boolean(getToken()) && Boolean(getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAuth(token, username) {
|
function saveAuth(token, username) {
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ class AuthFlowTest {
|
|||||||
.andExpect(jsonPath("$.data.claims.username").value("admin"));
|
.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
|
@Test
|
||||||
void shouldServeLearningShellWithoutToken() throws Exception {
|
void shouldServeLearningShellWithoutToken() throws Exception {
|
||||||
mockMvc.perform(get("/learning-shell.js"))
|
mockMvc.perform(get("/learning-shell.js"))
|
||||||
|
|||||||
Reference in New Issue
Block a user