diff --git a/pom.xml b/pom.xml index 4f7ec91..8d55bb1 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,29 @@ org.springframework.boot spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-security + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + diff --git a/src/main/java/com/example/demo/controller/LearnController.java b/src/main/java/com/example/demo/controller/LearnController.java index ca14d73..6406c8d 100644 --- a/src/main/java/com/example/demo/controller/LearnController.java +++ b/src/main/java/com/example/demo/controller/LearnController.java @@ -45,7 +45,9 @@ public class LearnController { "POST /learn/body - JSON 请求体示例", "GET /learn/path/{id} - 路径变量示例", "GET /learn/header - 请求头示例", - "GET /learn/cookie - Cookie 示例" + "GET /learn/cookie - Cookie 示例", + "POST /api/auth/login - 学习用 JWT 登录", + "GET /api/secure/me - 受保护接口(需 Bearer Token)" }); return info; } diff --git a/src/main/java/com/example/demo/controller/auth/LearningAuthController.java b/src/main/java/com/example/demo/controller/auth/LearningAuthController.java new file mode 100644 index 0000000..a276005 --- /dev/null +++ b/src/main/java/com/example/demo/controller/auth/LearningAuthController.java @@ -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> 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 访问 /api/secure/**" + )); + } + + @GetMapping("/mode") + public ApiResponse> mode() { + return ApiResponse.ok(Map.of( + "mode", "learning-jwt", + "protectedPath", "/api/secure/**", + "defaultAccounts", "admin/admin123, user/user123" + )); + } +} diff --git a/src/main/java/com/example/demo/controller/auth/SecureDemoController.java b/src/main/java/com/example/demo/controller/auth/SecureDemoController.java new file mode 100644 index 0000000..4b2775b --- /dev/null +++ b/src/main/java/com/example/demo/controller/auth/SecureDemoController.java @@ -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> me(Authentication authentication) { + return ApiResponse.ok(Map.of( + "principal", authentication.getName(), + "authorities", authentication.getAuthorities(), + "message", "你已通过学习用 JWT 鉴权" + )); + } +} diff --git a/src/main/java/com/example/demo/dto/auth/LoginRequest.java b/src/main/java/com/example/demo/dto/auth/LoginRequest.java new file mode 100644 index 0000000..1863d1e --- /dev/null +++ b/src/main/java/com/example/demo/dto/auth/LoginRequest.java @@ -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 +) {} diff --git a/src/main/java/com/example/demo/security/LearningJwtFilter.java b/src/main/java/com/example/demo/security/LearningJwtFilter.java new file mode 100644 index 0000000..2644f39 --- /dev/null +++ b/src/main/java/com/example/demo/security/LearningJwtFilter.java @@ -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); + } +} diff --git a/src/main/java/com/example/demo/security/LearningJwtUtil.java b/src/main/java/com/example/demo/security/LearningJwtUtil.java new file mode 100644 index 0000000..647d811 --- /dev/null +++ b/src/main/java/com/example/demo/security/LearningJwtUtil.java @@ -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(); + } +} diff --git a/src/main/java/com/example/demo/security/LearningSecurityConfig.java b/src/main/java/com/example/demo/security/LearningSecurityConfig.java new file mode 100644 index 0000000..9aef34b --- /dev/null +++ b/src/main/java/com/example/demo/security/LearningSecurityConfig.java @@ -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(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9206665..619cc0a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,10 @@ server.port=8082 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 diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 937b474..e5e1b38 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -68,6 +68,10 @@

📡 事件机制

发布/订阅模式、解耦业务逻辑

+ +

🔐 鉴权演示(学习用)

+

最小 JWT 流程:登录、携带 Token、访问受保护接口

+
diff --git a/src/test/java/com/example/demo/controller/AuthFlowTest.java b/src/test/java/com/example/demo/controller/AuthFlowTest.java new file mode 100644 index 0000000..89e0882 --- /dev/null +++ b/src/test/java/com/example/demo/controller/AuthFlowTest.java @@ -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")); + } +}