feat(learning-auth): add optional JWT learning flow with secure demo endpoint

This commit is contained in:
likingcode
2026-03-09 02:11:49 +08:00
parent 8f93488989
commit efcfe7e012
11 changed files with 322 additions and 1 deletions

23
pom.xml
View File

@@ -43,6 +43,29 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </dependency>
<!-- 仅用于学习的可选鉴权演示(不影响主流程) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<!-- 测试 --> <!-- 测试 -->
<dependency> <dependency>

View File

@@ -45,7 +45,9 @@ public class LearnController {
"POST /learn/body - JSON 请求体示例", "POST /learn/body - JSON 请求体示例",
"GET /learn/path/{id} - 路径变量示例", "GET /learn/path/{id} - 路径变量示例",
"GET /learn/header - 请求头示例", "GET /learn/header - 请求头示例",
"GET /learn/cookie - Cookie 示例" "GET /learn/cookie - Cookie 示例",
"POST /api/auth/login - 学习用 JWT 登录",
"GET /api/secure/me - 受保护接口(需 Bearer Token"
}); });
return info; return info;
} }

View File

@@ -0,0 +1,45 @@
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.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class LearningAuthController {
private final LearningJwtUtil jwtUtil;
public LearningAuthController(LearningJwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@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());
}
String token = jwtUtil.generateToken(req.username());
return ApiResponse.ok(Map.of(
"token", token,
"type", "Bearer",
"username", req.username(),
"tip", "在请求头中加入 Authorization: Bearer <token> 访问 /api/secure/**"
));
}
@GetMapping("/mode")
public ApiResponse<Map<String, Object>> mode() {
return ApiResponse.ok(Map.of(
"mode", "learning-jwt",
"protectedPath", "/api/secure/**",
"defaultAccounts", "admin/admin123, user/user123"
));
}
}

View File

@@ -0,0 +1,23 @@
package com.example.demo.controller.auth;
import com.example.demo.common.ApiResponse;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/secure")
public class SecureDemoController {
@GetMapping("/me")
public ApiResponse<Map<String, Object>> me(Authentication authentication) {
return ApiResponse.ok(Map.of(
"principal", authentication.getName(),
"authorities", authentication.getAuthorities(),
"message", "你已通过学习用 JWT 鉴权"
));
}
}

View File

@@ -0,0 +1,8 @@
package com.example.demo.dto.auth;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "密码不能为空") String password
) {}

View File

@@ -0,0 +1,62 @@
package com.example.demo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
public class LearningJwtFilter extends OncePerRequestFilter {
private final LearningJwtUtil jwtUtil;
public LearningJwtFilter(LearningJwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/secure/");
}
@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 ")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"缺少或非法 Authorization\"}");
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 无效或过期\"}");
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);
}
}

View File

@@ -0,0 +1,54 @@
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Component
public class LearningJwtUtil {
@Value("${learning.auth.jwt.secret}")
private String secret;
@Value("${learning.auth.jwt.expiration:86400000}")
private long expiration;
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String username) {
Date now = new Date();
return Jwts.builder()
.claims(Map.of("username", username))
.subject(username)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.signWith(key(), Jwts.SIG.HS256)
.compact();
}
public boolean validate(String token) {
try {
parse(token);
return true;
} catch (Exception e) {
return false;
}
}
public String username(String token) {
return parse(token).getSubject();
}
private Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
}
}

View File

@@ -0,0 +1,38 @@
package com.example.demo.security;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@ConditionalOnProperty(name = "learning.auth.enabled", havingValue = "true", matchIfMissing = true)
public class LearningSecurityConfig {
private final LearningJwtFilter learningJwtFilter;
public LearningSecurityConfig(LearningJwtFilter learningJwtFilter) {
this.learningJwtFilter = learningJwtFilter;
}
@Bean
public SecurityFilterChain learningSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.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"
).permitAll()
.requestMatchers("/api/secure/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(learningJwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@@ -1,2 +1,10 @@
server.port=8082 server.port=8082
spring.application.name=springboot-demo spring.application.name=springboot-demo
# 学习友好:默认只保护 /api/secure/**
learning.auth.enabled=true
learning.auth.jwt.secret=demo-learning-secret-key-demo-learning-secret-key
learning.auth.jwt.expiration=86400000
# 避免默认生成密码干扰学习输出
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration

View File

@@ -68,6 +68,10 @@
<h4>📡 事件机制</h4> <h4>📡 事件机制</h4>
<p>发布/订阅模式、解耦业务逻辑</p> <p>发布/订阅模式、解耦业务逻辑</p>
</a> </a>
<a href="/learn" class="feature-item">
<h4>🔐 鉴权演示(学习用)</h4>
<p>最小 JWT 流程:登录、携带 Token、访问受保护接口</p>
</a>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,54 @@
package com.example.demo.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
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;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class AuthFlowTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void secureEndpointShouldRejectWithoutToken() throws Exception {
mockMvc.perform(get("/api/secure/me"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401));
}
@Test
void shouldAccessSecureEndpointWithValidToken() 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();
mockMvc.perform(get("/api/secure/me")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.principal").value("admin"));
}
}