From c85c0229c14a7be61b92e678a270fa51e5de47a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 29 Nov 2018 08:01:25 +0100 Subject: [PATCH] First steps for JWT refresh --- .../java/sonia/scm/security/AccessToken.java | 46 ++++++---- .../scm/security/AccessTokenBuilder.java | 12 ++- .../sonia/scm/security/JwtAccessToken.java | 14 ++- .../scm/security/JwtAccessTokenBuilder.java | 71 +++++++++------ .../JwtAccessTokenRefreshStrategy.java | 5 ++ .../scm/security/JwtAccessTokenRefresher.java | 53 ++++++++++++ .../security/JwtAccessTokenRefresherTest.java | 86 +++++++++++++++++++ .../resources/sonia/scm/repository/shiro.ini | 2 + 8 files changed, 241 insertions(+), 48 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java create mode 100644 scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java create mode 100644 scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java diff --git a/scm-core/src/main/java/sonia/scm/security/AccessToken.java b/scm-core/src/main/java/sonia/scm/security/AccessToken.java index 714b09eff8..ec448ad235 100644 --- a/scm-core/src/main/java/sonia/scm/security/AccessToken.java +++ b/scm-core/src/main/java/sonia/scm/security/AccessToken.java @@ -31,6 +31,7 @@ package sonia.scm.security; import java.util.Date; +import java.util.Map; import java.util.Optional; /** @@ -38,70 +39,77 @@ import java.util.Optional; * be issued from a restful webservice endpoint by providing credentials. After the token was issued, the token must be * send along with every request. The token should be send in its compact representation as bearer authorization header * or as cookie. - * + * * @author Sebastian Sdorra * @since 2.0.0 */ public interface AccessToken { - + /** * Returns unique id of the access token. - * + * * @return unique id */ String getId(); - + /** * Returns name of subject which identifies the principal. - * + * * @return name of subject */ String getSubject(); - + /** * Returns optional issuer. The issuer identifies the principal that issued the token. - * + * * @return optional issuer */ Optional getIssuer(); - + /** * Returns time at which the token was issued. - * + * * @return time at which the token was issued */ Date getIssuedAt(); - + /** * Returns the expiration time of token. - * + * * @return expiration time */ Date getExpiration(); - + + Date getRefreshExpiration(); + /** - * Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this + * Returns the scope of the token. The scope is able to reduce the permissions of the subject in the context of this * token. For example we could issue a token which can only be used to read a single repository. for more informations * please have a look at {@link Scope}. - * + * * @return scope of token. */ Scope getScope(); - + /** * Returns an optional value of a custom token field. - * + * * @param type of field * @param key key of token field - * + * * @return optional value of custom field */ Optional getCustom(String key); - + /** * Returns compact representation of token. - * + * * @return compact representation */ String compact(); + + /** + * Returns read only map of all claim keys with their values. + */ + Map getClaims(); } diff --git a/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java index dd7986c22a..5e36ba468f 100644 --- a/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java +++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenBuilder.java @@ -74,11 +74,21 @@ public interface AccessTokenBuilder { * Sets the expiration for the token. * * @param count expiration count - * @param unit expirtation unit + * @param unit expiration unit * * @return {@code this} */ AccessTokenBuilder expiresIn(long count, TimeUnit unit); + + /** + * Sets the time how long this token may be refreshed. Set this to 0 (zero) to disable automatic refresh. + * + * @param count Time unit count. If set to 0, automatic refresh is disabled. + * @param unit time unit + * + * @return {@code this} + */ + AccessTokenBuilder refreshableFor(long count, TimeUnit unit); /** * Reduces the permissions of the token by providing a scope. diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java index 46f4c68e74..35013e4fec 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessToken.java @@ -31,7 +31,10 @@ package sonia.scm.security; import io.jsonwebtoken.Claims; + +import java.util.Collections; import java.util.Date; +import java.util.Map; import java.util.Optional; /** @@ -75,6 +78,11 @@ public final class JwtAccessToken implements AccessToken { return claims.getExpiration(); } + @Override + public Date getRefreshExpiration() { + return claims.get("scm-manager.refreshableUntil", Date.class); + } + @Override public Scope getScope() { return Scopes.fromClaims(claims); @@ -90,5 +98,9 @@ public final class JwtAccessToken implements AccessToken { public String compact() { return compact; } - + + @Override + public Map getClaims() { + return Collections.unmodifiableMap(claims); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java index ece96e2954..1207b252e4 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenBuilder.java @@ -39,7 +39,6 @@ import io.jsonwebtoken.SignatureAlgorithm; import java.util.Date; import java.util.HashMap; import java.util.Map; -import java.util.Set; import java.util.concurrent.TimeUnit; import org.apache.shiro.SecurityUtils; import org.apache.shiro.subject.Subject; @@ -48,7 +47,7 @@ import org.slf4j.LoggerFactory; /** * Jwt implementation of {@link AccessTokenBuilder}. - * + * * @author Sebastian Sdorra * @since 2.0.0 */ @@ -58,18 +57,20 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { * the logger for JwtAccessTokenBuilder */ private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenBuilder.class); - - private final KeyGenerator keyGenerator; - private final SecureKeyResolver keyResolver; - + + private final KeyGenerator keyGenerator; + private final SecureKeyResolver keyResolver; + private String subject; private String issuer; - private long expiresIn = 60l; - private TimeUnit expiresInUnit = TimeUnit.MINUTES; + private long expiresIn = 1; + private TimeUnit expiresInUnit = TimeUnit.HOURS; + private long refreshableFor = 12; + private TimeUnit refreshableForUnit = TimeUnit.HOURS; private Scope scope = Scope.empty(); - + private final Map custom = Maps.newHashMap(); - + JwtAccessTokenBuilder(KeyGenerator keyGenerator, SecureKeyResolver keyResolver) { this.keyGenerator = keyGenerator; this.keyResolver = keyResolver; @@ -81,7 +82,7 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { this.subject = subject; return this; } - + @Override public JwtAccessTokenBuilder custom(String key, Object value) { Preconditions.checkArgument(!Strings.isNullOrEmpty(key), "null or empty value not allowed"); @@ -92,11 +93,11 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { @Override public JwtAccessTokenBuilder scope(Scope scope) { - Preconditions.checkArgument(scope != null, "scope can not be null"); + Preconditions.checkArgument(scope != null, "scope cannot be null"); this.scope = scope; return this; } - + @Override public JwtAccessTokenBuilder issuer(String issuer) { Preconditions.checkArgument(!Strings.isNullOrEmpty(issuer), "null or empty value not allowed"); @@ -106,15 +107,26 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { @Override public JwtAccessTokenBuilder expiresIn(long count, TimeUnit unit) { - Preconditions.checkArgument(count > 0, "expires in must be greater than 0"); - Preconditions.checkArgument(unit != null, "unit can not be null"); - + Preconditions.checkArgument(count > 0, "count must be greater than 0"); + Preconditions.checkArgument(unit != null, "unit cannot be null"); + this.expiresIn = count; this.expiresInUnit = unit; - + return this; } - + + @Override + public JwtAccessTokenBuilder refreshableFor(long count, TimeUnit unit) { + Preconditions.checkArgument(count >= 0, "count must be greater or equal to 0"); + Preconditions.checkArgument(unit != null, "unit cannot be null"); + + this.refreshableFor = count; + this.refreshableForUnit = unit; + + return this; + } + private String getSubject(){ if (subject == null) { Subject currentSubject = SecurityUtils.getSubject(); @@ -130,35 +142,40 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder { String id = keyGenerator.createKey(); String sub = getSubject(); - + LOG.trace("create new token {} for user {}", id, subject); SecureKey key = keyResolver.getSecureKey(sub); - + Map customClaims = new HashMap<>(custom); - + // add scope to custom claims Scopes.toClaims(customClaims, scope); - + Date now = new Date(); long expiration = expiresInUnit.toMillis(expiresIn); - + Claims claims = Jwts.claims(customClaims) .setSubject(sub) .setId(id) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + expiration)); - + + if (refreshableFor > 0) { + long refreshExpiration = refreshableForUnit.toMillis(refreshableFor); + claims.put("scm-manager.refreshableUntil", new Date(now.getTime() + refreshExpiration).getTime() / 1000); + } + if ( issuer != null ) { claims.setIssuer(issuer); } - + // sign token and create compact version String compact = Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS256, key.getBytes()) .compact(); - + return new JwtAccessToken(claims, compact); } - + } diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java new file mode 100644 index 0000000000..47d6a09285 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefreshStrategy.java @@ -0,0 +1,5 @@ +package sonia.scm.security; + +public interface JwtAccessTokenRefreshStrategy { + boolean shouldBeRefreshed(JwtAccessToken oldToken); +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java new file mode 100644 index 0000000000..cb26d1f010 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java @@ -0,0 +1,53 @@ +package sonia.scm.security; + +import java.time.Instant; +import java.util.Date; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +public class JwtAccessTokenRefresher { + + private final JwtAccessTokenBuilderFactory builderFactory; + private final JwtAccessTokenRefreshStrategy refreshStrategy; + + public JwtAccessTokenRefresher(JwtAccessTokenBuilderFactory builderFactory, JwtAccessTokenRefreshStrategy refreshStrategy) { + this.builderFactory = builderFactory; + this.refreshStrategy = refreshStrategy; + } + + public Optional refresh(JwtAccessToken oldToken) { + JwtAccessTokenBuilder builder = builderFactory.create(); + Map claims = oldToken.getClaims(); + claims.forEach(builder::custom); + + if (canBeRefreshed(oldToken) && shouldBeRefreshed(oldToken)) { + builder.expiresIn(1, TimeUnit.HOURS); +// builder.custom("scm-manager.parentTokenId") + return Optional.of(builder.build()); + } else { + return Optional.empty(); + } + } + + private boolean canBeRefreshed(JwtAccessToken oldToken) { + return tokenIsValid(oldToken) || tokenCanBeRefreshed(oldToken); + } + + private boolean shouldBeRefreshed(JwtAccessToken oldToken) { + return refreshStrategy.shouldBeRefreshed(oldToken); + } + + private boolean tokenCanBeRefreshed(JwtAccessToken oldToken) { + Date refreshExpiration = oldToken.getRefreshExpiration(); + return refreshExpiration != null && isBeforeNow(refreshExpiration); + } + + private boolean tokenIsValid(JwtAccessToken oldToken) { + return isBeforeNow(oldToken.getExpiration()); + } + + private boolean isBeforeNow(Date expiration) { + return expiration.toInstant().isBefore(Instant.now()); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java new file mode 100644 index 0000000000..1464bfeade --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java @@ -0,0 +1,86 @@ +package sonia.scm.security; + +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import org.assertj.core.api.Assertions; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Collections; +import java.util.Optional; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@SubjectAware( + username = "user", + password = "secret", + configuration = "classpath:sonia/scm/repository/shiro.ini" +) +@RunWith(MockitoJUnitRunner.class) +public class JwtAccessTokenRefresherTest { + + @Rule + public ShiroRule shiro = new ShiroRule(); + + @Mock + private SecureKeyResolver keyResolver; + @Mock + private JwtAccessTokenRefreshStrategy refreshStrategy; + private JwtAccessTokenBuilderFactory builderFactory; + private JwtAccessTokenRefresher refresher; + private JwtAccessTokenBuilder tokenBuilder; + + @Before + public void initKeyResolver() { + byte[] bytes = new byte[256]; + new Random().nextBytes(bytes); + SecureKey secureKey = new SecureKey(bytes, System.currentTimeMillis()); + when(keyResolver.getSecureKey(any())).thenReturn(secureKey); + + builderFactory = new JwtAccessTokenBuilderFactory(new DefaultKeyGenerator(), keyResolver, Collections.emptySet()); + refresher = new JwtAccessTokenRefresher(builderFactory, refreshStrategy); + tokenBuilder = builderFactory.create(); + } + + @Test + public void shouldNotRefreshTokenWithDisabledRefresh() { + JwtAccessToken oldToken = tokenBuilder + .refreshableFor(0, TimeUnit.MINUTES) + .build(); + + Optional refreshedToken = refresher.refresh(oldToken); + + Assertions.assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldNotRefreshTokenWhenStrategyDoesNotSaySo() { + JwtAccessToken oldToken = tokenBuilder + .refreshableFor(10, TimeUnit.MINUTES) + .build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(false); + + Optional refreshedToken = refresher.refresh(oldToken); + + Assertions.assertThat(refreshedToken).isEmpty(); + } + + @Test + public void shouldRefreshTokenWithEnabledRefresh() { + JwtAccessToken oldToken = tokenBuilder + .refreshableFor(1, TimeUnit.MINUTES) + .build(); + when(refreshStrategy.shouldBeRefreshed(oldToken)).thenReturn(true); + + Optional refreshedToken = refresher.refresh(oldToken); + + Assertions.assertThat(refreshedToken).isNotEmpty(); + } +} diff --git a/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini b/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini index 9a39a2d46c..500325faf3 100644 --- a/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini +++ b/scm-webapp/src/test/resources/sonia/scm/repository/shiro.ini @@ -4,6 +4,7 @@ dent = secret, creator, heartOfGold, puzzle42 unpriv = secret crato = secret, creator community = secret, oss +user = secret, user [roles] admin = * @@ -11,3 +12,4 @@ creator = repository:create heartOfGold = "repository:read,modify,delete:hof" puzzle42 = "repository:read,write:p42" oss = "repository:pull" +user = *