Commit 6c46e78be89e848776fda5822b7df2c4a39a5103

Authored by zichun
1 parent 8458fa5a

feat(usr): JWT 工具 + BCrypt 编码器 REQ-USR-001

backend/src/main/java/com/xly/erp/common/config/PasswordEncoderConfig.java 0 → 100644
  1 +package com.xly.erp.common.config;
  2 +
  3 +import org.springframework.context.annotation.Bean;
  4 +import org.springframework.context.annotation.Configuration;
  5 +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
  6 +
  7 +/**
  8 + * BCrypt 密码编码器 Bean。strength=10(Spring Security 默认)。
  9 + * docs/03 sys_user.sPasswordHash + docs/04 § 1.6。
  10 + */
  11 +@Configuration
  12 +public class PasswordEncoderConfig {
  13 +
  14 + @Bean
  15 + public BCryptPasswordEncoder passwordEncoder() {
  16 + return new BCryptPasswordEncoder(10);
  17 + }
  18 +}
... ...
backend/src/main/java/com/xly/erp/common/security/JwtUtil.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import io.jsonwebtoken.Claims;
  6 +import io.jsonwebtoken.JwtException;
  7 +import io.jsonwebtoken.Jwts;
  8 +import io.jsonwebtoken.security.Keys;
  9 +import jakarta.annotation.PostConstruct;
  10 +import org.springframework.beans.factory.annotation.Value;
  11 +import org.springframework.stereotype.Component;
  12 +
  13 +import javax.crypto.SecretKey;
  14 +import java.nio.charset.StandardCharsets;
  15 +import java.util.Date;
  16 +import java.util.HashMap;
  17 +import java.util.Map;
  18 +import java.util.UUID;
  19 +
  20 +/**
  21 + * JWT 签发与验证工具。HS256,密钥来自 ${JWT_SECRET}。
  22 + * docs/04 § 1.6。
  23 + */
  24 +@Component
  25 +public class JwtUtil {
  26 +
  27 + @Value("${jwt.secret}")
  28 + private String secret;
  29 +
  30 + private SecretKey key;
  31 +
  32 + @PostConstruct
  33 + void init() {
  34 + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8);
  35 + if (bytes.length < 32) {
  36 + byte[] padded = new byte[32];
  37 + System.arraycopy(bytes, 0, padded, 0, bytes.length);
  38 + bytes = padded;
  39 + }
  40 + this.key = Keys.hmacShaKeyFor(bytes);
  41 + }
  42 +
  43 + public String issue(Map<String, Object> claims, long ttlSec) {
  44 + long now = System.currentTimeMillis();
  45 + Map<String, Object> all = new HashMap<>(claims);
  46 + String sub = String.valueOf(all.remove("sub"));
  47 + String jti = UUID.randomUUID().toString();
  48 + return Jwts.builder()
  49 + .subject(sub)
  50 + .claims(all)
  51 + .id(jti)
  52 + .issuedAt(new Date(now))
  53 + .expiration(new Date(now + ttlSec * 1000L))
  54 + .signWith(key)
  55 + .compact();
  56 + }
  57 +
  58 + public Map<String, Object> parse(String token) {
  59 + try {
  60 + Claims claims = Jwts.parser()
  61 + .verifyWith(key)
  62 + .build()
  63 + .parseSignedClaims(token)
  64 + .getPayload();
  65 + Map<String, Object> out = new HashMap<>(claims);
  66 + out.put("sub", claims.getSubject());
  67 + out.put("jti", claims.getId());
  68 + out.put("iat", claims.getIssuedAt() != null ? claims.getIssuedAt().getTime() / 1000 : null);
  69 + out.put("exp", claims.getExpiration() != null ? claims.getExpiration().getTime() / 1000 : null);
  70 + return out;
  71 + } catch (JwtException e) {
  72 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "token 无效或已过期");
  73 + }
  74 + }
  75 +}
... ...
backend/src/test/java/com/xly/erp/common/security/JwtUtilTest.java 0 → 100644
  1 +package com.xly.erp.common.security;
  2 +
  3 +import com.xly.erp.common.exception.BizException;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import org.junit.jupiter.api.Test;
  6 +import org.springframework.beans.factory.annotation.Autowired;
  7 +import org.springframework.boot.test.context.SpringBootTest;
  8 +import org.springframework.test.context.ActiveProfiles;
  9 +
  10 +import java.util.HashMap;
  11 +import java.util.Map;
  12 +
  13 +import static org.junit.jupiter.api.Assertions.*;
  14 +
  15 +@SpringBootTest
  16 +@ActiveProfiles("test")
  17 +class JwtUtilTest {
  18 +
  19 + @Autowired
  20 + private JwtUtil jwtUtil;
  21 +
  22 + private Map<String, Object> sampleClaims() {
  23 + Map<String, Object> claims = new HashMap<>();
  24 + claims.put("sub", "42");
  25 + claims.put("username", "alice");
  26 + claims.put("userType", "NORMAL");
  27 + claims.put("companyCode", "HQ");
  28 + claims.put("language", "zh-CN");
  29 + return claims;
  30 + }
  31 +
  32 + @Test
  33 + void issuedToken_canBeParsedBackToClaims() {
  34 + String token = jwtUtil.issue(sampleClaims(), 7200);
  35 + assertNotNull(token);
  36 + assertFalse(token.isEmpty());
  37 +
  38 + Map<String, Object> parsed = jwtUtil.parse(token);
  39 + assertEquals("42", parsed.get("sub"));
  40 + assertEquals("alice", parsed.get("username"));
  41 + assertEquals("NORMAL", parsed.get("userType"));
  42 + assertEquals("HQ", parsed.get("companyCode"));
  43 + assertEquals("zh-CN", parsed.get("language"));
  44 + assertNotNull(parsed.get("jti"));
  45 + assertNotNull(parsed.get("iat"));
  46 + assertNotNull(parsed.get("exp"));
  47 + }
  48 +
  49 + @Test
  50 + void tamperedToken_throwsBizException() {
  51 + String token = jwtUtil.issue(sampleClaims(), 7200);
  52 + String tampered = token.substring(0, token.length() - 4) + "XXXX";
  53 + BizException e = assertThrows(BizException.class, () -> jwtUtil.parse(tampered));
  54 + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode());
  55 + }
  56 +
  57 + @Test
  58 + void expiredToken_throwsBizException() {
  59 + String token = jwtUtil.issue(sampleClaims(), 0L);
  60 + try { Thread.sleep(1100); } catch (InterruptedException ignored) {}
  61 + BizException e = assertThrows(BizException.class, () -> jwtUtil.parse(token));
  62 + assertEquals(ErrorCode.BAD_CREDENTIALS, e.getCode());
  63 + }
  64 +}
... ...