Skip to content

Commit

Permalink
Epic/cognito/feature/apikey (#163)
Browse files Browse the repository at this point in the history
* Add apikey and client generation feature #156

* Fix provider issue

* Add apikey and client generation feature #156

* Fix provider issue

* Fix PR review comments

* Resolve merging issues

* Hide MFA for social sign in users

* Remove unwanted variables

* Fix failing test cases

* Fix pagination issue

* Update travis config

* updating wording and display for My API page, including default callbackurls for client registration, enable galah client callbacks by default

* updating to correct login urls for tokens app callback urls, updating api key and client wording descriptions

* adding additional wording to client id generation tab, updating defaultCallbackURLs list

* adding wording to link tokens app for detailed client app registration, updating token app url to base url

* Feature/more openapi specs (#167)

* adding openapi specs for /ws/registration/states.json and /ws/registration/countries.json paths, updating to latest stable release security plugin version 6.0.0

* updating response description for and operatoin id for /ws/registration specs

* WIP 3rd party applications registration support

* Add missing secrets when required

* Update ala plugin version

* Small fixes for apikey frontend

* Use String region for DynamoDB builder

* Increase role list limit

* Fix create applications issues

* Add extra config for mongo connection in GORM and fix for AWS region is null

* Update plugin versions

* Fix application generation related issues

* Allow client secert only for confidential clients

* Fix typo

* Add tokens app callback url

* Address review comments

* Remove galah client creation

* Fix text formatting

* Fix affiliation saving issue

* Fix tokens app url issue

* Update application feature

* Fix links

* Fix docs portal urls

* Add application help

* Fix help

* Fix issues

* Update ala-bootstrap plugin

* Update auth plugin version

* Update plugins

* Fix remove attribute error

---------

Co-authored-by: dewmini <[email protected]>
Co-authored-by: Sushant <[email protected]>
Co-authored-by: Simon Bear <[email protected]>
  • Loading branch information
4 people authored Nov 3, 2023
1 parent b705e81 commit cd6d17a
Show file tree
Hide file tree
Showing 38 changed files with 2,453 additions and 54 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ branches:
- epic/cognito/cleanup-2
- epic/cognito/fix-asset-pipeline-issue
- epic/cognito/openapi_fix
- epic/cognito/feature/apikey
before_cache:
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ userdetails

## Note

v2.0 of userdetails requires [ALA CAS 5](https://github.com/AtlasOfLivingAustralia/ala-cas-5)
v4.0 of userdetails requires [ALA CAS 5](https://github.com/AtlasOfLivingAustralia/ala-cas-5) v6.6+ or AWS Cognito

## About
The Atlas user management app (userdetails) manages profile information for users.
Expand All @@ -13,11 +13,15 @@ This application is the central repository for user information for Atlas system

Userdetails works hand in hand with [ALA CAS 5](https://github.com/AtlasOfLivingAustralia/ala-cas-5) and both share the same underlying database.

CAS manages the local authentication as well as third party auth provider integrtion.
CAS manages the local authentication as well as third party auth provider integration.


## General Information

### Builds

This project will build 3 artifacts, 2 concrete implementations of the user details app (userdetails-gorm for CAS and userdetails-cognito for AWS Cognito) and the userdetails-plugin that handles all commmon functions.

### Technologies
* Grails framework: 3.2.11
* JQuery
Expand Down
7 changes: 4 additions & 3 deletions userdetails-cognito/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,10 @@ dependencies {

// regular JAR dependencies

implementation 'com.amazonaws:aws-java-sdk-cognitoidentity:1.12.279'
implementation 'com.amazonaws:aws-java-sdk-cognitoidp:1.12.279'
implementation 'com.amazonaws:aws-java-sdk-cognitosync:1.12.279'
implementation 'com.amazonaws:aws-java-sdk-cognitoidentity:1.12.447'
implementation 'com.amazonaws:aws-java-sdk-cognitoidp:1.12.447'
implementation 'com.amazonaws:aws-java-sdk-cognitosync:1.12.447'
implementation 'com.amazonaws:aws-java-sdk-dynamodb:1.12.447'

}

Expand Down
8 changes: 8 additions & 0 deletions userdetails-cognito/grails-app/conf/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,11 @@ account:
MFAenabled: true
authorised-systems:
edit-enabled: false
oauth.support.dynamic.client.defaultCallbackURLs: ["http://localhost:8080", "http://localhost:8080/", "http://localhost:8080/*", "https://tokens.ala.org.au/login", "https://tokens.test.ala.org.au/login", "https://tokens-cognito-support.dev.ala.org.au/login"]
oauth.support.dynamic.client.registration: true
oauth.support.dynamic.client.scopes: ["email", "openid", "profile", "ala/attrs" , "ala/roles"]
oauth.support.dynamic.client.galah.callbackURLs: ["http://localhost:1410", "http://localhost:1410/", "http://localhost:1410/*"]
oauth.support.dynamic.client.postmanExample: https://www:postman.com/sushantcsiro/workspace/ala-common-apis/request/23926959-e63a1ccd-63ab-45c2-8de3-a856fd29ce57
tokenApp.url: https://tokens-cognito-support.dev.ala.org.au
oauth.support.dynamic.client.supportedIdentityProviders: ["COGNITO", "Facebook", "Google", "AAF", "SignInWithApple"]
oauth.support.dynamic.client.authFlows: ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_CUSTOM_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"]
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ import au.org.ala.web.OidcClientProperties
import au.org.ala.ws.security.JwtProperties
import au.org.ala.ws.tokens.TokenService
import com.amazonaws.auth.*
import com.amazonaws.regions.Region
import com.amazonaws.services.apigateway.AmazonApiGateway
import com.amazonaws.services.apigateway.AmazonApiGatewayClientBuilder
import com.amazonaws.services.cognitoidp.AWSCognitoIdentityProvider
import com.amazonaws.services.cognitoidp.AWSCognitoIdentityProviderClient
import com.amazonaws.services.cognitoidp.AWSCognitoIdentityProviderClientBuilder
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder
import com.amazonaws.services.dynamodbv2.document.DynamoDB
import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import groovy.util.logging.Slf4j
Expand Down Expand Up @@ -69,6 +74,14 @@ class Application extends GrailsAutoConfiguration {
return cognitoIdp
}

@Bean
AmazonDynamoDB amazonDynamoDB(AWSCredentialsProvider awsCredentialsProvider, Region awsRegion) {
return AmazonDynamoDBClientBuilder.standard()
.withRegion(awsRegion.toString())
.withCredentials(awsCredentialsProvider)
.build()
}

@Bean('userService')
IUserService userService(TokenService tokenService, EmailService emailService, AWSCognitoIdentityProvider cognitoIdp, JwtProperties jwtProperties) {

Expand All @@ -90,4 +103,41 @@ class Application extends GrailsAutoConfiguration {
return new CognitoPasswordOperations(cognitoIdp: cognitoIdp, poolId: grailsApplication.config.getProperty('cognito.poolId'),
oidcClientProperties: oidcClientProperties)
}

@Bean('applicationService')
IApplicationService applicationService(AWSCognitoIdentityProvider cognitoIdp, IUserService userService, AmazonDynamoDB amazonDynamoDB) {

def poolId = grailsApplication.config.getProperty('cognito.poolId')
def supportedIdentityProviders = grailsApplication.config.getProperty('oauth.support.dynamic.client.supportedIdentityProviders', List, [])
def authFlows = grailsApplication.config.getProperty('oauth.support.dynamic.client.authFlows', List, [])
def clientScopes = grailsApplication.config.getProperty('oauth.support.dynamic.client.scopes', List, [])
def galahCallbackURLs = grailsApplication.config.getProperty('oauth.support.dynamic.client.galah.callbackURLs', List, [])
def tokensCallbackURLs = grailsApplication.config.getProperty('oauth.support.dynamic.client.tokens.callbackURLs', List, [])
def dynamoDBTable = grailsApplication.config.getProperty('oauth.support.dynamic.client.dynamoDBTableName', String, null)
def dynamoDBPK = grailsApplication.config.getProperty('oauth.support.dynamic.client.dynamoDBTable.dynamoDBPK', String, null)
def dynamoDBSK = grailsApplication.config.getProperty('oauth.support.dynamic.client.dynamoDBTable.dynamoDBSK', String, null)

CognitoApplicationService applicationService = new CognitoApplicationService(
userService: userService,
cognitoIdp: cognitoIdp,
poolId: poolId,
supportedIdentityProviders: supportedIdentityProviders,
authFlows: authFlows,
clientScopes: clientScopes,
galahCallbackURLs: galahCallbackURLs,
dynamoDB: amazonDynamoDB,
dynamoDBTable: dynamoDBTable,
dynamoDBPK: dynamoDBPK,
dynamoDBSK: dynamoDBSK,
tokensCallbackURLs: tokensCallbackURLs
)


return applicationService
}
//
// @Bean('apikeyService')
// IApikeyService apikeyService(IUserService userService, AmazonApiGateway apiGatewayIdp) {
// return new AWSApikeyService(userService: userService, apiGatewayIdp: apiGatewayIdp)
// }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
package au.org.ala.userdetails

import com.amazonaws.AmazonWebServiceResult
import com.amazonaws.ResponseMetadata
import com.amazonaws.services.cognitoidp.AWSCognitoIdentityProvider
import com.amazonaws.services.cognitoidp.model.CreateUserPoolClientRequest
import com.amazonaws.services.cognitoidp.model.CreateUserPoolClientResult
import com.amazonaws.services.cognitoidp.model.DeleteUserPoolClientRequest
import com.amazonaws.services.cognitoidp.model.DescribeUserPoolClientRequest
import com.amazonaws.services.cognitoidp.model.UpdateUserPoolClientRequest
import com.amazonaws.services.cognitoidp.model.UserPoolClientType
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB
import com.amazonaws.services.dynamodbv2.model.AttributeValue
import com.amazonaws.services.dynamodbv2.model.DeleteItemRequest
import com.amazonaws.services.dynamodbv2.model.PutItemRequest
import com.amazonaws.services.dynamodbv2.model.QueryRequest

class CognitoApplicationService implements IApplicationService {

IUserService userService
AWSCognitoIdentityProvider cognitoIdp
String poolId

// Config config
List<String> supportedIdentityProviders
List<String> authFlows
List<String> clientScopes
List<String> galahCallbackURLs
List<String> tokensCallbackURLs

AmazonDynamoDB dynamoDB
String dynamoDBTable
String dynamoDBPK
String dynamoDBSK

List<ApplicationRecord> listApplicationsForUser(String userId) {
def qr = new QueryRequest()
.withTableName(dynamoDBTable)
.withKeyConditionExpression("$dynamoDBPK = :userId")
.withExpressionAttributeValues([":userId": new AttributeValue(userId)])
def result = dynamoDB.query(qr)

if (result.sdkHttpMetadata.httpStatusCode == 200) {
result.items.collect { itemToApplication(it) }
} else {
throw new RuntimeException("Could not list clients for user $userId")
}
}

private ApplicationRecord itemToApplication(item) {
def clientId = item.get(dynamoDBSK).getS()

def client = cognitoIdp.describeUserPoolClient(
new DescribeUserPoolClientRequest()
.withUserPoolId(poolId)
.withClientId(clientId)
)
userPoolClientToApplication(client.userPoolClient)
}

private ApplicationRecord userPoolClientToApplication(UserPoolClientType userPoolClient) {
def name = userPoolClient.clientName
def clientId = userPoolClient.clientId
def secret = userPoolClient.clientSecret
def callbackUrls = userPoolClient.callbackURLs
def allowedFlows = userPoolClient.allowedOAuthFlows
userPoolClient.logoutURLs
userPoolClient.defaultRedirectURI

def type
if (allowedFlows.contains('client_credentials')) {
type = ApplicationType.M2M
} else if (allowedFlows.contains('code')) {
if (userPoolClient.clientSecret) {
type = ApplicationType.CONFIDENTIAL
} else {
type = ApplicationType.PUBLIC
}
} else {
type = ApplicationType.UNKNOWN
}

return new ApplicationRecord(
name: name,
clientId: clientId,
secret: secret,
callbacks: callbackUrls,
type: type,
needTokenAppAsCallback: callbackUrls?.containsAll(tokensCallbackURLs)
)
}

List<String> listClientIdsForUser(String userId) {
listApplicationsForUser(userId).collect { it.clientId }
}

private def addClientIdForUser(String userId, String clientId) {
def putResponse = dynamoDB.putItem(
new PutItemRequest(dynamoDBTable, [(dynamoDBPK): new AttributeValue(userId), (dynamoDBSK): new AttributeValue(clientId)]))
if (putResponse.sdkHttpMetadata.httpStatusCode != 200) {
throw new RuntimeException("Couldn't add mapping for $clientId to $userId")
}
}

private def deleteClientIdForUser(String userId, String clientId) {
def deleteResponse = dynamoDB.deleteItem(
new DeleteItemRequest(dynamoDBTable, [(dynamoDBPK): new AttributeValue(userId), (dynamoDBSK): new AttributeValue(clientId)]))
if (deleteResponse.sdkHttpMetadata.httpStatusCode != 200) {
throw new RuntimeException("Couldn't delete mapping for $clientId to $userId")
}
}

private def getClientByUserIdAndClientId(String userId, String clientId) {
def result = dynamoDB.getItem(dynamoDBTable, [(dynamoDBPK): new AttributeValue(userId), (dynamoDBSK): new AttributeValue(clientId)])
return result.item
}

private def isUserOwnsClientId(String userId, String clientId) {
return getClientByUserIdAndClientId(userId, clientId) != null
}

@Override
ApplicationRecord generateClient(String userId, ApplicationRecord applicationRecord) {
CreateUserPoolClientRequest request = new CreateUserPoolClientRequest().withUserPoolId(poolId)
request.clientName = applicationRecord.name
// TODO enable user consent
if (applicationRecord.type == ApplicationType.M2M) {
request.generateSecret = true
request.allowedOAuthFlows = ["client_credentials"]
} else {
request.generateSecret = applicationRecord.type == ApplicationType.CONFIDENTIAL //do not need secret for public clients
request.allowedOAuthFlows = ["code"]
}
request.supportedIdentityProviders = new ArrayList<>(supportedIdentityProviders)
request.preventUserExistenceErrors = "ENABLED"
request.explicitAuthFlows = new ArrayList<>(authFlows)
request.allowedOAuthFlowsUserPoolClient = true

def scopes = new ArrayList<>(clientScopes)

if (scopes && applicationRecord.type != ApplicationType.M2M) {
request.allowedOAuthScopes = scopes
}
if(applicationRecord.type == ApplicationType.M2M) {
request.allowedOAuthScopes = ["ala/attrs"]
}

request.callbackURLs = new ArrayList<>(applicationRecord.callbacks.findAll{it != ""})
if (applicationRecord.type == ApplicationType.M2M) {
request.callbackURLs = null
}
else if(applicationRecord.needTokenAppAsCallback) {
request.callbackURLs.addAll(tokensCallbackURLs)
}

CreateUserPoolClientResult response = cognitoIdp.createUserPoolClient(request)

if (isSuccessful(response)) {
def clientId = response.userPoolClient.clientId
addClientIdForUser(userId, clientId)
return userPoolClientToApplication(response.userPoolClient)
} else {
throw new RuntimeException("Could not generate client")
}
}

@Override
void updateClient(String userId, ApplicationRecord applicationRecord) {
if (!isUserOwnsClientId(userId, applicationRecord.clientId)) {
throw new IllegalArgumentException("${applicationRecord.clientId} not found")
}
def request = new UpdateUserPoolClientRequest().withUserPoolId(poolId)
request.withClientId(applicationRecord.clientId)
request.withClientName(applicationRecord.name)
request.supportedIdentityProviders = new ArrayList<>(supportedIdentityProviders)
request.preventUserExistenceErrors = "ENABLED"
request.explicitAuthFlows = new ArrayList<>(authFlows)
request.allowedOAuthFlowsUserPoolClient = true

if (applicationRecord.type == ApplicationType.M2M) {
request.allowedOAuthFlows = ["client_credentials"]
} else {
request.allowedOAuthFlows = ["code"]
}

def scopes = new ArrayList<>(clientScopes)

if (scopes && applicationRecord.type != ApplicationType.M2M) {
request.allowedOAuthScopes = scopes
}
if(applicationRecord.type == ApplicationType.M2M) {
request.allowedOAuthScopes = ["ala/attrs"]
}

request.callbackURLs = new ArrayList<>(applicationRecord.callbacks.findAll{it != ""})
if (applicationRecord.type == ApplicationType.M2M) {
request.callbackURLs = null
}
else if(applicationRecord.needTokenAppAsCallback) {
request.callbackURLs.addAll(tokensCallbackURLs)
}

def response = cognitoIdp.updateUserPoolClient(request)
if (!isSuccessful(response)) {
throw new RuntimeException("Could not update client $applicationRecord.clientId")
}
}

@Override
ApplicationRecord findClientByClientId(String userId, String clientId) {
return itemToApplication(getClientByUserIdAndClientId(userId, clientId))
}

private static boolean isSuccessful(AmazonWebServiceResult<? extends ResponseMetadata> result) {
def code = result.sdkHttpMetadata.httpStatusCode
return code >= 200 && code < 300
}

@Override
boolean deleteApplication(String userId, String clientId){
if (!isUserOwnsClientId(userId, clientId)) {
throw new IllegalArgumentException("${clientId} not found")
}
def request = new DeleteUserPoolClientRequest().withUserPoolId(poolId).withClientId(clientId)

def response = cognitoIdp.deleteUserPoolClient(request)
if (!isSuccessful(response)) {
throw new RuntimeException("Could not delete client $clientId")
}
else{
deleteClientIdForUser(userId, clientId)
return true
}
}
}
Loading

0 comments on commit cd6d17a

Please sign in to comment.