Skip to content

Commit

Permalink
Feature/75 refresh token simple extend (#78)
Browse files Browse the repository at this point in the history
* #75 introduces access (payload) & refresh (cookie) tokens
  • Loading branch information
dk1844 authored Nov 9, 2023
1 parent dc4b034 commit f7d833e
Show file tree
Hide file tree
Showing 24 changed files with 834 additions and 205 deletions.
61 changes: 50 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,49 @@
# login-service
AbsaOSS Common Login service using JWT Public key signatures

## Basic usecase schematics
## Basic use-case schematics
![login-gw-basic-usecase2](https://user-images.githubusercontent.com/4457378/219037599-5674b63b-403c-4c02-8a54-a6e12dc01d47.png)

### Usage & Integration
To interact with the service, most notable endpoints are
- `/token/generate` to generate access & refresh tokens
- `/token/refresh` to obtain a new access token with a still-valid refresh token
- `/token/public-key` to obtain public key to verify tokens including their validity window

Please, refer to the [API documentation](#api-documentation) below for details of the endpoints.

#### Generate tokens
Once you request your token at `/token/generate` endpoint, you will receive both an access token (in body)
```json
{
"token": "..."
}
```
and a refresh token (in Cookie named `refresh`).

Both tokens are signed by LS public key and carry the username (`sub`), `type` (`access`/`refresh`) and creation/expiry info (`iat`/`exp`).

#### Refresh access token
During the time the refresh token is valid, you may refresh the access token (expired or not) using the `/token/refresh`
endpoint - as the service does not facilitate any internal service access to LDAP, both tokens must be sent.

#### Validate access token
On the side of the integrator, in order to trust the access token, one should do the following actions:
1. obtain the public-key from LS at `/token/public-key`
2. verify that the access token
1. is valid against this public-key (e.g. using `jwtt` library or similar)
2. is not expired
3. has `type=access`


## API documentation:
Swagger doc site is available at `http://localhost:port/swagger-ui.html`
(substitute `http://localhost:port` with any possible host and port you have deployed your package to.)
### Need the OpenAPI 3 yaml file?
It is available for download while running the service at `http://localhost:port/v3/api-docs.yaml` -
gets generated from code (specifically from Spring annotations)


## Configuration
The project requires a valid configuration file to run.
An [example configuration](https://github.com/AbsaOSS/login-service/blob/master/service/src/main/resources/example.application.yaml)
Expand Down Expand Up @@ -49,12 +89,7 @@ sbt
service / Tomcat / start
```

## API documentation:
Swagger doc site is available at `http://localhost:port/swagger-ui.html`
(substitute `http://localhost:port` with any possible host and port you have deployed your package to.)
### Need the OpenAPI 3 yaml file?
It is available for download while running the service at `http://localhost:port/v3/api-docs.yaml` -
gets generated from code (specifically from Spring annotations)


## Authentication Providers
### Enabling and Selecting Authentication Providers
Expand Down Expand Up @@ -105,12 +140,14 @@ loginsvc:
jwt:
generate-in-memory:
access-exp-time: 15min
rotate-time: 9h
refresh-exp-time: 9h
key-rotation-time: 9h
alg-name: "RS256"
```
There are a few important configuration values to be provided:
- `access-exp-time` which indicates how long a token is valid for,
- Optional property: `rotate-time` which indicates how often Key pairs are rotated. Rotation will be disabled if missing.
- `access-exp-time` which indicates how long an access token is valid for,
- `refresh-exp-time` which indicates how long a refresh token is valid for,
- Optional property: `key-rotation-time` which indicates how often Key pairs are rotated. Rotation will be disabled if missing.
- `alg-name` which indicates which algorithm is used to encode your keys.

To setup for AWS Secrets Manager, your config should look like so:
Expand All @@ -124,6 +161,7 @@ loginsvc:
private-key-field-name: "privateKey"
public-key-field-name: "publicKey"
access-exp-time: 15min
refresh-exp-time: 9h
poll-time: 30min
alg-name: "RS256"
```
Expand All @@ -136,7 +174,8 @@ with `"privateKey"` and `"publicKey"` indicating the field-name of those secrets
Replace the above example values with the field-names you used in AWS Secrets Manager.

There are a few important configuration values to be provided:
- `access-exp-time` which indicates how long a token is valid for,
- `access-exp-time` which indicates how long an access token is valid for,
- `refresh-exp-time` which indicates how long a refresh token is valid for,
- Optional property:`poll-time` which indicates how often key pairs (`private-key-field-name` and `public-key-field-name`) are polled and fetched from AWS Secrets Manager. Polling will be disabled if missing.
- `alg-name` which indicates which algorithm is used to encode your keys.

Expand Down
4 changes: 3 additions & 1 deletion service/src/main/resources/example.application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ loginsvc:
#Configuration to generate the key in memory
generate-in-memory:
access-exp-time: 15min
rotation-time: 9h
refresh-exp-time: 9h
key-rotation-time: 9h
alg-name: "RS256"
#Instead of generating the key in memory
#The Below Config allows for the application to fetch keys from AWS Secrets Manager.
Expand All @@ -15,6 +16,7 @@ loginsvc:
#private-key-field-name: "privateKey"
#public-key-field-name: "publicKey"
#access-exp-time: 15min
#refresh-exp-time: 9h
#poll-time: 5min
#alg-name: "RS256"
config:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright 2023 ABSA Group Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package za.co.absa.loginsvc.rest

import org.slf4j.LoggerFactory
import org.springframework.http.{HttpStatus, ResponseEntity}
import org.springframework.web.bind.annotation.{ControllerAdvice, ExceptionHandler, RestController}
import za.co.absa.loginsvc.rest.model.RestMessage

@ControllerAdvice(annotations = Array(classOf[RestController]))
class RestResponseEntityExceptionHandler {

private val logger = LoggerFactory.getLogger(classOf[RestResponseEntityExceptionHandler])

@ExceptionHandler(value = Array(
// specific subtypes of classOf[io.jsonwebtoken.JwtException]
classOf[io.jsonwebtoken.security.SignatureException], // e.g. signature does not match
classOf[io.jsonwebtoken.security.InvalidKeyException],
classOf[io.jsonwebtoken.ExpiredJwtException]
))
def handleInvalidSignatureException(exception: Exception): ResponseEntity[RestMessage] = {
logger.error(exception.getMessage)
ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(RestMessage(exception.getMessage))
}

@ExceptionHandler(value = Array(
classOf[java.security.SignatureException], // other signature exceptions, e.g signature mismatch, malformed, ...
classOf[io.jsonwebtoken.MalformedJwtException],
classOf[io.jsonwebtoken.JwtException] // default handler for JwtExceptions (more specific defined above => 401)
))
def handleSignatureProblemException(exception: Exception): ResponseEntity[RestMessage] = {
logger.error(exception.getMessage)
ResponseEntity.badRequest().body(RestMessage(exception.getMessage))
}

@ExceptionHandler(value = Array(classOf[IllegalArgumentException]))
def handleIllegalArgumentException(exception: IllegalArgumentException): ResponseEntity[RestMessage] = {
ResponseEntity.badRequest().body(RestMessage(exception.getMessage))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class SecurityConfig {
"/swagger-ui/**", "/swagger-ui.html", // "/swagger-ui.html" redirects to "/swagger-ui/index.html
"/swagger-resources/**", "/v3/api-docs/**", // swagger needs these
"/actuator/**",
"/token/refresh", // access+refresh JWT in payload, no auth
"/token/public-key-jwks",
"/token/public-key").permitAll()
.anyRequest().authenticated()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
package za.co.absa.loginsvc.rest.config.jwt

import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
import io.jsonwebtoken.SignatureAlgorithm
import org.slf4j.LoggerFactory
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
import software.amazon.awssdk.regions.Region
Expand All @@ -30,20 +29,22 @@ import java.security.{KeyFactory, KeyPair}
import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec}
import java.util.Base64
import scala.concurrent.duration.FiniteDuration
import scala.util.{Failure, Success, Try}

case class AwsSecretsManagerKeyConfig (secretName: String,
region: String,
privateKeyFieldName: String,
publicKeyFieldName: String,
algName: String,
accessExpTime: FiniteDuration,
pollTime: Option[FiniteDuration])
extends KeyConfig {

case class AwsSecretsManagerKeyConfig(
secretName: String,
region: String,
privateKeyFieldName: String,
publicKeyFieldName: String,
algName: String,
accessExpTime: FiniteDuration,
refreshExpTime: FiniteDuration,
pollTime: Option[FiniteDuration]
) extends KeyConfig {

private val logger = LoggerFactory.getLogger(classOf[AwsSecretsManagerKeyConfig])

override def refreshKeyTime : Option[FiniteDuration] = pollTime
override def keyRotationTime : Option[FiniteDuration] = pollTime
override def keyPair(): KeyPair = {

val default = DefaultCredentialsProvider.create
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,25 @@ package za.co.absa.loginsvc.rest.config.jwt
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
import org.slf4j.LoggerFactory
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess}
import za.co.absa.loginsvc.rest.config.validation.{ConfigValidationException, ConfigValidationResult}

import scala.util.{Failure, Success, Try}
import java.security.KeyPair
import scala.concurrent.duration.FiniteDuration

case class InMemoryKeyConfig (algName: String,
accessExpTime: FiniteDuration,
rotationTime: Option[FiniteDuration])
extends KeyConfig {
case class InMemoryKeyConfig(
algName: String,
accessExpTime: FiniteDuration,
refreshExpTime: FiniteDuration,
keyRotationTime: Option[FiniteDuration]
) extends KeyConfig {

private val logger = LoggerFactory.getLogger(classOf[InMemoryKeyConfig])

override def refreshKeyTime : Option[FiniteDuration] = rotationTime
override def keyPair(): KeyPair = {
logger.info("Generating new keys")
logger.info(s"Generating new keys - every ${keyRotationTime.getOrElse("?")}")
Keys.keyPairFor(SignatureAlgorithm.valueOf(algName))
}

override def throwErrors(): Unit = this.validate().throwOnErrors()

}

Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
package za.co.absa.loginsvc.rest.config.jwt

import io.jsonwebtoken.SignatureAlgorithm
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess}
import org.slf4j.LoggerFactory
import za.co.absa.loginsvc.rest.config.validation.{ConfigValidatable, ConfigValidationException, ConfigValidationResult}
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess}

import java.security.KeyPair
import java.util.concurrent.TimeUnit
Expand All @@ -28,7 +29,8 @@ import scala.util.{Failure, Success, Try}
trait KeyConfig extends ConfigValidatable {
def algName: String
def accessExpTime: FiniteDuration
def refreshKeyTime: Option[FiniteDuration]
def refreshExpTime: FiniteDuration
def keyRotationTime: Option[FiniteDuration]
def keyPair(): KeyPair
def throwErrors(): Unit

Expand All @@ -44,6 +46,8 @@ trait KeyConfig extends ConfigValidatable {
})
}

private val logger = LoggerFactory.getLogger(classOf[KeyConfig])

override def validate(): ConfigValidationResult = {

val algValidation = Try {
Expand All @@ -59,15 +63,24 @@ trait KeyConfig extends ConfigValidatable {
ConfigValidationError(ConfigValidationException(s"accessExpTime must be at least ${KeyConfig.minAccessExpTime}"))
} else ConfigValidationSuccess

val refreshKeyTimeResult = if (refreshKeyTime.nonEmpty && refreshKeyTime.get < KeyConfig.minRefreshKeyTime) {
ConfigValidationError(ConfigValidationException(s"refreshKeyTime must be at least ${KeyConfig.minRefreshKeyTime}"))
val refreshExpTimeResult = if (refreshExpTime < KeyConfig.minRefreshExpTime) {
ConfigValidationError(ConfigValidationException(s"refreshExpTime must be at least ${KeyConfig.minRefreshExpTime}"))
} else ConfigValidationSuccess

algValidation.merge(accessExpTimeResult).merge(refreshKeyTimeResult)
val keyRotationTimeResult = if (keyRotationTime.nonEmpty && keyRotationTime.get < KeyConfig.minKeyRotationTime) {
ConfigValidationError(ConfigValidationException(s"keyRotationTime must be at least ${KeyConfig.minKeyRotationTime}"))
} else ConfigValidationSuccess

if (keyRotationTime.isEmpty) {
logger.warn("keyRotationTime is not set in config, key-pair will not be rotated!")
}

algValidation.merge(accessExpTimeResult).merge(refreshExpTimeResult).merge(keyRotationTimeResult)
}
}

object KeyConfig {
val minAccessExpTime: FiniteDuration = FiniteDuration(10, TimeUnit.MILLISECONDS)
val minRefreshKeyTime: FiniteDuration = FiniteDuration(10, TimeUnit.MILLISECONDS)
val minRefreshExpTime: FiniteDuration = FiniteDuration(10, TimeUnit.MILLISECONDS)
val minKeyRotationTime: FiniteDuration = FiniteDuration(10, TimeUnit.MILLISECONDS)
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class ConfigProvider(@Value("${spring.config.location}") yamlPath: String)
getOrElse(BaseConfig(""))
}

def getJWTConfig : KeyConfig = {
def getJwtKeyConfig : KeyConfig = {
val inMemoryKeyConfig: Option[InMemoryKeyConfig] = createConfigClass[InMemoryKeyConfig]("loginsvc.rest.jwt.generate-in-memory")
val awsSecretsManagerKeyConfig: Option[AwsSecretsManagerKeyConfig] = createConfigClass[AwsSecretsManagerKeyConfig]("loginsvc.rest.jwt.aws-secrets-manager")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ package za.co.absa.loginsvc.rest.config.provider
import za.co.absa.loginsvc.rest.config.jwt.KeyConfig

trait JwtConfigProvider {
def getJWTConfig : KeyConfig
def getJwtKeyConfig: KeyConfig
}
Loading

0 comments on commit f7d833e

Please sign in to comment.