Commit 0d074e78eb6da2c2f13adc72a6d0f21ce743cd7e

Authored by zichun
1 parent 8fd249c6

feat(usr): JWT 签发/解析 + CurrentUser 上下文 REQ-USR-001

backend/src/main/java/com/xly/test4/common/security/CurrentUser.java 0 → 100644
  1 +package com.xly.test4.common.security;
  2 +
  3 +import lombok.Builder;
  4 +import lombok.Data;
  5 +
  6 +import java.util.List;
  7 +
  8 +@Data
  9 +@Builder
  10 +public class CurrentUser {
  11 + private Integer userId;
  12 + private String userName;
  13 + private String brandsId;
  14 + private String subsidiaryId;
  15 + private List<String> authorities;
  16 +}
... ...
backend/src/main/java/com/xly/test4/common/security/CurrentUserContext.java 0 → 100644
  1 +package com.xly.test4.common.security;
  2 +
  3 +import com.xly.test4.common.exception.BusinessException;
  4 +import com.xly.test4.common.response.ResultCode;
  5 +import org.springframework.security.core.Authentication;
  6 +import org.springframework.security.core.context.SecurityContextHolder;
  7 +
  8 +public final class CurrentUserContext {
  9 +
  10 + private CurrentUserContext() {
  11 + }
  12 +
  13 + public static CurrentUser current() {
  14 + Authentication auth = SecurityContextHolder.getContext().getAuthentication();
  15 + if (auth == null || !(auth.getPrincipal() instanceof CurrentUser principal)) {
  16 + throw new BusinessException(ResultCode.UNAUTHENTICATED, "未认证");
  17 + }
  18 + return principal;
  19 + }
  20 +}
... ...
backend/src/main/java/com/xly/test4/common/security/JwtTokenProvider.java 0 → 100644
  1 +package com.xly.test4.common.security;
  2 +
  3 +import com.xly.test4.common.exception.BusinessException;
  4 +import com.xly.test4.common.response.ResultCode;
  5 +import io.jsonwebtoken.Claims;
  6 +import io.jsonwebtoken.JwtException;
  7 +import io.jsonwebtoken.Jwts;
  8 +import io.jsonwebtoken.security.Keys;
  9 +import org.springframework.beans.factory.annotation.Value;
  10 +import org.springframework.stereotype.Component;
  11 +
  12 +import javax.crypto.SecretKey;
  13 +import java.nio.charset.StandardCharsets;
  14 +import java.time.Instant;
  15 +import java.time.temporal.ChronoUnit;
  16 +import java.util.Date;
  17 +import java.util.List;
  18 +
  19 +@Component
  20 +public class JwtTokenProvider {
  21 +
  22 + private final SecretKey key;
  23 + private final long ttlHours;
  24 +
  25 + public JwtTokenProvider(@Value("${app.security.jwt.secret}") String secret,
  26 + @Value("${app.security.jwt.access-ttl-hours}") long ttlHours) {
  27 + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
  28 + this.ttlHours = ttlHours;
  29 + }
  30 +
  31 + public String issue(CurrentUser user) {
  32 + Instant now = Instant.now();
  33 + return Jwts.builder()
  34 + .subject(user.getUserName())
  35 + .claim("uid", user.getUserId())
  36 + .claim("bid", user.getBrandsId())
  37 + .claim("sid", user.getSubsidiaryId())
  38 + .claim("auth", user.getAuthorities())
  39 + .issuedAt(Date.from(now))
  40 + .expiration(Date.from(now.plus(ttlHours, ChronoUnit.HOURS)))
  41 + .signWith(key)
  42 + .compact();
  43 + }
  44 +
  45 + @SuppressWarnings("unchecked")
  46 + public CurrentUser parse(String token) {
  47 + try {
  48 + Claims claims = Jwts.parser()
  49 + .verifyWith(key)
  50 + .build()
  51 + .parseSignedClaims(token)
  52 + .getPayload();
  53 +
  54 + Object uid = claims.get("uid");
  55 + return CurrentUser.builder()
  56 + .userId(uid == null ? null : ((Number) uid).intValue())
  57 + .userName(claims.getSubject())
  58 + .brandsId(claims.get("bid", String.class))
  59 + .subsidiaryId(claims.get("sid", String.class))
  60 + .authorities((List<String>) claims.get("auth", List.class))
  61 + .build();
  62 + } catch (JwtException | IllegalArgumentException e) {
  63 + throw new BusinessException(ResultCode.UNAUTHENTICATED, "未认证");
  64 + }
  65 + }
  66 +}
... ...
backend/src/test/java/com/xly/test4/common/security/JwtTokenProviderTest.java 0 → 100644
  1 +package com.xly.test4.common.security;
  2 +
  3 +import com.xly.test4.common.exception.BusinessException;
  4 +import io.jsonwebtoken.Jwts;
  5 +import io.jsonwebtoken.security.Keys;
  6 +import org.junit.jupiter.api.Test;
  7 +
  8 +import javax.crypto.SecretKey;
  9 +import java.nio.charset.StandardCharsets;
  10 +import java.time.Instant;
  11 +import java.time.temporal.ChronoUnit;
  12 +import java.util.Date;
  13 +import java.util.List;
  14 +
  15 +import static org.assertj.core.api.Assertions.assertThat;
  16 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
  17 +
  18 +class JwtTokenProviderTest {
  19 +
  20 + private static final String SECRET = "test-secret-pure-ascii-at-least-32-bytes-long-xxxx";
  21 +
  22 + private final JwtTokenProvider provider = new JwtTokenProvider(SECRET, 24);
  23 +
  24 + @Test
  25 + void issueThenParse_roundTripsAllClaims() {
  26 + CurrentUser original = CurrentUser.builder()
  27 + .userId(42)
  28 + .userName("admin")
  29 + .brandsId("BR-DEFAULT")
  30 + .subsidiaryId("SUB-DEFAULT")
  31 + .authorities(List.of("usr:user:create", "usr:user:update"))
  32 + .build();
  33 +
  34 + String token = provider.issue(original);
  35 + CurrentUser parsed = provider.parse(token);
  36 +
  37 + assertThat(parsed.getUserId()).isEqualTo(42);
  38 + assertThat(parsed.getUserName()).isEqualTo("admin");
  39 + assertThat(parsed.getBrandsId()).isEqualTo("BR-DEFAULT");
  40 + assertThat(parsed.getSubsidiaryId()).isEqualTo("SUB-DEFAULT");
  41 + assertThat(parsed.getAuthorities())
  42 + .containsExactly("usr:user:create", "usr:user:update");
  43 + }
  44 +
  45 + @Test
  46 + void parseExpiredToken_throws40101() throws Exception {
  47 + // 直接用 jjwt 构造一个已过期的 token,绕过 provider 自身的 ttl
  48 + SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
  49 + String expired = Jwts.builder()
  50 + .subject("admin")
  51 + .issuedAt(Date.from(Instant.now().minus(48, ChronoUnit.HOURS)))
  52 + .expiration(Date.from(Instant.now().minus(24, ChronoUnit.HOURS)))
  53 + .signWith(key)
  54 + .compact();
  55 +
  56 + assertThatThrownBy(() -> provider.parse(expired))
  57 + .isInstanceOf(BusinessException.class)
  58 + .matches(e -> ((BusinessException) e).getCode() == 40101);
  59 + }
  60 +
  61 + @Test
  62 + void parseTamperedSignature_throws40101() {
  63 + String token = provider.issue(CurrentUser.builder().userName("a").userId(1)
  64 + .brandsId("b").subsidiaryId("s").authorities(List.of()).build());
  65 + // 篡改 token 末尾几位
  66 + String tampered = token.substring(0, token.length() - 4) + "XXXX";
  67 +
  68 + assertThatThrownBy(() -> provider.parse(tampered))
  69 + .isInstanceOf(BusinessException.class)
  70 + .matches(e -> ((BusinessException) e).getCode() == 40101);
  71 + }
  72 +
  73 + @Test
  74 + void parseMalformedToken_throws40101() {
  75 + assertThatThrownBy(() -> provider.parse("not-a-jwt"))
  76 + .isInstanceOf(BusinessException.class)
  77 + .matches(e -> ((BusinessException) e).getCode() == 40101);
  78 + }
  79 +}
... ...