Skip to content

Commit cd6d17a

Browse files
yasima-csirodewminisughicssbearcsiro
authored
Epic/cognito/feature/apikey (#163)
* 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 <5234594+dewmini@users.noreply.github.com> Co-authored-by: Sushant <105408469+sughics@users.noreply.github.com> Co-authored-by: Simon Bear <simon.bear@csiro.au>
1 parent b705e81 commit cd6d17a

File tree

38 files changed

+2453
-54
lines changed

38 files changed

+2453
-54
lines changed

.travis.yml

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ branches:
1515
- epic/cognito/cleanup-2
1616
- epic/cognito/fix-asset-pipeline-issue
1717
- epic/cognito/openapi_fix
18+
- epic/cognito/feature/apikey
1819
before_cache:
1920
- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock
2021
- rm -fr $HOME/.gradle/caches/*/plugin-resolution/

README.md

+6-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ userdetails
44

55
## Note
66

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

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

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

16-
CAS manages the local authentication as well as third party auth provider integrtion.
16+
CAS manages the local authentication as well as third party auth provider integration.
1717

1818

1919
## General Information
2020

21+
### Builds
22+
23+
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.
24+
2125
### Technologies
2226
* Grails framework: 3.2.11
2327
* JQuery

userdetails-cognito/build.gradle

+4-3
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,10 @@ dependencies {
102102

103103
// regular JAR dependencies
104104

105-
implementation 'com.amazonaws:aws-java-sdk-cognitoidentity:1.12.279'
106-
implementation 'com.amazonaws:aws-java-sdk-cognitoidp:1.12.279'
107-
implementation 'com.amazonaws:aws-java-sdk-cognitosync:1.12.279'
105+
implementation 'com.amazonaws:aws-java-sdk-cognitoidentity:1.12.447'
106+
implementation 'com.amazonaws:aws-java-sdk-cognitoidp:1.12.447'
107+
implementation 'com.amazonaws:aws-java-sdk-cognitosync:1.12.447'
108+
implementation 'com.amazonaws:aws-java-sdk-dynamodb:1.12.447'
108109

109110
}
110111

userdetails-cognito/grails-app/conf/application.yml

+8
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,11 @@ account:
198198
MFAenabled: true
199199
authorised-systems:
200200
edit-enabled: false
201+
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"]
202+
oauth.support.dynamic.client.registration: true
203+
oauth.support.dynamic.client.scopes: ["email", "openid", "profile", "ala/attrs" , "ala/roles"]
204+
oauth.support.dynamic.client.galah.callbackURLs: ["http://localhost:1410", "http://localhost:1410/", "http://localhost:1410/*"]
205+
oauth.support.dynamic.client.postmanExample: https://www:postman.com/sushantcsiro/workspace/ala-common-apis/request/23926959-e63a1ccd-63ab-45c2-8de3-a856fd29ce57
206+
tokenApp.url: https://tokens-cognito-support.dev.ala.org.au
207+
oauth.support.dynamic.client.supportedIdentityProviders: ["COGNITO", "Facebook", "Google", "AAF", "SignInWithApple"]
208+
oauth.support.dynamic.client.authFlows: ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_CUSTOM_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"]

userdetails-cognito/grails-app/init/au/org/ala/userdetails/cognito/Application.groovy

+51-1
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ import au.org.ala.web.OidcClientProperties
2020
import au.org.ala.ws.security.JwtProperties
2121
import au.org.ala.ws.tokens.TokenService
2222
import com.amazonaws.auth.*
23+
import com.amazonaws.regions.Region
24+
import com.amazonaws.services.apigateway.AmazonApiGateway
25+
import com.amazonaws.services.apigateway.AmazonApiGatewayClientBuilder
2326
import com.amazonaws.services.cognitoidp.AWSCognitoIdentityProvider
24-
import com.amazonaws.services.cognitoidp.AWSCognitoIdentityProviderClient
2527
import com.amazonaws.services.cognitoidp.AWSCognitoIdentityProviderClientBuilder
28+
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB
29+
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder
30+
import com.amazonaws.services.dynamodbv2.document.DynamoDB
2631
import grails.boot.GrailsApp
2732
import grails.boot.config.GrailsAutoConfiguration
2833
import groovy.util.logging.Slf4j
@@ -69,6 +74,14 @@ class Application extends GrailsAutoConfiguration {
6974
return cognitoIdp
7075
}
7176

77+
@Bean
78+
AmazonDynamoDB amazonDynamoDB(AWSCredentialsProvider awsCredentialsProvider, Region awsRegion) {
79+
return AmazonDynamoDBClientBuilder.standard()
80+
.withRegion(awsRegion.toString())
81+
.withCredentials(awsCredentialsProvider)
82+
.build()
83+
}
84+
7285
@Bean('userService')
7386
IUserService userService(TokenService tokenService, EmailService emailService, AWSCognitoIdentityProvider cognitoIdp, JwtProperties jwtProperties) {
7487

@@ -90,4 +103,41 @@ class Application extends GrailsAutoConfiguration {
90103
return new CognitoPasswordOperations(cognitoIdp: cognitoIdp, poolId: grailsApplication.config.getProperty('cognito.poolId'),
91104
oidcClientProperties: oidcClientProperties)
92105
}
106+
107+
@Bean('applicationService')
108+
IApplicationService applicationService(AWSCognitoIdentityProvider cognitoIdp, IUserService userService, AmazonDynamoDB amazonDynamoDB) {
109+
110+
def poolId = grailsApplication.config.getProperty('cognito.poolId')
111+
def supportedIdentityProviders = grailsApplication.config.getProperty('oauth.support.dynamic.client.supportedIdentityProviders', List, [])
112+
def authFlows = grailsApplication.config.getProperty('oauth.support.dynamic.client.authFlows', List, [])
113+
def clientScopes = grailsApplication.config.getProperty('oauth.support.dynamic.client.scopes', List, [])
114+
def galahCallbackURLs = grailsApplication.config.getProperty('oauth.support.dynamic.client.galah.callbackURLs', List, [])
115+
def tokensCallbackURLs = grailsApplication.config.getProperty('oauth.support.dynamic.client.tokens.callbackURLs', List, [])
116+
def dynamoDBTable = grailsApplication.config.getProperty('oauth.support.dynamic.client.dynamoDBTableName', String, null)
117+
def dynamoDBPK = grailsApplication.config.getProperty('oauth.support.dynamic.client.dynamoDBTable.dynamoDBPK', String, null)
118+
def dynamoDBSK = grailsApplication.config.getProperty('oauth.support.dynamic.client.dynamoDBTable.dynamoDBSK', String, null)
119+
120+
CognitoApplicationService applicationService = new CognitoApplicationService(
121+
userService: userService,
122+
cognitoIdp: cognitoIdp,
123+
poolId: poolId,
124+
supportedIdentityProviders: supportedIdentityProviders,
125+
authFlows: authFlows,
126+
clientScopes: clientScopes,
127+
galahCallbackURLs: galahCallbackURLs,
128+
dynamoDB: amazonDynamoDB,
129+
dynamoDBTable: dynamoDBTable,
130+
dynamoDBPK: dynamoDBPK,
131+
dynamoDBSK: dynamoDBSK,
132+
tokensCallbackURLs: tokensCallbackURLs
133+
)
134+
135+
136+
return applicationService
137+
}
138+
//
139+
// @Bean('apikeyService')
140+
// IApikeyService apikeyService(IUserService userService, AmazonApiGateway apiGatewayIdp) {
141+
// return new AWSApikeyService(userService: userService, apiGatewayIdp: apiGatewayIdp)
142+
// }
93143
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
package au.org.ala.userdetails
2+
3+
import com.amazonaws.AmazonWebServiceResult
4+
import com.amazonaws.ResponseMetadata
5+
import com.amazonaws.services.cognitoidp.AWSCognitoIdentityProvider
6+
import com.amazonaws.services.cognitoidp.model.CreateUserPoolClientRequest
7+
import com.amazonaws.services.cognitoidp.model.CreateUserPoolClientResult
8+
import com.amazonaws.services.cognitoidp.model.DeleteUserPoolClientRequest
9+
import com.amazonaws.services.cognitoidp.model.DescribeUserPoolClientRequest
10+
import com.amazonaws.services.cognitoidp.model.UpdateUserPoolClientRequest
11+
import com.amazonaws.services.cognitoidp.model.UserPoolClientType
12+
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB
13+
import com.amazonaws.services.dynamodbv2.model.AttributeValue
14+
import com.amazonaws.services.dynamodbv2.model.DeleteItemRequest
15+
import com.amazonaws.services.dynamodbv2.model.PutItemRequest
16+
import com.amazonaws.services.dynamodbv2.model.QueryRequest
17+
18+
class CognitoApplicationService implements IApplicationService {
19+
20+
IUserService userService
21+
AWSCognitoIdentityProvider cognitoIdp
22+
String poolId
23+
24+
// Config config
25+
List<String> supportedIdentityProviders
26+
List<String> authFlows
27+
List<String> clientScopes
28+
List<String> galahCallbackURLs
29+
List<String> tokensCallbackURLs
30+
31+
AmazonDynamoDB dynamoDB
32+
String dynamoDBTable
33+
String dynamoDBPK
34+
String dynamoDBSK
35+
36+
List<ApplicationRecord> listApplicationsForUser(String userId) {
37+
def qr = new QueryRequest()
38+
.withTableName(dynamoDBTable)
39+
.withKeyConditionExpression("$dynamoDBPK = :userId")
40+
.withExpressionAttributeValues([":userId": new AttributeValue(userId)])
41+
def result = dynamoDB.query(qr)
42+
43+
if (result.sdkHttpMetadata.httpStatusCode == 200) {
44+
result.items.collect { itemToApplication(it) }
45+
} else {
46+
throw new RuntimeException("Could not list clients for user $userId")
47+
}
48+
}
49+
50+
private ApplicationRecord itemToApplication(item) {
51+
def clientId = item.get(dynamoDBSK).getS()
52+
53+
def client = cognitoIdp.describeUserPoolClient(
54+
new DescribeUserPoolClientRequest()
55+
.withUserPoolId(poolId)
56+
.withClientId(clientId)
57+
)
58+
userPoolClientToApplication(client.userPoolClient)
59+
}
60+
61+
private ApplicationRecord userPoolClientToApplication(UserPoolClientType userPoolClient) {
62+
def name = userPoolClient.clientName
63+
def clientId = userPoolClient.clientId
64+
def secret = userPoolClient.clientSecret
65+
def callbackUrls = userPoolClient.callbackURLs
66+
def allowedFlows = userPoolClient.allowedOAuthFlows
67+
userPoolClient.logoutURLs
68+
userPoolClient.defaultRedirectURI
69+
70+
def type
71+
if (allowedFlows.contains('client_credentials')) {
72+
type = ApplicationType.M2M
73+
} else if (allowedFlows.contains('code')) {
74+
if (userPoolClient.clientSecret) {
75+
type = ApplicationType.CONFIDENTIAL
76+
} else {
77+
type = ApplicationType.PUBLIC
78+
}
79+
} else {
80+
type = ApplicationType.UNKNOWN
81+
}
82+
83+
return new ApplicationRecord(
84+
name: name,
85+
clientId: clientId,
86+
secret: secret,
87+
callbacks: callbackUrls,
88+
type: type,
89+
needTokenAppAsCallback: callbackUrls?.containsAll(tokensCallbackURLs)
90+
)
91+
}
92+
93+
List<String> listClientIdsForUser(String userId) {
94+
listApplicationsForUser(userId).collect { it.clientId }
95+
}
96+
97+
private def addClientIdForUser(String userId, String clientId) {
98+
def putResponse = dynamoDB.putItem(
99+
new PutItemRequest(dynamoDBTable, [(dynamoDBPK): new AttributeValue(userId), (dynamoDBSK): new AttributeValue(clientId)]))
100+
if (putResponse.sdkHttpMetadata.httpStatusCode != 200) {
101+
throw new RuntimeException("Couldn't add mapping for $clientId to $userId")
102+
}
103+
}
104+
105+
private def deleteClientIdForUser(String userId, String clientId) {
106+
def deleteResponse = dynamoDB.deleteItem(
107+
new DeleteItemRequest(dynamoDBTable, [(dynamoDBPK): new AttributeValue(userId), (dynamoDBSK): new AttributeValue(clientId)]))
108+
if (deleteResponse.sdkHttpMetadata.httpStatusCode != 200) {
109+
throw new RuntimeException("Couldn't delete mapping for $clientId to $userId")
110+
}
111+
}
112+
113+
private def getClientByUserIdAndClientId(String userId, String clientId) {
114+
def result = dynamoDB.getItem(dynamoDBTable, [(dynamoDBPK): new AttributeValue(userId), (dynamoDBSK): new AttributeValue(clientId)])
115+
return result.item
116+
}
117+
118+
private def isUserOwnsClientId(String userId, String clientId) {
119+
return getClientByUserIdAndClientId(userId, clientId) != null
120+
}
121+
122+
@Override
123+
ApplicationRecord generateClient(String userId, ApplicationRecord applicationRecord) {
124+
CreateUserPoolClientRequest request = new CreateUserPoolClientRequest().withUserPoolId(poolId)
125+
request.clientName = applicationRecord.name
126+
// TODO enable user consent
127+
if (applicationRecord.type == ApplicationType.M2M) {
128+
request.generateSecret = true
129+
request.allowedOAuthFlows = ["client_credentials"]
130+
} else {
131+
request.generateSecret = applicationRecord.type == ApplicationType.CONFIDENTIAL //do not need secret for public clients
132+
request.allowedOAuthFlows = ["code"]
133+
}
134+
request.supportedIdentityProviders = new ArrayList<>(supportedIdentityProviders)
135+
request.preventUserExistenceErrors = "ENABLED"
136+
request.explicitAuthFlows = new ArrayList<>(authFlows)
137+
request.allowedOAuthFlowsUserPoolClient = true
138+
139+
def scopes = new ArrayList<>(clientScopes)
140+
141+
if (scopes && applicationRecord.type != ApplicationType.M2M) {
142+
request.allowedOAuthScopes = scopes
143+
}
144+
if(applicationRecord.type == ApplicationType.M2M) {
145+
request.allowedOAuthScopes = ["ala/attrs"]
146+
}
147+
148+
request.callbackURLs = new ArrayList<>(applicationRecord.callbacks.findAll{it != ""})
149+
if (applicationRecord.type == ApplicationType.M2M) {
150+
request.callbackURLs = null
151+
}
152+
else if(applicationRecord.needTokenAppAsCallback) {
153+
request.callbackURLs.addAll(tokensCallbackURLs)
154+
}
155+
156+
CreateUserPoolClientResult response = cognitoIdp.createUserPoolClient(request)
157+
158+
if (isSuccessful(response)) {
159+
def clientId = response.userPoolClient.clientId
160+
addClientIdForUser(userId, clientId)
161+
return userPoolClientToApplication(response.userPoolClient)
162+
} else {
163+
throw new RuntimeException("Could not generate client")
164+
}
165+
}
166+
167+
@Override
168+
void updateClient(String userId, ApplicationRecord applicationRecord) {
169+
if (!isUserOwnsClientId(userId, applicationRecord.clientId)) {
170+
throw new IllegalArgumentException("${applicationRecord.clientId} not found")
171+
}
172+
def request = new UpdateUserPoolClientRequest().withUserPoolId(poolId)
173+
request.withClientId(applicationRecord.clientId)
174+
request.withClientName(applicationRecord.name)
175+
request.supportedIdentityProviders = new ArrayList<>(supportedIdentityProviders)
176+
request.preventUserExistenceErrors = "ENABLED"
177+
request.explicitAuthFlows = new ArrayList<>(authFlows)
178+
request.allowedOAuthFlowsUserPoolClient = true
179+
180+
if (applicationRecord.type == ApplicationType.M2M) {
181+
request.allowedOAuthFlows = ["client_credentials"]
182+
} else {
183+
request.allowedOAuthFlows = ["code"]
184+
}
185+
186+
def scopes = new ArrayList<>(clientScopes)
187+
188+
if (scopes && applicationRecord.type != ApplicationType.M2M) {
189+
request.allowedOAuthScopes = scopes
190+
}
191+
if(applicationRecord.type == ApplicationType.M2M) {
192+
request.allowedOAuthScopes = ["ala/attrs"]
193+
}
194+
195+
request.callbackURLs = new ArrayList<>(applicationRecord.callbacks.findAll{it != ""})
196+
if (applicationRecord.type == ApplicationType.M2M) {
197+
request.callbackURLs = null
198+
}
199+
else if(applicationRecord.needTokenAppAsCallback) {
200+
request.callbackURLs.addAll(tokensCallbackURLs)
201+
}
202+
203+
def response = cognitoIdp.updateUserPoolClient(request)
204+
if (!isSuccessful(response)) {
205+
throw new RuntimeException("Could not update client $applicationRecord.clientId")
206+
}
207+
}
208+
209+
@Override
210+
ApplicationRecord findClientByClientId(String userId, String clientId) {
211+
return itemToApplication(getClientByUserIdAndClientId(userId, clientId))
212+
}
213+
214+
private static boolean isSuccessful(AmazonWebServiceResult<? extends ResponseMetadata> result) {
215+
def code = result.sdkHttpMetadata.httpStatusCode
216+
return code >= 200 && code < 300
217+
}
218+
219+
@Override
220+
boolean deleteApplication(String userId, String clientId){
221+
if (!isUserOwnsClientId(userId, clientId)) {
222+
throw new IllegalArgumentException("${clientId} not found")
223+
}
224+
def request = new DeleteUserPoolClientRequest().withUserPoolId(poolId).withClientId(clientId)
225+
226+
def response = cognitoIdp.deleteUserPoolClient(request)
227+
if (!isSuccessful(response)) {
228+
throw new RuntimeException("Could not delete client $clientId")
229+
}
230+
else{
231+
deleteClientIdForUser(userId, clientId)
232+
return true
233+
}
234+
}
235+
}

0 commit comments

Comments
 (0)