feat(learning-auth): add optional JWT learning flow with secure demo endpoint
This commit is contained in:
23
pom.xml
23
pom.xml
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 鉴权"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/main/java/com/example/demo/security/LearningJwtUtil.java
Normal file
54
src/main/java/com/example/demo/security/LearningJwtUtil.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
54
src/test/java/com/example/demo/controller/AuthFlowTest.java
Normal file
54
src/test/java/com/example/demo/controller/AuthFlowTest.java
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user