feat: protect learning pages and rebuild cockpit
This commit is contained in:
@@ -3,8 +3,12 @@ package com.example.demo.controller.auth;
|
|||||||
import com.example.demo.common.ApiResponse;
|
import com.example.demo.common.ApiResponse;
|
||||||
import com.example.demo.dto.auth.LoginRequest;
|
import com.example.demo.dto.auth.LoginRequest;
|
||||||
import com.example.demo.security.LearningJwtUtil;
|
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 jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
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;
|
||||||
@@ -13,6 +17,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -26,18 +31,28 @@ public class LearningAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@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()))
|
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 new ApiResponse<>(401, "Invalid demo credentials", null, java.time.Instant.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
String token = jwtUtil.generateToken(request.username());
|
String token = jwtUtil.generateToken(request.username());
|
||||||
|
response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token).toString());
|
||||||
return ApiResponse.ok(Map.of(
|
return 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."
|
"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() {
|
public ApiResponse<Map<String, Object>> mode() {
|
||||||
return ApiResponse.ok(Map.of(
|
return ApiResponse.ok(Map.of(
|
||||||
"mode", "learning-jwt",
|
"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"},
|
"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")
|
@GetMapping("/introspect")
|
||||||
public ApiResponse<Map<String, Object>> 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");
|
return ApiResponse.fail(401, "Missing bearer token");
|
||||||
}
|
}
|
||||||
|
|
||||||
String token = authorization.substring(7);
|
|
||||||
if (!jwtUtil.validate(token)) {
|
if (!jwtUtil.validate(token)) {
|
||||||
return ApiResponse.fail(401, "Token is invalid or expired");
|
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."
|
"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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.example.demo.security;
|
|||||||
|
|
||||||
import jakarta.servlet.FilterChain;
|
import jakarta.servlet.FilterChain;
|
||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
@@ -28,9 +29,17 @@ public class LearningJwtFilter extends OncePerRequestFilter {
|
|||||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
String uri = request.getRequestURI();
|
String uri = request.getRequestURI();
|
||||||
boolean learnRoute = "/learn".equals(uri) || uri.startsWith("/learn/");
|
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.equals("/api/users")
|
||||||
|| uri.startsWith("/api/users/")
|
|| uri.startsWith("/api/users/")
|
||||||
|
|| "/aop".equals(uri)
|
||||||
|| uri.startsWith("/aop/")
|
|| uri.startsWith("/aop/")
|
||||||
|| uri.startsWith("/api/lab/")
|
|| uri.startsWith("/api/lab/")
|
||||||
|| learnRoute);
|
|| learnRoute);
|
||||||
@@ -39,18 +48,9 @@ public class LearningJwtFilter extends OncePerRequestFilter {
|
|||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
String auth = request.getHeader("Authorization");
|
SecurityContextHolder.clearContext();
|
||||||
if (!StringUtils.hasText(auth) || !auth.startsWith("Bearer ")) {
|
String token = resolveToken(request);
|
||||||
writeUnauthorized(response, "Missing or invalid Authorization header");
|
if (StringUtils.hasText(token) && jwtUtil.validate(token)) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String token = auth.substring(7);
|
|
||||||
if (!jwtUtil.validate(token)) {
|
|
||||||
writeUnauthorized(response, "Token is invalid or expired");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String username = jwtUtil.username(token);
|
String username = jwtUtil.username(token);
|
||||||
var authToken = new UsernamePasswordAuthenticationToken(
|
var authToken = new UsernamePasswordAuthenticationToken(
|
||||||
username,
|
username,
|
||||||
@@ -59,13 +59,28 @@ public class LearningJwtFilter extends OncePerRequestFilter {
|
|||||||
);
|
);
|
||||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||||
|
}
|
||||||
|
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
|
private String resolveToken(HttpServletRequest request) {
|
||||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
String authorization = request.getHeader("Authorization");
|
||||||
response.setContentType("application/json;charset=UTF-8");
|
if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
|
||||||
response.getWriter().write("{\"code\":401,\"message\":\"" + message + "\"}");
|
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 "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import java.util.Map;
|
|||||||
@Component
|
@Component
|
||||||
public class LearningJwtUtil {
|
public class LearningJwtUtil {
|
||||||
|
|
||||||
|
public static final String AUTH_COOKIE_NAME = "learning_demo_token";
|
||||||
|
|
||||||
@Value("${learning.auth.jwt.secret}")
|
@Value("${learning.auth.jwt.secret}")
|
||||||
private String secret;
|
private String secret;
|
||||||
|
|
||||||
@@ -52,6 +54,10 @@ public class LearningJwtUtil {
|
|||||||
return Map.copyOf(parse(token));
|
return Map.copyOf(parse(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long expirationMillis() {
|
||||||
|
return expiration;
|
||||||
|
}
|
||||||
|
|
||||||
private Claims parse(String token) {
|
private Claims parse(String token) {
|
||||||
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
|
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.example.demo.security;
|
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.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
@@ -24,15 +27,34 @@ public class LearningSecurityConfig {
|
|||||||
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) -> {
|
||||||
|
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
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/", "/home", "/api/auth/**", "/actuator/health", "/index.html", "/access.html",
|
"/access.html", "/api/auth/**", "/actuator/health", "/error",
|
||||||
"/users.html", "/aop.html", "/events.html", "/advanced.html"
|
"/learning-shell.js", "/favicon.ico"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
.requestMatchers("/api/secure/**", "/api/users/**", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
|
.requestMatchers("/", "/home", "/index.html", "/users.html", "/aop.html", "/events.html").authenticated()
|
||||||
.anyRequest().permitAll()
|
.requestMatchers("/api/secure/**", "/api/users/**", "/aop", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
|
||||||
|
.anyRequest().denyAll()
|
||||||
)
|
)
|
||||||
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
return http.build();
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,10 +375,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function logout() {
|
||||||
window.learningShell.clearAuth();
|
await window.learningShell.logoutRemote();
|
||||||
setStatus(pageText().logoutSuccess, "success");
|
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";
|
document.getElementById("resultBox").dataset.state = "live";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,30 +7,79 @@
|
|||||||
|
|
||||||
const TEXT = {
|
const TEXT = {
|
||||||
zh: {
|
zh: {
|
||||||
brand: "Spring \u5b66\u4e60\u5de5\u4f5c\u53f0",
|
brand: "Spring 学习工作台",
|
||||||
home: "\u9996\u9875",
|
home: "学习总控台",
|
||||||
access: "\u767b\u5f55\u9875",
|
access: "登录页",
|
||||||
loginReady: "\u5df2\u767b\u5f55\uff0c\u53ef\u8bbf\u95ee\u53d7\u4fdd\u62a4\u5b9e\u9a8c",
|
loginReady: "已登录,受保护页面和接口都可访问",
|
||||||
loginMissing: "\u672a\u767b\u5f55\uff0c\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u4f1a\u8fd4\u56de 401",
|
loginMissing: "未登录,受保护页面会跳回登录页",
|
||||||
currentUser: "\u5f53\u524d\u7528\u6237",
|
currentUser: "当前用户",
|
||||||
logout: "\u9000\u51fa\u767b\u5f55",
|
logout: "退出登录",
|
||||||
login: "\u53bb\u767b\u5f55",
|
login: "去登录",
|
||||||
languageToggle: "EN",
|
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",
|
unauthorized: "未登录或认证已失效,请先打开登录页重新登录。",
|
||||||
requestFailed: "\u8bf7\u6c42\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002"
|
requestFailed: "请求失败,请稍后再试。",
|
||||||
|
authRequiredTitle: "登录后即可使用",
|
||||||
|
authRequiredMessage: "请先去登录页获取 token,才能继续访问主控台和实验",
|
||||||
|
authRequiredButton: "前往登录"
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
brand: "Spring Learning Workspace",
|
brand: "Spring Learning Workspace",
|
||||||
home: "Home",
|
home: "Cockpit",
|
||||||
access: "Login",
|
access: "Login",
|
||||||
loginReady: "Authenticated, protected labs are available",
|
loginReady: "Authenticated. Protected pages and APIs are available.",
|
||||||
loginMissing: "Not logged in, protected labs will return 401",
|
loginMissing: "Not authenticated. Protected pages will redirect to login.",
|
||||||
currentUser: "User",
|
currentUser: "User",
|
||||||
logout: "Logout",
|
logout: "Logout",
|
||||||
login: "Login",
|
login: "Login",
|
||||||
languageToggle: "\u4e2d\u6587",
|
languageToggle: "中文",
|
||||||
unauthorized: "Not logged in or token expired. Open the login page first.",
|
unauthorized: "Not authenticated or session expired. Open the login page first.",
|
||||||
requestFailed: "Request failed. Please try again."
|
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) {
|
function t(key) {
|
||||||
const lang = getLanguage();
|
const language = getLanguage();
|
||||||
return (TEXT[lang] && TEXT[lang][key]) || key;
|
return (TEXT[language] && TEXT[language][key]) || key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
@@ -65,12 +114,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isLoggedIn() {
|
function isLoggedIn() {
|
||||||
return Boolean(getToken());
|
return Boolean(getToken()) || Boolean(getUsername());
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAuth(token, username) {
|
function saveAuth(token, username) {
|
||||||
localStorage.setItem(STORAGE.token, token || "");
|
if (token) {
|
||||||
localStorage.setItem(STORAGE.username, username || "");
|
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", {
|
window.dispatchEvent(new CustomEvent("learning-auth-changed", {
|
||||||
detail: { loggedIn: isLoggedIn(), username: getUsername() }
|
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() {
|
function ensureStyle() {
|
||||||
if (document.getElementById("learning-shell-style")) {
|
if (document.getElementById("learning-shell-style")) {
|
||||||
return;
|
return;
|
||||||
@@ -106,7 +178,7 @@
|
|||||||
" padding: 14px 18px;",
|
" padding: 14px 18px;",
|
||||||
" border-radius: 22px;",
|
" border-radius: 22px;",
|
||||||
" border: 1px solid rgba(216, 228, 240, 0.9);",
|
" 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);",
|
" box-shadow: 0 12px 28px rgba(18, 32, 51, 0.08);",
|
||||||
" backdrop-filter: blur(10px);",
|
" backdrop-filter: blur(10px);",
|
||||||
"}",
|
"}",
|
||||||
@@ -176,6 +248,9 @@
|
|||||||
if (options && typeof options.onLanguageChange === "function") {
|
if (options && typeof options.onLanguageChange === "function") {
|
||||||
options.onLanguageChange(getLanguage());
|
options.onLanguageChange(getLanguage());
|
||||||
}
|
}
|
||||||
|
if (options && typeof options.onAuthChange === "function") {
|
||||||
|
options.onAuthChange({ loggedIn: isLoggedIn(), username: getUsername() });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +263,7 @@
|
|||||||
' <span data-role="status"></span>',
|
' <span data-role="status"></span>',
|
||||||
" </div>",
|
" </div>",
|
||||||
' <div class="learning-shell-actions">',
|
' <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>',
|
' <a class="learning-shell-link" href="/access.html" data-role="access"></a>',
|
||||||
' <span class="learning-shell-badge" data-role="user"></span>',
|
' <span class="learning-shell-badge" data-role="user"></span>',
|
||||||
' <button class="learning-shell-button alt" type="button" data-role="language"></button>',
|
' <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.status.textContent = loggedIn ? t("loginReady") : t("loginMissing");
|
||||||
els.home.textContent = t("home");
|
els.home.textContent = t("home");
|
||||||
els.access.textContent = t("access");
|
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.language.textContent = t("languageToggle");
|
||||||
els.login.textContent = t("login");
|
els.login.textContent = t("login");
|
||||||
els.logout.textContent = t("logout");
|
els.logout.textContent = t("logout");
|
||||||
@@ -247,9 +324,14 @@
|
|||||||
window.location.href = "/access.html";
|
window.location.href = "/access.html";
|
||||||
});
|
});
|
||||||
|
|
||||||
els.logout.addEventListener("click", function () {
|
els.logout.addEventListener("click", async function () {
|
||||||
clearAuth();
|
await logoutRemote();
|
||||||
render();
|
render();
|
||||||
|
if (options && typeof options.onLogout === "function") {
|
||||||
|
options.onLogout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = "/access.html";
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("learning-auth-changed", render);
|
window.addEventListener("learning-auth-changed", render);
|
||||||
@@ -266,7 +348,7 @@
|
|||||||
headers.set("Authorization", "Bearer " + token);
|
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) {
|
async function requestJson(url, options) {
|
||||||
@@ -304,50 +386,13 @@
|
|||||||
return payload;
|
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) {
|
function describeError(error) {
|
||||||
if (error && Number(error.status) === 401) {
|
if (error && Number(error.status) === 401) {
|
||||||
return t("unauthorized");
|
return t("unauthorized");
|
||||||
}
|
}
|
||||||
|
if (error && error.payload && error.payload.message) {
|
||||||
|
return error.payload.message;
|
||||||
|
}
|
||||||
return error && error.message ? error.message : t("requestFailed");
|
return error && error.message ? error.message : t("requestFailed");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,6 +407,7 @@
|
|||||||
isLoggedIn: isLoggedIn,
|
isLoggedIn: isLoggedIn,
|
||||||
saveAuth: saveAuth,
|
saveAuth: saveAuth,
|
||||||
clearAuth: clearAuth,
|
clearAuth: clearAuth,
|
||||||
|
logoutRemote: logoutRemote,
|
||||||
fetchWithAuth: fetchWithAuth,
|
fetchWithAuth: fetchWithAuth,
|
||||||
requestJson: requestJson,
|
requestJson: requestJson,
|
||||||
describeError: describeError,
|
describeError: describeError,
|
||||||
@@ -369,7 +415,8 @@
|
|||||||
return AUTH_INSIGHTS[getLanguage()] || AUTH_INSIGHTS.zh;
|
return AUTH_INSIGHTS[getLanguage()] || AUTH_INSIGHTS.zh;
|
||||||
},
|
},
|
||||||
getTokenTimeline: function () {
|
getTokenTimeline: function () {
|
||||||
return TOKEN_TIMELINE;
|
const insights = AUTH_INSIGHTS[getLanguage()] || AUTH_INSIGHTS.zh;
|
||||||
|
return insights.timeline || [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.example.demo.controller;
|
package com.example.demo.controller;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
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.hamcrest.Matchers.containsString;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
@@ -69,6 +71,28 @@ class AuthFlowTest {
|
|||||||
.andExpect(content().string(containsString("window.learningShell")));
|
.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 {
|
private String bearerToken() throws Exception {
|
||||||
String loginReq = objectMapper.writeValueAsString(Map.of("username", "admin", "password", "admin123"));
|
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();
|
String token = objectMapper.readTree(loginResp).path("data").path("token").asText();
|
||||||
return "Bearer " + token;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user