Skip to content

Commit

Permalink
Merge pull request #432 from longwa/develop
Browse files Browse the repository at this point in the history
Issue #431, merge changes from 2.x branch
  • Loading branch information
alvarosanchez authored Apr 6, 2020
2 parents 3346f53 + acddc17 commit a266d18
Show file tree
Hide file tree
Showing 11 changed files with 192 additions and 62 deletions.
13 changes: 10 additions & 3 deletions spring-security-rest-docs/src/docs/asciidoc/tokenStorage.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ the algorithm used is HMAC SHA-256 with a specified shared secret. The relevant

|`grails.plugin.springsecurity.rest.token.storage.jwt.expiration`
|`3600`

|`grails.plugin.springsecurity.rest.token.storage.jwt.refreshExpiration`
|`(No Expiration)`
|===

==== Encrypted JWT's
Expand Down Expand Up @@ -177,8 +180,12 @@ When using JWT, issued access tokens expire after a period of time, and they are
}
----

Refresh tokens never expire, and can be used to obtain a new access token by sending a POST request to the
`/oauth/access_token` endpoint:
Refresh tokens never expire, by default, and can be used to obtain a new access token by sending a POST request to the
`/oauth/access_token` endpoint.

If you prefer to configure your refresh tokens to expire automatically, you can set
`grails.plugin.springsecurity.rest.token.storage.jwt.refreshExpiration` to the number of seconds before the token
is invalid. After this period, a client would be forced to present login credentials in order to obtain a new access token.

[source,javascript]
.Listing {counter:listing}. Sample HTTP request to obtain an access token
Expand All @@ -197,7 +204,7 @@ As you can see, is a form request with 2 parameters:

[NOTE]
====
As refresh tokens never expire, they must be securely stored in your client application. See
By default, refresh tokens never expire and must be securely stored in your client application. See
https://tools.ietf.org/html/rfc6749#section-10.4[section 10.4 of the OAuth 2.0 spec] for more information.
====

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,13 @@ class MemcachedSpec extends AbstractRestSpec {
@Shared
MemcachedTokenStorageService memcachedTokenStorageService

@Shared
Integer originalExpiration

@Autowired
void setTokenStorageService(MemcachedTokenStorageService tokenStorageService) {
this.memcachedTokenStorageService = tokenStorageService
originalExpiration = memcachedTokenStorageService.expiration
}

void cleanupSpec() {
memcachedTokenStorageService.expiration = originalExpiration
memcachedTokenStorageService.expiration = 3600
}

@Unroll
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,13 @@ class MemcachedSpec extends AbstractRestSpec {
@Shared
MemcachedTokenStorageService memcachedTokenStorageService

@Shared
Integer originalExpiration

@Autowired
void setTokenStorageService(MemcachedTokenStorageService tokenStorageService) {
this.memcachedTokenStorageService = tokenStorageService
originalExpiration = memcachedTokenStorageService.expiration
}

void cleanupSpec() {
memcachedTokenStorageService.expiration = originalExpiration
memcachedTokenStorageService.expiration = 3600
}

@Unroll
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ package grails.plugin.springsecurity.rest

import com.nimbusds.jwt.JWT
import grails.plugin.springsecurity.rest.token.AccessToken
import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator
import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException
import grails.plugin.springsecurity.rest.token.storage.TokenStorageService
import groovy.time.TimeCategory
import groovy.time.TimeDuration
Expand Down Expand Up @@ -61,8 +63,13 @@ class RestAuthenticationProvider implements AuthenticationProvider {
if (useJwt) {
Date now = new Date()
jwt = jwtService.parse(authenticationRequest.accessToken)
Date expiry = jwt.JWTClaimsSet.expirationTime

// Prevent refresh tokens from being used for authentication
if (jwt.JWTClaimsSet.getBooleanClaim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM)) {
throw new TokenNotFoundException("Token ${authenticationRequest.accessToken} is not valid")
}

Date expiry = jwt.JWTClaimsSet.expirationTime
if (expiry) {
log.debug "Now is ${now} and token expires at ${expiry}"

Expand All @@ -73,7 +80,7 @@ class RestAuthenticationProvider implements AuthenticationProvider {
}

authenticationResult = new AccessToken(userDetails, userDetails.authorities, authenticationRequest.accessToken, null, expiration, jwt, null)
log.debug "Authentication result: ${authenticationResult}"
log.debug "Authentication result: {}", authenticationResult
}

return authenticationResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ class SpringSecurityRestGrailsPlugin extends Plugin {
jwtTokenStorageService = ref('tokenStorageService')
keyProvider = ref('keyProvider')
defaultExpiration = conf.rest.token.storage.jwt.expiration
defaultRefreshExpiration = conf.rest.token.storage.jwt.refreshExpiration
jweAlgorithm = JWEAlgorithm.parse(conf.rest.token.generation.jwt.jweAlgorithm)
encryptionMethod = EncryptionMethod.parse(conf.rest.token.generation.jwt.encryptionMethod)
}
Expand All @@ -262,6 +263,7 @@ class SpringSecurityRestGrailsPlugin extends Plugin {
jwtTokenStorageService = ref('tokenStorageService')
jwtSecret = jwtSecretValue
defaultExpiration = conf.rest.token.storage.jwt.expiration
defaultRefreshExpiration = conf.rest.token.storage.jwt.refreshExpiration
jwsAlgorithm = JWSAlgorithm.parse(conf.rest.token.generation.jwt.algorithm)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import org.springframework.security.core.userdetails.UserDetails
@Slf4j
@CompileStatic
abstract class AbstractJwtTokenGenerator implements TokenGenerator {
static String REFRESH_ONLY_CLAIM = "refresh_only"

Integer defaultExpiration
Integer defaultRefreshExpiration

JwtTokenStorageService jwtTokenStorageService

Expand All @@ -49,7 +51,7 @@ abstract class AbstractJwtTokenGenerator implements TokenGenerator {
generateAccessToken(details, true, expiration)
}

AccessToken generateAccessToken(UserDetails details, boolean withRefreshToken, Integer expiration = this.defaultExpiration) {
AccessToken generateAccessToken(UserDetails details, boolean withRefreshToken, Integer expiration = this.defaultExpiration, Integer refreshExpiration = this.defaultRefreshExpiration) {
log.debug "Serializing the principal received"
String serializedPrincipal = serializePrincipal(details)

Expand All @@ -59,11 +61,11 @@ abstract class AbstractJwtTokenGenerator implements TokenGenerator {
JWT accessTokenJwt = generateAccessToken(builder.build())
String accessToken = accessTokenJwt.serialize()

JWT refreshTokenJwt
String refreshToken
JWT refreshTokenJwt = null
String refreshToken = null
if (withRefreshToken) {
log.debug "Generating refresh token..."
refreshTokenJwt = generateRefreshToken(details, serializedPrincipal, expiration)
refreshTokenJwt = generateRefreshToken(details, serializedPrincipal, refreshExpiration)
refreshToken = refreshTokenJwt.serialize()
}

Expand Down Expand Up @@ -92,7 +94,7 @@ abstract class AbstractJwtTokenGenerator implements TokenGenerator {
customClaimProvider.provideCustomClaims(builder, details, serializedPrincipal, expiration)
}

log.debug "Generated claim set: ${builder.build().toJSONObject().toString()}"
log.debug "Generated claim set: {}", builder.build().toJSONObject().toString()
return builder
}

Expand All @@ -110,7 +112,9 @@ abstract class AbstractJwtTokenGenerator implements TokenGenerator {

protected JWT generateRefreshToken(UserDetails principal, String serializedPrincipal, Integer expiration) {
JWTClaimsSet.Builder builder = generateClaims(principal, serializedPrincipal, expiration)
builder.expirationTime(null)

// Flag this token as a refresh token
builder.claim(REFRESH_ONLY_CLAIM, true)

return generateAccessToken(builder.build())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package grails.plugin.springsecurity.rest.token.storage.jwt
import com.nimbusds.jose.JOSEException
import com.nimbusds.jwt.JWT
import grails.plugin.springsecurity.rest.JwtService
import grails.plugin.springsecurity.rest.token.generation.jwt.AbstractJwtTokenGenerator
import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException
import grails.plugin.springsecurity.rest.token.storage.TokenStorageService
import groovy.transform.CompileStatic
Expand All @@ -39,7 +40,7 @@ class JwtTokenStorageService implements TokenStorageService {

JwtService jwtService
UserDetailsService userDetailsService

@Override
UserDetails loadUserByToken(String tokenValue) throws TokenNotFoundException {
Date now = new Date()
Expand All @@ -49,52 +50,68 @@ class JwtTokenStorageService implements TokenStorageService {
if (jwt.JWTClaimsSet.expirationTime?.before(now)) {
throw new TokenNotFoundException("Token ${tokenValue} has expired")
}
boolean isRefreshToken = jwt.JWTClaimsSet.expirationTime == null

if(isRefreshToken){
UserDetails principal = userDetailsService.loadUserByUsername(jwt.JWTClaimsSet.subject)

if(!principal){
throw new TokenNotFoundException("Token no longer valid, principal not found")
}
if(!principal.enabled){
throw new TokenNotFoundException("Token no longer valid, account disabled")
}
if(!principal.accountNonExpired){
throw new TokenNotFoundException("Token no longer valid, account expired")
}
if(!principal.accountNonLocked){
throw new TokenNotFoundException("Token no longer valid, account locked")
}
if(!principal.credentialsNonExpired){
throw new TokenNotFoundException("Token no longer valid, credentials expired")
}

return principal

boolean isRefresh = jwt.JWTClaimsSet.getBooleanClaim(AbstractJwtTokenGenerator.REFRESH_ONLY_CLAIM) || jwt.JWTClaimsSet.expirationTime == null
if(isRefresh) {
return loadUserFromRefreshToken(jwt)
}

def roles = jwt.JWTClaimsSet.getStringArrayClaim('roles')?.collect { String role -> new SimpleGrantedAuthority(role) }
return loadUserFromAccessToken(jwt)

log.debug "Successfully verified JWT"
} catch (ParseException ignored) {
throw new TokenNotFoundException("Token ${tokenValue} is not valid")
} catch (JOSEException ignored) {
throw new TokenNotFoundException("Token ${tokenValue} has an invalid signature")
}
}

log.debug "Trying to deserialize the principal object"
try {
UserDetails details = JwtService.deserialize(jwt.JWTClaimsSet.getStringClaim('principal'))
log.debug "UserDetails deserialized: ${details}"
if (details) {
return details
}
} catch (exception) {
log.debug(exception.message)
/**
* Load user details for an access token
*/
protected UserDetails loadUserFromAccessToken(JWT jwt) {
log.debug "Verified JWT, trying to deserialize the principal object"
try {
UserDetails details = JwtService.deserialize(jwt.JWTClaimsSet.getStringClaim('principal'))
log.debug "UserDetails deserialized: {}", details
if (details) {
return details
}
} catch (exception) {
log.debug(exception.message)
}

log.debug "Returning a org.springframework.security.core.userdetails.User instance"
return new User(jwt.JWTClaimsSet.subject, 'N/A', roles)
} catch (ParseException pe) {
throw new TokenNotFoundException("Token ${tokenValue} is not valid")
} catch (JOSEException je) {
throw new TokenNotFoundException("Token ${tokenValue} has an invalid signature")
log.debug "Returning a org.springframework.security.core.userdetails.User instance"

List<SimpleGrantedAuthority> roles = jwt.JWTClaimsSet.getStringArrayClaim('roles')?.collect { String role -> new SimpleGrantedAuthority(role) }
return new User(jwt.JWTClaimsSet.subject, 'N/A', roles)
}

/**
* Load user details for a refresh token
*
* @param jwt the refresh token
* @return
*/
protected UserDetails loadUserFromRefreshToken(JWT jwt) {
UserDetails principal = userDetailsService.loadUserByUsername(jwt.JWTClaimsSet.subject)

if(!principal){
throw new TokenNotFoundException("Token no longer valid, principal not found")
}
if(!principal.enabled){
throw new TokenNotFoundException("Token no longer valid, account disabled")
}
if(!principal.accountNonExpired){
throw new TokenNotFoundException("Token no longer valid, account expired")
}
if(!principal.accountNonLocked){
throw new TokenNotFoundException("Token no longer valid, account locked")
}
if(!principal.credentialsNonExpired){
throw new TokenNotFoundException("Token no longer valid, credentials expired")
}

return principal
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ security {

secret = null
expiration = 3600
refreshExpiration = null
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package grails.plugin.springsecurity.rest

import grails.plugin.springsecurity.rest.token.AccessToken
import grails.plugin.springsecurity.rest.token.generation.jwt.SignedJwtTokenGenerator
import grails.plugin.springsecurity.rest.token.storage.TokenNotFoundException
import grails.plugin.springsecurity.rest.token.storage.jwt.JwtTokenStorageService
import org.springframework.security.core.Authentication
import org.springframework.security.core.userdetails.User
Expand Down Expand Up @@ -40,4 +41,18 @@ class RestAuthenticationProviderSpec extends Specification implements TokenGener
then:
result.authenticated
}

@Issue("https://github.com/alvarosanchez/grails-spring-security-rest/issues/391")
void "refresh tokens should not be usable for authentication"() {
given:
AccessToken accessToken = tokenGenerator.generateAccessToken(new User('testUser', 'testPassword', []), 0)
accessToken.accessToken = accessToken.refreshToken

when:
this.restAuthenticationProvider.authenticate(accessToken)

then:
def e = thrown(TokenNotFoundException)
e.message =~ /Token .* is not valid/
}
}
Loading

0 comments on commit a266d18

Please sign in to comment.