Compare commits
4 Commits
bde4f6b9cf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90e12c26c7 | ||
|
|
923302ca78 | ||
|
|
d81750aaf9 | ||
|
|
368a5061b0 |
@@ -3,8 +3,14 @@ package com.example.demo.controller.auth;
|
||||
import com.example.demo.common.ApiResponse;
|
||||
import com.example.demo.dto.auth.LoginRequest;
|
||||
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 org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -13,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@@ -26,18 +33,33 @@ public class LearningAuthController {
|
||||
}
|
||||
|
||||
@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()))
|
||||
|| ("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());
|
||||
return ApiResponse.ok(Map.of(
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token, servletRequest).toString());
|
||||
return ResponseEntity.ok(ApiResponse.ok(Map.of(
|
||||
"token", token,
|
||||
"type", "Bearer",
|
||||
"username", request.username(),
|
||||
"tip", "Attach Authorization: Bearer <token> when calling protected lab APIs."
|
||||
"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() {
|
||||
return ApiResponse.ok(Map.of(
|
||||
"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"},
|
||||
"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")
|
||||
public ApiResponse<Map<String, Object>> introspect(
|
||||
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> introspect(
|
||||
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (!StringUtils.hasText(authorization) || !authorization.startsWith("Bearer ")) {
|
||||
return ApiResponse.fail(401, "Missing bearer token");
|
||||
String token = resolveToken(authorization, request);
|
||||
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)) {
|
||||
return ApiResponse.fail(401, "Token is invalid or expired");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResponse.fail(401, "Token is invalid or expired"));
|
||||
}
|
||||
|
||||
return ApiResponse.ok(Map.of(
|
||||
return ResponseEntity.ok(ApiResponse.ok(Map.of(
|
||||
"subject", jwtUtil.username(token),
|
||||
"claims", jwtUtil.claims(token),
|
||||
"tip", "JWT is self-contained: the server can read claims without storing a session row for each request."
|
||||
));
|
||||
)));
|
||||
}
|
||||
|
||||
private String resolveToken(String authorization, HttpServletRequest request) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.example.demo.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -27,30 +28,15 @@ public class LearningJwtFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
boolean learnRoute = "/learn".equals(uri) || uri.startsWith("/learn/");
|
||||
return !(uri.startsWith("/api/secure/")
|
||||
|| uri.equals("/api/users")
|
||||
|| uri.startsWith("/api/users/")
|
||||
|| uri.startsWith("/aop/")
|
||||
|| uri.startsWith("/api/lab/")
|
||||
|| learnRoute);
|
||||
return !(LearningRoutePolicy.isProtectedPage(uri) || LearningRoutePolicy.isProtectedApi(uri));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (!StringUtils.hasText(auth) || !auth.startsWith("Bearer ")) {
|
||||
writeUnauthorized(response, "Missing or invalid Authorization header");
|
||||
return;
|
||||
}
|
||||
|
||||
String token = auth.substring(7);
|
||||
if (!jwtUtil.validate(token)) {
|
||||
writeUnauthorized(response, "Token is invalid or expired");
|
||||
return;
|
||||
}
|
||||
|
||||
SecurityContextHolder.clearContext();
|
||||
String token = resolveToken(request);
|
||||
if (StringUtils.hasText(token) && jwtUtil.validate(token)) {
|
||||
String username = jwtUtil.username(token);
|
||||
var authToken = new UsernamePasswordAuthenticationToken(
|
||||
username,
|
||||
@@ -59,13 +45,34 @@ public class LearningJwtFilter extends OncePerRequestFilter {
|
||||
);
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
}
|
||||
|
||||
if (LearningRoutePolicy.isProtectedPage(request.getRequestURI())
|
||||
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
response.sendRedirect("/access.html");
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":401,\"message\":\"" + message + "\"}");
|
||||
private String resolveToken(HttpServletRequest request) {
|
||||
String authorization = request.getHeader("Authorization");
|
||||
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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import java.util.Map;
|
||||
@Component
|
||||
public class LearningJwtUtil {
|
||||
|
||||
public static final String AUTH_COOKIE_NAME = "learning_demo_token";
|
||||
|
||||
@Value("${learning.auth.jwt.secret}")
|
||||
private String secret;
|
||||
|
||||
@@ -52,6 +54,10 @@ public class LearningJwtUtil {
|
||||
return Map.copyOf(parse(token));
|
||||
}
|
||||
|
||||
public long expirationMillis() {
|
||||
return expiration;
|
||||
}
|
||||
|
||||
private Claims parse(String token) {
|
||||
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
|
||||
}
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
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.context.annotation.Bean;
|
||||
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.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
@@ -24,15 +27,33 @@ public class LearningSecurityConfig {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> {
|
||||
if (isHtmlRequest(request)) {
|
||||
response.sendRedirect("/access.html");
|
||||
return;
|
||||
}
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":401,\"message\":\"Authentication required\"}");
|
||||
}))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
"/", "/home", "/api/auth/**", "/actuator/health", "/index.html", "/access.html",
|
||||
"/users.html", "/aop.html", "/events.html", "/advanced.html"
|
||||
"/access.html", "/api/auth/**", "/actuator/health", "/error",
|
||||
"/learning-shell.js", "/favicon.ico"
|
||||
).permitAll()
|
||||
.requestMatchers("/api/secure/**", "/api/users/**", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
|
||||
.anyRequest().permitAll()
|
||||
.requestMatchers("/", "/home", "/index.html", "/users.html", "/aop.html", "/events.html").authenticated()
|
||||
.requestMatchers("/api/secure/**", "/api/users/**", "/aop", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
|
||||
.anyRequest().denyAll()
|
||||
)
|
||||
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private boolean isHtmlRequest(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
String accept = request.getHeader(HttpHeaders.ACCEPT);
|
||||
return LearningRoutePolicy.isProtectedPage(path)
|
||||
|| path.endsWith(".html")
|
||||
|| (accept != null && accept.contains("text/html"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,10 +375,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
window.learningShell.clearAuth();
|
||||
async function logout() {
|
||||
await window.learningShell.logoutRemote();
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,30 +7,79 @@
|
||||
|
||||
const TEXT = {
|
||||
zh: {
|
||||
brand: "Spring \u5b66\u4e60\u5de5\u4f5c\u53f0",
|
||||
home: "\u9996\u9875",
|
||||
access: "\u767b\u5f55\u9875",
|
||||
loginReady: "\u5df2\u767b\u5f55\uff0c\u53ef\u8bbf\u95ee\u53d7\u4fdd\u62a4\u5b9e\u9a8c",
|
||||
loginMissing: "\u672a\u767b\u5f55\uff0c\u53d7\u4fdd\u62a4\u5b9e\u9a8c\u4f1a\u8fd4\u56de 401",
|
||||
currentUser: "\u5f53\u524d\u7528\u6237",
|
||||
logout: "\u9000\u51fa\u767b\u5f55",
|
||||
login: "\u53bb\u767b\u5f55",
|
||||
brand: "Spring 学习工作台",
|
||||
home: "总控台",
|
||||
access: "登录页",
|
||||
loginReady: "已登录,受保护页面和接口均可访问。",
|
||||
loginMissing: "未登录,受保护页面会跳回登录页。",
|
||||
currentUser: "当前用户",
|
||||
logout: "退出登录",
|
||||
login: "去登录",
|
||||
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",
|
||||
requestFailed: "\u8bf7\u6c42\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002"
|
||||
unauthorized: "未登录或认证已失效,请先打开登录页重新登录。",
|
||||
requestFailed: "请求失败,请稍后再试。",
|
||||
authRequiredTitle: "登录后即可使用",
|
||||
authRequiredMessage: "请先去登录页获取 token,然后再访问总控台和实验页。",
|
||||
authRequiredButton: "前往登录"
|
||||
},
|
||||
en: {
|
||||
brand: "Spring Learning Workspace",
|
||||
home: "Home",
|
||||
home: "Cockpit",
|
||||
access: "Login",
|
||||
loginReady: "Authenticated, protected labs are available",
|
||||
loginMissing: "Not logged in, protected labs will return 401",
|
||||
loginReady: "Authenticated. Protected pages and APIs are available.",
|
||||
loginMissing: "Not authenticated. Protected pages will redirect to login.",
|
||||
currentUser: "User",
|
||||
logout: "Logout",
|
||||
login: "Login",
|
||||
languageToggle: "\u4e2d\u6587",
|
||||
unauthorized: "Not logged in or token expired. Open the login page first.",
|
||||
requestFailed: "Request failed. Please try again."
|
||||
languageToggle: "中文",
|
||||
unauthorized: "Not authenticated or session expired. Open the login page first.",
|
||||
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) {
|
||||
const lang = getLanguage();
|
||||
return (TEXT[lang] && TEXT[lang][key]) || key;
|
||||
const language = getLanguage();
|
||||
return (TEXT[language] && TEXT[language][key]) || key;
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
@@ -65,12 +114,22 @@
|
||||
}
|
||||
|
||||
function isLoggedIn() {
|
||||
return Boolean(getToken());
|
||||
return Boolean(getToken()) && Boolean(getUsername());
|
||||
}
|
||||
|
||||
function saveAuth(token, username) {
|
||||
localStorage.setItem(STORAGE.token, token || "");
|
||||
localStorage.setItem(STORAGE.username, username || "");
|
||||
if (token) {
|
||||
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", {
|
||||
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() {
|
||||
if (document.getElementById("learning-shell-style")) {
|
||||
return;
|
||||
@@ -106,7 +178,7 @@
|
||||
" padding: 14px 18px;",
|
||||
" border-radius: 22px;",
|
||||
" 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);",
|
||||
" backdrop-filter: blur(10px);",
|
||||
"}",
|
||||
@@ -176,6 +248,9 @@
|
||||
if (options && typeof options.onLanguageChange === "function") {
|
||||
options.onLanguageChange(getLanguage());
|
||||
}
|
||||
if (options && typeof options.onAuthChange === "function") {
|
||||
options.onAuthChange({ loggedIn: isLoggedIn(), username: getUsername() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -188,7 +263,7 @@
|
||||
' <span data-role="status"></span>',
|
||||
" </div>",
|
||||
' <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>',
|
||||
' <span class="learning-shell-badge" data-role="user"></span>',
|
||||
' <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.home.textContent = t("home");
|
||||
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.login.textContent = t("login");
|
||||
els.logout.textContent = t("logout");
|
||||
@@ -247,9 +324,14 @@
|
||||
window.location.href = "/access.html";
|
||||
});
|
||||
|
||||
els.logout.addEventListener("click", function () {
|
||||
clearAuth();
|
||||
els.logout.addEventListener("click", async function () {
|
||||
await logoutRemote();
|
||||
render();
|
||||
if (options && typeof options.onLogout === "function") {
|
||||
options.onLogout();
|
||||
return;
|
||||
}
|
||||
window.location.href = "/access.html";
|
||||
});
|
||||
|
||||
window.addEventListener("learning-auth-changed", render);
|
||||
@@ -266,7 +348,7 @@
|
||||
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) {
|
||||
@@ -308,6 +390,9 @@
|
||||
if (error && Number(error.status) === 401) {
|
||||
return t("unauthorized");
|
||||
}
|
||||
if (error && error.payload && error.payload.message) {
|
||||
return error.payload.message;
|
||||
}
|
||||
return error && error.message ? error.message : t("requestFailed");
|
||||
}
|
||||
|
||||
@@ -322,8 +407,16 @@
|
||||
isLoggedIn: isLoggedIn,
|
||||
saveAuth: saveAuth,
|
||||
clearAuth: clearAuth,
|
||||
logoutRemote: logoutRemote,
|
||||
fetchWithAuth: fetchWithAuth,
|
||||
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 || [];
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
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.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.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
@@ -62,6 +64,24 @@ class AuthFlowTest {
|
||||
.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
|
||||
void shouldServeLearningShellWithoutToken() throws Exception {
|
||||
mockMvc.perform(get("/learning-shell.js"))
|
||||
@@ -69,6 +89,28 @@ class AuthFlowTest {
|
||||
.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 {
|
||||
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();
|
||||
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