feat: protect learning pages and rebuild cockpit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user