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.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.ResponseCookie;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import java.time.Duration;
import java.util.Map;
@RestController
@@ -26,18 +31,28 @@ public class LearningAuthController {
}
@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()))
|| ("user".equals(request.username()) && "user123".equals(request.password())))) {
return new ApiResponse<>(401, "Invalid demo credentials", null, java.time.Instant.now());
}
String token = jwtUtil.generateToken(request.username());
response.addHeader(HttpHeaders.SET_COOKIE, authCookie(token).toString());
return 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(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() {
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
@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");
}
String token = authorization.substring(7);
if (!jwtUtil.validate(token)) {
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."
));
}
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.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -28,9 +29,17 @@ public class LearningJwtFilter extends OncePerRequestFilter {
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
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.startsWith("/api/users/")
|| "/aop".equals(uri)
|| uri.startsWith("/aop/")
|| uri.startsWith("/api/lab/")
|| learnRoute);
@@ -39,33 +48,39 @@ public class LearningJwtFilter extends OncePerRequestFilter {
@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;
SecurityContextHolder.clearContext();
String token = resolveToken(request);
if (StringUtils.hasText(token) && jwtUtil.validate(token)) {
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 (!jwtUtil.validate(token)) {
writeUnauthorized(response, "Token is invalid or expired");
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);
}
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 "";
}
}

View File

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

View File

@@ -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,34 @@ 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 "/".equals(path)
|| "/home".equals(path)
|| path.endsWith(".html")
|| (accept != null && accept.contains("text/html"));
}
}