diff --git a/backend/src/main/java/com/xly/test4/common/security/CurrentUser.java b/backend/src/main/java/com/xly/test4/common/security/CurrentUser.java new file mode 100644 index 0000000..ccbebd9 --- /dev/null +++ b/backend/src/main/java/com/xly/test4/common/security/CurrentUser.java @@ -0,0 +1,16 @@ +package com.xly.test4.common.security; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class CurrentUser { + private Integer userId; + private String userName; + private String brandsId; + private String subsidiaryId; + private List authorities; +} diff --git a/backend/src/main/java/com/xly/test4/common/security/CurrentUserContext.java b/backend/src/main/java/com/xly/test4/common/security/CurrentUserContext.java new file mode 100644 index 0000000..2ca98d4 --- /dev/null +++ b/backend/src/main/java/com/xly/test4/common/security/CurrentUserContext.java @@ -0,0 +1,20 @@ +package com.xly.test4.common.security; + +import com.xly.test4.common.exception.BusinessException; +import com.xly.test4.common.response.ResultCode; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +public final class CurrentUserContext { + + private CurrentUserContext() { + } + + public static CurrentUser current() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !(auth.getPrincipal() instanceof CurrentUser principal)) { + throw new BusinessException(ResultCode.UNAUTHENTICATED, "未认证"); + } + return principal; + } +} diff --git a/backend/src/main/java/com/xly/test4/common/security/JwtTokenProvider.java b/backend/src/main/java/com/xly/test4/common/security/JwtTokenProvider.java new file mode 100644 index 0000000..ff9d33b --- /dev/null +++ b/backend/src/main/java/com/xly/test4/common/security/JwtTokenProvider.java @@ -0,0 +1,66 @@ +package com.xly.test4.common.security; + +import com.xly.test4.common.exception.BusinessException; +import com.xly.test4.common.response.ResultCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +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.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; + +@Component +public class JwtTokenProvider { + + private final SecretKey key; + private final long ttlHours; + + public JwtTokenProvider(@Value("${app.security.jwt.secret}") String secret, + @Value("${app.security.jwt.access-ttl-hours}") long ttlHours) { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.ttlHours = ttlHours; + } + + public String issue(CurrentUser user) { + Instant now = Instant.now(); + return Jwts.builder() + .subject(user.getUserName()) + .claim("uid", user.getUserId()) + .claim("bid", user.getBrandsId()) + .claim("sid", user.getSubsidiaryId()) + .claim("auth", user.getAuthorities()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(ttlHours, ChronoUnit.HOURS))) + .signWith(key) + .compact(); + } + + @SuppressWarnings("unchecked") + public CurrentUser parse(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + + Object uid = claims.get("uid"); + return CurrentUser.builder() + .userId(uid == null ? null : ((Number) uid).intValue()) + .userName(claims.getSubject()) + .brandsId(claims.get("bid", String.class)) + .subsidiaryId(claims.get("sid", String.class)) + .authorities((List) claims.get("auth", List.class)) + .build(); + } catch (JwtException | IllegalArgumentException e) { + throw new BusinessException(ResultCode.UNAUTHENTICATED, "未认证"); + } + } +} diff --git a/backend/src/test/java/com/xly/test4/common/security/JwtTokenProviderTest.java b/backend/src/test/java/com/xly/test4/common/security/JwtTokenProviderTest.java new file mode 100644 index 0000000..a396353 --- /dev/null +++ b/backend/src/test/java/com/xly/test4/common/security/JwtTokenProviderTest.java @@ -0,0 +1,79 @@ +package com.xly.test4.common.security; + +import com.xly.test4.common.exception.BusinessException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JwtTokenProviderTest { + + private static final String SECRET = "test-secret-pure-ascii-at-least-32-bytes-long-xxxx"; + + private final JwtTokenProvider provider = new JwtTokenProvider(SECRET, 24); + + @Test + void issueThenParse_roundTripsAllClaims() { + CurrentUser original = CurrentUser.builder() + .userId(42) + .userName("admin") + .brandsId("BR-DEFAULT") + .subsidiaryId("SUB-DEFAULT") + .authorities(List.of("usr:user:create", "usr:user:update")) + .build(); + + String token = provider.issue(original); + CurrentUser parsed = provider.parse(token); + + assertThat(parsed.getUserId()).isEqualTo(42); + assertThat(parsed.getUserName()).isEqualTo("admin"); + assertThat(parsed.getBrandsId()).isEqualTo("BR-DEFAULT"); + assertThat(parsed.getSubsidiaryId()).isEqualTo("SUB-DEFAULT"); + assertThat(parsed.getAuthorities()) + .containsExactly("usr:user:create", "usr:user:update"); + } + + @Test + void parseExpiredToken_throws40101() throws Exception { + // 直接用 jjwt 构造一个已过期的 token,绕过 provider 自身的 ttl + SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); + String expired = Jwts.builder() + .subject("admin") + .issuedAt(Date.from(Instant.now().minus(48, ChronoUnit.HOURS))) + .expiration(Date.from(Instant.now().minus(24, ChronoUnit.HOURS))) + .signWith(key) + .compact(); + + assertThatThrownBy(() -> provider.parse(expired)) + .isInstanceOf(BusinessException.class) + .matches(e -> ((BusinessException) e).getCode() == 40101); + } + + @Test + void parseTamperedSignature_throws40101() { + String token = provider.issue(CurrentUser.builder().userName("a").userId(1) + .brandsId("b").subsidiaryId("s").authorities(List.of()).build()); + // 篡改 token 末尾几位 + String tampered = token.substring(0, token.length() - 4) + "XXXX"; + + assertThatThrownBy(() -> provider.parse(tampered)) + .isInstanceOf(BusinessException.class) + .matches(e -> ((BusinessException) e).getCode() == 40101); + } + + @Test + void parseMalformedToken_throws40101() { + assertThatThrownBy(() -> provider.parse("not-a-jwt")) + .isInstanceOf(BusinessException.class) + .matches(e -> ((BusinessException) e).getCode() == 40101); + } +}