Skip to content

Commit

Permalink
Merge pull request #995 from AtlasOfLivingAustralia/feature/issue3265
Browse files Browse the repository at this point in the history
using configurable scopes - AtlasOfLivingAustralia/fieldcapture#3265
  • Loading branch information
chrisala authored Aug 2, 2024
2 parents 72d16f8 + ce7d109 commit 3501457
Show file tree
Hide file tree
Showing 32 changed files with 112 additions and 209 deletions.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ gormMongoVersion=8.2.0
grailsViewsVersion=2.3.2
assetPipelineVersion=4.3.0
elasticsearchVersion=7.17.21
alaSecurityLibsVersion=6.2.0
alaSecurityLibsVersion=6.3.0-SNAPSHOT
#22.x+ causes issues with mongo / GORM javax.validation.spi, might need grails 5
geoToolsVersion=21.5
#jtsVersion must match the geotools version
Expand Down
1 change: 0 additions & 1 deletion grails-app/conf/application.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,6 @@ environments {
audit.thread.schedule.interval = 500l;

paratoo.core.baseUrl = "http://localhost:${wiremock.port}/monitor"
app.clientId.whiteList = "jwtId"
}
production {
grails.logging.jul.usebridge = false
Expand Down
2 changes: 2 additions & 0 deletions grails-app/conf/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ grails:
app:
clientId:
attribute: "audience"
readScope: ["ecodata/read_test"]
writeScope: ["ecodata/write_test"]
http:
header:
hostName: "X-ALA-hostname"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import grails.converters.JSON

import static au.org.ala.ecodata.ElasticIndex.PROJECT_ACTIVITY_INDEX

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
class ActivityController {

ActivityService activityService
Expand Down Expand Up @@ -48,7 +48,7 @@ class ActivityController {
}
}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def delete(String id) {
boolean destroy = params.destroy == null ? false : params.destroy.toBoolean()
if (activityService.delete(id, destroy).status == 'ok') {
Expand All @@ -59,7 +59,7 @@ class ActivityController {
}
}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def bulkDelete() {
boolean destroy = params.destroy == null ? false : params.destroy.toBoolean()
Map payload = request.JSON
Expand All @@ -78,7 +78,7 @@ class ActivityController {
*
* @param id project activity id
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def deleteByProjectActivity(String id) {
boolean destroy = params.destroy == null ? false : params.destroy.toBoolean()
Map result = activityService.deleteByProjectActivity(id, destroy)
Expand All @@ -93,8 +93,7 @@ class ActivityController {
}
}


@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def update(String id) {
def props = request.JSON
//log.debug props
Expand Down Expand Up @@ -129,7 +128,7 @@ class ActivityController {
* All activities identified by the supplied ids will have the supplied properties updated.
*
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def bulkUpdate() {
def ids = params.list("id")
def props = request.JSON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import org.apache.http.HttpStatus
/**
* Responds to requests related to activity forms in ecodata.
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
class ActivityFormController {

static responseFormats = ['json', 'xml']
Expand Down
13 changes: 5 additions & 8 deletions grails-app/controllers/au/org/ala/ecodata/AdminController.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ class AdminController {

@AlaSecured(["ROLE_ADMIN"])
def tools() {}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
def syncCollectoryOrgs() {
def errors = collectoryService.syncOrganisations(organisationService)
if (errors)
Expand Down Expand Up @@ -135,8 +134,7 @@ class AdminController {
stream?.close()
}
}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
def getBare(String entity, String id) {
def map = [:]
switch (entity) {
Expand All @@ -157,14 +155,13 @@ class AdminController {
/**
* Re-index all docs with ElasticSearch
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def reIndexAll() {
def resp = elasticSearchService.indexAll()
flash.message = "Search index re-indexed - ${resp?.size()} docs"
render "Indexing done"
}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def clearMetadataCache() {
// clear any cached external config
cacheService.clear()
Expand Down Expand Up @@ -525,7 +522,7 @@ class AdminController {
* a test function to index a project.
* @return
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
def indexProjectDoc() {
if(params.projectId){
def projects = Project.findAllByProjectId(params.projectId)
Expand Down
95 changes: 0 additions & 95 deletions grails-app/controllers/au/org/ala/ecodata/ApiKeyInterceptor.groovy
Original file line number Diff line number Diff line change
@@ -1,30 +1,10 @@
package au.org.ala.ecodata

import au.org.ala.web.AlaSecured
import au.org.ala.ws.security.authenticator.AlaOidcAuthenticator
import au.org.ala.ws.security.client.AlaOidcClient
import com.nimbusds.jose.proc.JWSKeySelector
import com.nimbusds.jose.proc.JWSVerificationKeySelector
import com.nimbusds.jose.proc.SecurityContext
import com.nimbusds.jwt.JWT
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.JWTParser
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier
import com.nimbusds.jwt.proc.DefaultJWTProcessor
import com.nimbusds.oauth2.sdk.token.AccessToken
import grails.converters.JSON
import grails.web.http.HttpHeaders
import org.pac4j.core.config.Config
import org.pac4j.core.context.WebContext
import org.pac4j.core.profile.UserProfile
import org.pac4j.core.util.FindBest
import org.pac4j.jee.context.JEEContextFactory
import org.pac4j.oidc.credentials.OidcCredentials
import org.springframework.beans.factory.annotation.Autowired

import javax.servlet.http.HttpServletRequest
import java.text.ParseException

class ApiKeyInterceptor {

Expand All @@ -34,10 +14,6 @@ class ApiKeyInterceptor {
PermissionService permissionService
CommonService commonService
ActivityService activityService
@Autowired(required = false)
AlaOidcClient alaOidcClient
@Autowired(required = false)
Config config

int order = -100 // This can go before the ala-ws-security interceptor to do the IP check

Expand Down Expand Up @@ -119,18 +95,6 @@ class ApiKeyInterceptor {
result.status = 403
result.error = "not authorised"
}

// claims check
List whiteListClientId = buildClientIdWhiteList()
String clientId = getClientId() ?: ""
boolean isClientIdOk = checkClientIdInWhiteList(clientId, whiteListClientId)

// All request without PreAuthorise annotation needs to be secured by IP for backward compatibility
if (!isClientIdOk) {
log.warn("Non-authorised client id - ${clientId}" )
result.status = 403
result.error = "not authorised"
}
}
}

Expand All @@ -155,16 +119,6 @@ class ApiKeyInterceptor {
clientIps.size() > 0 && whiteList.containsAll(clientIps) || (whiteList.size() == 1 && whiteList[0] == LOCALHOST_IP)
}

/**
* Client ID passes if it is in the whitelist. Fails if whitelist is empty.
* @param clientId
* @param whiteList
* @return
*/
boolean checkClientIdInWhiteList(String clientId, List whiteList) {
whiteList.size() > 0 && clientId?.size() > 0 && whiteList.contains(clientId)
}

private List buildWhiteList() {
def whiteList = [LOCALHOST_IP] // allow calls from localhost to make testing easier
def config = grailsApplication.config.getProperty('app.api.whiteList')
Expand All @@ -174,15 +128,6 @@ class ApiKeyInterceptor {
whiteList
}

private List buildClientIdWhiteList() {
def whiteList = [] // allow calls from localhost to make testing easier
def config = grailsApplication.config.getProperty('app.clientId.whiteList')
if (config) {
whiteList.addAll(config.split(',').collect({it.trim()}))
}
whiteList
}

private List getClientIP(HttpServletRequest request) {
// External requests to ecodata are proxied by Apache, which uses X-Forwarded-For to identify the original IP.
// From grails 5, tomcat started returning duplicate headers as a comma separated list. When a download
Expand All @@ -198,44 +143,4 @@ class ApiKeyInterceptor {
return allIps
}

private String getClientId() {
final WebContext context = FindBest.webContextFactory(null, config, JEEContextFactory.INSTANCE).newContext(request, response)
def optCredentials = alaOidcClient.getCredentials(context, config.sessionStore)
if (optCredentials.isPresent()) {
OidcCredentials credentials = optCredentials.get()
return parseClientId(credentials.getAccessToken())
}
}

private String parseClientId(AccessToken accessToken) {
if (accessToken) {
String clientIdClaim = grailsApplication.config.getProperty("app.clientId.attribute")
final JWT jwt
try {
jwt = JWTParser.parse(accessToken.getValue())
} catch (ParseException e) {
log.error("Cannot decrypt / verify JWT")
return null
}

ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<SecurityContext>()
AlaOidcAuthenticator authenticator = alaOidcClient.getAuthenticator()
// Configure the JWT processor with a key selector to feed matching public
// RSA keys sourced from the JWK set URL
JWSKeySelector<SecurityContext> keySelector = new JWSVerificationKeySelector<SecurityContext>(authenticator.getExpectedJWSAlgs(), authenticator.getKeySource())
jwtProcessor.setJWSKeySelector(keySelector)

// Set the required JWT claims for access tokens issued by the server
jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier(new JWTClaimsSet.Builder().issuer(authenticator.issuer.getValue()).build(), Set.copyOf(authenticator.requiredClaims)))

try {
JWTClaimsSet claimsSet = jwtProcessor.process(jwt, null)
return (String) claimsSet.getClaim(clientIdClaim)
}
catch (Exception e) {
log.error("Cannot decrypt / verify JWT")
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import grails.converters.JSON

import static org.apache.http.HttpStatus.SC_BAD_REQUEST

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
class AuditController {

def userService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import grails.converters.JSON
import groovy.json.JsonSlurper

import static org.apache.http.HttpStatus.*
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
class BulkImportController {

BulkImportService bulkImportService
Expand Down Expand Up @@ -34,6 +34,7 @@ class BulkImportController {
}


@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def create() {
def json = new JsonSlurper().parse( request.inputStream)
if (!json.userId) {
Expand All @@ -49,7 +50,7 @@ class BulkImportController {

}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def update() {
def json = new JsonSlurper().parse( request.inputStream)
if (!params.id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import grails.converters.JSON
import groovy.json.JsonSlurper

import static org.apache.http.HttpStatus.*;
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
class CommentController {
CommentService commentService

Expand Down Expand Up @@ -81,7 +81,7 @@ class CommentController {

}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def update() {
def jsonSlurper = new JsonSlurper()
def json = jsonSlurper.parse(request.getReader())
Expand Down Expand Up @@ -114,7 +114,7 @@ class CommentController {
}
}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def delete() {
if (!params.id) {
response.sendError(SC_BAD_REQUEST, "Missing id");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package au.org.ala.ecodata

import org.apache.http.HttpStatus
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
class DataSetSummaryController {

static responseFormats = ['json', 'xml']
Expand All @@ -10,7 +10,7 @@ class DataSetSummaryController {
ProjectService projectService

/** Updates a single dataset for a project */
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def update(String projectId) {
Map dataSet = request.JSON
projectId = projectId ?: dataSet.projectId
Expand All @@ -36,7 +36,7 @@ class DataSetSummaryController {
* This method expects the projectId to be supplied via the URL and the data sets to be supplied in the request
* body as a JSON object with key="dataSets" and value=List of data sets.
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def bulkUpdate(String projectId) {
Map postBody = request.JSON
List dataSets = postBody?.dataSets
Expand All @@ -56,7 +56,7 @@ class DataSetSummaryController {
respond projectService.updateDataSets(projectId, dataSets)
}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def delete(String projectId, String dataSetId) {
if (!projectId || !dataSetId) {
render status: HttpStatus.SC_BAD_REQUEST, text: "projectId and dataSetId are required"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import org.springframework.web.multipart.MultipartHttpServletRequest

import static au.org.ala.ecodata.ElasticIndex.PROJECT_ACTIVITY_INDEX
import static au.org.ala.ecodata.Status.ACTIVE
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.readScope"])
class DocumentController {

DocumentService documentService
Expand Down Expand Up @@ -129,7 +129,7 @@ class DocumentController {
asJson searchResults
}

@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def delete(String id) {
Document document = Document.findByDocumentId(id)
if (document) {
Expand Down Expand Up @@ -158,7 +158,7 @@ class DocumentController {
* an update.
* @param id The ID of an existing document to update. If not present, a new Document will be created.
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
@au.ala.org.ws.security.RequireApiKey(scopesFromProperty=["app.writeScope"])
def update(String id) {
def props = null
def stream = null
Expand Down
Loading

0 comments on commit 3501457

Please sign in to comment.