Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
- migrated to JWT based authentication from API keys
  • Loading branch information
temi committed Jul 18, 2024
1 parent b8f7728 commit 71640a6
Show file tree
Hide file tree
Showing 30 changed files with 205 additions and 110 deletions.
1 change: 1 addition & 0 deletions grails-app/conf/application.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,7 @@ 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 @@ -18,6 +18,8 @@ grails:
transactionManagement:
proxies: false
app:
clientId:
attribute: "audience"
http:
header:
hostName: "X-ALA-hostname"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +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"])
class ActivityController {

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

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

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


@RequireApiKey
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
def update(String id) {
def props = request.JSON
//log.debug props
Expand Down Expand Up @@ -128,7 +129,7 @@ class ActivityController {
* All activities identified by the supplied ids will have the supplied properties updated.
*
*/
@RequireApiKey
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
def bulkUpdate() {
def ids = params.list("id")
def props = request.JSON
Expand Down Expand Up @@ -309,7 +310,6 @@ class ActivityController {
*
* @return a list of the activities that match the supplied criteria
*/
@RequireApiKey
def search() {
def searchCriteria = request.JSON

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package au.org.ala.ecodata

import au.ala.org.ws.security.SkipApiKeyCheck
import au.org.ala.web.AlaSecured
import grails.converters.JSON
import groovy.json.JsonSlurper
import org.apache.http.HttpStatus

/**
* Responds to requests related to activity forms in ecodata.
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
class ActivityFormController {

static responseFormats = ['json', 'xml']
Expand Down Expand Up @@ -51,6 +51,7 @@ class ActivityFormController {
* @return
*/
@AlaSecured(["ROLE_ADMIN"])
@SkipApiKeyCheck
def update() {

// We are using JsonSlurper instead of request.JSON to avoid JSONObject.Null causing the string
Expand All @@ -66,6 +67,7 @@ class ActivityFormController {
}

@AlaSecured(["ROLE_ADMIN"])
@SkipApiKeyCheck
def create() {
// We are using JsonSlurper instead of request.JSON to avoid JSONObject.Null causing the string
// "null" to be saved in templates (it will happen in any embedded Maps).
Expand All @@ -85,6 +87,7 @@ class ActivityFormController {
* @return the new form.
*/
@AlaSecured(["ROLE_ADMIN"])
@SkipApiKeyCheck
def newDraftForm(String name) {
respond activityFormService.newDraft(name)
}
Expand All @@ -95,6 +98,7 @@ class ActivityFormController {
* @return the new form.
*/
@AlaSecured(["ROLE_ADMIN"])
@SkipApiKeyCheck
def publish(String name, Integer formVersion) {
respond activityFormService.publish(name, formVersion)
}
Expand All @@ -105,11 +109,13 @@ class ActivityFormController {
* @return the new form.
*/
@AlaSecured(["ROLE_ADMIN"])
@SkipApiKeyCheck
def unpublish(String name, Integer formVersion) {
respond activityFormService.unpublish(name, formVersion)
}

@AlaSecured(["ROLE_ADMIN"])
@SkipApiKeyCheck
def findUsesOfForm(String name, Integer formVersion) {
int count = Activity.countByTypeAndFormVersionAndStatusNotEqual(name, formVersion, Status.DELETED)
Map result = [count:count]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class AdminController {
@AlaSecured(["ROLE_ADMIN"])
def tools() {}

@RequireApiKey
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
def syncCollectoryOrgs() {
def errors = collectoryService.syncOrganisations(organisationService)
if (errors)
Expand Down Expand Up @@ -136,7 +136,7 @@ class AdminController {
}
}

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

@RequireApiKey
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
def clearMetadataCache() {
// clear any cached external config
cacheService.clear()
Expand Down Expand Up @@ -525,6 +525,7 @@ class AdminController {
* a test function to index a project.
* @return
*/
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/read"])
def indexProjectDoc() {
if(params.projectId){
def projects = Project.findAllByProjectId(params.projectId)
Expand Down
103 changes: 88 additions & 15 deletions grails-app/controllers/au/org/ala/ecodata/ApiKeyInterceptor.groovy
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
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 @@ -14,6 +34,10 @@ 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 @@ -84,26 +108,16 @@ class ApiKeyInterceptor {

// Allow migration to the AlaSecured annotation.
if (!controllerClass?.isAnnotationPresent(AlaSecured) && !method?.isAnnotationPresent(AlaSecured)) {
List whiteList = buildWhiteList()
List clientIp = getClientIP(request)
boolean ipOk = checkClientIp(clientIp, whiteList)
List whiteList = buildClientIdWhiteList()
String clientId = getClientId() ?: ""
boolean isClientIdOk = checkClientIdInWhiteList(clientId, whiteList)

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

// Support RequireApiKey on top of ip restriction.
if(controllerClass?.isAnnotationPresent(RequireApiKey) || method?.isAnnotationPresent(RequireApiKey)){
def keyOk = commonService.checkApiKey(request.getHeader('Authorization')).valid
if(!keyOk) {
log.warn("No valid api key for ${controllerName}/${actionName}")
result.status = 403
result.error = "not authorised"
}
}
}
}

Expand All @@ -128,6 +142,16 @@ 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 @@ -137,6 +161,15 @@ 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 @@ -152,4 +185,44 @@ 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,6 +4,7 @@ import grails.converters.JSON

import static org.apache.http.HttpStatus.SC_BAD_REQUEST

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

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

import static org.apache.http.HttpStatus.*

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

BulkImportService bulkImportService

static allowedMethods = ['create': 'POST', 'get': 'GET', 'list': 'GET', 'update': ['PUT', 'POST']]

@RequireApiKey
def list() {
String sort = params.sort ?: "lastUpdated"
String order = params.order ?: "desc"
Expand All @@ -35,7 +34,6 @@ class BulkImportController {
}


@RequireApiKey
def create() {
def json = new JsonSlurper().parse( request.inputStream)
if (!json.userId) {
Expand All @@ -51,7 +49,7 @@ class BulkImportController {

}

@RequireApiKey
@au.ala.org.ws.security.RequireApiKey(scopes=["ecodata/write"])
def update() {
def json = new JsonSlurper().parse( request.inputStream)
if (!params.id) {
Expand All @@ -68,7 +66,6 @@ class BulkImportController {
}
}

@RequireApiKey
def get() {
if (!params.id) {
render text: [status: "error", error: "Missing id"] as JSON, status: SC_BAD_REQUEST, contentType: "application/json"
Expand Down
Loading

0 comments on commit 71640a6

Please sign in to comment.