Skip to content

Commit

Permalink
Merge pull request #289 from AuthGuard/feature/refresh-token-details
Browse files Browse the repository at this point in the history
Add access/refresh token details and verify on refresh
  • Loading branch information
kmehrunes committed Oct 14, 2023
2 parents 53a4ec2 + 055f81b commit 99ed4cb
Show file tree
Hide file tree
Showing 9 changed files with 253 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public class AccountTokenDO extends AbstractDO {
private String token;
private String associatedAccountId;
private Instant expiresAt;
private String sourceAuthType;
private String deviceId;
private String clientId;
private String externalSessionId;
private String userAgent;
private String sourceIp;

@ElementCollection(fetch = FetchType.EAGER)
@MapKeyColumn(name="key")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public AuthResponseBO generateToken(final AccountBO account, final TokenRestrict
final String finalToken = encryptIfNeeded(signedToken);
final String refreshToken = jwtGenerator.generateRandomRefreshToken();

final AccountTokenDO persisted = storeRefreshToken(account.getId(), refreshToken, restrictions);
final AccountTokenDO persisted = storeRefreshToken(account.getId(), refreshToken, restrictions, options);

LOG.info("Generated refresh token. accountId={}, domain={}, tokenId={}, expiresAt={}",
account.getId(), account.getDomain(), persisted.getId(), persisted.getExpiresAt());
Expand Down Expand Up @@ -146,17 +146,27 @@ public AuthResponseBO delete(final AuthRequestBO authRequest) {
.orElseThrow(() -> new ServiceAuthorizationException(ErrorCode.INVALID_TOKEN, "Invalid refresh token"));
}

private AccountTokenDO storeRefreshToken(final String accountId, final String refreshToken, final TokenRestrictionsBO tokenRestrictions) {
final AccountTokenDO accountToken = AccountTokenDO.builder()
private AccountTokenDO storeRefreshToken(final String accountId, final String refreshToken,
final TokenRestrictionsBO tokenRestrictions,
final TokenOptions tokenOptions) {
final AccountTokenDO.AccountTokenDOBuilder<?, ?> accountToken = AccountTokenDO.builder()
.id(ID.generate())
.createdAt(Instant.now())
.token(refreshToken)
.associatedAccountId(accountId)
.expiresAt(refreshTokenExpiry())
.tokenRestrictions(serviceMapper.toDO(tokenRestrictions)) // Mapstruct already checks for null
.build();
.tokenRestrictions(serviceMapper.toDO(tokenRestrictions)); // Mapstruct already checks for null

if (tokenOptions != null) {
accountToken.sourceIp(tokenOptions.getSourceIp())
.clientId(tokenOptions.getClientId())
.deviceId(tokenOptions.getDeviceId())
.sourceAuthType(tokenOptions.getSource())
.externalSessionId(tokenOptions.getExternalSessionId())
.userAgent(tokenOptions.getUserAgent());
}

return accountTokensRepository.save(accountToken)
return accountTokensRepository.save(accountToken.build())
.join();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public BasicToAccessToken(final BasicAuthProvider basicAuth, final AccessTokenPr
public Either<Exception, AuthResponseBO> exchange(final AuthRequestBO request) {
final TokenOptionsBO options = TokenOptionsBO.builder()
.source("basic")
.userAgent(request.getUserAgent())
.sourceIp(request.getSourceIp())
.clientId(request.getClientId())
.externalSessionId(request.getExternalSessionId())
.deviceId(request.getDeviceId())
.build();

return basicAuth.authenticateAndGetAccount(request)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,19 @@ public Either<Exception, AuthResponseBO> exchange(final AuthRequestBO request) {
return otpVerifier.verifyAccountToken(request.getToken())
.map(accountsService::getById)
.flatMap(accountOpt -> accountOpt
.map(this::generate)
.map(account -> generate(account, request))
.orElseGet(() -> Either.left(new ServiceAuthorizationException(ErrorCode.GENERIC_AUTH_FAILURE,
"Failed to generate access token"))));
}

private Either<Exception, AuthResponseBO> generate(final AccountBO account) {
private Either<Exception, AuthResponseBO> generate(final AccountBO account, final AuthRequestBO request) {
final TokenOptionsBO options = TokenOptionsBO.builder()
.source("otp")
.userAgent(request.getUserAgent())
.sourceIp(request.getSourceIp())
.clientId(request.getClientId())
.externalSessionId(request.getExternalSessionId())
.deviceId(request.getDeviceId())
.build();

return Either.right(accessTokenProvider.generateToken(account, options));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,19 @@ public Either<Exception, AuthResponseBO> exchange(final AuthRequestBO request) {
return passwordlessVerifier.verifyAccountToken(request.getToken())
.map(accountsService::getById)
.flatMap(accountOpt -> accountOpt
.map(this::generate)
.map(account -> generate(account, request))
.orElseGet(() -> Either.left(new ServiceAuthorizationException(ErrorCode.GENERIC_AUTH_FAILURE,
"Failed to generate access token"))));
}

private Either<Exception, AuthResponseBO> generate(final AccountBO account) {
private Either<Exception, AuthResponseBO> generate(final AccountBO account, final AuthRequestBO request) {
final TokenOptionsBO options = TokenOptionsBO.builder()
.source("passwordless")
.userAgent(request.getUserAgent())
.sourceIp(request.getSourceIp())
.clientId(request.getClientId())
.externalSessionId(request.getExternalSessionId())
.deviceId(request.getDeviceId())
.build();

return Either.right(accessTokenProvider.generateToken(account, options));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.nexblocks.authguard.jwt.exchange;

import com.google.inject.Inject;
import com.google.inject.name.Named;
import com.nexblocks.authguard.config.ConfigContext;
import com.nexblocks.authguard.dal.cache.AccountTokensRepository;
import com.nexblocks.authguard.dal.model.AccountTokenDO;
import com.nexblocks.authguard.jwt.AccessTokenProvider;
import com.nexblocks.authguard.service.AccountsService;
import com.nexblocks.authguard.service.config.JwtConfig;
import com.nexblocks.authguard.service.exceptions.ServiceAuthorizationException;
import com.nexblocks.authguard.service.exceptions.codes.ErrorCode;
import com.nexblocks.authguard.service.exchange.Exchange;
Expand All @@ -16,6 +19,7 @@
import org.slf4j.LoggerFactory;

import java.time.Instant;
import java.util.Objects;

@TokenExchange(from = "refresh", to = "accessToken")
public class RefreshToAccessToken implements Exchange {
Expand All @@ -24,48 +28,71 @@ public class RefreshToAccessToken implements Exchange {
private final AccountTokensRepository accountTokensRepository;
private final AccountsService accountsService;
private final AccessTokenProvider accessTokenProvider;
private final JwtConfig jwtConfig;
private final ServiceMapper serviceMapper;

@Inject
public RefreshToAccessToken(final AccountTokensRepository accountTokensRepository,
final AccountsService accountsService,
final AccessTokenProvider accessTokenProvider,
final @Named("jwt") ConfigContext jwtConfigContext,
final ServiceMapper serviceMapper) {
this(accountTokensRepository, accountsService, accessTokenProvider,
jwtConfigContext.asConfigBean(JwtConfig.class), serviceMapper);
}

public RefreshToAccessToken(final AccountTokensRepository accountTokensRepository,
final AccountsService accountsService,
final AccessTokenProvider accessTokenProvider,
final JwtConfig jwtConfig,
final ServiceMapper serviceMapper) {
this.accountTokensRepository = accountTokensRepository;
this.accountsService = accountsService;
this.accessTokenProvider = accessTokenProvider;
this.jwtConfig = jwtConfig;
this.serviceMapper = serviceMapper;
}

@Override
public Either<Exception, AuthResponseBO> exchange(final AuthRequestBO request) {
return accountTokensRepository.getByToken(request.getToken())
.join()
.map(this::generateAndClear)
.map(accountToken -> this.generateAndClear(accountToken, request))
.orElseGet(() -> Either.left(new ServiceAuthorizationException(ErrorCode.INVALID_TOKEN, "Invalid refresh token")));
}

private Either<Exception, AuthResponseBO> generateAndClear(final AccountTokenDO accountToken) {
return generate(accountToken)
private Either<Exception, AuthResponseBO> generateAndClear(final AccountTokenDO accountToken,
final AuthRequest request) {
return generate(accountToken, request)
.peek(response -> deleteRefreshToken(accountToken));
}

private Either<Exception, AuthResponseBO> generate(final AccountTokenDO accountToken) {
private Either<Exception, AuthResponseBO> generate(final AccountTokenDO accountToken,
final AuthRequest authRequest) {
if (!validateExpirationDateTime(accountToken)) {
final ServiceAuthorizationException error = new ServiceAuthorizationException(ErrorCode.EXPIRED_TOKEN, "Refresh token has expired",
EntityType.ACCOUNT, accountToken.getAssociatedAccountId());
ServiceAuthorizationException error =
new ServiceAuthorizationException(ErrorCode.EXPIRED_TOKEN, "Refresh token has expired",
EntityType.ACCOUNT, accountToken.getAssociatedAccountId());

deleteRefreshToken(accountToken);

return Either.left(error);
}

if (!validateTokenValues(accountToken, authRequest)) {
ServiceAuthorizationException error =
new ServiceAuthorizationException(ErrorCode.EXPIRED_TOKEN, "Refresh token has expired",
EntityType.ACCOUNT, accountToken.getAssociatedAccountId());

return Either.left(error);
}

return generateNewTokens(accountToken);
}

private Either<Exception, AuthResponseBO> generateNewTokens(final AccountTokenDO accountToken) {
final String accountId = accountToken.getAssociatedAccountId();
final TokenRestrictionsBO tokenRestrictions = serviceMapper.toBO(accountToken.getTokenRestrictions());
String accountId = accountToken.getAssociatedAccountId();
TokenRestrictionsBO tokenRestrictions = serviceMapper.toBO(accountToken.getTokenRestrictions());

return getAccount(accountId, accountToken).map(account -> accessTokenProvider.generateToken(account, tokenRestrictions));
}
Expand All @@ -82,11 +109,39 @@ private Either<Exception, AccountBO> getAccount(final String accountId, final Ac
}

private boolean validateExpirationDateTime(final AccountTokenDO accountToken) {
final Instant now = Instant.now();
Instant now = Instant.now();

return now.isBefore(accountToken.getExpiresAt());
}

private boolean validateTokenValues(final AccountTokenDO accountToken, AuthRequest authRequest) {
if (jwtConfig.checkRefreshTokenOption()) {
if (!Objects.equals(accountToken.getClientId(), authRequest.getClientId())) {
return false;
}

if (!Objects.equals(accountToken.getDeviceId(), authRequest.getDeviceId())) {
return false;
}

if (!Objects.equals(accountToken.getUserAgent(), authRequest.getUserAgent())) {
return false;
}

if (!Objects.equals(accountToken.getExternalSessionId(), authRequest.getExternalSessionId())) {
return false;
}
}

if (jwtConfig.checkRefreshTokenRequestIp()) {
if (!Objects.equals(accountToken.getSourceIp(), authRequest.getSourceIp())) {
return false;
}
}

return true;
}

private void deleteRefreshToken(final AccountTokenDO accountToken) {
LOG.info("Deleting old refresh token. tokenId={}, accountId={}",
accountToken.getId(), accountToken.getAssociatedAccountId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import org.jeasy.random.EasyRandomParameters;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.TestMethodOrder;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
Expand Down Expand Up @@ -193,6 +192,55 @@ void generateWithRestrictions() {
verifyToken(tokens.getToken().toString(), account.getId(), null, Collections.singletonList("permission-1"));
}

@Test
void generateWithOptions() {
final AccessTokenProvider accessTokenProvider = newProviderInstance(jwtConfig(), strategyConfig());

final AccountBO account = RANDOM.nextObject(AccountBO.class)
.withActive(true)
.withPermissions(Arrays.asList(
PermissionBO.builder().group("super").name("permission-1").build(),
PermissionBO.builder().group("super").name("permission-2").build())
);

final TokenOptionsBO tokenOptions = TokenOptionsBO.builder()
.clientId("client-1")
.source("basic")
.deviceId("device-1")
.sourceIp("127.0.0.1")
.externalSessionId("session-1")
.userAgent("test")
.build();

final AuthResponseBO tokens = accessTokenProvider.generateToken(account, tokenOptions);

assertThat(tokens).isNotNull();
assertThat(tokens.getToken()).isNotNull();
assertThat(tokens.getRefreshToken()).isNotNull();
assertThat(tokens.getToken()).isNotEqualTo(tokens.getRefreshToken());

final ArgumentCaptor<AccountTokenDO> accountTokenCaptor = ArgumentCaptor.forClass(AccountTokenDO.class);
final AccountTokenDO expectedRefreshToken = AccountTokenDO.builder()
.associatedAccountId(account.getId())
.clientId("client-1")
.sourceAuthType("basic")
.deviceId("device-1")
.sourceIp("127.0.0.1")
.externalSessionId("session-1")
.userAgent("test")
.build();

Mockito.verify(accountTokensRepository).save(accountTokenCaptor.capture());

assertThat(accountTokenCaptor.getValue())
.isEqualToIgnoringGivenFields(expectedRefreshToken, "id", "createdAt", "lastModifiedAt",
"token", "expiresAt");

assertThat(accountTokenCaptor.getValue().getToken()).isEqualTo(tokens.getRefreshToken());
assertThat(accountTokenCaptor.getValue().getExpiresAt()).isNotNull()
.isAfter(Instant.now());
}

@Test
void generateWithJti() {
final AccessTokenProvider accessTokenProvider = newProviderInstance(jwtConfig(), strategyConfigWithJti());
Expand Down

0 comments on commit 99ed4cb

Please sign in to comment.