From 90d61a27e9c3cd08817dc979a2cf8fe1b80b1d87 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Wed, 13 Apr 2022 19:38:34 +0800 Subject: [PATCH] feat: add token provider (#1841) --- build.gradle | 6 ++ .../identity/authentication/AccessToken.java | 35 ++++++++ .../authentication/JwtTokenProvider.java | 86 +++++++++++++++++++ .../authentication/SecurityConstant.java | 28 ++++++ .../app/infra/properties/JwtProperties.java | 14 +++ .../run/halo/app/infra/utils/HaloUtils.java | 5 ++ .../run/halo/app/JwtTokenProviderTest.java | 64 ++++++++++++++ 7 files changed, 238 insertions(+) create mode 100644 src/main/java/run/halo/app/identity/authentication/AccessToken.java create mode 100644 src/main/java/run/halo/app/identity/authentication/JwtTokenProvider.java create mode 100644 src/main/java/run/halo/app/identity/authentication/SecurityConstant.java create mode 100644 src/test/java/run/halo/app/JwtTokenProviderTest.java diff --git a/build.gradle b/build.gradle index d79c45a76..87758ebc8 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,9 @@ bootJar { } ext['h2.version'] = '2.1.210' +ext { + commonsLang3 = "3.12.0" +} dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' @@ -54,6 +57,9 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-jetty" implementation "org.flywaydb:flyway-core" implementation "org.flywaydb:flyway-mysql" + + implementation "org.apache.commons:commons-lang3:$commonsLang3" + compileOnly 'org.projectlombok:lombok' annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" diff --git a/src/main/java/run/halo/app/identity/authentication/AccessToken.java b/src/main/java/run/halo/app/identity/authentication/AccessToken.java new file mode 100644 index 000000000..3894e5c72 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/AccessToken.java @@ -0,0 +1,35 @@ +package run.halo.app.identity.authentication; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.io.Serializable; +import java.util.Collections; +import java.util.Map; +import lombok.Data; +import org.springframework.security.oauth2.jwt.Jwt; + +/** + * @author guqing + * @date 2022-04-12 + */ +@Data +public class AccessToken implements Serializable { + private String tokenType; + + private Jwt accessToken; + + private Jwt refreshToken; + private Map additionalInformation; + + @JsonIgnore + private long expiration; + + public AccessToken(Jwt accessToken) { + this.tokenType = "Bearer".toLowerCase(); + this.additionalInformation = Collections.emptyMap(); + this.accessToken = accessToken; + } + + public long getExpiresIn() { + return this.expiration; + } +} diff --git a/src/main/java/run/halo/app/identity/authentication/JwtTokenProvider.java b/src/main/java/run/halo/app/identity/authentication/JwtTokenProvider.java new file mode 100644 index 000000000..ed1913e3b --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/JwtTokenProvider.java @@ -0,0 +1,86 @@ +package run.halo.app.identity.authentication; + +import java.io.Serializable; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Component; +import run.halo.app.infra.properties.JwtProperties; +import run.halo.app.infra.utils.HaloUtils; + +/** + * Jwt token utils. + * + * @author guqing + * @date 2022-04-12 + */ +@Component +@EnableConfigurationProperties(JwtProperties.class) +public class JwtTokenProvider implements Serializable { + + private final JwtEncoder jwtEncoder; + + private final JwtDecoder jwtDecoder; + + private final JwtProperties jwtProperties; + + public JwtTokenProvider(JwtEncoder jwtEncoder, JwtDecoder jwtDecoder, + JwtProperties jwtProperties) { + this.jwtEncoder = jwtEncoder; + this.jwtDecoder = jwtDecoder; + this.jwtProperties = jwtProperties; + } + + private JwtClaimsSet createJwt(Authentication authentication, Instant expireAt) { + String scope = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(" ")); + return JwtClaimsSet.builder() + // JWT ID (jti) + .id(HaloUtils.simpleUUID()) + // 签发者 + .issuer(StringUtils.defaultIfBlank(jwtProperties.getIssuerUri(), + "https://halo.run")) + .issuedAt(Instant.now()) + // Authentication#getName maps to the JWT’s sub property, if one is present. + .subject(authentication.getName()) + // expiration time (exp) claim + .expiresAt(expireAt) + .claim("scope", scope) + .build(); + } + + public AccessToken getToken(Authentication authentication) { + Instant expireAt = Instant.now().plusMillis(SecurityConstant.EXPIRATION_TIME); + JwtClaimsSet tokenClaimsSet = createJwt(authentication, expireAt); + Jwt token = jwtEncoder.encode(JwtEncoderParameters.from(tokenClaimsSet)); + + JwtClaimsSet refreshTokenClaimsSet = + createJwt(authentication, expireAt.plus(30, ChronoUnit.MINUTES)); + Jwt refreshToken = jwtEncoder.encode(JwtEncoderParameters.from(refreshTokenClaimsSet)); + + AccessToken accessToken = new AccessToken(token); + accessToken.setRefreshToken(refreshToken); + accessToken.setExpiration(expireAt.toEpochMilli()); + accessToken.setTokenType(SecurityConstant.TOKEN_PREFIX); + return accessToken; + } + + public Jwt verify(String token) { + try { + return jwtDecoder.decode(token); + } catch (JwtException e) { + return null; + } + } +} diff --git a/src/main/java/run/halo/app/identity/authentication/SecurityConstant.java b/src/main/java/run/halo/app/identity/authentication/SecurityConstant.java new file mode 100644 index 000000000..9eb97582d --- /dev/null +++ b/src/main/java/run/halo/app/identity/authentication/SecurityConstant.java @@ -0,0 +1,28 @@ +package run.halo.app.identity.authentication; + +/** + * @author guqing + * @date 2022-04-13 + */ +public interface SecurityConstant { + + /** + * 30 mins + */ + long EXPIRATION_TIME = 1_800_000; + + /** + * Token prefix + */ + String TOKEN_PREFIX = "Bearer"; + + /** + * Authentication header + */ + String HEADER_STRING = "Authorization"; + + /** + * login uri + */ + String LOGIN_URL = "/api/v1/oauth/login"; +} diff --git a/src/main/java/run/halo/app/infra/properties/JwtProperties.java b/src/main/java/run/halo/app/infra/properties/JwtProperties.java index 991a6997c..012873d75 100644 --- a/src/main/java/run/halo/app/infra/properties/JwtProperties.java +++ b/src/main/java/run/halo/app/infra/properties/JwtProperties.java @@ -20,6 +20,12 @@ import org.springframework.util.StreamUtils; @ConfigurationProperties(prefix = "halo.security.oauth2.jwt") public class JwtProperties { + /** + * URI that can either be an OpenID Connect discovery endpoint or an OAuth 2.0 + * Authorization Server Metadata endpoint defined by RFC 8414. + */ + private String issuerUri; + /** * JSON Web Algorithm used for verifying the digital signatures. */ @@ -32,6 +38,14 @@ public class JwtProperties { private Resource privateKeyLocation; + public String getIssuerUri() { + return issuerUri; + } + + public void setIssuerUri(String issuerUri) { + this.issuerUri = issuerUri; + } + public String getJwsAlgorithm() { return this.jwsAlgorithm; } diff --git a/src/main/java/run/halo/app/infra/utils/HaloUtils.java b/src/main/java/run/halo/app/infra/utils/HaloUtils.java index c6cab57f8..fb862cf4b 100644 --- a/src/main/java/run/halo/app/infra/utils/HaloUtils.java +++ b/src/main/java/run/halo/app/infra/utils/HaloUtils.java @@ -1,6 +1,7 @@ package run.halo.app.infra.utils; import jakarta.servlet.http.HttpServletRequest; +import java.util.UUID; /** * @author guqing @@ -11,4 +12,8 @@ public class HaloUtils { String requestedWith = request.getHeader("x-requested-with"); return requestedWith == null || requestedWith.equalsIgnoreCase("XMLHttpRequest"); } + + public static String simpleUUID() { + return UUID.randomUUID().toString().replace("-", ""); + } } diff --git a/src/test/java/run/halo/app/JwtTokenProviderTest.java b/src/test/java/run/halo/app/JwtTokenProviderTest.java new file mode 100644 index 000000000..d59e0014e --- /dev/null +++ b/src/test/java/run/halo/app/JwtTokenProviderTest.java @@ -0,0 +1,64 @@ +package run.halo.app; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.MalformedURLException; +import java.net.URL; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.TestPropertySource; +import run.halo.app.identity.authentication.AccessToken; +import run.halo.app.identity.authentication.JwtTokenProvider; + +/** + * @author guqing + * @date 2022-04-13 + */ +@WithMockUser(username = "test", password = "test") +@TestPropertySource(properties = {"halo.security.oauth2.jwt.public-key-location=classpath:app.pub", + "halo.security.oauth2.jwt.private-key-location=classpath:app.key"}) +@SpringBootTest +public class JwtTokenProviderTest { + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + private AccessToken accessToken; + + @BeforeEach + public void setUp() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + accessToken = jwtTokenProvider.getToken(authentication); + } + + @Test + public void createJwt() throws MalformedURLException { + Jwt token = accessToken.getAccessToken(); + Jwt refreshToken = accessToken.getRefreshToken(); + System.out.println(token.getTokenValue()); + assertThat(token.getClaims()).isNotNull() + .containsEntry("sub", "test") + .containsEntry("scope", "ROLE_USER") + .containsEntry("iss", new URL("https://halo.run")) + .containsKey("iss") + .containsKey("jti"); + + assertThat(refreshToken.getClaims()) + .isNotNull() + .containsEntry("sub", "test") + .containsKey("iss") + .containsKey("jti"); + } + + @Test + public void verifyBadToken() { + Jwt badToken = jwtTokenProvider.verify("badToken"); + assertThat(badToken).isNull(); + } +}