First steps for JWT refresh

This commit is contained in:
René Pfeuffer
2018-11-29 08:01:25 +01:00
parent 0664303854
commit c85c0229c1
8 changed files with 241 additions and 48 deletions

View File

@@ -31,6 +31,7 @@
package sonia.scm.security; package sonia.scm.security;
import java.util.Date; import java.util.Date;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -79,6 +80,8 @@ public interface AccessToken {
*/ */
Date getExpiration(); 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 * token. For example we could issue a token which can only be used to read a single repository. for more informations
@@ -104,4 +107,9 @@ public interface AccessToken {
* @return compact representation * @return compact representation
*/ */
String compact(); String compact();
/**
* Returns read only map of all claim keys with their values.
*/
Map<String, Object> getClaims();
} }

View File

@@ -74,12 +74,22 @@ public interface AccessTokenBuilder {
* Sets the expiration for the token. * Sets the expiration for the token.
* *
* @param count expiration count * @param count expiration count
* @param unit expirtation unit * @param unit expiration unit
* *
* @return {@code this} * @return {@code this}
*/ */
AccessTokenBuilder expiresIn(long count, TimeUnit unit); 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. * Reduces the permissions of the token by providing a scope.
* *

View File

@@ -31,7 +31,10 @@
package sonia.scm.security; package sonia.scm.security;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -75,6 +78,11 @@ public final class JwtAccessToken implements AccessToken {
return claims.getExpiration(); return claims.getExpiration();
} }
@Override
public Date getRefreshExpiration() {
return claims.get("scm-manager.refreshableUntil", Date.class);
}
@Override @Override
public Scope getScope() { public Scope getScope() {
return Scopes.fromClaims(claims); return Scopes.fromClaims(claims);
@@ -91,4 +99,8 @@ public final class JwtAccessToken implements AccessToken {
return compact; return compact;
} }
@Override
public Map<String, Object> getClaims() {
return Collections.unmodifiableMap(claims);
}
} }

View File

@@ -39,7 +39,6 @@ import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date; import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import org.apache.shiro.SecurityUtils; import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
@@ -64,8 +63,10 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
private String subject; private String subject;
private String issuer; private String issuer;
private long expiresIn = 60l; private long expiresIn = 1;
private TimeUnit expiresInUnit = TimeUnit.MINUTES; private TimeUnit expiresInUnit = TimeUnit.HOURS;
private long refreshableFor = 12;
private TimeUnit refreshableForUnit = TimeUnit.HOURS;
private Scope scope = Scope.empty(); private Scope scope = Scope.empty();
private final Map<String,Object> custom = Maps.newHashMap(); private final Map<String,Object> custom = Maps.newHashMap();
@@ -92,7 +93,7 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
@Override @Override
public JwtAccessTokenBuilder scope(Scope scope) { 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; this.scope = scope;
return this; return this;
} }
@@ -106,8 +107,8 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
@Override @Override
public JwtAccessTokenBuilder expiresIn(long count, TimeUnit unit) { public JwtAccessTokenBuilder expiresIn(long count, TimeUnit unit) {
Preconditions.checkArgument(count > 0, "expires in must be greater than 0"); Preconditions.checkArgument(count > 0, "count must be greater than 0");
Preconditions.checkArgument(unit != null, "unit can not be null"); Preconditions.checkArgument(unit != null, "unit cannot be null");
this.expiresIn = count; this.expiresIn = count;
this.expiresInUnit = unit; this.expiresInUnit = unit;
@@ -115,6 +116,17 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
return this; 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(){ private String getSubject(){
if (subject == null) { if (subject == null) {
Subject currentSubject = SecurityUtils.getSubject(); Subject currentSubject = SecurityUtils.getSubject();
@@ -148,6 +160,11 @@ public final class JwtAccessTokenBuilder implements AccessTokenBuilder {
.setIssuedAt(now) .setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expiration)); .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 ) { if ( issuer != null ) {
claims.setIssuer(issuer); claims.setIssuer(issuer);
} }

View File

@@ -0,0 +1,5 @@
package sonia.scm.security;
public interface JwtAccessTokenRefreshStrategy {
boolean shouldBeRefreshed(JwtAccessToken oldToken);
}

View File

@@ -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<JwtAccessToken> refresh(JwtAccessToken oldToken) {
JwtAccessTokenBuilder builder = builderFactory.create();
Map<String, Object> 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());
}
}

View File

@@ -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<JwtAccessToken> 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<JwtAccessToken> 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<JwtAccessToken> refreshedToken = refresher.refresh(oldToken);
Assertions.assertThat(refreshedToken).isNotEmpty();
}
}

View File

@@ -4,6 +4,7 @@ dent = secret, creator, heartOfGold, puzzle42
unpriv = secret unpriv = secret
crato = secret, creator crato = secret, creator
community = secret, oss community = secret, oss
user = secret, user
[roles] [roles]
admin = * admin = *
@@ -11,3 +12,4 @@ creator = repository:create
heartOfGold = "repository:read,modify,delete:hof" heartOfGold = "repository:read,modify,delete:hof"
puzzle42 = "repository:read,write:p42" puzzle42 = "repository:read,write:p42"
oss = "repository:pull" oss = "repository:pull"
user = *