From 6c46e78be89e848776fda5822b7df2c4a39a5103 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 15 May 2026 00:19:39 +0800 Subject: [PATCH] feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001 --- backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java | 18 ++++++++++++++++++ backend/src/main/java/com/xly/erp/common/security/JwtUtil.java | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java create mode 100644 backend/src/main/java/com/xly/erp/common/security/JwtUtil.java create mode 100644 backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java diff --git a/backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java b/backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..26131c8 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java @@ -0,0 +1,18 @@ +package com.xly.erp.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** + * BCrypt 密码编码器 Bean。strength=10(Spring Security 默认)。 + * docs/03 sys_user.sPasswordHash + docs/04 § 1.6。 + */ +@Configuration +public class PasswordEncoderConfig { + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(10); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java b/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java new file mode 100644 index 0000000..5320200 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/security/JwtUtil.java @@ -0,0 +1,75 @@ +package com.xly.erp.common.security; + +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +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.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * JWT 签发与验证工具。HS256,密钥来自 ${JWT_SECRET}。 + * docs/04 § 1.6。 + */ +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + private SecretKey key; + + @PostConstruct + void init() { + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < 32) { + byte[] padded = new byte[32]; + System.arraycopy(bytes, 0, padded, 0, bytes.length); + bytes = padded; + } + this.key = Keys.hmacShaKeyFor(bytes); + } + + public String issue(Map claims, long ttlSec) { + long now = System.currentTimeMillis(); + Map all = new HashMap<>(claims); + String sub = String.valueOf(all.remove("sub")); + String jti = UUID.randomUUID().toString(); + return Jwts.builder() + .subject(sub) + .claims(all) + .id(jti) + .issuedAt(new Date(now)) + .expiration(new Date(now + ttlSec * 1000L)) + .signWith(key) + .compact(); + } + + public Map parse(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + Map out = new HashMap<>(claims); + out.put("sub", claims.getSubject()); + out.put("jti", claims.getId()); + out.put("iat", claims.getIssuedAt() != null ? claims.getIssuedAt().getTime() / 1000 : null); + out.put("exp", claims.getExpiration() != null ? claims.getExpiration().getTime() / 1000 : null); + return out; + } catch (JwtException e) { + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 无效或已过期"); + } + } +} diff --git a/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java b/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java new file mode 100644 index 0000000..be17b94 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java @@ -0,0 +1,64 @@ +package com.xly.erp.common.security; + +import com.xly.erp.common.exception.BizException; +import com.xly.erp.common.response.ErrorCode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@ActiveProfiles("test") +class JwtUtilTest { + + @Autowired + private JwtUtil jwtUtil; + + private Map sampleClaims() { + Map claims = new HashMap<>(); + claims.put("sub", "42"); + claims.put("username", "alice"); + claims.put("userType", "NORMAL"); + claims.put("companyCode", "HQ"); + claims.put("language", "zh-CN"); + return claims; + } + + @Test + void issuedToken_canBeParsedBackToClaims() { + String token = jwtUtil.issue(sampleClaims(), 7200); + assertNotNull(token); + assertFalse(token.isEmpty()); + + Map parsed = jwtUtil.parse(token); + assertEquals("42", parsed.get("sub")); + assertEquals("alice", parsed.get("username")); + assertEquals("NORMAL", parsed.get("userType")); + assertEquals("HQ", parsed.get("companyCode")); + assertEquals("zh-CN", parsed.get("language")); + assertNotNull(parsed.get("jti")); + assertNotNull(parsed.get("iat")); + assertNotNull(parsed.get("exp")); + } + + @Test + void tamperedToken_throwsBizException() { + String token = jwtUtil.issue(sampleClaims(), 7200); + String tampered = token.substring(0, token.length() - 4) + "XXXX"; + BizException e = assertThrows(BizException.class, () -> jwtUtil.parse(tampered)); + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); + } + + @Test + void expiredToken_throwsBizException() { + String token = jwtUtil.issue(sampleClaims(), 0L); + try { Thread.sleep(1100); } catch (InterruptedException ignored) {} + BizException e = assertThrows(BizException.class, () -> jwtUtil.parse(token)); + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode()); + } +} -- libgit2 0.22.2