Commit 0d074e78eb6da2c2f13adc72a6d0f21ce743cd7e
1 parent
8fd249c6
feat(usr): JWT 签发/解析 + CurrentUser 上下文 REQ-USR-001
Showing
4 changed files
with
181 additions
and
0 deletions
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 | +} | ... | ... |