feat: finish bilingual learning auth cockpit

This commit is contained in:
Codex
2026-03-23 13:05:44 +08:00
parent 09574c3400
commit bde4f6b9cf
56 changed files with 5043 additions and 1377 deletions

View File

@@ -16,7 +16,7 @@ public record ApiResponse<T>(
return new ApiResponse<>(0, message, data, Instant.now());
}
public static ApiResponse<Void> fail(int code, String message) {
public static <T> ApiResponse<T> fail(int code, String message) {
return new ApiResponse<>(code, message, null, Instant.now());
}
}

View File

@@ -0,0 +1,180 @@
package com.example.demo.controller;
import com.example.demo.common.ApiResponse;
import com.example.demo.security.LearningJwtUtil;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
@RestController
@RequestMapping("/api/lab")
public class AdvancedLabController {
private final RequestMappingHandlerMapping handlerMapping;
private final LearningJwtUtil jwtUtil;
public AdvancedLabController(
@Qualifier("requestMappingHandlerMapping") RequestMappingHandlerMapping handlerMapping,
LearningJwtUtil jwtUtil
) {
this.handlerMapping = handlerMapping;
this.jwtUtil = jwtUtil;
}
@GetMapping("/reflection/routes")
public ApiResponse<Map<String, Object>> reflectionRoutes() {
List<Map<String, Object>> routes = handlerMapping.getHandlerMethods().entrySet().stream()
.map(this::toRouteView)
.sorted((left, right) -> left.get("path").toString().compareTo(right.get("path").toString()))
.toList();
List<Map<String, Object>> classSummaries = List.of(
summarizeClass(UserController.class),
summarizeClass(LearnController.class),
summarizeClass(AopEventController.class),
summarizeClass(com.example.demo.controller.auth.LearningAuthController.class)
);
return ApiResponse.ok(Map.of(
"routeCount", routes.size(),
"routes", routes,
"classSummaries", classSummaries,
"tip", "Use reflection to connect annotations, method signatures, and runtime route registration."
));
}
@GetMapping("/reflection/user-model")
public ApiResponse<Map<String, Object>> reflectUserModel() {
List<Map<String, Object>> fields = Arrays.stream(com.example.demo.model.User.class.getDeclaredFields())
.map(this::toFieldView)
.toList();
return ApiResponse.ok(Map.of(
"className", com.example.demo.model.User.class.getName(),
"fieldCount", fields.size(),
"fields", fields,
"tip", "Reflection makes frameworks possible because metadata can be discovered at runtime."
));
}
@GetMapping("/concurrency/simulate")
public ApiResponse<Map<String, Object>> simulateConcurrency(
@RequestParam(defaultValue = "12") int tasks,
@RequestParam(defaultValue = "4") int poolSize
) {
int safeTasks = Math.max(1, Math.min(tasks, 32));
int safePoolSize = Math.max(1, Math.min(poolSize, 8));
ExecutorService executor = Executors.newFixedThreadPool(safePoolSize);
AtomicInteger counter = new AtomicInteger();
Instant start = Instant.now();
try {
List<CompletableFuture<Map<String, Object>>> jobs = createJobs(safeTasks, counter, executor);
CompletableFuture.allOf(jobs.toArray(CompletableFuture[]::new)).join();
List<Map<String, Object>> results = jobs.stream()
.map(CompletableFuture::join)
.toList();
return ApiResponse.ok(Map.of(
"tasks", safeTasks,
"poolSize", safePoolSize,
"finalCounter", counter.get(),
"durationMs", Duration.between(start, Instant.now()).toMillis(),
"sample", results.stream().limit(5).toList(),
"tip", "AtomicInteger keeps the shared counter safe when many tasks run in parallel."
));
} finally {
executor.shutdownNow();
}
}
@GetMapping("/jwt/claims")
public ApiResponse<Map<String, Object>> jwtClaims(@RequestHeader("Authorization") String authorization) {
String token = authorization.substring(7);
return ApiResponse.ok(Map.of(
"subject", jwtUtil.username(token),
"claims", jwtUtil.claims(token),
"tip", "JWT claims can be parsed without a database lookup, which is one reason token auth scales well."
));
}
private List<CompletableFuture<Map<String, Object>>> createJobs(int tasks, AtomicInteger counter, ExecutorService executor) {
return java.util.stream.IntStream.range(0, tasks)
.mapToObj(index -> CompletableFuture.supplyAsync(() -> {
int current = counter.incrementAndGet();
try {
Thread.sleep(20L + (index % 3) * 10L);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
}
return Map.<String, Object>of(
"task", index,
"thread", Thread.currentThread().getName(),
"counterAfterIncrement", current
);
}, executor))
.toList();
}
private Map<String, Object> toRouteView(Map.Entry<RequestMappingInfo, HandlerMethod> entry) {
HandlerMethod handlerMethod = entry.getValue();
return Map.of(
"path", entry.getKey().getPatternValues().toString(),
"methods", entry.getKey().getMethodsCondition().getMethods().toString(),
"handler", handlerMethod.getBeanType().getSimpleName() + "#" + handlerMethod.getMethod().getName(),
"parameters", Arrays.stream(handlerMethod.getMethod().getParameters())
.map(parameter -> parameter.getType().getSimpleName())
.toList()
);
}
private Map<String, Object> summarizeClass(Class<?> type) {
List<Map<String, Object>> methods = Arrays.stream(type.getDeclaredMethods())
.filter(method -> !method.isSynthetic())
.map(this::toMethodView)
.toList();
return Map.of(
"className", type.getSimpleName(),
"annotations", Arrays.stream(type.getAnnotations()).map(annotation -> annotation.annotationType().getSimpleName()).toList(),
"methodCount", methods.size(),
"methods", methods
);
}
private Map<String, Object> toMethodView(Method method) {
return Map.of(
"name", method.getName(),
"returnType", method.getReturnType().getSimpleName(),
"parameterCount", method.getParameterCount(),
"annotations", Arrays.stream(method.getAnnotations()).map(Annotation::annotationType).map(Class::getSimpleName).toList()
);
}
private Map<String, Object> toFieldView(Field field) {
return Map.of(
"name", field.getName(),
"type", field.getType().getSimpleName(),
"annotations", Arrays.stream(field.getAnnotations()).map(Annotation::annotationType).map(Class::getSimpleName).toList()
);
}
}

View File

@@ -4,7 +4,14 @@ import com.example.demo.common.ApiResponse;
import com.example.demo.dto.auth.LoginRequest;
import com.example.demo.security.LearningJwtUtil;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.HttpHeaders;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@@ -19,27 +26,48 @@ public class LearningAuthController {
}
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest req) {
// 学习演示:仅做最小账号检查
if (!(("admin".equals(req.username()) && "admin123".equals(req.password()))
|| ("user".equals(req.username()) && "user123".equals(req.password())))) {
return new ApiResponse<>(401, "用户名或密码错误", null, java.time.Instant.now());
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest request) {
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(req.username());
String token = jwtUtil.generateToken(request.username());
return ApiResponse.ok(Map.of(
"token", token,
"type", "Bearer",
"username", req.username(),
"tip", "在请求头中加入 Authorization: Bearer <token> 访问 /api/secure/**"
"token", token,
"type", "Bearer",
"username", request.username(),
"tip", "Attach Authorization: Bearer <token> when calling protected lab APIs."
));
}
@GetMapping("/mode")
public ApiResponse<Map<String, Object>> mode() {
return ApiResponse.ok(Map.of(
"mode", "learning-jwt",
"protectedPath", "/api/secure/**",
"defaultAccounts", "admin/admin123, user/user123"
"mode", "learning-jwt",
"protectedPaths", new String[]{"/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."
));
}
@GetMapping("/introspect")
public ApiResponse<Map<String, Object>> introspect(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization
) {
if (!StringUtils.hasText(authorization) || !authorization.startsWith("Bearer ")) {
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");
}
return 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."
));
}
}

View File

@@ -26,37 +26,46 @@ public class LearningJwtFilter extends OncePerRequestFilter {
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/secure/");
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);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
throws ServletException, IOException {
String auth = request.getHeader("Authorization");
if (!StringUtils.hasText(auth) || !auth.startsWith("Bearer ")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"缺少或非法 Authorization\"}");
writeUnauthorized(response, "Missing or invalid Authorization header");
return;
}
String token = auth.substring(7);
if (!jwtUtil.validate(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"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"))
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 + "\"}");
}
}

View File

@@ -48,6 +48,10 @@ public class LearningJwtUtil {
return parse(token).getSubject();
}
public Map<String, Object> claims(String token) {
return Map.copyOf(parse(token));
}
private Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
}

View File

@@ -26,10 +26,10 @@ public class LearningSecurityConfig {
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/", "/home", "/learn/**", "/aop/**", "/api/users/**", "/api/health",
"/api/auth/**", "/actuator/**", "/index.html", "/users.html", "/aop.html", "/events.html"
"/", "/home", "/api/auth/**", "/actuator/health", "/index.html", "/access.html",
"/users.html", "/aop.html", "/events.html", "/advanced.html"
).permitAll()
.requestMatchers("/api/secure/**").authenticated()
.requestMatchers("/api/secure/**", "/api/users/**", "/aop/**", "/api/lab/**", "/learn/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);