feat: protect learning pages and rebuild cockpit

This commit is contained in:
Codex
2026-03-24 17:07:40 +08:00
parent 368a5061b0
commit d81750aaf9
8 changed files with 430 additions and 809 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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 || [];
} }
}; };
})(); })();

View File

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