Compare commits

...

4 Commits

Author SHA1 Message Date
Codex
90e12c26c7 fix: harden learning auth flow 2026-04-01 10:33:02 +08:00
Codex
923302ca78 fix: enforce html auth redirect at runtime 2026-03-25 09:13:12 +08:00
Codex
d81750aaf9 feat: protect learning pages and rebuild cockpit 2026-03-24 17:07:40 +08:00
Codex
368a5061b0 feat: add auth strategy visualization 2026-03-24 12:22:06 +08:00
9 changed files with 508 additions and 674 deletions

View File

@@ -3,8 +3,14 @@ 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.HttpStatus;
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;
@@ -13,6 +19,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 +33,33 @@ public class LearningAuthController {
} }
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest request) { 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());
return ApiResponse.ok(Map.of( response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token, servletRequest).toString());
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." "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(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."
)); ));
} }
@@ -45,29 +67,75 @@ 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 ResponseEntity<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);
return ApiResponse.fail(401, "Missing bearer token"); if (!StringUtils.hasText(token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(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 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) {
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, 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(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

@@ -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;
@@ -27,45 +28,51 @@ 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();
boolean learnRoute = "/learn".equals(uri) || uri.startsWith("/learn/"); return !(LearningRoutePolicy.isProtectedPage(uri) || LearningRoutePolicy.isProtectedApi(uri));
return !(uri.startsWith("/api/secure/")
|| uri.equals("/api/users")
|| uri.startsWith("/api/users/")
|| uri.startsWith("/aop/")
|| uri.startsWith("/api/lab/")
|| learnRoute);
} }
@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 username = jwtUtil.username(token);
var authToken = new UsernamePasswordAuthenticationToken(
username,
null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
} }
String token = auth.substring(7); if (LearningRoutePolicy.isProtectedPage(request.getRequestURI())
if (!jwtUtil.validate(token)) { && SecurityContextHolder.getContext().getAuthentication() == null) {
writeUnauthorized(response, "Token is invalid or expired"); response.sendRedirect("/access.html");
return; return;
} }
String username = jwtUtil.username(token);
var authToken = new UsernamePasswordAuthenticationToken(
username,
null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
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

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

@@ -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;
@@ -22,17 +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))
.authorizeHttpRequests(auth -> auth .exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
.requestMatchers( if (isHtmlRequest(request)) {
"/", "/home", "/api/auth/**", "/actuator/health", "/index.html", "/access.html", response.sendRedirect("/access.html");
"/users.html", "/aop.html", "/events.html", "/advanced.html" return;
).permitAll() }
.requestMatchers("/api/secure/**", "/api/users/**", "/aop/**", "/api/lab/**", "/learn/**").authenticated() response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
.anyRequest().permitAll() response.setContentType("application/json;charset=UTF-8");
) response.getWriter().write("{\"code\":401,\"message\":\"Authentication required\"}");
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class); }))
.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(); return http.build();
} }
private boolean isHtmlRequest(HttpServletRequest request) {
String path = request.getRequestURI();
String accept = request.getHeader(HttpHeaders.ACCEPT);
return LearningRoutePolicy.isProtectedPage(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: "后续可以继续扩展成独立章节,对比登录态持久化、注解鉴权和续期机制。"
},
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: "This keeps a session-based auth comparison point beside the JWT route for teaching purposes.",
learnMore: "You can extend it later into a dedicated chapter about annotations, sessions, and renewal."
},
filters: ["LearningJwtFilter", "AuthenticationEntryPoint", "AuthorizationFilter"],
tokenTips: "The demo now issues both a bearer token and an auth cookie, so header-based learning and page-level protection can coexist.",
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) {
@@ -308,6 +390,9 @@
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");
} }
@@ -322,8 +407,16 @@
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,
getAuthInsights: function () {
return AUTH_INSIGHTS[getLanguage()] || AUTH_INSIGHTS.zh;
},
getTokenTimeline: function () {
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;
@@ -62,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"))
@@ -69,6 +89,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("consoleOutput")));
}
@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 +126,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);
}
} }