diff --git a/.travis.yml b/.travis.yml index a5bdd779..6358d278 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ branches: only: - master - develop + - /^epic\/.*\/develop$/ - /^feature.*$/ before_cache: - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock diff --git a/README.md b/README.md index 6fba6d19..0b82eb7b 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,39 @@ -# ala-ws-security-plugin -Web service specific security code, e.g. API Key filters +# ala-security-libs +Java libraries and Grails plugins for authentication and authorization with backwards compatibility to previous +ALA plugins -## Status -[![Build Status](https://travis-ci.org/AtlasOfLivingAustralia/ala-ws-security-plugin.svg?branch=master)](https://travis-ci.org/AtlasOfLivingAustralia/ala-ws-security-plugin) +Usage +----- -## Usage -``` -compile "org.grails.plugins:ala-ws-security-plugin:4.0.0-SNAPSHOT" -``` - -### JWT Usage +The current version of these libraries is: `6.0.0-SNAPSHOT`. -From the client side, send an Authorization: Bearer request _header_ on all secured service requests, with a JWT access token issued by an OIDC IdP as the payload. +To ensure that various plugins and libraries and self-consistent, a project should use the same version for +each of the plugins and libraries that it consumes, eg for a Grails project: -On the server side, the legacy `@RequireApiKey` annotations will still be honoured, but will -look for a JWT in the request first before optionally falling back to the legacy behaviour. - -Optionally, you may add a `scopes` parameter to the `@RequireApiKey` annotation, to enforce incoming JWT -requests to have the given scopes (ie, an app might have a `read:appname` scope defined for reading from its API) +```gradle.properties +alaSecurityLibsVersion=6.0.0-SNAPSHOT +``` -### Legacy Usage +```build.gradle +dependencies { + implementation "org.grails.plugins:ala-auth-plugin:$alaSecurityLibsVersion" + implementation "org.grails.plugins:ala-ws-plugin:$alaSecurityLibsVersion" + implementation "org.grails.plugins:ala-ws-security-plugin:$alaSecurityLibsVersion" +} +``` -From the client side, set the ```apiKey``` request _header_ on all secured service requests to a valid API Key (registered in the API Key service). +Components +---------- -On the server side, annotate protected controllers (either the class or individual methods) with the ```RequireApiKey``` annotation. +This project contains all of the following previously separate ALA Grails plugins and libs: -## External configuration properties +- [ala-auth-plugin](ala-auth) - For interactively authenticating users +- [ala-ws-plugin](ala-ws-plugin) - For adding authenticated tokens to outgoing web service requests +- [ala-ws-security-plugin](ala-ws-security-plugin) - For adding access token authentication for web services +- [userdetails-service-client](userdetails-service-client) - For contacting userdetails web services -### JWT support -- ```security.jwt.enabled``` - Defaults to true. True indicates the plugin should check for JWTs on incoming requests. -- ```security.jwt.fallback-to-legacy-behaviour``` - Defaults to true. True indicates that if no JWT is present on a request, legacy api keys will be checked instead. -- ```security.jwt.discovery-uri``` - The discovery URI of the OIDC provider. JWT validation will be bootstrapped from this document. -- ```security.jwt.connect-timeout-ms``` - HTTP request connection timeout -- ```security.jwt.read-timeout-ms``` - HTTP request read timeout -- ```security.jwt.required-claims``` - The claims that must be present on the JWT for it to be valid. By default this is `"sub", "iat", "exp", "nbf", "cid", "jti"` -- ```security.jwt.required-scopes``` - List of scopes that are required for all JWT endpoints in this app +In addition there is support for Spring Boot apps using the same underlying libraries and code in: -### Mandatory -- ```security.apikey.check.serviceUrl``` - URL of the API Key service endpoint, up to and including the key parameter name. E.g. https://auth.ala.org.au/apikey/ws/check?apikey= +- [ala-ws-spring-security](ala-ws-spring-security) -### Optional -- ```security.apikey.ip.whitelist``` - comma separated list of IP Addresses that are exempt from the API key security check. -- ```security.apikey.header.override ``` - override the default request header name (apiKey) to use a different name. -## Changelog -- **Version 4.0.0** - - Grails 4 version - - Add JWT support -- **Version 2.0** - - Grails 3 version -- **Version 1.0** (2/7/2015) - - Initial release. - - Includes a grails filter and a ```RequireApiKey``` annotation for securing web service calls via the ALA API Key infrastructure. diff --git a/ala-auth/.gitignore b/ala-auth/.gitignore new file mode 100644 index 00000000..cb013d6e --- /dev/null +++ b/ala-auth/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +*.zip +*.xml +*.iml +*.sha1 +*.tld + +.gradle/ +.idea/ +target/ +build/ +out/ diff --git a/ala-auth/.travis.yml b/ala-auth/.travis.yml new file mode 100644 index 00000000..c37d0018 --- /dev/null +++ b/ala-auth/.travis.yml @@ -0,0 +1,33 @@ +language: groovy +dist: focal +jdk: +- openjdk11 +sudo: false +branches: + only: + - master + - grails2 + - 4.0.x + - 3.2.x + - 3.1.x + - 3.0.x + - 2.1.x + - gateway-experiment + - /^feature.*$/ +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +cache: + directories: + - $HOME/.m2 + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ +before_install: + - ./gradlew classes testClasses +after_success: + - '[ "${TRAVIS_PULL_REQUEST}" = "false" ] && travis_retry ./gradlew publish' +env: + global: + - JAVA_TOOL_OPTIONS=-Dhttps.protocols=TLSv1.2 + - secure: UHC6qsBSvqs6VQbP1WWCDUtt+PXssJLlK3s8/1VB7TmIJEHy/f6r7HSZoFPA9p9UrAPWpeS0w1uiETKjhhC+842b/hNCa/0e4aM8hE/S8WQ9ic72T7DSnxx7s2tSBVANr/Qd9o2drbyophRVCFL7In2meaFIzAApmBI2aZ/Wn2w= + - secure: AZdwRjNv371AiUx1b7YdPhIjAY0aE74UQYS8arU46ony3FQObwGoiqii/qP7yKo+EynZvPR21azGC5FWAIInvy7rht6qiO1euQj9jK99H035nDd+OpWev54W1Fu7NlFgA44EQiPbCt1FtdI0BamgMs3OmyY4+wpj1pJBp7FwdPk= diff --git a/ala-auth/README.md b/ala-auth/README.md new file mode 100644 index 00000000..e6777a0b --- /dev/null +++ b/ala-auth/README.md @@ -0,0 +1,275 @@ +# ala-auth-plugin +## Usage +``` +compile "org.grails.plugins:ala-auth:6.0.0-SNAPSHOT" +``` + +## Description +ALA authentication/authorization Grails 5 plugin interface to CAS. +The Grails 3 version of this plugin can be found under the 3.x branches in the [ala-auth-plugin](https://github.com/AtlasOfLivingAustralia/ala-auth-plugin) repository . +The Grails 2 version of this plugin can be found on the grails2 branch in the [ala-auth-plugin](https://github.com/AtlasOfLivingAustralia/ala-auth-plugin) repository. + +### Upgrade notes + +Grails 4 version of the plugin now provides a OpenID Connect option for authenticating users. + +*NOTE*: This plugin currently requires JDK11 due to the use of PAC4j, which itself requires JDK11. + +To enable it, you must disable CAS and enable OpenID Connect like so: + +```yaml +security: + cas: + enabled: false + oidc: + enabled: true +``` + +To configure the OpenID Connect provider, you may set the following properties: + +```yaml +security: + oidc: + discovery-uri: 'https://auth.ala.org.au/cas/oidc/.well-known' + client-id: 'ChangeMe' + secret: 'ChangeMe' + scope: 'openid profile email ala roles' +``` + +`discovery-uri` can use auth-test or auth-dev instead. + +The scopes available are: + - `openid` must be present for OpenID Connect + - `profile` contains the user's name + - `email` contains the user's email + - `ala` contains ALA extended attributes + - `roles` to get the user's roles. + +## Usage + +Select one of CAS or OpenID Connect authentication and then follow the guide for that +auth system. Note that OIDC is preferred going forward. + +### Setup OpenID Connect Authentication for your app + +To configure the OpenID Connect provider, you may set the following properties: + +```yaml +security: + cas: + enabled: false // default is true, undefined behaviour if this omitted + oidc: + enabled: true // default is false + discovery-uri: 'https://auth.ala.org.au/cas/oidc/.well-known' + client-id: 'ChangeMe' + secret: 'ChangeMe' + scope: 'openid profile email ala roles' + with-state: true // set to false to disable use of state parameter in login + logout-action: DEFAULT // use COGNITO for cognito non-standard logout + logout-url: // omit if OIDC provider has end_session_endpoint in discovery doc, otherwise provide here + ala-userid-claim: // add this if the legacy ala userid is in a custom claim + user-name-claim: // add this to prefer a non-standard claim for the user's username + display-name-claim: name // add this override using 'name' as the claim for the user's display name or set to null to calculate display name from first and last names + core: + default-logout-redirect-uri: '/' // App relative URI to redirect to after OIDC logout + auth-cookie-name: 'ALA-Auth' // not supported with cognito + uri-filter-pattern: ['/paths/*','/that','/require/*,'/auth/*'] // Java servlet filter style paths only + authenticate-only-if-cookie-filter-pattern: ['/optional-auth/*'] // Will force OIDC auth if the Auth Cookie is defined + gateway-filter-pattern: ['/api/*'] // Use OIDC prompt=none + gateway-if-cookie-filter-pattern: ['/sso-only/*'] // Use OIDC prompt=none for these paths if the Auth Cookie is defined + uri-exclusion-filter-pattern: ['/paths/anonymous/.*', 'https?://.*/.*\?ignoreCas=true'] // Regex URLs supported, only necessary to exclude a path from one / all of the above. +``` + +For ease of transition, the following old property names are accepted for configuring the OIDC authn: + +```yaml +security: + cas: + uriFilterPattern: ['/paths/*','/that','/require/*,'/auth/*'] // Java servlet filter style paths only + authenticateOnlyIfCookieFilterPattern: ['/optional-auth/*'] // Will force OIDC auth if the Auth Cookie is defined + gatewayFilterPattern: ['/api/*'] // Use OIDC prompt=none + gatewayIfCookieFilterPattern: ['/sso-only/*'] // Use OIDC prompt=none for these paths if the Auth Cookie is defined + uriExclusionFilterPattern: ['/paths/anonymous/.*', 'https?://.*/.*\?ignoreCas=true'] // Regex URLs supported, only necessary to exclude a path from one / all of the above. +``` + +For local development, dev and test deployments `discovery-uri` should be set to auth-test or auth-dev instead. + +The scopes available are: +- `openid` must be present for OpenID Connect +- `profile` contains the user's name +- `email` contains the user's email +- `ala` contains ALA extended attributes +- `roles` to get the user's roles. + +#### Register your OpenID Connect app + +Head to the CAS Management app and add an OpenID Connect Relying Party. For local development, the dev and test environments +may already have existing RPs that you can use. + +- Add the redirect URI, which is regex, so can be of the form `https?://app.ala.org.au/.*` +- Ensure the JWKS is set to the correct path to the JWKS +- Ensure that the scopes list all the scopes required by your application +- To participate in Single Sign Out, add a logout handler URL of `/callback?logoutendpoint` + +Take the client id and secret from the RP registration and add them to your app's external config under +`security.oidc.client-id` and `security.oidc.secret`. + +### Setup CAS Authentication for your app + +In your `application.yml` or `application.groovy` you should define the following +properties: + +**NOTE** `uriFilterPattern`, `authenticateOnlyIfLoggedInFilterPattern` and `uriExclusionFilterPattern` have changed: + - All properties are now lists instead of comma separated strings, + - Only the `uriExclusionFilterPattern` supports regexes now, all others only support Java Servlet Filter paths, + +```groovy +security { + cas { + appServerName = 'http://devt.ala.org.au:8080' // or similar, up to the request path part + uriFilterPattern = ['/paths/*','/that','/require/*,'/auth/*'] // Java servlet filter style paths only + authenticateOnlyIfCookieFilterPattern = ['/optional-auth/*'] // Will force CAS auth if the Auth Cookie is defined + gatewayFilterPattern = ['/api/*'] // Use CAS gateway requests for these paths + gatewayIfCookieFilterPattern = ['/sso-only/*'] // Uses CAS gateway requests for these paths if the Auth Cookie is defined + uriExclusionFilterPattern = ['/paths/anonymous/.*', 'https?://.*/.*\?ignoreCas=true'] // Regex URLs supported, only necessary to exclude a path from one / all of the above. + } +} +``` + +**NOTE** If setting `security.cas.appServerName` only and a scheme / port number is not specified: ensure that the app +server (eg Tomcat) is receiving the correct remote scheme / port from any reverse proxy (eg by using the AJP protocol +or enabling the Tomcat Remote IP Valve and the appropriate headers from the RP) otherwise the CAS filter will get +confused trying to generate the service url for the CAS callback. + +The remaining properties should have sensible default values that are provided by this plugin. You can +override these if you wish, however: + +```yaml +security: + cas: + casServerName: https://auth.ala.org.au + casServerUrlPrefix: https://auth.ala.org.au/cas + loginUrl: https://auth.ala.org.au/cas/login + logoutUrl: https://auth.ala.org.au/cas/logout + bypass: false + roleAttribute: authority + ignoreCase: true + renew: false + authCookieName: 'ALA-Auth' +``` + +`ala-cas-client` v2.3+ will now get the context path from the Servlet Context, so that property is +no longer required. + +### (Optional) Replace or add authentication using the @SSO annotation + +Instead of (or as well as) specifying URIs for authentication using the `security.cas.uriFilterPattern` property (and +friends), v3.2 of the plugin introduces applying an `@SSO` annotation to a controller or action, allowing said +controller/action to always ensure authentication via CAS regardless of how it's defined in URL mapping. + +The `@SSO` annotation also supports the following arguments: + + - `gateway` - Use a CAS gateway request + - `cookie` - Only force authentication if the ALA Auth cookie is present + +If `@SSO` is applied to a controller and an action within the controller doesn't require authentication +then a corresponding `@NoSSO` annotation may be used to opt out of the authentication for that action, eg: + +```groovy + +@SSO +class TestController { + + def index() { + log.debug('username should always be present: {}', request.userName) + } + + @NoSSO + def info() { + log.debug('username may not be present if this action is accessed directly: {}', request.userName) + } + +} +``` + +Also note that if a URI in `security.cas.uriFilterPattern` covers a controller / action which has been annotated then the +`security.cas.uriFilterPattern` version will take precedence (with regard to gateway / cookie settings) + +### (Optional) Add role based authorization using the @AlaSecured annotation + +On a Grails controller, you can use the `@AlaSecured` annotation to do role based authorization for +Grails actions. + +### Use a different user details location + +You can change the base address of the UserDetails web services by overriding the following config value: + +```groovy +userDetails.url = 'https://auth.ala.org.au/userdetails/' +``` + +### AuthService configuration + +The AuthService Grails service calls web services on the UserDetails application. To ensure that these +calls include a bearer access token you must provide a `jwtInterceptor` bean. This bean should implement the +okhttp interceptor interface and insert a Bearer token in the Authorization header containing a client +credentials grant with the required scopes for the service (typically `users:read`). If the `ala-ws-plugin` +is also used, then this step is performed automatically. + +### Migration from 1.x + +See [this page](https://github.com/AtlasOfLivingAustralia/ala-auth-plugin/wiki/1.x-Migration-Guide) on the wiki for steps to upgrade from 1.x. + +## Changelog +- **Version 6.0.0** + - Update to Grails 5 base +- **Version 5.1.1**(5/08/2022): + - Fix login controller storing redirect URL +- **Version 5.1.0**(25/07/2022): + - Better Grails 5 experience + - Update pac4j + - Add OIDC SLO support for spring session + - Better support for custom ALA userid attribute + - Minor fixes and improvements +- **Version 5.0.0** (11/02/2022): + - Support Grails 4 + - Support OIDC login +- **Version 3.2.3** (10/02/2021): + - Updated `loginLogout`, supports Grails 3.x apps +- **Version 3.1.3** (10/02/2021): + - Updated `loginLogout`, supports Grails 3.x apps +- **Version 3.0.5** (10/02/2021): + - Updated `loginLogout`, supports Grails apps on ala-auth-plugin 3.0.x +- **Version 2.1.6** (10/02/2021): + - Updated `loginLogout`, supports Grails 2.x apps + +- **Version 3.2.0**: + - Add support for using CAS gateway requests for certain paths + - Convert paths to Java Servlet Filter paths + - Add authentication via annotation +- **Version 3.1.0**: + - Updates for ALA CAS 5 + - Update ALA CAS client + - Update userdetails-service-client + - Always use the CAS HttpServletRequestFilter to put the CAS principal in the request scope if it's available. +- **Version 3.0.3**: + - Fix @Cacheable annotations to use the Grails versions instead of Spring +- **Version 3.0.2**: + - Fix CAS filter registration order WRT the Grails Character Encoding filter. +- **Version 3.0.1**: + - Support both `authenticateOnlyIfLoggedInPattern` and `authenticateOnlyIfLoggedInFilterPattern` properties for the only if previously logged in filter. +- **Version 3.0.0**: + - Upgrade to Grails 3 + - Use userdetails-service-client in preference to HttpWebService class + - Use ala-cas-client 2.3 changes + - Move servlet context init-param and filter setup into Spring, allowing them to use properties directly from Application.groovy +- **Version 1.3** (07/05/2015): + - Fixed several URL encoding issues + - Add support for extra user properties in userdetails web services + - Add missing config.userDetailsById.bulkPath default setting +- **Version 1.2** (24/02/2015): + - Excludes the servlet-api dependency from being resolved as a dependency in the host app +- **Version 1.1** (23/02/2015): + - Added `loginLogout` taglib method +- **Version 1.0** (18/02/2015): + - Initial release. diff --git a/ala-auth/build.gradle b/ala-auth/build.gradle new file mode 100644 index 00000000..6bb215f8 --- /dev/null +++ b/ala-auth/build.gradle @@ -0,0 +1,175 @@ +buildscript { + repositories { + mavenLocal() + maven { url "https://nexus.ala.org.au/content/groups/public/" } + maven { url "https://repo.grails.org/grails/core" } + } + dependencies { + classpath "org.grails:grails-gradle-plugin:$grailsGradlePluginVersion" +// classpath "com.bertramlabs.plugins:asset-pipeline-gradle:3.3.4" + } +} + +apply plugin:"eclipse" +apply plugin:"idea" +apply plugin:"org.grails.grails-plugin" +//apply plugin:"asset-pipeline" +apply plugin:"org.grails.grails-gsp" +apply plugin: 'maven-publish' + + +group 'org.grails.plugins' + +repositories { + mavenLocal() + maven { url "https://nexus.ala.org.au/content/groups/public/" } + maven { url "https://repo.grails.org/grails/core" } + mavenCentral() +} + +configurations { + developmentOnly + runtimeClasspath { + extendsFrom developmentOnly + } +} + +configurations.all { + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + +//dependencyManagement { +// imports { +// mavenBom "org.grails:grails-bom:$grailsVersion" +// } +// applyMavenExclusions false +//} + +dependencies { + developmentOnly("org.springframework.boot:spring-boot-devtools") + implementation "org.springframework.boot:spring-boot-starter-logging" + implementation "org.springframework.boot:spring-boot-autoconfigure" + implementation "org.grails:grails-core" + implementation "org.springframework.boot:spring-boot-starter-actuator" + implementation "org.springframework.boot:spring-boot-starter-tomcat" + implementation "org.grails:grails-web-boot" + implementation "org.grails:grails-logging" + implementation "org.grails:grails-plugin-rest" + implementation "org.grails:grails-plugin-databinding" + implementation "org.grails:grails-plugin-i18n" + implementation "org.grails:grails-plugin-services" + implementation "org.grails:grails-plugin-url-mappings" + implementation "org.grails:grails-plugin-interceptors" + + implementation "org.grails.plugins:cache" + implementation 'org.grails.plugins:cache-ehcache:3.0.0' + + implementation "org.grails.plugins:async" + implementation "org.grails.plugins:scaffolding" + implementation "org.grails.plugins:gsp" + compileOnly "io.micronaut:micronaut-inject-groovy" + console "org.grails:grails-console" + profile "org.grails.profiles:web-plugin" +// runtime "com.bertramlabs.plugins:asset-pipeline-grails:3.3.4" + testImplementation "io.micronaut:micronaut-inject-groovy" + testImplementation "org.grails:grails-gorm-testing-support" + testImplementation "org.mockito:mockito-core" + testImplementation "org.grails:grails-web-testing-support" + + implementation project(':userdetails-service-client') + testImplementation "com.squareup.retrofit2:retrofit-mock:2.9.0" + + implementation 'au.org.ala:ala-cas-client:3.0.0' + + implementation 'au.org.ala.grails:interceptor-annotation-matcher:1.0.0' + + implementation(pac4j.oidc) + implementation(pac4j.jee) + implementation(pac4j.jee.support) + + // Not required but this plugin can react to the presence of Spring Session + compileOnly 'org.springframework.session:spring-session-core' + compileOnly 'org.springframework.session:spring-session-data-mongodb' + compileOnly 'org.springframework.boot:spring-boot-starter-data-mongodb' + + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + compileOnly "org.springframework.boot:spring-boot-configuration-processor" + + implementation group: 'com.github.ben-manes.caffeine', name: 'caffeine', version: '2.7.0' + +} + +compileGroovy { + groovyOptions.javaAnnotationProcessing = true +} + +tasks.withType(GroovyCompile) { + configure(groovyOptions) { + forkOptions.jvmArgs = ['-Xmx1024m'] + } +} + +compileJava.dependsOn(processResources) + +bootRun { + ignoreExitValue true + jvmArgs( + '-Dspring.output.ansi.enabled=always', + '-noverify', + '-XX:TieredStopAtLevel=1', + '-Xmx1024m') + sourceResources sourceSets.main + String springProfilesActive = 'spring.profiles.active' + systemProperty springProfilesActive, System.getProperty(springProfilesActive) +} + +tasks.withType(Test) { + useJUnitPlatform() +} +// enable if you wish to package this plugin as a standalone application +bootJar.enabled = false + +publishing { + repositories { + maven { + name 'Nexus' + url "https://nexus.ala.org.au/content/repositories/${project.version.endsWith('-SNAPSHOT') ? 'snapshots' : 'releases' }" + credentials { + username = System.getenv('TRAVIS_DEPLOY_USERNAME') + password = System.getenv('TRAVIS_DEPLOY_PASSWORD') + } + } + } + publications { + maven(MavenPublication) { + from components.java + artifact sourcesJar + artifact javadocJar + + pom { + name = 'ALA Auth Plugin' + description = 'Authentication for ALA Apps' + url = 'https://github.com/AtlasOfLivingAustralia/ala-auth-plugin' + licenses { + license { + name = 'MPL-1.1' + url = 'https://www.mozilla.org/en-US/MPL/1.1/' + } + } + developers { + developer { + id = 'sbearcsiro' + name = 'Simon Bear' + email = 'simon.bear@csiro.au' + } + } + scm { + connection = 'scm:git:git://github.com/AtlasOfLivingAustralia/ala-auth-plugin.git' + developerConnection = 'scm:git:ssh://github.com:AtlasOfLivingAustralia/ala-auth-plugin.git' + url = 'https://github.com/AtlasOfLivingAustralia/ala-auth-plugin/tree/main' + } + } + + } + } +} diff --git a/ala-auth/gradle.properties b/ala-auth/gradle.properties new file mode 100644 index 00000000..836024c3 --- /dev/null +++ b/ala-auth/gradle.properties @@ -0,0 +1,8 @@ +#Thu Jan 12 15:48:27 AEDT 2017 +grailsVersion=5.2.1 +grailsGradlePluginVersion=5.2.1 +groovyVersion=3.0.11 +gorm.version=7.3.2 +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1024M \ No newline at end of file diff --git a/ala-auth/gradle/wrapper/gradle-wrapper.jar b/ala-auth/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..5c2d1cf0 Binary files /dev/null and b/ala-auth/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ala-auth/gradle/wrapper/gradle-wrapper.properties b/ala-auth/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..7c08e4f0 --- /dev/null +++ b/ala-auth/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/ala-auth/gradlew b/ala-auth/gradlew new file mode 100755 index 00000000..83f2acfd --- /dev/null +++ b/ala-auth/gradlew @@ -0,0 +1,188 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/ala-auth/gradlew.bat b/ala-auth/gradlew.bat new file mode 100755 index 00000000..24467a14 --- /dev/null +++ b/ala-auth/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ala-auth/grails-app/conf/application.yml b/ala-auth/grails-app/conf/application.yml new file mode 100644 index 00000000..60c1f001 --- /dev/null +++ b/ala-auth/grails-app/conf/application.yml @@ -0,0 +1,10 @@ +grails: + profile: web-plugin + codegen: + defaultPackage: au.org.ala.web +info: + app: + name: '@info.app.name@' + version: '@info.app.version@' + grailsVersion: '@info.app.grailsVersion@' + diff --git a/ala-auth/grails-app/conf/logback.groovy b/ala-auth/grails-app/conf/logback.groovy new file mode 100644 index 00000000..30b4d60a --- /dev/null +++ b/ala-auth/grails-app/conf/logback.groovy @@ -0,0 +1,39 @@ +import grails.util.BuildSettings +import grails.util.Environment +import org.springframework.boot.logging.logback.ColorConverter +import org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter + +import java.nio.charset.Charset + +conversionRule 'clr', ColorConverter +conversionRule 'wex', WhitespaceThrowableProxyConverter + +// See http://logback.qos.ch/manual/groovy.html for details on configuration +appender('STDOUT', ConsoleAppender) { + encoder(PatternLayoutEncoder) { + charset = Charset.forName('UTF-8') + + pattern = + '%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} ' + // Date + '%clr(%5p) ' + // Log level + '%clr(---){faint} %clr([%15.15t]){faint} ' + // Thread + '%clr(%-40.40logger{39}){cyan} %clr(:){faint} ' + // Logger + '%m%n%wex' // Message + } +} + +def targetDir = BuildSettings.TARGET_DIR +if (Environment.isDevelopmentMode() && targetDir != null) { + appender("FULL_STACKTRACE", FileAppender) { + file = "${targetDir}/stacktrace.log" + append = true + encoder(PatternLayoutEncoder) { + pattern = "%level %logger - %msg%n" + } + } + logger("StackTrace", ERROR, ['FULL_STACKTRACE'], false) + root(ERROR, ['STDOUT', 'FULL_STACKTRACE']) +} +else { + root(ERROR, ['STDOUT']) +} diff --git a/ala-auth/grails-app/conf/plugin.groovy.old b/ala-auth/grails-app/conf/plugin.groovy.old new file mode 100644 index 00000000..987d1df9 --- /dev/null +++ b/ala-auth/grails-app/conf/plugin.groovy.old @@ -0,0 +1,62 @@ +import au.org.ala.cas.client.AjaxAwareGatewayStorage +import org.jasig.cas.client.authentication.DefaultGatewayResolverImpl + +userDetails { + url = 'https://auth.ala.org.au/userdetails/' + readTimeout = 0 // disable read timeouts for user details because some services are slooooow... +} +security { + core { + permissionsAttributes = ['scopes'] + } + oidc { + enabled = false + clientId = 'ChangeMe' + secret = 'ChangeMe' + discoveryUri = 'https://auth.ala.org.au/cas/oidc/.well-known' + scope = 'openid profile email ala roles' + } + cas { + appServerName = null + casServerName = 'https://auth.ala.org.au' + casServerUrlPrefix = 'https://auth.ala.org.au/cas' + loginUrl = 'https://auth.ala.org.au/cas/login' + logoutUrl = 'https://auth.ala.org.au/cas/logout' + bypass = false + gateway = false + renew = false + encodeServiceUrl = true + uriFilterPattern = ['/admin/*','/testAuth','/authTest/*'] + uriExclusionFilterPattern = [] + authenticateOnlyIfLoggedInPattern = [] + authenticateOnlyIfLoggedInFilterPattern = ['/'] + gatewayFilterPattern = [] + gatewayIfCookieFilterPattern = [] + gatewayStorageClass = DefaultGatewayResolverImpl.name + roleAttribute = 'role' + ignoreCase = true +// encodeServiceUrl = 'true' +// contextPath = '/set-this-to-override-default' + authCookieName = 'ALA-Auth' + } +} + +// TODO Document caches +//grails { +// cache { +// config { +// defaults { +// eternal = false +// overflowToDisk = false +// maxElementsInMemory = 20000 +// timeToLiveSeconds = 3600 +// } +// cache { +// name = 'userListCache' +// } +// cache { +// name = 'userMapCache' +// } +// } +// } +//} diff --git a/ala-auth/grails-app/conf/plugin.yml b/ala-auth/grails-app/conf/plugin.yml new file mode 100644 index 00000000..1e9f7803 --- /dev/null +++ b/ala-auth/grails-app/conf/plugin.yml @@ -0,0 +1,35 @@ +userDetails: + url: 'https://auth.ala.org.au/userdetails/' + readTimeout: 0 # disable read timeouts for user details because some services are slooooow... +#name: +#main: +#version: +security: + core: + permissionsAttributes: ['scopes'] + authCookieName: ${security.cas.authCookieName} + oidc: + enabled: false + clientId: 'ChangeMe' + secret: 'ChangeMe' + discoveryUri: 'https://auth.ala.org.au/cas/oidc/.well-known' + scope: 'openid profile email ala roles' + cas: + casServerName: 'https://auth.ala.org.au' + casServerUrlPrefix: 'https://auth.ala.org.au/cas' + loginUrl: 'https://auth.ala.org.au/cas/login' + logoutUrl: 'https://auth.ala.org.au/cas/logout' + bypass: false + gateway: false + renew: false + encodeServiceUrl: true + uriFilterPattern: ['/admin/*','/testAuth','/authTest/*'] + uriExclusionFilterPattern: [] + authenticateOnlyIfLoggedInPattern: [] + authenticateOnlyIfLoggedInFilterPattern: ['/'] + gatewayFilterPattern: [] + gatewayIfCookieFilterPattern: [] + gatewayStorageClass: 'org.jasig.cas.client.authentication.DefaultGatewayResolverImpl' + roleAttribute: 'role' + ignoreCase: true + authCookieName: 'ALA-Auth' diff --git a/ala-auth/grails-app/controllers/au/org/ala/web/AlaAuthUrlMappings.groovy b/ala-auth/grails-app/controllers/au/org/ala/web/AlaAuthUrlMappings.groovy new file mode 100644 index 00000000..0e800d14 --- /dev/null +++ b/ala-auth/grails-app/controllers/au/org/ala/web/AlaAuthUrlMappings.groovy @@ -0,0 +1,8 @@ +package au.org.ala.web + +class AlaAuthUrlMappings { + + static mappings = { + name login: "/login" (controller: 'login', action: 'index', plugin: 'alaAuth') + } +} \ No newline at end of file diff --git a/ala-auth/grails-app/controllers/au/org/ala/web/AlaSecuredInterceptor.groovy b/ala-auth/grails-app/controllers/au/org/ala/web/AlaSecuredInterceptor.groovy new file mode 100644 index 00000000..f0066c24 --- /dev/null +++ b/ala-auth/grails-app/controllers/au/org/ala/web/AlaSecuredInterceptor.groovy @@ -0,0 +1,106 @@ +package au.org.ala.web + +import au.org.ala.grails.AnnotationMatcher +import grails.core.GrailsApplication +import groovy.transform.CompileStatic + +import javax.annotation.PostConstruct + +@CompileStatic +class AlaSecuredInterceptor { + + // Run before other interceptors since we might fail the request + int order = HIGHEST_PRECEDENCE + 50 + + SecurityPrimitives securityPrimitives + GrailsApplication grailsApplication + + AlaSecuredInterceptor() { +// matchAll().except(uri: '/error') + } + + @PostConstruct + void init() { + AnnotationMatcher.matchAnnotation(this, grailsApplication, AlaSecured) + } + + boolean before() { + def annotations = AnnotationMatcher.getAnnotation(grailsApplication, controllerNamespace, controllerName, actionName, AlaSecured) + def effectiveAnnotation = annotations.effectiveAnnotation() + + if (effectiveAnnotation) { + + boolean error = false + + if (effectiveAnnotation.anyRole() && effectiveAnnotation.notRoles()) { + throw new IllegalArgumentException("Only one of anyRole and notRoles should be specified") + } + + def roles = effectiveAnnotation.value()?.toList() + + if (effectiveAnnotation.anonymous() && securityPrimitives.isNotLoggedIn(request)) { + error = false + } else if ((roles == null || roles.empty) && securityPrimitives.isNotLoggedIn(request)) { + error = true + } else if (effectiveAnnotation.anyRole() && !securityPrimitives.isAnyGranted(request, roles)) { + error = true + } else if (effectiveAnnotation.notRoles() && !securityPrimitives.isNotGranted(request, roles)) { + error = true + } else if (!effectiveAnnotation.anyRole() && !securityPrimitives.isAllGranted(request, roles)) { + error = true + } + + if (error) { + if (effectiveAnnotation.message()) { + flash.errorMessage = effectiveAnnotation.message() + } + + def status = effectiveAnnotation.statusCode() + if (status == 0) status = 403 + + if (params.returnTo) { + redirect(url: params.returnTo) + } else if (effectiveAnnotation.redirectUri()) { + redirect(uri: effectiveAnnotation.redirectUri()) + } else if (!getController(effectiveAnnotation) && !getAction(effectiveAnnotation)) { + if (effectiveAnnotation.view()) { + render(status: status, view: effectiveAnnotation.view()) + } else if (effectiveAnnotation.message()) { + render(status: status, text: effectiveAnnotation.message()) + } else { + render(status: status) + } + } else { + def toController = getController(effectiveAnnotation) ?: controllerName + def toAction = getAction(effectiveAnnotation) ?: 'index' + + if (controllerName == toController && !annotations.actionAnnotation && !effectiveAnnotation.forward()) { + log.warn('Redirecting to the current controller with a Controller level @AlaSecured, this is likely to result in a redirect loop!') + } + if (effectiveAnnotation.forward()) { + forward(status: status, controller: toController, action: toAction) + } else { + redirect(controller: toController, action: toAction) + } + } + return false + } + } + return true + } + + boolean after() { true } + + void afterView() { + // no-op + } + + def getController(AlaSecured a) { + return a.controller() ?: a.redirectController() + } + + def getAction(AlaSecured a) { + return a.redirectAction() ?: a.action() + } + +} \ No newline at end of file diff --git a/ala-auth/grails-app/controllers/au/org/ala/web/AuthTestController.groovy b/ala-auth/grails-app/controllers/au/org/ala/web/AuthTestController.groovy new file mode 100644 index 00000000..0fad710a --- /dev/null +++ b/ala-auth/grails-app/controllers/au/org/ala/web/AuthTestController.groovy @@ -0,0 +1,33 @@ +package au.org.ala.web + +class AuthTestController { + + def authService + + def index() { + if (!authService.userInRole(CASRoles.ROLE_ADMIN)) { + flash.message = "You do not have the required permissions!" + } + } + + @AlaSecured(value = CASRoles.ROLE_ADMIN, action = 'index') + def userList() { + } + + @AlaSecured(value = CASRoles.ROLE_ADMIN, action = 'index') + def userDetailsSearch() { + } + + @AlaSecured(value = CASRoles.ROLE_ADMIN, action = 'index') + def userSearchResults(String userId) { + UserDetails user = null + if (userId) { + user = authService.getUserForUserId(userId) + } + [user: user] + } + + @AlaSecured(value = CASRoles.ROLE_ADMIN, action = 'index') + def currentUserDetails() { + } +} diff --git a/ala-auth/grails-app/controllers/au/org/ala/web/LoginController.groovy b/ala-auth/grails-app/controllers/au/org/ala/web/LoginController.groovy new file mode 100644 index 00000000..062eec3e --- /dev/null +++ b/ala-auth/grails-app/controllers/au/org/ala/web/LoginController.groovy @@ -0,0 +1,39 @@ +package au.org.ala.web + +import grails.web.mapping.LinkGenerator +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value + +class LoginController { + + @Autowired + LinkGenerator linkGenerator + + @Autowired + SSOStrategy ssoStrategy + + @Value('${security.core.defaultRedirectUri:/}') + String defaultRedirect + + def index() { + def path = params.get('path', defaultRedirect) + + def context = linkGenerator.contextPath + // TODO What if there is a controller with the same name as the context path? + if (path.startsWith(context)) { + path -= context + } + + def baseAbsUrl = linkGenerator.serverBaseURL + def absPath = linkGenerator.link(absolute: true, uri: path) + if (!absPath.startsWith(baseAbsUrl)) { + log.error("Path param appears to redirect outside this app, path: {}, absPath: {}", path, absPath) + absPath = linkGenerator.link(absolute: true, uri: defaultRedirect) + } + boolean auth = ssoStrategy.authenticate(request, response, false, absPath) + + if (auth) { + redirect(absolute: true, uri: path) + } + } +} diff --git a/ala-auth/grails-app/controllers/au/org/ala/web/LogoutController.groovy b/ala-auth/grails-app/controllers/au/org/ala/web/LogoutController.groovy new file mode 100644 index 00000000..7f0b8da4 --- /dev/null +++ b/ala-auth/grails-app/controllers/au/org/ala/web/LogoutController.groovy @@ -0,0 +1,85 @@ +package au.org.ala.web + +import org.pac4j.core.util.Pac4jConstants +import org.springframework.beans.factory.annotation.Autowired + +class LogoutController { + + @Autowired + CoreAuthProperties coreAuthProperties + + /** + * Do logouts through this app so we can invalidate the session. + * + * Note this controller is only used for CAS logouts, OIDC logouts use the Pac4j LogoutFilter. + * + * @param casUrl the url for logging out of cas + * @param appUrl the url to redirect back to after the logout + */ + def logout() { + session.invalidate() + def appUrl = URLEncoder.encode(validateLogoutRedirectUrl(params.url ?: params.appUrl), "UTF-8") + def casUrl = grailsApplication.config.getProperty('security.cas.logoutUrl') + redirect(url:"${casUrl}?url=${appUrl}") + } + + /** + * Check that the appUrl for logout is a part of the current app and convert it to an absolute URI for logout if + * required + * + * @param appUrl the appUrl parameter value + * @return The appUrl if it's a valid URL for this app or this app's / URI + */ + private String validateLogoutRedirectUrl(String appUrl) { + def uri + String retVal + def logoutPattern = coreAuthProperties.logoutUrlPattern ?: Pac4jConstants.DEFAULT_LOGOUT_URL_PATTERN_VALUE + try { + uri = appUrl?.toURI() + } catch (URISyntaxException e) { + uri = null + } + // For an absolute URI, make sure it's allowed by the pattern *OR* that it starts with + // our current base URI and the relative part matches the pattern + // For a relative URI, make sure it's allowed + if (uri == null) { + retVal = coreAuthProperties.defaultLogoutRedirectUri + } else if (uri.isAbsolute()) { + def baseUrl = g.createLink(absolute: true, uri: '/').toString() + if (appUrl.matches(logoutPattern)) { + retVal = appUrl + } else if (appUrl.startsWith(baseUrl) && getRelativeComponent(uri).matches(logoutPattern)) { + retVal = appUrl + } else { + retVal = coreAuthProperties.defaultLogoutRedirectUri + } + } else { + if (appUrl.matches(logoutPattern)) { + // Could be g.createLink(absolute: true, uri: appUrl)? + retVal = request.requestURL.toURI().resolve(appUrl).toString() + } else { + retVal = coreAuthProperties.defaultLogoutRedirectUri + } + } + return retVal + } + + private String getRelativeComponent(URI uri) { + def path = uri.normalize().path + if (uri.query) { + path += '?' + uri.query + } + if (uri.fragment) { + path += '#' + uri.fragment + } + return path + } + + /** + * Clear the headers and footers cache + * + */ + def clearCache() { + render hf.clearCache() + } +} diff --git a/ala-auth/grails-app/controllers/au/org/ala/web/SsoInterceptor.groovy b/ala-auth/grails-app/controllers/au/org/ala/web/SsoInterceptor.groovy new file mode 100644 index 00000000..5ce5431b --- /dev/null +++ b/ala-auth/grails-app/controllers/au/org/ala/web/SsoInterceptor.groovy @@ -0,0 +1,86 @@ +package au.org.ala.web + +import au.org.ala.grails.AnnotationMatcher +import au.org.ala.web.config.AuthPluginConfig +import grails.core.GrailsApplication +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value + +import javax.annotation.PostConstruct +import javax.servlet.http.Cookie +import javax.servlet.http.HttpServletRequest + +@CompileStatic +@Slf4j +class SsoInterceptor { + + int order = HIGHEST_PRECEDENCE + + @Value('${security.cas.enabled:true}') + boolean enabled + + @Value('${security.oidc.enabled:false}') + boolean oidcEnabled + + @Value('${security.cas.authCookieName:ALA-Auth}') + String authCookieName + + @Autowired + UserAgentFilterService userAgentFilterService + + @Autowired + GrailsApplication grailsApplication + + @Autowired + SSOStrategy ssoStrategy + + SsoInterceptor() { +// matchAll().except(uri: '/error') + } + + @PostConstruct + void init() { + if (enabled || oidcEnabled) { + AnnotationMatcher.matchAnnotation(this, grailsApplication, SSO) + } + } + + boolean before() { + if (!(enabled || oidcEnabled)) return true + if (request.getAttribute(AuthPluginConfig.AUTH_FILTER_KEY)) return true + + final result = AnnotationMatcher.getAnnotation(grailsApplication, controllerNamespace, controllerName, actionName, SSO, NoSSO) + final effectiveAnnotation = result.effectiveAnnotation() + final actionNoSso = result.overrideAnnotation + + if (actionNoSso) return true + + if (!effectiveAnnotation) return true + + if (effectiveAnnotation.cookie() && !cookieExists(request)) { + log.debug("{}.{}.{} requested the presence of a {} cookie but none was found", controllerNamespace, controllerName, actionName, authCookieName) + return true + } + + def userAgent = request.getHeader('User-Agent') + if ((effectiveAnnotation.gateway()) && userAgentFilterService.isFiltered(userAgent)) { + log.debug("{}.{}.{} skipping SSO because it is gateway and the user agent is filtered", controllerNamespace, controllerName, actionName) + return true + } + + return ssoStrategy.authenticate(request, response, effectiveAnnotation.gateway()) + } + + boolean after() { true } + + void afterView() { + // no-op + } + + protected boolean cookieExists(final HttpServletRequest request) { + return request.cookies.any { Cookie cookie -> cookie.name == this.authCookieName && cookie.value} + } + +} diff --git a/ala-auth/grails-app/init/au/org/ala/web/Application.groovy b/ala-auth/grails-app/init/au/org/ala/web/Application.groovy new file mode 100644 index 00000000..8da2544c --- /dev/null +++ b/ala-auth/grails-app/init/au/org/ala/web/Application.groovy @@ -0,0 +1,12 @@ +package au.org.ala.web + +import grails.boot.* +import grails.boot.config.GrailsAutoConfiguration +import grails.plugins.metadata.* + +@PluginSource +class Application extends GrailsAutoConfiguration { + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} \ No newline at end of file diff --git a/ala-auth/grails-app/init/au/org/ala/web/BootStrap.groovy b/ala-auth/grails-app/init/au/org/ala/web/BootStrap.groovy new file mode 100644 index 00000000..04516f69 --- /dev/null +++ b/ala-auth/grails-app/init/au/org/ala/web/BootStrap.groovy @@ -0,0 +1,19 @@ +package au.org.ala.web + +class BootStrap { + + def grailsApplication + + def init = { servletContext -> + // hack to load the auth cookie name into a system property from the grails config properties before the + // AuthenticationCookieUtils static initializer runs + def config = grailsApplication.config + + def cookieName = config.getProperty('security.core.auth-cookie-name') ?: config.getProperty('security.core.authCookieName') ?: config.getProperty('security.cas.authCookieName') + if (cookieName) { + System.setProperty('ala.auth.cookie.name', cookieName) + } + } + def destroy = { + } +} diff --git a/ala-auth/grails-app/services/au/org/ala/web/AuthService.groovy b/ala-auth/grails-app/services/au/org/ala/web/AuthService.groovy new file mode 100644 index 00000000..d8c26944 --- /dev/null +++ b/ala-auth/grails-app/services/au/org/ala/web/AuthService.groovy @@ -0,0 +1,175 @@ +package au.org.ala.web + +import au.org.ala.userdetails.UserDetailsClient +import au.org.ala.userdetails.UserDetailsFromIdListRequest +import au.org.ala.userdetails.UserDetailsFromIdListResponse +import grails.plugin.cache.Cacheable +import grails.web.mapping.LinkGenerator +import org.springframework.beans.factory.annotation.Autowired + +import javax.servlet.http.HttpServletRequest + +class AuthService implements IAuthService { + + static transactional = false + + def grailsApplication + def userListService + UserDetailsClient userDetailsClient + // Delegate the auth service implementation to one for our auth config + + IAuthService delegateService + + @Autowired + LinkGenerator linkGenerator + + String getEmail() { + delegateService.getEmail() + } + + String getUserName() { + delegateService.getUserName() + } + + String getUserId() { + delegateService.getUserId() + } + + String getDisplayName() { + delegateService.getDisplayName() + } + + String getFirstName() { + delegateService.getFirstName() + } + + String getLastName() { + delegateService.getLastName() + } + + boolean userInRole(String role) { + delegateService.userInRole(role) + } + + UserDetails userDetails() { + delegateService.userDetails() + } + + String loginUrl(String path) { + delegateService.loginUrl(path) + } + + String loginUrl(HttpServletRequest request) { + + def requestPath = request.forwardURI ? ((request.forwardURI.startsWith('/') ? '' : '/') + request.forwardURI) : '' + def requestQuery = request.queryString ? (request.queryString.startsWith('?') ? '' : '?') + request.queryString : '' + + loginUrl("${requestPath}${requestQuery}") + } + + UserDetails getUserForUserId(String userId, boolean includeProps = true) { + return getUserForUserIdInternal(userId, includeProps).orElse(null) + } + + @Cacheable("userDetailsCache") + Optional getUserForUserIdInternal(String userId, boolean includeProps = true) { + if (!userId) return Optional.empty() // this would have failed anyway + def call = userDetailsClient.getUserDetails(userId, includeProps) + try { + def response = call.execute() + + if (response.successful) { + return Optional.of(response.body()) + } else { + log.warn("Failed to retrieve user details for userId: $userId, includeProps: $includeProps. Error was: ${response.message()}") + } + } catch (Exception ex) { + log.error("Exception caught trying get find user details for $userId.", ex) + } + return Optional.empty() + } + + UserDetails getUserForEmailAddress(String emailAddress, boolean includeProps = true) { + // The user details service lookup service should accept either a numerical id or email address and respond appropriately + return getUserForUserId(emailAddress, includeProps) + } + + /** + * + * Do a bulk lookup of user ids from the userdetails service. Accepts a list of numeric user ids and returns a + * map that looks like this: + * + *
+[
+  users:[
+     "546": UserDetails(userId: "546", userName: "user1@gmail.com", displayName: "First User"),
+     "4568": UserDetails(userId: "4568", userName: "user2@hotmail.com", displayName: "Second User"),
+     "8744": UserDetails(userId: "8744", userName: "user3@fake.edu.au", displayName: "Third User")
+  ],
+  invalidIds:[ 575 ],
+  success: true
+]
+     
+ * + * @param userIds + * @return + */ + def getUserDetailsById(List userIds, boolean includeProps = true) { + return getUserDetailsByIdInternal(userIds, includeProps).orElse(null) + } + + @Cacheable("userDetailsByIdCache") + Optional getUserDetailsByIdInternal(List userIds, boolean includeProps = true) { + def call = userDetailsClient.getUserDetailsFromIdList(new UserDetailsFromIdListRequest(userIds, includeProps)) + try { + def response = call.execute() + if (response.successful) { + return Optional.of(response.body()) + } else { + log.warn("Failed to retrieve user details. Error was: ${response.message()}") + } + } catch (Exception e) { + log.error("Exception caught retrieving userdetails for ${userIds}", e) + } + return Optional.empty() + } + + /** + * @deprecated - use a lookup service e.g. getUserForEmailAddress() + * @return + */ + Map getAllUserNameMap() { + def userListMap = [:] + + try { + def userListJson = userListService.getFullUserList() + userListJson.eachWithIndex { user, i -> + userListMap.put(user.userName?.toLowerCase(), user) // username as key (email address) + } + } catch (Exception e) { + log.error "Cache refresh error: " + e.message, e + } + + return userListMap + } + + + /** + * @deprecated - use a lookup service e.g. getUserForEmailAddress() + * @return + */ + def getAllUserNameList() { + def userList = [] + try { + def userListJson = userListService.getFullUserList() + userListJson.eachWithIndex { user, i -> + userList.add(user) + } + } catch (Exception e) { + log.error "Cache refresh error: " + e.message, e + } + + return userList + } + +} diff --git a/ala-auth/grails-app/services/au/org/ala/web/UserListService.groovy b/ala-auth/grails-app/services/au/org/ala/web/UserListService.groovy new file mode 100644 index 00000000..37b7da28 --- /dev/null +++ b/ala-auth/grails-app/services/au/org/ala/web/UserListService.groovy @@ -0,0 +1,41 @@ +package au.org.ala.web + +import au.org.ala.userdetails.UserDetailsClient +import grails.plugin.cache.Cacheable + +/** + * This service has one method that returns a large list of objects containing data about ALA users. + * + * This method has been split from the AuthService because it is an expensive call, and is used internally by other auth service methods, and so should + * be cached. Service methods called by other methods on the same service do not get cached (because the caching is implemented via proxy wrappers), + * so we stick it in a different service + */ +class UserListService { + + static transactional = false + + def grailsApplication + UserDetailsClient userDetailsClient + + /** + * @deprecated use the AuthService.getUserDetailsById instead + * @return All the UserDetails, without extended properties + */ + @Cacheable("userListCache") + List getFullUserList() { + checkConfig() + def response = userDetailsClient.getUserListFull().execute() + if (response.successful) { + return response.body() + } else { + throw new RuntimeException("Error response from UserListFull service: ${response.code()} ${response.message()}") + } + } + + private void checkConfig() { + if (!grailsApplication.config.getProperty('userDetails.url')) { + log.error "Required config not found: userDetails.url - please add to Config.groovy" + } + } + +} diff --git a/ala-auth/grails-app/taglib/au/org/ala/web/auth/AuthTagLib.groovy b/ala-auth/grails-app/taglib/au/org/ala/web/auth/AuthTagLib.groovy new file mode 100644 index 00000000..89aee21f --- /dev/null +++ b/ala-auth/grails-app/taglib/au/org/ala/web/auth/AuthTagLib.groovy @@ -0,0 +1,132 @@ +package au.org.ala.web.auth + +import au.org.ala.cas.util.AuthenticationCookieUtils +import grails.util.Holders + +class AuthTagLib { + + def authService + def securityPrimitives // gets injected in AlaWebThemeGrailsPlugin 'doWithSpring' section + + def grailServerURL = Holders.config.getProperty('grails.serverURL') ?: "http://bie.ala.org.au" + // the next two can also be overridden by tag attributes + def casLoginUrl = Holders.config.getProperty('security.cas.loginUrl') ?: "https://auth.ala.org.au/cas/login" + + static namespace = "auth" + //static encodeAsForTags = [tagName: 'raw'] + + /** + * Is the user logged in? + */ + def ifLoggedIn = { attrs, body -> + if (securityPrimitives.isLoggedIn(request)) out << body() + } + + /** + * Is the user not logged in? + */ + def ifNotLoggedIn = { attrs, body -> + if (securityPrimitives.isNotLoggedIn(request)) out << body() + } + + /** + * Does the currently logged in user have any of the given roles? + * + * @attr roles REQUIRED A comma separated list of roles to check + */ + def ifAnyGranted = { attrs, body -> + if (securityPrimitives.isAnyGranted(rolesStringToList(attrs))) out << body() + } + + /** + * Does the currently logged in user have all of the given roles? + * + * @attr roles REQUIRED A comma separated list of roles to check + */ + def ifAllGranted = { attrs, body -> + if (securityPrimitives.isAllGranted(rolesStringToList(attrs))) out << body() + } + + /** + * Does the currently logged in user have none of the given roles? + * + * @attr roles REQUIRED A comma separated list of roles to check + */ + def ifNotGranted = { attrs, body -> + if (securityPrimitives.isNotGranted(rolesStringToList(attrs))) out << body() + } + + /** + * Generate the login/logout link (taken from ala-web-theme plugin) + * + * @attr cssClass - CSS class to add to a tag + * + * plus + * @attr logoutUrl the local url that should invalidate the session and redirect to the auth + * logout url - defaults to {CH.config.grails.serverURL}/session/logout + * @attr loginReturnToUrl where to go after logging in - defaults to current page + * @attr logoutReturnToUrl where to go after logging out - defaults to current page + * @attr loginReturnUrl where to go after login - defaults to current page + * @attr casLoginUrl - defaults to {CH.config.security.cas.loginUrl} + * @attr ignoreCookie - if true the helper cookie will not be used to determine login - defaults to false + */ + def loginLogout = { attrs -> + out << buildLoginoutLink(attrs) + } + + /** + * Builds the login or logout link based on current login status. + * @param attrs any specified params to override defaults + * @return + */ + String buildLoginoutLink(attrs) { + def requestUri = removeContext(grailServerURL) + request.forwardURI + def logoutUrl = attrs.logoutUrl ?: g.createLink(controller: 'logout', action: 'logout') + def logoutReturnToUrl = attrs.logoutReturnToUrl ?: requestUri + + // TODO should this be attrs.logoutReturnToUrl? + if (!attrs.loginReturnToUrl && request.queryString) { + logoutReturnToUrl += "?" + URLEncoder.encode(request.queryString, "UTF-8") + } + + if ((attrs.ignoreCookie != "true" && + AuthenticationCookieUtils.cookieExists(request, AuthenticationCookieUtils.ALA_AUTH_COOKIE)) || + request.userPrincipal) { + return "Logout" + } else { + // currently logged out + return "Log in" + } + } + + /** + * Build the login link + * @param attrs any specified params to override defaults + * @return The login url + */ + String buildLoginLink(attrs) { + + return attrs.loginReturnToUrl ? authService.loginUrl(attrs.loginReturnToUrl) : authService.loginUrl(request) + } + + /** + * Remove the context path and params from the url. + * @param urlString + * @return + */ + private String removeContext(urlString) { + def url = urlString.toURL() + def protocol = url.protocol != -1 ? url.protocol + "://" : "" + def port = url.port != -1 ? ":" + url.port : "" + return protocol + url.host + port + } + + private def rolesStringToList(attrs) { + def roles = attrs.roles ?: "" + def split = roles.split(",") + def list = split.toList() + return list + } +} diff --git a/ala-auth/grails-app/views/authTest/currentUserDetails.gsp b/ala-auth/grails-app/views/authTest/currentUserDetails.gsp new file mode 100644 index 00000000..b86a6ad0 --- /dev/null +++ b/ala-auth/grails-app/views/authTest/currentUserDetails.gsp @@ -0,0 +1,53 @@ +<% + def authService = applicationContext.authService +%> +<%@ page contentType="text/html;charset=UTF-8" %> + + + + + Test page for auth + + + + + +

Current User

+ + + + + + + + + + + + + + + + + + +
+ User Id + + ${authService.userId} +
+ Display Name + + ${authService.displayName} +
+ Email + + ${authService.email} +
+ Current User Obj + + ${authService.userDetails()} +
+ + \ No newline at end of file diff --git a/ala-auth/grails-app/views/authTest/index.gsp b/ala-auth/grails-app/views/authTest/index.gsp new file mode 100644 index 00000000..a7dd4562 --- /dev/null +++ b/ala-auth/grails-app/views/authTest/index.gsp @@ -0,0 +1,59 @@ +<%@ page contentType="text/html;charset=UTF-8" %> + + + + + Test page for auth + + + + + + +
+ ${flash.message} +
+
+ +

AUTH test pages

+ + + + + + + + + + + + + + + + + + + +
+ User List + + Dumps the entire user list +
+ User Details Search + + Search for user details +
+ Current User + + Test AuthService methods that return info about current user +
+
+ ${request.userPrincipal} +
+
+ Test the Header/Footer logout button +
+ + \ No newline at end of file diff --git a/ala-auth/grails-app/views/authTest/userDetailsSearch.gsp b/ala-auth/grails-app/views/authTest/userDetailsSearch.gsp new file mode 100644 index 00000000..120135c5 --- /dev/null +++ b/ala-auth/grails-app/views/authTest/userDetailsSearch.gsp @@ -0,0 +1,30 @@ +<%@ page contentType="text/html;charset=UTF-8" %> + + + + + Test page for auth + + + + +

User Details Search

+ + +
+
+ +
+ +
+
+
+
+ +
+
+
+
+ + \ No newline at end of file diff --git a/ala-auth/grails-app/views/authTest/userList.gsp b/ala-auth/grails-app/views/authTest/userList.gsp new file mode 100644 index 00000000..b3490ca8 --- /dev/null +++ b/ala-auth/grails-app/views/authTest/userList.gsp @@ -0,0 +1,36 @@ +<% + def authService = applicationContext.authService +%> +<%@ page contentType="text/html;charset=UTF-8" %> + + + + + Test page for auth + + + +

AUTH User List

+
+ + + + + + + + + + + + + + + + + +
User IdUser NameDisplay Name
${user.userId}${user.userName}${user.displayName}
+
+ + \ No newline at end of file diff --git a/ala-auth/grails-app/views/authTest/userSearchResults.gsp b/ala-auth/grails-app/views/authTest/userSearchResults.gsp new file mode 100644 index 00000000..b950d145 --- /dev/null +++ b/ala-auth/grails-app/views/authTest/userSearchResults.gsp @@ -0,0 +1,38 @@ +<%@ page contentType="text/html;charset=UTF-8" %> + + + + + Test page for auth + + + + +

User Details Search Results

+ + + + + + + + + + + + + + + + +
User ID${user.userId}
User name${user.userName}
Display Name${user.displayName}
+
+ +
+ User not found +
+
+ + + \ No newline at end of file diff --git a/ala-auth/grails-wrapper.jar b/ala-auth/grails-wrapper.jar new file mode 100644 index 00000000..36369355 Binary files /dev/null and b/ala-auth/grails-wrapper.jar differ diff --git a/ala-auth/grailsw b/ala-auth/grailsw new file mode 100755 index 00000000..958b832f --- /dev/null +++ b/ala-auth/grailsw @@ -0,0 +1,151 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Grails start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRAILS_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +JAR_PATH=$APP_HOME/grails-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRAILS_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRAILS_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRAILS_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRAILS_OPTS + +exec "$JAVACMD" -jar "$JAR_PATH" "${JVM_OPTS[@]}" "$@" diff --git a/ala-auth/grailsw.bat b/ala-auth/grailsw.bat new file mode 100755 index 00000000..d1f09590 --- /dev/null +++ b/ala-auth/grailsw.bat @@ -0,0 +1,89 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Grails startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRAILS_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line +set JAR_PATH=%APP_HOME%/grails-wrapper.jar + +@rem Execute Grails +"%JAVA_EXE%" -jar %JAR_PATH% %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRAILS_OPTS% %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRAILS_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRAILS_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ala-auth/settings.gradle b/ala-auth/settings.gradle new file mode 100644 index 00000000..56e2e8cb --- /dev/null +++ b/ala-auth/settings.gradle @@ -0,0 +1 @@ +rootProject.name='ala-auth' \ No newline at end of file diff --git a/ala-auth/src/main/groovy/au/org/ala/web/AlaAuthGrailsPlugin.groovy b/ala-auth/src/main/groovy/au/org/ala/web/AlaAuthGrailsPlugin.groovy new file mode 100644 index 00000000..e2718ecc --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/AlaAuthGrailsPlugin.groovy @@ -0,0 +1,74 @@ +package au.org.ala.web + +import au.org.ala.web.config.AuthGenericPluginConfig +import au.org.ala.web.config.AuthPac4jPluginConfig +import au.org.ala.web.config.AuthPluginConfig +import au.org.ala.web.config.MongoSpringSessionPluginConfig +import au.org.ala.web.config.SpringSessionPluginConfig +import grails.plugins.* +import groovy.util.logging.Slf4j + +@Slf4j +class AlaAuthGrailsPlugin extends Plugin { + + // the version or versions of Grails the plugin is designed for + def grailsVersion = "3.2.4 > *" + // resources that are excluded from plugin packaging + def pluginExcludes = [] + + def title = "Ala Auth Plugin" // Headline display name of the plugin + def author = "Nick dos Remedios" + def authorEmail = "nick.dosremedios@csiro.au" + def description = '''\ +This plugin provides auth services for ALA. +''' + + // URL to the plugin's documentation + def documentation = "https://github.com/AtlasOfLivingAustralia/ala-auth-plugin" + + // Extra (optional) plugin metadata + + // License: one of 'APACHE', 'GPL2', 'GPL3' + def license = "MPL2" + + // Details of company behind the plugin (if there is one) + def organization = [ name: "Atlas of Living Australia", url: "http://www.ala.org.au/" ] + + // Any additional developers beyond the author specified above. + def developers = [ [ name: "Peter Ansell", email: "p_ansell@yahoo.com" ], [ name: "Simon Bear", email: "simon.bear@csiro.au" ], [ name: "Nick dos Remedios", email: "nick.dosremedios@csiro.au" ], [ name: "Chris Godwin", email: "chris.godwin.ala@gmail.com" ], [ name: "Dave Martin", email: "david.martin@csiro.au" ]] + + // Location of the plugin's issue tracker. + def issueManagement = [ system: "github", url: "https://github.com/AtlasOfLivingAustralia/ala-auth-plugin/issues" ] + + // Online location of the plugin's browseable source code. + def scm = [ url: "https://github.com/AtlasOfLivingAustralia/ala-auth-plugin" ] + + Closure doWithSpring() { {-> + + authGenericPluginConfiguration(AuthGenericPluginConfig) + alaAuthPluginConfiguration(AuthPluginConfig) + authOidcPluginConfiguration(AuthPac4jPluginConfig) +// springSessionPluginConfiguration(SpringSessionPluginConfig) // included via spring.factories +// mongoSpringSessionPluginConfiguration(MongoSpringSessionPluginConfig) + + securityPrimitives(SecurityPrimitives) { beanDefinition -> + beanDefinition.constructorArgs = [ref('authService'), ref('grailsApplication')] + } + } + } + + void doWithDynamicMethods() { + } + + void doWithApplicationContext() { + } + + void onChange(Map event) { + } + + void onConfigChange(Map event) { + } + + void onShutdown(Map event) { + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/CASRoles.groovy b/ala-auth/src/main/groovy/au/org/ala/web/CASRoles.groovy new file mode 100644 index 00000000..c420c785 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/CASRoles.groovy @@ -0,0 +1,8 @@ +package au.org.ala.web + +public class CASRoles { + + public static final String ROLE_ADMIN = "ROLE_ADMIN" + public static final String ROLE_USER = "ROLE_USER" + +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/CasAuthService.groovy b/ala-auth/src/main/groovy/au/org/ala/web/CasAuthService.groovy new file mode 100644 index 00000000..db1f24d2 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/CasAuthService.groovy @@ -0,0 +1,127 @@ +package au.org.ala.web + +import au.org.ala.cas.util.AuthenticationUtils +import au.org.ala.userdetails.UserDetailsClient +import groovy.util.logging.Slf4j +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.util.UriComponentsBuilder + +import javax.servlet.http.HttpServletRequest + +/** + * CAS based implementation of the generic auth service methods. + */ +@Slf4j +class CasAuthService implements IAuthService { + + private UserDetailsClient userDetailsClient + private boolean casBypass + private String casLoginUrl + + CasAuthService(UserDetailsClient userDetailsClient, boolean casBypass, String casLoginUrl) { + this.casBypass = casBypass + this.userDetailsClient = userDetailsClient + this.casLoginUrl = casLoginUrl + } + + String getEmail() { + return AuthenticationUtils.getEmailAddress(RequestContextHolder.currentRequestAttributes().getRequest()) + } + + String getUserName() { + def request = RequestContextHolder.currentRequestAttributes().getRequest() as HttpServletRequest + def username = AuthenticationUtils.getPrincipalAttribute(request, "username") // check this + return username + } + + String getUserId() { + def request = RequestContextHolder.currentRequestAttributes().getRequest() as HttpServletRequest + def userId = AuthenticationUtils.getUserId(request) + if (!userId) { + log.warn("Attempt to get email address from cookie, this is deprecated and may not be supported in the future.") + // try the email address, and working backwards from there + def emailAddress = AuthenticationUtils.getEmailAddress(request) + if (emailAddress) { + def user = getUserForEmailAddress(emailAddress) + if (user) { + userId = user.userId + } + } + } + return userId + } + + + String getDisplayName() { + return AuthenticationUtils.getDisplayName(RequestContextHolder.currentRequestAttributes().getRequest()) + } + + String getFirstName() { + return AuthenticationUtils.getFirstName(RequestContextHolder.currentRequestAttributes().getRequest()) + } + + String getLastName() { + return AuthenticationUtils.getLastName(RequestContextHolder.currentRequestAttributes().getRequest()) + } + + boolean userInRole(String role) { + + def inRole = AuthenticationUtils.isUserInRole(RequestContextHolder.currentRequestAttributes().getRequest(), role) + def bypass = casBypass + log.debug("userInRole(${role}) - ${inRole} (bypassing CAS - ${bypass})") + return bypass.toString().toBoolean() || inRole + } + + UserDetails userDetails() { + def attr = RequestContextHolder.currentRequestAttributes()?.getUserPrincipal()?.attributes + def details = null + + if (attr) { + details = new UserDetails( + userId:attr?.userid?.toString(), + userName: attr?.email?.toString()?.toLowerCase(), + email: attr?.email?.toString()?.toLowerCase(), + firstName: attr?.firstname?.toString() ?: "", + lastName: attr?.lastname?.toString() ?: "", + locked: attr?.locked?.toBoolean() ?: false, + organisation: attr?.organisation?.toString() ?: "", + city: attr?.country?.toString() ?: "", + state: attr?.state?.toString() ?: "", + country: attr?.country?.toString() ?: "", + roles: AuthenticationUtils.getUserRoles(RequestContextHolder.currentRequestAttributes().request) + ) + } + + details + } + + @Override + String loginUrl(String returnUrl) { + def builder = UriComponentsBuilder.fromHttpUrl(casLoginUrl) + builder.queryParam('service', returnUrl) + return builder.build(true).toUriString() + } + + /** + * XXX Simply copied here to prevent circular dependency. + * @param email Email + * @param includeProps Props + * @return + */ + private UserDetails getUserForEmailAddress(String email, boolean includeProps = true) { + if (!email) return null // this would have failed anyway + def call = userDetailsClient.getUserDetails(userId, includeProps) + try { + def response = call.execute() + + if (response.successful) { + return response.body() + } else { + log.warn("Failed to retrieve user details for userId: $userId, includeProps: $includeProps. Error was: ${response.message()}") + } + } catch (Exception ex) { + log.error("Exception caught trying get find user details for $userId.", ex) + } + return null + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/CasContextParamInitializer.groovy b/ala-auth/src/main/groovy/au/org/ala/web/CasContextParamInitializer.groovy new file mode 100644 index 00000000..989ccf95 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/CasContextParamInitializer.groovy @@ -0,0 +1,95 @@ +package au.org.ala.web + +import groovy.util.logging.Slf4j +import org.jasig.cas.client.configuration.ConfigurationStrategyName +import org.jasig.cas.client.session.SingleSignOutHttpSessionListener +import org.springframework.boot.web.servlet.ServletContextInitializer +import org.springframework.stereotype.Component + +import javax.servlet.ServletContext +import javax.servlet.ServletException + +import static org.jasig.cas.client.configuration.ConfigurationKeys.* + +@Component +@Slf4j +class CasContextParamInitializer implements ServletContextInitializer { + + private final CasClientProperties casClientProperties + private final CoreAuthProperties coreAuthProperties + + CasContextParamInitializer(CoreAuthProperties coreAuthProperties, CasClientProperties casClientProperties) { + this.coreAuthProperties = coreAuthProperties + this.casClientProperties = casClientProperties + } + + @Override + void onStartup(ServletContext servletContext) throws ServletException { + log.debug("CAS Servlet Context Initializer") + + servletContext.addListener(SingleSignOutHttpSessionListener) + + servletContext.setInitParameter('configurationStrategy', ConfigurationStrategyName.WEB_XML.name()) + + def appServerName = casClientProperties.appServerName + def service = casClientProperties.service + if (!appServerName && !service) { + def message = "One of 'security.cas.appServerName' or 'security.cas.service' config settings is required by the CAS filters." + log.error(message) + throw new IllegalStateException(message) + } + if (appServerName) { + servletContext.setInitParameter(SERVER_NAME.name, appServerName) + } + if (service) { + servletContext.setInitParameter(SERVICE.name, service) + } + servletContext.setInitParameter(CAS_SERVER_URL_PREFIX.name, casClientProperties.casServerUrlPrefix) + servletContext.setInitParameter(CAS_SERVER_LOGIN_URL.name, casClientProperties.loginUrl) + servletContext.setInitParameter(ROLE_ATTRIBUTE.name, coreAuthProperties.roleAttribute ?: casClientProperties.roleAttribute) + servletContext.setInitParameter(IGNORE_PATTERN.name, (coreAuthProperties.uriExclusionFilterPattern + casClientProperties.uriExclusionFilterPattern).join(',')) + servletContext.setInitParameter(IGNORE_URL_PATTERN_TYPE.name, RegexListUrlPatternMatcherStrategy.name) + + def ignoreCase = casClientProperties.ignoreCase + if (isBoolesque(ignoreCase)) { + servletContext.setInitParameter(IGNORE_CASE.name, ignoreCase.toString()) + } + + servletContext.setInitParameter('casServerName', casClientProperties.casServerName) + + def encodeServiceUrl = casClientProperties.encodeServiceUrl + if (isBoolesque(encodeServiceUrl)) { + servletContext.setInitParameter(ENCODE_SERVICE_URL.name, encodeServiceUrl.toString()) + } + + def contextPath = casClientProperties.contextPath + if (contextPath) { + log.warn("Setting security.cas.contextPath is unnecessary, ala-cas-client can now retrieve it from the ServletContext") + servletContext.setInitParameter('contextPath', contextPath) + } + + def gatewayStorageClass = casClientProperties.gatewayStorageClass + if (gatewayStorageClass) { + servletContext.setInitParameter(GATEWAY_STORAGE_CLASS.name, gatewayStorageClass) + } + + def renew = casClientProperties.renew + if (isBoolesque(renew)) { + servletContext.setInitParameter(RENEW.name, renew.toString()) + } + } + + private static boolean isBoolesque(o) { + if (o instanceof Boolean) { + return true + } + if (o instanceof String) { + if (o.equalsIgnoreCase('true') || o.equalsIgnoreCase('false')) { + return true + } else { + log.warn("$o is not a boolean value") + } + } + return false + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/CasSSOStrategy.groovy b/ala-auth/src/main/groovy/au/org/ala/web/CasSSOStrategy.groovy new file mode 100644 index 00000000..d1e250e2 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/CasSSOStrategy.groovy @@ -0,0 +1,137 @@ +package au.org.ala.web + +import groovy.util.logging.Slf4j +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.jasig.cas.client.Protocol +import org.jasig.cas.client.authentication.AuthenticationFilter +import org.jasig.cas.client.authentication.AuthenticationRedirectStrategy +import org.jasig.cas.client.authentication.DefaultAuthenticationRedirectStrategy +import org.jasig.cas.client.authentication.GatewayResolver +import org.jasig.cas.client.authentication.UrlPatternMatcherStrategy +import org.jasig.cas.client.util.CommonUtils +import org.jasig.cas.client.validation.Assertion + +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Slf4j +class CasSSOStrategy implements SSOStrategy { + + // The CAS service param to use + String service + // The app server name to use + String serverName + // CAS Login URL + String casServerLoginUrl + // ALA Cookie Name + String authCookieName + // Whether to encode the service URL + boolean encodeServiceUrl + // Whether CAS is enabled + boolean enabled + // Whether all CAS is permitted to allow SSO or whether it must re-authenticate users + boolean renew + // Matcher for ignoring URLs + UrlPatternMatcherStrategy ignoreUrlPatternMatcherStrategy + // Filter out unwanted user agents + UserAgentFilterService userAgentFilterService + // Storage for gateway requests + GatewayResolver gatewayStorage + + Protocol protocol = Protocol.CAS3 + + AuthenticationRedirectStrategy authenticationRedirectStrategy = new DefaultAuthenticationRedirectStrategy() + + CasSSOStrategy(String service, String serverName, String casServerLoginUrl, String authCookieName, + boolean encodeServiceUrl, boolean enabled, boolean renew, UrlPatternMatcherStrategy ignoreUrlPatternMatcherStrategy, UserAgentFilterService userAgentFilterService, GatewayResolver gatewayStorage) { + this.service = service + this.serverName = serverName + this.casServerLoginUrl = casServerLoginUrl + this.authCookieName = authCookieName + this.encodeServiceUrl = encodeServiceUrl + this.enabled = enabled + this.renew = renew + this.ignoreUrlPatternMatcherStrategy = ignoreUrlPatternMatcherStrategy + this.userAgentFilterService = userAgentFilterService + this.gatewayStorage = gatewayStorage + } + + @Override + boolean authenticate(HttpServletRequest request, HttpServletResponse response, boolean gateway) { + authenticate(request, response, gateway, null) + } + + @Override + boolean authenticate(HttpServletRequest request, HttpServletResponse response, boolean gateway, String redirectUri) { + if (isRequestUrlExcluded(request)) { + log.debug("Request is ignored.") + return true + } + + final session = request.getSession(false) + final Assertion assertion = session != null ? (Assertion) session.getAttribute(AuthenticationFilter.CONST_CAS_ASSERTION) : null + + if (assertion != null) { + def gwr = GrailsWebRequest.lookup(request) + log.debug("{}.{}.{} request already authenticated", gwr?.controllerNamespace, gwr?.controllerName, gwr?.actionName) + return true + } + + final String serviceUrl = constructServiceUrl(request, response, redirectUri) + final String ticket = retrieveTicketFromRequest(request) + final boolean wasGatewayed = gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl) + + if (CommonUtils.isNotBlank(ticket) || wasGatewayed) { + return true + } + + final String modifiedServiceUrl + + log.debug("no ticket and no assertion found") + if (gateway) { + log.debug("setting gateway attribute in session") + modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl) + } else { + modifiedServiceUrl = serviceUrl + } + + log.debug("Constructed service url: {}", modifiedServiceUrl) + + final String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, + getProtocol().getServiceParameterName(), modifiedServiceUrl, renew, gateway) + + log.debug("redirecting to \"{}\"", urlToRedirectTo) + this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo) + + return false + } + + protected final String constructServiceUrl(final HttpServletRequest request, final HttpServletResponse response, final String redirectUri) { + return CommonUtils.constructServiceUrl(request, response, redirectUri ?: this.service, this.serverName, + this.protocol.getServiceParameterName(), + this.protocol.getArtifactParameterName(), this.encodeServiceUrl) + } + + /** + * Template method to allow you to change how you retrieve the ticket. + * + * @param request the HTTP ServletRequest. CANNOT be NULL. + * @return the ticket if its found, null otherwise. + */ + protected String retrieveTicketFromRequest(final HttpServletRequest request) { + return CommonUtils.safeGetParameter(request, this.protocol.getArtifactParameterName()) + } + + private boolean isRequestUrlExcluded(final HttpServletRequest request) { + if (this.ignoreUrlPatternMatcherStrategy == null) { + return false + } + + final StringBuffer urlBuffer = request.getRequestURL() + if (request.getQueryString() != null) { + urlBuffer.append("?").append(request.getQueryString()) + } + final String requestUri = urlBuffer.toString() + return this.ignoreUrlPatternMatcherStrategy.matches(requestUri) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/CookieFilterWrapper.groovy b/ala-auth/src/main/groovy/au/org/ala/web/CookieFilterWrapper.groovy new file mode 100644 index 00000000..43a5b181 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/CookieFilterWrapper.groovy @@ -0,0 +1,56 @@ +package au.org.ala.web + +import groovy.transform.CompileStatic + +import javax.servlet.Filter +import javax.servlet.FilterChain +import javax.servlet.FilterConfig +import javax.servlet.ServletException +import javax.servlet.ServletRequest +import javax.servlet.ServletResponse +import javax.servlet.http.Cookie +import javax.servlet.http.HttpServletRequest + +/** + * Java Servlet Filter wrapper that will only execute a filter if a cookie is present + */ +@CompileStatic +class CookieFilterWrapper implements Filter { + + private final Filter filter + private final String cookieName + + CookieFilterWrapper(Filter filter, String cookieName) { + this.filter = filter + this.cookieName = cookieName + } + + @Override + void init(FilterConfig filterConfig) throws ServletException { + this.filter.init(filterConfig) + } + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (request instanceof HttpServletRequest) { + if (request.cookies?.any { Cookie cookie -> cookie.name == this.cookieName && cookie.value }) { + filter.doFilter(request, response, chain) + } else { + chain.doFilter(request, response) + } + } else { + chain.doFilter(request, response) + } + } + + @Override + void destroy() { + this.filter.destroy() + } + + @Override + String toString() { + return "CookieFilterWrapper(cookieName = " + cookieName + " delegate = " + filter.toString() + ")" + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/CookieMatcher.groovy b/ala-auth/src/main/groovy/au/org/ala/web/CookieMatcher.groovy new file mode 100644 index 00000000..69987f3f --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/CookieMatcher.groovy @@ -0,0 +1,21 @@ +package au.org.ala.web + +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.matching.matcher.Matcher + +class CookieMatcher implements Matcher { + + private final String cookieName + private final String cookiePattern + + CookieMatcher(String cookieName, String cookiePattern) { + this.cookiePattern = cookiePattern + this.cookieName = cookieName + } + + @Override + boolean matches(WebContext context, SessionStore sessionStore) { + return context.getRequestCookies().find { it.name == cookieName }?.value?.matches(cookiePattern) ?: false + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/CooperatingFilterWrapper.groovy b/ala-auth/src/main/groovy/au/org/ala/web/CooperatingFilterWrapper.groovy new file mode 100644 index 00000000..cf6935fe --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/CooperatingFilterWrapper.groovy @@ -0,0 +1,45 @@ +package au.org.ala.web + +import javax.servlet.Filter +import javax.servlet.FilterChain +import javax.servlet.FilterConfig +import javax.servlet.ServletException +import javax.servlet.ServletRequest +import javax.servlet.ServletResponse + +class CooperatingFilterWrapper implements Filter { + + private final Filter delegate + private final String key + + CooperatingFilterWrapper(Filter delegate, String key) { + this.key = key + this.delegate = delegate + } + + @Override + void init(FilterConfig filterConfig) throws ServletException { + delegate.init(filterConfig) + } + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + if (request.getAttribute(key)) { + chain.doFilter(request, response) + } else { + request.setAttribute(key, Boolean.TRUE) + delegate.doFilter(request, response, chain) + request.removeAttribute(key) + } + } + + @Override + void destroy() { + + } + + @Override + String toString() { + return "CooperatingFilterWrapper(key = " + key + " delegate = " + delegate.toString() + ")" + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/GrailsPac4jContextProvider.groovy b/ala-auth/src/main/groovy/au/org/ala/web/GrailsPac4jContextProvider.groovy new file mode 100644 index 00000000..70deb648 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/GrailsPac4jContextProvider.groovy @@ -0,0 +1,27 @@ +package au.org.ala.web + +import org.grails.web.util.WebUtils +import org.pac4j.core.config.Config +import org.pac4j.core.context.WebContext +import org.pac4j.core.util.FindBest +import org.pac4j.jee.context.JEEContextFactory +/** + * Pac4jContextProvider that uses static Grails methods to get at the request and response. + */ +class GrailsPac4jContextProvider implements Pac4jContextProvider { + + Config config + + GrailsPac4jContextProvider(Config config) { + this.config = config + } + + @Override + WebContext webContext() { + def gwr = WebUtils.retrieveGrailsWebRequest() + def request = gwr.request + def response = gwr.response + final WebContext context = FindBest.webContextFactory(null, config, JEEContextFactory.INSTANCE).newContext(request, response) + return context + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/IAuthService.groovy b/ala-auth/src/main/groovy/au/org/ala/web/IAuthService.groovy new file mode 100644 index 00000000..8da8551b --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/IAuthService.groovy @@ -0,0 +1,63 @@ +package au.org.ala.web + +/** + * Generalise the Auth Service implementation that depends on the kind of authentication being used. + */ +interface IAuthService { + + /** + * Get the current user's email address + * @return the current user's email address + */ + String getEmail() + + /** + * Get the current user's preferred username + * @return the current user's preferred username + */ + String getUserName() + + /** + * Get the current user's id + * @return the current user's id + */ + String getUserId() + + /** + * Get the current user's display name + * @return the current user's display name + */ + String getDisplayName() + + /** + * Get the current user's first name + * @return the current user's first name + */ + String getFirstName() + + /** + * Get the current user's last name + * @return the current user's last name + */ + String getLastName() + + /** + * Is the current user in the given role + * @return true if the current user has the given role + */ + boolean userInRole(String role) + + /** + * UserDetails for the current user + * @return UserDetails for the current user + */ + UserDetails userDetails() + + + /** + * Get the login URL for the current auth service + * @param returnUrl The url to return to. + * @return The login url + */ + String loginUrl(String returnUrl) +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/NotBotMatcher.groovy b/ala-auth/src/main/groovy/au/org/ala/web/NotBotMatcher.groovy new file mode 100644 index 00000000..96de1d94 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/NotBotMatcher.groovy @@ -0,0 +1,25 @@ +package au.org.ala.web + +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.matching.matcher.Matcher + +/** + * PAC4j matcher that uses the User-Agent header to determine if the client is a search bot. + */ +class NotBotMatcher implements Matcher { + + private UserAgentFilterService filterService + + NotBotMatcher(UserAgentFilterService filterService) { + + this.filterService = filterService + } + + @Override + boolean matches(WebContext context, SessionStore sessionStore) { + def headerValue = context.getRequestHeader("User-Agent") + def header = headerValue.orElseGet { "" } + return !filterService.isFiltered(header) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/OverrideSavedRequestHandler.groovy b/ala-auth/src/main/groovy/au/org/ala/web/OverrideSavedRequestHandler.groovy new file mode 100644 index 00000000..18ac1533 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/OverrideSavedRequestHandler.groovy @@ -0,0 +1,21 @@ +package au.org.ala.web + +import groovy.transform.CompileStatic +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.engine.savedrequest.DefaultSavedRequestHandler + +/** + * SavedRequestHandler that allows the application to set the requested URL by adding a request attribute, + * {@link OverrideSavedRequestHandler#OVERRIDE_REQUESTED_URL_ATTRIBUTE}, with the URL to redirect to. If the + * attribute is not set then this class falls back to the {@link DefaultSavedRequestHandler} behaviour. + */ +@CompileStatic +class OverrideSavedRequestHandler extends DefaultSavedRequestHandler { + + final static String OVERRIDE_REQUESTED_URL_ATTRIBUTE = '_ala_override_pac4j_requested_url_' + + protected String getRequestedUrl(final WebContext context, final SessionStore sessionStore) { + return context.getRequestAttribute(OVERRIDE_REQUESTED_URL_ATTRIBUTE).orElseGet { super.getRequestedUrl(context, sessionStore) } + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/Pac4jAuthService.groovy b/ala-auth/src/main/groovy/au/org/ala/web/Pac4jAuthService.groovy new file mode 100644 index 00000000..17cc279c --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/Pac4jAuthService.groovy @@ -0,0 +1,194 @@ +package au.org.ala.web + +import grails.web.mapping.LinkGenerator +import groovy.transform.stc.ClosureParams +import groovy.transform.stc.FromString +import org.pac4j.core.config.Config +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.profile.ProfileManager +import org.pac4j.core.profile.UserProfile + +class Pac4jAuthService implements IAuthService { + + // TODO Make these configurable? + // OIDC openid scope attrs + static final String ATTR_SUB = 'sub' + // OIDC email scope attrs + static final String ATTR_EMAIL = 'email' + static final String ATTR_EMAIL_VERIFIED = 'email_verified' + // OIDC profile scope attrs + static final String ATTR_NAME = 'name' + static final String ATTR_FIRST_NAME = 'given_name' + static final String ATTR_MIDDLE_NAME = 'middle_name' + static final String ATTR_LAST_NAME = 'family_name' + static final String ATTR_NICKNAME = 'nickname' + static final String ATTR_PICTURE = 'picture' + static final String ATTR_UPDATED_AT = 'updated_at' + + // fallback ALA CAS attributes + static final String ATTR_CAS_FIRST_NAME = 'firstname' + static final String ATTR_CAS_LAST_NAME = 'sn' + + // ALA scoped attributes + static final String ATTR_ROLE = 'role' + static final String ATTR_ROLES = 'roles' + + static final String ATTR_USERID = 'userid' + + private final Config config + + private final Pac4jContextProvider pac4jContextProvider + + private final SessionStore sessionStore + + private final LinkGenerator grailsLinkGenerator + + private final String alaUseridClaim + + private final String userNameClaim + + private final String displayNameClaim + + Pac4jAuthService(Config config, Pac4jContextProvider pac4jContextProvider, SessionStore sessionStore, LinkGenerator grailsLinkGenerator, String alaUseridClaim, String userNameClaim, String displayNameClaim) { + this.config = config + this.pac4jContextProvider = pac4jContextProvider + this.sessionStore = sessionStore + this.grailsLinkGenerator = grailsLinkGenerator + this.alaUseridClaim = alaUseridClaim + this.userNameClaim = userNameClaim + this.displayNameClaim = displayNameClaim + } + + ProfileManager getProfileManager() { + def context = pac4jContextProvider.webContext() + final ProfileManager manager = new ProfileManager(context, sessionStore) + manager.config = config + return manager + } + + UserProfile getUserProfile() { + def manager = profileManager + + def value = null + if (manager.authenticated) { + final Optional profile = manager.getProfile() + if (profile.isPresent()) { + value = profile.get() + } + } + return value + } + + String getAttribute(@ClosureParams(value = FromString, options = ["org.pac4j.core.profile.UserProfile"]) Closure attributeClosure) { + def manager = profileManager + if (manager.authenticated) { + return manager.profile.map(attributeClosure).orElse(null) + } else { + return null + } + } + + String getAttribute(String attribute) { + getAttribute { it.getAttribute(attribute) } + } + + @Override + String getEmail() { + return getAttribute(ATTR_EMAIL) + } + + @Override + String getUserName() { + getAttribute { + (userNameClaim ? it.getAttribute(userNameClaim) : null) ?: it.username ?: it.id + } + } + + @Override + String getUserId() { + getAttribute { + (alaUseridClaim ? it.getAttribute(alaUseridClaim) : null) ?: it.getAttribute(ATTR_USERID) ?: it.id + } + } + + @Override + String getDisplayName() { + String displayName = null + if (displayNameClaim) { + displayName = getAttribute(displayNameClaim) + } + if (!displayName) { + String firstname = getAttribute(ATTR_FIRST_NAME) + String lastname = getAttribute(ATTR_LAST_NAME) + if (firstname && lastname) { + displayName = String.format("%s %s", firstname, lastname) + } else if (firstname || lastname) { + displayName = String.format("%s", firstname ?: lastname) + } + + } + return displayName + } + + @Override + String getFirstName() { + return getAttribute(ATTR_FIRST_NAME) ?: getAttribute(ATTR_CAS_FIRST_NAME) + } + + @Override + String getLastName() { + return getAttribute(ATTR_LAST_NAME) ?: getAttribute(ATTR_CAS_LAST_NAME) + } + + /** + * + * @param request Needs to be a {@link org.pac4j.jee.util.Pac4JHttpServletRequestWrapper} + * @return The users roles in a set or an empty set if the user is not authenticated + */ + Set getUserRoles() { + def userProfile = userProfile + def retVal = Collections.emptySet() + + if (userProfile != null) { + def roles = userProfile.roles + if (roles) { + retVal = roles + } + } + return retVal + } + + @Override + boolean userInRole(String role) { + return userRoles.contains(role) + } + + @Override + UserDetails userDetails() { + def attr = userProfile?.attributes + def details = null + + if (attr) { + details = new UserDetails( + userId: userId?.toString(), + userName: userName?.toString(), + email: email?.toString()?.toLowerCase(), + firstName: firstName?.toString() ?: "", + lastName: lastName?.toString() ?: "", + locked: attr?.locked?.toBoolean() ?: false, + organisation: attr?.organisation?.toString() ?: "", + city: attr?.country?.toString() ?: "", + state: attr?.state?.toString() ?: "", + country: attr?.country?.toString() ?: "", + roles: userRoles + ) + } + + details + } + + @Override + String loginUrl(String returnUrl) { + return grailsLinkGenerator.link(mapping:'login', params: [path: returnUrl]) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/Pac4jContextProvider.groovy b/ala-auth/src/main/groovy/au/org/ala/web/Pac4jContextProvider.groovy new file mode 100644 index 00000000..7982e7c3 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/Pac4jContextProvider.groovy @@ -0,0 +1,11 @@ +package au.org.ala.web + +import org.pac4j.core.context.WebContext + +/** + * Provides a Pac4j Context via static methods or similar so that the client code need not take them as params. + */ +interface Pac4jContextProvider { + + WebContext webContext() +} \ No newline at end of file diff --git a/ala-auth/src/main/groovy/au/org/ala/web/Pac4jHttpServletRequestWrapperFilter.groovy b/ala-auth/src/main/groovy/au/org/ala/web/Pac4jHttpServletRequestWrapperFilter.groovy new file mode 100644 index 00000000..c2d6e8f1 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/Pac4jHttpServletRequestWrapperFilter.groovy @@ -0,0 +1,49 @@ +package au.org.ala.web + +import org.pac4j.core.config.Config +import org.pac4j.core.context.WebContextFactory +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.profile.factory.ProfileManagerFactory +import org.pac4j.core.util.FindBest +import org.pac4j.jee.config.AbstractConfigFilter +import org.pac4j.jee.context.JEEContextFactory +import org.pac4j.jee.context.session.JEESessionStore +import org.pac4j.jee.util.Pac4JHttpServletRequestWrapper +import org.springframework.web.util.WebUtils + +import javax.servlet.FilterChain +import javax.servlet.ServletException +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * Loads existing Pac4J profiles from the Http Session and, if they are present and the + * request is not already wrapped (ie has already authenticated a user), wraps the request + * in a Pac4JHttpServletRequestWrapper with the existing profiles. + */ +class Pac4jHttpServletRequestWrapperFilter extends AbstractConfigFilter { + + WebContextFactory webContextFactory + SessionStore sessionStore + ProfileManagerFactory profileManagerFactory + + Pac4jHttpServletRequestWrapperFilter(Config config, SessionStore sessionStore, WebContextFactory webContextFactory) { + this.config = config + this.sessionStore = sessionStore + this.webContextFactory = webContextFactory + } + + @Override + protected void internalFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + def webContext = FindBest.webContextFactory(this.webContextFactory, config, JEEContextFactory.INSTANCE).newContext(request, response) + def sessionStore = FindBest.sessionStore(this.sessionStore, config, JEESessionStore.INSTANCE) + def profileManager = FindBest.profileManagerFactory(this.profileManagerFactory, config, ProfileManagerFactory.DEFAULT).apply(webContext, sessionStore) + profileManager.setConfig(config) + + def existing = WebUtils.getNativeRequest(request, Pac4JHttpServletRequestWrapper) + def profiles = profileManager.getProfiles() + chain.doFilter(existing == null && profiles + ? new Pac4JHttpServletRequestWrapper(request, profiles) + : request, response) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/Pac4jSSOStrategy.groovy b/ala-auth/src/main/groovy/au/org/ala/web/Pac4jSSOStrategy.groovy new file mode 100644 index 00000000..3d991367 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/Pac4jSSOStrategy.groovy @@ -0,0 +1,69 @@ +package au.org.ala.web + +import org.pac4j.core.config.Config +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.engine.DefaultSecurityLogic +import org.pac4j.core.engine.SecurityLogic +import org.pac4j.core.http.adapter.HttpActionAdapter +import org.pac4j.core.util.FindBest +import org.pac4j.jee.context.JEEContextFactory +import org.pac4j.jee.context.session.JEESessionStore +import org.pac4j.jee.http.adapter.JEEHttpActionAdapter + +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class Pac4jSSOStrategy implements SSOStrategy { + + private Config config + + private SecurityLogic securityLogic + + private String clients + private String gatewayClients + + private String authorizers + private String gatewayAuthorizers + + private String matchers + + Pac4jSSOStrategy(Config config, SecurityLogic securityLogic, String clients, String gatewayClients, String authorizers, String gatewayAuthorizers, String matchers) { + + this.config = config + this.securityLogic = securityLogic + this.clients = clients + this.gatewayClients = gatewayClients + this.authorizers = authorizers + this.gatewayAuthorizers = gatewayAuthorizers + this.matchers = matchers + } + + @Override + boolean authenticate(HttpServletRequest request, HttpServletResponse response, boolean gateway) { + authenticate(request, response, gateway, null) + } + + @Override + boolean authenticate(HttpServletRequest request, HttpServletResponse response, boolean gateway, String redirectUri) { + + final SessionStore bestSessionStore = FindBest.sessionStore(null, config, JEESessionStore.INSTANCE) + final HttpActionAdapter bestAdapter = FindBest.httpActionAdapter(null, config, JEEHttpActionAdapter.INSTANCE) + final SecurityLogic bestLogic = FindBest.securityLogic(securityLogic, config, DefaultSecurityLogic.INSTANCE) + + if (bestLogic instanceof DefaultSecurityLogic && bestLogic.savedRequestHandler instanceof OverrideSavedRequestHandler) { + request.setAttribute(OverrideSavedRequestHandler.OVERRIDE_REQUESTED_URL_ATTRIBUTE, redirectUri) + } + + final WebContext context = FindBest.webContextFactory(null, config, JEEContextFactory.INSTANCE).newContext(request, response) + + def result = false + + bestLogic.perform(context, bestSessionStore, config, { ctx, session, profiles, parameters -> + // if no profiles are loaded, pac4j is not concerned with this request + result = true + }, bestAdapter, gateway ? gatewayClients : clients, gateway ? gatewayAuthorizers : authorizers, matchers); + return result + } + +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/RegexListUrlPatternMatcherStrategy.groovy b/ala-auth/src/main/groovy/au/org/ala/web/RegexListUrlPatternMatcherStrategy.groovy new file mode 100644 index 00000000..fb1aa495 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/RegexListUrlPatternMatcherStrategy.groovy @@ -0,0 +1,20 @@ +package au.org.ala.web + +import org.jasig.cas.client.authentication.UrlPatternMatcherStrategy + +import java.util.regex.Pattern + +class RegexListUrlPatternMatcherStrategy implements UrlPatternMatcherStrategy { + + List patterns = [] + + @Override + boolean matches(String url) { + return patterns.any { pattern -> pattern.matcher(url).matches() } + } + + @Override + void setPattern(String pattern) { + this.patterns = pattern.split(',').collect { Pattern.compile(it, Pattern.CASE_INSENSITIVE)} + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/SSOStrategy.groovy b/ala-auth/src/main/groovy/au/org/ala/web/SSOStrategy.groovy new file mode 100644 index 00000000..020f0292 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/SSOStrategy.groovy @@ -0,0 +1,32 @@ +package au.org.ala.web + +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +/** + * Strategy for implementing SSO. Used by the SSO Interceptor to generalise authentication method + */ +interface SSOStrategy { + + /** + * Authenticate a request with the SSO provider + * + * @param request The current request + * @param response The current response + * @param gateway Whether the request is allowed to callback without authenticating + * @return true if the request will be authenticated, false if no authentication is required. + */ + boolean authenticate(HttpServletRequest request, HttpServletResponse response, boolean gateway) + + /** + * Authenticate a request with the SSO provider + * + * @param request The current request + * @param response The current response + * @param gateway Whether the request is allowed to callback without authenticating + * @param redirectUri A redirect URI within the current app to redirect to + * @return true if the request will be authenticated, false if no authentication is required. + */ + boolean authenticate(HttpServletRequest request, HttpServletResponse response, boolean gateway, String redirectUri) + +} \ No newline at end of file diff --git a/ala-auth/src/main/groovy/au/org/ala/web/SecurityPrimitives.groovy b/ala-auth/src/main/groovy/au/org/ala/web/SecurityPrimitives.groovy new file mode 100644 index 00000000..fff9c988 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/SecurityPrimitives.groovy @@ -0,0 +1,128 @@ +package au.org.ala.web + +import grails.core.GrailsApplication + +import javax.servlet.http.HttpServletRequest + +class SecurityPrimitives { + + private final AuthService authService + private final GrailsApplication grailsApplication + + SecurityPrimitives(AuthService authService, GrailsApplication grailsApplication) { + this.authService = authService + this.grailsApplication = grailsApplication + } + + /** + * Is the current user logged in? + */ + boolean isLoggedIn() { + authService.userId != null + } + + /** + * Is the current user logged in? Bypasses the authService and checks the request details instead. + * + * @param request The http request object + * @return true if logged in + */ + boolean isLoggedIn(HttpServletRequest request) { + request.userPrincipal != null + } + + /** + * Is the current user not logged in? + */ + boolean isNotLoggedIn() { + return authService.userId == null + } + + /** + * Is the current user not logged in? Bypasses the authService and checks the request details instead. + * + * @param request The http request object + * @return true if logged out + */ + boolean isNotLoggedIn(HttpServletRequest request) { + !isLoggedIn(request) + } + + boolean bypassCas() { + def bypass = grailsApplication.config.getProperty('security.cas.bypass') + return bypass?.toString()?.toBoolean() ?: false + } + + /** + * Does the currently logged in user have any of the given roles? + * + * @param roles A list of roles to check + */ + boolean isAnyGranted(Iterable roles) { + fixAlaAdminRole(roles).any { role -> + authService.userInRole(role?.trim()) + } + } + + /** + * Does the currently logged in user have any of the given roles? + * + * @param roles A list of roles to check + */ + boolean isAnyGranted(HttpServletRequest request, Iterable roles) { + bypassCas() || fixAlaAdminRole(roles).any { role -> + request.isUserInRole(role) + } + } + + /** + * Does the currently logged in user have all of the given roles? + * + * @param roles A list of roles to check + */ + boolean isAllGranted(Iterable roles) { + fixAlaAdminRole(roles).every { role -> + authService.userInRole(role?.trim()) + } + } + + /** + * Does the currently logged in user have all of the given roles? + * + * @param roles A list of roles to check + */ + boolean isAllGranted(HttpServletRequest request, Iterable roles) { + bypassCas() || fixAlaAdminRole(roles).every { role -> + request.isUserInRole(role) + } + } + + + /** + * Does the currently logged in user have none of the given roles? + * + * @param roles A list of roles to check + */ + boolean isNotGranted(Iterable roles) { + !isAnyGranted(roles) + } + + /** + * Does the currently logged in user have none of the given roles? + * + * @param roles A list of roles to check + */ + boolean isNotGranted(HttpServletRequest request, Iterable roles) { + bypassCas() || !isAnyGranted(request, roles) + } + + /** + * Replace CASRoles.ROLE_ADMIN Role with the security.cas.adminRole property if it's defined. + * @param roles The list of roles to modify + * @return The roles with ROLE_ADMIN replaced + */ + private Iterable fixAlaAdminRole(Iterable roles) { + def adminRole = grailsApplication.config.getProperty('security.cas.adminRole') ?: '' + adminRole && roles?.contains(CASRoles.ROLE_ADMIN) ? roles + adminRole : roles + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/SpringSessionLogoutHandler.groovy b/ala-auth/src/main/groovy/au/org/ala/web/SpringSessionLogoutHandler.groovy new file mode 100644 index 00000000..f4b1737c --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/SpringSessionLogoutHandler.groovy @@ -0,0 +1,124 @@ +package au.org.ala.web + +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.logout.handler.LogoutHandler +import org.pac4j.core.profile.factory.ProfileManagerFactoryAware +import org.pac4j.core.util.CommonHelper +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.session.FindByIndexNameSessionRepository +import org.springframework.session.Session + +/** + * This is a LogoutHandler that works with the Spring Session repository instead of relying + * on a Pac4J Store as the DefaultLogoutHandler does. The behaviour should be equivalent to that + * of the DefaultLogoutHandler. + * + * It stores the OIDC Session ID (SID) in the Spring Session SID_FIELD_NAME attribute, + * so that it can then lookup sessions based on the SID for Single Logout. + * + * {@link org.springframework.session.data.mongo.AbstractMongoSessionConverter} + * instances need to accept {@link #SID_INDEX_NAME} as the index name for looking up the + * {@link #SID_FIELD_NAME} value. One such implementation is provided in this plugin: + * {@link au.org.ala.web.mongo.Pac4jJdkMongoSessionConverter} + */ +class SpringSessionLogoutHandler extends ProfileManagerFactoryAware implements LogoutHandler { + + protected final Logger logger = LoggerFactory.getLogger(getClass()) + + private boolean destroySession; + private FindByIndexNameSessionRepository repository + + public static final String SID_INDEX_NAME = FindByIndexNameSessionRepository.class.getName() + .concat(".SID_INDEX_NAME") + public static final String SID_FIELD_NAME = "_sid" + + SpringSessionLogoutHandler(FindByIndexNameSessionRepository repository) { + this.repository = repository + } + + + @Override + void recordSession(final WebContext context, final SessionStore sessionStore, final String key) { + if (sessionStore == null) { + logger.error("No session store available for this web context"); + } else { + // unnecessary? JDK Converter should extract into serialised session + sessionStore.set(context, SID_FIELD_NAME, key) + } + } + + @Override + void destroySessionFront(final WebContext context, final SessionStore sessionStore, final String key) { + def sessions + if (!key) { + def sessionId = sessionStore.getSessionId(context, false).orElse('') + if (sessionId) { + sessions = [(sessionId): repository.findById(sessionId)] + } else { + sessions = [:] + } + } else { + sessions = repository.findByIndexNameAndIndexValue(SID_INDEX_NAME, key) + } + + sessions.keySet().each { id -> repository.deleteById(id) } + + destroy(context, sessionStore, "front") + + } + + protected void destroy(final WebContext context, final SessionStore sessionStore, final String channel) { + // remove profiles + final def manager = getProfileManager(context, sessionStore); + manager.removeProfiles(); + logger.debug("{} channel logout call: destroy the user profiles", channel); + // and optionally the web session + if (destroySession) { + logger.debug("destroy the whole session"); + final def invalidated = sessionStore.destroySession(context); + if (!invalidated) { + logger.error("The session has not been invalidated"); + } + } + } + + @Override + void destroySessionBack(final WebContext context, final SessionStore sessionStore, final String key) { + def sessions + if (!key) { + sessions = [:] + } else { + sessions = repository.findByIndexNameAndIndexValue(SID_INDEX_NAME, key) + } + + sessions.keySet().each { id -> repository.deleteById(id) } + + destroy(context, sessionStore, "back") + + } + + @Override + void renewSession(final String oldSessionId, final WebContext context, final SessionStore sessionStore) { + def oldSession = repository.findById(oldSessionId) + def key = oldSession?.getAttribute(SID_FIELD_NAME) + if (key) { + repository.deleteById(oldSessionId) + recordSession(context, sessionStore, key) + } + } + + boolean isDestroySession() { + return destroySession; + } + + void setDestroySession(final boolean destroySession) { + this.destroySession = destroySession; + } + + @Override + String toString() { + return CommonHelper.toNiceString(this.getClass(), "repository", repository, "destroySession", destroySession) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/UriExclusionFilter.groovy b/ala-auth/src/main/groovy/au/org/ala/web/UriExclusionFilter.groovy new file mode 100644 index 00000000..b7527776 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/UriExclusionFilter.groovy @@ -0,0 +1,54 @@ +package au.org.ala.web + +import javax.servlet.Filter +import javax.servlet.FilterChain +import javax.servlet.FilterConfig +import javax.servlet.ServletException +import javax.servlet.ServletRequest +import javax.servlet.ServletResponse +import javax.servlet.http.HttpServletRequest + +/** + * Simple filter wrapper that skips the filter if the request URI starts with the given path. + * The intended use of this is to prevent filters being applied to Spring Boot actuator endpoints. + * + * The path comparision is simply: "does the request URI minus the context path start with a given path?" + */ +class UriExclusionFilter implements Filter { + + private Filter delegate + private String path + + UriExclusionFilter(Filter delegate, String path) { + this.delegate = delegate + this.path = path + } + + @Override + void init(FilterConfig filterConfig) throws ServletException { + delegate.init(filterConfig) + } + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + if (request instanceof HttpServletRequest) { + final httpRequest = (HttpServletRequest) request + final ctx = httpRequest.contextPath + def uri = httpRequest.requestURI + if (uri.startsWith(ctx)) uri = uri.substring(ctx.length()) + + if (uri.startsWith(path)) { + chain.doFilter(request, response) + } else { + delegate.doFilter(request, response, chain) + } + } else { + chain.doFilter(request, response) + } + } + + @Override + void destroy() { + delegate.destroy() + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/UserAgentBypassFilterWrapper.groovy b/ala-auth/src/main/groovy/au/org/ala/web/UserAgentBypassFilterWrapper.groovy new file mode 100644 index 00000000..cd167b48 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/UserAgentBypassFilterWrapper.groovy @@ -0,0 +1,51 @@ +package au.org.ala.web + +import javax.servlet.Filter +import javax.servlet.FilterChain +import javax.servlet.FilterConfig +import javax.servlet.ServletException +import javax.servlet.ServletRequest +import javax.servlet.ServletResponse +import javax.servlet.http.HttpServletRequest + +class UserAgentBypassFilterWrapper implements Filter { + + Filter delegate + UserAgentFilterService userAgentFilterService + + UserAgentBypassFilterWrapper(Filter delegate, UserAgentFilterService userAgentFilterService) { + this.delegate = delegate + this.userAgentFilterService = userAgentFilterService + } + + @Override + void init(FilterConfig filterConfig) throws ServletException { + delegate.init(filterConfig) + } + + @Override + void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + + if (request instanceof HttpServletRequest) { + def userAgent = request.getHeader('User-Agent') + def accepted = this.userAgentFilterService.isFiltered(userAgent) + if (accepted) { + chain.doFilter(request, response) + } else { + this.delegate.doFilter(request, response, chain) + } + } else { + this.delegate.doFilter(request, response, chain) + } + } + + @Override + void destroy() { + + } + + @Override + String toString() { + return "UserAgentFilterWrapper(delegate = " + delegate.toString() + ")" + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/UserAgentFilterService.groovy b/ala-auth/src/main/groovy/au/org/ala/web/UserAgentFilterService.groovy new file mode 100644 index 00000000..a0e92f90 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/UserAgentFilterService.groovy @@ -0,0 +1,30 @@ +package au.org.ala.web + +import com.github.benmanes.caffeine.cache.CacheLoader +import com.github.benmanes.caffeine.cache.Caffeine +import com.github.benmanes.caffeine.cache.LoadingCache +import groovy.transform.CompileStatic + +import java.util.regex.Pattern + +@CompileStatic +class UserAgentFilterService { + + LoadingCache cache + List crawlerPatterns + + UserAgentFilterService(String cacheConfig, List crawlerPatterns) { + if (!cacheConfig) cacheConfig = 'maximumSize=1000' + this.crawlerPatterns = crawlerPatterns + this.cache = Caffeine.from(cacheConfig).build(this.&isFilteredInternal as CacheLoader) + } + + boolean isFiltered(String userAgent) { + cache.get(userAgent) + } + + Boolean isFilteredInternal(String userAgent) { + return crawlerPatterns.any { it.matcher(userAgent).matches() } + } + +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/config/AuthGenericPluginConfig.groovy b/ala-auth/src/main/groovy/au/org/ala/web/config/AuthGenericPluginConfig.groovy new file mode 100644 index 00000000..e4cd2a31 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/config/AuthGenericPluginConfig.groovy @@ -0,0 +1,104 @@ +package au.org.ala.web.config + +import au.org.ala.userdetails.UserDetailsClient +import au.org.ala.web.CasClientProperties +import au.org.ala.web.UserAgentFilterService +import com.squareup.moshi.Moshi +import com.squareup.moshi.Rfc3339DateJsonAdapter +import groovy.json.JsonSlurper +import groovy.transform.CompileDynamic +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +import java.util.regex.Pattern + +import static java.util.concurrent.TimeUnit.MILLISECONDS + +@CompileStatic +@Configuration("authGenericPluginConfiguration") +@EnableConfigurationProperties(CasClientProperties) +@Slf4j +class AuthGenericPluginConfig { + + @Value('${info.app.name:Unknown-App}') + String name + + @Value('${info.app.version:1}') + String version + + @Value('${userDetails.readTimeout:10000}') + Long userDetailsReadTimeout + + @Value('${userDetails.url}') + String userDetailsUrl + + @Bean('userAgentInterceptor') + @ConditionalOnMissingBean(name = 'userAgentInterceptor') + Interceptor userAgentInterceptor() { + def userAgent = "$name/$version" + new Interceptor() { + @Override + Response intercept(Interceptor.Chain chain) throws IOException { + chain.proceed( + chain.request().newBuilder() + .header('User-Agent', userAgent) + .build() + ) + } + } + } + + @Bean + @ConditionalOnMissingBean(name = 'userDetailsInterceptors') + List userDetailsInterceptors( + @Autowired(required = false) @Qualifier("jwtInterceptor") Interceptor jwtInterceptor, + @Qualifier('userAgentInterceptor') Interceptor userAgentInterceptor) { + [userAgentInterceptor, jwtInterceptor].findAll() + } + + @ConditionalOnMissingBean(name = "userDetailsHttpClient") + @Bean(name = ["defaultUserDetailsHttpClient", "userDetailsHttpClient"]) + OkHttpClient userDetailsHttpClient(@Qualifier("userDetailsInterceptors") List userDetailsInterceptors) { + new OkHttpClient.Builder().tap {builder -> + builder.readTimeout(userDetailsReadTimeout, MILLISECONDS) + userDetailsInterceptors.each(builder.&addInterceptor) + }.build() + } + + @ConditionalOnMissingBean(name = "userDetailsMoshi") + @Bean(name = ["defaultUserDetailsMoshi", "userDetailsMoshi"]) + Moshi userDetailsMoshi() { + new Moshi.Builder().add(Date, new Rfc3339DateJsonAdapter().nullSafe()).build() + } + + + @Bean("userDetailsClient") + UserDetailsClient userDetailsClient(@Qualifier("userDetailsHttpClient") OkHttpClient userDetailsHttpClient, + @Qualifier('userDetailsMoshi') Moshi moshi) { + String baseUrl = userDetailsUrl + UserDetailsClient.Builder.from(userDetailsHttpClient, baseUrl).moshi(moshi).build() + } + + @ConditionalOnMissingBean(name = "crawlerPatterns") + @Bean + @CompileDynamic + List crawlerPatterns() { + List crawlerUserAgents = new JsonSlurper().parse(this.class.classLoader.getResource('crawler-user-agents.json')) + return crawlerUserAgents*.pattern.collect { Pattern.compile(it) } + } + + @Bean + UserAgentFilterService userAgentFilterService() { + return new UserAgentFilterService('', crawlerPatterns()) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/config/AuthPac4jPluginConfig.groovy b/ala-auth/src/main/groovy/au/org/ala/web/config/AuthPac4jPluginConfig.groovy new file mode 100644 index 00000000..9f48243a --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/config/AuthPac4jPluginConfig.groovy @@ -0,0 +1,497 @@ +package au.org.ala.web.config + + +import au.org.ala.web.CasClientProperties +import au.org.ala.web.CookieFilterWrapper +import au.org.ala.web.CookieMatcher +import au.org.ala.web.CooperatingFilterWrapper +import au.org.ala.web.CoreAuthProperties +import au.org.ala.web.GrailsPac4jContextProvider +import au.org.ala.web.IAuthService +import au.org.ala.web.NotBotMatcher +import au.org.ala.web.OidcClientProperties +import au.org.ala.web.OverrideSavedRequestHandler +import au.org.ala.web.Pac4jAuthService +import au.org.ala.web.Pac4jContextProvider +import au.org.ala.web.Pac4jHttpServletRequestWrapperFilter +import au.org.ala.web.Pac4jSSOStrategy +import au.org.ala.web.SSOStrategy +import au.org.ala.web.UserAgentFilterService +import au.org.ala.web.pac4j.ConvertingFromAttributesAuthorizationGenerator +import com.nimbusds.jose.util.DefaultResourceRetriever +import grails.core.GrailsApplication +import grails.web.http.HttpHeaders +import grails.web.mapping.LinkGenerator +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.pac4j.core.authorization.generator.DefaultRolesPermissionsAuthorizationGenerator +import org.pac4j.core.client.Client +import org.pac4j.core.client.Clients +import org.pac4j.core.client.direct.AnonymousClient +import org.pac4j.core.config.Config +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.WebContextFactory +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.context.session.SessionStoreFactory +import org.pac4j.core.engine.DefaultLogoutLogic +import org.pac4j.core.engine.DefaultSecurityLogic +import org.pac4j.core.engine.LogoutLogic +import org.pac4j.core.engine.SecurityLogic +import org.pac4j.core.engine.savedrequest.SavedRequestHandler +import org.pac4j.core.http.url.DefaultUrlResolver +import org.pac4j.core.logout.handler.LogoutHandler +import org.pac4j.core.matching.matcher.PathMatcher +import org.pac4j.core.util.Pac4jConstants +import org.pac4j.jee.context.JEEContextFactory +import org.pac4j.jee.context.session.JEESessionStore +import org.pac4j.jee.context.session.JEESessionStoreFactory +import org.pac4j.jee.filter.CallbackFilter +import org.pac4j.jee.filter.LogoutFilter +import org.pac4j.jee.filter.SecurityFilter +import org.pac4j.oidc.client.OidcClient +import org.pac4j.oidc.config.OidcConfiguration +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary + +import javax.servlet.DispatcherType +import java.util.regex.Pattern + +import static org.pac4j.core.authorization.authorizer.IsAnonymousAuthorizer.isAnonymous +import static org.pac4j.core.authorization.authorizer.IsAuthenticatedAuthorizer.isAuthenticated +import static org.pac4j.core.authorization.authorizer.OrAuthorizer.or + +@CompileStatic +@Configuration("authPac4jPluginConfiguration") +@EnableConfigurationProperties([CasClientProperties, OidcClientProperties, CoreAuthProperties]) +@Slf4j +class AuthPac4jPluginConfig { + + static final String DEFAULT_CLIENT = "OidcClient" + static final String PROMPT_NONE_CLIENT = "PromptNoneClient" + + static final String ALLOW_ALL = "allowAll" + static final String IS_AUTHENTICATED = "isAuthenticated" + + static final String ALA_COOKIE_MATCHER = "alaCookieMatcher" + static final String EXCLUDE_PATHS = "excludePaths" + public static final String CALLBACK_URI = "/callback" + public static final String NOT_BOT_MATCHER = "notBotMatcher" + + @Value('${info.app.name:Unknown-App}') + String name + + @Value('${info.app.version:1}') + String version + + @Autowired + CasClientProperties casClientProperties + @Autowired + CoreAuthProperties coreAuthProperties + @Autowired + OidcClientProperties oidcClientProperties + + @Autowired + LinkGenerator linkGenerator + + @Autowired + GrailsApplication grailsApplication + + @Autowired(required = false) + LogoutHandler oidcLogoutHandler + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + IAuthService delegateService(Config config, Pac4jContextProvider pac4jContextProvider, SessionStore sessionStore, LinkGenerator grailsLinkGenerator) { + new Pac4jAuthService(config, pac4jContextProvider, sessionStore, grailsLinkGenerator, + oidcClientProperties.alaUseridClaim, oidcClientProperties.userNameClaim, oidcClientProperties.displayNameClaim) + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + OidcConfiguration oidcConfiguration() { + OidcConfiguration config = generateBaseOidcClientConfiguration(oidcLogoutHandler) + return config + } + + private OidcConfiguration generateBaseOidcClientConfiguration(LogoutHandler logoutHandler) { + OidcConfiguration config = new OidcConfiguration() + config.setClientId(oidcClientProperties.clientId) + config.setSecret(oidcClientProperties.secret) + config.setDiscoveryURI(oidcClientProperties.discoveryUri) + config.setConnectTimeout(oidcClientProperties.connectTimeout) + config.setReadTimeout(oidcClientProperties.readTimeout) + config.setScope(oidcClientProperties.scope) + config.setWithState(oidcClientProperties.withState) + config.customParams.putAll(oidcClientProperties.customParams) + if (oidcClientProperties.clientAuthenticationMethod) { + config.setClientAuthenticationMethodAsString(oidcClientProperties.clientAuthenticationMethod) + } + if (oidcClientProperties.allowUnsignedIdTokens) { + config.allowUnsignedIdTokens = oidcClientProperties.allowUnsignedIdTokens + } + if (logoutHandler) { + config.logoutHandler = logoutHandler + } + if (oidcClientProperties.logoutUrl) { + config.logoutUrl = oidcClientProperties.logoutUrl + } + + def resourceRetriever = new DefaultResourceRetriever(oidcClientProperties.connectTimeout, oidcClientProperties.readTimeout) + String userAgent = "$name/$version" + resourceRetriever.headers = [(HttpHeaders.USER_AGENT): [userAgent]] + config.resourceRetriever = resourceRetriever + + // select display mode: page, popup, touch, and wap +// config.addCustomParam("display", "popup"); + // select prompt mode: none, consent, select_account +// config.addCustomParam("prompt", "none"); + config + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + @Primary + OidcClient oidcClient(OidcConfiguration oidcConfiguration) { + def client = createOidcClientFromConfig(oidcConfiguration) + client.setName(DEFAULT_CLIENT) + return client + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + OidcClient oidcPromptNoneClient() { + def config = generateBaseOidcClientConfiguration(oidcLogoutHandler) + // select prompt mode: none, consent, select_account + config.addCustomParam("prompt", "none") + def client = createOidcClientFromConfig(config) + client.setName(PROMPT_NONE_CLIENT) + return client + } + + private OidcClient createOidcClientFromConfig(OidcConfiguration oidcConfiguration) { + def client = new OidcClient(oidcConfiguration) + client.addAuthorizationGenerator(new ConvertingFromAttributesAuthorizationGenerator([coreAuthProperties.roleAttribute ?: casClientProperties.roleAttribute],coreAuthProperties.permissionAttributes, oidcClientProperties.rolePrefix, oidcClientProperties.convertRolesToUpperCase)) + client.addAuthorizationGenerator(new DefaultRolesPermissionsAuthorizationGenerator(['ROLE_USER'] , [])) + client.setUrlResolver(new DefaultUrlResolver(true)) + def logoutActionBuilder = oidcClientProperties.logoutAction.getLogoutActionBuilder(oidcConfiguration) + if (logoutActionBuilder != null) { + client.logoutActionBuilder = logoutActionBuilder + } + + return client + } + + @ConditionalOnProperty(prefix='security.oidc', name=['enabled', 'useAnonymousClient']) + @Bean + Client anonymousClient() { + return AnonymousClient.INSTANCE + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + Pac4jContextProvider pac4jContextProvider(Config config) { + new GrailsPac4jContextProvider(config) + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + SessionStore sessionStore() { + JEESessionStore.INSTANCE + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + SessionStoreFactory sessionStoreFactory() { + JEESessionStoreFactory.INSTANCE + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + WebContextFactory webContextFactory() { + JEEContextFactory.INSTANCE + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @ConditionalOnMissingBean + @Bean + SavedRequestHandler savedRequestHandler() { + new OverrideSavedRequestHandler() + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @ConditionalOnMissingBean + @Bean + SecurityLogic securityLogic(SavedRequestHandler savedRequestHandler) { + new DefaultSecurityLogic().tap { + it.savedRequestHandler = savedRequestHandler + } + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + Config pac4jConfig(List clientBeans, SessionStore sessionStore, SessionStoreFactory sessionStoreFactory, WebContextFactory webContextFactory, UserAgentFilterService userAgentFilterService, SecurityLogic securityLogic) { + Clients clients = new Clients(linkGenerator.link(absolute: true, uri: CALLBACK_URI), clientBeans) + + Config config = new Config(clients) + config.sessionStore = sessionStore + config.sessionStoreFactory = sessionStoreFactory + config.webContextFactory = webContextFactory + config.securityLogic = securityLogic + config.addAuthorizer(IS_AUTHENTICATED, isAuthenticated()) + config.addAuthorizer(ALLOW_ALL, or(isAuthenticated(), isAnonymous())) + config.addMatcher(ALA_COOKIE_MATCHER, new CookieMatcher(coreAuthProperties.authCookieName ?: casClientProperties.authCookieName,".*")) + config.addMatcher(NOT_BOT_MATCHER, new NotBotMatcher(userAgentFilterService)) + def excludeMatcher = new PathMatcher() + (coreAuthProperties.uriExclusionFilterPattern + casClientProperties.uriExclusionFilterPattern).each { + if (!it.startsWith("^")) { + it = '^' + it + } + if (!it.endsWith('$')) { + it += '$' + } + excludeMatcher.excludeRegex(it) + } + config.addMatcher(EXCLUDE_PATHS, excludeMatcher) + config + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @ConditionalOnMissingBean(name = 'defaultLogoutLogic') + @Bean('defaultLogoutLogic') + LogoutLogic defaultLogoutLogic() { + return new DefaultLogoutLogic() { + @Override + protected String enhanceRedirectUrl(Config config, Client client, WebContext context, SessionStore sessionStore, String redirectUrl) { + def redirectUri = URI.create(redirectUrl) + if (!redirectUri.isAbsolute()) { + return URI.create(context.requestURL).resolve(redirectUri).toString() + } else { + return redirectUrl + } + } + } + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + FilterRegistrationBean pac4jLogoutFilter(Config pac4jConfig, @Qualifier('defaultLogoutLogic') LogoutLogic defaultLogoutLogic) { + final name = 'Pac4j Logout Filter' + def frb = new FilterRegistrationBean() + frb.name = name + // Redirect must be absolute for indirect client aka OIDC logout + def redirectUrl = linkGenerator.link(absolute: true, uri: coreAuthProperties.defaultLogoutRedirectUri) + def baseUrl = linkGenerator.serverBaseURL + // Is this necessary? + if (baseUrl.endsWith('/')) { + baseUrl.substring(0, baseUrl.length() - 1) + } + LogoutFilter logoutFilter = new LogoutFilter(pac4jConfig, redirectUrl) + if (coreAuthProperties.logoutUrlPattern) { + logoutFilter.setLogoutUrlPattern(coreAuthProperties.logoutUrlPattern) + } else { + // default logout url pattern is the PAC4j url pattern with an optional base url pre-pended + def pac4jDefaultLogoutUrlPatternValue = Pac4jConstants.DEFAULT_LOGOUT_URL_PATTERN_VALUE + boolean startsWith = false + boolean endsWith = false + if (pac4jDefaultLogoutUrlPatternValue.startsWith('^')) { + startsWith = true + pac4jDefaultLogoutUrlPatternValue = pac4jDefaultLogoutUrlPatternValue.substring(1) + } + if (pac4jDefaultLogoutUrlPatternValue.endsWith('$')) { + endsWith = true + pac4jDefaultLogoutUrlPatternValue = pac4jDefaultLogoutUrlPatternValue.substring(0, pac4jDefaultLogoutUrlPatternValue.length() -1) + } + def pattern = "(${Pattern.quote(baseUrl)})?${pac4jDefaultLogoutUrlPatternValue}" + if (startsWith) { + pattern = '^' + pattern + } + if (endsWith) { + pattern = pattern + '$' + } + logoutFilter.setLogoutUrlPattern(pattern.toString()) + } + logoutFilter.setCentralLogout(coreAuthProperties.centralLogout) + logoutFilter.setDestroySession(coreAuthProperties.destroySession) + logoutFilter.setLocalLogout(coreAuthProperties.localLogout) + logoutFilter.setLogoutLogic(defaultLogoutLogic) + frb.filter = logoutFilter + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = AuthPluginConfig.filterOrder() + frb.urlPatterns = [ '/logout' ] + frb.enabled = true + frb.asyncSupported = true + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + FilterRegistrationBean pac4jCallbackFilter(Config pac4jConfig) { + final name = 'Pac4j Callback Filter' + def frb = new FilterRegistrationBean() + frb.name = name + // TODO Add config property for Default URI? + CallbackFilter callbackFilter = new CallbackFilter(pac4jConfig, linkGenerator.link(uri: '/')) + callbackFilter.defaultClient = DEFAULT_CLIENT + frb.filter = callbackFilter + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = AuthPluginConfig.filterOrder() + frb.urlPatterns = [ CALLBACK_URI ] + frb.enabled = true + frb.asyncSupported = true + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + FilterRegistrationBean pac4jUriFilter(Config pac4jConfig) { + + // This filter will apply the uriFiltersPattern + final name = 'Pac4j Security Filter' + def frb = new FilterRegistrationBean() + frb.name = name + def clients = oidcClientProperties.isUseAnonymousClient() + ? toStringParam(DEFAULT_CLIENT, AnonymousClient.class.name) + : toStringParam(DEFAULT_CLIENT) + SecurityFilter securityFilter = new SecurityFilter(pac4jConfig, + clients, + IS_AUTHENTICATED, EXCLUDE_PATHS) + frb.filter = new CooperatingFilterWrapper(securityFilter, AuthPluginConfig.AUTH_FILTER_KEY) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = AuthPluginConfig.filterOrder() + 1 + frb.urlPatterns = coreAuthProperties.uriFilterPattern ?: casClientProperties.uriFilterPattern + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + FilterRegistrationBean pac4jOptionalFilter(Config pac4jConfig) { + + // This filter will apply the optional auth filter patterns - will only SSO if a cookie is present + final name = 'Pac4j Optional Security Filter' + def frb = new FilterRegistrationBean() + frb.name = name + def clients = oidcClientProperties.isUseAnonymousClient() + ? toStringParam(DEFAULT_CLIENT, AnonymousClient.class.name) + : toStringParam(DEFAULT_CLIENT) + SecurityFilter securityFilter = new SecurityFilter(pac4jConfig, + clients, + IS_AUTHENTICATED, toStringParam(ALA_COOKIE_MATCHER, EXCLUDE_PATHS)) + frb.filter = new CooperatingFilterWrapper(securityFilter, AuthPluginConfig.AUTH_FILTER_KEY) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = AuthPluginConfig.filterOrder() + 2 + frb.urlPatterns = coreAuthProperties.optionalFilterPattern + + casClientProperties.authenticateOnlyIfCookieFilterPattern + + casClientProperties.authenticateOnlyIfLoggedInFilterPattern + + casClientProperties.authenticateOnlyIfLoggedInPattern + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + FilterRegistrationBean pac4jPromptNoneFilter(Config pac4jConfig) { + + // This filter will apply the prompt=none filter patterns + final name = 'Pac4j Prompt None Security Filter' + def frb = new FilterRegistrationBean() + frb.name = name + def clients = oidcClientProperties.isUseAnonymousClient() + ? toStringParam(PROMPT_NONE_CLIENT, AnonymousClient.class.name) + : toStringParam(PROMPT_NONE_CLIENT) + SecurityFilter securityFilter = new SecurityFilter(pac4jConfig, + clients, + ALLOW_ALL, toStringParam(NOT_BOT_MATCHER,EXCLUDE_PATHS)) + frb.filter = new CooperatingFilterWrapper(securityFilter, AuthPluginConfig.AUTH_FILTER_KEY) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = AuthPluginConfig.filterOrder() + 3 + frb.urlPatterns = casClientProperties.gatewayFilterPattern + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + FilterRegistrationBean pac4jPromptNoneCookieFilter(Config pac4jConfig) { + + // This filter will apply the prompt=none filter patterns if a cookie is present + final name = 'Pac4j Prompt None Cookie Security Filter' + def frb = new FilterRegistrationBean() + frb.name = name + def clients = oidcClientProperties.isUseAnonymousClient() + ? toStringParam(PROMPT_NONE_CLIENT, AnonymousClient.class.name) + : toStringParam(PROMPT_NONE_CLIENT) + SecurityFilter securityFilter = new SecurityFilter(pac4jConfig, + clients, + ALLOW_ALL, toStringParam(ALA_COOKIE_MATCHER, NOT_BOT_MATCHER, EXCLUDE_PATHS)) + frb.filter = new CooperatingFilterWrapper(new CookieFilterWrapper(securityFilter, coreAuthProperties.authCookieName), AuthPluginConfig.AUTH_FILTER_KEY) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = AuthPluginConfig.filterOrder() + 4 + frb.urlPatterns = casClientProperties.gatewayIfCookieFilterPattern + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + FilterRegistrationBean pac4jProfileFilter(Config pac4jConfig, SessionStore sessionStore, WebContextFactory webContextFactory) { + + // This filter will apply to all requests but apply no SSO or authentication, + // only wrap the request in a pac4j request wrapper if profiles exist in the session + // Analogous to the CAS HttpServletRequestWrapperFilter + final name = 'Pac4j Existing Profiles Filter' + def frb = new FilterRegistrationBean() + frb.name = name + Pac4jHttpServletRequestWrapperFilter pac4jFilter = new Pac4jHttpServletRequestWrapperFilter(pac4jConfig, sessionStore, webContextFactory) + frb.filter = pac4jFilter + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = AuthPluginConfig.filterOrder() + 5 + frb.urlPatterns = ['/*'] + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + logFilter(name, frb) + return frb + } + + private static void logFilter(String name, FilterRegistrationBean frb) { + if (frb.enabled) { + log.debug('{} enabled with type: {}', name, frb.filter) + log.debug('{} enabled with params: {}', name, frb.initParameters) + log.debug('{} enabled for paths: {}', name, frb.urlPatterns) + } else { + log.debug('{} disabled', name) + } + } + + @ConditionalOnProperty(prefix= 'security.oidc', name='enabled') + @Bean + SSOStrategy ssoStrategy(Config config) { + new Pac4jSSOStrategy(config, null, + oidcClientProperties.isUseAnonymousClient() ? toStringParam(AnonymousClient.class.name, DEFAULT_CLIENT) : DEFAULT_CLIENT, + oidcClientProperties.isUseAnonymousClient() ? toStringParam(AnonymousClient.class.name, PROMPT_NONE_CLIENT) : PROMPT_NONE_CLIENT, + IS_AUTHENTICATED, ALLOW_ALL, + "") + } + + private static String toStringParam(String... params) { + params.join(Pac4jConstants.ELEMENT_SEPARATOR) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/config/AuthPluginConfig.groovy b/ala-auth/src/main/groovy/au/org/ala/web/config/AuthPluginConfig.groovy new file mode 100644 index 00000000..40104771 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/config/AuthPluginConfig.groovy @@ -0,0 +1,276 @@ +package au.org.ala.web.config + +import au.org.ala.userdetails.UserDetailsClient +import au.org.ala.web.CasAuthService +import au.org.ala.web.CasClientProperties +import au.org.ala.web.CasContextParamInitializer +import au.org.ala.web.CasSSOStrategy +import au.org.ala.web.CookieFilterWrapper +import au.org.ala.web.CooperatingFilterWrapper +import au.org.ala.web.CoreAuthProperties +import au.org.ala.web.IAuthService +import au.org.ala.web.RegexListUrlPatternMatcherStrategy +import au.org.ala.web.SSOStrategy +import au.org.ala.web.UriExclusionFilter +import au.org.ala.web.UserAgentBypassFilterWrapper +import au.org.ala.web.UserAgentFilterService +import grails.core.GrailsApplication +import grails.util.Metadata +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.jasig.cas.client.authentication.AuthenticationFilter +import org.jasig.cas.client.authentication.DefaultGatewayResolverImpl +import org.jasig.cas.client.authentication.GatewayResolver +import org.jasig.cas.client.authentication.UrlPatternMatcherStrategy +import org.jasig.cas.client.configuration.ConfigurationKeys +import org.jasig.cas.client.session.SingleSignOutFilter +import org.jasig.cas.client.util.HttpServletRequestWrapperFilter +import org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.boot.web.servlet.FilterRegistrationBean +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.util.AntPathMatcher + +import javax.servlet.DispatcherType +import javax.servlet.Filter + +@CompileStatic +@Configuration("alaAuthPluginConfiguration") +@EnableConfigurationProperties([CasClientProperties, CoreAuthProperties]) +@Slf4j +class AuthPluginConfig { + + static final String AUTH_FILTER_KEY = '_cas_authentication_filter_' + + @Autowired + CasClientProperties casClientProperties + @Autowired + CoreAuthProperties coreAuthProperties + + @Autowired + GrailsApplication grailsApplication + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + IAuthService delegateService(UserDetailsClient userDetailsClient) { + new CasAuthService(userDetailsClient, casClientProperties.bypass, casClientProperties.loginUrl) + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + CasContextParamInitializer casContextParamInitializer() { + new CasContextParamInitializer(coreAuthProperties, casClientProperties) + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean("ignoreUrlPatternMatcherStrategy") + UrlPatternMatcherStrategy ignoreUrlPatternMatcherStrategy() { + def strat = new RegexListUrlPatternMatcherStrategy() + strat.setPattern((coreAuthProperties.uriExclusionFilterPattern + casClientProperties.uriExclusionFilterPattern).join(',')) + return strat + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @ConditionalOnMissingBean(name = 'gatewayResolver') + @Bean('gatewayResolver') + GatewayResolver gatewayResolver() { + final GatewayResolver resolver + if (casClientProperties.gatewayStorageClass) { + resolver = (GatewayResolver) Class.forName(casClientProperties.gatewayStorageClass).newInstance() + } else { + resolver = new DefaultGatewayResolverImpl() + } + return resolver + } + + // The filter chain has to be before grailsWebRequestFilter but after the encoding filter. + // Its order changed in 3.1 (from Ordered.HIGHEST_PRECEDENCE + 30 (-2147483618) to + // FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER + 30 (30)) + static int filterOrder() { + String grailsVersion = Metadata.current.getGrailsVersion() + if (grailsVersion.startsWith('3.0')) { + return Ordered.HIGHEST_PRECEDENCE + 21 + } + else { + return 21 // FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER + 21 + } + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + FilterRegistrationBean casSSOFilter() { + def frb = new FilterRegistrationBean() + frb.name = 'Cas Single Sign Out Filter' + frb.filter = new SingleSignOutFilter() + frb.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST)) + frb.setOrder(filterOrder()) + frb.setUrlPatterns(['/*']) + frb.setAsyncSupported(true) + return frb + } + + private static void logFilter(String name, FilterRegistrationBean frb) { + if (frb.enabled) { + log.debug('{} enabled with type: {}', name, frb.filter) + log.debug('{} enabled with params: {}', name, frb.initParameters) + log.debug('{} enabled for paths: {}', name, frb.urlPatterns) + } else { + log.debug('{} disabled', name) + } + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + FilterRegistrationBean casAuthFilter() { + final name = 'CAS Authentication Filter' + def frb = new FilterRegistrationBean() + frb.name = name + frb.filter = new CooperatingFilterWrapper(new AuthenticationFilter(), AUTH_FILTER_KEY) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = filterOrder() + 1 + frb.urlPatterns = coreAuthProperties.uriFilterPattern ?: casClientProperties.uriFilterPattern + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + frb.initParameters = [(ConfigurationKeys.GATEWAY.name) : 'false'] + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + FilterRegistrationBean casAuthGatewayFilter(UserAgentFilterService userAgentFilterService) { + final name = 'CAS Gateway Authentication Filter' + def frb = new FilterRegistrationBean() + frb.name = name + frb.filter = new CooperatingFilterWrapper(new UserAgentBypassFilterWrapper(new AuthenticationFilter(), userAgentFilterService), AUTH_FILTER_KEY) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = filterOrder() + 2 + frb.urlPatterns = casClientProperties.gatewayFilterPattern + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + frb.initParameters = [(ConfigurationKeys.GATEWAY.name) : 'true'] + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + FilterRegistrationBean casAuthCookieFilter() { + final name = 'CAS Cookie Authentication Filter' + def frb = new FilterRegistrationBean() + frb.name = name + frb.filter = new CooperatingFilterWrapper(new CookieFilterWrapper(new AuthenticationFilter(), coreAuthProperties.authCookieName ?: casClientProperties.authCookieName), AUTH_FILTER_KEY) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = filterOrder() + 3 + frb.urlPatterns = coreAuthProperties.optionalFilterPattern + + casClientProperties.authenticateOnlyIfCookieFilterPattern + + casClientProperties.authenticateOnlyIfLoggedInPattern + + casClientProperties.authenticateOnlyIfLoggedInFilterPattern + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + frb.initParameters = [(ConfigurationKeys.GATEWAY.name) : 'false'] + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + FilterRegistrationBean casAuthCookieGatewayFilter(UserAgentFilterService userAgentFilterService) { + final name = 'CAS Gateway Cookie Authentication Filter' + def frb = new FilterRegistrationBean() + frb.name = name + frb.filter = new CooperatingFilterWrapper(new CookieFilterWrapper(new UserAgentBypassFilterWrapper(new AuthenticationFilter(), userAgentFilterService), coreAuthProperties.authCookieName ?: casClientProperties.authCookieName), AUTH_FILTER_KEY) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = filterOrder() + 4 + frb.urlPatterns = casClientProperties.gatewayIfCookieFilterPattern + frb.enabled = !frb.urlPatterns.empty + frb.asyncSupported = true + frb.initParameters = [(ConfigurationKeys.GATEWAY.name) : 'true'] + logFilter(name, frb) + return frb + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + FilterRegistrationBean casValidationFilter() { + def frb = new FilterRegistrationBean() + frb.name = 'CAS Validation Filter' + frb.filter = new Cas30ProxyReceivingTicketValidationFilter() + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) + frb.order = filterOrder() + 5 + frb.urlPatterns = ['/*'] + frb.asyncSupported = true + frb.initParameters = [:] + log.debug('CAS Validation Filter enabled') + return frb + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + FilterRegistrationBean casHttpServletRequestWrapperFilter() { + FilterRegistrationBean frb = new FilterRegistrationBean() + frb.name = 'CAS HttpServletRequest Wrapper Filter' + frb.filter = wrapFilterForActuator(new HttpServletRequestWrapperFilter()) + frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR) + frb.order = filterOrder() + 6 + frb.urlPatterns = ['/*'] + frb.asyncSupported = true + frb.initParameters = [:] + log.debug('CAS HttpServletRequest Wrapper Filter enabled') + return frb + } + + @ConditionalOnProperty(prefix= 'security.cas', name='enabled', matchIfMissing = true) + @Bean + SSOStrategy ssoStrategy(UserAgentFilterService userAgentFilterService) { + new CasSSOStrategy( + casClientProperties.service, + casClientProperties.appServerName, + casClientProperties.loginUrl, + coreAuthProperties.authCookieName ?: casClientProperties.authCookieName, + casClientProperties.encodeServiceUrl, + casClientProperties.enabled, + casClientProperties.renew, + ignoreUrlPatternMatcherStrategy(), + userAgentFilterService, + gatewayResolver() + ) + } + + // nb this would be nicer if we could use the Spring Boot Configuration Property classes but these seem to cause + // problems for grails. + private Filter wrapFilterForActuator(Filter delegate) { + final filter + final config = grailsApplication.config + final managementSecurityEnabled = config.getProperty('management.security.enabled', Boolean, false) + final springSecurityBasicEnabled = config.getProperty('security.basic.enabled', Boolean, false) + if (managementSecurityEnabled && springSecurityBasicEnabled) { + AntPathMatcher matcher = new AntPathMatcher() + final path = config.getProperty('management.contextPath') ?: config.getProperty('management.context-path', '') + if (path) { + final basicPaths = config.getProperty('security.basic.path', String[]) + final matches = basicPaths?.any { String pattern -> matcher.match(pattern, path) } + if (matches) { + log.info('Wrapping {} because {} is in {}', delegate, path, basicPaths) + filter = new UriExclusionFilter(delegate, path) + } else { + log.info('Not wrapping {} because the management path {} isn\'t covered by the basic auth paths {}', delegate, path, basicPaths) + filter = delegate + } + } else { + log.info('Not wrapping {} because the management path is not set', delegate) + filter = delegate + } + } else { + log.info('Not wrapping {} because either management security ({}) or spring security basic auth ({}) is not enabled', delegate, managementSecurityEnabled, springSecurityBasicEnabled) + filter = delegate + } + return filter + } + +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/config/MongoSpringSessionPluginConfig.groovy b/ala-auth/src/main/groovy/au/org/ala/web/config/MongoSpringSessionPluginConfig.groovy new file mode 100644 index 00000000..6585dd21 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/config/MongoSpringSessionPluginConfig.groovy @@ -0,0 +1,41 @@ +package au.org.ala.web.config + +import au.org.ala.web.mongo.Pac4jJdkMongoSessionConverter +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.AutoConfigureAfter +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.session.SessionProperties +import org.springframework.boot.autoconfigure.web.ServerProperties +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.session.data.mongo.AbstractMongoSessionConverter +import org.springframework.session.data.mongo.JacksonMongoSessionConverter +import org.springframework.session.data.mongo.JdkMongoSessionConverter +import org.springframework.session.data.mongo.MongoSession + +@Configuration +@ConditionalOnClass(MongoSession) +@ConditionalOnProperty(prefix = 'spring.session', name = 'store-type', havingValue = "mongodb") +@AutoConfigureAfter(SpringSessionPluginConfig.class) +@EnableConfigurationProperties([SessionProperties, ServerProperties]) +class MongoSpringSessionPluginConfig { + + @Autowired + SessionProperties sessionProperties + + @Autowired + ServerProperties serverProperties + + @Bean + @ConditionalOnMissingBean([ + AbstractMongoSessionConverter, + JdkMongoSessionConverter, + JacksonMongoSessionConverter + ]) + JdkMongoSessionConverter sessionConverter() { + new Pac4jJdkMongoSessionConverter(sessionProperties.timeout ?: serverProperties.servlet.session.timeout) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/config/SpringSessionPluginConfig.groovy b/ala-auth/src/main/groovy/au/org/ala/web/config/SpringSessionPluginConfig.groovy new file mode 100644 index 00000000..c68db6e5 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/config/SpringSessionPluginConfig.groovy @@ -0,0 +1,25 @@ +package au.org.ala.web.config; + +import au.org.ala.web.SpringSessionLogoutHandler +import org.pac4j.core.logout.handler.LogoutHandler +import org.springframework.boot.autoconfigure.AutoConfigureAfter +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.autoconfigure.session.SessionAutoConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.session.FindByIndexNameSessionRepository +import org.springframework.session.Session + +@Configuration +@ConditionalOnClass(FindByIndexNameSessionRepository.class) +@ConditionalOnProperty(prefix = 'spring.session', name = 'enabled', havingValue = "true") +@AutoConfigureAfter(SessionAutoConfiguration.class) +class SpringSessionPluginConfig { + @Bean + @ConditionalOnBean(FindByIndexNameSessionRepository.class) + LogoutHandler oidcLogoutHandler(FindByIndexNameSessionRepository repository) { + new SpringSessionLogoutHandler(repository) + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/mongo/Pac4jJdkMongoSessionConverter.java b/ala-auth/src/main/groovy/au/org/ala/web/mongo/Pac4jJdkMongoSessionConverter.java new file mode 100644 index 00000000..498885ec --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/mongo/Pac4jJdkMongoSessionConverter.java @@ -0,0 +1,96 @@ +package au.org.ala.web.mongo; + +import com.mongodb.DBObject; +import org.pac4j.core.profile.AnonymousProfile; +import org.pac4j.core.profile.UserProfile; +import org.pac4j.core.util.Pac4jConstants; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.lang.Nullable; +import org.springframework.session.Session; +import org.springframework.session.data.mongo.JdkMongoSessionConverter; +import org.springframework.session.data.mongo.MongoSession; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import static au.org.ala.web.SpringSessionLogoutHandler.SID_INDEX_NAME; +import static au.org.ala.web.SpringSessionLogoutHandler.SID_FIELD_NAME; + +/** + * Copy of the Spring Session JDK Mongo Session Converter with an additional field for specifying an external + * Session ID. + * + * It additionally extracts the principal name from the Pac4j profile name if available + */ +public class Pac4jJdkMongoSessionConverter extends JdkMongoSessionConverter { + + public Pac4jJdkMongoSessionConverter(Duration maxInactiveInterval) { + super(maxInactiveInterval); + } + + public Pac4jJdkMongoSessionConverter(Converter serializer, Converter deserializer, Duration maxInactiveInterval) { + super(serializer, deserializer, maxInactiveInterval); + } + + protected void ensureIndexes(IndexOperations sessionCollectionIndexes) { + super.ensureIndexes(sessionCollectionIndexes); + // TODO SID mongo index? + } + + @Override + @Nullable + public Query getQueryForIndex(String indexName, Object indexValue) { + + if (SID_INDEX_NAME.equals(indexName)) { + return Query.query(Criteria.where(SID_FIELD_NAME).is(indexValue)); + } else { + return super.getQueryForIndex(indexName, indexValue); + } + } + + @Override + protected DBObject convert(MongoSession session) { + + DBObject basicDBObject = super.convert(session); + + basicDBObject.put(SID_FIELD_NAME, extractSessionId(session)); + + return basicDBObject; + } + + protected String extractSessionId(MongoSession session) { + return getProfile(session).map(profile -> (String) profile.getAttribute(Pac4jConstants.OIDC_CLAIM_SESSIONID)).orElse(null); + } + + @Override + protected String extractPrincipal(MongoSession session) { + return getProfile(session).map(profile -> profile.getUsername()).orElseGet(() -> super.extractPrincipal(session)); + } + + private Optional getProfile(Session session) { + // Could use profile manager here but that requires the request and response... + var result = Optional.empty(); + if (session.getAttributeNames().contains(Pac4jConstants.USER_PROFILES)) { + var profiles = session.getAttribute(Pac4jConstants.USER_PROFILES); + if (profiles instanceof Map) { + var profile = ((Map) profiles).values() + .stream() + .filter(p -> p != null && !(p instanceof AnonymousProfile)) + .filter( p -> !p.isExpired() ) + .findFirst(); + if (profile.isPresent()) { + result = profile; + } else { + result = ((Map) profiles).values().stream().filter(Objects::nonNull).findFirst(); + } + + } + } + return result; + } +} diff --git a/ala-auth/src/main/groovy/au/org/ala/web/pac4j/ConvertingFromAttributesAuthorizationGenerator.java b/ala-auth/src/main/groovy/au/org/ala/web/pac4j/ConvertingFromAttributesAuthorizationGenerator.java new file mode 100644 index 00000000..2f5f2f84 --- /dev/null +++ b/ala-auth/src/main/groovy/au/org/ala/web/pac4j/ConvertingFromAttributesAuthorizationGenerator.java @@ -0,0 +1,125 @@ +package au.org.ala.web.pac4j; + +import org.apache.commons.lang3.StringUtils; +import org.pac4j.core.authorization.generator.AuthorizationGenerator; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.profile.UserProfile; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Optional; +import java.util.StringTokenizer; + +/** + * Copy of the pac4j FromAttributesAuthorizationGenerator that applies the transform in convertProvidedRoleName to role + * names before adding them to the user profile. + */ +public class ConvertingFromAttributesAuthorizationGenerator implements AuthorizationGenerator { + + + private Collection roleAttributes; + + private Collection permissionAttributes; + + private String splitChar = ","; + + private String rolePrefix; + private boolean convertRolesToUpperCase; + + public ConvertingFromAttributesAuthorizationGenerator() { + this.roleAttributes = new ArrayList<>(); + this.permissionAttributes = new ArrayList<>(); + this.rolePrefix = ""; + this.convertRolesToUpperCase = true; + } + + public ConvertingFromAttributesAuthorizationGenerator(final Collection roleAttributes, final Collection permissionAttributes, String rolePrefix, boolean convertRolesToUpperCase) { + this.roleAttributes = roleAttributes; + this.permissionAttributes = permissionAttributes; + this.rolePrefix = rolePrefix; + this.convertRolesToUpperCase = convertRolesToUpperCase; + } + + public ConvertingFromAttributesAuthorizationGenerator(final String[] roleAttributes, final String[] permissionAttributes, String rolePrefix, boolean convertRolesToUpperCase) { + this.rolePrefix = rolePrefix; + this.convertRolesToUpperCase = convertRolesToUpperCase; + if (roleAttributes != null) { + this.roleAttributes = Arrays.asList(roleAttributes); + } else { + this.roleAttributes = null; + } + if (permissionAttributes != null) { + this.permissionAttributes = Arrays.asList(permissionAttributes); + } else { + this.permissionAttributes = null; + } + } + + @Override + public Optional generate(final WebContext context, final SessionStore sessionStore, final UserProfile profile) { + generateAuth(profile, this.roleAttributes, true); + generateAuth(profile, this.permissionAttributes, false); + return Optional.of(profile); + } + + private void generateAuth(final UserProfile profile, final Iterable attributes, final boolean isRole) { + if (attributes == null) { + return; + } + + for (final var attribute : attributes) { + final var value = profile.getAttribute(attribute); + if (value != null) { + if (value instanceof String) { + final var st = new StringTokenizer((String) value, this.splitChar); + while (st.hasMoreTokens()) { + addRoleOrPermissionToProfile(profile, st.nextToken(), isRole); + } + } else if (value.getClass().isArray() && value.getClass().getComponentType().isAssignableFrom(String.class)) { + for (var item : (Object[]) value) { + addRoleOrPermissionToProfile(profile, item.toString(), isRole); + } + } else if (Collection.class.isAssignableFrom(value.getClass())) { + for (Object item : (Collection) value) { + if (item.getClass().isAssignableFrom(String.class)) { + addRoleOrPermissionToProfile(profile, item.toString(), isRole); + } + } + } + } + } + + } + + private void addRoleOrPermissionToProfile(final UserProfile profile, final String value, final boolean isRole) { + if (isRole) { + profile.addRole(convertProvidedRoleName(value)); + } else { + profile.addPermission(value); + } + } + + private String convertProvidedRoleName(String role) { + String result = !StringUtils.isBlank(rolePrefix) ? (rolePrefix + role) : role; + return convertRolesToUpperCase ? result.toUpperCase() : result; + } + + public String getSplitChar() { + return this.splitChar; + } + + public void setSplitChar(final String splitChar) { + this.splitChar = splitChar; + } + + public void setRoleAttributes(final String roleAttributesStr) { + this.roleAttributes = Arrays.asList(roleAttributesStr.split(splitChar)); + } + + public void setPermissionAttributes(final String permissionAttributesStr) { + this.permissionAttributes = Arrays.asList(permissionAttributesStr.split(splitChar)); + } + +} diff --git a/ala-auth/src/main/java/au/org/ala/pac4j/core/logout/CognitoLogoutActionBuilder.java b/ala-auth/src/main/java/au/org/ala/pac4j/core/logout/CognitoLogoutActionBuilder.java new file mode 100644 index 00000000..709ca058 --- /dev/null +++ b/ala-auth/src/main/java/au/org/ala/pac4j/core/logout/CognitoLogoutActionBuilder.java @@ -0,0 +1,66 @@ +package au.org.ala.pac4j.core.logout; + +import org.pac4j.core.context.HttpConstants; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.exception.TechnicalException; +import org.pac4j.core.exception.http.ForbiddenAction; +import org.pac4j.core.exception.http.RedirectionAction; +import org.pac4j.core.http.ajax.AjaxRequestResolver; +import org.pac4j.core.http.ajax.DefaultAjaxRequestResolver; +import org.pac4j.core.logout.LogoutActionBuilder; +import org.pac4j.core.profile.UserProfile; +import org.pac4j.core.util.CommonHelper; +import org.pac4j.core.util.HttpActionHelper; +import org.pac4j.core.util.Pac4jConstants; +import org.pac4j.oidc.config.OidcConfiguration; +import org.pac4j.oidc.profile.OidcProfile; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Optional; + +public class CognitoLogoutActionBuilder implements LogoutActionBuilder { + + protected OidcConfiguration configuration; + + private AjaxRequestResolver ajaxRequestResolver = new DefaultAjaxRequestResolver(); + + public CognitoLogoutActionBuilder(final OidcConfiguration configuration) { + CommonHelper.assertNotNull("configuration", configuration); + this.configuration = configuration; + } + + @Override + public Optional getLogoutAction(WebContext context, SessionStore sessionStore, UserProfile currentProfile, String targetUrl) { + final var logoutUrl = configuration.findLogoutUrl(); + if (CommonHelper.isNotBlank(logoutUrl) && currentProfile instanceof OidcProfile) { + try { + final var completeLogoutUrl = UriComponentsBuilder.fromUriString(logoutUrl) + .queryParam("client_id", configuration.getClientId()) + .queryParam("logout_uri", targetUrl) + .toUriString(); + + if (ajaxRequestResolver.isAjax(context, sessionStore)) { + sessionStore.set(context, Pac4jConstants.REQUESTED_URL, null); + context.setResponseHeader(HttpConstants.LOCATION_HEADER, completeLogoutUrl); + throw new ForbiddenAction(); + } + + return Optional.of(HttpActionHelper.buildRedirectUrlAction(context, completeLogoutUrl)); + } catch (final RuntimeException e) { + throw new TechnicalException(e); + } + } + + return Optional.empty(); + } + + public AjaxRequestResolver getAjaxRequestResolver() { + return ajaxRequestResolver; + } + + public void setAjaxRequestResolver(final AjaxRequestResolver ajaxRequestResolver) { + CommonHelper.assertNotNull("ajaxRequestResolver", ajaxRequestResolver); + this.ajaxRequestResolver = ajaxRequestResolver; + } +} diff --git a/ala-auth/src/main/java/au/org/ala/web/AlaSecured.java b/ala-auth/src/main/java/au/org/ala/web/AlaSecured.java new file mode 100644 index 00000000..9b11e1f3 --- /dev/null +++ b/ala-auth/src/main/java/au/org/ala/web/AlaSecured.java @@ -0,0 +1,81 @@ +package au.org.ala.web; + +import java.lang.annotation.*; + +/** + * Cut down version of the Spring Security @Secured annotation that will allow role based authorisation + * on Grails controllers and controller actions *only*. + * + * @author Simon Bear (simon.bear@csiro.au) + */ +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +public @interface AlaSecured { + /** + * A list of roles that the user must have to have access to the method, if omitted then the user must be + * logged in. + * + * @return the list of roles + */ + String[] value() default {}; + + /** + * Change the behaviour such that the user may be anonymous + * @return whether the user may be anonymous + */ + boolean anonymous() default false; + + /** + * Change the behaviour such that the user must have only one role from the roles list to have access to the method + * @return whether any role from the list is acceptable + */ + boolean anyRole() default false; + + /** + * Change the behaviour such that the user must *not* have any roles from the roles list to have access to the method + * @return whether having any role from the list is unacceptable + */ + boolean notRoles() default false; + + /** + * Name of the controller to redirect to, defaults to current controller + * @return The Grails controller to redirect to if authorization fails + */ + String controller() default ""; + String redirectController() default ""; + + /** + * Name of the action to redirect to, defaults to index + * @return The action to redirect to if authorization fails + */ + String action() default "";//"index"; + String redirectAction() default "";//"index"; + + String view() default ""; + + /** + * The context relative uri to redirect to, this takes precedent over the controller if specified. + * @return the URI to redirect to if authorization fails + */ + String redirectUri() default ""; + + /** + * Status code to return instead of redirecting, takes precendence over Uri if specified + * @return The status code to return + */ + int statusCode() default 0; + + /** + * The message to put in flashScope.errorMessage, set to null to disable. + * @return The flash scope message to use if authorization fails + */ + String message() default "Permission denied"; + + /** + * Use a servlet forward instead of a redirect + * @return true to use forward instead of redirect + */ + boolean forward() default false; +} diff --git a/ala-auth/src/main/java/au/org/ala/web/CasClientProperties.java b/ala-auth/src/main/java/au/org/ala/web/CasClientProperties.java new file mode 100644 index 00000000..c2f34cd8 --- /dev/null +++ b/ala-auth/src/main/java/au/org/ala/web/CasClientProperties.java @@ -0,0 +1,219 @@ +package au.org.ala.web; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(value = "security.cas") +public class CasClientProperties { + + private String appServerName; + private String service; + private String casServerName; + private String casServerUrlPrefix; + private String loginUrl; + private String logoutUrl; + private boolean enabled = true; + private boolean gateway = false; + private boolean renew = false; + private List uriFilterPattern = new ArrayList<>(); + private List uriExclusionFilterPattern = new ArrayList<>(); + private List authenticateOnlyIfLoggedInPattern = new ArrayList<>(); + private List authenticateOnlyIfLoggedInFilterPattern = new ArrayList<>(); + private List authenticateOnlyIfCookieFilterPattern = new ArrayList<>(); + private List gatewayIfCookieFilterPattern = new ArrayList<>(); + private List gatewayFilterPattern = new ArrayList<>(); + private String gatewayStorageClass; + private String roleAttribute = "role"; + private boolean ignoreCase = true; + private boolean encodeServiceUrl = true; + private boolean bypass = false; + private String contextPath = null; + @Deprecated + private String authCookieName = "ALA-Auth"; + + public String getAppServerName() { + return appServerName; + } + + public void setAppServerName(String appServerName) { + this.appServerName = appServerName; + } + + public String getService() { + return service; + } + + public void setService(String service) { + this.service = service; + } + + public String getCasServerName() { + return casServerName; + } + + public void setCasServerName(String casServerName) { + this.casServerName = casServerName; + } + + public String getCasServerUrlPrefix() { + return casServerUrlPrefix; + } + + public void setCasServerUrlPrefix(String casServerUrlPrefix) { + this.casServerUrlPrefix = casServerUrlPrefix; + } + + public String getLoginUrl() { + return loginUrl; + } + + public void setLoginUrl(String loginUrl) { + this.loginUrl = loginUrl; + } + + public String getLogoutUrl() { + return logoutUrl; + } + + public void setLogoutUrl(String logoutUrl) { + this.logoutUrl = logoutUrl; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isGateway() { + return gateway; + } + + public void setGateway(boolean gateway) { + this.gateway = gateway; + } + + public boolean isRenew() { + return renew; + } + + public void setRenew(boolean renew) { + this.renew = renew; + } + + public List getUriFilterPattern() { + return uriFilterPattern; + } + + public void setUriFilterPattern(List uriFilterPattern) { + this.uriFilterPattern = uriFilterPattern; + } + + public List getUriExclusionFilterPattern() { + return uriExclusionFilterPattern; + } + + public void setUriExclusionFilterPattern(List uriExclusionFilterPattern) { + this.uriExclusionFilterPattern = uriExclusionFilterPattern; + } + + public List getAuthenticateOnlyIfLoggedInPattern() { + return authenticateOnlyIfLoggedInPattern; + } + + public void setAuthenticateOnlyIfLoggedInPattern(List authenticateOnlyIfLoggedInPattern) { + this.authenticateOnlyIfLoggedInPattern = authenticateOnlyIfLoggedInPattern; + } + + public List getAuthenticateOnlyIfLoggedInFilterPattern() { + return authenticateOnlyIfLoggedInFilterPattern; + } + + public void setAuthenticateOnlyIfLoggedInFilterPattern(List authenticateOnlyIfLoggedInFilterPattern) { + this.authenticateOnlyIfLoggedInFilterPattern = authenticateOnlyIfLoggedInFilterPattern; + } + + public String getGatewayStorageClass() { + return gatewayStorageClass; + } + + public void setGatewayStorageClass(String gatewayStorageClass) { + this.gatewayStorageClass = gatewayStorageClass; + } + + public String getRoleAttribute() { + return roleAttribute; + } + + public void setRoleAttribute(String roleAttribute) { + this.roleAttribute = roleAttribute; + } + + public boolean isIgnoreCase() { + return ignoreCase; + } + + public void setIgnoreCase(boolean ignoreCase) { + this.ignoreCase = ignoreCase; + } + + public boolean isEncodeServiceUrl() { + return encodeServiceUrl; + } + + public void setEncodeServiceUrl(boolean encodeServiceUrl) { + this.encodeServiceUrl = encodeServiceUrl; + } + + public String getContextPath() { + return contextPath; + } + + public void setContextPath(String contextPath) { + this.contextPath = contextPath; + } + + public List getAuthenticateOnlyIfCookieFilterPattern() { + return authenticateOnlyIfCookieFilterPattern; + } + + public void setAuthenticateOnlyIfCookieFilterPattern(List authenticateOnlyIfCookieFilterPattern) { + this.authenticateOnlyIfCookieFilterPattern = authenticateOnlyIfCookieFilterPattern; + } + + public List getGatewayIfCookieFilterPattern() { + return gatewayIfCookieFilterPattern; + } + + public void setGatewayIfCookieFilterPattern(List gatewayIfCookieFilterPattern) { + this.gatewayIfCookieFilterPattern = gatewayIfCookieFilterPattern; + } + + public String getAuthCookieName() { + return authCookieName; + } + + public void setAuthCookieName(String authCookieName) { + this.authCookieName = authCookieName; + } + + public List getGatewayFilterPattern() { + return gatewayFilterPattern; + } + + public void setGatewayFilterPattern(List gatewayFilterPattern) { + this.gatewayFilterPattern = gatewayFilterPattern; + } + + public boolean isBypass() { + return bypass; + } + + public void setBypass(boolean bypass) { + this.bypass = bypass; + } +} diff --git a/ala-auth/src/main/java/au/org/ala/web/CoreAuthProperties.java b/ala-auth/src/main/java/au/org/ala/web/CoreAuthProperties.java new file mode 100644 index 00000000..0f71b097 --- /dev/null +++ b/ala-auth/src/main/java/au/org/ala/web/CoreAuthProperties.java @@ -0,0 +1,110 @@ +package au.org.ala.web; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(value = "security.core") +public class CoreAuthProperties { + private String authCookieName; + private String roleAttribute; + private List permissionAttributes = new ArrayList<>(); + + private List uriFilterPattern = new ArrayList<>(); + private List optionalFilterPattern = new ArrayList<>(); + private List uriExclusionFilterPattern = new ArrayList<>(); + private String defaultLogoutRedirectUri = "/"; + private String logoutUrlPattern = null; // Pac4j will default to the default value if this is null + private boolean centralLogout = true; + private boolean destroySession = true; + private boolean localLogout = true; + + public List getUriFilterPattern() { + return uriFilterPattern; + } + + public void setUriFilterPattern(List uriFilterPattern) { + this.uriFilterPattern = uriFilterPattern; + } + + public List getOptionalFilterPattern() { + return optionalFilterPattern; + } + + public void setOptionalFilterPattern(List optionalFilterPattern) { + this.optionalFilterPattern = optionalFilterPattern; + } + + public String getAuthCookieName() { + return authCookieName; + } + + public void setAuthCookieName(String authCookieName) { + this.authCookieName = authCookieName; + } + + public List getUriExclusionFilterPattern() { + return uriExclusionFilterPattern; + } + + public void setUriExclusionFilterPattern(List uriExclusionFilterPattern) { + this.uriExclusionFilterPattern = uriExclusionFilterPattern; + } + + public String getRoleAttribute() { + return roleAttribute; + } + + public void setRoleAttribute(String roleAttribute) { + this.roleAttribute = roleAttribute; + } + + public List getPermissionAttributes() { + return permissionAttributes; + } + + public void setPermissionAttributes(List permissionAttributes) { + this.permissionAttributes = permissionAttributes; + } + + public String getDefaultLogoutRedirectUri() { + return defaultLogoutRedirectUri; + } + + public void setDefaultLogoutRedirectUri(String defaultLogoutRedirectUri) { + this.defaultLogoutRedirectUri = defaultLogoutRedirectUri; + } + + public boolean isCentralLogout() { + return centralLogout; + } + + public void setCentralLogout(boolean centralLogout) { + this.centralLogout = centralLogout; + } + + public boolean isDestroySession() { + return destroySession; + } + + public void setDestroySession(boolean destroySession) { + this.destroySession = destroySession; + } + + public boolean isLocalLogout() { + return localLogout; + } + + public void setLocalLogout(boolean localLogout) { + this.localLogout = localLogout; + } + + public String getLogoutUrlPattern() { + return logoutUrlPattern; + } + + public void setLogoutUrlPattern(String logoutUrlPattern) { + this.logoutUrlPattern = logoutUrlPattern; + } +} diff --git a/ala-auth/src/main/java/au/org/ala/web/LogoutActionType.java b/ala-auth/src/main/java/au/org/ala/web/LogoutActionType.java new file mode 100644 index 00000000..a7014f70 --- /dev/null +++ b/ala-auth/src/main/java/au/org/ala/web/LogoutActionType.java @@ -0,0 +1,22 @@ +package au.org.ala.web; + +import au.org.ala.pac4j.core.logout.CognitoLogoutActionBuilder; +import org.pac4j.core.logout.LogoutActionBuilder; +import org.pac4j.oidc.config.OidcConfiguration; + +public enum LogoutActionType { + + DEFAULT { + public LogoutActionBuilder getLogoutActionBuilder(OidcConfiguration oidcConfiguration) { + return null; + } + }, + COGNITO { + @Override + public LogoutActionBuilder getLogoutActionBuilder(OidcConfiguration oidcConfiguration) { + return new CognitoLogoutActionBuilder(oidcConfiguration); + } + }; + + abstract public LogoutActionBuilder getLogoutActionBuilder(OidcConfiguration oidcConfiguration); +} diff --git a/ala-auth/src/main/java/au/org/ala/web/NoSSO.java b/ala-auth/src/main/java/au/org/ala/web/NoSSO.java new file mode 100644 index 00000000..1e566640 --- /dev/null +++ b/ala-auth/src/main/java/au/org/ala/web/NoSSO.java @@ -0,0 +1,15 @@ +package au.org.ala.web; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +public @interface NoSSO { +} diff --git a/ala-auth/src/main/java/au/org/ala/web/OidcClientProperties.java b/ala-auth/src/main/java/au/org/ala/web/OidcClientProperties.java new file mode 100644 index 00000000..54b1e73b --- /dev/null +++ b/ala-auth/src/main/java/au/org/ala/web/OidcClientProperties.java @@ -0,0 +1,215 @@ +package au.org.ala.web; + +import org.pac4j.core.context.HttpConstants; +import org.pac4j.oidc.profile.OidcProfileDefinition; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.LinkedHashMap; +import java.util.Map; + +@ConfigurationProperties(value = "security.oidc") +public class OidcClientProperties { + + private boolean enabled = false; + private String discoveryUri = "https://auth.ala.org.au/cas/oidc/.well-known"; + private String clientId = "ChangeMe"; + private String secret = "ChangeMe"; + private String scope = "openid profile email roles"; + private boolean withState = true; + private Map customParams = new LinkedHashMap<>(); + private String clientAuthenticationMethod = null; + private boolean allowUnsignedIdTokens = false; + private boolean useAnonymousClient = true; + private int connectTimeout = HttpConstants.DEFAULT_CONNECT_TIMEOUT; + private int readTimeout = HttpConstants.DEFAULT_READ_TIMEOUT; + + /** + * Only set this if the OIDC provider doesn't set the end_session_url in the discovery document + */ + private String logoutUrl = null; + + /** + * Only set this if the standard OIDC logout is not supported. + */ + private LogoutActionType logoutAction = LogoutActionType.DEFAULT; + + /** + * A prefix to add to all incoming role names, e.g. cognito which might provide role names like "user" but + * the application code requires the role name to be "role_user" + */ + private String rolePrefix = ""; + + /** + * Whether to convert all incoming role names to upper case, e.g. cognito which might provide role names like + * "user" but the application code requires the role name to be "USER" + */ + private boolean convertRolesToUpperCase = true; + + /** + * Set this to add a preferred claim name to retrieve the ala user id from + */ + private String alaUseridClaim = null; + + /** + * Set this to add a preferred claim name to retrieve the user name from + */ + private String userNameClaim = null; + + /** + * Set this to the claim name that contains the full display name for the user, + * set to null to calculate from first and last names. + */ + private String displayNameClaim = OidcProfileDefinition.NAME; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getDiscoveryUri() { + return discoveryUri; + } + + public void setDiscoveryUri(String discoveryUri) { + this.discoveryUri = discoveryUri; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public boolean isWithState() { + return withState; + } + + public void setWithState(boolean withState) { + this.withState = withState; + } + + public Map getCustomParams() { + return customParams; + } + + public void setCustomParams(Map customParams) { + this.customParams = customParams; + } + + public String getClientAuthenticationMethod() { + return clientAuthenticationMethod; + } + + public void setClientAuthenticationMethod(String clientAuthenticationMethod) { + this.clientAuthenticationMethod = clientAuthenticationMethod; + } + + public boolean isAllowUnsignedIdTokens() { + return allowUnsignedIdTokens; + } + + public void setAllowUnsignedIdTokens(boolean allowUnsignedIdTokens) { + this.allowUnsignedIdTokens = allowUnsignedIdTokens; + } + + public boolean isUseAnonymousClient() { + return useAnonymousClient; + } + + public void setUseAnonymousClient(boolean useAnonymousClient) { + this.useAnonymousClient = useAnonymousClient; + } + + public int getConnectTimeout() { + return connectTimeout; + } + + public void setConnectTimeout(int connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public int getReadTimeout() { + return readTimeout; + } + + public void setReadTimeout(int readTimeout) { + this.readTimeout = readTimeout; + } + + public String getLogoutUrl() { + return logoutUrl; + } + + public void setLogoutUrl(String logoutUrl) { + this.logoutUrl = logoutUrl; + } + + public LogoutActionType getLogoutAction() { + return logoutAction; + } + + public void setLogoutAction(LogoutActionType logoutAction) { + this.logoutAction = logoutAction; + } + + public String getRolePrefix() { + return rolePrefix; + } + + public void setRolePrefix(String rolePrefix) { + this.rolePrefix = rolePrefix; + } + + public boolean isConvertRolesToUpperCase() { + return convertRolesToUpperCase; + } + + public void setConvertRolesToUpperCase(boolean convertRolesToUpperCase) { + this.convertRolesToUpperCase = convertRolesToUpperCase; + } + + public String getAlaUseridClaim() { + return alaUseridClaim; + } + + public void setAlaUseridClaim(String alaUseridClaim) { + this.alaUseridClaim = alaUseridClaim; + } + + public String getUserNameClaim() { + return userNameClaim; + } + + public void setUserNameClaim(String userNameClaim) { + this.userNameClaim = userNameClaim; + } + + public String getDisplayNameClaim() { + return displayNameClaim; + } + + public void setDisplayNameClaim(String displayNameClaim) { + this.displayNameClaim = displayNameClaim; + } +} diff --git a/ala-auth/src/main/java/au/org/ala/web/SSO.java b/ala-auth/src/main/java/au/org/ala/web/SSO.java new file mode 100644 index 00000000..92d8a9a9 --- /dev/null +++ b/ala-auth/src/main/java/au/org/ala/web/SSO.java @@ -0,0 +1,26 @@ +package au.org.ala.web; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +public @interface SSO { + + /** + * Only authenticate the user if they're already signed in. Non-authenticated users will + * be returned to the app with no current principal. + */ + boolean gateway() default false; + + /** + * Only redirect for SSO if the user has a cookie set + */ + boolean cookie() default false; +} diff --git a/ala-auth/src/main/resources/META-INF/spring.factories b/ala-auth/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..6668c0f1 --- /dev/null +++ b/ala-auth/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ + org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + au.org.ala.web.config.SpringSessionPluginConfig, \ + au.org.ala.web.config.MongoSpringSessionPluginConfig diff --git a/ala-auth/src/main/resources/crawler-user-agents.json b/ala-auth/src/main/resources/crawler-user-agents.json new file mode 100644 index 00000000..34c70905 --- /dev/null +++ b/ala-auth/src/main/resources/crawler-user-agents.json @@ -0,0 +1,2830 @@ +[ + { + "pattern": "Googlebot\\/", + "url": "http://www.google.com/bot.html", + "instances": [ + "Googlebot/2.1 (+http://www.google.com/bot.html)", + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Version/8.0 Mobile/12F70 Safari/600.1.4 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12F70 Safari/600.1.4 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.96 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Safari/537.36", + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; Google Web Preview Analytics) Chrome/27.0.1453 Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" + ] + }, + { + "pattern": "Googlebot-Mobile", + "instances": [ + "DoCoMo/2.0 N905i(c100;TB;W24H16) (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25 (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_1 like Mac OS X; en-us) AppleWebKit/532.9 (KHTML, like Gecko) Version/4.0.5 Mobile/8B117 Safari/6531.22.7 (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)", + "Nokia6820/2.0 (4.83) Profile/MIDP-1.0 Configuration/CLDC-1.0 (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)", + "SAMSUNG-SGH-E250/1.0 Profile/MIDP-2.0 Configuration/CLDC-1.1 UP.Browser/6.2.3.3.c.1.101 (GUI) MMP/2.0 (compatible; Googlebot-Mobile/2.1; +http://www.google.com/bot.html)" + ] + }, + { + "pattern": "Googlebot-Image", + "instances": [ + "Googlebot-Image/1.0" + ] + }, + { + "pattern": "Googlebot-News", + "instances": [ + "Googlebot-News" + ] + }, + { + "pattern": "Googlebot-Video", + "instances": [ + "Googlebot-Video/1.0" + ] + }, + { + "pattern": "AdsBot-Google([^-]|$)", + "url": "https://support.google.com/webmasters/answer/1061943?hl=en", + "instances": [ + "AdsBot-Google (+http://www.google.com/adsbot.html)" + ] + }, + { + "pattern": "AdsBot-Google-Mobile", + "addition_date": "2017/08/21", + "url": "https://support.google.com/adwords/answer/2404197", + "instances": [ + "AdsBot-Google-Mobile-Apps", + "Mozilla/5.0 (Linux; Android 5.0; SM-G920A) AppleWebKit (KHTML, like Gecko) Chrome Mobile Safari (compatible; AdsBot-Google-Mobile; +http://www.google.com/mobile/adsbot.html)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1 (compatible; AdsBot-Google-Mobile; +http://www.google.com/mobile/adsbot.html)" + ] + }, + { + "pattern": "Feedfetcher-Google", + "addition_date": "2018/06/27", + "url": "https://support.google.com/webmasters/answer/178852", + "instances": [ + "Feedfetcher-Google; (+http://www.google.com/feedfetcher.html; 1 subscribers; feed-id=728742641706423)" + ] + }, + { + "pattern": "Mediapartners-Google", + "url": "https://support.google.com/webmasters/answer/1061943?hl=en", + "instances": [ + "Mediapartners-Google", + "Mozilla/5.0 (compatible; MSIE or Firefox mutant; not on Windows server;) Daumoa/4.0 (Following Mediapartners-Google)", + "Mozilla/5.0 (iPhone; U; CPU iPhone OS 10_0 like Mac OS X; en-us) AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A5297c Safari/602.1 (compatible; Mediapartners-Google/2.1; +http://www.google.com/bot.html)", + "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_1 like Mac OS X; en-us) AppleWebKit/532.9 (KHTML, like Gecko) Version/4.0.5 Mobile/8B117 Safari/6531.22.7 (compatible; Mediapartners-Google/2.1; +http://www.google.com/bot.html)" + ] + }, + { + "pattern": "Mediapartners \\(Googlebot\\)", + "addition_date": "2017/08/08", + "url": "https://support.google.com/webmasters/answer/1061943?hl=en", + "instances": [] + }, + { + "pattern": "APIs-Google", + "addition_date": "2017/08/08", + "url": "https://support.google.com/webmasters/answer/1061943?hl=en", + "instances": [ + "APIs-Google (+https://developers.google.com/webmasters/APIs-Google.html)" + ] + }, + { + "pattern": "bingbot", + "url": "http://www.bing.com/bingbot.htm", + "instances": [ + "Mozilla/5.0 (Windows Phone 8.1; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; Lumia 530) like Gecko (compatible; adidxbot/2.0; +http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (compatible; adidxbot/2.0; http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (compatible; adidxbot/2.0; +http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (compatible; bingbot/2.0; http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm", + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm) SitemapProbe", + "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53 (compatible; adidxbot/2.0; http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53 (compatible; adidxbot/2.0; +http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53 (compatible; bingbot/2.0; http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + "Mozilla/5.0 (seoanalyzer; compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" + ] + }, + { + "pattern": "Slurp", + "url": "http://help.yahoo.com/help/us/ysearch/slurp", + "instances": [ + "Mozilla/5.0 (compatible; Yahoo! Slurp/3.0; http://help.yahoo.com/help/us/ysearch/slurp)", + "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)", + "Mozilla/5.0 (compatible; Yahoo! Slurp China; http://misc.yahoo.com.cn/help.html)" + ] + }, + { + "pattern": "[wW]get", + "instances": [ + "WGETbot/1.0 (+http://wget.alanreed.org)", + "Wget/1.14 (linux-gnu)" + ] + }, + { + "pattern": "curl", + "instances": [ + "eCairn-Grabber/1.0 (+http://ecairn.com/grabber) curl/7.15" + ] + }, + { + "pattern": "LinkedInBot", + "instances": [ + "LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/3.1 +http://www.linkedin.com)", + "LinkedInBot/1.0 (compatible; Mozilla/5.0; Jakarta Commons-HttpClient/4.3 +http://www.linkedin.com)" + ] + }, + { + "pattern": "Python-urllib", + "instances": [ + "Python-urllib/2.5", + "Python-urllib/2.5", + "Python-urllib/2.6", + "Python-urllib/2.7", + "Python-urllib/3.1", + "Python-urllib/3.2", + "Python-urllib/3.3", + "Python-urllib/3.4", + "Python-urllib/3.5", + "Python-urllib/3.6" + ] + }, + { + "pattern": "python-requests", + "addition_date": "2018/05/27", + "instances": [ + "python-requests/2.18.4" + ] + }, + { + "pattern": "libwww", + "instances": [ + "2Bone_LinkChecker/1.0 libwww-perl/6.03", + "2Bone_LinkChkr/1.0 libwww-perl/6.03", + "W3C-checklink/2.90 libwww-perl/5.64", + "W3C-checklink/3.6.2.3 libwww-perl/5.64", + "W3C-checklink/4.2 [4.20] libwww-perl/5.803", + "W3C-checklink/4.2.1 [4.21] libwww-perl/5.803", + "W3C-checklink/4.3 [4.42] libwww-perl/5.805", + "W3C-checklink/4.3 [4.42] libwww-perl/5.808", + "W3C-checklink/4.3 [4.42] libwww-perl/5.820", + "W3C-checklink/4.5 [4.154] libwww-perl/5.823", + "W3C-checklink/4.5 [4.160] libwww-perl/5.823", + "amibot - http://www.amidalla.de - tech@amidalla.com libwww-perl/5.831" + ] + }, + { + "pattern": "httpunit", + "instances": [ + "httpunit/1.x" + ] + }, + { + "pattern": "nutch", + "instances": [ + "NutchCVS/0.7.1 (Nutch; http://lucene.apache.org/nutch/bot.html; nutch-agent@lucene.apache.org)", + "istellabot-nutch/Nutch-1.10" + ] + }, + { + "pattern": "Go-http-client", + "addition_date": "2016/03/26", + "url": "https://golang.org/pkg/net/http/", + "instances": [ + "Go-http-client/1.1" + ] + }, + { + "pattern": "phpcrawl", + "addition_date": "2012-09/17", + "url": "http://phpcrawl.cuab.de/", + "instances": [ + "phpcrawl" + ] + }, + { + "pattern": "msnbot", + "url": "http://search.msn.com/msnbot.htm", + "instances": [ + "adidxbot/1.1 (+http://search.msn.com/msnbot.htm)", + "adidxbot/2.0 (+http://search.msn.com/msnbot.htm)", + "librabot/1.0 (+http://search.msn.com/msnbot.htm)", + "librabot/2.0 (+http://search.msn.com/msnbot.htm)", + "msnbot-NewsBlogs/2.0b (+http://search.msn.com/msnbot.htm)", + "msnbot-UDiscovery/2.0b (+http://search.msn.com/msnbot.htm)", + "msnbot-media/1.0 (+http://search.msn.com/msnbot.htm)", + "msnbot-media/1.1 (+http://search.msn.com/msnbot.htm)", + "msnbot-media/2.0b (+http://search.msn.com/msnbot.htm)", + "msnbot/1.0 (+http://search.msn.com/msnbot.htm)", + "msnbot/1.1 (+http://search.msn.com/msnbot.htm)", + "msnbot/2.0b (+http://search.msn.com/msnbot.htm)", + "msnbot/2.0b (+http://search.msn.com/msnbot.htm).", + "msnbot/2.0b (+http://search.msn.com/msnbot.htm)._" + ] + }, + { + "pattern": "jyxobot", + "instances": [] + }, + { + "pattern": "FAST-WebCrawler", + "instances": [ + "FAST-WebCrawler/3.6/FirstPage (atw-crawler at fast dot no;http://fast.no/support/crawler.asp)", + "FAST-WebCrawler/3.7 (atw-crawler at fast dot no; http://fast.no/support/crawler.asp)", + "FAST-WebCrawler/3.7/FirstPage (atw-crawler at fast dot no;http://fast.no/support/crawler.asp)", + "FAST-WebCrawler/3.8" + ] + }, + { + "pattern": "FAST Enterprise Crawler", + "instances": [ + "FAST Enterprise Crawler 6 / Scirus scirus-crawler@fast.no; http://www.scirus.com/srsapp/contactus/", + "FAST Enterprise Crawler 6 used by Schibsted (webcrawl@schibstedsok.no)" + ] + }, + { + "pattern": "BIGLOTRON", + "instances": [ + "BIGLOTRON (Beta 2;GNU/Linux)" + ] + }, + { + "pattern": "Teoma", + "instances": [ + "Mozilla/2.0 (compatible; Ask Jeeves/Teoma; +http://sp.ask.com/docs/about/tech_crawling.html)", + "Mozilla/2.0 (compatible; Ask Jeeves/Teoma; +http://about.ask.com/en/docs/about/webmasters.shtml)" + ], + "url": "http://about.ask.com/en/docs/about/webmasters.shtml" + }, + { + "pattern": "convera", + "instances": [ + "ConveraCrawler/0.9e (+http://ews.converasearch.com/crawl.htm)" + ], + "url": "http://ews.converasearch.com/crawl.htm" + }, + { + "pattern": "seekbot", + "instances": [ + "Seekbot/1.0 (http://www.seekbot.net/bot.html) RobotsTxtFetcher/1.2" + ], + "url": "http://www.seekbot.net/bot.html" + }, + { + "pattern": "Gigabot", + "instances": [ + "Gigabot/1.0", + "Gigabot/2.0 (http://www.gigablast.com/spider.html)" + ], + "url": "http://www.gigablast.com/spider.html" + }, + { + "pattern": "Gigablast", + "instances": [ + "GigablastOpenSource/1.0" + ], + "url": "https://github.com/gigablast/open-source-search-engine" + }, + { + "pattern": "exabot", + "instances": [ + "Mozilla/5.0 (compatible; Alexabot/1.0; +http://www.alexa.com/help/certifyscan; certifyscan@alexa.com)", + "Mozilla/5.0 (compatible; Exabot PyExalead/3.0; +http://www.exabot.com/go/robot)", + "Mozilla/5.0 (compatible; Exabot-Images/3.0; +http://www.exabot.com/go/robot)", + "Mozilla/5.0 (compatible; Exabot/3.0 (BiggerBetter); +http://www.exabot.com/go/robot)", + "Mozilla/5.0 (compatible; Exabot/3.0; +http://www.exabot.com/go/robot)" + ] + }, + { + "pattern": "ia_archiver", + "instances": [ + "ia_archiver (+http://www.alexa.com/site/help/webmasters; crawler@alexa.com)", + "ia_archiver-web.archive.org" + ] + }, + { + "pattern": "GingerCrawler", + "instances": [ + "GingerCrawler/1.0 (Language Assistant for Dyslexics; www.gingersoftware.com/crawler_agent.htm; support at ginger software dot com)" + ] + }, + { + "pattern": "webmon ", + "instances": [] + }, + { + "pattern": "HTTrack", + "instances": [ + "Mozilla/4.5 (compatible; HTTrack 3.0x; Windows 98)" + ] + }, + { + "pattern": "grub.org", + "instances": [ + "Mozilla/4.0 (compatible; grub-client-0.3.0; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.0.4; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.0.5; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.0.6; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.0.7; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.1.1; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.2.1; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.3.1; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.3.7; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.4.3; Crawl your own stuff with http://grub.org)", + "Mozilla/4.0 (compatible; grub-client-1.5.3; Crawl your own stuff with http://grub.org)" + ] + }, + { + "pattern": "UsineNouvelleCrawler", + "instances": [] + }, + { + "pattern": "antibot", + "instances": [] + }, + { + "pattern": "netresearchserver", + "instances": [] + }, + { + "pattern": "speedy", + "instances": [ + "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) Speedy Spider (http://www.entireweb.com/about/search_tech/speedy_spider/)", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US) Speedy Spider for SpeedyAds (http://www.entireweb.com/about/search_tech/speedy_spider/)", + "Mozilla/5.0 (compatible; Speedy Spider; http://www.entireweb.com/about/search_tech/speedy_spider/)", + "Speedy Spider (Entireweb; Beta/1.2; http://www.entireweb.com/about/search_tech/speedyspider/)", + "Speedy Spider (http://www.entireweb.com/about/search_tech/speedy_spider/)" + ] + }, + { + "pattern": "fluffy", + "instances": [] + }, + { + "pattern": "bibnum.bnf", + "instances": [ + "Mozilla/5.0 (compatible; bnf.fr_bot; +http://bibnum.bnf.fr/robot/bnf.html)" + ] + }, + { + "pattern": "findlink", + "instances": [ + "findlinks/1.0 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.3-beta8 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.3-beta9 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.5-beta7 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.6-beta1 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.6-beta1 (+http://wortschatz.uni-leipzig.de/findlinks/; YaCy 0.1; yacy.net)", + "findlinks/1.1.6-beta2 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.6-beta3 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.6-beta4 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.6-beta5 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/1.1.6-beta6 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.0 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.0.1 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.0.2 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.0.4 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.0.5 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.0.9 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.1 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.1.3 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.1.5 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.2 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.5 (+http://wortschatz.uni-leipzig.de/findlinks/)", + "findlinks/2.6 (+http://wortschatz.uni-leipzig.de/findlinks/)" + ] + }, + { + "pattern": "msrbot", + "instances": [] + }, + { + "pattern": "panscient", + "instances": [ + "panscient.com" + ] + }, + { + "pattern": "yacybot", + "instances": [ + "yacybot (-global; amd64 FreeBSD 9.2-RELEASE-p10; java 1.7.0_65; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 2.6.32-042stab111.11; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 2.6.32-042stab116.1; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.10.0-229.4.2.el7.x86_64; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.10.0-229.4.2.el7.x86_64; java 1.8.0_45; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.13.0-61-generic; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.14.32-xxxx-grs-ipv6-64; java 1.8.0_111; Europe/de) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.16.0-4-amd64; java 1.7.0_75; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.19.0-15-generic; java 1.8.0_45-internal; Europe/de) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.2.0-4-amd64; java 1.7.0_65; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.2.0-4-amd64; java 1.7.0_67; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 4.4.0-57-generic; java 9-internal; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Windows 8 6.2; java 1.7.0_55; Europe/de) http://yacy.net/bot.html", + "yacybot (-global; amd64 Windows 8.1 6.3; java 1.7.0_55; Europe/de) http://yacy.net/bot.html", + "yacybot (/global; amd64 FreeBSD 10.3-RELEASE-p7; java 1.7.0_95; GMT/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 FreeBSD 10.3-RELEASE; java 1.8.0_77; GMT/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 2.6.32-042stab093.4; java 1.7.0_65; Etc/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 2.6.32-042stab094.8; java 1.7.0_79; America/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 2.6.32-042stab108.8; java 1.7.0_91; America/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 2.6.32-573.3.1.el6.x86_64; java 1.7.0_85; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.10.0-229.7.2.el7.x86_64; java 1.8.0_45; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.10.0-327.22.2.el7.x86_64; java 1.7.0_101; Etc/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.11.10-21-desktop; java 1.7.0_51; America/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.12.1; java 1.7.0_65; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-042stab093.4; java 1.7.0_79; Europe/de) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-042stab093.4; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-45-generic; java 1.7.0_75; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-74-generic; java 1.7.0_91; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-83-generic; java 1.7.0_95; Europe/de) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-83-generic; java 1.7.0_95; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-85-generic; java 1.7.0_101; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-85-generic; java 1.7.0_95; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.13.0-88-generic; java 1.7.0_101; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.14-0.bpo.1-amd64; java 1.7.0_55; Europe/de) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.14.32-xxxx-grs-ipv6-64; java 1.7.0_75; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.16-0.bpo.2-amd64; java 1.7.0_65; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.16.0-4-amd64; java 1.7.0_111; Europe/de) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.16.0-4-amd64; java 1.7.0_75; America/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.16.0-4-amd64; java 1.7.0_75; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.16.0-4-amd64; java 1.7.0_79; Europe/de) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.16.0-4-amd64; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.16.0-4-amd64; java 1.7.0_91; Europe/de) http://yacy.net/bot.html", + "yacybot (-global; amd64 FreeBSD 9.2-RELEASE-p10; java 1.7.0_65; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 2.6.32-042stab111.11; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 2.6.32-042stab116.1; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (-global; amd64 Linux 3.10.0-229.4.2.el7.x86_64; java 1.7.0_79; Europe/en) http://yacy.net/bot.html", + "yacybot (/global; amd64 Linux 3.16.0-4-amd64; java 1.7.0_95; Europe/en) http://yacy.net/bot.html" + ] + }, + { + "pattern": "AISearchBot", + "instances": [] + }, + { + "pattern": "IOI", + "instances": [] + }, + { + "pattern": "ips-agent", + "instances": [ + "BlackBerry9000/4.6.0.167 Profile/MIDP-2.0 Configuration/CLDC-1.1 VendorID/102 ips-agent", + "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.7.12; ips-agent) Gecko/20050922 Fedora/1.0.7-1.1.fc4 Firefox/1.0.7", + "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.1.3; ips-agent) Gecko/20090824 Fedora/1.0.7-1.1.fc4 Firefox/3.5.3", + "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.24; ips-agent) Gecko/20111107 Ubuntu/10.04 (lucid) Firefox/3.6.24", + "Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:14.0; ips-agent) Gecko/20100101 Firefox/14.0.1" + ] + }, + { + "pattern": "tagoobot", + "instances": [] + }, + { + "pattern": "MJ12bot", + "instances": [ + "MJ12bot/v1.2.0 (http://majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.2.1; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.2.3; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.2.4; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.2.5; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.3.0; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.3.1; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.3.2; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.3.3; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.0; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.1; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.2; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.3; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.4 (domain ownership verifier); http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.4; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.5; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.6; http://mj12bot.com/)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.7; http://mj12bot.com/)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.7; http://www.majestic12.co.uk/bot.php?+)", + "Mozilla/5.0 (compatible; MJ12bot/v1.4.8; http://mj12bot.com/)" + ] + }, + { + "pattern": "woriobot", + "instances": [ + "Mozilla/5.0 (compatible; woriobot +http://worio.com)", + "Mozilla/5.0 (compatible; woriobot support [at] zite [dot] com +http://zite.com)" + ] + }, + { + "pattern": "yanga", + "instances": [ + "Yanga WorldSearch Bot v1.1/beta (http://www.yanga.co.uk/)" + ] + }, + { + "pattern": "buzzbot", + "instances": [ + "Buzzbot/1.0 (Buzzbot; http://www.buzzstream.com; buzzbot@buzzstream.com)" + ] + }, + { + "pattern": "mlbot", + "instances": [ + "MLBot (www.metadatalabs.com/mlbot)" + ] + }, + { + "pattern": "YandexBot", + "url": "http://yandex.com/bots", + "instances": [ + "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)" + ], + "addition_date": "2015/04/14" + }, + { + "pattern": "yandex.com\\/bots", + "url": "https://yandex.com/support/webmaster/robot-workings/check-yandex-robots.xml#robot-in-logs", + "instances": [ + "Mozilla/5.0 (compatible; YandexWebmaster/2.0; +http://yandex.com/bots)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 8_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12B411 Safari/600.1.4 (compatible; YandexMobileBot/3.0; +http://yandex.com/bots)" + ], + "addition_date": "2016/12/01" + }, + { + "pattern": "purebot", + "addition_date": "2010/01/19", + "instances": [] + }, + { + "pattern": "Linguee Bot", + "addition_date": "2010/01/26", + "url": "http://www.linguee.com/bot", + "instances": [ + "Linguee Bot (http://www.linguee.com/bot)", + "Linguee Bot (http://www.linguee.com/bot; bot@linguee.com)" + ] + }, + { + "pattern": "CyberPatrol", + "addition_date": "2010/02/11", + "url": "http://www.cyberpatrol.com/cyberpatrolcrawler.asp", + "instances": [ + "CyberPatrol SiteCat Webbot (http://www.cyberpatrol.com/cyberpatrolcrawler.asp)" + ] + }, + { + "pattern": "voilabot", + "addition_date": "2010/05/18", + "instances": [ + "Mozilla/5.0 (Windows NT 5.1; U; Win64; fr; rv:1.8.1) VoilaBot BETA 1.2 (support.voilabot@orange-ftgroup.com)", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; fr; rv:1.8.1) VoilaBot BETA 1.2 (support.voilabot@orange-ftgroup.com)", + "Mozilla/5.0 (compatible; OrangeBot/2.0; support.voilabot@orange.com)" + ] + }, + { + "pattern": "Baiduspider", + "addition_date": "2010/07/15", + "url": "http://www.baidu.jp/spider/", + "instances": [ + "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)" + ] + }, + { + "pattern": "citeseerxbot", + "addition_date": "2010/07/17", + "instances": [] + }, + { + "pattern": "spbot", + "addition_date": "2010/07/31", + "url": "http://www.seoprofiler.com/bot", + "instances": [ + "Mozilla/5.0 (compatible; spbot/1.0; +http://www.seoprofiler.com/bot/ )", + "Mozilla/5.0 (compatible; spbot/1.1; +http://www.seoprofiler.com/bot/ )", + "Mozilla/5.0 (compatible; spbot/1.2; +http://www.seoprofiler.com/bot/ )", + "Mozilla/5.0 (compatible; spbot/2.0.1; +http://www.seoprofiler.com/bot/ )", + "Mozilla/5.0 (compatible; spbot/2.0.2; +http://www.seoprofiler.com/bot/ )", + "Mozilla/5.0 (compatible; spbot/2.0.3; +http://www.seoprofiler.com/bot/ )", + "Mozilla/5.0 (compatible; spbot/2.0.4; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/2.0; +http://www.seoprofiler.com/bot/ )", + "Mozilla/5.0 (compatible; spbot/2.1; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/3.0; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/3.1; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.1; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.2; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.3; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.4; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.5; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.6; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.7; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.7; +https://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.8; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/4.0.9; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/4.0; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0a; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.0b; +http://www.seoprofiler.com/bot )", + "Mozilla/5.0 (compatible; spbot/4.1.0; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/4.2.0; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/4.3.0; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/4.4.0; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/4.4.1; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/4.4.2; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/5.0.1; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/5.0.2; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/5.0.3; +http://OpenLinkProfiler.org/bot )", + "Mozilla/5.0 (compatible; spbot/5.0; +http://OpenLinkProfiler.org/bot )" + ] + }, + { + "pattern": "twengabot", + "addition_date": "2010/08/03", + "url": "http://www.twenga.com/bot.html", + "instances": [] + }, + { + "pattern": "postrank", + "addition_date": "2010/08/03", + "url": "http://www.postrank.com", + "instances": [ + "PostRank/2.0 (postrank.com)", + "PostRank/2.0 (postrank.com; 1 subscribers)" + ] + }, + { + "pattern": "turnitinbot", + "addition_date": "2010/09/26", + "url": "http://www.turnitin.com", + "instances": [] + }, + { + "pattern": "scribdbot", + "addition_date": "2010/09/28", + "url": "http://www.scribd.com", + "instances": [] + }, + { + "pattern": "page2rss", + "addition_date": "2010/10/07", + "url": "http://www.page2rss.com", + "instances": [ + "Mozilla/5.0 (compatible; Page2RSS/0.7; +http://page2rss.com/)" + ] + }, + { + "pattern": "sitebot", + "addition_date": "2010/12/15", + "url": "http://www.sitebot.org", + "instances": [ + "Mozilla/5.0 (compatible; Whoiswebsitebot/0.1; +http://www.whoiswebsite.net)" + ] + }, + { + "pattern": "linkdex", + "addition_date": "2011/01/06", + "url": "http://www.linkdex.com", + "instances": [ + "Mozilla/5.0 (compatible; linkdexbot/2.0; +http://www.linkdex.com/about/bots/)", + "Mozilla/5.0 (compatible; linkdexbot/2.0; +http://www.linkdex.com/bots/)", + "Mozilla/5.0 (compatible; linkdexbot/2.1; +http://www.linkdex.com/about/bots/)", + "Mozilla/5.0 (compatible; linkdexbot/2.1; +http://www.linkdex.com/bots/)", + "Mozilla/5.0 (compatible; linkdexbot/2.2; +http://www.linkdex.com/bots/)", + "linkdex.com/v2.0", + "linkdexbot/Nutch-1.0-dev (http://www.linkdex.com/; crawl at linkdex dot com)" + ] + }, + { + "pattern": "Adidxbot", + "url": "http://onlinehelp.microsoft.com/en-us/bing/hh204496.aspx", + "instances": [] + }, + { + "pattern": "blekkobot", + "url": "http://blekko.com/about/blekkobot", + "instances": [ + "Mozilla/5.0 (compatible; Blekkobot; ScoutJet; +http://blekko.com/about/blekkobot)" + ] + }, + { + "pattern": "ezooms", + "addition_date": "2011/04/27", + "url": "http://www.phpbb.com/community/viewtopic.php?f=64&t=935605&start=450#p12948289", + "instances": [ + "Mozilla/5.0 (compatible; Ezooms/1.0; ezooms.bot@gmail.com)" + ] + }, + { + "pattern": "dotbot", + "addition_date": "2011/04/27", + "instances": [ + "Mozilla/5.0 (compatible; DotBot/1.1; http://www.opensiteexplorer.org/dotbot, help@moz.com)", + "dotbot" + ] + }, + { + "pattern": "Mail.RU_Bot", + "addition_date": "2011/04/27", + "instances": [ + "Mozilla/5.0 (compatible; Linux x86_64; Mail.RU_Bot/2.0; +http://go.mail.ru/", + "Mozilla/5.0 (compatible; Mail.RU_Bot/2.0; +http://go.mail.ru/" + ] + }, + { + "pattern": "discobot", + "addition_date": "2011/05/03", + "url": "http://discoveryengine.com/discobot.html", + "instances": [ + "Mozilla/5.0 (compatible; discobot/1.0; +http://discoveryengine.com/discobot.html)", + "Mozilla/5.0 (compatible; discobot/2.0; +http://discoveryengine.com/discobot.html)", + "mozilla/5.0 (compatible; discobot/1.1; +http://discoveryengine.com/discobot.html)" + ] + }, + { + "pattern": "heritrix", + "addition_date": "2011/06/21", + "url": "http://crawler.archive.org/", + "instances": [ + "Mozilla/5.0 (compatible; archive.org_bot/heritrix-1.15.4 +http://www.archive.org)", + "Mozilla/5.0 (compatible; heritrix/1.12.1 +http://www.webarchiv.cz)", + "Mozilla/5.0 (compatible; heritrix/1.12.1b +http://netarkivet.dk/website/info.html)", + "Mozilla/5.0 (compatible; heritrix/1.14.2 +http://rjpower.org)", + "Mozilla/5.0 (compatible; heritrix/1.14.2 +http://www.webarchiv.cz)", + "Mozilla/5.0 (compatible; heritrix/1.14.3 +http://archive.org)", + "Mozilla/5.0 (compatible; heritrix/1.14.3 +http://www.accelobot.com)", + "Mozilla/5.0 (compatible; heritrix/1.14.3 +http://www.webarchiv.cz)", + "Mozilla/5.0 (compatible; heritrix/1.14.3.r6601 +http://www.buddybuzz.net/yptrino)", + "Mozilla/5.0 (compatible; heritrix/1.14.4 +http://parsijoo.ir)", + "Mozilla/5.0 (compatible; heritrix/1.14.4 +http://www.exif-search.com)", + "Mozilla/5.0 (compatible; heritrix/2.0.2 +http://aihit.com)", + "Mozilla/5.0 (compatible; heritrix/2.0.2 +http://seekda.com)", + "Mozilla/5.0 (compatible; heritrix/3.0.0-SNAPSHOT-20091120.021634 +http://crawler.archive.org)", + "Mozilla/5.0 (compatible; heritrix/3.1.0-RC1 +http://boston.lti.cs.cmu.edu/crawler_12/)", + "Mozilla/5.0 (compatible; heritrix/3.1.1 +http://places.tomtom.com/crawlerinfo)", + "Mozilla/5.0 (compatible; heritrix/3.1.1 +http://www.mixdata.com)", + "Mozilla/5.0 (compatible; heritrix/3.1.1-SNAPSHOT-20120116.200628 +http://www.archive.org/details/archive.org_bot)", + "Mozilla/5.0 (compatible; heritrix/3.1.1; UniLeipzigASV +http://corpora.informatik.uni-leipzig.de/crawler_faq.html)", + "Mozilla/5.0 (compatible; heritrix/3.2.0 +http://www.crim.ca)", + "Mozilla/5.0 (compatible; heritrix/3.2.0 +http://www.exif-search.com)", + "Mozilla/5.0 (compatible; heritrix/3.2.0 +http://www.mixdata.com)", + "Mozilla/5.0 (compatible; heritrix/3.3.0-SNAPSHOT-20140702-2247 +http://archive.org/details/archive.org_bot)", + "Mozilla/5.0 (compatible; heritrix/3.3.0-SNAPSHOT-20160309-0050; UniLeipzigASV +http://corpora.informatik.uni-leipzig.de/crawler_faq.html)", + "Mozilla/5.0 (compatible; sukibot_heritrix/3.1.1 +http://suki.ling.helsinki.fi/eng/webmasters.html)" + ] + }, + { + "pattern": "findthatfile", + "addition_date": "2011/06/21", + "url": "http://www.findthatfile.com/", + "instances": [] + }, + { + "pattern": "europarchive.org", + "addition_date": "2011/06/21", + "url": "", + "instances": [ + "Mozilla/5.0 (compatible; MSIE 7.0 +http://www.europarchive.org)" + ] + }, + { + "pattern": "NerdByNature.Bot", + "addition_date": "2011/07/12", + "url": "http://www.nerdbynature.net/bot", + "instances": [ + "Mozilla/5.0 (compatible; NerdByNature.Bot; http://www.nerdbynature.net/bot)" + ] + }, + { + "pattern": "sistrix crawler", + "addition_date": "2011/08/02", + "instances": [] + }, + { + "pattern": "AhrefsBot", + "addition_date": "2011/08/28", + "instances": [ + "Mozilla/5.0 (compatible; AhrefsBot/5.2; News; +http://ahrefs.com/robot/)", + "Mozilla/5.0 (compatible; AhrefsBot/5.2; +http://ahrefs.com/robot/)" + ] + }, + { + "pattern": "Aboundex", + "addition_date": "2011/09/28", + "url": "http://www.aboundex.com/crawler/", + "instances": [ + "Aboundex/0.2 (http://www.aboundex.com/crawler/)", + "Aboundex/0.3 (http://www.aboundex.com/crawler/)" + ] + }, + { + "pattern": "domaincrawler", + "addition_date": "2011/10/21", + "instances": [ + "CipaCrawler/3.0 (info@domaincrawler.com; http://www.domaincrawler.com/www.example.com)" + ] + }, + { + "pattern": "wbsearchbot", + "addition_date": "2011/12/21", + "url": "http://www.warebay.com/bot.html", + "instances": [] + }, + { + "pattern": "summify", + "addition_date": "2012/01/04", + "url": "http://summify.com", + "instances": [ + "Summify (Summify/1.0.1; +http://summify.com)" + ] + }, + { + "pattern": "CCBot", + "addition_date": "2012/02/05", + "url": "http://www.commoncrawl.org/bot.html", + "instances": [ + "CCBot/2.0 (http://commoncrawl.org/faq/)" + ] + }, + { + "pattern": "edisterbot", + "addition_date": "2012/02/25", + "instances": [] + }, + { + "pattern": "seznambot", + "addition_date": "2012/03/14", + "instances": [ + "Mozilla/5.0 (compatible; SeznamBot/3.2-test1-1; +http://napoveda.seznam.cz/en/seznambot-intro/)", + "Mozilla/5.0 (compatible; SeznamBot/3.2-test1; +http://napoveda.seznam.cz/en/seznambot-intro/)", + "Mozilla/5.0 (compatible; SeznamBot/3.2-test2; +http://napoveda.seznam.cz/en/seznambot-intro/)", + "Mozilla/5.0 (compatible; SeznamBot/3.2-test4; +http://napoveda.seznam.cz/en/seznambot-intro/)", + "Mozilla/5.0 (compatible; SeznamBot/3.2; +http://napoveda.seznam.cz/en/seznambot-intro/)" + ] + }, + { + "pattern": "ec2linkfinder", + "addition_date": "2012/03/22", + "instances": [ + "ec2linkfinder" + ] + }, + { + "pattern": "gslfbot", + "addition_date": "2012/04/03", + "instances": [] + }, + { + "pattern": "aihitbot", + "addition_date": "2012/04/16", + "instances": [] + }, + { + "pattern": "intelium_bot", + "addition_date": "2012/05/07", + "instances": [] + }, + { + "pattern": "facebookexternalhit", + "addition_date": "2012/05/07", + "instances": [ + "facebookexternalhit/1.0 (+http://www.facebook.com/externalhit_uatext.php)", + "facebookexternalhit/1.1", + "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)" + ] + }, + { + "pattern": "Yeti", + "addition_date": "2012/05/07", + "url": "http://naver.me/bot", + "instances": [ + "Mozilla/5.0 (compatible; Yeti/1.1; +http://naver.me/bot)" + ] + }, + { + "pattern": "RetrevoPageAnalyzer", + "addition_date": "2012/05/07", + "instances": [ + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; RetrevoPageAnalyzer; +http://www.retrevo.com/content/about-us)" + ] + }, + { + "pattern": "lb-spider", + "addition_date": "2012/05/07", + "instances": [] + }, + { + "pattern": "Sogou", + "addition_date": "2012/05/13", + "url": "http://www.sogou.com/docs/help/webmasters.htm#07", + "instances": [ + "Sogou News Spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm#07)", + "Sogou Pic Spider/3.0(+http://www.sogou.com/docs/help/webmasters.htm#07)", + "Sogou web spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm#07)" + ] + }, + { + "pattern": "lssbot", + "addition_date": "2012/05/15", + "instances": [] + }, + { + "pattern": "careerbot", + "addition_date": "2012/05/23", + "url": "http://www.career-x.de/bot.html", + "instances": [] + }, + { + "pattern": "wotbox", + "addition_date": "2012/06/12", + "url": "http://www.wotbox.com", + "instances": [ + "Wotbox/2.0 (bot@wotbox.com; http://www.wotbox.com)", + "Wotbox/2.01 (+http://www.wotbox.com/bot/)" + ] + }, + { + "pattern": "wocbot", + "addition_date": "2012/07/25", + "url": "http://www.wocodi.com/crawler", + "instances": [] + }, + { + "pattern": "ichiro", + "addition_date": "2012/08/28", + "url": "http://help.goo.ne.jp/help/article/1142", + "instances": [ + "DoCoMo/2.0 P900i(c100;TB;W24H11) (compatible; ichiro/mobile goo; +http://help.goo.ne.jp/help/article/1142/)", + "DoCoMo/2.0 P900i(c100;TB;W24H11) (compatible; ichiro/mobile goo; +http://search.goo.ne.jp/option/use/sub4/sub4-1/)", + "DoCoMo/2.0 P900i(c100;TB;W24H11) (compatible; ichiro/mobile goo;+http://search.goo.ne.jp/option/use/sub4/sub4-1/)", + "DoCoMo/2.0 P900i(c100;TB;W24H11)(compatible; ichiro/mobile goo;+http://help.goo.ne.jp/door/crawler.html)", + "DoCoMo/2.0 P901i(c100;TB;W24H11) (compatible; ichiro/mobile goo; +http://help.goo.ne.jp/door/crawler.html)", + "KDDI-CA31 UP.Browser/6.2.0.7.3.129 (GUI) MMP/2.0 (compatible; ichiro/mobile goo; +http://help.goo.ne.jp/help/article/1142/)", + "KDDI-CA31 UP.Browser/6.2.0.7.3.129 (GUI) MMP/2.0 (compatible; ichiro/mobile goo; +http://search.goo.ne.jp/option/use/sub4/sub4-1/)", + "KDDI-CA31 UP.Browser/6.2.0.7.3.129 (GUI) MMP/2.0 (compatible; ichiro/mobile goo;+http://search.goo.ne.jp/option/use/sub4/sub4-1/)", + "ichiro/2.0 (http://help.goo.ne.jp/door/crawler.html)", + "ichiro/2.0 (ichiro@nttr.co.jp)", + "ichiro/3.0 (http://help.goo.ne.jp/door/crawler.html)", + "ichiro/3.0 (http://help.goo.ne.jp/help/article/1142)", + "ichiro/3.0 (http://search.goo.ne.jp/option/use/sub4/sub4-1/)", + "ichiro/4.0 (http://help.goo.ne.jp/door/crawler.html)", + "ichiro/5.0 (http://help.goo.ne.jp/door/crawler.html)" + ] + }, + { + "pattern": "DuckDuckBot", + "addition_date": "2012/09/19", + "url": "http://duckduckgo.com/duckduckbot.html", + "instances": [ + "DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)", + "DuckDuckBot/1.1; (+http://duckduckgo.com/duckduckbot.html)" + ] + }, + { + "pattern": "lssrocketcrawler", + "addition_date": "2012/09/24", + "instances": [] + }, + { + "pattern": "drupact", + "addition_date": "2012/09/27", + "url": "http://www.arocom.de/drupact", + "instances": [ + "drupact/0.7; http://www.arocom.de/drupact" + ] + }, + { + "pattern": "webcompanycrawler", + "addition_date": "2012/10/03", + "instances": [] + }, + { + "pattern": "acoonbot", + "addition_date": "2012/10/07", + "url": "http://www.acoon.de/robot.asp", + "instances": [] + }, + { + "pattern": "openindexspider", + "addition_date": "2012/10/26", + "url": "http://www.openindex.io/en/webmasters/spider.html", + "instances": [] + }, + { + "pattern": "gnam gnam spider", + "addition_date": "2012/10/31", + "instances": [] + }, + { + "pattern": "web-archive-net.com.bot", + "instances": [] + }, + { + "pattern": "backlinkcrawler", + "addition_date": "2013/01/04", + "instances": [] + }, + { + "pattern": "coccoc", + "addition_date": "2013/01/04", + "url": "http://help.coccoc.vn/", + "instances": [ + "Mozilla/5.0 (compatible; coccoc/1.0; +http://help.coccoc.com/)", + "Mozilla/5.0 (compatible; coccoc/1.0; +http://help.coccoc.com/searchengine)", + "Mozilla/5.0 (compatible; coccocbot-image/1.0; +http://help.coccoc.com/searchengine)", + "Mozilla/5.0 (compatible; coccocbot-web/1.0; +http://help.coccoc.com/searchengine)", + "Mozilla/5.0 (compatible; image.coccoc/1.0; +http://help.coccoc.com/)", + "Mozilla/5.0 (compatible; imagecoccoc/1.0; +http://help.coccoc.com/)", + "Mozilla/5.0 (compatible; imagecoccoc/1.0; +http://help.coccoc.com/searchengine)", + "coccoc", + "coccoc/1.0 ()", + "coccoc/1.0 (http://help.coccoc.com/)", + "coccoc/1.0 (http://help.coccoc.vn/)" + ] + }, + { + "pattern": "integromedb", + "addition_date": "2013/01/10", + "url": "http://www.integromedb.org/Crawler", + "instances": [ + "www.integromedb.org/Crawler" + ] + }, + { + "pattern": "content crawler spider", + "addition_date": "2013/01/11", + "instances": [] + }, + { + "pattern": "toplistbot", + "addition_date": "2013/02/05", + "instances": [] + }, + { + "pattern": "seokicks-robot", + "addition_date": "2013/02/25", + "instances": [] + }, + { + "pattern": "it2media-domain-crawler", + "addition_date": "2013/03/12", + "instances": [ + "it2media-domain-crawler/1.0 on crawler-prod.it2media.de", + "it2media-domain-crawler/2.0" + ] + }, + { + "pattern": "ip-web-crawler.com", + "addition_date": "2013/03/22", + "instances": [] + }, + { + "pattern": "siteexplorer.info", + "addition_date": "2013/05/01", + "instances": [ + "Mozilla/5.0 (compatible; SiteExplorer/1.0b; +http://siteexplorer.info/)", + "Mozilla/5.0 (compatible; SiteExplorer/1.1b; +http://siteexplorer.info/Backlink-Checker-Spider/)" + ] + }, + { + "pattern": "elisabot", + "addition_date": "2013/06/27", + "instances": [] + }, + { + "pattern": "proximic", + "addition_date": "2013/09/12", + "url": "http://www.proximic.com/info/spider.php", + "instances": [ + "Mozilla/5.0 (compatible; proximic; +http://www.proximic.com)", + "Mozilla/5.0 (compatible; proximic; +http://www.proximic.com/info/spider.php)" + ] + }, + { + "pattern": "changedetection", + "addition_date": "2013/09/13", + "url": "http://www.changedetection.com/bot.html", + "instances": [ + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; http://www.changedetection.com/bot.html )" + ] + }, + { + "pattern": "blexbot", + "addition_date": "2013/10/03", + "url": "http://webmeup-crawler.com/", + "instances": [] + }, + { + "pattern": "arabot", + "addition_date": "2013/10/09", + "instances": [] + }, + { + "pattern": "WeSEE:Search", + "addition_date": "2013/11/18", + "instances": [ + "WeSEE:Search", + "WeSEE:Search/0.1 (Alpha, http://www.wesee.com/en/support/bot/)" + ] + }, + { + "pattern": "niki-bot", + "addition_date": "2014/01/01", + "instances": [] + }, + { + "pattern": "CrystalSemanticsBot", + "addition_date": "2014/02/17", + "url": "http://www.crystalsemantics.com/user-agent/", + "instances": [] + }, + { + "pattern": "rogerbot", + "addition_date": "2014/02/28", + "url": "http://moz.com/help/pro/what-is-rogerbot-", + "instances": [ + "Mozilla/5.0 (compatible; rogerBot/1.0; UrlCrawler; http://www.seomoz.org/dp/rogerbot)", + "rogerbot/1.0 (http://moz.com/help/pro/what-is-rogerbot-, rogerbot-crawler+partager@moz.com)", + "rogerbot/1.0 (http://moz.com/help/pro/what-is-rogerbot-, rogerbot-crawler+shiny@moz.com)", + "rogerbot/1.0 (http://moz.com/help/pro/what-is-rogerbot-, rogerbot-wherecat@moz.com", + "rogerbot/1.0 (http://moz.com/help/pro/what-is-rogerbot-, rogerbot-wherecat@moz.com)", + "rogerbot/1.0 (http://www.moz.com/dp/rogerbot, rogerbot-crawler@moz.com)", + "rogerbot/1.0 (http://www.seomoz.org/dp/rogerbot, rogerbot-crawler+shiny@seomoz.org)", + "rogerbot/1.0 (http://www.seomoz.org/dp/rogerbot, rogerbot-crawler@seomoz.org)", + "rogerbot/1.0 (http://www.seomoz.org/dp/rogerbot, rogerbot-wherecat@moz.com)", + "rogerbot/1.1 (http://moz.com/help/guides/search-overview/crawl-diagnostics#more-help, rogerbot-crawler+pr2-crawler-05@moz.com)", + "rogerbot/1.1 (http://moz.com/help/guides/search-overview/crawl-diagnostics#more-help, rogerbot-crawler+pr4-crawler-11@moz.com)", + "rogerbot/1.1 (http://moz.com/help/guides/search-overview/crawl-diagnostics#more-help, rogerbot-crawler+pr4-crawler-15@moz.com)", + "rogerbot/1.2 (http://moz.com/help/pro/what-is-rogerbot-, rogerbot-crawler+phaser-testing-crawler-01@moz.com)" + ] + }, + { + "pattern": "360Spider", + "addition_date": "2014/03/14", + "url": "http://needs-be.blogspot.co.uk/2013/02/how-to-block-spider360.html", + "instances": [ + "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1; 360Spider", + "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1; 360Spider(compatible; HaosouSpider; http://www.haosou.com/help/help_3_2.html)", + "Mozilla/5.0 (Windows NT 6.2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36 QIHU 360SE; 360Spider", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; ) Firefox/1.5.0.11; 360Spider", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.8.0.11) Firefox/1.5.0.11; 360Spider", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.8.0.11) Firefox/1.5.0.11 360Spider;", + "Mozilla/5.0 (Windows; U; Windows NT 5.1; zh-CN; rv:1.8.0.11) Gecko/20070312 Firefox/1.5.0.11; 360Spider", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0); 360Spider", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0); 360Spider(compatible; HaosouSpider; http://www.haosou.com/help/help_3_2.html)" + ] + }, + { + "pattern": "psbot", + "addition_date": "2014/03/31", + "url": "http://www.picsearch.com/bot.html", + "instances": [ + "psbot-image (+http://www.picsearch.com/bot.html)", + "psbot-page (+http://www.picsearch.com/bot.html)", + "psbot/0.1 (+http://www.picsearch.com/bot.html)" + ] + }, + { + "pattern": "InterfaxScanBot", + "addition_date": "2014/03/31", + "url": "http://scan-interfax.ru", + "instances": [] + }, + { + "pattern": "CC Metadata Scaper", + "addition_date": "2014/04/01", + "url": "http://wiki.creativecommons.org/Metadata_Scraper", + "instances": [ + "CC Metadata Scaper http://wiki.creativecommons.org/Metadata_Scraper" + ] + }, + { + "pattern": "g00g1e.net", + "addition_date": "2014/04/01", + "url": "http://www.g00g1e.net/", + "instances": [] + }, + { + "pattern": "GrapeshotCrawler", + "addition_date": "2014/04/01", + "url": "http://www.grapeshot.co.uk/crawler.php", + "instances": [ + "Mozilla/5.0 (compatible; GrapeshotCrawler/2.0; +http://www.grapeshot.co.uk/crawler.php)" + ] + }, + { + "pattern": "urlappendbot", + "addition_date": "2014/05/10", + "url": "http://www.profound.net/urlappendbot.html", + "instances": [ + "Mozilla/5.0 (compatible; URLAppendBot/1.0; +http://www.profound.net/urlappendbot.html)" + ] + }, + { + "pattern": "brainobot", + "addition_date": "2014/06/24", + "instances": [] + }, + { + "pattern": "fr-crawler", + "addition_date": "2014/07/31", + "instances": [ + "Mozilla/5.0 (compatible; fr-crawler/1.1)" + ] + }, + { + "pattern": "binlar", + "addition_date": "2014/09/12", + "instances": [ + "binlar_2.6.3 binlar2.6.3@unspecified.mail", + "binlar_2.6.3 binlar_2.6.3@unspecified.mail", + "binlar_2.6.3 larbin2.6.3@unspecified.mail", + "binlar_2.6.3 phanendra_kalapala@McAfee.com", + "binlar_2.6.3 test@mgmt.mic" + ] + }, + { + "pattern": "SimpleCrawler", + "addition_date": "2014/09/12", + "instances": [ + "SimpleCrawler/0.1" + ] + }, + { + "pattern": "Twitterbot", + "addition_date": "2014/09/12", + "url": "https://dev.twitter.com/cards/getting-started", + "instances": [ + "Twitterbot/0.1", + "Twitterbot/1.0" + ] + }, + { + "pattern": "cXensebot", + "addition_date": "2014/10/05", + "instances": [ + "cXensebot/1.1a" + ], + "url": "http://www.cxense.com/bot.html" + }, + { + "pattern": "smtbot", + "addition_date": "2014/10/04", + "instances": [ + "Mozilla/5.0 (compatible; SMTBot/1.0; +http://www.similartech.com/smtbot)", + "SMTBot (similartech.com/smtbot)" + ], + "url": "http://www.similartech.com/smtbot" + }, + { + "pattern": "bnf.fr_bot", + "addition_date": "2014/11/18", + "url": "http://www.bnf.fr/fr/outils/a.dl_web_capture_robot.html", + "instances": [ + "Mozilla/5.0 (compatible; bnf.fr_bot; +http://www.bnf.fr/fr/outils/a.dl_web_capture_robot.html)" + ] + }, + { + "pattern": "A6-Indexer", + "addition_date": "2014/12/05", + "url": "http://www.a6corp.com/a6-web-scraping-policy/", + "instances": [ + "A6-Indexer" + ] + }, + { + "pattern": "ADmantX", + "addition_date": "2014/12/05", + "url": "http://www.admantx.com", + "instances": [ + "ADmantX Platform Semantic Analyzer - ADmantX Inc. - www.admantx.com - support@admantx.com" + ] + }, + { + "pattern": "Facebot", + "url": "https://developers.facebook.com/docs/sharing/best-practices#crawl", + "addition_date": "2014/12/30", + "instances": [ + "Facebot/1.0" + ] + }, + { + "pattern": "OrangeBot", + "instances": [ + "Mozilla/5.0 (compatible; OrangeBot/2.0; support.orangebot@orange.com" + ], + "addition_date": "2015/01/12" + }, + { + "pattern": "memorybot", + "url": "http://mignify.com/bot.htm", + "instances": [ + "Mozilla/5.0 (compatible; memorybot/1.21.14 +http://mignify.com/bot.html)" + ], + "addition_date": "2015/02/01" + }, + { + "pattern": "AdvBot", + "url": "http://advbot.net/bot.html", + "instances": [ + "Mozilla/5.0 (compatible; AdvBot/2.0; +http://advbot.net/bot.html)" + ], + "addition_date": "2015/02/01" + }, + { + "pattern": "MegaIndex", + "url": "https://www.megaindex.ru/?tab=linkAnalyze", + "instances": [ + "Mozilla/5.0 (compatible; MegaIndex.ru/2.0; +https://www.megaindex.ru/?tab=linkAnalyze)" + ], + "addition_date": "2015/03/28" + }, + { + "pattern": "SemanticScholarBot", + "url": "http://s2.allenai.org/bot.html", + "instances": [ + "SemanticScholarBot/1.0 (+http://s2.allenai.org/bot.html)" + ], + "addition_date": "2015/03/28" + }, + { + "pattern": "ltx71", + "url": "http://ltx71.com/", + "instances": [ + "ltx71 - (http://ltx71.com/)" + ], + "addition_date": "2015/04/04" + }, + { + "pattern": "nerdybot", + "url": "http://nerdybot.com/", + "instances": [ + "nerdybot" + ], + "addition_date": "2015/04/05" + }, + { + "pattern": "xovibot", + "url": "http://www.xovibot.net/", + "instances": [ + "Mozilla/5.0 (compatible; XoviBot/2.0; +http://www.xovibot.net/)" + ], + "addition_date": "2015/04/05" + }, + { + "pattern": "BUbiNG", + "url": "http://law.di.unimi.it/BUbiNG.html", + "instances": [ + "BUbiNG (+http://law.di.unimi.it/BUbiNG.html)" + ], + "addition_date": "2015/04/06" + }, + { + "pattern": "Qwantify", + "url": "https://www.qwant.com/", + "instances": [ + "Mozilla/5.0 (compatible; Qwantify/2.0n; +https://www.qwant.com/)/*" + ], + "addition_date": "2015/04/06" + }, + { + "pattern": "archive.org_bot", + "url": "http://www.archive.org/details/archive.org_bot", + "instances": [ + "Mozilla/5.0 (compatible; archive.org_bot +http://www.archive.org/details/archive.org_bot)" + ], + "addition_date": "2015/04/14" + }, + { + "pattern": "Applebot", + "url": "http://www.apple.com/go/applebot", + "addition_date": "2015/04/15", + "instances": [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/600.2.5 (KHTML, like Gecko) Version/8.0.2 Safari/600.2.5 (Applebot/0.1)", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/600.2.5 (KHTML, like Gecko) Version/8.0.2 Safari/600.2.5 (Applebot/0.1; +http://www.apple.com/go/applebot)", + "Mozilla/5.0 (compatible; Applebot/0.3; +http://www.apple.com/go/applebot)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25 (compatible; Applebot/0.3; +http://www.apple.com/go/applebot)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 8_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12B410 Safari/600.1.4 (Applebot/0.1; +http://www.apple.com/go/applebot)" + ] + }, + { + "pattern": "TweetmemeBot", + "url": "http://datasift.com/bot.html", + "instances": [ + "Mozilla/5.0 (TweetmemeBot/4.0; +http://datasift.com/bot.html) Gecko/20100101 Firefox/31.0" + ], + "addition_date": "2015/04/15" + }, + { + "pattern": "crawler4j", + "url": "https://github.com/yasserg/crawler4j", + "instances": [ + "crawler4j (http://code.google.com/p/crawler4j/)" + ], + "addition_date": "2015/05/07" + }, + { + "pattern": "findxbot", + "url": "http://www.findxbot.com", + "instances": [ + "Mozilla/5.0 (compatible; Findxbot/1.0; +http://www.findxbot.com)" + ], + "addition_date": "2015/05/07" + }, + { + "pattern": "S[eE][mM]rushBot", + "url": "http://www.semrush.com/bot.html", + "instances": [ + "Mozilla/5.0 (compatible; SemrushBot/0.98~bl; +http://www.semrush.com/bot.html)", + "SEMrushBot" + ], + "addition_date": "2015/05/26" + }, + { + "pattern": "yoozBot", + "url": "http://yooz.ir", + "instances": [ + "Mozilla/5.0 (compatible; yoozBot-2.2; http://yooz.ir; info@yooz.ir)" + ], + "addition_date": "2015/05/26" + }, + { + "pattern": "lipperhey", + "url": "http://www.lipperhey.com/", + "instances": [ + "Mozilla/5.0 (compatible; Lipperhey Link Explorer; http://www.lipperhey.com/)", + "Mozilla/5.0 (compatible; Lipperhey SEO Service; http://www.lipperhey.com/)", + "Mozilla/5.0 (compatible; Lipperhey Site Explorer; http://www.lipperhey.com/)", + "Mozilla/5.0 (compatible; Lipperhey-Kaus-Australis/5.0; +https://www.lipperhey.com/en/about/)" + ], + "addition_date": "2015/08/26" + }, + { + "pattern": "Y!J", + "url": "https://www.yahoo-help.jp/app/answers/detail/p/595/a_id/42716/~/%E3%82%A6%E3%82%A7%E3%83%96%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AB%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%81%99%E3%82%8B%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%82%A8%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%B3%E3%83%88%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6", + "instances": [ + "Y!J-ASR/0.1 crawler (http://www.yahoo-help.jp/app/answers/detail/p/595/a_id/42716/)", + "Y!J-BRJ/YATS crawler (http://help.yahoo.co.jp/help/jp/search/indexing/indexing-15.html)", + "Y!J-PSC/1.0 crawler (http://help.yahoo.co.jp/help/jp/search/indexing/indexing-15.html)", + "Y!J-BRW/1.0 crawler (http://help.yahoo.co.jp/help/jp/search/indexing/indexing-15.html)", + "Mozilla/5.0 (iPhone; Y!J-BRY/YATSH crawler; http://help.yahoo.co.jp/help/jp/search/indexing/indexing-15.html)", + "Mozilla/5.0 (compatible; Y!J SearchMonkey/1.0 (Y!J-AGENT; http://help.yahoo.co.jp/help/jp/search/indexing/indexing-15.html))" + ], + "addition_date": "2015/05/26" + }, + { + "pattern": "Domain Re-Animator Bot", + "url": "http://domainreanimator.com", + "instances": [ + "Domain Re-Animator Bot (http://domainreanimator.com) - support@domainreanimator.com" + ], + "addition_date": "2015/04/14" + }, + { + "pattern": "AddThis", + "url": "https://www.addthis.com", + "instances": [ + "AddThis.com robot tech.support@clearspring.com" + ], + "addition_date": "2015/06/02" + }, + { + "pattern": "Screaming Frog SEO Spider", + "url": "http://www.screamingfrog.co.uk/seo-spider", + "instances": [ + "Screaming Frog SEO Spider/5.1" + ], + "addition_date": "2016/01/08" + }, + { + "pattern": "MetaURI", + "url": "http://www.useragentstring.com/MetaURI_id_17683.php", + "instances": [ + "MetaURI API/2.0 +metauri.com" + ], + "addition_date": "2016/01/02" + }, + { + "pattern": "Scrapy", + "url": "http://scrapy.org/", + "instances": [ + "Scrapy/1.0.3 (+http://scrapy.org)" + ], + "addition_date": "2016/01/02" + }, + { + "pattern": "Livelap[bB]ot", + "url": "http://site.livelap.com/crawler", + "instances": [ + "LivelapBot/0.2 (http://site.livelap.com/crawler)", + "Livelapbot/0.1" + ], + "addition_date": "2016/01/02" + }, + { + "pattern": "OpenHoseBot", + "url": "http://www.openhose.org/bot.html", + "instances": [ + "Mozilla/5.0 (compatible; OpenHoseBot/2.1; +http://www.openhose.org/bot.html)" + ], + "addition_date": "2016/01/02" + }, + { + "pattern": "CapsuleChecker", + "url": "http://www.capsulink.com/about", + "instances": [ + "CapsuleChecker (http://www.capsulink.com/)" + ], + "addition_date": "2016/01/02" + }, + { + "pattern": "collection@infegy.com", + "url": "http://infegy.com/", + "instances": [ + "Mozilla/5.0 (compatible) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.73 Safari/537.36 collection@infegy.com" + ], + "addition_date": "2016/01/03" + }, + { + "pattern": "IstellaBot", + "url": "http://www.tiscali.it/", + "instances": [ + "Mozilla/5.0 (compatible; IstellaBot/1.23.15 +http://www.tiscali.it/)" + ], + "addition_date": "2016/01/09" + }, + { + "pattern": "DeuSu\\/", + "addition_date": "2016/01/23", + "url": "https://deusu.de/robot.html", + "instances": [ + "Mozilla/5.0 (compatible; DeuSu/0.1.0; +https://deusu.org)", + "Mozilla/5.0 (compatible; DeuSu/5.0.2; +https://deusu.de/robot.html)" + ] + }, + { + "pattern": "betaBot", + "addition_date": "2016/01/23", + "instances": [] + }, + { + "pattern": "Cliqzbot\\/", + "addition_date": "2016/01/23", + "url": "http://cliqz.com/company/cliqzbot", + "instances": [ + "Cliqzbot/0.1 (+http://cliqz.com +cliqzbot@cliqz.com)", + "Cliqzbot/0.1 (+http://cliqz.com/company/cliqzbot)", + "Mozilla/5.0 (compatible; Cliqzbot/0.1 +http://cliqz.com/company/cliqzbot)", + "Mozilla/5.0 (compatible; Cliqzbot/1.0 +http://cliqz.com/company/cliqzbot)" + ] + }, + { + "pattern": "MojeekBot\\/", + "addition_date": "2016/01/23", + "url": "https://www.mojeek.com/bot.html", + "instances": [ + "MojeekBot/0.2 (archi; http://www.mojeek.com/bot.html)", + "Mozilla/5.0 (compatible; MojeekBot/0.2; http://www.mojeek.com/bot.html#relaunch)", + "Mozilla/5.0 (compatible; MojeekBot/0.2; http://www.mojeek.com/bot.html)", + "Mozilla/5.0 (compatible; MojeekBot/0.5; http://www.mojeek.com/bot.html)", + "Mozilla/5.0 (compatible; MojeekBot/0.6; +https://www.mojeek.com/bot.html)", + "Mozilla/5.0 (compatible; MojeekBot/0.6; http://www.mojeek.com/bot.html)" + ] + }, + { + "pattern": "netEstate NE Crawler", + "addition_date": "2016/01/23", + "url": "+http://www.website-datenbank.de/", + "instances": [ + "netEstate NE Crawler (+http://www.sengine.info/)", + "netEstate NE Crawler (+http://www.website-datenbank.de/)" + ] + }, + { + "pattern": "SafeSearch microdata crawler", + "addition_date": "2016/01/23", + "url": "https://safesearch.avira.com", + "instances": [ + "SafeSearch microdata crawler (https://safesearch.avira.com, safesearch-abuse@avira.com)" + ] + }, + { + "pattern": "Gluten Free Crawler\\/", + "addition_date": "2016/01/23", + "url": "http://glutenfreepleasure.com/", + "instances": [ + "Mozilla/5.0 (compatible; Gluten Free Crawler/1.0; +http://glutenfreepleasure.com/)" + ] + }, + { + "pattern": "Sonic", + "addition_date": "2016/02/08", + "url": "http://www.yama.info.waseda.ac.jp/~crawler/info.html", + "instances": [ + "Mozilla/5.0 (compatible; RankSonicSiteAuditor/1.0; +https://ranksonic.com/ranksonic_sab.html)", + "Mozilla/5.0 (compatible; Sonic/1.0; http://www.yama.info.waseda.ac.jp/~crawler/info.html)", + "Mozzila/5.0 (compatible; Sonic/1.0; http://www.yama.info.waseda.ac.jp/~crawler/info.html)" + ] + }, + { + "pattern": "Sysomos", + "addition_date": "2016/02/08", + "url": "http://www.sysomos.com", + "instances": [ + "Mozilla/5.0 (compatible; Sysomos/1.0; +http://www.sysomos.com/; Sysomos)" + ] + }, + { + "pattern": "Trove", + "addition_date": "2016/02/08", + "url": "http://www.trove.com", + "instances": [] + }, + { + "pattern": "deadlinkchecker", + "addition_date": "2016/02/08", + "url": "http://www.deadlinkchecker.com", + "instances": [ + "www.deadlinkchecker.com Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.86 Safari/537.36", + "www.deadlinkchecker.com XMLHTTP/1.0", + "www.deadlinkchecker.com XMLHTTP/1.0 Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.86 Safari/537.36" + ] + }, + { + "pattern": "Slack-ImgProxy", + "addition_date": "2016/04/25", + "url": "https://api.slack.com/robots", + "instances": [ + "Slack-ImgProxy (+https://api.slack.com/robots)", + "Slack-ImgProxy 0.59 (+https://api.slack.com/robots)", + "Slack-ImgProxy 0.66 (+https://api.slack.com/robots)", + "Slack-ImgProxy 1.106 (+https://api.slack.com/robots)", + "Slack-ImgProxy 1.138 (+https://api.slack.com/robots)", + "Slack-ImgProxy 149 (+https://api.slack.com/robots)" + ] + }, + { + "pattern": "Embedly", + "addition_date": "2016/04/25", + "url": "http://support.embed.ly", + "instances": [ + "Embedly +support@embed.ly", + "Mozilla/5.0 (compatible; Embedly/0.2; +http://support.embed.ly/)", + "Mozilla/5.0 (compatible; Embedly/0.2; snap; +http://support.embed.ly/)" + ] + }, + { + "pattern": "RankActiveLinkBot", + "addition_date": "2016/06/20", + "url": "https://rankactive.com/resources/rankactive-linkbot", + "instances": [ + "Mozilla/5.0 (compatible; RankActiveLinkBot; +https://rankactive.com/resources/rankactive-linkbot)" + ] + }, + { + "pattern": "iskanie", + "addition_date": "2016/09/02", + "url": "http://www.iskanie.com", + "instances": [ + "iskanie (+http://www.iskanie.com)" + ] + }, + { + "pattern": "SafeDNSBot", + "addition_date": "2016/09/10", + "url": "https://www.safedns.com/searchbot", + "instances": [ + "SafeDNSBot (https://www.safedns.com/searchbot)" + ] + }, + { + "pattern": "SkypeUriPreview", + "addition_date": "2016/10/10", + "instances": [ + "Mozilla/5.0 (Windows NT 6.1; WOW64) SkypeUriPreview Preview/0.5" + ] + }, + { + "pattern": "Veoozbot", + "addition_date": "2016/11/03", + "url": "http://www.veooz.com/veoozbot.html", + "instances": [ + "Mozilla/5.0 (compatible; Veoozbot/1.0; +http://www.veooz.com/veoozbot.html)" + ] + }, + { + "pattern": "Slackbot", + "addition_date": "2016/11/03", + "url": "https://api.slack.com/robots", + "instances": [ + "Slackbot-LinkExpanding (+https://api.slack.com/robots)", + "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)" + ] + }, + { + "pattern": "redditbot", + "addition_date": "2016/11/03", + "url": "http://www.reddit.com/feedback", + "instances": [ + "Mozilla/5.0 (compatible; redditbot/1.0; +http://www.reddit.com/feedback)" + ] + }, + { + "pattern": "datagnionbot", + "addition_date": "2016/11/03", + "url": "http://www.datagnion.com/bot.html", + "instances": [ + "datagnionbot (+http://www.datagnion.com/bot.html)" + ] + }, + { + "pattern": "Google-Adwords-Instant", + "addition_date": "2016/11/03", + "url": "http://www.google.com/adsbot.html", + "instances": [ + "Google-Adwords-Instant (+http://www.google.com/adsbot.html)" + ] + }, + { + "pattern": "adbeat_bot", + "addition_date": "2016/11/04", + "instances": [ + "Mozilla/5.0 (compatible; adbeat_bot; +support@adbeat.com; support@adbeat.com)", + "adbeat_bot" + ] + }, + { + "pattern": "WhatsApp", + "addition_date": "2016/11/15", + "url": "https://www.whatsapp.com/", + "instances": [ + "WhatsApp", + "WhatsApp/2.12.15/i", + "WhatsApp/2.12.16/i", + "WhatsApp/2.12.17/i", + "WhatsApp/2.12.449 A", + "WhatsApp/2.12.453 A", + "WhatsApp/2.12.510 A", + "WhatsApp/2.12.540 A", + "WhatsApp/2.12.548 A", + "WhatsApp/2.12.555 A", + "WhatsApp/2.12.556 A", + "WhatsApp/2.16.1/i", + "WhatsApp/2.16.13 A", + "WhatsApp/2.16.2/i", + "WhatsApp/2.16.42 A", + "WhatsApp/2.16.57 A" + ] + }, + { + "pattern": "contxbot", + "addition_date": "2017/02/25", + "instances": [ + "Mozilla/5.0 (compatible;contxbot/1.0)" + ] + }, + { + "pattern": "pinterest", + "addition_date": "2017/03/03", + "instances": [ + "Pinterest/0.2 (+http://www.pinterest.com/bot.html)" + ], + "url": "http://www.pinterest.com/bot.html" + }, + { + "pattern": "electricmonk", + "addition_date": "2017/03/04", + "instances": [ + "Mozilla/5.0 (compatible; electricmonk/3.2.0 +https://www.duedil.com/our-crawler/)" + ], + "url": "https://www.duedil.com/our-crawler/" + }, + { + "pattern": "GarlikCrawler", + "addition_date": "2017/03/18", + "instances": [ + "GarlikCrawler/1.2 (http://garlik.com/, crawler@garlik.com)" + ], + "url": "http://garlik.com/" + }, + { + "pattern": "BingPreview\\/", + "addition_date": "2017/04/23", + "url": "https://www.bing.com/webmaster/help/which-crawlers-does-bing-use-8c184ec0", + "instances": [ + "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/534+ (KHTML, like Gecko) BingPreview/1.0b", + "Mozilla/5.0 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0; BingPreview/1.0b) like Gecko", + "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Trident/6.0; WOW64; Trident/6.0; BingPreview/1.0b)", + "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0; WOW64; Trident/5.0; BingPreview/1.0b)", + "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53 BingPreview/1.0b" + ] + }, + { + "pattern": "vebidoobot", + "addition_date": "2017/05/08", + "instances": [ + "Mozilla/5.0 (compatible; vebidoobot/1.0; +https://blog.vebidoo.de/vebidoobot/" + ], + "url": "https://blog.vebidoo.de/vebidoobot/" + }, + { + "pattern": "FemtosearchBot", + "addition_date": "2017/05/16", + "instances": [ + "Mozilla/5.0 (compatible; FemtosearchBot/1.0; http://femtosearch.com)" + ], + "url": "http://femtosearch.com" + }, + { + "pattern": "Yahoo Link Preview", + "addition_date": "2017/06/28", + "instances": [ + "Mozilla/5.0 (compatible; Yahoo Link Preview; https://help.yahoo.com/kb/mail/yahoo-link-preview-SLN23615.html)" + ], + "url": "https://help.yahoo.com/kb/mail/yahoo-link-preview-SLN23615.html" + }, + { + "pattern": "MetaJobBot", + "addition_date": "2017/08/16", + "instances": [ + "Mozilla/5.0 (compatible; MetaJobBot; http://www.metajob.de/crawler)" + ], + "url": "http://www.metajob.de/the/crawler" + }, + { + "pattern": "DomainStatsBot", + "addition_date": "2017/08/16", + "instances": [ + "DomainStatsBot/1.0 (http://domainstats.io/our-bot)" + ], + "url": "http://domainstats.io/our-bot" + }, + { + "pattern": "mindUpBot", + "addition_date": "2017/08/16", + "instances": [ + "mindUpBot (datenbutler.de)" + ], + "url": "http://www.datenbutler.de/" + }, + { + "pattern": "Daum", + "addition_date": "2017/08/16", + "instances": [ + "Mozilla/5.0 (compatible; Daum/4.1; +http://cs.daum.net/faq/15/4118.html?faqId=28966)" + ], + "url": "http://cs.daum.net/faq/15/4118.html?faqId=28966" + }, + { + "pattern": "Jugendschutzprogramm-Crawler", + "addition_date": "2017/08/16", + "instances": [ + "Jugendschutzprogramm-Crawler; Info: http://www.jugendschutzprogramm.de" + ], + "url": "http://www.jugendschutzprogramm.de" + }, + { + "pattern": "Xenu Link Sleuth", + "addition_date": "2017/08/19", + "instances": [ + "Xenu Link Sleuth/1.3.8" + ], + "url": "http://home.snafu.de/tilman/xenulink.html" + }, + { + "pattern": "Pcore-HTTP", + "addition_date": "2017/08/19", + "instances": [ + "Pcore-HTTP/v0.40.3" + ], + "url": "https://bitbucket.org/softvisio/pcore/overview" + }, + { + "pattern": "moatbot", + "addition_date": "2017/09/16", + "instances": [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.111 Safari/537.36 moatbot", + "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4 moatbot" + ], + "url": "https://moat.com" + }, + { + "pattern": "KosmioBot", + "addition_date": "2017/09/16", + "instances": [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.125 Safari/537.36 (compatible; KosmioBot/1.0; +http://kosm.io/bot.html)" + ], + "url": "http://kosm.io/bot.html" + }, + { + "pattern": "Pingdom", + "addition_date": "2017/09/16", + "instances": [ + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/59.0.3071.109 Chrome/59.0.3071.109 Safari/537.36 PingdomPageSpeed/1.0 (pingbot/2.0; +http://www.pingdom.com/)" + ], + "url": "http://www.pingdom.com" + }, + { + "pattern": "PhantomJS", + "addition_date": "2017/09/18", + "instances": [ + "Mozilla/5.0 (Unknown; Linux x86_64) AppleWebKit/538.1 (KHTML, like Gecko) PhantomJS/2.1.1 Safari/538.1 bl.uk_lddc_renderbot/2.0.0 (+ http://www.bl.uk/aboutus/legaldeposit/websites/websites/faqswebmaster/index.html)" + ], + "url": "http://phantomjs.org/" + }, + { + "pattern": "Gowikibot", + "addition_date": "2017/10/26", + "instances": [ + "Mozilla/5.0 (compatible; Gowikibot/1.0; +http://www.gowikibot.com)" + ], + "url": "http://www.gowikibot.com" + }, + { + "pattern": "PiplBot", + "addition_date": "2017/10/30", + "instances": [ + "Mozilla/5.0+(compatible;+PiplBot;+http://www.pipl.com/bot/)" + ], + "url": "http://www.pipl.com/bot/" + }, + { + "pattern": "Discordbot", + "addition_date": "2017/09/22", + "url": "https://discordapp.com", + "instances": [ + "Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)" + ] + }, + { + "pattern": "TelegramBot", + "addition_date": "2017/10/01", + "instances": [ + "TelegramBot (like TwitterBot)" + ] + }, + { + "pattern": "InfoPath.2", + "addition_date": "2017/10/07", + "instances": [ + "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; InfoPath.2)" + ] + }, + { + "pattern": "Jetslide", + "addition_date": "2017/09/27", + "url": "http://jetsli.de/crawler", + "instances": [ + "Mozilla/5.0 (compatible; Jetslide; +http://jetsli.de/crawler)" + ] + }, + { + "pattern": "newsharecounts", + "addition_date": "2017/09/30", + "url": "http://newsharecounts.com/crawler", + "instances": [ + "Mozilla/5.0 (compatible; NewShareCounts.com/1.0; +http://newsharecounts.com/crawler)" + ] + }, + { + "pattern": "James BOT", + "addition_date": "2017/10/12", + "url": "http://cognitiveseo.com/bot.html", + "instances": [ + "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.8.1.6) Gecko/20070725 Firefox/2.0.0.6 - James BOT - WebCrawler http://cognitiveseo.com/bot.html" + ] + }, + { + "pattern": "Barkrowler", + "addition_date": "2017/10/09", + "url": "http://www.exensa.com/crawl", + "instances": [ + "Barkrowler/0.5.1 (experimenting / debugging - sorry for your logs ) http://www.exensa.com/crawl - admin@exensa.com -- based on BuBiNG", + "Barkrowler/0.7 (+http://www.exensa.com/crawl)" + ] + }, + { + "pattern": "TinEye", + "addition_date": "2017/10/14", + "url": "http://www.tineye.com/crawler.html", + "instances": [ + "Mozilla/5.0 (compatible; TinEye-bot/1.31; +http://www.tineye.com/crawler.html)", + "TinEye/1.1 (http://tineye.com/crawler.html)" + ] + }, + { + "pattern": "SocialRankIOBot", + "addition_date": "2017/10/19", + "url": "http://socialrank.io/about", + "instances": [ + "SocialRankIOBot; http://socialrank.io/about" + ] + }, + { + "pattern": "trendictionbot", + "addition_date": "2017/10/30", + "url": "http://www.trendiction.de/bot", + "instances": [ + "Mozilla/5.0 (Windows; U; Windows NT 6.0; en-GB; rv:1.0; trendictionbot0.5.0; trendiction search; http://www.trendiction.de/bot; please let us know of any problems; web at trendiction.com) Gecko/20071127 Firefox/3.0.0.11" + ] + }, + { + "pattern": "Ocarinabot", + "addition_date": "2017/09/27", + "instances": [ + "Ocarinabot" + ] + }, + { + "pattern": "epicbot", + "addition_date": "2017/10/31", + "url": "http://www.epictions.com/epicbot", + "instances": [ + "Mozilla/5.0 (compatible; epicbot; +http://www.epictions.com/epicbot)" + ] + }, + { + "pattern": "Primalbot", + "addition_date": "2017/09/27", + "url": "https://www.primal.com", + "instances": [ + "Mozilla/5.0 (compatible; Primalbot; +https://www.primal.com;)" + ] + }, + { + "pattern": "DuckDuckGo-Favicons-Bot", + "addition_date": "2017/10/06", + "url": "http://duckduckgo.com", + "instances": [ + "Mozilla/5.0 (compatible; DuckDuckGo-Favicons-Bot/1.0; +http://duckduckgo.com)" + ] + }, + { + "pattern": "GnowitNewsbot", + "addition_date": "2017/10/30", + "url": "http://www.gnowit.com", + "instances": [ + "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:49.0) Gecko/20100101 Firefox/49.0 / GnowitNewsbot / Contact information at http://www.gnowit.com" + ] + }, + { + "pattern": "Leikibot", + "addition_date": "2017/09/24", + "url": "http://www.leiki.com", + "instances": [ + "Mozilla/5.0 (Windows NT 6.3;compatible; Leikibot/1.0; +http://www.leiki.com)" + ] + }, + { + "pattern": "LinkArchiver", + "addition_date": "2017/09/24", + "instances": [ + "@LinkArchiver twitter bot" + ] + }, + { + "pattern": "YaK", + "addition_date": "2017/09/25", + "url": "http://linkfluence.com", + "instances": [ + "Mozilla/5.0 (compatible; YaK/1.0; http://linkfluence.com/; bot@linkfluence.com)" + ] + }, + { + "pattern": "PaperLiBot", + "addition_date": "2017/09/25", + "url": "http://support.paper.li/entries/20023257-what-is-paper-li", + "instances": [ + "Mozilla/5.0 (compatible; PaperLiBot/2.1; http://support.paper.li/entries/20023257-what-is-paper-li)" + ] + }, + { + "pattern": "Digg Deeper", + "addition_date": "2017/09/26", + "url": "http://digg.com/about", + "instances": [ + "Digg Deeper/v1 (http://digg.com/about)" + ] + }, + { + "pattern": "dcrawl", + "addition_date": "2017/09/22", + "instances": [ + "dcrawl/1.0" + ] + }, + { + "pattern": "Snacktory", + "addition_date": "2017/09/23", + "url": "https://github.com/karussell/snacktory", + "instances": [ + "Mozilla/5.0 (compatible; Snacktory; +https://github.com/karussell/snacktory)" + ] + }, + { + "pattern": "AndersPinkBot", + "addition_date": "2017/09/24", + "url": "http://anderspink.com/bot.html", + "instances": [ + "Mozilla/5.0 (compatible; AndersPinkBot/1.0; +http://anderspink.com/bot.html)" + ] + }, + { + "pattern": "Fyrebot", + "addition_date": "2017/09/22", + "instances": [ + "Fyrebot/1.0" + ] + }, + { + "pattern": "EveryoneSocialBot", + "addition_date": "2017/09/22", + "url": "http://everyonesocial.com", + "instances": [ + "Mozilla/5.0 (compatible; EveryoneSocialBot/1.0; support@everyonesocial.com http://everyonesocial.com/)" + ] + }, + { + "pattern": "Mediatoolkitbot", + "addition_date": "2017/10/06", + "url": "http://mediatoolkit.com", + "instances": [ + "Mediatoolkitbot (complaints@mediatoolkit.com)" + ] + }, + { + "pattern": "Luminator-robots", + "addition_date": "2017/09/22", + "instances": [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.13 (KHTML, like Gecko) Chrome/30.0.1599.66 Safari/537.13 Luminator-robots/2.0" + ] + }, + { + "pattern": "ExtLinksBot", + "addition_date": "2017/11/02", + "url": "https://extlinks.com/Bot.html", + "instances": [ + "Mozilla/5.0 (compatible; ExtLinksBot/1.5 +https://extlinks.com/Bot.html)" + ] + }, + { + "pattern": "SurveyBot", + "addition_date": "2017/11/02", + "instances": [ + "Mozilla/5.0 (Windows; U; Windows NT 5.1; en; rv:1.9.0.13) Gecko/2009073022 Firefox/3.5.2 (.NET CLR 3.5.30729) SurveyBot/2.3 (DomainTools)" + ] + }, + { + "pattern": "NING", + "addition_date": "2017/11/02", + "instances": [ + "NING/1.0" + ] + }, + { + "pattern": "okhttp", + "addition_date": "2017/11/02", + "instances": [ + "okhttp/2.5.0", + "okhttp/2.7.5", + "okhttp/3.2.0", + "okhttp/3.5.0" + ] + }, + { + "pattern": "Nuzzel", + "addition_date": "2017/11/02", + "instances": [ + "Nuzzel" + ] + }, + { + "pattern": "omgili", + "addition_date": "2017/11/02", + "url": "http://omgili.com", + "instances": [ + "omgili/0.5 +http://omgili.com" + ] + }, + { + "pattern": "PocketParser", + "addition_date": "2017/11/02", + "url": "https://getpocket.com/pocketparser_ua", + "instances": [ + "PocketParser/2.0 (+https://getpocket.com/pocketparser_ua)" + ] + }, + { + "pattern": "YisouSpider", + "addition_date": "2017/11/02", + "instances": [ + "YisouSpider" + ] + }, + { + "pattern": "um-LN", + "addition_date": "2017/11/02", + "instances": [ + "Mozilla/5.0 (compatible; um-LN/1.0; mailto: techinfo@ubermetrics-technologies.com)" + ] + }, + { + "pattern": "ToutiaoSpider", + "addition_date": "2017/11/02", + "url": "http://web.toutiao.com/media_cooperation/", + "instances": [ + "Mozilla/5.0 (compatible; ToutiaoSpider/1.0; http://web.toutiao.com/media_cooperation/;)" + ] + }, + { + "pattern": "MuckRack", + "addition_date": "2017/11/02", + "url": "http://muckrack.com", + "instances": [ + "Mozilla/5.0 (compatible; MuckRack/1.0; +http://muckrack.com)" + ] + }, + { + "pattern": "Jamie's Spider", + "addition_date": "2017/11/02", + "url": "http://jamiembrown.com/", + "instances": [ + "Jamie's Spider (http://jamiembrown.com/)" + ] + }, + { + "pattern": "AHC", + "addition_date": "2017/11/02", + "instances": [ + "AHC/2.0" + ] + }, + { + "pattern": "NetcraftSurveyAgent", + "addition_date": "2017/11/02", + "instances": [ + "Mozilla/5.0 (compatible; NetcraftSurveyAgent/1.0; +info@netcraft.com)" + ] + }, + { + "pattern": "Laserlikebot", + "addition_date": "2017/11/02", + "instances": [ + "Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12F70 Safari/600.1.4 (compatible; Laserlikebot/0.1)" + ] + }, + { + "pattern": "Apache-HttpClient", + "addition_date": "2017/11/02", + "instances": [ + "Apache-HttpClient/4.2.3 (java 1.5)", + "Apache-HttpClient/4.2.5 (java 1.5)", + "Apache-HttpClient/4.3.1 (java 1.5)", + "Apache-HttpClient/4.3.3 (java 1.5)", + "Apache-HttpClient/4.3.5 (java 1.5)", + "Apache-HttpClient/4.4.1 (Java/1.8.0_65)", + "Apache-HttpClient/4.5.3 (Java/1.8.0_121)" + ] + }, + { + "pattern": "AppEngine-Google", + "addition_date": "2017/11/02", + "instances": [ + "AppEngine-Google; (+http://code.google.com/appengine; appid: example)" + ] + }, + { + "pattern": "Jetty", + "addition_date": "2017/11/02", + "instances": [ + "Jetty/9.3.z-SNAPSHOT" + ] + }, + { + "pattern": "Upflow", + "addition_date": "2017/11/02", + "instances": [ + "Upflow/1.0" + ] + }, + { + "pattern": "Thinklab", + "addition_date": "2017/11/02", + "url": "thinklab.com", + "instances": [ + "Thinklab (thinklab.com)" + ] + }, + { + "pattern": "Traackr.com", + "addition_date": "2017/11/02", + "url": "Traackr.com", + "instances": [ + "Traackr.com" + ] + }, + { + "pattern": "Twurly", + "addition_date": "2017/11/02", + "url": "http://twurly.org", + "instances": [ + "Ruby, Twurly v1.1 (http://twurly.org)" + ] + }, + { + "pattern": "Mastodon", + "addition_date": "2017/11/02", + "instances": [ + "http.rb/2.2.2 (Mastodon/1.5.1; +https://example-masto-instance.org/)" + ] + }, + { + "pattern": "http_get", + "addition_date": "2017/11/02", + "instances": [ + "http_get" + ] + }, + { + "pattern": "DnyzBot", + "addition_date": "2017/11/20", + "instances": [ + "Mozilla/5.0 (compatible; DnyzBot/1.0)" + ] + }, + { + "pattern": "botify", + "addition_date": "2018/02/01", + "instances": [ + "Mozilla/5.0 (compatible; botify; http://botify.com)" + ] + }, + { + "pattern": "007ac9 Crawler", + "addition_date": "2018/02/09", + "instances": [ + "Mozilla/5.0 (compatible; 007ac9 Crawler; http://crawler.007ac9.net/)" + ] + }, + { + "pattern": "BehloolBot", + "addition_date": "2018/02/09", + "instances": [ + "Mozilla/5.0 (compatible; BehloolBot/beta; +http://www.webeaver.com/bot)" + ] + }, + { + "pattern": "BrandVerity", + "addition_date": "2018/02/27", + "instances": [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:41.0) Gecko/20100101 Firefox/55.0 BrandVerity/1.0 (http://www.brandverity.com/why-is-brandverity-visiting-me)" + ] + }, + { + "pattern": "check_http", + "addition_date": "2018/02/09", + "instances": [ + "check_http/v2.2.1 (nagios-plugins 2.2.1)" + ] + }, + { + "pattern": "BDCbot", + "addition_date": "2018/02/09", + "instances": [ + "Mozilla/5.0 (Windows NT 6.1; compatible; BDCbot/1.0; +http://bigweb.bigdatacorp.com.br/faq.aspx) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36" + ] + }, + { + "pattern": "ZumBot", + "addition_date": "2018/02/09", + "instances": [ + "Mozilla/5.0 (compatible; ZumBot/1.0; http://help.zum.com/inquiry)" + ] + }, + { + "pattern": "EZID", + "addition_date": "2018/02/09", + "instances": [ + "EZID (EZID link checker; https://ezid.cdlib.org/)" + ] + }, + { + "pattern": "ICC-Crawler", + "addition_date": "2018/02/28", + "instances": [ + "ICC-Crawler/2.0 (Mozilla-compatible; ; http://ucri.nict.go.jp/en/icccrawler.html)" + ], + "url": "http://ucri.nict.go.jp/en/icccrawler.html" + }, + { + "pattern": "ArchiveBot", + "addition_date": "2018/02/28", + "instances": [ + "ArchiveTeam ArchiveBot/20170106.02 (wpull 2.0.2)" + ], + "url": "https://github.com/ArchiveTeam/ArchiveBot" + }, + { + "pattern": "LCC", + "addition_date": "2018/02/28", + "instances": [ + "LCC (+http://corpora.informatik.uni-leipzig.de/crawler_faq.html)" + ], + "url": "http://corpora.informatik.uni-leipzig.de/crawler_faq.html" + }, + { + "pattern": "filterdb.iss.net\\/crawler", + "addition_date": "2018/03/16", + "instances": [ + "Mozilla/5.0 (compatible; oBot/2.3.1; +http://filterdb.iss.net/crawler/)" + ], + "url": "http://filterdb.iss.net/crawler/" + }, + { + "pattern": "BLP_bbot", + "addition_date": "2018/03/27", + "instances": [ + "BLP_bbot/0.1" + ] + }, + { + "pattern": "BomboraBot", + "addition_date": "2018/03/27", + "instances": [ + "Mozilla/5.0 (compatible; BomboraBot/1.0; +http://www.bombora.com/bot)" + ], + "url": "http://www.bombora.com/bot" + }, + { + "pattern": "Buck\\/", + "addition_date": "2018/03/27", + "instances": [ + "Buck/2.2; (+https://app.hypefactors.com/media-monitoring/about.html)" + ], + "url": "https://app.hypefactors.com/media-monitoring/about.html" + }, + { + "pattern": "Companybook-Crawler", + "addition_date": "2018/03/27", + "instances": [ + "Companybook-Crawler (+https://www.companybooknetworking.com/)" + ], + "url": "https://www.companybooknetworking.com/" + }, + { + "pattern": "Genieo", + "addition_date": "2018/03/27", + "instances": [ + "Mozilla/5.0 (compatible; Genieo/1.0 http://www.genieo.com/webfilter.html)" + ], + "url": "http://www.genieo.com/webfilter.html" + }, + { + "pattern": "magpie-crawler", + "addition_date": "2018/03/27", + "instances": [ + "magpie-crawler/1.1 (U; Linux amd64; en-GB; +http://www.brandwatch.net)" + ], + "url": "http://www.brandwatch.net" + }, + { + "pattern": "MeltwaterNews", + "addition_date": "2018/03/27", + "instances": [ + "MeltwaterNews www.meltwater.com" + ], + "url": "http://www.meltwater.com" + }, + { + "pattern": "Moreover", + "addition_date": "2018/03/27", + "instances": [ + "Mozilla/5.0 Moreover/5.1 (+http://www.moreover.com)" + ], + "url": "http://www.moreover.com" + }, + { + "pattern": "newspaper\\/", + "addition_date": "2018/03/27", + "instances": [ + "newspaper/0.2.5", + "newspaper/0.2.6", + "newspaper/0.1.0.7" + ] + }, + { + "pattern": "ScoutJet", + "addition_date": "2018/03/27", + "instances": [ + "Mozilla/5.0 (compatible; ScoutJet; +http://www.scoutjet.com/)" + ], + "url": "http://www.scoutjet.com/" + }, + { + "pattern": "sentry\\/", + "addition_date": "2018/03/27", + "instances": [ + "sentry/8.22.0 (https://sentry.io)" + ], + "url": "https://sentry.io" + }, + { + "pattern": "StorygizeBot", + "addition_date": "2018/03/27", + "instances": [ + "Mozilla/5.0 (compatible; StorygizeBot; http://www.storygize.com)" + ], + "url": "http://www.storygize.com" + }, + { + "pattern": "UptimeRobot", + "addition_date": "2018/03/27", + "instances": [ + "Mozilla/5.0+(compatible; UptimeRobot/2.0; http://www.uptimerobot.com/)" + ], + "url": "http://www.uptimerobot.com/" + }, + { + "pattern": "OutclicksBot", + "addition_date": "2018/04/21", + "instances": [ + "OutclicksBot/2 +https://www.outclicks.net/agent/VjzDygCuk4ubNmg40ZMbFqT0sIh7UfOKk8s8ZMiupUR", + "OutclicksBot/2 +https://www.outclicks.net/agent/gIYbZ38dfAuhZkrFVl7sJBFOUhOVct6J1SvxgmBZgCe", + "OutclicksBot/2 +https://www.outclicks.net/agent/PryJzTl8POCRHfvEUlRN5FKtZoWDQOBEvFJ2wh6KH5J" + ], + "url": "https://www.outclicks.net" + }, + { + "pattern": "seoscanners", + "addition_date": "2018/05/27", + "instances": [ + "Mozilla/5.0 (compatible; seoscanners.net/1; +spider@seoscanners.net)" + ], + "url": "http://www.seoscanners.net/" + }, + { + "pattern": "Hatena Antenna", + "addition_date": "2018/05/29", + "instances": [ + "Hatena Antenna/0.3" + ] + }, + { + "pattern": "Google Web Preview", + "addition_date": "2018/05/31", + "instances": [ + "Mozilla/5.0 (Linux; U; Android 2.3.4; generic) AppleWebKit/537.36 (KHTML, like Gecko; Google Web Preview) Version/4.0 Mobile Safari/537.36" + ] + }, + { + "pattern": "MauiBot", + "addition_date": "2018/06/06", + "instances": [ + "MauiBot (crawler.feedback+wc@gmail.com)" + ] + }, + { + "pattern": "AlphaBot", + "addition_date": "2018/05/27", + "instances": [ + "Mozilla/5.0 (compatible; AlphaBot/3.2; +http://alphaseobot.com/bot.html)" + ], + "url": "http://alphaseobot.com/bot.html" + }, + { + "pattern": "SBL-BOT", + "addition_date": "2018/06/06", + "instances": [ + "SBL-BOT (http://sbl.net)" + ], + "url": "http://sbl.net", + "description": "Bot of SoftByte BlackWidow" + }, + { + "pattern": "IAS crawler", + "addition_date": "2018/06/06", + "instances": [ + "IAS crawler (ias_crawler; http://integralads.com/site-indexing-policy/)" + ], + "url": "http://integralads.com/site-indexing-policy/", + "description": "Bot of Integral Ad Science, Inc." + }, + { + "pattern": "adscanner", + "addition_date": "2018/06/24", + "instances": [ + "Mozilla/5.0 (compatible; adscanner/)" + ] + }, + { + "pattern": "Netvibes", + "addition_date": "2018/06/24", + "instances": [ + "Netvibes (crawler/bot; http://www.netvibes.com" + ], + "url": "http://www.netvibes.com" + }, + { + "pattern": "acapbot", + "addition_date": "2018/06/27", + "instances": [ + "Mozilla/5.0 (compatible;acapbot/0.1;treat like Googlebot)", + "Mozilla/5.0 (compatible;acapbot/0.1.;treat like Googlebot)" + ] + }, + { + "pattern": "Baidu-YunGuanCe", + "addition_date": "2018/06/27", + "instances": [ + "Baidu-YunGuanCe-Bot(ce.baidu.com)", + "Baidu-YunGuanCe-SLABot(ce.baidu.com)", + "Baidu-YunGuanCe-ScanBot(ce.baidu.com)", + "Baidu-YunGuanCe-PerfBot(ce.baidu.com)", + "Baidu-YunGuanCe-VSBot(ce.baidu.com)" + ], + "url": "https://ce.baidu.com/topic/topic20150908", + "description": "Baidu Cloud Watch" + }, + { + "pattern": "bitlybot", + "addition_date": "2018/06/27", + "instances": [ + "bitlybot/3.0 (+http://bit.ly/)", + "bitlybot/2.0", + "bitlybot" + ], + "url": "http://bit.ly/" + }, + { + "pattern": "blogmuraBot", + "addition_date": "2018/06/27", + "instances": [ + "blogmuraBot (+http://www.blogmura.com)" + ], + "url": "http://www.blogmura.com", + "description": "A blog ranking site which links to blogs on just about every theme possible." + }, + { + "pattern": "Bot.AraTurka.com", + "addition_date": "2018/06/27", + "instances": [ + "Bot.AraTurka.com/0.0.1" + ], + "url": "http://www.araturka.com" + }, + { + "pattern": "bot-pge.chlooe.com", + "addition_date": "2018/06/27", + "instances": [ + "bot-pge.chlooe.com/1.0.0 (+http://www.chlooe.com/)" + ] + }, + { + "pattern": "BoxcarBot", + "addition_date": "2018/06/27", + "instances": [ + "Mozilla/5.0 (compatible; BoxcarBot/1.1; +awesome@boxcar.io)" + ], + "url": "https://boxcar.io/" + }, + { + "pattern": "BTWebClient", + "addition_date": "2018/06/27", + "instances": [ + "BTWebClient/180B(9704)" + ], + "url": "http://www.utorrent.com/", + "description": "µTorrent BitTorrent Client" + }, + { + "pattern": "ContextAd Bot", + "addition_date": "2018/06/27", + "instances": [ + "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0;.NET CLR 1.0.3705; ContextAd Bot 1.0)", + "ContextAd Bot 1.0" + ] + }, + { + "pattern": "Digincore bot", + "addition_date": "2018/06/27", + "instances": [ + "Mozilla/5.0 (compatible; Digincore bot; https://www.digincore.com/crawler.html for rules and instructions.)" + ], + "url": "http://www.digincore.com/crawler.html" + }, + { + "pattern": "Disqus", + "addition_date": "2018/06/27", + "instances": [ + "Disqus/1.0" + ], + "url": "https://disqus.com/", + "description": "validate and quality check pages." + }, + { + "pattern": "Feedly", + "addition_date": "2018/06/27", + "instances": [ + "Feedly/1.0 (+http://www.feedly.com/fetcher.html; like FeedFetcher-Google)", + "FeedlyBot/1.0 (http://feedly.com)" + ], + "url": "https://www.feedly.com/fetcher.html", + "description": "Feedly Fetcher is how Feedly grabs RSS or Atom feeds when users choose to add them to their Feedly or any of the other applications built on top of the feedly cloud." + }, + { + "pattern": "Fetch", + "addition_date": "2018/06/27", + "instances": [ + "Fetch/2.0a (CMS Detection/Web/SEO analysis tool, see http://guess.scritch.org)" + ] + }, + { + "pattern": "Fever", + "addition_date": "2018/06/27", + "instances": [ + "Fever/1.38 (Feed Parser; http://feedafever.com; Allow like Gecko)" + ], + "url": "http://feedafever.com" + }, + { + "pattern": "Flamingo_SearchEngine", + "addition_date": "2018/06/27", + "instances": [ + "Flamingo_SearchEngine (+http://www.flamingosearch.com/bot)" + ] + }, + { + "pattern": "FlipboardProxy", + "addition_date": "2018/06/27", + "instances": [ + "Mozilla/5.0 (compatible; FlipboardProxy/1.1; +http://flipboard.com/browserproxy)", + "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.6; en-US; rv:1.9.2) Gecko/20100115 Firefox/3.6 (FlipboardProxy/1.1; +http://flipboard.com/browserproxy)", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:28.0) Gecko/20100101 Firefox/28.0 (FlipboardProxy/1.1; +http://flipboard.com/browserproxy)" + ], + "url": "https://about.flipboard.com/browserproxy/", + "description": "a proxy service to fetch, validate, and prepare certain elements of websites for presentation through the Flipboard Application" + }, + { + "pattern": "g2reader-bot", + "addition_date": "2018/06/27", + "instances": [ + "g2reader-bot/1.0 (+http://www.g2reader.com/)" + ], + "url": "http://www.g2reader.com/" + }, + { + "pattern": "imrbot", + "addition_date": "2018/06/27", + "instances": [ + "Mozilla/5.0 (compatible; imrbot/1.10.8 +http://www.mignify.com)" + ], + "url": "http://www.mignify.com" + }, + { + "pattern": "K7MLWCBot", + "addition_date": "2018/06/27", + "instances": [ + "K7MLWCBot/1.0 (+http://www.k7computing.com)" + ], + "url": "http://www.k7computing.com", + "description": "Virus scanner" + }, + { + "pattern": "Kemvibot", + "addition_date": "2018/06/27", + "instances": [ + "Kemvibot/1.0 (http://kemvi.com, marco@kemvi.com)" + ], + "url": "http://kemvi.com" + }, + { + "pattern": "Landau-Media-Spider", + "addition_date": "2018/06/27", + "instances": [ + "Landau-Media-Spider/1.0(http://bots.landaumedia.de/bot.html)" + ], + "url": "http://bots.landaumedia.de/bot.html" + }, + { + "pattern": "linkapediabot", + "addition_date": "2018/06/27", + "instances": [ + "linkapediabot (+http://www.linkapedia.com)" + ], + "url": "http://www.linkapedia.com" + }, + { + "pattern": "vkShare", + "addition_date": "2018/07/02", + "instances": [ + "Mozilla/5.0 (compatible; vkShare; +http://vk.com/dev/Share)" + ], + "url": "http://vk.com/dev/Share" + }, + { + "pattern": "Siteimprove.com", + "addition_date": "2018/06/22", + "instances": [ + "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0) LinkCheck by Siteimprove.com", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.0) Match by Siteimprove.com", + "Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; Trident/6.0) SiteCheck-sitecrawl by Siteimprove.com", + "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.0) LinkCheck by Siteimprove.com" + ] + } +] \ No newline at end of file diff --git a/ala-auth/src/test/groovy/au/org/ala/web/AuthServiceSpec.groovy b/ala-auth/src/test/groovy/au/org/ala/web/AuthServiceSpec.groovy new file mode 100644 index 00000000..1060ee48 --- /dev/null +++ b/ala-auth/src/test/groovy/au/org/ala/web/AuthServiceSpec.groovy @@ -0,0 +1,153 @@ +package au.org.ala.web + +import au.org.ala.userdetails.UserDetailsClient +import au.org.ala.userdetails.UserDetailsFromIdListResponse +import grails.testing.services.ServiceUnitTest +import grails.web.mapping.LinkGenerator +import org.grails.spring.beans.factory.InstanceFactoryBean +import retrofit2.mock.Calls +import spock.lang.Specification + +class AuthServiceSpec extends Specification implements ServiceUnitTest { + + + def setup() { + + grailsApplication.config.userDetails.url = 'http://auth.ala.org.au/userdetails/' + + defineBeans { + linkGenerator(InstanceFactoryBean, Stub(LinkGenerator), LinkGenerator) + } + } + + def testGetUserDetailsById() { + setup: + def mockUserDetailsClient = Stub(UserDetailsClient) + def response = new UserDetailsFromIdListResponse() + response.users = [ + '546': new UserDetails(userId: "546", userName: "user1@gmail.com", email: "user1@gmail.com", firstName: "Jimmy-Bob", lastName: "Dursten"), + '4568': new UserDetails(userId: "4568", userName: "user2@hotmail.com", email: "user2@hotmail.com", firstName: "James Robert", lastName: "Durden"), + '8744': new UserDetails(userId: "8744", userName: "user3@fake.edu.au", email: "user3@fake.edu.au", firstName: "Jim Rob", lastName: "Durpen") + ] + response.invalidIds = [ 575 ] + response.success = true + mockUserDetailsClient.getUserDetailsFromIdList(_) >> Calls.response(response) + + service.userDetailsClient = mockUserDetailsClient + + when: + def x = service.getUserDetailsById(['546','8744','4568','575']) + + then: + x.success == true + def users = x.users + users['546'] instanceof UserDetails + users['546'].userName == 'user1@gmail.com' + x.invalidIds == [ 575 ] + } + + def testGetUserDetailsById_null() { + setup: + def mockUserDetailsClient = Stub(UserDetailsClient) + service.userDetailsClient = mockUserDetailsClient + + when: + mockUserDetailsClient.getUserDetailsFromIdList(_) >> Calls.response(null) + def x = service.getUserDetailsById([]) + + then: + x == null + } + + def testGetUserForUserId() { + setup: + def mockUserDetailsClient = Stub(UserDetailsClient) + def response = new UserDetails(userId: "546", userName: "user1@gmail.com", email: "user1@gmail.com", firstName: "Jimmy-Bob", lastName: "Dursten") + mockUserDetailsClient.getUserDetails('546', true) >> Calls.response(response) + + service.userDetailsClient = mockUserDetailsClient + + when: + def x = service.getUserForUserId('546') + + then: + x != null + x.userName == "user1@gmail.com" + x.userId == "546" + x.firstName == "Jimmy-Bob" + x.lastName == "Dursten" + } + + def testGetUserForUserId_nullUserId() { + setup: + def mockUserDetailsClient = Stub(UserDetailsClient) + + service.userDetailsClient = mockUserDetailsClient + + when: + def x = service.getUserForUserId(null) + + then: + x == null + } + + def testGetUserForUserId_nullUser() { + setup: + def mockUserDetailsClient = Stub(UserDetailsClient) + mockUserDetailsClient.getUserDetails('546', true) >> Calls.response(null) + + service.userDetailsClient = mockUserDetailsClient + + when: + def x = service.getUserForUserId('546') + + then: + x == null + } + + def testGetUserForEmailAddress() { + setup: + def mockUserDetailsClient = Stub(UserDetailsClient) + def response = new UserDetails(userId: "546", userName: "user1@gmail.com", email: "user1@gmail.com", firstName: "Jimmy-Bob", lastName: "Dursten") + mockUserDetailsClient.getUserDetails('user1@gmail.com', true) >> Calls.response(response) + + service.userDetailsClient = mockUserDetailsClient + + when: + def x = service.getUserForEmailAddress('user1@gmail.com') + + then: + x != null + x.userName == "user1@gmail.com" + x.userId == "546" + x.firstName == "Jimmy-Bob" + x.lastName == "Dursten" + } + + def testGetUserForEmailAddress_nullUserEmail() { + setup: + def mockUserDetailsClient = Stub(UserDetailsClient) + + service.userDetailsClient = mockUserDetailsClient + + when: + def x = service.getUserForEmailAddress(null) + + then: + x == null + } + + def testGetUserForEmailAddress_nullUser() { + setup: + def mockUserDetailsClient = Stub(UserDetailsClient) + mockUserDetailsClient.getUserDetails('user1@gmail.com', true) >> Calls.response(null) + + service.userDetailsClient = mockUserDetailsClient + + when: + def x = service.getUserForEmailAddress('user1@gmail.com') + + then: + x == null + } +} diff --git a/ala-auth/src/test/groovy/au/org/ala/web/LoginControllerSpec.groovy b/ala-auth/src/test/groovy/au/org/ala/web/LoginControllerSpec.groovy new file mode 100644 index 00000000..6dbd9bc9 --- /dev/null +++ b/ala-auth/src/test/groovy/au/org/ala/web/LoginControllerSpec.groovy @@ -0,0 +1,50 @@ +package au.org.ala.web + +import grails.testing.web.controllers.ControllerUnitTest +import org.grails.spring.beans.factory.InstanceFactoryBean +import spock.lang.Specification + +class LoginControllerSpec extends Specification implements ControllerUnitTest { + + def mockSsoStrategy = Mock(SSOStrategy) + + def setup() { + defineBeans { + ssoStrategy(InstanceFactoryBean, mockSsoStrategy, SSOStrategy) + } + } + + def cleanup() { + } + + void "test log in action"() { + given: + def path = '/test' + 1 * mockSsoStrategy.authenticate(request,response,false,_) >> { request, response, gateway, pathArg -> + response.setStatus(302) + response.setHeader('Location', "http://localhost/oidc/authorize?clientid=test&secret=test&redirectUrl=$pathArg") + false + } + + when:"login without authenticated user" + params.path = path + controller.index() + + then:"redirected to identity provider" + response.redirectedUrl == 'http://localhost/oidc/authorize?clientid=test&secret=test&redirectUrl=http://localhost:8080/test' + } + + + void "test already logged in action"() { + given: + def path = '/test' + 1 * mockSsoStrategy.authenticate(request,response,false,_) >> true + + when:"login with authenticated user" + params.path = path + controller.index() + + then:"redirected to page straight away" + response.redirectedUrl == 'http://localhost:8080/test' + } +} diff --git a/ala-auth/src/test/groovy/au/org/ala/web/LogoutControllerSpec.groovy b/ala-auth/src/test/groovy/au/org/ala/web/LogoutControllerSpec.groovy new file mode 100644 index 00000000..3b0d73de --- /dev/null +++ b/ala-auth/src/test/groovy/au/org/ala/web/LogoutControllerSpec.groovy @@ -0,0 +1,130 @@ +package au.org.ala.web + +import grails.testing.web.controllers.ControllerUnitTest +import org.grails.spring.beans.factory.InstanceFactoryBean +import spock.lang.Specification + +class LogoutControllerSpec extends Specification implements ControllerUnitTest { + + static LOGOUT_URL = 'https://example.org/' + + def setup() { + CoreAuthProperties coreAuthPropertiesBean = new CoreAuthProperties() + coreAuthPropertiesBean.defaultLogoutRedirectUri = 'http://localhost:8080/' + + defineBeans { + coreAuthProperties(InstanceFactoryBean, coreAuthPropertiesBean, CoreAuthProperties) + } + } + + Closure doWithConfig() {{ config -> + config.security.cas.logoutUrl = LOGOUT_URL + }} + + def testLogoutDefaultAppUrlIsAbsolute() { + setup: + // need to save a reference to current session to prevent creation of a new session + def session = getSession() + + when: + controller.logout() + + then: + session.isInvalid() + response.redirectedUrl == "$LOGOUT_URL?url=${URLEncoder.encode('http://localhost:8080/','UTF-8')}" + } + + def testLogoutAppUrlDisallowsExternalRedirect() { + setup: + def session = getSession() + params.appUrl = 'https://test.org' + + when: + controller.logout() + + then: + session.isInvalid() + response.redirectedUrl == "$LOGOUT_URL?url=${URLEncoder.encode('http://localhost:8080/','UTF-8')}" + } + + def testLogoutAppUrlWithChildUri() { + setup: + def session = getSession() + params.appUrl = 'http://localhost:8080/home/index' + + when: + controller.logout() + + then: + session.isInvalid() + response.redirectedUrl == "$LOGOUT_URL?url=${URLEncoder.encode('http://localhost:8080/home/index','UTF-8')}" + } + + def testLogoutAppUrlWithChildUriAndQueryFragment() { + setup: + def session = getSession() + params.appUrl = 'http://localhost:8080/home/index?test#test' + + when: + controller.logout() + + then: + session.isInvalid() + response.redirectedUrl == "$LOGOUT_URL?url=${URLEncoder.encode('http://localhost:8080/home/index?test#test','UTF-8')}" + } + + def testLogoutAppUrlWithRelativeUri() { + setup: + def session = getSession() + params.url = '/home/index' + + when: + controller.logout() + + then: + session.isInvalid() + // This is missing the port number due to the way the MockServletRequest works + response.redirectedUrl == "$LOGOUT_URL?url=${URLEncoder.encode('http://localhost/home/index','UTF-8')}" + } + + def testLogoutAppUrlWithRelativeUriAndQueryFragment() { + setup: + def session = getSession() + params.url = '/home/index?test#test' + + when: + controller.logout() + + then: + session.isInvalid() + // This is missing the port number due to the way the MockServletRequest works + response.redirectedUrl == "$LOGOUT_URL?url=${URLEncoder.encode('http://localhost/home/index?test#test','UTF-8')}" + } + + def testLogoutAppUrlWithInvalidRelativeUri() { + setup: + def session = getSession() + params.url = 'no-starting-slash' + + when: + controller.logout() + + then: + session.isInvalid() + response.redirectedUrl == "$LOGOUT_URL?url=${URLEncoder.encode('http://localhost:8080/','UTF-8')}" + } + + + def testLogoutAppUrlWithInvalidSchemaRelativeUri() { + setup: + def session = getSession() + params.url = '//example.org/home/index' + + when: + controller.logout() + + then: + session.isInvalid() + response.redirectedUrl == "$LOGOUT_URL?url=${URLEncoder.encode('http://localhost:8080/','UTF-8')}" + } +} diff --git a/ala-auth/src/test/groovy/au/org/ala/web/SecurityPrimitivesSpec.groovy b/ala-auth/src/test/groovy/au/org/ala/web/SecurityPrimitivesSpec.groovy new file mode 100644 index 00000000..ec686e96 --- /dev/null +++ b/ala-auth/src/test/groovy/au/org/ala/web/SecurityPrimitivesSpec.groovy @@ -0,0 +1,126 @@ +package au.org.ala.web + +import grails.core.GrailsApplication +import spock.lang.Ignore +import spock.lang.Specification + +class SecurityPrimitivesSpec extends Specification { + + private static final String ROLE_POTATO = "ROLE_POTATO" + + AuthService mockAuthService + GrailsApplication mockGrailsApplication + ConfigObject mockConfig + SecurityPrimitives secPrim + + def setup() { + mockAuthService = Mock() + mockGrailsApplication = Mock() + mockConfig = new ConfigObject() + mockGrailsApplication.config >> mockConfig + + secPrim = new SecurityPrimitives(mockAuthService, mockGrailsApplication) + } + + + def "test logged in user"() { + given: + mockAuthService.getUserId() >> "potato" + + when: + def loggedIn = secPrim.loggedIn + def notLoggedIn = secPrim.notLoggedIn + + then: + loggedIn == true + notLoggedIn == false + } + + def "test anonymous user"() { + given: + mockAuthService.getUserId() >> null + + when: + def loggedIn = secPrim.loggedIn + def notLoggedIn = secPrim.notLoggedIn + + then: + loggedIn == false + notLoggedIn == true + } + + // The following tests ignored because of an odd org.codehaus.groovy.runtime.typehandling.GroovyCastException + @Ignore + def "test isAllGranted"() { + given: + mockAuthService.userInRole(ROLE_POTATO) >> false + mockAuthService.userInRole(!ROLE_POTATO) >> true + + when: + def noPotato = secPrim.isAllGranted([CASRoles.ROLE_ADMIN, CASRoles.ROLE_USER]) + def potato = secPrim.isAllGranted([CASRoles.ROLE_ADMIN, CASRoles.ROLE_USER, ROLE_POTATO]) + + then: + noPotato == true + potato == false + } + + @Ignore + def "test isAnyGranted"() { + given: + mockAuthService.userInRole(ROLE_POTATO) >> false + mockAuthService.userInRole(!ROLE_POTATO) >> true + + when: + def noPotato = secPrim.isAnyGranted([CASRoles.ROLE_ADMIN, CASRoles.ROLE_USER]) + def potato = secPrim.isAnyGranted([CASRoles.ROLE_ADMIN, CASRoles.ROLE_USER, ROLE_POTATO]) + def onlyPotato = secPrim.isAnyGranted([ROLE_POTATO]) + + then: + noPotato == true + potato == true + onlyPotato == false + } + + @Ignore + def "test isNotGranted"() { + given: + mockAuthService.userInRole(ROLE_POTATO) >> false + mockAuthService.userInRole(!ROLE_POTATO) >> true + + when: + def noPotato = secPrim.isNotGranted([CASRoles.ROLE_ADMIN, CASRoles.ROLE_USER]) + def potato = secPrim.isNotGranted([CASRoles.ROLE_ADMIN, CASRoles.ROLE_USER, ROLE_POTATO]) + def onlyPotato = secPrim.isNotGranted([ROLE_POTATO]) + + then: + noPotato == false + potato == false + onlyPotato == true + } + + @Ignore + def "test ROLE_ADMIN replacement"() { + given: + // security.cas.adminRole + def cas = new ConfigObject() + cas.put("adminRole", "ROLE_MAGIC") + def security = new ConfigObject() + security.put("cas", cas) + mockConfig.put("security", security) + mockAuthService.userInRole("ROLE_MAGIC") >> true + mockAuthService.userInRole(!"ROLE_MAGIC") >> false + + when: + def replaced = secPrim.isAllGranted([CASRoles.ROLE_ADMIN]) + def anyReplaced = secPrim.isAnyGranted([CASRoles.ROLE_ADMIN, ROLE_POTATO]) + def notReplacedPos = secPrim.isNotGranted([ROLE_POTATO]) + def notReplacedNeg = secPrim.isNotGranted([CASRoles.ROLE_ADMIN]) + + then: + replaced == true + anyReplaced == true + notReplacedPos == true + notReplacedNeg == false + } +} diff --git a/ala-auth/src/test/groovy/au/org/ala/web/SsoInterceptorSpec.groovy b/ala-auth/src/test/groovy/au/org/ala/web/SsoInterceptorSpec.groovy new file mode 100644 index 00000000..96f57445 --- /dev/null +++ b/ala-auth/src/test/groovy/au/org/ala/web/SsoInterceptorSpec.groovy @@ -0,0 +1,40 @@ +package au.org.ala.web + + +import grails.testing.web.interceptor.InterceptorUnitTest +import org.grails.spring.beans.factory.InstanceFactoryBean +import org.jasig.cas.client.authentication.DefaultGatewayResolverImpl +import spock.lang.Specification + +/** + * See the API for {@link grails.test.mixin.web.ControllerUnitTestMixin} for usage instructions + */ +class SsoInterceptorSpec extends Specification implements InterceptorUnitTest { + + SSOStrategy mockSsoStrategy = Mock(SSOStrategy) + + def setup() { + defineBeans{ + ssoStrategy(InstanceFactoryBean, mockSsoStrategy, SSOStrategy) + } + } + + def cleanup() { + + } + + Closure doWithSpring() {{ -> + ignoreUrlPatternMatcherStrategy(RegexListUrlPatternMatcherStrategy) + userAgentFilterService(UserAgentFilterService, null, []) + gatewayStorage(DefaultGatewayResolverImpl) +// grailsApplication(grailsApplication) + }} + + void "Test sso interceptor matching"() { + when:"A request matches the interceptor" + withRequest(controller:"sso") + + then:"The interceptor does match" + interceptor.doesMatch() + } +} diff --git a/ala-auth/src/test/groovy/au/org/ala/web/pac4j/ConvertingFromAttributesAuthorizationGeneratorSpec.groovy b/ala-auth/src/test/groovy/au/org/ala/web/pac4j/ConvertingFromAttributesAuthorizationGeneratorSpec.groovy new file mode 100644 index 00000000..42fae209 --- /dev/null +++ b/ala-auth/src/test/groovy/au/org/ala/web/pac4j/ConvertingFromAttributesAuthorizationGeneratorSpec.groovy @@ -0,0 +1,40 @@ +package au.org.ala.web.pac4j + +import org.pac4j.jee.context.JEEContextFactory +import org.pac4j.jee.context.session.JEESessionStoreFactory +import org.pac4j.oidc.profile.OidcProfile +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import spock.lang.Specification + +class ConvertingFromAttributesAuthorizationGeneratorSpec extends Specification { + + def "test role conversion"(String rolePrefix, boolean convertRolesToUpperCase, String result) { + setup: + + def request = new MockHttpServletRequest() + def response = new MockHttpServletResponse() + def sessionStore = JEESessionStoreFactory.INSTANCE.newSessionStore() + def context = JEEContextFactory.INSTANCE.newContext(request, response) + + def authGen = new ConvertingFromAttributesAuthorizationGenerator(['role:ala'], ['scp'], rolePrefix, convertRolesToUpperCase) + + def profile = new OidcProfile() + profile.addAttribute('role:ala', 'user') + + when: + def newProfile = authGen.generate(context, sessionStore, profile) + + then: + newProfile.isPresent() + newProfile.get().roles.contains(result) + + where: + rolePrefix | convertRolesToUpperCase | result + '' | false | 'user' + '' | true | 'USER' + 'role_' | false | 'role_user' + 'role_' | true | 'ROLE_USER' + + } +} diff --git a/ala-ws-plugin/.gitignore b/ala-ws-plugin/.gitignore new file mode 100644 index 00000000..64312cee --- /dev/null +++ b/ala-ws-plugin/.gitignore @@ -0,0 +1,16 @@ +*.iml +.classpath +.idea/ +.project +coverage/ +target/ +out/ +.DS_Store + +*.zip +*.sha1 +plugin.xml +web-app/ + +.gradle/ +build/ diff --git a/ala-ws-plugin/.travis.yml b/ala-ws-plugin/.travis.yml new file mode 100644 index 00000000..d5484bb2 --- /dev/null +++ b/ala-ws-plugin/.travis.yml @@ -0,0 +1,26 @@ +language: groovy +jdk: +- openjdk11 +sudo: false +branches: + only: + - master + - develop + - grails3 + - 1.5.x + - /^feature.*$/ +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +cache: + directories: + - $HOME/.m2 + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ +after_success: + - '[ "${TRAVIS_PULL_REQUEST}" = "false" ] && ./gradlew clean && travis_retry ./gradlew publish' +env: + global: + - JAVA_TOOL_OPTIONS=-Dhttps.protocols=TLSv1.2 + - secure: Wz5z+3RtS1H2uOUvT6ZQm0lxwNvR2PSgsTak99nGHVqrfpzQWXy0NcuoO309WrcfHvLHkVzjzfIBrkECH+l+8gPs7K0mElxLt1PMC/kYD+Tw/Yv6mj3ZCOb+NT2GYBbBDDHysMgFk4WZ4oGBK6jvgiThW+5MECwZ4Fo7pQpLbxo5pe2kPjRQ26fzef89eNHb7oTfUE2l26PSR/ndj5xkyeS5U1YtCDJVQIJal8nZ7RMd74c7NpuepxF/AenJPtOSKREwWETlaWnnwoFsxNOJpNEzxFalkH2R1fpT9AFU/D+1Egub3C+FVHalFNb/njHD07ui4nE8G61/AGk/c+VXW0qbqL58ZZWzCrBHTVFEqYWjsCke39lz58NkJ8j9TxVten4XFCUgb/oPA7N2KvIqHAWtQPPYGg3vojnXZSvihMQ0K7S5A/j+7lH2eALGUiif9pHeHDZFaGhG7yIwroW264fXs01G8bD1inVwrsukq7T52+K9sDuFu9suPbD4CNVobpuaI/qLNzkWxo9hWx/8MHPIKIsU7BtAs/kn6KZypneNZzkY+wpWCWmMURtdGF9lpjDY6Oc0lgGGhWh9koeS+iWYGSWtxNXcPb7fEKdk15dACRX4vvDZ2hmvKzVFe0djn+7rlieoTAr9UinciDsdRjdVLEFuct8DN3xOcx+N9d8= + - secure: F22yWqwhD1Q7hG/LQibxHRHuCuQHzV9yyj0Br9ot43x8GMAm+2OUmOUpXDXP1/4fzM9B6Qt9329Yrph2WX9lBgpmcwyCORNl2JISYgpxZEy3txJvjY3CJV7YJwQp+BABHZi4T9e+mR2IsDN7QKhbFFTFIv1c7G5B6GSgKVrWwuK8PNx+yG44kDvvls7TG2LFeGOAWzi2T9X0Y1KTCpDyxbuNtfKQ6bVuMk1Y/WvXooA1P3fZE554rU9eSI2DRzia8rtInt6IwQ1O/m/sScZLt9SwlseDaO3tBRBdm/E7k3plH74BuwlPyiN36U5WBhcHLPPABGqKmr3TNs4F8SffxoD2t7aGzY88th7AxtokbuirZZTBr9LZe6V89gXPkZaV0ylg6fl5xYZsN+VGBlkP/8cth56f4iAq8Qy/GSHzRFJxjGuIgmgHtoWhI+BqSgXHn+u3KNofRx6arlPMuJyEp0rJIsznQuoKefjYyJxb+vfJ9PI6xPTTy0/dR9qeu96DM5gRXRwyOG83y95b5bLuWQCCBVm4Y0sGHct7hCjDsOsV0WqGpe9e2NjkxFIycEnWrEeZm6qno47IbOPaKambZZ0TjfwTvz44yVq0842fGtU2ane4PkfSm8G39vmobMcCntx6VfKvQeYjHnXOX4Ql6HiHKYrEIy78UqXZm/gh2M4= diff --git a/ala-ws-plugin/README.md b/ala-ws-plugin/README.md new file mode 100644 index 00000000..f1f0c74d --- /dev/null +++ b/ala-ws-plugin/README.md @@ -0,0 +1,171 @@ +# ala-ws-plugin +Grails plugin containing common REST and general webservice functionality + +# Status +[![Build Status](https://travis-ci.org/AtlasOfLivingAustralia/ala-ws-plugin.svg?branch=master)](https://travis-ci.org/AtlasOfLivingAustralia/ala-ws-plugin) + + +# Usage + +``` +compile ":ala-ws-plugin:x.y.z" +``` + +Note: this plugin MUST be declared as a *compile* time dependency. + +### Logging Config + +If you want to see the raw data that is sent, add the following lines to your Config.groovy file's log4j config: + +``` +debug "org.apache.http.wire", + "org.apache.http.headers" +``` + +## The WebService class + +This is intended as a common replacement for all application-specific implementations of the WebService class. It supports the following functionality: + +Automatic injection of security tokens onto outoing requests. Use the `webservice.jwt` property to enable reading the auth token from a user's profile. This requires the OIDC support of the `ala-auth` plugin to be enabled to work. Leaving `webservice.jwt` false will revert to using the legacy api key behaviour. + +See the groovydoc for API documentation. + +``` +// Inject the WebService class +WebService webService + +... +// Invoke a service using the HTTP verb (get, post, put, delete) +webService.post(...) +``` + +All operations return a Map with the following structure: + +For JSON request types: +```[statusCode: int, resp: [:]]``` on success, where ```resp``` is a Map containing the JSON response object, or ```[statusCode: int, error: string]``` on error, where ```error``` is the error message or HTTP status message. + + +## Request Validation + +The Grails recommended way to validate request parameters is to use either a Domain or a Command object, and implement the ```static constraints = {...}``` closure. E.g. + +``` +class MyController { + def action1(ActionCommand command) { + if (command.hasErrors()) { + response.status = HttpStatus.SC_BAD_REQUEST + response.sendError(HttpStatus.SC_BAD_REQUEST, command.errors.join(";")) + } else { + // do stuff + } + } +} + +@grails.validation.Validateable +class ActionCommand { + String param1 + String param2 + + static constraints = { + param1 nullable: false + params2 minSize: 6 + } +} +``` + +A more generalised validation approach is to use the [Bean Validation](http://beanvalidation.org/) standard. + +This plugin provides a basic implementation of a grails filter that will validate requests using JSR-303 annotations. +Any validation errors will result in a HTTP 400 (BAD_REQUEST). This pulls the validation code and the subsequent error +handling out of the controller, allowing you to just annotate your actions and otherwise ignore validation. E.g. + +``` +class MyController { + def action1(@NotNull String param1, @Size(min = 6) String param2) { + // do stuff + } +``` +This is equivalent in functionality to the Command Object example above, except you'll get a better error message. + +## Notes + +* bean validation support is only available for action _methods_. Action closures are NOT supported (because they are not the recommended way to implement controller actions). +* to use bean validation for method arguments, you MUST specify the argument type. If you do not, the type will default to 'Object' and you'll get an error like ```No validator could be found for type: java.lang.Object```. + +## Supported constraints + +See [the JavaEE 6 doco](http://docs.oracle.com/javaee/6/api/javax/validation/constraints/package-summary.html) for a +list of available annotations. + + +### Custom constraints + +As per the bean validation spec, any validation constraint annotation (including custom annotations, as long as the annotation is itself annotated with @Constraint meta-annotation) can be used to validate the request parameters. + +In addition to the JavaEE 6 core constraints, this plugin provides a number of custom constraints in the ```au.org.ala.ws.validation.constraints``` package, such as + +* UUID - performs a regex pattern match to ensure the parameter is a valid UUID + +# External configuration properties + +* ```webservice.jwt``` Set to true if using OIDC login and you want to use the access token as a bearer token instead of sending a legacy apikey +* ```webservice.client-id``` Set the client id when requesting a client credentials grant JWT from the OIDC provider. This only applies to client credentials grants. +* ```webservice.client-secret``` Set the client secret when requesting a client credentials grant JWT from the OIDC provider. This only applies to client credentials grants. +* ```webservice.jwt-scopes``` Set to a space separated list of scopes to request when requesting a client credentials grant JWT from the OIDC provider. This only applies to client credentials grants. If there is already a user profile in the session, the scopes that apply to their access token will apply instead. +* ```webservice.jwt-include-legacy-headers``` Set to true to send legacy apikey headers in addition to JWTs, requires `webservice.jwt` is true. Defaults to true. +* ```webservice.cache-tokens``` Set to true to cache and re-use client credential tokens and refresh tokens. Set to false to request a new client credentials token each time. Defaults to true. +* ```webservice.apiKey``` The ALA api key to be included in each request (in the ```apiKey``` header field) when ```includeApiKey = true```. API Keys are intended to be used with the [ALA WS Security Plugin](https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin). +* ```webservice.apiKeyHeader``` Override the default name of the apiKey header. This applied to all service calls - if you need to change the name for a single service, then you'll need to pass in the api key in a custom header via the API. +* ```webservice.connect.timeout``` The connect timeout setting for all web service requests (default is 5 minutes). +* ```webservice.read.timeout``` The read timeout setting for all web service requests (default is 5 minutes). +* ```webservice.socket.timeout``` The socket timeout setting for all web service requests (default is 5 minutes). +* ```app.http.header.userId``` The header name for the ALA user details (used by the auth framework). Defaults to X-ALA-userId. + +# Traits & Base classes + +## BasicWSController + +Provides convenience methods for sending common errors (not found, bad request, etc), and for handling the response from the ```au.ala.org.service.WebService``` class. + +E.g. + +``` +class MyController implements BasicWSController { + WebService webService + + def getSomething() { + handleWSResponse webService.get(...) + } + + def lookup(String id) { + MyEntity m = MyEntity.findById(id) + + if (m) { + ... + } else { + notFound "No record was found with id ${id}" + } + } +} +``` + +# Integration Testing + +This plugin uses [Ratpack](http://ratpack.io) to set up an embedded http server for the tests. Ratpack makes doing this extremely easy, and allows us to write integration tests which invoke real http services so we can check that the WebService class has set the appropriate headers, cookies, etc. + +Ratpack requires Java 1.8, so if you are modifying this plugin, be sure to use jdk8. + +# Dev environment set up + +1. Clone the repo +1. Import the source into your IDE +1. Use Grails version 4.0.11 +1. Use JDK 1.11 + +To test changes locally, set the plugin as a local plugin on a grails application: + +1. In the host application's BuildConfig.groovy + 1. Comment out (if present) the existing dependency on ala-ws-plugin + 1. Add ```grails.plugin.location.ala-ws-plugin = "/path/to/local/ala-ws-plugin"``` + + diff --git a/ala-ws-plugin/build.gradle b/ala-ws-plugin/build.gradle new file mode 100644 index 00000000..82e5744e --- /dev/null +++ b/ala-ws-plugin/build.gradle @@ -0,0 +1,169 @@ +buildscript { + repositories { + mavenLocal() + maven { url "https://nexus.ala.org.au/content/groups/public/" } + maven { url "https://repo.grails.org/grails/core" } + } + dependencies { + classpath "org.grails:grails-gradle-plugin:$grailsVersion" +// classpath "com.bertramlabs.plugins:asset-pipeline-gradle:3.3.4" + } +} + +group "org.grails.plugins" + +apply plugin:"eclipse" +apply plugin:"idea" +apply plugin:"org.grails.grails-plugin" +apply plugin:"org.grails.grails-gsp" +//apply plugin:"asset-pipeline" +apply plugin:"maven-publish" + +sourceCompatibility = 1.11 +targetCompatibility = 1.11 + +repositories { + mavenLocal() + maven { url "https://nexus.ala.org.au/content/groups/public/" } + maven { url "https://repo.grails.org/grails/core" } + mavenCentral() +} + +configurations { + developmentOnly + testImplementation { + exclude group: 'ch.qos.logback', module: 'logback-classic' + } + runtimeClasspath { + extendsFrom developmentOnly + } +} + +dependencies { + developmentOnly("org.springframework.boot:spring-boot-devtools") + implementation "org.springframework.boot:spring-boot-starter-logging" + implementation "org.springframework.boot:spring-boot-autoconfigure" + implementation "org.grails:grails-core" + implementation "org.springframework.boot:spring-boot-starter-actuator" + implementation "org.springframework.boot:spring-boot-starter-tomcat" + implementation "org.grails:grails-web-boot" + implementation "org.grails:grails-logging" + implementation "org.grails:grails-plugin-rest" + implementation "org.grails:grails-plugin-databinding" + implementation "org.grails:grails-plugin-i18n" + implementation "org.grails:grails-plugin-services" + implementation "org.grails:grails-plugin-url-mappings" + implementation "org.grails:grails-plugin-interceptors" + implementation "org.grails.plugins:cache" + implementation "org.grails.plugins:async" + implementation "org.grails.plugins:scaffolding" + implementation "org.grails.plugins:gsp" + compileOnly "io.micronaut:micronaut-inject-groovy" + console "org.grails:grails-console" + profile "org.grails.profiles:web-plugin" +// runtime "com.bertramlabs.plugins:asset-pipeline-grails:3.3.4" + testImplementation "io.micronaut:micronaut-inject-groovy" + testImplementation "org.grails:grails-gorm-testing-support" + testImplementation "org.mockito:mockito-core" + testImplementation "org.grails:grails-web-testing-support" + + // Grails plugin dependencies + implementation 'org.grails.plugins:http-builder-helper:1.1.0' + implementation('org.codehaus.groovy.modules.http-builder:http-builder:0.7.1') { + exclude module: "commons-logging" + exclude module: "groovy" + } + implementation project(':ala-auth') + implementation project(':userdetails-service-client') + implementation(pac4j.oidc) + implementation(pac4j.jee) + + // Regular JAR dependencies + implementation "org.apache.httpcomponents:httpmime:4.5.3" + implementation "javax.validation:validation-api:1.1.0.Final" + implementation "javax.el:javax.el-api:2.2.4" + + implementation 'org.glassfish.web:javax.el:2.2.6' + implementation "org.hibernate:hibernate-validator:5.1.3.Final" + implementation "org.hibernate:hibernate-validator-annotation-processor:5.1.3.Final" + + testImplementation 'uk.org.lidalia:slf4j-test:1.2.0' + + testImplementation ("io.ratpack:ratpack-core:1.9.0") { + exclude group: 'io.ratpack', module: 'ratpack-guice' + } + testImplementation "io.ratpack:ratpack-test:1.2.0" + testImplementation ("io.ratpack:ratpack-groovy:1.2.0") { + exclude group: 'io.ratpack', module: 'ratpack-guice' + } + testImplementation "io.ratpack:ratpack-groovy-test:1.2.0" +} + +compileGroovy { + groovyOptions.javaAnnotationProcessing = true +} + +tasks.withType(GroovyCompile) { + configure(groovyOptions) { + forkOptions.jvmArgs = ['-Xmx1024m'] + } +} + +compileJava.dependsOn(processResources) + +bootRun { + ignoreExitValue true + jvmArgs( + '-Dspring.output.ansi.enabled=always', + '-noverify', + '-XX:TieredStopAtLevel=1', + '-Xmx1024m') + sourceResources sourceSets.main + String springProfilesActive = 'spring.profiles.active' + systemProperty springProfilesActive, System.getProperty(springProfilesActive) +} + +tasks.withType(Test) { + useJUnitPlatform() +} +// enable if you wish to package this plugin as a standalone application +bootJar.enabled = false + +publishing { + repositories { + maven { + name 'Nexus' + url "https://nexus.ala.org.au/content/repositories/${project.version.endsWith('-SNAPSHOT') ? 'snapshots' : 'releases' }" + credentials { + username = System.getenv('TRAVIS_DEPLOY_USERNAME') + password = System.getenv('TRAVIS_DEPLOY_PASSWORD') + } + } + } + publications { + maven(MavenPublication) { + from components.java + artifact sourcesJar + artifact javadocJar + + pom { + name = 'ALA WS Plugin' + description = 'Plugin for invoking ALA web services' + url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-plugin' + licenses { + license { + name = 'MPL-1.1' + url = 'https://www.mozilla.org/en-US/MPL/1.1/' + } + } + developers { + } + scm { + connection = 'scm:git:git://github.com/AtlasOfLivingAustralia/ala-ws-plugin.git' + developerConnection = 'scm:git:ssh://github.com:AtlasOfLivingAustralia/ala-ws-plugin.git' + url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-plugin/tree/main' + } + } + } + } +} diff --git a/ala-ws-plugin/gradle.properties b/ala-ws-plugin/gradle.properties new file mode 100644 index 00000000..c46c810d --- /dev/null +++ b/ala-ws-plugin/gradle.properties @@ -0,0 +1,8 @@ +#Tue Jul 18 18:40:44 AEST 2017 +grailsVersion=5.2.1 +grailsGradlePluginVersion=5.2.1 +groovyVersion=3.0.11 +gorm.version=7.3.2 +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1024M \ No newline at end of file diff --git a/ala-ws-plugin/gradle/wrapper/gradle-wrapper.jar b/ala-ws-plugin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..deedc7fa Binary files /dev/null and b/ala-ws-plugin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ala-ws-plugin/gradle/wrapper/gradle-wrapper.properties b/ala-ws-plugin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..13aebb6b --- /dev/null +++ b/ala-ws-plugin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Jul 18 16:24:28 AEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/ala-ws-plugin/gradlew b/ala-ws-plugin/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/ala-ws-plugin/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/ala-ws-plugin/gradlew.bat b/ala-ws-plugin/gradlew.bat new file mode 100755 index 00000000..aec99730 --- /dev/null +++ b/ala-ws-plugin/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ala-ws-plugin/grails-app/conf/application.yml b/ala-ws-plugin/grails-app/conf/application.yml new file mode 100644 index 00000000..67c0a365 --- /dev/null +++ b/ala-ws-plugin/grails-app/conf/application.yml @@ -0,0 +1,23 @@ +--- +grails: + profile: web-plugin + codegen: + defaultPackage: au.org.ala.ws +info: + app: + name: '@info.app.name@' + version: '@info.app.version@' + grailsVersion: '@info.app.grailsVersion@' + + +--- +# configuration for plugin testing - will not be included in the plugin zip +security: + cas: + service: 'http://localhost:8080' + +environments: + test: + security: + cas: + service: 'http://localhost:8080' diff --git a/grails-app/conf/logback.groovy b/ala-ws-plugin/grails-app/conf/logback.groovy similarity index 100% rename from grails-app/conf/logback.groovy rename to ala-ws-plugin/grails-app/conf/logback.groovy diff --git a/ala-ws-plugin/grails-app/conf/plugin.yml b/ala-ws-plugin/grails-app/conf/plugin.yml new file mode 100644 index 00000000..05c6363d --- /dev/null +++ b/ala-ws-plugin/grails-app/conf/plugin.yml @@ -0,0 +1,5 @@ +webservice: + jwt: false + jwt-include-legacy-headers: true + cache-tokens: true + scopes: openid \ No newline at end of file diff --git a/ala-ws-plugin/grails-app/controllers/au/org/ala/ws/WSInterceptor.groovy b/ala-ws-plugin/grails-app/controllers/au/org/ala/ws/WSInterceptor.groovy new file mode 100644 index 00000000..88c46b65 --- /dev/null +++ b/ala-ws-plugin/grails-app/controllers/au/org/ala/ws/WSInterceptor.groovy @@ -0,0 +1,90 @@ +package au.org.ala.ws + +import org.apache.http.HttpStatus + +import javax.validation.ConstraintViolation +import java.lang.reflect.Method +import java.lang.annotation.Annotation +import au.org.ala.ws.validation.ValidatedParameter +import javax.validation.Validation +import javax.validation.ValidatorFactory +import javax.validation.executable.ExecutableValidator +import java.util.concurrent.ConcurrentHashMap + +class WSInterceptor { + + static ValidatorFactory factory = Validation.buildDefaultValidatorFactory() + static ExecutableValidator validator = factory.getValidator().forExecutables() + static Map dummyControllers = [:] as ConcurrentHashMap + + WSInterceptor() { + matchAll() + } + + /** + * Executed before a matched action + * + * @return Whether the action should continue and execute + */ + boolean before() { + def controller = grailsApplication.getArtefactByLogicalPropertyName("Controller", controllerName) + Class controllerClass = controller?.clazz + + // grails url mapping like "/home controller: 'home'" will result in a null actionName but maps to index + String methodName = actionName ?: "index" + + // the following works because action methods cannot be overloaded in Grails Controllers - therefore we + // will only ever have 1 declared method of a given name with parameters (grails will auto generate a no-arg + // version of the method, but we're only interested in the version with params + Method method = controllerClass?.getDeclaredMethods()?.find { + it.name == methodName && it.parameterTypes?.length > 0 + } + + if (method) { + List validators = method.getParameterAnnotations()*.find { + it instanceof ValidatedParameter + }?.flatten()?.findResults { it } + + if (validators) { + List parameterValues = [] + validators.each { + if (it) { + String paramString = params.containsKey(it.paramName()) ? params[it.paramName()] : null + parameterValues << (paramString ? paramString.asType(it.paramType()) : null) + } + } + + // We need to validate against a dummy instance of the concrete controller class, because the + // instance available to the filter is a DefaultGrailsControllerClass, which is not actually + // validateable (fails the hasConstraints check in the validator implementation) + def dummyControllerImpl = dummyControllers[controllerClass] + if (!dummyControllerImpl) { + dummyControllerImpl = controllerClass.newInstance() + dummyControllers.put(controllerClass, dummyControllerImpl) + } + + Set violations = validator.validateParameters(dummyControllerImpl, method, parameterValues as Object[]) + if (violations) { + WSInterceptor.log.debug("Request validation failed: ${violations}") + response.status = HttpStatus.SC_BAD_REQUEST + response.sendError(HttpStatus.SC_BAD_REQUEST, "Request validation failed: ${violations*.message.join("; ")}") + } + + return violations == null || violations.isEmpty() + } + } + return true + } + + /** + * Executed after the action executes but prior to view rendering + * + * @return True if view rendering should continue, false otherwise + */ + boolean after() { true } + + /** + * Executed after view rendering completes + */ + void afterView() {} +} diff --git a/ala-ws-plugin/grails-app/controllers/au/org/ala/ws/controller/BasicWSController.groovy b/ala-ws-plugin/grails-app/controllers/au/org/ala/ws/controller/BasicWSController.groovy new file mode 100644 index 00000000..40c8a12d --- /dev/null +++ b/ala-ws-plugin/grails-app/controllers/au/org/ala/ws/controller/BasicWSController.groovy @@ -0,0 +1,71 @@ +package au.org.ala.ws.controller + +import grails.converters.JSON + +import static org.apache.http.HttpStatus.SC_BAD_REQUEST +import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR +import static org.apache.http.HttpStatus.SC_NOT_FOUND +import static org.apache.http.HttpStatus.SC_OK +import static org.apache.http.HttpStatus.SC_UNAUTHORIZED + +abstract class BasicWSController { + static final String CONTENT_TYPE_JSON = "application/json" + + protected notFound = { String message = null -> + sendError(SC_NOT_FOUND, message ?: "") + } + + protected badRequest = { String message = null -> + sendError(SC_BAD_REQUEST, message ?: "") + } + + protected notAuthorised = { String message = null -> + sendError(SC_UNAUTHORIZED, message ?: "You do not have permission to perform the requested action.") + } + + /** + * Renders the provided Map as a JSON response with status code 200 + * + * @param resp The map to render as JSON data on the response + */ + protected success = { resp -> + response.status = SC_OK + response.setContentType(CONTENT_TYPE_JSON) + render resp as JSON + } + + protected saveFailed = { + sendError(SC_INTERNAL_SERVER_ERROR) + } + + protected sendError = { int status, String msg = null -> + response.status = status + response.sendError(status, msg) + } + + /** + * Renders the WS response structure (see ala.org.au.ws.service.WebService) as JSON, or sends a HTTP error if resp.status is not in the 2xx range. + * + * @param resp response structure as returned by the ala.org.au.ws.service.WebService class + */ + protected handleWSResponse(Map resp) { + if (resp) { + if (!isSuccessful(resp.statusCode)) { + log.debug "Response status ${resp.statusCode} returned from operation" + sendError(resp.statusCode, resp.error ?: "") + } else { + response.status = resp.statusCode + response.setContentType(CONTENT_TYPE_JSON) + render((resp.resp ?: [:]) as JSON) + } + } else { + response.setContentType(CONTENT_TYPE_JSON) + render [:] as JSON + } + } + + /** Returns true for HTTP status codes from 200 to 299 */ + protected isSuccessful(int statusCode) { + return statusCode >= SC_OK && statusCode <= 299 + } +} \ No newline at end of file diff --git a/ala-ws-plugin/grails-app/i18n/ValidationMessages.properties b/ala-ws-plugin/grails-app/i18n/ValidationMessages.properties new file mode 100644 index 00000000..821b90ab --- /dev/null +++ b/ala-ws-plugin/grails-app/i18n/ValidationMessages.properties @@ -0,0 +1 @@ +au.org.ala.ws.validation.constraints.UUID.message=is not a valid UUID \ No newline at end of file diff --git a/ala-ws-plugin/grails-app/init/au/org/ala/ws/Application.groovy b/ala-ws-plugin/grails-app/init/au/org/ala/ws/Application.groovy new file mode 100644 index 00000000..0f6910a6 --- /dev/null +++ b/ala-ws-plugin/grails-app/init/au/org/ala/ws/Application.groovy @@ -0,0 +1,12 @@ +package au.org.ala.ws + +import grails.boot.* +import grails.boot.config.GrailsAutoConfiguration +import grails.plugins.metadata.* + +@PluginSource +class Application extends GrailsAutoConfiguration { + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} \ No newline at end of file diff --git a/ala-ws-plugin/grails-app/init/au/org/ala/ws/BootStrap.groovy b/ala-ws-plugin/grails-app/init/au/org/ala/ws/BootStrap.groovy new file mode 100644 index 00000000..94667ae1 --- /dev/null +++ b/ala-ws-plugin/grails-app/init/au/org/ala/ws/BootStrap.groovy @@ -0,0 +1,9 @@ +package au.org.ala.ws + +class BootStrap { + + def init = { servletContext -> + } + def destroy = { + } +} diff --git a/ala-ws-plugin/grails-app/services/au/org/ala/ws/service/WebService.groovy b/ala-ws-plugin/grails-app/services/au/org/ala/ws/service/WebService.groovy new file mode 100644 index 00000000..2bb4ffe3 --- /dev/null +++ b/ala-ws-plugin/grails-app/services/au/org/ala/ws/service/WebService.groovy @@ -0,0 +1,546 @@ +package au.org.ala.ws.service + +import au.org.ala.web.AuthService +import au.org.ala.web.UserDetails +import au.org.ala.ws.tokens.TokenService +import com.google.common.net.HttpHeaders +import grails.converters.JSON +import groovyx.net.http.ContentType as GContentType +import groovyx.net.http.HTTPBuilder +import groovyx.net.http.Method +import groovyx.net.http.ParserRegistry +import org.apache.http.HttpEntity +import org.apache.http.HttpResponse +import org.apache.http.HttpStatus +import org.apache.http.client.config.RequestConfig +import org.apache.http.entity.AbstractHttpEntity +import org.apache.http.entity.ContentType +import org.apache.http.entity.StringEntity +import org.apache.http.entity.mime.HttpMultipartMode +import org.apache.http.entity.mime.MultipartEntityBuilder +import org.apache.http.entity.mime.content.ByteArrayBody +import org.apache.http.entity.mime.content.FileBody +import org.apache.http.entity.mime.content.InputStreamBody +import org.apache.http.entity.mime.content.StringBody +import org.grails.web.json.JSONElement +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.multipart.MultipartFile + +import javax.servlet.http.HttpServletResponse +import java.nio.charset.Charset + +import static grails.web.http.HttpHeaders.AUTHORIZATION +import static grails.web.http.HttpHeaders.CONNECTION +import static grails.web.http.HttpHeaders.CONTENT_DISPOSITION +import static groovyx.net.http.Method.* + +class WebService { + static final String CHAR_ENCODING = "UTF-8" + static final Charset UTF_8 = Charset.forName(CHAR_ENCODING) + + static final int DEFAULT_TIMEOUT_MILLIS = 600000 // five minutes + static final String DEFAULT_AUTH_HEADER = "X-ALA-userId" + static final String DEFAULT_API_KEY_HEADER = "apiKey" + + static { + ParserRegistry.setDefaultCharset(CHAR_ENCODING) + } + + def grailsApplication + AuthService authService + @Autowired + TokenService tokenService + + /** + * Sends an HTTP GET request to the specified URL. The URL must already be URL-encoded (if necessary). + * + * Note: by default, the Accept header will be set to the same content type as the ContentType provided. To override + * this default behaviour, include an 'Accept' header in the 'customHeaders' parameter. + * + * @param url The url-encoded URL to send the request to + * @param params Map of parameters to be appended to the query string. Parameters will be URL-encoded automatically. + * @param contentType the desired content type for the request. Defaults to application/json + * @param includeApiKey true to include the service's API Key in the request headers (uses property 'service.apiKey'). If using JWTs, instead sends a JWT Bearer tokens Default = true. + * @param includeUser true to include the userId and email in the request headers and the ALA-Auth cookie. If using JWTs sends the current user's access token, if false only sends a ClientCredentials grant token for this apps client id Default = true. + * @param customHeaders Map of [headerName:value] for any extra HTTP headers to be sent with the request. Default = [:]. + * @return [statusCode: int, resp: [:]] on success, or [statusCode: int, error: string] on error + */ + Map get(String url, Map params = [:], ContentType contentType = ContentType.APPLICATION_JSON, boolean includeApiKey = true, boolean includeUser = true, Map customHeaders = [:]) { + send(GET, url, params, contentType, null, null, includeApiKey, includeUser, customHeaders) + } + + /** + * Sends an HTTP PUT request to the specified URL. The URL must already be URL-encoded (if necessary). + * + * Note: by default, the Accept header will be set to the same content type as the ContentType provided. To override + * this default behaviour, include an 'Accept' header in the 'customHeaders' parameter. + * + * The body map will be sent as the JSON body of the request (i.e. use request.getJSON() on the receiving end). + * + * @param url The url-encoded url to send the request to + * @param body Map containing the data to be sent as the post body + * @param params Map of parameters to be appended to the query string. Parameters will be URL-encoded automatically. + * @param contentType the desired content type for the request. Defaults to application/json + * @param includeApiKey true to include the service's API Key in the request headers (uses property 'service.apiKey'). If using JWTs, instead sends a JWT Bearer tokens Default = true. + * @param includeUser true to include the userId and email in the request headers and the ALA-Auth cookie. If using JWTs sends the current user's access token, if false only sends a ClientCredentials grant token for this apps client id Default = true. + * @param customHeaders Map of [headerName:value] for any extra HTTP headers to be sent with the request. Default = [:]. + * @return [statusCode: int, resp: [:]] on success, or [statusCode: int, error: string] on error + */ + Map put(String url, Map body, Map params = [:], ContentType contentType = ContentType.APPLICATION_JSON, boolean includeApiKey = true, boolean includeUser = true, Map customHeaders = [:]) { + send(PUT, url, params, contentType, body, null, includeApiKey, includeUser, customHeaders) + } + + /** + * Sends an HTTP POST request to the specified URL. The URL must already be URL-encoded (if necessary). + * + * Note: by default, the Accept header will be set to the same content type as the ContentType provided. To override + * this default behaviour, include an 'Accept' header in the 'customHeaders' parameter. + * + * The body map will be sent as the body of the request (i.e. use request.getJSON() on the receiving end). + * + * @param url The url-encoded url to send the request to + * @param body Map containing the data to be sent as the post body + * @param params Map of parameters to be appended to the query string. Parameters will be URL-encoded automatically. + * @param contentType the desired content type for the request. Defaults to application/json + * @param includeApiKey true to include the service's API Key in the request headers (uses property 'service.apiKey'). If using JWTs, instead sends a JWT Bearer tokens Default = true. + * @param includeUser true to include the userId and email in the request headers and the ALA-Auth cookie. If using JWTs sends the current user's access token, if false only sends a ClientCredentials grant token for this apps client id Default = true. + * @param customHeaders Map of [headerName:value] for any extra HTTP headers to be sent with the request. Default = [:]. + * @return [statusCode: int, resp: [:]] on success, or [statusCode: int, error: string] on error + */ + Map post(String url, Map body, Map params = [:], ContentType contentType = ContentType.APPLICATION_JSON, boolean includeApiKey = true, boolean includeUser = true, Map customHeaders = [:]) { + send(POST, url, params, contentType, body, null, includeApiKey, includeUser, customHeaders) + } + + /** + * Sends a multipart HTTP POST request to the specified URL. The URL must already be URL-encoded (if necessary). + * + * Note: by default, the Accept header will be set to the same content type as the ContentType provided. To override + * this default behaviour, include an 'Accept' header in the 'customHeaders' parameter. + * + * Each item in the body map will be sent as a separate Part in the Multipart Request. To send the entire map as a + * single part, you will need too use the format [data: body]. + * + * Files can be one of the following types: + *
    + *
  • byte[]
  • + *
  • CommonsMultipartFile
  • + *
  • InputStream
  • + *
  • File
  • + *
  • Anything that supports the .bytes accessor
  • + *
+ * + * @param url The url-encoded url to send the request to + * @param body Map containing the data to be sent as the post body + * @param params Map of parameters to be appended to the query string. Parameters will be URL-encoded automatically. + * @param files List of 0 or more files to be included in the multipart request (note: if files is null, then the request will NOT be multipart) + * @param partContentType the desired content type for the request PARTS (the request itself will always be sent as multipart/form-data). Defaults to application/json. All non-file parts will have the same content type. + * @param includeApiKey true to include the service's API Key in the request headers (uses property 'service.apiKey'). If using JWTs, instead sends a JWT Bearer tokens Default = true. + * @param includeUser true to include the userId and email in the request headers and the ALA-Auth cookie. If using JWTs sends the current user's access token, if false only sends a ClientCredentials grant token for this apps client id Default = true. + * @param customHeaders Map of [headerName:value] for any extra HTTP headers to be sent with the request. Default = [:]. + * @return [statusCode: int, resp: [:]] on success, or [statusCode: int, error: string] on error + */ + Map postMultipart(String url, Map body, Map params = [:], List files = [], ContentType partContentType = ContentType.APPLICATION_JSON, boolean includeApiKey = true, boolean includeUser = true, Map customHeaders = [:]) { + send(POST, url, params, partContentType, body, files, includeApiKey, includeUser, customHeaders) + } + + /** + * Sends a HTTP DELETE request to the specified URL. The URL must already be URL-encoded (if necessary). + * + * Note: by default, the Accept header will be set to the same content type as the ContentType provided. To override + * this default behaviour, include an 'Accept' header in the 'customHeaders' parameter. + * + * @param url The url-encoded url to send the request to + * @param params Map of parameters to be appended to the query string. Parameters will be URL-encoded automatically. + * @param contentType the desired content type for the request. Defaults to application/json + * @param includeApiKey true to include the service's API Key in the request headers (uses property 'service.apiKey'). If using JWTs, instead sends a JWT Bearer tokens Default = true. + * @param includeUser true to include the userId and email in the request headers and the ALA-Auth cookie. If using JWTs sends the current user's access token, if false only sends a ClientCredentials grant token for this apps client id Default = true. + * @param customHeaders Map of [headerName:value] for any extra HTTP headers to be sent with the request. Default = [:]. + * @return [statusCode: int, resp: [:]] on success, or [statusCode: int, error: string] on error + */ + Map delete(String url, Map params = [:], ContentType contentType = ContentType.APPLICATION_JSON, boolean includeApiKey = true, boolean includeUser = true, Map customHeaders = [:]) { + send(DELETE, url, params, contentType, null, null, includeApiKey, includeUser, customHeaders) + } + + /** + * Proxies a request URL but doesn't assume the response is text based. + * + * Used for operations like proxying a download request from one application to another. + * + * @param response The HttpServletResponse of the calling request: the response from the proxied request will be written to this object + * @param url The URL of the service to proxy to + * @param includeApiKey true to include the service's API Key in the request headers (uses property 'service.apiKey'). If using JWTs, instead sends a JWT Bearer tokens Default = true. + * @param includeUser true to include the userId and email in the request headers and the ALA-Auth cookie. If using JWTs sends the current user's access token, if false only sends a ClientCredentials grant token for this apps client id Default = true. + */ + void proxyGetRequest(HttpServletResponse response, String url, boolean includeApiKey = true, boolean includeUser = true) { + log.debug("Proxying GET request to ${url}") + HttpURLConnection conn = (HttpURLConnection) configureConnection(url, includeApiKey, includeUser) + conn.useCaches = false + + try { + conn.setRequestProperty(CONNECTION, 'close') // disable Keep Alive + + conn.connect() + + response.contentType = conn.contentType + int contentLength = conn.contentLength + if (contentLength != -1) { + response.contentLength = contentLength + } + + List headers = [CONTENT_DISPOSITION] + headers.each { header -> + String headerValue = conn.getHeaderField(header) + if (headerValue) { + response.setHeader(header, headerValue) + } + } + response.status = conn.responseCode + conn.inputStream.withStream { response.outputStream << it } + } finally { + conn.disconnect() + } + } + + /** + * Proxies a request URL with post data but doesn't assume the response is text based. + * + * @param response The HttpServletResponse of the calling request: the response from the proxied request will be written to this object + * @param url The URL of the service to proxy to + * @param postBody The POST data to send with the proxied request. If it is a Collection, then it will be converted to JSON, otherwise it will be sent as a String. + * @param contentType the desired content type for the request. Defaults to application/json. + * @param includeApiKey true to include the service's API Key in the request headers (uses property 'service.apiKey'). If using JWTs, instead sends a JWT Bearer tokens Default = true. + * @param includeUser true to include the userId and email in the request headers and the ALA-Auth cookie. If using JWTs sends the current user's access token, if false only sends a ClientCredentials grant token for this apps client id Default = true. + */ + void proxyPostRequest(HttpServletResponse response, String url, postBody, ContentType contentType = ContentType.APPLICATION_JSON, boolean includeApiKey = false, boolean includeUser = true, Map cookies = [:]) { + log.debug("Proxying POST request to ${url}") + + HttpURLConnection conn = (HttpURLConnection) configureConnection(url, includeApiKey, includeUser) + conn.useCaches = false + + try { + conn.setRequestMethod("POST") + conn.setRequestProperty(CONNECTION, 'close') // disable Keep Alive + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", contentType.toString()); + + cookies?.each { cookie, value -> + conn.setRequestProperty(cookie, value) + } + + OutputStreamWriter wr = new OutputStreamWriter(conn.getOutputStream(), CHAR_ENCODING) + if (contentType == ContentType.APPLICATION_JSON && postBody instanceof Collection) { + wr.write((postBody as JSON).toString()) + } else if (contentType == ContentType.APPLICATION_FORM_URLENCODED) { + String formData = postBody.inject([]) { result, entry -> + if (entry.value instanceof Collection || entry.value instanceof String[]) { + result << "${enc(entry.key)}=${enc(entry.value?.join(","))}" + } else { + result << "${enc(entry.key)}=${enc(entry.value?.toString())}" + } + }?.join("&") + wr.write(formData) + } else { + wr.write(postBody?.toString()) + } + wr.flush() + wr.close() + + response.contentType = conn.contentType + int contentLength = conn.contentLength + if (contentLength != -1) { + response.contentLength = contentLength + } + + List headers = [CONTENT_DISPOSITION] + headers.each { header -> + String headerValue = conn.getHeaderField(header) + if (headerValue) { + response.setHeader(header, headerValue) + } + } + response.status = conn.responseCode + response.outputStream << conn.inputStream + } finally { + conn.disconnect() + } + } + + private Map send(Method method, String url, Map params = [:], ContentType contentType = ContentType.APPLICATION_JSON, + Map body = null, List files = null, boolean includeApiKey = true, boolean includeUser = true, + Map customHeaders = [:]) { + log.debug("${method} request to ${url}") + + Map result = [:] + + try { + url = appendQueryString(url, params) + + HTTPBuilder http = newHttpBuilder(url, contentType) + + http.request(method, contentType) { request -> + configureRequestTimeouts(request) + configureRequestHeaders(headers, includeApiKey, includeUser, customHeaders) + + if (files != null) { + // NOTE: order is important - Content-Type MUST be set BEFORE the body + request.entity = constructMultiPartEntity(body, files, contentType) + } else if (body != null) { + // NOTE: order is important - Content-Type MUST be set BEFORE the body + delegate.contentType = contentType + delegate.body = body + } + + response.success = { resp, data -> + result.statusCode = resp.status + if (data instanceof InputStreamReader) { + result.resp = data.text + } else if (data instanceof List) { + // ensure an empty list is not converted to an empty object + result.resp = data + } else { + result.resp = data ?: [:] + } + } + response.failure = { resp -> + log.error("Request failed with response: ${resp?.entity?.content?.text}") + result.statusCode = resp.status + result.error = "Failed calling web service - service returned HTTP ${resp.status}" + } + } + } catch (Exception e) { + e.printStackTrace() + log.error("Failed sending ${method} request to ${url}", e) + result.statusCode = HttpStatus.SC_INTERNAL_SERVER_ERROR + result.error = "Failed calling web service. ${e.getClass()} ${e.getMessage()} URL= ${url}, method ${method}." + } + + result + } + + HTTPBuilder newHttpBuilder(String url, ContentType contentType) { + HTTPBuilder http = new HTTPBuilder(url, contentType) + // Since we're in a Grails context, let's use Grails JSON for encoding and decoding + final encoder = WebService.&encodeJSON + final decoder = WebService.&decodeJSON + http.encoder[GContentType.JSON] = encoder + http.encoder[ContentType.APPLICATION_JSON] = encoder + http.parser[GContentType.JSON] = decoder + http.parser[ContentType.APPLICATION_JSON] = decoder + // TODO XML + return http + } + + private static String appendQueryString(String url, Map params) { + if (params) { + url += url.contains("?") ? '&' : '?' + + + url += params.inject([]) { result, entry -> + result << "${enc(entry.key)}=${enc(entry.value?.toString())}" + }?.join("&") + } + + url + } + + private String getApiKey() { + grailsApplication.config.getProperty('webservice.apiKey') ?: null + } + + static String enc(String str) { + str ? URLEncoder.encode(str, CHAR_ENCODING) : "" + } + + private void configureRequestTimeouts(request) { + int connectTimeout = (grailsApplication.config.getProperty('webservice.connect.timeout') ?: DEFAULT_TIMEOUT_MILLIS) as int + int readTimeout = (grailsApplication.config.getProperty('webservice.read.timeout') ?: DEFAULT_TIMEOUT_MILLIS) as int + int socketTimeout = (grailsApplication.config.getProperty('webservice.socket.timeout') ?: DEFAULT_TIMEOUT_MILLIS) as int + + RequestConfig.Builder config = RequestConfig.custom() + config.setConnectTimeout(connectTimeout) + config.setSocketTimeout(socketTimeout) + config.setConnectionRequestTimeout(readTimeout) + + request?.config = config.build() + } + + private void configureRequestHeaders(Map headers, boolean includeApiKey = true, boolean includeUser = true, Map customHeaders = [:]) { + + UserDetails user + // We can only get the user id from the auth service if we are running in a http request. + // The Sprint RequestContextHolder's requestAttributes will be null if there is no request. + // The #currentRequestAttributes method, which is used by the authService, throws an IllegalStateException if + // there is no request, so we need to check if requestAttributes exist before trying to get the user details. + if (includeUser && RequestContextHolder.getRequestAttributes() != null) { + user = authService.userDetails() + } + + def userAgent = getUserAgent() + if (userAgent) { + headers.put(HttpHeaders.USER_AGENT, userAgent) + } + + includeAuthTokensInternal(includeUser, includeApiKey, user) { key, value -> + headers.put(key, value) + } + + if (customHeaders) { + headers.putAll(customHeaders) + } + } + + private static HttpEntity constructMultiPartEntity(Map parts, List files, ContentType partContentType = ContentType.APPLICATION_JSON) { + MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create() + entityBuilder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE) + + parts?.each { key, value -> + def val = partContentType == ContentType.APPLICATION_JSON && !(value instanceof net.sf.json.JSON) ? value as JSON : value + entityBuilder.addPart(key?.toString(), new StringBody((val) as String, partContentType)) + } + + files.eachWithIndex { it, index -> + if (it instanceof byte[]) { + entityBuilder.addPart("file${index}", new ByteArrayBody(it, "file${index}")) + } + // Grails 3.3 multipart file is instance of org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.StandardMultipartFile + // But StandardMultipartFile and CommonMultipartFile are both inherited from MultipartFile + else if (it instanceof MultipartFile) { + entityBuilder.addPart(it.originalFilename, new InputStreamBody(it.inputStream, it.contentType, it.originalFilename)) + } else if (it instanceof InputStream) { + entityBuilder.addPart("file${index}", new InputStreamBody(it, "file${index}")) + } else if (it instanceof File) { + entityBuilder.addPart(it.getName(), new FileBody(it, it.getName())) + } else { + entityBuilder.addPart("file${index}", new ByteArrayBody(it.bytes, "file${index}")) + } + } + entityBuilder.build() + } + + private URLConnection configureConnection(String url, boolean includeApiKey = true, boolean includeUser = true) { + URLConnection conn = new URL(url).openConnection() + + conn.setConnectTimeout((grailsApplication.config.getProperty('webservice.connect.timeout') ?: DEFAULT_TIMEOUT_MILLIS) as int) + conn.setReadTimeout((grailsApplication.config.getProperty('webservice.read.timeout') ?: DEFAULT_TIMEOUT_MILLIS) as int) + def userAgent = getUserAgent() + if (userAgent) { + conn.setRequestProperty(HttpHeaders.USER_AGENT, userAgent) + } + def user = authService.userDetails() + + includeAuthTokens(includeUser, includeApiKey, user, conn) + + conn + } + + void includeAuthTokens(Boolean includeUser, Boolean includeApiKey, UserDetails user, URLConnection conn) { + includeAuthTokensInternal(includeUser, includeApiKey, user) { key, value -> + conn.setRequestProperty(key, value) + } + } + + private void includeAuthTokensInternal(Boolean includeUser, Boolean includeApiKey, UserDetails user, Closure headerSetter) { + if (grailsApplication.config.getProperty('webservice.jwt', Boolean, false)) { + includeAuthTokensJwt(includeUser, includeApiKey, user, headerSetter) + if (grailsApplication.config.getProperty('webservice.jwt-include-legacy-headers', Boolean, true)) { + includeAuthTokensLegacy(includeUser, includeApiKey, user, headerSetter) + } + } else { + includeAuthTokensLegacy(includeUser, includeApiKey, user, headerSetter) + } + } + + void includeAuthTokensJwt(includeUser, includeApiKey, user, headerSetter) { + if ((user && includeUser) || (includeApiKey)) { + def token = tokenService.getAuthToken(user && includeUser) + if (token) { + headerSetter(AUTHORIZATION, token.toAuthorizationHeader()) + } + } + } + + void includeAuthTokensLegacy(includeUser, includeApiKey, user, headerSetter) { + if ((user && includeUser)) { + headerSetter((grailsApplication.config.getProperty('app.http.header.userId') ?: DEFAULT_AUTH_HEADER) as String, user.userId as String) + headerSetter("Cookie", "ALA-Auth=${URLEncoder.encode(user.userName ?: "", CHAR_ENCODING)}") + headerSetter("ALA-Auth", "${URLEncoder.encode(user.userName ?: "", CHAR_ENCODING)}") + } + + String apiKey = getApiKey() + if (apiKey && includeApiKey) { + headerSetter("apiKey", apiKey) + } + } + + private String getUserAgent() { + def name = grailsApplication.config.getProperty('info.app.name', String) + def version = grailsApplication.config.getProperty('info.app.version', String) + if (name && version) { + return "$name/$version" + } else { + return '' + } + } + + /** + * Use Grails JSON to encode an object as JSON. If the object is a String, assume that it's + * already a well formed JSON document and return it as such. Otherwise, convert the object + * to JSON using `o as JSON` and then return an entity that will write the result to an OutputStream. + * + * @param model The model to convert to JSON + * @param contentType The content type. Could be anything. + * @return The HTTP Entity that will write the model to an outputstream as JSON + */ + static HttpEntity encodeJSON(Object model, Object contentType) { +// log.info("Grails encodeJSON") + final entity + if (model instanceof String) { + entity = new StringEntity( model, contentType.toString(), CHAR_ENCODING ) + } else { + final json = model as JSON + entity = new AbstractHttpEntity() { + @Override + boolean isRepeatable() { + false + } + + @Override + long getContentLength() { + -1 + } + + @Override + InputStream getContent() throws IOException, IllegalStateException { + throw new UnsupportedOperationException('This entity only supports writing') + } + + @Override + void writeTo(OutputStream outputStream) throws IOException { + OutputStreamWriter w = new OutputStreamWriter(outputStream, UTF_8) + json.render(w) + } + + @Override + boolean isStreaming() { + false + } + } + } + entity.setContentType( contentType.toString() ) + return entity + } + + /** + * Decode an Apache HTTP Response as JSON using the Grails JSON support + * + * @param httpResponse The HTTP Response to decode as JSON + * @return A Grails JSONElement + */ + static JSONElement decodeJSON(HttpResponse httpResponse) { +// log.info("Grails decodeJSON") + final cs = ParserRegistry.getCharset(httpResponse) + def json = JSON.parse(new InputStreamReader(httpResponse.entity.content, cs)) + return json + } +} diff --git a/grails-wrapper.jar b/ala-ws-plugin/grails-wrapper.jar similarity index 100% rename from grails-wrapper.jar rename to ala-ws-plugin/grails-wrapper.jar diff --git a/grailsw b/ala-ws-plugin/grailsw similarity index 100% rename from grailsw rename to ala-ws-plugin/grailsw diff --git a/grailsw.bat b/ala-ws-plugin/grailsw.bat old mode 100755 new mode 100644 similarity index 100% rename from grailsw.bat rename to ala-ws-plugin/grailsw.bat diff --git a/ala-ws-plugin/scripts/_Events.groovy b/ala-ws-plugin/scripts/_Events.groovy new file mode 100644 index 00000000..1de6260d --- /dev/null +++ b/ala-ws-plugin/scripts/_Events.groovy @@ -0,0 +1,39 @@ +eventCompileStart = { target -> + println "Compiling Bean Validation AST Transformation (must happen before the classes being transformed)..." + println "Compiling AST classes to ${classesDir}" + def sourcePath = "${alaWsPluginPluginDir}/src" + def destPath = "${classesDir}" + + compileAST(alaWsPluginPluginDir, classesDir) + ant.sequential { +// mkdir(dir: destPath) +// groovyc(destdir: destPath, +// encoding: "UTF-8") { +// src(path: "${sourcePath}/groovy") +// } + copy(todir: "${destPath}") { + fileset dir: "${alaWsPluginPluginDir}/grails-app/i18n" + } + } + + grailsSettings.compileDependencies << new File(destPath) + classpathSet = false + classpath() +} + +def compileAST(def srcBaseDir, def destDir) { + ant.sequential { + echo "Precompiling AST Transformations ..." + echo "src ${srcBaseDir} ${destDir}" + path id: "grails.compile.classpath", compileClasspath + def classpathId = "grails.compile.classpath" + mkdir dir: destDir + groovyc(destdir: destDir, + srcDir: "$srcBaseDir/src/groovy", + classpathref: classpathId, + verbose: grailsSettings.verboseCompile, + stacktrace: "yes", + encoding: "UTF-8") + echo "done precompiling AST Transformations" + } +} \ No newline at end of file diff --git a/ala-ws-plugin/settings.gradle b/ala-ws-plugin/settings.gradle new file mode 100644 index 00000000..95518f52 --- /dev/null +++ b/ala-ws-plugin/settings.gradle @@ -0,0 +1 @@ +rootProject.name='ala-ws-plugin' diff --git a/ala-ws-plugin/src/integration-test/groovy/au/org/ala/ws/service/WebServiceSpec.groovy b/ala-ws-plugin/src/integration-test/groovy/au/org/ala/ws/service/WebServiceSpec.groovy new file mode 100644 index 00000000..a425dfa3 --- /dev/null +++ b/ala-ws-plugin/src/integration-test/groovy/au/org/ala/ws/service/WebServiceSpec.groovy @@ -0,0 +1,274 @@ +package au.org.ala.ws.service + +import au.org.ala.web.AuthService +import au.org.ala.web.UserDetails +import au.org.ala.ws.tokens.TokenService +import com.google.common.collect.ImmutableList +import grails.converters.JSON +import grails.testing.mixin.integration.Integration +import grails.testing.services.ServiceUnitTest +import grails.util.GrailsWebMockUtil +import groovy.json.JsonSlurper +import org.apache.http.HttpStatus +import org.apache.http.entity.ContentType +import org.grails.spring.beans.factory.InstanceFactoryBean +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.context.WebApplicationContext +import org.springframework.web.context.request.RequestContextHolder +import ratpack.exec.Promise +import ratpack.form.Form +import ratpack.groovy.test.embed.GroovyEmbeddedApp +import ratpack.http.Status +import ratpack.http.TypedData +import ratpack.test.embed.EmbeddedApp +import spock.lang.Ignore +import spock.lang.Shared +import spock.lang.Specification +import uk.org.lidalia.slf4jext.Level +import uk.org.lidalia.slf4jtest.LoggingEvent +import uk.org.lidalia.slf4jtest.TestLogger +import uk.org.lidalia.slf4jtest.TestLoggerFactory + +@Integration +class WebServiceSpec extends Specification implements ServiceUnitTest { + + @Shared + EmbeddedApp server + + @Shared + String url + + @Autowired + WebApplicationContext ctx + + def setupSpec() { + /* https://ratpack.io/manual/current/all.html */ + server = GroovyEmbeddedApp.of { + handlers { + get("success") { + render '{"hello": "world"}' + } + get("fail") { + context.clientError(HttpStatus.SC_BAD_REQUEST) + } + get("headers") { + Map incomingHeaders = context.getRequest().getHeaders().asMultiValueMap()?.collectEntries { it } + + def json = [headers: incomingHeaders] as JSON + + render json.toString(true) + } + post("post") { + Promise body = context.getRequest().getBody() + body.then { TypedData b -> + def json = [contentType: b.getContentType()?.toString(), bodyText: b.getText(), query: context.getRequest().getQuery()] as JSON + + context.getResponse().send(b.getContentType()?.toString(), json.toString(true)) + } + } + post("postMultipart") { + Promise body = context.parse(Form) + body.then { Form f -> + List files = [] + f.files().each { files << it.value.fileName } + def json = [files: files.sort(), data: f.data, foo: f.foo, bar: f.bar] as JSON + + context.getResponse().send(ContentType.APPLICATION_JSON.getMimeType(), json.toString(true)) + } + } + } + } + + url = server.getAddress().toString() + println "Running embedded Ratpack server at ${url}" + } + + def setup() { +// service = new WebService() + defineBeans { + tokenService(InstanceFactoryBean, Stub(TokenService), TokenService) + } + service.authService = Mock(AuthService) + service.authService.userDetails() >> new UserDetails(userId: '1234', userName: 'fred@bla.com', email: 'fred@bla.com') + + service.grailsApplication.config.merge([ + webservice: [ + timeout: 10, + apiKey : "myApiKey" + ], + app : [] + ]) + GrailsWebMockUtil.bindMockWebRequest(ctx) + } + + def cleanupSpec() { + server?.close() + } + + def cleanup() { + RequestContextHolder.resetRequestAttributes() + } + + def "a request that results in a connection exception should return a statusCode == 500 and an error message, and log the error"() { + setup: + TestLogger logger = TestLoggerFactory.getTestLogger("au.org.ala.ws.service.WebService") + + when: "the call results in a 404 (i.e. there is no server running)" + Map result = service.get("http://localhost:3123") + ImmutableList loggingEvents = logger.getLoggingEvents() + + then: + result.error != null + result.statusCode == HttpStatus.SC_INTERNAL_SERVER_ERROR + + loggingEvents.size() > 0 + loggingEvents.any {it.level == Level.ERROR } + } + + def "a successful request should return a map with statusCode == 200 and resp JSON object"() { + when: + Map result = service.get("${url}/success") + + then: + !result.error + result.statusCode == HttpStatus.SC_OK + result.resp == [hello: "world"] + } + + def "a failed request should return a map with the server status code and an error message"() { + when: + Map result = service.get("${url}/fail") + + then: + result.error != null + result.statusCode == HttpStatus.SC_BAD_REQUEST + !result.resp + } + + def "a request should include the ALA auth header and cookie if includeUser = true"() { + when: + Map result = service.get("${url}/headers", [:], ContentType.APPLICATION_JSON, false, true) + + then: + result.resp.headers['Cookie'] == "ALA-Auth=fred%40bla.com" // url encoded email address + result.resp.headers['X-ALA-userId'] == "1234" + } + + def "a request should include the ALA API Key header if includeApiKey = true"() { + when: + Map result = service.get("${url}/headers", [:], ContentType.APPLICATION_JSON, true, false) + + then: + result.resp.headers['apiKey'] == "myApiKey" + } + + @Ignore('no longer supported') + def "a request should include the ALA API Key header with the overridden name if webservice.apiKeyHeader is set in the grails config"() { + setup: + service.grailsApplication.config.merge([ + webservice: [ + apiKeyHeader: "customApiKeyHeader" + ] + ]) + + when: + Map result = service.get("${url}/headers", [:], ContentType.APPLICATION_JSON, true, false) + + then: + result.resp.headers['customApiKeyHeader'] == "myApiKey" + !result.resp.headers['apiKey'] + } + + def "a request should include any custom headers that were provided"() { + when: + Map result = service.get("${url}/headers", [:], ContentType.APPLICATION_JSON, true, false, [header1: "foo", header2: "bar"]) + + then: + result.resp.headers['header1'] == "foo" + result.resp.headers['header2'] == "bar" + } + + def "The request should set the params as the url query string when there is no existing query string"() { + when: + Map result = service.post("${url}/post", [foo: "bar"], [a: "b", c: "d"], ContentType.APPLICATION_JSON) + + then: + result.resp.contentType.toLowerCase() == ContentType.APPLICATION_JSON.toString()?.toLowerCase() + result.resp.query == 'a=b&c=d' + } + + def "The request should append all params to the url query string if there is an existing query string"() { + when: + Map result = service.post("${url}/post?x=y", [foo: "bar"], [a: "b", c: "d"], ContentType.APPLICATION_JSON) + + then: + result.resp.contentType.toLowerCase() == ContentType.APPLICATION_JSON.toString()?.toLowerCase() + result.resp.query == 'x=y&a=b&c=d' + } + + def "The request should URL-encode all params in the query string"() { + when: + Map result = service.post("${url}/post", [foo: "bar"], [a: "!", c: "&"], ContentType.APPLICATION_JSON) + + then: "! should be encoded as %21 and & should be encoded as %26" + result.resp.contentType.toLowerCase() == ContentType.APPLICATION_JSON.toString()?.toLowerCase() + result.resp.query == 'a=%21&c=%26' + } + + def "The request's content type should match the specified type - JSON"() { + when: + Map result = service.post("${url}/post", [foo: "bar"], [:], ContentType.APPLICATION_JSON) + + then: + result.resp.contentType.toLowerCase() == ContentType.APPLICATION_JSON.toString()?.toLowerCase() + result.resp.bodyText == '{"foo":"bar"}' + } + + def "The request's content type should match the specified type - HTML"() { + when: + def result = new JsonSlurper().parseText(service.post("${url}/post", [foo: "bar"], [:], ContentType.TEXT_HTML)?.resp?.toString()) + + then: + result.contentType.toLowerCase() == ContentType.TEXT_HTML.toString()?.toLowerCase() + result.bodyText == '{foo=bar}' + } + + def "The request's content type should match the specified type - TEXT"() { + when: + def result = new JsonSlurper().parseText(service.post("${url}/post", [foo: "bar"], [:], ContentType.TEXT_PLAIN)?.resp?.toString()) + + then: + result.contentType.toLowerCase() == ContentType.TEXT_PLAIN.toString()?.toLowerCase() + result.bodyText == '{foo=bar}' + } + + def "Passing a list of files to postMultipart() should result in a MultiPart request"() { + when: + Map result = service.postMultipart("${url}/postMultipart", [data: [foo: "bar"]], [:], ["file1".bytes, "file2".bytes]) + + then: + !result.error + result.resp.files.size() == 2 + result.resp.data == '{"foo":"bar"}' + } + + def "postMultipart() should send each element of the data map as a separate part - JSON"() { + when: "the partContentType parameter is set to JSON" + Map result = service.postMultipart("${url}/postMultipart", [foo: [a: "b"], bar: [c: "d"]], [:], ["file1".bytes, "file2".bytes]) + + then: "the response object will be a JSON Object" + !result.error + result.resp.files.size() == 2 + result.resp.foo == '{"a":"b"}' + result.resp.bar == '{"c":"d"}' + } + + def "postMultipart() should send each element of the data map as a separate part - TEXT"() { + when: "the partContentType parameter is set to TEXT" + Map result = service.postMultipart("${url}/postMultipart", [foo: [a: "b"], bar: [c: "d"]], [:], ["file1".bytes, "file2".bytes], ContentType.TEXT_PLAIN) + + then: "the response will be the plain-text representation of the json object returned by the dummy service" + !result.error + result.resp.replaceAll("\\s", "") == '{"bar": "[c:d]", "data": null, "foo": "[a:b]", "files": ["file0", "file1"]}'.replaceAll("\\s", "") + } +} \ No newline at end of file diff --git a/ala-ws-plugin/src/main/groovy/META-INF/services/org.codehaus.groovy.transform.ASTTransformation b/ala-ws-plugin/src/main/groovy/META-INF/services/org.codehaus.groovy.transform.ASTTransformation new file mode 100644 index 00000000..2df4adb7 --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/META-INF/services/org.codehaus.groovy.transform.ASTTransformation @@ -0,0 +1 @@ +au.org.ala.ws.ast.BeanValidationAST diff --git a/ala-ws-plugin/src/main/groovy/au/org/ala/ws/AlaWsPluginGrailsPlugin.groovy b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/AlaWsPluginGrailsPlugin.groovy new file mode 100644 index 00000000..88519b17 --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/AlaWsPluginGrailsPlugin.groovy @@ -0,0 +1,48 @@ +package au.org.ala.ws + +import au.org.ala.ws.config.AlaWsPluginConfig + +class AlaWsPluginGrailsPlugin { + // the version or versions of Grails the plugin is designed for + def grailsVersion = "3.1.0 > *" + // resources that are excluded from plugin packaging + def pluginExcludes = [] + + def title = "ALA WS Plugin" // Headline display name of the plugin + def author = "Atlas of Living Australia" + def authorEmail = "" + def description = "Grails plugin containing common REST and general webservice functionality." + + def profiles = ['web'] + + // URL to the plugin's documentation + def documentation = "https://github.com/AtlasOfLivingAustralia/ala-ws-plugin" + + // License: one of 'APACHE', 'GPL2', 'GPL3' + def license = "MPL-2.0" + + // Details of company behind the plugin (if there is one) + def organization = [ name: "Atlas of Living Australia", url: "http://ala.org.au" ] + + def doWithWebDescriptor = { xml -> + } + + def doWithSpring = { + alaWsPluginConfg(AlaWsPluginConfig) + } + + def doWithDynamicMethods = { ctx -> + } + + def doWithApplicationContext = { ctx -> + } + + def onChange = { event -> + } + + def onConfigChange = { event -> + } + + def onShutdown = { event -> + } +} diff --git a/ala-ws-plugin/src/main/groovy/au/org/ala/ws/ast/BeanValidationAST.groovy b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/ast/BeanValidationAST.groovy new file mode 100644 index 00000000..f711cfac --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/ast/BeanValidationAST.groovy @@ -0,0 +1,72 @@ +package au.org.ala.ws.ast + +import au.org.ala.ws.validation.ValidatedParameter +import grails.web.RequestParameter +import org.codehaus.groovy.ast.ASTNode +import org.codehaus.groovy.ast.ClassHelper +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.expr.ClassExpression +import org.codehaus.groovy.ast.expr.ConstantExpression +import org.codehaus.groovy.control.CompilePhase +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.transform.ASTTransformation +import org.codehaus.groovy.transform.GroovyASTTransformation +import org.codehaus.groovy.ast.MethodNode +import org.codehaus.groovy.ast.AnnotationNode +import org.codehaus.groovy.ast.Parameter + +import javax.validation.Constraint + +@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS) +class BeanValidationAST implements ASTTransformation { + @Override + void visit(ASTNode[] nodes, SourceUnit source) { + source?.AST?.classes?.each { ClassNode clazz -> + if (clazz.nameWithoutPackage.endsWith("Controller")) { + // Do not remove this print statement: it helps (A LOT) when trying to determine if the transformation + // was actually applied to a class!! This only executes at compile time. + println "Applying Bean Validation AST Transformation to ${clazz.name}" + List methods = clazz.methods + + methods.each { MethodNode method -> + method.getParameters()?.each { Parameter parameter -> + AnnotationNode validator = null + + if (!parameter.getAnnotations(ClassHelper.make(ValidatedParameter))) { + parameter.getAnnotations()?.each { AnnotationNode annotation -> + if (annotation.classNode.getAnnotations(ClassHelper.make(Constraint))) { + String paramName = getParamName(parameter) + + if (!annotation.getMember("message")) { + annotation.setMember("message", new ConstantExpression("${paramName} {${annotation.classNode.name}.message}".toString())) + } + + AnnotationNode validatorAnnotation = new AnnotationNode(ClassHelper.make(ValidatedParameter)) + validatorAnnotation.addMember("paramName", new ConstantExpression(paramName)) + validatorAnnotation.addMember("paramType", new ClassExpression(parameter.type)) + validator = validatorAnnotation + } + } + + if (validator) { + parameter.addAnnotation(validator) + } + } + } + } + } + } + } + + // grails allows method parameter names to be explicitly mapped to request parameters where the names do not match + private String getParamName(Parameter parameter) { + String paramName = parameter.name + + List requestParamAnnotations = parameter.getAnnotations(ClassHelper.make(RequestParameter)) + if (requestParamAnnotations) { + paramName = ((ConstantExpression)requestParamAnnotations[0].getMember("value"))?.value + } + + paramName + } +} \ No newline at end of file diff --git a/ala-ws-plugin/src/main/groovy/au/org/ala/ws/config/AlaWsPluginConfig.groovy b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/config/AlaWsPluginConfig.groovy new file mode 100644 index 00000000..6f210932 --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/config/AlaWsPluginConfig.groovy @@ -0,0 +1,62 @@ +package au.org.ala.ws.config + +import au.org.ala.web.Pac4jContextProvider +import au.org.ala.ws.tokens.TokenClient +import au.org.ala.ws.tokens.TokenInterceptor +import au.org.ala.ws.tokens.TokenService +import okhttp3.Interceptor +import org.pac4j.core.config.Config +import org.pac4j.core.context.session.SessionStore +import org.pac4j.oidc.config.OidcConfiguration +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class AlaWsPluginConfig { + + @Value('${webservice.client-id}') + String clientId + + @Value('${webservice.client-secret}') + String clientSecret + + @Value('${webservice.jwt-scopes}') + String jwtScopes + + @Value('${webservices.cache-tokens:true}') + boolean cacheTokens + + @Bean + TokenClient tokenClient( + @Autowired(required = false) OidcConfiguration oidcConfiguration + ) { + new TokenClient(oidcConfiguration) + } + + @Bean + TokenService tokenService( + @Autowired(required = false) Config config, + @Autowired(required = false) OidcConfiguration oidcConfiguration, + @Autowired(required = false) Pac4jContextProvider pac4jContextProvider, + @Autowired(required = false) SessionStore sessionStore, + @Autowired TokenClient tokenClient + ) { + new TokenService(config, oidcConfiguration, pac4jContextProvider, + sessionStore, tokenClient, clientId, clientSecret, jwtScopes, cacheTokens) + } + + /** + * OK HTTP Interceptor that injects a client credentials Bearer token into a request + * @return + */ + @ConditionalOnProperty(prefix='webservice', name ='jwt') + @ConditionalOnMissingBean(name = "jwtInterceptor") + @Bean + TokenInterceptor jwtInterceptor(@Autowired TokenService tokenService) { + new TokenInterceptor(tokenService) + } +} diff --git a/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenClient.groovy b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenClient.groovy new file mode 100644 index 00000000..e43e9902 --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenClient.groovy @@ -0,0 +1,52 @@ +package au.org.ala.ws.tokens + +import com.nimbusds.oauth2.sdk.ParseException +import com.nimbusds.oauth2.sdk.TokenErrorResponse +import com.nimbusds.oauth2.sdk.TokenRequest +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse +import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser +import groovy.util.logging.Slf4j +import org.pac4j.core.exception.TechnicalException +import org.pac4j.oidc.config.OidcConfiguration +import org.pac4j.oidc.credentials.OidcCredentials + +@Slf4j +class TokenClient { + + private OidcConfiguration oidcConfiguration + + TokenClient(OidcConfiguration oidcConfiguration) { + this.oidcConfiguration = oidcConfiguration + } + + + OidcCredentials executeTokenRequest(TokenRequest request) throws IOException, ParseException { + def tokenHttpRequest = request.toHTTPRequest() + if (oidcConfiguration) { + oidcConfiguration.configureHttpRequest(tokenHttpRequest) + } + + def httpResponse = tokenHttpRequest.send() + log.debug("Token response: status={}, content={}", httpResponse.getStatusCode(), + httpResponse.getContent()) + + def response = OIDCTokenResponseParser.parse(httpResponse) + if (response instanceof TokenErrorResponse) { + def errorObject = ((TokenErrorResponse) response).getErrorObject() + throw new TechnicalException("Bad token response, error=" + errorObject.getCode() + "," + + " description=" + errorObject.getDescription()) + } + log.debug("Token response successful") + def tokenSuccessResponse = (OIDCTokenResponse) response + + def credentials = new OidcCredentials() + def oidcTokens = tokenSuccessResponse.getOIDCTokens() + credentials.setAccessToken(oidcTokens.getAccessToken()) + credentials.setRefreshToken(oidcTokens.getRefreshToken()) + if (oidcTokens.getIDToken() != null) { + credentials.setIdToken(oidcTokens.getIDToken()) + } + return credentials + } + +} diff --git a/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenInterceptor.groovy b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenInterceptor.groovy new file mode 100644 index 00000000..c9bc9f2f --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenInterceptor.groovy @@ -0,0 +1,26 @@ +package au.org.ala.ws.tokens + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * okhttp interceptor that inserts a bearer token into the request + */ +class TokenInterceptor implements Interceptor { + + private final TokenService tokenService + + TokenInterceptor(TokenService tokenService) { + this.tokenService = tokenService + } + + @Override + Response intercept(Chain chain) throws IOException { + return chain.proceed( + chain.request().newBuilder() + .addHeader('Authorization', tokenService.getAuthToken(false).toAuthorizationHeader()) + .build() + ) + } + +} diff --git a/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenService.groovy b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenService.groovy new file mode 100644 index 00000000..584fc9b4 --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/tokens/TokenService.groovy @@ -0,0 +1,163 @@ +package au.org.ala.ws.tokens + +import au.org.ala.web.Pac4jContextProvider +import com.google.common.annotations.VisibleForTesting +import com.nimbusds.oauth2.sdk.ClientCredentialsGrant +import com.nimbusds.oauth2.sdk.RefreshTokenGrant +import com.nimbusds.oauth2.sdk.Scope +import com.nimbusds.oauth2.sdk.TokenRequest +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic +import com.nimbusds.oauth2.sdk.auth.Secret +import com.nimbusds.oauth2.sdk.id.ClientID +import com.nimbusds.oauth2.sdk.token.AccessToken +import com.nimbusds.oauth2.sdk.token.RefreshToken +import groovy.util.logging.Slf4j +import org.pac4j.core.config.Config +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.profile.ProfileManager +import org.pac4j.oidc.config.OidcConfiguration +import org.pac4j.oidc.credentials.OidcCredentials +import org.pac4j.oidc.profile.OidcProfile + +/** + * Component for getting access tokens for using on web service requests. + */ +@Slf4j +class TokenService { + + final boolean cacheTokens + + final String clientId + final String clientSecret + + final String jwtScopes + final List finalScopes + + private final Config config + + private final OidcConfiguration oidcConfiguration + + private final Pac4jContextProvider pac4jContextProvider + + private final SessionStore sessionStore + + private final TokenClient tokenClient + + TokenService(Config config, OidcConfiguration oidcConfiguration, Pac4jContextProvider pac4jContextProvider, + SessionStore sessionStore, TokenClient tokenClient, String clientId, String clientSecret, String jwtScopes, + boolean cacheTokens) { + this.cacheTokens = cacheTokens + this.config = config + this.oidcConfiguration = oidcConfiguration + this.pac4jContextProvider = pac4jContextProvider + this.sessionStore = sessionStore + this.tokenClient = tokenClient + + this.clientId = clientId + this.clientSecret = clientSecret + this.jwtScopes = jwtScopes + if (jwtScopes) { + this.finalScopes = jwtScopes.tokenize(' ').findAll().toSet().toList() + } + } + + ProfileManager getProfileManager() { + def context = pac4jContextProvider.webContext() + final ProfileManager manager = new ProfileManager(context, sessionStore) + manager.config = config + return manager + } + + /** + * Get an access token. Will return the current user's access token or if there is no + * current user, will request a client credentials grant based access token for this app. + * @param requireUser Whether the auth token must belong to an individual user (setting this to true will disable requesting a client credentials based app JWT) + * @return The access token + */ + AccessToken getAuthToken(boolean requireUser) { + AccessToken token + if (requireUser) { + token = profileManager.getProfile(OidcProfile).map { it.accessToken }.orElse(null) + } else { + def credentials + if (oidcConfiguration) { + if (cacheTokens) { + credentials = getOrRefreshToken() + } else { + credentials = clientCredentialsToken() + } + token = credentials?.accessToken + } else { + log.debug("Not generating token because OIDC is not configured") + token = null + } + } + return token + } + + private long expiryWindow = 1 // 1 second + private volatile transient OidcCredentials cachedCredentials + private volatile transient long cachedCredentialsLifetime = 0 + @VisibleForTesting + final Object lock = new Object() + + private OidcCredentials getOrRefreshToken() { + + long now = System.currentTimeSeconds() - expiryWindow + + def lifetime = cachedCredentialsLifetime + if (lifetime == 0 || now >= lifetime) { + synchronized (lock) { + lifetime = cachedCredentialsLifetime + if (lifetime == 0 || now >= lifetime) { + def credentials = tokenSupplier(cachedCredentials) + cachedCredentials = credentials + cachedCredentialsLifetime = System.currentTimeSeconds() + credentials.accessToken.lifetime + return credentials + } + } + } + return cachedCredentials + } + + private OidcCredentials tokenSupplier(OidcCredentials existingCredentials) { + OidcCredentials credentials = null + if (existingCredentials && existingCredentials.refreshToken) { + try { + log.debug("Refreshing existing token") + credentials = refreshToken(existingCredentials.refreshToken) + } catch (e) { + log.warn("Couldn't get refresh token from {}", existingCredentials.refreshToken, e) + } + } + if (!credentials) { // no refresh token or refresh token grant failed + log.debug("Requesting new client credentials token") + credentials = clientCredentialsToken() + } + return credentials + } + + private OidcCredentials clientCredentialsToken() { + + def tokenRequest = new TokenRequest( + oidcConfiguration.findProviderMetadata().getTokenEndpointURI(), + new ClientSecretBasic(new ClientID(clientId), new Secret(clientSecret)), + new ClientCredentialsGrant(), + finalScopes ? new Scope(*finalScopes) : new Scope() + ) + return tokenClient.executeTokenRequest(tokenRequest) + } + + + private OidcCredentials refreshToken(RefreshToken refreshToken) { + def tokenRequest = new TokenRequest( + oidcConfiguration.findProviderMetadata().getTokenEndpointURI(), + new ClientSecretBasic(new ClientID(clientId), new Secret(clientSecret)), + new RefreshTokenGrant(refreshToken), + new Scope(*finalScopes) + ) + return tokenClient.executeTokenRequest(tokenRequest) + } + + +} diff --git a/ala-ws-plugin/src/main/groovy/au/org/ala/ws/validation/ValidatedParameter.groovy b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/validation/ValidatedParameter.groovy new file mode 100644 index 00000000..afb83540 --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/validation/ValidatedParameter.groovy @@ -0,0 +1,18 @@ +package au.org.ala.ws.validation + +import au.org.ala.ws.ast.BeanValidationAST +import org.codehaus.groovy.transform.GroovyASTTransformationClass + +import java.lang.annotation.ElementType +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +@Retention(RetentionPolicy.RUNTIME) +@Target([ElementType.PARAMETER]) +@GroovyASTTransformationClass(classes = BeanValidationAST) +public @interface ValidatedParameter { + String paramName() + + Class paramType() +} \ No newline at end of file diff --git a/ala-ws-plugin/src/main/groovy/au/org/ala/ws/validation/constraints/UUID.groovy b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/validation/constraints/UUID.groovy new file mode 100644 index 00000000..5876508a --- /dev/null +++ b/ala-ws-plugin/src/main/groovy/au/org/ala/ws/validation/constraints/UUID.groovy @@ -0,0 +1,34 @@ +package au.org.ala.ws.validation.constraints + +import javax.validation.Constraint +import javax.validation.ConstraintValidator +import javax.validation.ConstraintValidatorContext +import javax.validation.Payload +import java.lang.annotation.ElementType +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import java.lang.annotation.Target + +@Target([ElementType.PARAMETER]) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UUIDValidator) +@interface UUID { + String message() default '{au.org.ala.ws.validation.constraints.message}' + + Class[] groups() default [] + + Class[] payload() default [] +} + +class UUIDValidator implements ConstraintValidator { + + static final UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + + @Override + void initialize(UUID constraintAnnotation) {} + + @Override + boolean isValid(String value, ConstraintValidatorContext context) { + value =~ UUID_REGEX + } +} \ No newline at end of file diff --git a/ala-ws-plugin/src/test/groovy/au/org/ala/ws/ast/BeanValidationASTSpec.groovy b/ala-ws-plugin/src/test/groovy/au/org/ala/ws/ast/BeanValidationASTSpec.groovy new file mode 100644 index 00000000..9d2ca491 --- /dev/null +++ b/ala-ws-plugin/src/test/groovy/au/org/ala/ws/ast/BeanValidationASTSpec.groovy @@ -0,0 +1,211 @@ +package au.org.ala.ws.ast + +import au.org.ala.ws.validation.ValidatedParameter +import org.codehaus.groovy.control.CompilePhase +import spock.lang.Specification +import org.codehaus.groovy.tools.ast.TransformTestHelper + +import javax.validation.constraints.NotNull +import javax.validation.constraints.Null +import javax.validation.constraints.Size + +class BeanValidationASTSpec extends Specification { + static CompilePhase PHASE = CompilePhase.SEMANTIC_ANALYSIS + + def "transform should add @ValidatedParameter to JSR303-annotated params for controller action methods"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import javax.validation.constraints.NotNull + import javax.validation.constraints.Null + class TestController { + def action1(@NotNull String param1, @Null String param2, String param3) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", String, String, String).getParameterAnnotations()[0].length == 2 + clazz.class.getMethod("action1", String, String, String).getParameterAnnotations()[0][0].annotationType() == NotNull + clazz.class.getMethod("action1", String, String, String).getParameterAnnotations()[0][1].annotationType() == ValidatedParameter + clazz.class.getMethod("action1", String, String, String).getParameterAnnotations()[1].length == 2 + clazz.class.getMethod("action1", String, String, String).getParameterAnnotations()[1][0].annotationType() == Null + clazz.class.getMethod("action1", String, String, String).getParameterAnnotations()[1][1].annotationType() == ValidatedParameter + clazz.class.getMethod("action1", String, String, String).getParameterAnnotations()[2].length == 0 + } + + def "transform should store the parameter name from the source code in the @ValidatedParameter annotation"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import javax.validation.constraints.NotNull + class TestController { + def action1(@NotNull String param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][1].paramName() == "param1" + } + + def "transform should store the parameter type from the source code in the @ValidatedParameter annotation"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import javax.validation.constraints.NotNull + class TestController { + def action1(@NotNull Double param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", Double).getParameterAnnotations()[0][1].paramType() == Double + } + + def "transform should set the constraint message to the param name followed by the default message expression if message is not set"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import javax.validation.constraints.NotNull + class TestController { + def action1(@NotNull String param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][0].message() == "param1 {${NotNull.class.name}.message}" + } + + def "transform should not change the constraint message if it has been set on the annotation"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import javax.validation.constraints.NotNull + class TestController { + def action1(@NotNull(message = 'test message') String param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][0].message() == "test message" + } + + def "transform should store the parameter name from the @RequestParameter annotation in the @ValidatedParameter annotation"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import javax.validation.constraints.NotNull + import grails.web.RequestParameter + class TestController { + def action1(@NotNull @RequestParameter(value="otherName") String param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: "there will only be 2 annotations since RequestParameter has a retention policy of Source" + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][1].paramName() == "otherName" + } + + def "transform should ignore classes whose names do not end in Controller"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import javax.validation.constraints.NotNull + class TestClass { + def action1(@NotNull String param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", String).getParameterAnnotations()[0].length == 1 + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][0].annotationType() == NotNull + } + + def "transform should not add or override the @ValidatedParameter annotation if it already exists"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import au.org.ala.ws.validation.ValidatedParameter + import javax.validation.constraints.NotNull + class TestController { + def action1(@NotNull @ValidatedParameter(paramName = "test") String param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", String).getParameterAnnotations()[0].length == 2 + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][0].annotationType() == NotNull + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][1].annotationType() == ValidatedParameter + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][1].paramName() == "test" + } + + def "transform should only add the @ValidatedParameter annotation once even if a param has multiple constraints"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + import au.org.ala.ws.validation.ValidatedParameter + import javax.validation.constraints.NotNull + import javax.validation.constraints.Size + class TestController { + def action1(@NotNull @Size(min = 2) String param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", String).getParameterAnnotations()[0].length == 3 + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][0].annotationType() == NotNull + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][1].annotationType() == Size + clazz.class.getMethod("action1", String).getParameterAnnotations()[0][2].annotationType() == ValidatedParameter + } + + def "transform should ignore method parameters not annotated with JSR303 annotations"() { + setup: + BeanValidationAST transformer = new BeanValidationAST() + Class testClass = new TransformTestHelper(transformer, PHASE).parse ''' + class TestController { + def action1(String param1) { + } + } + ''' + + when: + def clazz = testClass.newInstance() + + then: + clazz.class.getMethod("action1", String).getParameterAnnotations()[0].length == 0 + } +} diff --git a/ala-ws-plugin/src/test/groovy/au/org/ala/ws/filter/WSFiltersSpec.groovy b/ala-ws-plugin/src/test/groovy/au/org/ala/ws/filter/WSFiltersSpec.groovy new file mode 100644 index 00000000..43f23e15 --- /dev/null +++ b/ala-ws-plugin/src/test/groovy/au/org/ala/ws/filter/WSFiltersSpec.groovy @@ -0,0 +1,68 @@ +package au.org.ala.ws.filter + +import au.org.ala.ws.WSInterceptor +import au.org.ala.ws.validation.ValidatedParameter +import grails.testing.web.interceptor.InterceptorUnitTest +import org.grails.web.util.GrailsApplicationAttributes +import spock.lang.Specification + +import javax.validation.constraints.Min +import javax.validation.constraints.NotNull + +class WSFiltersSpec extends Specification implements InterceptorUnitTest { + def controller = new TestController() + + void "invalid parameters should result in a HTTP 400 (BAD_REQUEST)"() { + setup: + // need to do this because grailsApplication.controllerClasses is empty in the filter when run from the unit test + // unless we manually add the dummy controller class used in this test + grailsApplication.addArtefact("Controller", TestController) + + when: + + request.setAttribute(GrailsApplicationAttributes.CONTROLLER_NAME_ATTRIBUTE, 'test') + request.setAttribute(GrailsApplicationAttributes.ACTION_NAME_ATTRIBUTE, 'action1') + withInterceptors(controller: "test", action: "action1") { + controller.action1() + } + + then: + response.status == 400 + } + + void "valid parameters should result in a HTTP 200 (OK)"() { + setup: + // need to do this because grailsApplication.controllerClasses is empty in the filter when run from the unit test + // unless we manually add the dummy controller class used in this test + grailsApplication.addArtefact("Controller", TestController) + + when: + request.setAttribute(GrailsApplicationAttributes.CONTROLLER_NAME_ATTRIBUTE, 'test') + request.setAttribute(GrailsApplicationAttributes.ACTION_NAME_ATTRIBUTE, 'action1') + params.param1 = "test" + params.param2 = 666 + withInterceptors(controller: "test", action: "action1") { + controller.action1() + } + + then: + response.status == 200 + } +} + +/** + * This class mimics the runtime Grails controller classes, after they have had the BeanValidationAST and the grails web + * ASTs applied: + * + * - BeanValidationAST adds @ValidatedParameter to every parameter that is annotated with a JSR303 constraint; and + * 0 Grails creates a no-arg version of each controller action + */ +class TestController { + def action1( + @ValidatedParameter(paramName = "param1", paramType = String) @NotNull String param1, + @ValidatedParameter(paramName = "param2", paramType = Integer) @Min(2L) int param2) { + + } + + def action1() {} +} \ No newline at end of file diff --git a/ala-ws-plugin/src/test/groovy/au/org/ala/ws/tokens/TokenServiceSpec.groovy b/ala-ws-plugin/src/test/groovy/au/org/ala/ws/tokens/TokenServiceSpec.groovy new file mode 100644 index 00000000..502306a7 --- /dev/null +++ b/ala-ws-plugin/src/test/groovy/au/org/ala/ws/tokens/TokenServiceSpec.groovy @@ -0,0 +1,119 @@ +package au.org.ala.ws.tokens + +import au.org.ala.web.Pac4jContextProvider +import com.nimbusds.oauth2.sdk.id.Issuer +import com.nimbusds.oauth2.sdk.token.BearerAccessToken +import com.nimbusds.oauth2.sdk.token.RefreshToken +import com.nimbusds.openid.connect.sdk.SubjectType +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import org.pac4j.core.config.Config +import org.pac4j.core.context.WebContext +import org.pac4j.core.util.Pac4jConstants +import org.pac4j.jee.context.JEEContextFactory +import org.pac4j.jee.context.session.JEESessionStore +import org.pac4j.oidc.config.OidcConfiguration +import org.pac4j.oidc.credentials.OidcCredentials +import org.pac4j.oidc.profile.OidcProfile +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import spock.lang.Specification + +import javax.servlet.http.HttpServletRequest + +class TokenServiceSpec extends Specification { + + def tokenUri = 'https://example.org/token' + + def config + def oidcConfiguration + def pac4jContextProvider + def sessionStore + HttpServletRequest request + TokenClient tokenClient + TokenService tokenService + + def setup() { + config = Stub(Config) + oidcConfiguration = Stub(OidcConfiguration) + oidcConfiguration.clientId >> 'clientid' + oidcConfiguration.secret >> 'secret' + def providerMetadata = new OIDCProviderMetadata(new Issuer('https://example.org/'), [SubjectType.PUBLIC], 'https://example.org/jwks'.toURI()) + providerMetadata.setTokenEndpointURI(tokenUri.toURI()) + oidcConfiguration.findProviderMetadata() >> providerMetadata + request = new MockHttpServletRequest() + request.getSession(true) + def response = new MockHttpServletResponse() + pac4jContextProvider = new Pac4jContextProvider() { + @Override + WebContext webContext() { + JEEContextFactory.INSTANCE.newContext(request, response) + } + } + sessionStore = JEESessionStore.INSTANCE + tokenClient = Mock(TokenClient) + tokenService = new TokenService(config, oidcConfiguration, pac4jContextProvider, sessionStore, tokenClient, 'client-id', 'client-secret', 'openid ala:internal users:read', false) + } + + + def 'test token service requireUser false'() { + setup: + def oidcCredentials = new OidcCredentials().tap { it.accessToken = new BearerAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') } + + when: + def token = tokenService.getAuthToken(false) + + then: + 1 * tokenClient.executeTokenRequest(_) >> oidcCredentials + token != null + } + + def 'test token service requireUser true'() { + setup: + request.getSession(false).setAttribute(Pac4jConstants.USER_PROFILES, ['oidc': new OidcProfile().tap { it.accessToken = new BearerAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c') }]) + + when: + def token = tokenService.getAuthToken(true) + + then: + 0 * tokenClient.executeTokenRequest(_) + token != null + } + + def 'test token service requireUser false with cache'() { + setup: + def tokenService = new TokenService(config, oidcConfiguration, pac4jContextProvider, sessionStore, tokenClient, + 'client-id', 'client-secret', 'openid ala:internal users:read', true) + + def oidcCredentials = new OidcCredentials().tap { + it.accessToken = new BearerAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', 2l, null) + it.refreshToken = new RefreshToken("asdfasdfasdfasdf") + } + + def oidcCredentials2 = new OidcCredentials().tap { + it.accessToken = new BearerAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNjc2MjM5MDIyfQ.wF1li4R8Gu0h54T_DwxfKGRAtvR1MV43wdpuc2o17Lo', 2l, null) + it.refreshToken = new RefreshToken("qwerqwerqwer") + } + +// tokenService.cachedCredentials = null + when: + def token1 + def token2 + synchronized (tokenService.lock) { + token1 = tokenService.getAuthToken(false) + token2 = tokenService.getAuthToken(false) + } + then: "cached token returned for second call" + 1 * tokenClient.executeTokenRequest(_) >> oidcCredentials + token1 == token2 + + when: + + sleep(3000) + def token3 = tokenService.getAuthToken(false) + + then: "refresh token grant used" + 1 * tokenClient.executeTokenRequest(_) >> oidcCredentials2 + token1 != token3 + + } +} \ No newline at end of file diff --git a/ala-ws-plugin/wrapper/grails-wrapper-runtime-2.5.6.jar b/ala-ws-plugin/wrapper/grails-wrapper-runtime-2.5.6.jar new file mode 100644 index 00000000..59e5645b Binary files /dev/null and b/ala-ws-plugin/wrapper/grails-wrapper-runtime-2.5.6.jar differ diff --git a/ala-ws-plugin/wrapper/grails-wrapper.properties b/ala-ws-plugin/wrapper/grails-wrapper.properties new file mode 100644 index 00000000..0c31d6f9 --- /dev/null +++ b/ala-ws-plugin/wrapper/grails-wrapper.properties @@ -0,0 +1 @@ +wrapper.dist.url=https://github.com/grails/grails-core/releases/download/v2.5.6/ diff --git a/ala-ws-plugin/wrapper/springloaded-1.2.7.RELEASE.jar b/ala-ws-plugin/wrapper/springloaded-1.2.7.RELEASE.jar new file mode 100644 index 00000000..acdd828f Binary files /dev/null and b/ala-ws-plugin/wrapper/springloaded-1.2.7.RELEASE.jar differ diff --git a/ala-ws-security-plugin/README.md b/ala-ws-security-plugin/README.md new file mode 100644 index 00000000..ea2d1cbb --- /dev/null +++ b/ala-ws-security-plugin/README.md @@ -0,0 +1,68 @@ +# ala-ws-security-plugin +Web service specific security code, e.g. API Key filters + +## Status +[![Build Status](https://travis-ci.org/AtlasOfLivingAustralia/ala-ws-security-plugin.svg?branch=master)](https://travis-ci.org/AtlasOfLivingAustralia/ala-ws-security-plugin) + +## Usage +``` +compile "org.grails.plugins:ala-ws-security-plugin:4.4.0-SNAPSHOT" // Grails +compile "au.org.ala.ala-ws-spring-security:4.4.0-SNAPSHOT" // Spring Boot w/ Spring Security +``` + +### JWT Usage + +From the client side, send an Authorization: Bearer request _header_ on all secured service requests, with a JWT access token issued by an OIDC IdP as the payload. + +On the server side, the legacy `@RequireApiKey` annotations will still be honoured, but will +look for a JWT in the request first before optionally falling back to the legacy behaviour. + +Optionally, you may add a `scopes` parameter to the `@RequireApiKey` annotation, to enforce incoming JWT +requests to have the given scopes (ie, an app might have a `read:appname` scope defined for reading from its API) + +### Legacy Usage + +From the client side, set the ```apiKey``` request _header_ on all secured service requests to a valid API Key (registered in the API Key service). + +On the server side, annotate protected controllers (either the class or individual methods) with the ```RequireApiKey``` annotation. + +## External configuration properties + +### JWT support +- ```security.jwt.enabled``` - Defaults to true. True indicates the plugin should check for JWTs on incoming requests. +- ```security.jwt.discovery-uri``` - The discovery URI of the OIDC provider. JWT validation will be bootstrapped from this document. +- ```security.jwt.connect-timeout-ms``` - HTTP request connection timeout +- ```security.jwt.read-timeout-ms``` - HTTP request read timeout +- ```security.jwt.required-claims``` - The claims that must be present on the JWT for it to be valid. By default this is `"sub", "iat", "exp", "nbf", "cid", "jti"` +- ```security.jwt.required-scopes``` - List of scopes that are required for all JWT endpoints in this app +- ```security.jwt.user-id-claim``` - The claims from the access token that contains the userId (default: `userid`) +- ```security.jwt.role-claims``` - The name of the claim(s) that contain the roles (default: `role`) +- ```security.jwt.permission-claims``` - The name of the claims(s) that contain the permissions (default: `scope,scopes,scp`) +- ```security.jwt.roles-from-access-token``` - should the role claims be read from the access_token (default: `true`) +- ```security.jwt.role-prefix``` - The prefix to apply to the access token roles (eg. `ROLE_`) +- ```security.jwt.role-to-uppercase``` - Should the role be converted to upper case (default: `true`) + +### ApiKey support +- ```security.apikey.enabled``` - Defaults to false. True indicated the plugin should check for apikey on incoming requests. + +#### Mandatory +- ```security.apikey.auth.serviceUrl``` - **NOTE: Changed** URL of the API Key service endpoint, up to the context path. E.g. https://auth.ala.org.au/apikey/ +- ```security.apikey.userdetails.serviceUrl``` - URL of the userdetails service endpoint. E.g. https://auth.ala.org.au/userdetails/ +#### Optional +- ```security.apikey.header.override``` - override the default request header name (apiKey) to use a different name. +- ```security.apikey.header.alternatives``` - alternate request header names to check if the default request header (`apiKey`) is not found + +### IP whitelist support +- ```security.ip.whitelist``` - comma separated list of IP Addresses that are exempt from the API key security check. If the property is not defined then IP whitelisting is disabled. + +## Changelog +- ** Version 4.4.0 ** + - Spring Boot Support +- **Version 4.0.0** + - Grails 4 version + - Add JWT support +- **Version 2.0** + - Grails 3 version +- **Version 1.0** (2/7/2015) + - Initial release. + - Includes a grails filter and a ```RequireApiKey``` annotation for securing web service calls via the ALA API Key infrastructure. diff --git a/ala-ws-security-plugin/build.gradle b/ala-ws-security-plugin/build.gradle new file mode 100644 index 00000000..d4c13607 --- /dev/null +++ b/ala-ws-security-plugin/build.gradle @@ -0,0 +1,161 @@ +buildscript { + repositories { + mavenLocal() + maven { url "https://nexus.ala.org.au/content/groups/public/" } + maven { url "https://repo.grails.org/grails/core" } + } + dependencies { + classpath "org.grails:grails-gradle-plugin:$grailsGradlePluginVersion" +// classpath "com.bertramlabs.plugins:asset-pipeline-gradle:2.14.2" + } +} + +group "org.grails.plugins" + +apply plugin:"eclipse" +apply plugin:"idea" +apply plugin:'java-library' +apply plugin:"org.grails.grails-plugin" +apply plugin:"org.grails.grails-gsp" +//apply plugin:"asset-pipeline" +//apply plugin:"maven" +apply plugin:"maven-publish" + +sourceCompatibility = 1.11 +targetCompatibility = 1.11 + +repositories { + mavenLocal() + maven { url "https://nexus.ala.org.au/content/groups/public/" } + maven { url "https://repo.grails.org/grails/core" } +} + +configurations.all { + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' + resolutionStrategy.cacheDynamicVersionsFor 0, 'seconds' +} + +configurations { + developmentOnly + runtimeClasspath { + extendsFrom developmentOnly + } +} + +dependencies { + developmentOnly("org.springframework.boot:spring-boot-devtools") + implementation "org.springframework.boot:spring-boot-starter-logging" + implementation "org.springframework.boot:spring-boot-autoconfigure" + implementation "org.grails:grails-core" + implementation "org.springframework.boot:spring-boot-starter-actuator" + implementation "org.springframework.boot:spring-boot-starter-tomcat" + implementation "org.grails:grails-web-boot" + implementation "org.grails:grails-logging" + implementation "org.grails:grails-plugin-rest" + implementation "org.grails:grails-plugin-databinding" + implementation "org.grails:grails-plugin-i18n" + implementation "org.grails:grails-plugin-services" + implementation "org.grails:grails-plugin-url-mappings" + implementation "org.grails:grails-plugin-interceptors" + + implementation "org.grails.plugins:cache" + implementation 'org.grails.plugins:cache-ehcache:3.0.0' + + implementation "org.grails.plugins:async" + implementation "org.grails.plugins:scaffolding" + implementation "org.grails.plugins:gsp" + compileOnly "io.micronaut:micronaut-inject-groovy" + console "org.grails:grails-console" + profile "org.grails.profiles:web-plugin" +// runtime "com.bertramlabs.plugins:asset-pipeline-grails:3.0.10" + testImplementation "io.micronaut:micronaut-inject-groovy" + testImplementation "org.grails:grails-gorm-testing-support" + testImplementation "org.mockito:mockito-core:4.6.1" + testImplementation "org.grails:grails-web-testing-support" + testImplementation 'com.github.tomakehurst:wiremock-jre8:2.33.2' + testImplementation "co.infinum:retromock:1.1.1" +// testImplementation 'cglib:cglib-nodep:3.3.0' +// testImplementation 'org.objenesis:objenesis:3.3' +// testImplementation 'org.modelmapper:modelmapper:3.1.0' + + implementation 'au.org.ala.grails:interceptor-annotation-matcher:1.0.0' +// testImplementation 'io.github.joke:spock-mockable:1.5.5' + + api project(':ala-ws-security') + + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + compileOnly "org.springframework.boot:spring-boot-configuration-processor" + +} + +compileGroovy { + groovyOptions.javaAnnotationProcessing = true +} + +tasks.withType(GroovyCompile) { + configure(groovyOptions) { + forkOptions.jvmArgs = ['-Xmx1024m'] + } +} + +tasks.withType(Test) { + useJUnitPlatform() +} + +compileJava.dependsOn(processResources) + +bootRun.enabled = false +/* +bootRun { + ignoreExitValue true + jvmArgs( + '-Dspring.output.ansi.enabled=always', + '-noverify', + '-XX:TieredStopAtLevel=1', + '-Xmx1024m') + sourceResources sourceSets.main + String springProfilesActive = 'spring.profiles.active' + systemProperty springProfilesActive, System.getProperty(springProfilesActive) +} + */ +// enable if you wish to package this plugin as a standalone application +bootJar.enabled = false + +publishing { + repositories { + maven { + name 'Nexus' + url "https://nexus.ala.org.au/content/repositories/${project.version.endsWith('-SNAPSHOT') ? 'snapshots' : 'releases' }" + credentials { + username = System.getenv('TRAVIS_DEPLOY_USERNAME') + password = System.getenv('TRAVIS_DEPLOY_PASSWORD') + } + } + } + publications { + maven(MavenPublication) { + from components.java + artifact sourcesJar + artifact javadocJar + + pom { + name = 'ALA WS Security Plugin' + description = 'Plugin for authenticating web service calls for ALA systems' + url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin' + licenses { + license { + name = 'MPL-1.1' + url = 'https://www.mozilla.org/en-US/MPL/1.1/' + } + } + developers { + } + scm { + connection = 'scm:git:git://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin.git' + developerConnection = 'scm:git:ssh://github.com:AtlasOfLivingAustralia/ala-ws-security-plugin.git' + url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin/tree/main' + } + } + } + } +} diff --git a/ala-ws-security-plugin/gradle.properties b/ala-ws-security-plugin/gradle.properties new file mode 100644 index 00000000..ddb74394 --- /dev/null +++ b/ala-ws-security-plugin/gradle.properties @@ -0,0 +1,8 @@ +#Mon Jul 24 00:31:11 AEST 2017 +grailsVersion=5.2.1 +grailsGradlePluginVersion=5.2.1 +groovyVersion=3.0.11 +gorm.version=7.3.2 +org.gradle.daemon=true +org.gradle.parallel=true +org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1024M \ No newline at end of file diff --git a/grails-app/conf/application.yml b/ala-ws-security-plugin/grails-app/conf/application.yml similarity index 90% rename from grails-app/conf/application.yml rename to ala-ws-security-plugin/grails-app/conf/application.yml index d5cd5584..0c0d9408 100644 --- a/grails-app/conf/application.yml +++ b/ala-ws-security-plugin/grails-app/conf/application.yml @@ -14,6 +14,9 @@ grails: # Whether to translate GORM events into Reactor events # Disabled by default for performance reasons events: false + cache: + ehcache: + ehcacheXmlLocation: 'classpath:ehcache3.xml' info: app: name: '@info.app.name@' diff --git a/ala-ws-security-plugin/grails-app/conf/logback.old.groovy b/ala-ws-security-plugin/grails-app/conf/logback.old.groovy new file mode 100644 index 00000000..20f85e19 --- /dev/null +++ b/ala-ws-security-plugin/grails-app/conf/logback.old.groovy @@ -0,0 +1,36 @@ +import grails.util.BuildSettings +import grails.util.Environment +import org.springframework.boot.logging.logback.ColorConverter +import org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter + +import java.nio.charset.Charset + +conversionRule 'clr', ColorConverter +conversionRule 'wex', WhitespaceThrowableProxyConverter + +// See http://logback.qos.ch/manual/groovy.html for details on configuration +appender('STDOUT', ConsoleAppender) { + encoder(PatternLayoutEncoder) { + charset = Charset.forName('UTF-8') + + pattern = + '%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} ' + // Date + '%clr(%5p) ' + // Log level + '%clr(---){faint} %clr([%15.15t]){faint} ' + // Thread + '%clr(%-40.40logger{39}){cyan} %clr(:){faint} ' + // Logger + '%m%n%wex' // Message + } +} + +def targetDir = BuildSettings.TARGET_DIR +if (Environment.isDevelopmentMode() && targetDir != null) { + appender("FULL_STACKTRACE", FileAppender) { + file = "${targetDir}/stacktrace.log" + append = true + encoder(PatternLayoutEncoder) { + pattern = "%level %logger - %msg%n" + } + } + logger("StackTrace", ERROR, ['FULL_STACKTRACE'], false) +} +root(ERROR, ['STDOUT']) diff --git a/ala-ws-security-plugin/grails-app/conf/logback.xml b/ala-ws-security-plugin/grails-app/conf/logback.xml new file mode 100644 index 00000000..6d1a7868 --- /dev/null +++ b/ala-ws-security-plugin/grails-app/conf/logback.xml @@ -0,0 +1,18 @@ + + + + + + + + + UTF-8 + %clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(%5p) %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n%wex + + + + + + + + diff --git a/ala-ws-security-plugin/grails-app/controllers/au/org/ala/ws/security/AlaSecurityInterceptor.groovy b/ala-ws-security-plugin/grails-app/controllers/au/org/ala/ws/security/AlaSecurityInterceptor.groovy new file mode 100644 index 00000000..8081595a --- /dev/null +++ b/ala-ws-security-plugin/grails-app/controllers/au/org/ala/ws/security/AlaSecurityInterceptor.groovy @@ -0,0 +1,161 @@ +package au.org.ala.ws.security + + +import au.ala.org.ws.security.RequireApiKey +import au.ala.org.ws.security.SkipApiKeyCheck +import au.org.ala.grails.AnnotationMatcher +import au.org.ala.ws.security.client.AlaAuthClient +import grails.core.GrailsApplication +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import org.pac4j.core.config.Config +import org.pac4j.core.context.WebContext +import org.pac4j.core.credentials.Credentials +import org.pac4j.core.exception.CredentialsException +import org.pac4j.core.profile.ProfileManager +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 org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.http.HttpStatus + +import javax.annotation.PostConstruct + +@CompileStatic +@Slf4j +@EnableConfigurationProperties(JwtProperties) +class AlaSecurityInterceptor { + + @Autowired(required = false) + AlaAuthClient alaAuthClient // Could be any DirectClient? + + @Autowired(required = false) + Config config + + GrailsApplication grailsApplication + + AlaSecurityInterceptor() { +// matchAll() + } + + @PostConstruct + def init() { + AnnotationMatcher.matchAnnotation(this, grailsApplication, RequireApiKey) + } + + /** + * Executed before a matched action + * + * @return Whether the action should continue and execute + */ + boolean before() { + + def matchResult = AnnotationMatcher.getAnnotation(grailsApplication, controllerNamespace, controllerName, actionName, RequireApiKey, SkipApiKeyCheck) + def effectiveAnnotation = matchResult.effectiveAnnotation() + def skipAnnotation = matchResult.overrideAnnotation + + if (effectiveAnnotation && !skipAnnotation && alaAuthClient) { + + boolean authenticated = false + boolean authorised = true + + try { + + WebContext context = FindBest.webContextFactory(null, config, JEEContextFactory.INSTANCE).newContext(request, response) + + Optional optCredentials = alaAuthClient.getCredentials(context, config.sessionStore) + if (optCredentials.isPresent()) { + + authenticated = true + Credentials credentials = optCredentials.get() + + String[] requiredScopes = effectiveAnnotation.scopes() + if (requiredScopes) { + + if (credentials instanceof OidcCredentials) { + + OidcCredentials oidcCredentials = credentials + + authorised = requiredScopes.every { String requiredScope -> + oidcCredentials.accessToken.scope.contains(requiredScope) + } + + if (!authorised) { + log.info "access_token scopes '${oidcCredentials.accessToken.scope}' is missing required scopes ${requiredScopes}" + } + } + } + + if (authorised) { + + Optional optProfile = alaAuthClient.getUserProfile(credentials, context, config.sessionStore) + if (optProfile.isPresent()) { + + UserProfile userProfile = optProfile.get() + + ProfileManager profileManager = new ProfileManager(context, config.sessionStore) + profileManager.setConfig(config) + + profileManager.save( + alaAuthClient.getSaveProfileInSession(context, userProfile), + userProfile, + alaAuthClient.isMultiProfile(context, userProfile) + ) + + String[] requiredRoles = effectiveAnnotation.roles() + + if (requiredRoles) { + authorised = requiredRoles.every() { String requiredRole -> userProfile.roles.contains(requiredRole) } + + if (!authorised) { + log.info "user profile roles '${userProfile.roles}' is missing required scopes ${requiredRoles}" + } + } + } else if (effectiveAnnotation.roles()) { + + authorised = false + log.info "no user profile available missing roles" + } + } + } else { + + log.info "no auth credentials found" + authorised = false + } + + } catch (CredentialsException e) { + + log.info "authentication failed invalid credentials", e + authenticated = false + } + + if (!authenticated) { + + response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()) + return false + } + + if (!authorised) { + + response.sendError(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase()) + return false + } + } + + return true + } + + /** + * Executed after the action executes but prior to view rendering + * + * @return True if view rendering should continue, false otherwise + */ + boolean after() { true } + + /** + * Executed after view rendering completes + */ + void afterView() {} +} diff --git a/grails-app/init/au/org/ala/ws/security/Application.groovy b/ala-ws-security-plugin/grails-app/init/au/org/ala/ws/security/Application.groovy similarity index 100% rename from grails-app/init/au/org/ala/ws/security/Application.groovy rename to ala-ws-security-plugin/grails-app/init/au/org/ala/ws/security/Application.groovy diff --git a/grails-app/init/au/org/ala/ws/security/BootStrap.groovy b/ala-ws-security-plugin/grails-app/init/au/org/ala/ws/security/BootStrap.groovy similarity index 100% rename from grails-app/init/au/org/ala/ws/security/BootStrap.groovy rename to ala-ws-security-plugin/grails-app/init/au/org/ala/ws/security/BootStrap.groovy diff --git a/ala-ws-security-plugin/grails-app/plugin.yml b/ala-ws-security-plugin/grails-app/plugin.yml new file mode 100644 index 00000000..af5f5c99 --- /dev/null +++ b/ala-ws-security-plugin/grails-app/plugin.yml @@ -0,0 +1,3 @@ +security: + auth: + serviceUrl: https://auth.ala.org.au/apikey/ \ No newline at end of file diff --git a/ala-ws-security-plugin/grails-wrapper.jar b/ala-ws-security-plugin/grails-wrapper.jar new file mode 100644 index 00000000..bc85146c Binary files /dev/null and b/ala-ws-security-plugin/grails-wrapper.jar differ diff --git a/ala-ws-security-plugin/grailsw b/ala-ws-security-plugin/grailsw new file mode 100755 index 00000000..c2c921c2 --- /dev/null +++ b/ala-ws-security-plugin/grailsw @@ -0,0 +1,151 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Grails start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRAILS_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-XX:+TieredCompilation" "-XX:TieredStopAtLevel=1" "-XX:CICompilerCount=3"' + + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +JAR_PATH=$APP_HOME/grails-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRAILS_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRAILS_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRAILS_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRAILS_OPTS + +exec "$JAVACMD" -jar "${JVM_OPTS[@]}" "$JAR_PATH" "$@" diff --git a/ala-ws-security-plugin/grailsw.bat b/ala-ws-security-plugin/grailsw.bat new file mode 100755 index 00000000..c48c3840 --- /dev/null +++ b/ala-ws-security-plugin/grailsw.bat @@ -0,0 +1,89 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Grails startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRAILS_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-XX:+TieredCompilation" "-XX:TieredStopAtLevel=1" "-XX:CICompilerCount=3" + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line +set JAR_PATH=%APP_HOME%/grails-wrapper.jar + +@rem Execute Grails +"%JAVA_EXE%" -jar %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRAILS_OPTS% %JAR_PATH% %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRAILS_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRAILS_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPlugin.groovy b/ala-ws-security-plugin/src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPlugin.groovy similarity index 92% rename from src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPlugin.groovy rename to ala-ws-security-plugin/src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPlugin.groovy index ceae6038..6284456f 100644 --- a/src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPlugin.groovy +++ b/ala-ws-security-plugin/src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPlugin.groovy @@ -1,4 +1,4 @@ -import au.ala.org.ws.security.AlaWsSecurityGrailsPluginConfiguration +import au.org.ala.ws.security.AlaWsSecurityConfiguration class AlaWsSecurityGrailsPlugin { // the plugin version @@ -41,7 +41,7 @@ This plugin provides web service specific security code, such as API Key filters } def doWithSpring = { - alaWsSecurityGrailsPluiginConfiguration(AlaWsSecurityGrailsPluginConfiguration) +// alaWsSecurityConfiguration(AlaWsSecurityConfiguration) } def doWithDynamicMethods = { ctx -> diff --git a/src/main/groovy/au/ala/org/ws/security/RequireApiKey.groovy b/ala-ws-security-plugin/src/main/groovy/au/ala/org/ws/security/RequireApiKey.groovy similarity index 95% rename from src/main/groovy/au/ala/org/ws/security/RequireApiKey.groovy rename to ala-ws-security-plugin/src/main/groovy/au/ala/org/ws/security/RequireApiKey.groovy index f10ed0bb..206418bc 100644 --- a/src/main/groovy/au/ala/org/ws/security/RequireApiKey.groovy +++ b/ala-ws-security-plugin/src/main/groovy/au/ala/org/ws/security/RequireApiKey.groovy @@ -12,7 +12,8 @@ import java.lang.annotation.Target @Target([ElementType.TYPE, ElementType.METHOD]) @Retention(RetentionPolicy.RUNTIME) @Documented -public @interface RequireApiKey { +@interface RequireApiKey { + String projectIdParam() default "id" String redirectController() default "project" diff --git a/src/main/groovy/au/ala/org/ws/security/SkipApiKeyCheck.groovy b/ala-ws-security-plugin/src/main/groovy/au/ala/org/ws/security/SkipApiKeyCheck.groovy similarity index 100% rename from src/main/groovy/au/ala/org/ws/security/SkipApiKeyCheck.groovy rename to ala-ws-security-plugin/src/main/groovy/au/ala/org/ws/security/SkipApiKeyCheck.groovy diff --git a/ala-ws-security-plugin/src/main/recources/ehcache3.xml b/ala-ws-security-plugin/src/main/recources/ehcache3.xml new file mode 100644 index 00000000..217dcc82 --- /dev/null +++ b/ala-ws-security-plugin/src/main/recources/ehcache3.xml @@ -0,0 +1,20 @@ + + + + java.lang.String + au.org.ala.ws.security.profile.AlaUserProfile + + 60 + + + 1000 + + + + \ No newline at end of file diff --git a/src/test/groovy/au/org/ala/ws/security/ApiKeyInterceptorSpec.groovy b/ala-ws-security-plugin/src/test/groovy/au/org/ala/ws/security/AlaSecurityInterceptorSpec.groovy similarity index 80% rename from src/test/groovy/au/org/ala/ws/security/ApiKeyInterceptorSpec.groovy rename to ala-ws-security-plugin/src/test/groovy/au/org/ala/ws/security/AlaSecurityInterceptorSpec.groovy index c8a86145..a8bc58d4 100644 --- a/src/test/groovy/au/org/ala/ws/security/ApiKeyInterceptorSpec.groovy +++ b/ala-ws-security-plugin/src/test/groovy/au/org/ala/ws/security/AlaSecurityInterceptorSpec.groovy @@ -2,58 +2,99 @@ package au.org.ala.ws.security import au.ala.org.ws.security.RequireApiKey import au.ala.org.ws.security.SkipApiKeyCheck -import au.org.ala.ws.security.service.ApiKeyService +import au.org.ala.ws.security.authenticator.AlaApiKeyAuthenticator +import au.org.ala.ws.security.authenticator.AlaIpWhitelistAuthenticator +import au.org.ala.ws.security.authenticator.AlaOidcAuthenticator +import au.org.ala.ws.security.client.AlaApiKeyClient +import au.org.ala.ws.security.client.AlaAuthClient +import au.org.ala.ws.security.client.AlaIpWhitelistClient +import au.org.ala.ws.security.client.AlaOidcClient +import au.org.ala.ws.security.credentials.AlaApiKeyCredentialsExtractor +import au.org.ala.ws.security.credentials.AlaOidcCredentialsExtractor +import au.org.ala.ws.security.profile.AlaApiUserProfile + import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jose.jwk.JWKSet import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.jwk.source.RemoteJWKSet import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.oauth2.sdk.id.Issuer import grails.testing.web.interceptor.InterceptorUnitTest import groovy.time.TimeCategory import org.grails.spring.beans.factory.InstanceFactoryBean import org.grails.web.util.GrailsApplicationAttributes -import org.pac4j.core.authorization.generator.FromAttributesAuthorizationGenerator import org.pac4j.core.config.Config -import org.pac4j.http.client.direct.DirectBearerAuthClient +import org.pac4j.core.exception.CredentialsException +import org.pac4j.core.profile.creator.ProfileCreator +import org.pac4j.http.credentials.extractor.IpExtractor import org.pac4j.jee.context.session.JEESessionStore +import org.pac4j.oidc.config.OidcConfiguration +import spock.lang.Shared import spock.lang.Specification import spock.lang.Unroll import static au.org.ala.ws.security.JwtUtils.* @Unroll -class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest { +class AlaSecurityInterceptorSpec extends Specification implements InterceptorUnitTest { - static final int UNAUTHORISED = 403 + static final int UNAUTHORISED = 401 + static final int FORBIDDEN = 403 static final int OK = 200 - ApiKeyService apiKeyService - JWKSet jwkSet = jwkSet('test.jwks') def jwtProperties = new JwtProperties() + @Shared + AlaOidcClient alaOidcClient + + @Shared + AlaApiKeyClient alaApiKeyClient + + @Shared + AlaIpWhitelistClient alaIpWhitelistClient void setup() { + OidcConfiguration oidcConfiguration = Mock() + + GroovyMock(RemoteJWKSet, global: true) + new RemoteJWKSet(_, _) >> new ImmutableJWKSet(jwkSet('test.jwks')) + + ProfileCreator profileCreator = Mock() + + AlaOidcAuthenticator alaOidcAuthenticator = new AlaOidcAuthenticator(oidcConfiguration, profileCreator) + alaOidcAuthenticator.issuer = new Issuer('http://localhost') + alaOidcAuthenticator.expectedJWSAlgs = [ JWSAlgorithm.RS256 ].toSet() + alaOidcAuthenticator.requiredClaims = [] + alaOidcAuthenticator.keySource = new ImmutableJWKSet(jwkSet) + + AlaApiKeyAuthenticator alaApiKeyAuthenticator = Stub(AlaApiKeyAuthenticator) { + validate(_, _, _) >> { args -> + if (args[0].token == 'valid') { + args[0].userProfile = new AlaApiUserProfile(email: 'email@test.com', givenName: 'first_name', familyName: 'last_name') + } else { + throw new CredentialsException("invalid apikey: '${args[0].token}'") + } + } + } + + AlaIpWhitelistAuthenticator alaIpWhitelistAuthenticator = new AlaIpWhitelistAuthenticator() + alaIpWhitelistAuthenticator.ipWhitelist = [ '2.2.2.2', '3.3.3.3' ] + + alaOidcClient = new AlaOidcClient(new AlaOidcCredentialsExtractor(), alaOidcAuthenticator) + alaApiKeyClient = new AlaApiKeyClient(new AlaApiKeyCredentialsExtractor(), alaApiKeyAuthenticator) + alaIpWhitelistClient = new AlaIpWhitelistClient(new IpExtractor(), alaIpWhitelistAuthenticator) + defineBeans { config(InstanceFactoryBean, new Config().tap { sessionStore = JEESessionStore.INSTANCE }) - directBearerAuthClient(InstanceFactoryBean, - new DirectBearerAuthClient( - new JwtAuthenticator( - 'http://localhost', - jwtProperties.getRequiredClaims(), - [JWSAlgorithm.RS256].toSet(), - new ImmutableJWKSet(jwkSet)) - ).tap { - it.addAuthorizationGenerator(new FromAttributesAuthorizationGenerator(['role'],['scope', 'scp'])) - }) + alaAuthClient(InstanceFactoryBean, new AlaAuthClient().tap { + + it.authClients = [ alaOidcClient, alaApiKeyClient, alaIpWhitelistClient ] + }) + jwtProperties(InstanceFactoryBean, jwtProperties) } - // grailsApplication is not isolated in unit tests, so clear the ip.whitelist property to avoid polluting independent tests - grailsApplication.config.security.apikey.ip = [whitelist: ""] - apiKeyService = Stub(ApiKeyService) - apiKeyService.checkApiKey(_) >> { String key -> [valid: (key == "valid")] } - - interceptor.apiKeyService = apiKeyService } void "All methods of a controller annotated with RequireApiKey at the class level should be protected"() { @@ -73,6 +114,8 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest def result = interceptor.before() then: +// 1 * alaAuthClient.getCredentials(_, _) >> Optional.empty() + result == before response.status == responseCode @@ -88,6 +131,11 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest // unless we manually add the dummy controller class used in this test grailsApplication.addArtefact("Controller", AnnotatedMethodController) + AlaApiKeyAuthenticator alaApiKeyAuthenticator = Spy() + AlaApiKeyClient apiKeyClient = new AlaApiKeyClient(new AlaApiKeyCredentialsExtractor(), alaApiKeyAuthenticator) + interceptor.alaAuthClient = new AlaAuthClient() + interceptor.alaAuthClient.authClients = [ apiKeyClient ] + AnnotatedMethodController controller = new AnnotatedMethodController() when: @@ -99,6 +147,7 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest def result = interceptor.before() then: + alaApiKeyAuthenticator.validate(_, _, _) >> { throw new CredentialsException('invalid apikey')} result == before response.status == responseCode @@ -163,7 +212,9 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest grailsApplication.addArtefact("Controller", AnnotatedClassController) AnnotatedClassController controller = new AnnotatedClassController() - grailsApplication.config.security.apikey.header.alternatives = ['Authorization'] + + alaApiKeyClient.credentialsExtractor.alternativeHeaderNames = [ 'Authorization' ] +// grailsApplication.config.security.apikey.header.alternatives = [ 'Authorization' ] when: request.addHeader("Authorization", "valid") @@ -192,8 +243,7 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest AnnotatedClassController controller = new AnnotatedClassController() when: - grailsApplication.config.security.apikey.ip = [whitelist: "2.2.2.2, 3.3.3.3"] - request.remoteHost = ipAddress + request.remoteAddr = ipAddress request.setAttribute(GrailsApplicationAttributes.CONTROLLER_NAME_ATTRIBUTE, 'annotatedClass') request.setAttribute(GrailsApplicationAttributes.ACTION_NAME_ATTRIBUTE, action) @@ -220,7 +270,7 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest AnnotatedClassController controller = new AnnotatedClassController() when: - request.remoteHost = ipAddress + request.remoteAddr = ipAddress request.setAttribute(GrailsApplicationAttributes.CONTROLLER_NAME_ATTRIBUTE, 'annotatedClass') request.setAttribute(GrailsApplicationAttributes.ACTION_NAME_ATTRIBUTE, action) @@ -249,7 +299,7 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest when: request.addHeader("X-Forwarded-For", ipAddress) - request.remoteHost = "1.2.3.4" + request.remoteAddr = "1.2.3.4" request.setAttribute(GrailsApplicationAttributes.CONTROLLER_NAME_ATTRIBUTE, 'annotatedClass') request.setAttribute(GrailsApplicationAttributes.ACTION_NAME_ATTRIBUTE, action) @@ -316,8 +366,8 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest where: action | responseCode | before - "action1" | UNAUTHORISED | false - "action2" | UNAUTHORISED | false + "action1" | FORBIDDEN | false + "action2" | FORBIDDEN | false } void "Secured methods should be inaccessible when given a valid JWT without the required scopes from properties"() { @@ -327,7 +377,8 @@ class ApiKeyInterceptorSpec extends Specification implements InterceptorUnitTest grailsApplication.addArtefact("Controller", AnnotatedClassController) AnnotatedClassController controller = new AnnotatedClassController() - interceptor.jwtProperties.requiredScopes += 'missing' + + alaOidcClient.authenticator.requiredScopes = [ 'missing' ] when: request.addHeader("Authorization", "Bearer ${generateJwt(jwkSet)}") diff --git a/src/test/groovy/au/org/ala/ws/security/JwtUtils.groovy b/ala-ws-security-plugin/src/test/groovy/au/org/ala/ws/security/JwtUtils.groovy similarity index 100% rename from src/test/groovy/au/org/ala/ws/security/JwtUtils.groovy rename to ala-ws-security-plugin/src/test/groovy/au/org/ala/ws/security/JwtUtils.groovy diff --git a/src/test/resources/test.jwks b/ala-ws-security-plugin/src/test/resources/test.jwks similarity index 100% rename from src/test/resources/test.jwks rename to ala-ws-security-plugin/src/test/resources/test.jwks diff --git a/src/test/resources/wrong-test.jwks b/ala-ws-security-plugin/src/test/resources/wrong-test.jwks similarity index 100% rename from src/test/resources/wrong-test.jwks rename to ala-ws-security-plugin/src/test/resources/wrong-test.jwks diff --git a/ala-ws-security/build.gradle b/ala-ws-security/build.gradle new file mode 100644 index 00000000..e14eb94e --- /dev/null +++ b/ala-ws-security/build.gradle @@ -0,0 +1,116 @@ +plugins { + id("org.springframework.boot") version "2.7.0" apply false + id("io.spring.dependency-management") version "1.0.13.RELEASE" + + id 'groovy' + id 'java-library' + + id 'maven-publish' +} + + +group "au.org.ala" + +dependencyManagement { + imports { + mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES) + } +} + +repositories { + mavenLocal() + maven { url "https://nexus.ala.org.au/content/groups/public/" } + mavenCentral() +} + +java { + withJavadocJar() + withSourcesJar() +} + +dependencies { + + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + compileOnly "org.springframework.boot:spring-boot-configuration-processor" + + + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework:spring-web' + implementation 'org.springframework:spring-context-support' + + implementation 'org.ehcache:ehcache' +// compileOnly 'org.springframework.boot:spring-boot-starter-security' + compileOnly 'javax.servlet:javax.servlet-api:4.0.1' + + api project(':userdetails-service-client') + + implementation 'com.github.seancfoley:ipaddress:5.3.4' + + api(pac4j.oidc) + api(pac4j.jwt) + api(pac4j.http) + api(pac4j.jee) + api(pac4j.jee.support) + + // mandatory dependencies for using Spock +// testImplementation 'org.junit.jupiter:junit-jupiter:5.8.2' +// implementation platform('org.apache.groovy:groovy-bom:4.0.5') +// implementation 'org.apache.groovy:groovy' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.codehaus.groovy:groovy-all:3.0.11' + testImplementation platform("org.spockframework:spock-bom:2.3-groovy-3.0") + testImplementation "org.spockframework:spock-core" + testImplementation "org.spockframework:spock-junit4" // you can remove this if your code does not rely on old JUnit 4 rules + + testImplementation "org.mockito:mockito-core:4.6.1" + testImplementation 'javax.servlet:javax.servlet-api:4.0.1' + testImplementation "co.infinum:retromock:1.1.1" + testImplementation 'com.squareup.retrofit2:retrofit-mock:2.9.0' +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } +} + +publishing { + repositories { + maven { + name 'Nexus' + url "https://nexus.ala.org.au/content/repositories/${project.version.endsWith('-SNAPSHOT') ? 'snapshots' : 'releases' }" + credentials { + username = System.getenv('TRAVIS_DEPLOY_USERNAME') + password = System.getenv('TRAVIS_DEPLOY_PASSWORD') + } + } + } + publications { + maven(MavenPublication) { + from components.java +// artifact sourcesJar +// artifact javadocJar + + pom { + name = 'ALA WS Security Library' + description = 'Library to authenticate web service calls for ALA systems' + url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin' + licenses { + license { + name = 'MPL-1.1' + url = 'https://www.mozilla.org/en-US/MPL/1.1/' + } + } + developers { + } + scm { + connection = 'scm:git:git://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin.git' + developerConnection = 'scm:git:ssh://github.com:AtlasOfLivingAustralia/ala-ws-security-plugin.git' + url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin/tree/main' + } + } + } + } +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/AlaWsSecurityConfiguration.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/AlaWsSecurityConfiguration.java new file mode 100644 index 00000000..21930762 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/AlaWsSecurityConfiguration.java @@ -0,0 +1,221 @@ +package au.org.ala.ws.security; + +import au.org.ala.ws.security.authenticator.AlaApiKeyAuthenticator; +import au.org.ala.ws.security.authenticator.AlaIpWhitelistAuthenticator; +import au.org.ala.ws.security.authenticator.AlaOidcAuthenticator; +import au.org.ala.ws.security.client.AlaApiKeyClient; +import au.org.ala.ws.security.client.AlaAuthClient; +import au.org.ala.ws.security.client.AlaDirectClient; +import au.org.ala.ws.security.client.AlaIpWhitelistClient; +import au.org.ala.ws.security.client.AlaOidcClient; +import au.org.ala.ws.security.credentials.AlaApiKeyCredentialsExtractor; +import au.org.ala.ws.security.credentials.AlaOidcCredentialsExtractor; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.DefaultResourceRetriever; +import com.nimbusds.jose.util.ResourceRetriever; +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata; +import org.ehcache.Cache; +import org.ehcache.config.CacheConfiguration; +import org.ehcache.config.builders.CacheConfigurationBuilder; +import org.ehcache.config.builders.CacheManagerBuilder; +import org.ehcache.config.builders.ExpiryPolicyBuilder; +import org.ehcache.config.builders.ResourcePoolsBuilder; +import org.pac4j.core.authorization.generator.FromAttributesAuthorizationGenerator; +import org.pac4j.core.client.Client; +import org.pac4j.core.config.Config; +import org.pac4j.core.context.WebContextFactory; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.profile.creator.ProfileCreator; +import org.pac4j.http.credentials.extractor.IpExtractor; +import org.pac4j.jee.context.JEEContextFactory; +import org.pac4j.jee.context.session.JEESessionStore; +import org.pac4j.oidc.client.OidcClient; +import org.pac4j.oidc.config.OidcConfiguration; +import org.pac4j.oidc.profile.creator.OidcProfileCreator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.cache.CacheManager; +import org.springframework.cache.ehcache.EhCacheCacheManager; +import org.springframework.cache.jcache.JCacheCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; + +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Configuration +@EnableConfigurationProperties({JwtProperties.class, ApiKeyProperties.class, IpWhitelistProperties.class}) +public class AlaWsSecurityConfiguration { + + private static final String JWT_CLIENT = "JwtClient"; + @Autowired + private JwtProperties jwtProperties; + @Autowired + private ApiKeyProperties apiKeyProperties; + @Autowired + private IpWhitelistProperties ipWhitelistProperties; + + @Value("${info.app.name:Unknown-App}") + String name; + @Value("${info.app.version:1}") + String version; + + @Bean + @ConditionalOnMissingBean + public SessionStore sessionStore() { + return JEESessionStore.INSTANCE; + } + + @Bean + @ConditionalOnMissingBean + public WebContextFactory webContextFactory() { + return JEEContextFactory.INSTANCE; + } + + @Bean + @ConditionalOnMissingBean + public Config pac4jConfig(List clients, SessionStore sessionStore, WebContextFactory webContextFactory) { + Config config = new Config(clients); + + config.setSessionStore(sessionStore); + config.setWebContextFactory(webContextFactory); + return config; + } + + @Bean + @ConditionalOnProperty(prefix = "security.jwt", name="enabled") + public ResourceRetriever jwtResourceRetriever() { + DefaultResourceRetriever resourceRetriever = new DefaultResourceRetriever(jwtProperties.getConnectTimeoutMs(), jwtProperties.getReadTimeoutMs()); + String userAgent = name+"/"+version; + resourceRetriever.setHeaders(Map.of(HttpHeaders.USER_AGENT, List.of(userAgent))); + return resourceRetriever; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "security.jwt", name = "enabled") + public OidcConfiguration oidcConfiguration(ResourceRetriever jwtResourceRetriever) { + + OidcConfiguration oidcConfig = new OidcConfiguration(); + oidcConfig.setDiscoveryURI(jwtProperties.getDiscoveryUri()); + oidcConfig.setClientId(jwtProperties.getClientId()); + oidcConfig.setConnectTimeout(jwtProperties.getConnectTimeoutMs()); + oidcConfig.setReadTimeout(jwtProperties.getReadTimeoutMs()); + + oidcConfig.setResourceRetriever(jwtResourceRetriever); + + return oidcConfig; + } + + @Bean + JWKSource jwkSource(OidcConfiguration oidcConfiguration) { + OIDCProviderMetadata providerMetadata = oidcConfiguration.findProviderMetadata(); + URL keySourceUrl; + try { + keySourceUrl = providerMetadata.getJWKSetURI().toURL(); + } catch (MalformedURLException e) { + throw new RuntimeException("shouldn't happen", e); + } + return new RemoteJWKSet<>(keySourceUrl, oidcConfiguration.findResourceRetriever()); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty("security.jwt.enabled") + public AlaOidcClient getAlaOidcClient(OidcConfiguration oidcConfiguration, JWKSource jwkSource, CacheManager cacheManager) { + + AlaOidcCredentialsExtractor credentialsExtractor = new AlaOidcCredentialsExtractor(); + ProfileCreator profileCreator = new OidcProfileCreator(oidcConfiguration, new OidcClient()); + + AlaOidcAuthenticator authenticator = new AlaOidcAuthenticator(oidcConfiguration, profileCreator); + OIDCProviderMetadata providerMetadata = oidcConfiguration.findProviderMetadata(); + authenticator.setIssuer(providerMetadata.getIssuer()); + authenticator.setExpectedJWSAlgs(Set.copyOf(providerMetadata.getIDTokenJWSAlgs())); + + authenticator.setKeySource(jwkSource); + authenticator.setAuthorizationGenerator(new FromAttributesAuthorizationGenerator(jwtProperties.getRoleClaims(), jwtProperties.getPermissionClaims())); + + authenticator.setUserIdClaim(jwtProperties.getUserIdClaim()); + authenticator.setRequiredClaims(jwtProperties.getRequiredClaims()); + authenticator.setRequiredScopes(jwtProperties.getRequiredScopes()); + + authenticator.setRolesFromAccessToken(jwtProperties.isRolesFromAccessToken()); + if (authenticator.isRolesFromAccessToken()) { + authenticator.setAccessTokenRoleClaims(jwtProperties.getRoleClaims()); + } + + authenticator.setRolePrefix(jwtProperties.getRolePrefix()); + authenticator.setRoleToUppercase(jwtProperties.isRoleToUppercase()); + + authenticator.setCacheManager(cacheManager); + + return new AlaOidcClient(credentialsExtractor, authenticator); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "security.apikey", name = "enabled") + public AlaApiKeyClient getAlaApiKeyClient(ApiKeyClient apiKeyClient) { + + AlaApiKeyCredentialsExtractor credentialsExtractor = new AlaApiKeyCredentialsExtractor(); + credentialsExtractor.setHeaderName(apiKeyProperties.getHeader().getOverride()); + credentialsExtractor.setAlternativeHeaderNames(apiKeyProperties.getHeader().getAlternatives()); + + AlaApiKeyAuthenticator authenticator = new AlaApiKeyAuthenticator(); + authenticator.setApiKeyClient(apiKeyClient); + + return new AlaApiKeyClient(credentialsExtractor, authenticator); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty("security.ip.whitelist") + public AlaIpWhitelistClient getAlaIpWhitelistClient() { + + IpExtractor credentialsExtractor = new IpExtractor(); + + AlaIpWhitelistAuthenticator authenticator = new AlaIpWhitelistAuthenticator(); + authenticator.setIpWhitelist(ipWhitelistProperties.getWhitelist()); + + return new AlaIpWhitelistClient(credentialsExtractor, authenticator); + } + + @Bean + @ConditionalOnMissingBean + public AlaAuthClient getAlaAuthClient(List authClients) { + + AlaAuthClient authClient = new AlaAuthClient(); + authClient.setAuthClients(authClients); + + return authClient; + } + + @Bean + @ConditionalOnProperty(prefix = "security.jwt", name = "enabled") + public FilterRegistrationBean pac4jHttpRequestWrapper(Config config) { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>(); + filterRegistrationBean.setFilter(new Pac4jProfileManagerHttpRequestWrapperFilter(config)); + filterRegistrationBean.setOrder(filterOrder() + 6);// This is to place this filter after the request wrapper filter in the ala-auth-plugin + filterRegistrationBean.setInitParameters(new LinkedHashMap()); + filterRegistrationBean.addUrlPatterns("/*"); + return filterRegistrationBean; + } + + public static int filterOrder() { + + return 21;// FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER + 21 + } + +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/AlaWsSecurityRetrofitConfiguration.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/AlaWsSecurityRetrofitConfiguration.java new file mode 100644 index 00000000..e6b66853 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/AlaWsSecurityRetrofitConfiguration.java @@ -0,0 +1,130 @@ +package au.org.ala.ws.security; + +import au.org.ala.userdetails.UserDetailsClient; +import com.google.common.collect.Lists; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter; +import okhttp3.Call; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import retrofit2.Retrofit; +import retrofit2.converter.moshi.MoshiConverterFactory; + +import java.util.Date; +import java.util.List; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + * // TODO a lot of this is common with the ala-auth plugin, + * so it might need to be separated into it's own library + */ +@Configuration +@EnableConfigurationProperties({ApiKeyProperties.class, UserDetailsProperties.class}) +public class AlaWsSecurityRetrofitConfiguration { + + @Autowired + private ApiKeyProperties apiKeyProperties; + @Autowired + private UserDetailsProperties userDetailsProperties; + + @Value("${info.app.name:Unknown-App}") + String name; + + @Value("${info.app.version:1}") + String version; + + @Bean("userAgentInterceptor") + @ConditionalOnMissingBean(name = "userAgentInterceptor") + Interceptor userAgentInterceptor() { + String userAgent = name + "/" + version; + return chain -> chain.proceed( + chain.request().newBuilder() + .header("User-Agent", userAgent) + .build() + ); + } + + /** + * To use this with a client credentials token, you must provide a jwtInterceptor. + * The ALA Auth and WS plugins will provide one by default but non Grails apps + * will need to provide their own + * @param jwtInterceptor The okhttp interceptor that inserts a bearer token onto the request + * @param userAgentInterceptor okhttp interceptor that puts the UserAgent on the request + * @return All interceptors for the userdetails ws client + */ + @Bean + @ConditionalOnMissingBean(name = "userDetailsInterceptors") + List userDetailsInterceptors( + @Autowired(required = false) @Qualifier("jwtInterceptor") Interceptor jwtInterceptor, + @Qualifier("userAgentInterceptor") Interceptor userAgentInterceptor) { + var result= Lists.newArrayList(userAgentInterceptor); + if (jwtInterceptor != null) { + result.add(jwtInterceptor); + } + return result; + } + + @ConditionalOnMissingBean(name = "userDetailsHttpClient") + @Bean(name = {"defaultUserDetailsHttpClient", "userDetailsHttpClient"}) + OkHttpClient userDetailsHttpClient(@Qualifier("userDetailsInterceptors") List userDetailsInterceptors) { + var builder = new OkHttpClient.Builder() + .readTimeout(userDetailsProperties.getReadTimeout(), MILLISECONDS); + for (var interceptor : userDetailsInterceptors) { + builder.addInterceptor(interceptor); + } + return builder.build(); + } + + @ConditionalOnMissingBean(name = "apikeyHttpClient") + @ConditionalOnProperty(prefix = "security.apikey", name = "enabled") + @Bean(name = {"defaultApikeyHttpClient", "apikeyHttpClient"}) + OkHttpClient apikeyHttpClient(@Qualifier("userAgentInterceptor") Interceptor userAgentInterceptor) { + return new OkHttpClient.Builder() + .readTimeout(userDetailsProperties.getReadTimeout(), MILLISECONDS) + .addInterceptor(userAgentInterceptor) + .build(); + } + + + @ConditionalOnMissingBean(name = "userDetailsMoshi") + @Bean(name = { "defaultUserDetailsMoshi", "userDetailsMoshi" }) + Moshi userDetailsMoshi() { + return new Moshi.Builder() + .add(Date.class, new Rfc3339DateJsonAdapter().nullSafe()) + .build(); + } + + @Bean + @ConditionalOnMissingBean + public UserDetailsClient userDetailsClient( + @Qualifier("userDetailsHttpClient") OkHttpClient userDetailsHttpClient, + @Qualifier("userDetailsMoshi") Moshi userDetailsMoshi) { + return new UserDetailsClient.Builder((Call.Factory) userDetailsHttpClient, userDetailsProperties.getUrl()) + .moshi(userDetailsMoshi) + .build(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "security.apikey", name = "enabled") + public ApiKeyClient apiKeyClient( + @Qualifier("apikeyHttpClient") OkHttpClient apikeyHttpClient, + @Qualifier("userDetailsMoshi") Moshi userDetailsMoshi) { + return new Retrofit.Builder() + .baseUrl(apiKeyProperties.getAuth().getServiceUrl()) + .addConverterFactory(MoshiConverterFactory.create(userDetailsMoshi)) + .client(apikeyHttpClient) + .build() + .create(ApiKeyClient.class); + } + +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/ApiKeyClient.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/ApiKeyClient.java new file mode 100644 index 00000000..9b4fbe7f --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/ApiKeyClient.java @@ -0,0 +1,10 @@ +package au.org.ala.ws.security; + +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Query; + +public interface ApiKeyClient { + @GET("ws/check") + public abstract Call checkApiKey(@Query("apikey") String apiKey); +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/ApiKeyProperties.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/ApiKeyProperties.java new file mode 100644 index 00000000..595e2673 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/ApiKeyProperties.java @@ -0,0 +1,75 @@ +package au.org.ala.ws.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(value = "security.apikey") +public class ApiKeyProperties { + + private boolean enabled = true; + + private HeaderProperties header = new HeaderProperties(); + + private WebServiceProperties auth = new WebServiceProperties(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public HeaderProperties getHeader() { + return header; + } + + public void setHeader(HeaderProperties header) { + this.header = header; + } + + public WebServiceProperties getAuth() { + return auth; + } + + public void setAuth(WebServiceProperties auth) { + this.auth = auth; + } + + public class HeaderProperties { + + private String override = "apiKey"; + + private List alternatives = List.of(); + + public String getOverride() { + return override; + } + + public void setOverride(String override) { + this.override = override; + } + + public List getAlternatives() { + return alternatives; + } + + public void setAlternatives(List alternatives) { + this.alternatives = alternatives; + } + } + + public class WebServiceProperties { + + private String serviceUrl = "https://auth.ala.org.au/apikey/"; + + public String getServiceUrl() { + return serviceUrl; + } + + public void setServiceUrl(String serviceUrl) { + this.serviceUrl = serviceUrl; + } + } +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/CheckApiKeyResult.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/CheckApiKeyResult.java new file mode 100644 index 00000000..b0dcfa72 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/CheckApiKeyResult.java @@ -0,0 +1,35 @@ +package au.org.ala.ws.security; + +public class CheckApiKeyResult { + public boolean getValid() { + return valid; + } + + public boolean isValid() { + return valid; + } + + public void setValid(boolean valid) { + this.valid = valid; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + private boolean valid; + private String userId; + private String email; +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/IpWhitelistProperties.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/IpWhitelistProperties.java new file mode 100644 index 00000000..7baa2806 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/IpWhitelistProperties.java @@ -0,0 +1,19 @@ +package au.org.ala.ws.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(value = "security.ip") +public class IpWhitelistProperties { + + private List whitelist = List.of(); + + public List getWhitelist() { + return whitelist; + } + + public void setWhitelist(List whitelist) { + this.whitelist = whitelist; + } +} diff --git a/src/main/java/au/org/ala/ws/security/JwtAuthenticator.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/JwtAuthenticator.java similarity index 99% rename from src/main/java/au/org/ala/ws/security/JwtAuthenticator.java rename to ala-ws-security/src/main/java/au/org/ala/ws/security/JwtAuthenticator.java index 65784d68..0147f43e 100644 --- a/src/main/java/au/org/ala/ws/security/JwtAuthenticator.java +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/JwtAuthenticator.java @@ -145,7 +145,7 @@ public void validate(final Credentials cred, final WebContext context, final Ses // Set the required "typ" header "at+jwt" for access tokens issued by the // Connect2id server, may not be set by other servers - jwtProcessor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType(jwtType))); +// jwtProcessor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType(jwtType))); // The expected JWS algorithm of the access tokens (agreed out-of-band) // JWSAlgorithm expectedJWSAlg = JWSAlgorithm.RS256; diff --git a/src/main/java/au/org/ala/ws/security/JwtProperties.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/JwtProperties.java similarity index 63% rename from src/main/java/au/org/ala/ws/security/JwtProperties.java rename to ala-ws-security/src/main/java/au/org/ala/ws/security/JwtProperties.java index 5b740e7d..37a9b4e0 100644 --- a/src/main/java/au/org/ala/ws/security/JwtProperties.java +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/JwtProperties.java @@ -15,8 +15,14 @@ public class JwtProperties { private String jwtType = "jwt"; private int connectTimeoutMs = HttpConstants.DEFAULT_CONNECT_TIMEOUT;; private int readTimeoutMs = HttpConstants.DEFAULT_READ_TIMEOUT; - private List roleAttributes = List.of("role"); - private List permissionAttributes = List.of("scope","scp", "scopes"); + + private boolean rolesFromAccessToken = true; + private String rolePrefix = "ROLE_"; + private boolean roleToUppercase = true; + private List roleClaims = List.of("role"); + private List permissionClaims = List.of("scope","scp", "scopes"); + + private String userIdClaim = "userid"; private List requiredClaims = List.of("sub", "iat", "exp", "client_id", "jti", "iss"); private List requiredScopes = List.of(); private List urlPatterns = List.of(); // hard coded paths to apply JWT authentication to @@ -85,20 +91,62 @@ public void setJwtType(String jwtType) { this.jwtType = jwtType; } - public List getRoleAttributes() { - return roleAttributes; + public boolean isRolesFromAccessToken() { + return rolesFromAccessToken; + } + + public void setRolesFromAccessToken(boolean rolesFromAccessToken) { + this.rolesFromAccessToken = rolesFromAccessToken; + } + + public String getRolePrefix() { + return rolePrefix; + } + + public void setRolePrefix(String rolePrefix) { + this.rolePrefix = rolePrefix; + } + + public boolean isRoleToUppercase() { + return roleToUppercase; + } + + public void setRoleToUppercase(boolean roleToUppercase) { + this.roleToUppercase = roleToUppercase; + } + + public List getRoleClaims() { + return roleClaims; + } + + @Deprecated + public void setRoleAttributes(List roleClaims) { + this.roleClaims = roleClaims; + } + + public void setRoleClaims(List roleClaims) { + this.roleClaims = roleClaims; + } + + public List getPermissionClaims() { + return permissionClaims; + } + + public void setPermissionAttibutes(List permissionClaims) { + this.permissionClaims = permissionClaims; } - public void setRoleAttributes(List roleAttributes) { - this.roleAttributes = roleAttributes; + @Deprecated + public void setPermissionClaims(List permissionClaims) { + this.permissionClaims = permissionClaims; } - public List getPermissionAttributes() { - return permissionAttributes; + public String getUserIdClaim() { + return userIdClaim; } - public void setPermissionAttributes(List permissionAttributes) { - this.permissionAttributes = permissionAttributes; + public void setUserIdClaim(String userIdClaim) { + this.userIdClaim = userIdClaim; } public List getRequiredClaims() { diff --git a/src/main/java/au/org/ala/ws/security/Pac4jProfileManagerHttpRequestWrapper.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/Pac4jProfileManagerHttpRequestWrapper.java similarity index 100% rename from src/main/java/au/org/ala/ws/security/Pac4jProfileManagerHttpRequestWrapper.java rename to ala-ws-security/src/main/java/au/org/ala/ws/security/Pac4jProfileManagerHttpRequestWrapper.java diff --git a/src/main/java/au/org/ala/ws/security/Pac4jProfileManagerHttpRequestWrapperFilter.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/Pac4jProfileManagerHttpRequestWrapperFilter.java similarity index 100% rename from src/main/java/au/org/ala/ws/security/Pac4jProfileManagerHttpRequestWrapperFilter.java rename to ala-ws-security/src/main/java/au/org/ala/ws/security/Pac4jProfileManagerHttpRequestWrapperFilter.java diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/UserDetailsProperties.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/UserDetailsProperties.java new file mode 100644 index 00000000..3a596e52 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/UserDetailsProperties.java @@ -0,0 +1,27 @@ +package au.org.ala.ws.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(value = "userdetails") +public class UserDetailsProperties { + + private String url = "https://auth.ala.org.au/userdetails/"; + + private long readTimeout = 10_000; + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public long getReadTimeout() { + return readTimeout; + } + + public void setReadTimeout(long readTimeout) { + this.readTimeout = readTimeout; + } +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaApiKeyAuthenticator.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaApiKeyAuthenticator.java new file mode 100644 index 00000000..856be5d4 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaApiKeyAuthenticator.java @@ -0,0 +1,133 @@ +package au.org.ala.ws.security.authenticator; + +import au.org.ala.userdetails.UserDetailsClient; +import au.org.ala.web.UserDetails; +import au.org.ala.ws.security.ApiKeyClient; +import au.org.ala.ws.security.CheckApiKeyResult; +import au.org.ala.ws.security.profile.AlaApiUserProfile; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.credentials.TokenCredentials; +import org.pac4j.core.credentials.authenticator.Authenticator; +import org.pac4j.core.exception.CredentialsException; +import org.pac4j.core.profile.definition.CommonProfileDefinition; +import org.pac4j.core.util.CommonHelper; +import org.pac4j.core.util.InitializableObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import retrofit2.Call; +import retrofit2.Response; + +import java.io.IOException; +import java.util.LinkedHashMap; + +public class AlaApiKeyAuthenticator extends InitializableObject implements Authenticator { + + public static final Logger log = LoggerFactory.getLogger(AlaApiKeyAuthenticator.class); + + @Override + protected void internalInit(boolean forceReinit) { + CommonHelper.assertNotNull("apiKeyClient", apiKeyClient); + CommonHelper.assertNotNull("userDetailsClient", userDetailsClient); + } + + @Override + public void validate(Credentials credentials, WebContext context, SessionStore sessionStore) { + + init(); + + TokenCredentials alaApiKeyCredentials = (TokenCredentials) credentials; + + AlaApiUserProfile alaApiUserProfile = null; + try { + alaApiUserProfile = fetchUserProfile(alaApiKeyCredentials.getToken()); + } catch (IOException e) { + log.warn("Couldn't fetch user profile", e); + throw new CredentialsException("Coudln't fetch user profile"); + } + + if (alaApiUserProfile.isActivated() && !alaApiUserProfile.isLocked()) { + + alaApiKeyCredentials.setUserProfile(alaApiUserProfile); + } + + } + + public AlaApiUserProfile fetchUserProfile(final String apiKey) throws IOException { + + AlaApiUserProfile alaApiUserProfile = new AlaApiUserProfile(); + + Call checkApiKeyCall = apiKeyClient.checkApiKey(apiKey); + + final Response checkApiKeyResponse = checkApiKeyCall.execute(); + + if (!checkApiKeyResponse.isSuccessful()) { + throw new CredentialsException("apikey check failed : " + checkApiKeyResponse.message()); + } + + + CheckApiKeyResult apiKeyCheck = checkApiKeyResponse.body(); + + if (apiKeyCheck.getValid()) { + + String userId = apiKeyCheck.getUserId(); + + alaApiUserProfile.setUserId(userId); + alaApiUserProfile.setEmail(apiKeyCheck.getEmail()); + + Call userDetailsCall = userDetailsClient.getUserDetails(userId, true); + + Response response = userDetailsCall.execute(); + + if (response.isSuccessful()) { + + UserDetails userDetails = response.body(); + + alaApiUserProfile.setGivenName(userDetails.getFirstName()); + alaApiUserProfile.setFamilyName(userDetails.getLastName()); + alaApiUserProfile.setActivated(userDetails.getActivated()); + final Boolean locked = userDetails.getLocked(); + alaApiUserProfile.setLocked(locked != null ? locked : true); + alaApiUserProfile.addRoles(userDetails.getRoles()); + + // The attributes map doesn't appear to be used but just in case... + var attributes = new LinkedHashMap(); + attributes.put(CommonProfileDefinition.FIRST_NAME, userDetails.getFirstName()); + attributes.put(CommonProfileDefinition.FAMILY_NAME, userDetails.getLastName()); + attributes.put(CommonProfileDefinition.EMAIL, userDetails.getEmail()); + attributes.put("activated", userDetails.getActivated()); + attributes.put("locked", userDetails.getLocked()); + attributes.put("roles", userDetails.getRoles()); + attributes.putAll(userDetails.getProps()); + + alaApiUserProfile.setAttributes(attributes); + } + + + return alaApiUserProfile; + } + + + throw new CredentialsException("invalid apiKey: '" + apiKey + "'"); + } + + public ApiKeyClient getApiKeyClient() { + return apiKeyClient; + } + + public void setApiKeyClient(ApiKeyClient apiKeyClient) { + this.apiKeyClient = apiKeyClient; + } + + public UserDetailsClient getUserDetailsClient() { + return userDetailsClient; + } + + public void setUserDetailsClient(UserDetailsClient userDetailsClient) { + this.userDetailsClient = userDetailsClient; + } + + private ApiKeyClient apiKeyClient; + private UserDetailsClient userDetailsClient; +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaIpWhitelistAuthenticator.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaIpWhitelistAuthenticator.java new file mode 100644 index 00000000..958137f2 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaIpWhitelistAuthenticator.java @@ -0,0 +1,47 @@ +package au.org.ala.ws.security.authenticator; + +import inet.ipaddr.IPAddressString; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.credentials.TokenCredentials; +import org.pac4j.core.credentials.authenticator.Authenticator; +import org.pac4j.core.exception.CredentialsException; +import org.pac4j.core.util.InitializableObject; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +public class AlaIpWhitelistAuthenticator extends InitializableObject implements Authenticator { + private static final List LOOPBACK_ADDRESSES = Arrays.asList("127.0.0.1", + "0:0:0:0:0:0:0:1", + "::1"); + + private List ipMatches = LOOPBACK_ADDRESSES.stream().map(IPAddressString::new).collect(Collectors.toList()); + + public void setIpWhitelist(Collection ipWhitelist) { + + ArrayList result = new ArrayList<>(ipMatches.size() + ipWhitelist.size()); + result.addAll(ipMatches); + ipWhitelist.forEach(s -> result.add(new IPAddressString(s))); + ipMatches = result; + } + + @Override + public void validate(Credentials credentials, WebContext context, SessionStore sessionStore) { + + final IPAddressString ip = new IPAddressString(((TokenCredentials) credentials).getToken()); + + if (ipMatches.stream().noneMatch(ipMatcher -> ipMatcher.contains(ip))) { + throw new CredentialsException("Unauthorized IP address: " + ip); + } + } + + @Override + protected void internalInit(boolean forceReinit) { + + } +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaOidcAuthenticator.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaOidcAuthenticator.java new file mode 100644 index 00000000..4becb612 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/authenticator/AlaOidcAuthenticator.java @@ -0,0 +1,397 @@ +package au.org.ala.ws.security.authenticator; + +import au.org.ala.ws.security.profile.AlaOidcUserProfile; +import au.org.ala.ws.security.profile.AlaUserProfile; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.BadJOSEException; +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.Scope; +import com.nimbusds.oauth2.sdk.id.Identifier; +import com.nimbusds.oauth2.sdk.id.Issuer; +import com.nimbusds.oauth2.sdk.token.AccessToken; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.openid.connect.sdk.OIDCScopeValue; +import org.pac4j.core.authorization.generator.AuthorizationGenerator; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.credentials.TokenCredentials; +import org.pac4j.core.credentials.authenticator.Authenticator; +import org.pac4j.core.exception.CredentialsException; +import org.pac4j.core.profile.UserProfile; +import org.pac4j.core.profile.creator.ProfileCreator; +import org.pac4j.core.util.CommonHelper; +import org.pac4j.core.util.InitializableObject; +import org.pac4j.oidc.config.OidcConfiguration; +import org.pac4j.oidc.credentials.OidcCredentials; +import org.pac4j.oidc.credentials.authenticator.UserInfoOidcAuthenticator; +import org.pac4j.oidc.profile.OidcProfile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +import java.text.ParseException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Authenticator for JWT access_token based on the Pac4j {@link UserInfoOidcAuthenticator}, + * But instead it of using the userInfo endpoint to validate the access_token it uses OIDC metadata to get key information to validate JWT. + * The scope parameter of {@link AccessToken} from the {@link OidcCredentials} is updated with the scope from the validated JWT access_token. + * The credentials.userProfile is set to an instance of {@link AlaOidcUserProfile} a wrapped {@link OidcProfile} from the OIDC UserInfo endpoint. + */ +public class AlaOidcAuthenticator extends InitializableObject implements Authenticator { + + public static final Logger log = LoggerFactory.getLogger(AlaOidcAuthenticator.class); + + final OidcConfiguration configuration; + final ProfileCreator profileCreator; + + CacheManager cacheManager; + Cache cache; + + public AlaOidcAuthenticator(final OidcConfiguration configuration, final ProfileCreator profileCreator) { + this.configuration = configuration; + this.profileCreator = profileCreator; + } + + @Override + protected void internalInit(boolean forceReinit) { + + CommonHelper.assertNotNull("configuration", configuration); + CommonHelper.assertNotNull("issuer", issuer); + CommonHelper.assertTrue(CommonHelper.isNotEmpty(expectedJWSAlgs), "expectedJWSAlgs cannot be empty"); + CommonHelper.assertNotNull("keySource", keySource); + + if (cacheManager != null) { + + cache = cacheManager.getCache("user-profile"); + } + + if (cache != null) { + + log.warn("no 'user-profile' caching configured."); + } + } + + public CacheManager getCacheManager() { + return cacheManager; + } + + public void setCacheManager(CacheManager cacheManager) { + this.cacheManager = cacheManager; + } + + @Override + public void validate(Credentials cred, WebContext context, SessionStore sessionStore) { + + init(); + + final OidcCredentials credentials = (OidcCredentials) cred; + final String accessToken = credentials.getAccessToken().getValue(); + final JWT jwt; + try { + jwt = JWTParser.parse(accessToken); + } catch (ParseException e) { + throw new CredentialsException("Cannot decrypt / verify JWT", e); + } + + // Create a JWT processor for the access tokens + ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor(); + + // Set the required "typ" header "at+jwt" for access tokens issued by the + // Connect2id server, may not be set by other servers +// jwtProcessor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType(jwtType))); + +// The expected JWS algorithm of the access tokens (agreed out-of-band) +// JWSAlgorithm expectedJWSAlg = JWSAlgorithm.RS256; + + // Configure the JWT processor with a key selector to feed matching public + // RSA keys sourced from the JWK set URL + JWSKeySelector keySelector = new JWSVerificationKeySelector(expectedJWSAlgs, keySource); + +// JWEKeySelector jweKeySelector = +// new JWEDecryptionKeySelector<>(expectedJWSAlgs, keySource); + + jwtProcessor.setJWSKeySelector(keySelector); +// jwtProcessor.setJWEDecrypterFactory(); + + // Set the required JWT claims for access tokens issued by the server + // TODO externalise the required claims + jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier(new JWTClaimsSet.Builder().issuer(issuer.getValue()).build(), Set.copyOf(requiredClaims))); + + String userId = null; + Collection accessTokenRoles; + + try { + + JWTClaimsSet claimsSet = jwtProcessor.process(jwt, null); + userId = (String) claimsSet.getClaim(userIdClaim); + + accessTokenRoles = getRoles(claimsSet); + + var scopeClaim = claimsSet.getClaim(OidcConfiguration.SCOPE); + Scope scope; + if (scopeClaim == null) { + scope = null; + } else if (scopeClaim instanceof String) { + scope = Scope.parse((String)scopeClaim); + } else if (scopeClaim instanceof Collection) { + scope = Scope.parse((Collection)scopeClaim); + } else { + throw new CredentialsException("Internal error parsing token scopes: " + accessToken); + } + credentials.setAccessToken(new BearerAccessToken(accessToken, 0L, scope)); + + } catch (BadJOSEException e) { + throw new CredentialsException("JWT Verification failed: " + accessToken, e); + } catch (JOSEException e) { + throw new CredentialsException("Internal error parsing token: " + accessToken, e); + } + + if (requiredScopes != null && !requiredScopes.isEmpty()) { + + boolean scopesMatch = requiredScopes.stream().allMatch( requiredScope -> + credentials.getAccessToken().getScope().stream().anyMatch( scope -> + requiredScope.equals(scope.getValue()))); + if (!scopesMatch) { + log.info("access_token scopes '" + credentials.getAccessToken().getScope() + "' is missing required scopes " + getRequiredScopes()); + throw new CredentialsException("access_token with scope '" + credentials.getAccessToken().getScope() + "' is missing required scopes " + getRequiredScopes()); + } + } + + var accessTokenScope = credentials.getAccessToken().getScope(); + var accessTokenScopeSet = accessTokenScope != null ? + accessTokenScope.stream().map(Identifier::getValue).collect(Collectors.toSet()) : + Collections.emptySet(); + AlaOidcUserProfile alaOidcUserProfile = null; + + // if the access-token contains the 'profile' scope then create a user profile + if (accessTokenScope != null && accessTokenScope.contains(OIDCScopeValue.PROFILE)) { + + // if a cache of + if (cache != null) { + + Cache.ValueWrapper cachedProfile = cache.get(accessToken); + + if (cachedProfile != null) { + alaOidcUserProfile = (AlaOidcUserProfile) cachedProfile.get(); + } + } + + if (alaOidcUserProfile == null) { + + UserProfile userProfile = profileCreator.create(new TokenCredentials(accessToken), context, sessionStore).get(); + + if (authorizationGenerator != null) { + + final String finalUserId = userId; + alaOidcUserProfile = authorizationGenerator.generate(context, sessionStore, userProfile) + .map( userProf -> this.generateAlaUserProfile(finalUserId, userProf, accessTokenScopeSet) ).get(); + + } else { + alaOidcUserProfile = generateAlaUserProfile(userId, userProfile, accessTokenScopeSet); + } + + if (cache != null) { + + cache.put(accessToken, alaOidcUserProfile); + } + } + + } else if (userId != null && !userId.isEmpty()) { + + alaOidcUserProfile = new AlaOidcUserProfile(userId); + } + + if (alaOidcUserProfile != null) { + + alaOidcUserProfile.setAccessToken(credentials.getAccessToken()); + + if (accessTokenRoles != null && !accessTokenRoles.isEmpty()) { + alaOidcUserProfile.addRoles(accessTokenRoles); + } + alaOidcUserProfile.addPermissions(accessTokenScopeSet); + + cred.setUserProfile(alaOidcUserProfile); + } + } + + public AlaOidcUserProfile generateAlaUserProfile(String userId, UserProfile profile, Set accessTokenScopeSet) { + + + AlaOidcUserProfile alaOidcUserProfile = new AlaOidcUserProfile(userId); + alaOidcUserProfile.addAttributes(profile.getAttributes()); + alaOidcUserProfile.setRoles(profile.getRoles()); + alaOidcUserProfile.setPermissions(profile.getPermissions()); + alaOidcUserProfile.addPermissions(accessTokenScopeSet); + + return alaOidcUserProfile; + } + + Collection getRoles(JWTClaimsSet claimsSet) { + + if (!rolesFromAccessToken) { + return List.of(); + } + + Stream roles = accessTokenRoleClaims.stream() + .map(claimsSet::getClaim) + .filter(Objects::nonNull) + .flatMap((Object roleClaim) -> { + Stream result; + if (roleClaim instanceof String) { + result = Stream.of(((String)roleClaim).split(Pattern.quote(","))); + } else if (roleClaim.getClass().isArray() && roleClaim.getClass().getComponentType().isAssignableFrom(String.class)) { + result = Stream.of((String[])roleClaim); + } else if (Collection.class.isAssignableFrom(roleClaim.getClass())) { + result = ((Collection) roleClaim).stream(); + } else { + log.debug("Couldn't parse role claim value: {}", roleClaim); + result = Stream.empty(); + } + return result; + }); + + if (this.rolePrefix != null && !this.rolePrefix.trim().isEmpty()) { + roles = roles.map( role -> role.startsWith(this.rolePrefix) ? role : this.rolePrefix + role ); + } + + if (this.roleToUppercase) { + roles = roles.map(String::toUpperCase); + } + + return roles.collect(Collectors.toSet()); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("AlaOidcAuthenticator{"); + sb.append("issuer='").append(issuer).append("'"); +// sb.append(", jwtType='").append(jwtType).append('\'') + sb.append(", expectedJWSAlgs=").append(expectedJWSAlgs); + sb.append(", keySource=").append(keySource); +// sb.append(", realmName='").append(realmName).append('\'') +// sb.append(", expirationTime=").append(expirationTime) +// sb.append(", identifierGenerator=").append(identifierGenerator) + sb.append("}"); + return sb.toString(); + } + + public Issuer getIssuer() { + return issuer; + } + + public void setIssuer(Issuer issuer) { + this.issuer = issuer; + } + + public Set getExpectedJWSAlgs() { + return expectedJWSAlgs; + } + + public void setExpectedJWSAlgs(Set expectedJWSAlgs) { + this.expectedJWSAlgs = expectedJWSAlgs; + } + + public JWKSource getKeySource() { + return keySource; + } + + public void setKeySource(JWKSource keySource) { + this.keySource = keySource; + } + + public AuthorizationGenerator getAuthorizationGenerator() { + return authorizationGenerator; + } + + public void setAuthorizationGenerator(AuthorizationGenerator authorizationGenerator) { + this.authorizationGenerator = authorizationGenerator; + } + + public String getUserIdClaim() { + return userIdClaim; + } + + public void setUserIdClaim(String userIdClaim) { + this.userIdClaim = userIdClaim; + } + + public List getRequiredClaims() { + return requiredClaims; + } + + public void setRequiredClaims(List requiredClaims) { + this.requiredClaims = requiredClaims; + } + + public List getRequiredScopes() { + return requiredScopes; + } + + public void setRequiredScopes(List requiredScopes) { + this.requiredScopes = requiredScopes; + } + + public List getAccessTokenRoleClaims() { + return accessTokenRoleClaims; + } + + public void setAccessTokenRoleClaims(List accessTokenRoleClaims) { + this.accessTokenRoleClaims = accessTokenRoleClaims; + } + + public boolean isRolesFromAccessToken() { + return rolesFromAccessToken; + } + + public void setRolesFromAccessToken(boolean rolesFromAccessToken) { + this.rolesFromAccessToken = rolesFromAccessToken; + } + + public String getRolePrefix() { + return rolePrefix; + } + + public void setRolePrefix(String rolePrefix) { + this.rolePrefix = rolePrefix; + } + + public boolean isRoleToUppercase() { + return roleToUppercase; + } + + public void setRoleToUppercase(boolean roleToUppercase) { + this.roleToUppercase = roleToUppercase; + } + + private Issuer issuer; + private Set expectedJWSAlgs; + private JWKSource keySource; + private AuthorizationGenerator authorizationGenerator; + private String userIdClaim; + private List requiredClaims; + private List requiredScopes; + List accessTokenRoleClaims; + boolean rolesFromAccessToken = false; + String rolePrefix = ""; + boolean roleToUppercase = true; +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaApiKeyClient.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaApiKeyClient.java new file mode 100644 index 00000000..1f25bb73 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaApiKeyClient.java @@ -0,0 +1,17 @@ +package au.org.ala.ws.security.client; + +import au.org.ala.ws.security.authenticator.AlaApiKeyAuthenticator; +import au.org.ala.ws.security.credentials.AlaApiKeyCredentialsExtractor; + +public class AlaApiKeyClient extends AlaDirectClient { + public AlaApiKeyClient(AlaApiKeyCredentialsExtractor alaApiKeyCredentialsExtractor, AlaApiKeyAuthenticator alaApiKeyAuthenticator) { + + defaultCredentialsExtractor(alaApiKeyCredentialsExtractor); + defaultAuthenticator(alaApiKeyAuthenticator); + } + + @Override + protected void internalInit(boolean forceReinit) { + } + +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaAuthClient.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaAuthClient.java new file mode 100644 index 00000000..49a0dba9 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaAuthClient.java @@ -0,0 +1,83 @@ +package au.org.ala.ws.security.client; + +import org.pac4j.core.client.BaseClient; +import org.pac4j.core.context.HttpConstants; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.exception.http.RedirectionAction; +import org.pac4j.core.profile.UserProfile; +import org.pac4j.core.util.CommonHelper; +import org.pac4j.core.util.Pac4jConstants; + +import java.util.List; +import java.util.Optional; + +public class AlaAuthClient extends BaseClient { + @Override + protected void beforeInternalInit(final boolean forceReinit) { + if (saveProfileInSession == null) { + setSaveProfileInSession(false); + } + + } + + @Override + protected void internalInit(boolean forceReinit) { + + CommonHelper.assertNotBlank("realmName", this.realmName); + } + + @Override + protected Optional retrieveCredentials(final WebContext context, final SessionStore sessionStore) { + + if (authClients == null || authClients.isEmpty()) { + return Optional.empty(); + } + + + // set the www-authenticate in case of error + context.setResponseHeader(HttpConstants.AUTHENTICATE_HEADER, HttpConstants.BEARER_HEADER_PREFIX + "realm=\"" + realmName + "\""); + + for (BaseClient authClient : authClients) { + + final Optional optCredentials = authClient.getCredentials(context, sessionStore); + if (optCredentials.isPresent()) { + + return optCredentials; + } + + } + + + return Optional.empty(); + } + + @Override + public Optional getRedirectionAction(WebContext context, SessionStore sessionStore) { + return Optional.empty(); + } + + @Override + public Optional getCredentials(WebContext context, SessionStore sessionStore) { + + init(); + return retrieveCredentials(context, sessionStore); + } + + @Override + public Optional getLogoutAction(WebContext context, SessionStore sessionStore, UserProfile currentProfile, String targetUrl) { + return Optional.empty(); + } + + public List getAuthClients() { + return authClients; + } + + public void setAuthClients(List authClients) { + this.authClients = authClients; + } + + private String realmName = Pac4jConstants.DEFAULT_REALM_NAME; + private List authClients; +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaDirectClient.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaDirectClient.java new file mode 100644 index 00000000..34415ef5 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaDirectClient.java @@ -0,0 +1,34 @@ +package au.org.ala.ws.security.client; + +import org.pac4j.core.client.DirectClient; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.exception.CredentialsException; + +import java.util.Optional; + +public abstract class AlaDirectClient extends DirectClient { + @Override + protected Optional retrieveCredentials(final WebContext context, final SessionStore sessionStore) { + try { + final Optional optCredentials = this.getCredentialsExtractor().extract(context, sessionStore); + optCredentials.ifPresent( credentials -> { + final long t0 = System.currentTimeMillis(); + try { + AlaDirectClient.this.getAuthenticator().validate(credentials, context, sessionStore); + } finally { + final long t1 = System.currentTimeMillis(); + logger.debug("Credentials validation took: {} ms", t1 - t0); + } + + }); + return optCredentials; + } catch (CredentialsException e) { + logger.info("Failed to retrieve or validate credentials: {}", e.getMessage()); + logger.debug("Failed to retrieve or validate credentials", e); + + throw e; + } + } +} \ No newline at end of file diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaIpWhitelistClient.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaIpWhitelistClient.java new file mode 100644 index 00000000..989c842e --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaIpWhitelistClient.java @@ -0,0 +1,17 @@ +package au.org.ala.ws.security.client; + +import au.org.ala.ws.security.authenticator.AlaIpWhitelistAuthenticator; +import org.pac4j.http.credentials.extractor.IpExtractor; + +public class AlaIpWhitelistClient extends AlaDirectClient { + public AlaIpWhitelistClient(IpExtractor ipExtractor, AlaIpWhitelistAuthenticator alaIpWhitelistAuthenticator) { + + defaultCredentialsExtractor(ipExtractor); + defaultAuthenticator(alaIpWhitelistAuthenticator); + } + + @Override + protected void internalInit(boolean forceReinit) { + } + +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaOidcClient.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaOidcClient.java new file mode 100644 index 00000000..0a2f276f --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/client/AlaOidcClient.java @@ -0,0 +1,41 @@ +package au.org.ala.ws.security.client; + +import au.org.ala.ws.security.authenticator.AlaOidcAuthenticator; +import au.org.ala.ws.security.credentials.AlaOidcCredentialsExtractor; +import org.pac4j.core.context.HttpConstants; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.util.Pac4jConstants; + +import java.util.Optional; + +public class AlaOidcClient extends AlaDirectClient { + public AlaOidcClient(AlaOidcCredentialsExtractor alaOidcCredentialsExtractor, AlaOidcAuthenticator alaOidcAuthenticator) { + + defaultAuthenticator(alaOidcAuthenticator); + defaultCredentialsExtractor(alaOidcCredentialsExtractor); + } + + @Override + protected void internalInit(boolean forceReinit) { + } + + @Override + protected Optional retrieveCredentials(final WebContext context, final SessionStore sessionStore) { + // set the www-authenticate in case of error + context.setResponseHeader(HttpConstants.AUTHENTICATE_HEADER, HttpConstants.BEARER_HEADER_PREFIX + "realm=\"" + realmName + "\""); + + return super.retrieveCredentials(context, sessionStore); + } + + public String getRealmName() { + return realmName; + } + + public void setRealmName(final String realmName) { + this.realmName = realmName; + } + + private String realmName = Pac4jConstants.DEFAULT_REALM_NAME; +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/credentials/AlaApiKeyCredentialsExtractor.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/credentials/AlaApiKeyCredentialsExtractor.java new file mode 100644 index 00000000..6c2e4c13 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/credentials/AlaApiKeyCredentialsExtractor.java @@ -0,0 +1,57 @@ +package au.org.ala.ws.security.credentials; + +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.credentials.extractor.HeaderExtractor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +public class AlaApiKeyCredentialsExtractor extends HeaderExtractor { + public AlaApiKeyCredentialsExtractor() { + setHeaderName("apiKey"); + setPrefixHeader(""); + } + + @Override + public void setHeaderName(String headerName) { + super.setHeaderName(headerName); + } + + public void setAlternativeHeaderNames(List alternativeHeaderNames) { + + alternativeHeaderExtractors = alternativeHeaderNames.stream().map( alternativeHeaderName -> { + AlaApiKeyCredentialsExtractor alternativeHeaderExtractor = new AlaApiKeyCredentialsExtractor(); + alternativeHeaderExtractor.setHeaderName(alternativeHeaderName); + return alternativeHeaderExtractor; + }).collect(Collectors.toList()); + } + + @Override + public Optional extract(final WebContext context, final SessionStore sessionStore) { + + final Optional credentials = super.extract(context, sessionStore); + + if (credentials.isPresent()) { + return credentials; + } + + return alternativeHeaderExtractors.stream() + .map(alternativeHeaderExtractor -> alternativeHeaderExtractor.extract(context, sessionStore)) + .flatMap(Optional::stream) + .findFirst(); + } + + public List getAlternativeHeaderExtractors() { + return alternativeHeaderExtractors; + } + + public void setAlternativeHeaderExtractors(List alternativeHeaderExtractors) { + this.alternativeHeaderExtractors = alternativeHeaderExtractors; + } + + private List alternativeHeaderExtractors = new ArrayList(); +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/credentials/AlaOidcCredentialsExtractor.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/credentials/AlaOidcCredentialsExtractor.java new file mode 100644 index 00000000..ad87404e --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/credentials/AlaOidcCredentialsExtractor.java @@ -0,0 +1,35 @@ +package au.org.ala.ws.security.credentials; + +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.context.session.SessionStore; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.credentials.TokenCredentials; +import org.pac4j.core.credentials.extractor.BearerAuthExtractor; +import org.pac4j.core.exception.CredentialsException; +import org.pac4j.oidc.credentials.OidcCredentials; + +import java.util.Optional; + +public class AlaOidcCredentialsExtractor extends BearerAuthExtractor { + @Override + public Optional extract(WebContext context, SessionStore sessionStore) { + + try { + + return super.extract(context, sessionStore).map((Credentials tokenCredentials) -> { + OidcCredentials oidcCredentials = new OidcCredentials(); + oidcCredentials.setAccessToken(new BearerAccessToken(((TokenCredentials)tokenCredentials).getToken())); + + return oidcCredentials; + }); + + } catch (CredentialsException ce) { + // exception extracting credentials, treat as no credentials to allow pass through + } + + + return Optional.empty(); + } + +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaApiUserProfile.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaApiUserProfile.java new file mode 100644 index 00000000..81cd291c --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaApiUserProfile.java @@ -0,0 +1,223 @@ +package au.org.ala.ws.security.profile; + +import java.security.Principal; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +public class AlaApiUserProfile implements AlaUserProfile { + public AlaApiUserProfile() { + } + + public AlaApiUserProfile(String userId, String email, String givenName, String familyName, Set roles, Map attributes) { + this.email = email; + this.userId = userId; + this.roles = roles; + this.attributes = attributes; + this.givenName = givenName; + this.familyName = familyName; + } + + @Override + public String getName() { + return getUsername(); + } + + @Override + public String getId() { + return userId; + } + + @Override + public void setId(String id) { + this.userId = id; + } + + @Override + public String getTypedId() { + return null; + } + + @Override + public String getUsername() { + return email; + } + + @Override + public Object getAttribute(String name) { + return attributes.get(name); + } + + @Override + public boolean containsAttribute(String name) { + return attributes.containsKey(name); + } + + @Override + public void addAttribute(String key, Object value) { + attributes.put(key, value); + } + + @Override + public void removeAttribute(String key) { + attributes.remove(key); + } + + @Override + public void addAuthenticationAttribute(String key, Object value) { + } + + @Override + public void removeAuthenticationAttribute(String key) { + } + + @Override + public void addRole(String role) { + roles.add(role); + } + + @Override + public void addRoles(Collection roles) { + this.roles.addAll(roles); + } + + @Override + public void addPermission(String permission) { + + } + + @Override + public void addPermissions(Collection permissions) { + + } + + @Override + public Set getPermissions() { + return Set.of(); + } + + @Override + public boolean isRemembered() { + return false; + } + + @Override + public void setRemembered(boolean rme) { + + } + + @Override + public String getClientName() { + return null; + } + + @Override + public void setClientName(String clientName) { + + } + + @Override + public String getLinkedId() { + return null; + } + + @Override + public void setLinkedId(String linkedId) { + + } + + @Override + public boolean isExpired() { + return false; + } + + @Override + public Principal asPrincipal() { + + return this; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getGivenName() { + return givenName; + } + + public void setGivenName(String givenName) { + this.givenName = givenName; + } + + public String getFamilyName() { + return familyName; + } + + public void setFamilyName(String familyName) { + this.familyName = familyName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public boolean getActivated() { + return activated; + } + + public boolean isActivated() { + return activated; + } + + public void setActivated(boolean activated) { + this.activated = activated; + } + + public boolean getLocked() { + return locked; + } + + public boolean isLocked() { + return locked; + } + + public void setLocked(boolean locked) { + this.locked = locked; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + private String userId; + private String givenName; + private String familyName; + private String email; + private boolean activated = true; + private boolean locked = false; + private Set roles = new LinkedHashSet<>(); + private Map attributes = new LinkedHashMap<>(); +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaOidcUserProfile.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaOidcUserProfile.java new file mode 100644 index 00000000..919ca82a --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaOidcUserProfile.java @@ -0,0 +1,34 @@ +package au.org.ala.ws.security.profile; + +import org.pac4j.oidc.profile.OidcProfile; + +import java.security.Principal; + +public class AlaOidcUserProfile extends OidcProfile implements AlaUserProfile { + + final String userId; + + public AlaOidcUserProfile(String userId) { + this.userId = userId; + } + + @Override + public String getUserId() { + return userId; + } + + @Override + public String getName() { + return this.getDisplayName(); + } + + @Override + public String getGivenName() { + return super.getFirstName(); + } + + @Override + public Principal asPrincipal() { + return this; + } +} diff --git a/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaUserProfile.java b/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaUserProfile.java new file mode 100644 index 00000000..194aad97 --- /dev/null +++ b/ala-ws-security/src/main/java/au/org/ala/ws/security/profile/AlaUserProfile.java @@ -0,0 +1,16 @@ +package au.org.ala.ws.security.profile; + +import org.pac4j.core.profile.UserProfile; + +import java.security.Principal; + +public interface AlaUserProfile extends Principal, UserProfile { + + String getUserId(); + + String getEmail(); + + String getGivenName(); + + String getFamilyName(); +} diff --git a/ala-ws-security/src/main/resources/META-INF/spring.factories b/ala-ws-security/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..43dddc6b --- /dev/null +++ b/ala-ws-security/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +au.org.ala.ws.security.AlaWsSecurityRetrofitConfiguration,\ +au.org.ala.ws.security.AlaWsSecurityConfiguration \ No newline at end of file diff --git a/ala-ws-security/src/test/groovy/au/org/ala/ws/security/AlaCredentialsExtractorSpec.groovy b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/AlaCredentialsExtractorSpec.groovy new file mode 100644 index 00000000..5e6c420a --- /dev/null +++ b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/AlaCredentialsExtractorSpec.groovy @@ -0,0 +1,59 @@ +package au.org.ala.ws.security + +import au.org.ala.ws.security.credentials.AlaApiKeyCredentialsExtractor +import au.org.ala.ws.security.credentials.AlaOidcCredentialsExtractor +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.credentials.Credentials +import org.pac4j.core.credentials.TokenCredentials +import org.pac4j.oidc.credentials.OidcCredentials +import spock.lang.Specification + +class AlaCredentialsExtractorSpec extends Specification { + + def 'extract jwt credentials'() { + + setup: + AlaOidcCredentialsExtractor alaCredentialsExtractor = new AlaOidcCredentialsExtractor() + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + Optional credentials = alaCredentialsExtractor.extract(context, sessionStore) + + then: + 1 * context.getRequestHeader('Authorization') >> Optional.of('Bearer auth_token') + + credentials.present + credentials.get() instanceof OidcCredentials + credentials.get().accessToken as String == 'auth_token' + + when: + credentials = alaCredentialsExtractor.extract(context, sessionStore) + + then: + _ * context.getRequestHeader(_) >> Optional.empty() + + !credentials.present + } + + def 'extract apiKey credentials'() { + + setup: + AlaApiKeyCredentialsExtractor alaCredentialsExtractor = new AlaApiKeyCredentialsExtractor() + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + Optional credentials = alaCredentialsExtractor.extract(context, sessionStore) + + then: + 1 * context.getRequestHeader('apiKey') >> Optional.of('apiKey') + + credentials.present + credentials.get() instanceof TokenCredentials + credentials.get().token as String == 'apiKey' + } +} diff --git a/ala-ws-security/src/test/groovy/au/org/ala/ws/security/JwtUtils.groovy b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/JwtUtils.groovy new file mode 100644 index 00000000..466bd0c6 --- /dev/null +++ b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/JwtUtils.groovy @@ -0,0 +1,56 @@ +package au.org.ala.ws.security + +import com.google.common.io.Resources +import com.nimbusds.jose.JOSEObjectType +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.crypto.RSASSASigner +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import groovy.time.TimeCategory + +class JwtUtils { + + static JWKSet jwkSet(String name) { + JWKSet.load(Resources.getResource(name).newInputStream()) + } + + static String generateJwt(JWKSet jwkSet, Set scopes = ['read:userdetails']) { + def header = new JWSHeader(JWSAlgorithm.RS256, new JOSEObjectType("jwt"), null, null, null, null, null, null, null, null, "test", true, null, null) + def claimsSet = generateClaims(scopes).build() + def signedJWT = new SignedJWT(header, claimsSet) + signedJWT.sign(new RSASSASigner(jwkSet.getKeyByKeyId('test').toRSAKey())) + signedJWT.serialize(false) + } + + static String generateJwt(JWKSet jwkSet, JWTClaimsSet claimsSet) { + def header = new JWSHeader(JWSAlgorithm.RS256, new JOSEObjectType("jwt"), null, null, null, null, null, null, null, null, "test", true, null, null) + def signedJWT = new SignedJWT(header, claimsSet) + signedJWT.sign(new RSASSASigner(jwkSet.getKeyByKeyId('test').toRSAKey())) + signedJWT.serialize(false) + } + + static JWTClaimsSet.Builder generateClaims( + Set scopes = ['read:userdetails'], + String subject = 'sub', + String issuer = 'http://localhost', + String audience = 'some-aud', + Date notBefore = new Date(), + Date issueTime = new Date(), + Date expiration = use(TimeCategory) { new Date() + 1.minute } + ) { + new JWTClaimsSet.Builder() + .subject(subject) + .issuer(issuer) + .claim('scope',scopes) + .notBeforeTime(notBefore) + .expirationTime(expiration) + .audience(audience) + .issueTime(issueTime) + .claim('client_id', 'some-client-id') + .claim('cit', 'client_id') + .claim('jti', 'asdfasdfgafgadfg') + .claim('scp', scopes) + } +} diff --git a/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaApiKeyAuthenticatorSpec.groovy b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaApiKeyAuthenticatorSpec.groovy new file mode 100644 index 00000000..b9c0b7bb --- /dev/null +++ b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaApiKeyAuthenticatorSpec.groovy @@ -0,0 +1,104 @@ +package au.org.ala.ws.security.authenticator + +import au.org.ala.userdetails.UserDetailsClient +import au.org.ala.web.UserDetails +import au.org.ala.ws.security.ApiKeyClient +import au.org.ala.ws.security.CheckApiKeyResult +import au.org.ala.ws.security.profile.AlaApiUserProfile +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter +import okhttp3.OkHttpClient +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.credentials.TokenCredentials +import org.pac4j.core.exception.CredentialsException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.mock.Calls +import spock.lang.Specification + +class AlaApiKeyAuthenticatorSpec extends Specification { + + def 'validate apikey'() { + + setup: + + ApiKeyClient apiKeyClient = Stub() + apiKeyClient.checkApiKey('testkey') >> Calls.response(new CheckApiKeyResult() { + @Override + boolean getValid() { + return true + } + + @Override + String getUserId() { + return '0' + } + + @Override + String getEmail() { + return 'email@test.com' + } + }) + + UserDetailsClient userDetailsClient = Stub() + userDetailsClient.getUserDetails('0', true) >> + { Calls.response(new UserDetails(0l, "given_name", "family_name", "email@test.com", "email@test.com", + "0", false, true, Map.of(), Set.of('ROLE_USER'))) } + + AlaApiKeyAuthenticator alaApiKeyAuthenticator = new AlaApiKeyAuthenticator() + alaApiKeyAuthenticator.apiKeyClient = apiKeyClient + alaApiKeyAuthenticator.userDetailsClient = userDetailsClient + + TokenCredentials alaApiKeyCredentials = new TokenCredentials('testkey') + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + alaApiKeyAuthenticator.validate(alaApiKeyCredentials, context, sessionStore) + + then: + alaApiKeyCredentials.userProfile instanceof AlaApiUserProfile + alaApiKeyCredentials.userProfile.givenName == 'given_name' + alaApiKeyCredentials.userProfile.familyName == 'family_name' + alaApiKeyCredentials.userProfile.email == 'email@test.com' + } + + def 'invalid apikey'() { + + setup: + ApiKeyClient apiKeyClient = Stub() + apiKeyClient.checkApiKey('testkey') >> Calls.response(new CheckApiKeyResult() { + @Override + boolean getValid() { + return false + } + }) + + UserDetailsClient userDetailsClient = Stub() + + AlaApiKeyAuthenticator alaApiKeyAuthenticator = new AlaApiKeyAuthenticator() + alaApiKeyAuthenticator.apiKeyClient = apiKeyClient + alaApiKeyAuthenticator.userDetailsClient = userDetailsClient + + TokenCredentials alaApiKeyCredentials = new TokenCredentials('testkey') + + WebContext context = Mock() + SessionStore sessionStore = Mock() + +// wm.stubFor( +// get(urlEqualTo('/apikey/ws/check?apikey=testkey')) +// .willReturn(okJson(Json.write([ +// valid: false +// ]))) +// ) + + when: + alaApiKeyAuthenticator.validate(alaApiKeyCredentials, context, sessionStore) + + then: + CredentialsException ce = thrown CredentialsException + ce.message == 'invalid apiKey: \'testkey\'' + } +} diff --git a/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaIpWhitelistAuthenticatorSpec.groovy b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaIpWhitelistAuthenticatorSpec.groovy new file mode 100644 index 00000000..7cab8e89 --- /dev/null +++ b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaIpWhitelistAuthenticatorSpec.groovy @@ -0,0 +1,84 @@ +package au.org.ala.ws.security.authenticator + +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.credentials.TokenCredentials +import org.pac4j.core.exception.CredentialsException +import org.pac4j.jee.context.JEEContext +import org.pac4j.jee.context.session.JEESessionStore +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import spock.lang.Specification +import spock.lang.Unroll + +class AlaIpWhitelistAuthenticatorSpec extends Specification { + + def allowedAddrs = [ + '8.8.8.8', + '8.8.4.4', + '1:2:3:4:5:6:7:8', + '192.168.1.0/24', + '1111:222::/64', + '1.*.1-3.1-4' + ] + + def setup() { + + } + + @Unroll + def 'valid ip address #ip is allowed'(String ip) { + setup: + def authenticator = new AlaIpWhitelistAuthenticator() + def credentials = new TokenCredentials(ip) + + authenticator.setIpWhitelist(allowedAddrs) + + MockHttpServletRequest request = new MockHttpServletRequest() + MockHttpServletResponse response = new MockHttpServletResponse() + + WebContext context = new JEEContext(request, response) + SessionStore store = JEESessionStore.INSTANCE + + when: + authenticator.validate(credentials, context, store) + + then: + notThrown(CredentialsException) + + where: + ip << [ + '127.0.0.1', + '1.1.1.1', + '1.127.2.3', + '8.8.8.8', + '192.168.1.1', + '1:2:3:4:5:6:7:8', + '1111:222:0:0:0:8a2e:370:7334' + ] + } + + @Unroll + def 'invalid ip address #ip is denied'(String ip) { + setup: + def authenticator = new AlaIpWhitelistAuthenticator() + def credentials = new TokenCredentials(ip) + + authenticator.setIpWhitelist(allowedAddrs) + + MockHttpServletRequest request = new MockHttpServletRequest() + MockHttpServletResponse response = new MockHttpServletResponse() + + WebContext context = new JEEContext(request, response) + SessionStore store = JEESessionStore.INSTANCE + + when: + authenticator.validate(credentials, context, store) + + then: + thrown(CredentialsException) + + where: + ip << ['2.2.2.2', '1.127.1.5'] + } +} diff --git a/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaOidcAuthenticatorSpec.groovy b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaOidcAuthenticatorSpec.groovy new file mode 100644 index 00000000..8780172f --- /dev/null +++ b/ala-ws-security/src/test/groovy/au/org/ala/ws/security/authenticator/AlaOidcAuthenticatorSpec.groovy @@ -0,0 +1,263 @@ +package au.org.ala.ws.security.authenticator + +import au.org.ala.ws.security.profile.AlaOidcUserProfile +import au.org.ala.ws.security.profile.AlaUserProfile +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWKSet +import com.nimbusds.jose.jwk.source.ImmutableJWKSet +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.oauth2.sdk.Scope +import com.nimbusds.oauth2.sdk.id.Issuer +import com.nimbusds.oauth2.sdk.token.BearerAccessToken +import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata +import groovy.time.TimeCategory +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.exception.CredentialsException +import org.pac4j.core.profile.creator.ProfileCreator +import org.pac4j.oidc.config.OidcConfiguration +import org.pac4j.oidc.credentials.OidcCredentials +import org.pac4j.oidc.profile.OidcProfile + +import spock.lang.Specification + +import static au.org.ala.ws.security.JwtUtils.* + +class AlaOidcAuthenticatorSpec extends Specification { + + JWKSet jwkSet = jwkSet('test.jwks') + + def 'validate access_token without scope'() { + + setup: + OidcConfiguration oidcConfiguration = Mock() + ProfileCreator profileCreator = Mock() + + AlaOidcAuthenticator alaOidcAuthenticator = new AlaOidcAuthenticator(oidcConfiguration, profileCreator) + alaOidcAuthenticator.issuer = new Issuer('http://localhost') + alaOidcAuthenticator.requiredClaims = [] + alaOidcAuthenticator.expectedJWSAlgs = [ JWSAlgorithm.RS256 ].toSet() + alaOidcAuthenticator.keySource = new ImmutableJWKSet(jwkSet) + + + OidcCredentials oidcCredentials = new OidcCredentials() + oidcCredentials.accessToken = new BearerAccessToken(generateJwt(jwkSet, [].toSet())) + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + alaOidcAuthenticator.validate(oidcCredentials, context, sessionStore) + + then: + !oidcCredentials.accessToken.scope + !oidcCredentials.userProfile + } + + def 'access_token missing required scope'() { + + setup: + OidcConfiguration oidcConfiguration = Mock() + ProfileCreator profileCreator = Mock() + + AlaOidcAuthenticator alaOidcAuthenticator = new AlaOidcAuthenticator(oidcConfiguration, profileCreator) + alaOidcAuthenticator.issuer = new Issuer('http://localhost') + alaOidcAuthenticator.expectedJWSAlgs = [ JWSAlgorithm.RS256 ].toSet() + alaOidcAuthenticator.requiredClaims = [] + alaOidcAuthenticator.keySource = new ImmutableJWKSet(jwkSet) + + alaOidcAuthenticator.requiredScopes = [ 'required/scope' ] + + OidcCredentials oidcCredentials = new OidcCredentials() + oidcCredentials.accessToken = new BearerAccessToken(generateJwt(jwkSet, [ 'test/scope' ].toSet())) + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + alaOidcAuthenticator.validate(oidcCredentials, context, sessionStore) + + then: + CredentialsException ce = thrown CredentialsException + ce.message == 'access_token with scope \'test/scope\' is missing required scopes [required/scope]' + } + + def 'validate access_token with userId'() { + + setup: + OidcConfiguration oidcConfiguration = Mock() + ProfileCreator profileCreator = Mock() + + AlaOidcAuthenticator alaOidcAuthenticator = new AlaOidcAuthenticator(oidcConfiguration, profileCreator) + alaOidcAuthenticator.issuer = new Issuer('http://localhost') + alaOidcAuthenticator.expectedJWSAlgs = [ JWSAlgorithm.RS256 ].toSet() + alaOidcAuthenticator.requiredClaims = [] + alaOidcAuthenticator.keySource = new ImmutableJWKSet(jwkSet) + + alaOidcAuthenticator.userIdClaim = 'username' + + OidcCredentials oidcCredentials = new OidcCredentials() + JWTClaimsSet jwtClaims = new JWTClaimsSet.Builder() + .subject('sub') + .issuer(alaOidcAuthenticator.issuer.value) + .notBeforeTime(new Date()) + .expirationTime(use(TimeCategory) { new Date() + 1.minute }) + .audience('aud') + .issueTime(new Date()) + .claim('username', 'user-id') + .build() + + oidcCredentials.accessToken = new BearerAccessToken(generateJwt(jwkSet, jwtClaims)) + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + alaOidcAuthenticator.validate(oidcCredentials, context, sessionStore) + + then: + oidcCredentials.userProfile instanceof AlaUserProfile + oidcCredentials.userProfile.userId == 'user-id' + } + + def 'validate access_token with scopes'() { + + setup: + OidcConfiguration oidcConfiguration = Mock() + ProfileCreator profileCreator = Mock() + + AlaOidcAuthenticator alaOidcAuthenticator = new AlaOidcAuthenticator(oidcConfiguration, profileCreator) + alaOidcAuthenticator.issuer = new Issuer('http://localhost') + alaOidcAuthenticator.expectedJWSAlgs = [ JWSAlgorithm.RS256 ].toSet() + alaOidcAuthenticator.requiredClaims = [] + alaOidcAuthenticator.requiredScopes = ['some:test'] + alaOidcAuthenticator.keySource = new ImmutableJWKSet(jwkSet) + + alaOidcAuthenticator.userIdClaim = 'username' + + OidcCredentials oidcCredentials = new OidcCredentials() + JWTClaimsSet jwtClaims = new JWTClaimsSet.Builder() + .subject('sub') + .issuer(alaOidcAuthenticator.issuer.value) + .notBeforeTime(new Date()) + .expirationTime(use(TimeCategory) { new Date() + 1.minute }) + .audience('aud') + .issueTime(new Date()) + .claim('username', 'user-id') + .claim('scope', ['oidc','some:test']) + .build() + + oidcCredentials.accessToken = new BearerAccessToken(generateJwt(jwkSet, jwtClaims)) + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + alaOidcAuthenticator.validate(oidcCredentials, context, sessionStore) + + then: + oidcCredentials.userProfile instanceof AlaOidcUserProfile + oidcCredentials.userProfile.userId == 'user-id' + oidcCredentials.userProfile.permissions.containsAll(['oidc','some:test']) + oidcCredentials.userProfile.accessToken.scope.contains('oidc') + oidcCredentials.userProfile.accessToken.scope.contains('some:test') + } + + + + def 'validate access_token with roles'() { + + setup: + OidcConfiguration oidcConfiguration = Mock() + ProfileCreator profileCreator = Mock() + + AlaOidcAuthenticator alaOidcAuthenticator = new AlaOidcAuthenticator(oidcConfiguration, profileCreator) + alaOidcAuthenticator.issuer = new Issuer('http://localhost') + alaOidcAuthenticator.expectedJWSAlgs = [ JWSAlgorithm.RS256 ].toSet() + alaOidcAuthenticator.requiredClaims = [] + alaOidcAuthenticator.keySource = new ImmutableJWKSet(jwkSet) + + alaOidcAuthenticator.userIdClaim = 'username' + alaOidcAuthenticator.rolesFromAccessToken = true + alaOidcAuthenticator.accessTokenRoleClaims = [ 'roles' ] + alaOidcAuthenticator.rolePrefix = 'ROLE_' + + OidcCredentials oidcCredentials = new OidcCredentials() + JWTClaimsSet jwtClaims = new JWTClaimsSet.Builder() + .subject('sub') + .issuer(alaOidcAuthenticator.issuer.value) + .notBeforeTime(new Date()) + .expirationTime(use(TimeCategory) { new Date() + 1.minute }) + .audience('aud') + .issueTime(new Date()) + .claim('username', 'user-id') + .claim('roles', [ 'user', 'admin' ]) + .build() + + oidcCredentials.accessToken = new BearerAccessToken(generateJwt(jwkSet, jwtClaims)) + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + alaOidcAuthenticator.validate(oidcCredentials, context, sessionStore) + + then: + oidcCredentials.userProfile.roles == Set.of('ROLE_USER', 'ROLE_ADMIN') + } + + def 'validate access_token with user profile'() { + + setup: + + OIDCProviderMetadata oidcProviderMetadata = Mock() { + _ * getUserInfoEndpointURI() >> new URI("http://localhost/userInfo") + } + + OidcConfiguration oidcConfiguration = Mock() { + _ * findProviderMetadata() >> oidcProviderMetadata + _ * getMappedClaims() >> [:] + } + + ProfileCreator profileCreator = Mock() { + 1 * create(_, _, _) >> Optional.of(new OidcProfile() { + @Override + Map getAttributes() { + return [ + sub: 'subject', + given_name: 'given_name', + family_name: 'family_name', + email: 'email@test.com' + ] + } + }) + } + + AlaOidcAuthenticator alaOidcAuthenticator = new AlaOidcAuthenticator(oidcConfiguration, profileCreator) + alaOidcAuthenticator.issuer = new Issuer('http://localhost') + alaOidcAuthenticator.expectedJWSAlgs = [ JWSAlgorithm.RS256 ].toSet() + alaOidcAuthenticator.requiredClaims = [] + alaOidcAuthenticator.keySource = new ImmutableJWKSet(jwkSet) + + alaOidcAuthenticator.requiredScopes = [ 'openid', 'profile', 'email' ] + + + OidcCredentials oidcCredentials = new OidcCredentials() + oidcCredentials.accessToken = new BearerAccessToken(generateJwt(jwkSet, [ 'openid', 'profile', 'email' ].toSet())) + + WebContext context = Mock() + SessionStore sessionStore = Mock() + + when: + alaOidcAuthenticator.validate(oidcCredentials, context, sessionStore) + + then: + oidcCredentials.accessToken.scope == new Scope('openid', 'profile', 'email') + oidcCredentials.userProfile instanceof AlaOidcUserProfile + + oidcCredentials.userProfile.givenName == 'given_name' + oidcCredentials.userProfile.familyName == 'family_name' + oidcCredentials.userProfile.email == 'email@test.com' + } +} diff --git a/ala-ws-security/src/test/resources/test.jwks b/ala-ws-security/src/test/resources/test.jwks new file mode 100644 index 00000000..ee3794f2 --- /dev/null +++ b/ala-ws-security/src/test/resources/test.jwks @@ -0,0 +1,18 @@ +{ + "keys": [ + { + "p": "8GrmEoqyyiJBZpDzvpcJkHAQyhyyP8SHwEPojqnElQV8l2II9YGWIRxPypFv4gObGnM5gV9fdSLuNd-FHUc3EA1k3CphHCd1OZUIm7KyLJEZwQ9q8a-UpO1r6fHidCRPFAoxJVO9CpNVymk3iwMEDtJOxFZd1ruDTmG5gHsl9b8", + "kty": "RSA", + "q": "vuSChGrOGldR5PrDdwvFf1K8g0WRkCLVM9NtDwVWpDitQKzsHvjoap_c-dNP7YC_FdxSrdd6SF7LkrI3lffkRxPmIPS6I1pUZLiHtYjebAmwBlHlnJR7KYdvh58g0yqaIGbZlLINY4fApQ-YPOECXaBP3LDibwD8d47oo3n_taE", + "d": "MXWtTCTaxwbX8oi3DZkDeoVNR5BfeopxXqR5XVx2kTfWQDtT-fC5LNlIXCskDOx2XBwps2MccYsOUN-raMWF_tgBh2qF_V4anyomQQ36kOQ_rVjXWVmhfxcvrA3eMORywybEmfuQf-PhNkbyQL5ngLlt-QOD_3OK5BJ_HK6F3oKMWofLe72Aj5nb1LNTWeKNQOhQU6_H3dpshG1uStWSgi-xQ92bwAW05_SjnzjvDgyKbrcOOVXIFTWoWuMQWaXnbs-1KfkVW6ZosW4FGmfBF8cikIs-h5LfiYPjSPwAP0zIyN_PgrviwyYola-Kps5NFe2WNPxV0pvxPsDvEYpkwQ", + "e": "AQAB", + "use": "sig", + "kid": "test", + "qi": "V4P7h00ngYKNJ3OaeeFlbWJOejiTdz3GI_pJcSf21-wH7PzamJyYoT-ITXyx3tQGlughc82X3wHMnzWpY6HGc2J88hm5jWYNiMFVbqKKjY3HRrQpz_WsFZK_1HVwKZUFz5r9xbeR1EKYflKMgElnL6SBMSAoxcziCNTPQg5b5og", + "dp": "D8GBuH9cfkDGdnlTcA8n8k5V21kMGX_AwcnxiL_5gSat5qHnImfOtfbMB-OGKJLB7HbWsvLUJ7IVWHSAnc2X_zZRgNhKKvUvCooI2WNZp_AOdweSo6o1HKXup5NRmvjyMccFN4QIZJQJUE2a9UJKVspTnSxn0_XQAigHZzMq5rM", + "alg": "RS256", + "dq": "K3SLfaduqbJtEyo-qvAEEpr8DQoeO-iiDj04G25Erfe1AP8cdWSGTBd-T2TaUh-34DsamzZtQOJLh2aIntwinEecYK41XWznv1H-msXAlGmUJ6wnEAEBFJAfRIlmCIwvL-cZ6u6pe5ngsfKd3mX-it--rmeZ9FkOIA1pSiWwn2E", + "n": "s0XwiU-2F0Vu1pFCrUXUsloEWEJcjY4_0Eia3QpKzx3L0A2m2QDSpOy_VI_MAshGr_gnTDLMX4jXCdcHfwA5JiD-nlI5ZaUv6ovvOZ1A0Nl7lXqNP3yJPAcPzeaTJV-URsHwk-4yB0hodrUJx3XRAn2uSIp8Cs-DD1MysKLqZQTweADLOcLumYXdYuYeDQZLvi4fGYUedtI70OjGiPm-W5LSUWbEcwoMcf30AbRGfZ8MYUuMyxq4Nhn4YEmRCRTe_bbu0R_3sPXeWpJDu0Imnzy48rSoTQBgEW2SHXSB5oGt1PBItKPIzkMvzIS-2S3TQYNndCp-cLvNn61zWeCYHw" + } + ] +} \ No newline at end of file diff --git a/ala-ws-spring-security/build.gradle b/ala-ws-spring-security/build.gradle new file mode 100644 index 00000000..6e23404c --- /dev/null +++ b/ala-ws-spring-security/build.gradle @@ -0,0 +1,78 @@ +plugins { + id("org.springframework.boot") version "2.7.0" apply false + id("io.spring.dependency-management") version "1.0.13.RELEASE" + + id 'java-library' + + id 'maven-publish' +} + +group "au.org.ala" + +dependencyManagement { + imports { + mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES) + } +} + +repositories { + mavenLocal() + maven { url "https://nexus.ala.org.au/content/groups/public/" } + mavenCentral() +} + +java { + withJavadocJar() + withSourcesJar() +} + +dependencies { + + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + compileOnly "org.springframework.boot:spring-boot-configuration-processor" + compileOnly 'org.springframework.boot:spring-boot-starter-security' + compileOnly 'javax.servlet:javax.servlet-api:4.0.1' + + api project(':ala-ws-security') + +} + +publishing { + repositories { + maven { + name 'Nexus' + url "https://nexus.ala.org.au/content/repositories/${project.version.endsWith('-SNAPSHOT') ? 'snapshots' : 'releases' }" + credentials { + username = System.getenv('TRAVIS_DEPLOY_USERNAME') + password = System.getenv('TRAVIS_DEPLOY_PASSWORD') + } + } + } + publications { + maven(MavenPublication) { + from components.java +// artifact sourcesJar +// artifact javadocJar + + pom { + name = 'ALA WS Spring Security Library' + description = 'Library for integrating ALA WS Security library with Spring Security' + url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin' + licenses { + license { + name = 'MPL-1.1' + url = 'https://www.mozilla.org/en-US/MPL/1.1/' + } + } + developers { + } + scm { + connection = 'scm:git:git://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin.git' + developerConnection = 'scm:git:ssh://github.com:AtlasOfLivingAustralia/ala-ws-security-plugin.git' + url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin/tree/main' + } + } + } + } +} + diff --git a/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaUser.java b/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaUser.java new file mode 100644 index 00000000..722a5fb2 --- /dev/null +++ b/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaUser.java @@ -0,0 +1,83 @@ +package au.org.ala.ws.security; + +import org.springframework.security.core.AuthenticatedPrincipal; + +import java.security.Principal; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + + +public class AlaUser implements Principal, AuthenticatedPrincipal { + + String email; + String userId; + Set roles = Collections.emptySet(); + Map attributes = Collections.emptyMap(); + String firstName; + String lastName; + + public AlaUser(){} + + public AlaUser(String email, String userId, Set roles, Map attributes, String firstName, String lastName) { + this.email = email; + this.userId = userId; + this.roles = roles; + this.attributes = attributes; + this.firstName = firstName; + this.lastName = lastName; + } + + @Override + public String getName() { + return email; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } +} \ No newline at end of file diff --git a/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaWebServiceAuthFilter.java b/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaWebServiceAuthFilter.java new file mode 100644 index 00000000..b037dfd9 --- /dev/null +++ b/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaWebServiceAuthFilter.java @@ -0,0 +1,117 @@ +package au.org.ala.ws.security; + +import au.org.ala.ws.security.client.AlaAuthClient; +import org.pac4j.core.config.Config; +import org.pac4j.core.context.WebContext; +import org.pac4j.core.credentials.Credentials; +import org.pac4j.core.exception.CredentialsException; +import org.pac4j.core.profile.ProfileManager; +import org.pac4j.core.profile.UserProfile; +import org.pac4j.core.util.FindBest; +import org.pac4j.jee.context.JEEContextFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Spring based Webservice Authentication Filter. This filter supports 3 modes of authentication: + * 1) JSON Web tokens + * 2) Legacy API keys using ALA's apikey app + * 3) Whitelist IP + */ +public class AlaWebServiceAuthFilter extends OncePerRequestFilter { + public static final Logger log = LoggerFactory.getLogger(AlaWebServiceAuthFilter.class); + + private Config config; + private AlaAuthClient alaAuthClient; + + public AlaWebServiceAuthFilter(Config config, AlaAuthClient alaAuthClient) { + this.config = config; + this.alaAuthClient = alaAuthClient; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { + + try { + + WebContext context = FindBest.webContextFactory(null, config, JEEContextFactory.INSTANCE).newContext(request, response); + + Optional optCredentials = alaAuthClient.getCredentials(context, config.getSessionStore()); + if (optCredentials.isPresent()) { + + Credentials credentials = optCredentials.get(); + + Optional optProfile = alaAuthClient.getUserProfile(credentials, context, config.getSessionStore()); + if (optProfile.isPresent()) { + + UserProfile userProfile = optProfile.get(); + + setAuthenticatedUserAsPrincipal(userProfile); + + ProfileManager profileManager = new ProfileManager(context, config.getSessionStore()); + profileManager.setConfig(config); + + profileManager.save(alaAuthClient.getSaveProfileInSession(context, userProfile), userProfile, alaAuthClient.isMultiProfile(context, userProfile)); + } + + } + + + } catch (CredentialsException e) { + + log.info("authentication failed invalid credentials", e); + + response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); + return; + + } + + + chain.doFilter(request, response); + } + + private void setAuthenticatedUserAsPrincipal(UserProfile userProfile) { + + SecurityContext securityContext = SecurityContextHolder.getContext(); + List credentials = new ArrayList(); + final List authorities = new ArrayList(); + + userProfile.getRoles().forEach(s -> authorities.add(new SimpleGrantedAuthority(s))); + + PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken(userProfile, credentials, authorities); + token.setAuthenticated(true); + securityContext.setAuthentication(token); + } + + public Config getConfig() { + return config; + } + + public void setConfig(Config config) { + this.config = config; + } + + public AlaAuthClient getAlaAuthClient() { + return alaAuthClient; + } + + public void setAlaAuthClient(AlaAuthClient alaAuthClient) { + this.alaAuthClient = alaAuthClient; + } +} \ No newline at end of file diff --git a/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaWsSpringSecurityConfiguration.java b/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaWsSpringSecurityConfiguration.java new file mode 100644 index 00000000..f5a8ffbb --- /dev/null +++ b/ala-ws-spring-security/src/main/java/au/org/ala/ws/security/AlaWsSpringSecurityConfiguration.java @@ -0,0 +1,16 @@ +package au.org.ala.ws.security; + +import au.org.ala.ws.security.client.AlaAuthClient; +import org.pac4j.core.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AlaWsSpringSecurityConfiguration { + + @Bean + AlaWebServiceAuthFilter alaWebServiceAuthFilter(Config config, AlaAuthClient alaAuthClient) { + return new AlaWebServiceAuthFilter(config, alaAuthClient); + } + +} diff --git a/ala-ws-spring-security/src/main/resources/META-INF/spring.factories b/ala-ws-spring-security/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..5f79a096 --- /dev/null +++ b/ala-ws-spring-security/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +au.org.ala.ws.security.AlaWsSpringSecurityConfiguration \ No newline at end of file diff --git a/build.gradle b/build.gradle index c9ec91c8..3b8d3e20 100644 --- a/build.gradle +++ b/build.gradle @@ -1,147 +1,3 @@ -buildscript { - repositories { - mavenLocal() - maven { url "https://nexus.ala.org.au/content/groups/public/" } - maven { url "https://repo.grails.org/grails/core" } - } - dependencies { - classpath "org.grails:grails-gradle-plugin:$grailsVersion" -// classpath "com.bertramlabs.plugins:asset-pipeline-gradle:2.14.2" - } -} - -version "4.1.2" -group "org.grails.plugins" - -apply plugin:"eclipse" -apply plugin:"idea" -apply plugin:"org.grails.grails-plugin" -apply plugin:"org.grails.grails-gsp" -//apply plugin:"asset-pipeline" -apply plugin:"maven-publish" - -sourceCompatibility = 1.11 -targetCompatibility = 1.11 - -repositories { - mavenLocal() - maven { url "https://nexus.ala.org.au/content/groups/public/" } - maven { url "https://repo.grails.org/grails/core" } -} - -configurations.all { - resolutionStrategy.cacheChangingModulesFor 0, 'seconds' - resolutionStrategy.cacheDynamicVersionsFor 0, 'seconds' -} - -configurations { - developmentOnly - runtimeClasspath { - extendsFrom developmentOnly - } -} - -dependencies { - developmentOnly("org.springframework.boot:spring-boot-devtools") - compile "org.springframework.boot:spring-boot-starter-logging" - compile "org.springframework.boot:spring-boot-autoconfigure" - compile "org.grails:grails-core" - compile "org.springframework.boot:spring-boot-starter-actuator" - compile "org.springframework.boot:spring-boot-starter-tomcat" - compile "org.grails:grails-web-boot" - compile "org.grails:grails-logging" - compile "org.grails:grails-plugin-rest" - compile "org.grails:grails-plugin-databinding" - compile "org.grails:grails-plugin-i18n" - compile "org.grails:grails-plugin-services" - compile "org.grails:grails-plugin-url-mappings" - compile "org.grails:grails-plugin-interceptors" - compile "org.grails.plugins:cache" - compile "org.grails.plugins:async" - compile "org.grails.plugins:scaffolding" - compile "org.grails.plugins:gsp" - compileOnly "io.micronaut:micronaut-inject-groovy" - console "org.grails:grails-console" - profile "org.grails.profiles:web-plugin" -// runtime "com.bertramlabs.plugins:asset-pipeline-grails:3.0.10" - testCompile "io.micronaut:micronaut-inject-groovy" - testCompile "org.grails:grails-gorm-testing-support" - testCompile "org.mockito:mockito-core" - testCompile "org.grails:grails-web-testing-support" - - implementation 'au.org.ala.grails:interceptor-annotation-matcher:1.0.0' - - annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" - compileOnly "org.springframework.boot:spring-boot-configuration-processor" - - compile 'org.pac4j:pac4j-oidc:5.4.3' - compile 'org.pac4j:pac4j-jwt:5.4.3' - compile 'org.pac4j:pac4j-http:5.4.3' - compile 'org.pac4j:pac4j-javaee:5.4.3' - compile 'org.pac4j:javaee-pac4j:7.0.0' -} - -compileGroovy { - groovyOptions.javaAnnotationProcessing = true -} - -tasks.withType(GroovyCompile) { - configure(groovyOptions) { - forkOptions.jvmArgs = ['-Xmx1024m'] - } -} - -compileJava.dependsOn(processResources) - -bootRun { - ignoreExitValue true - jvmArgs( - '-Dspring.output.ansi.enabled=always', - '-noverify', - '-XX:TieredStopAtLevel=1', - '-Xmx1024m') - sourceResources sourceSets.main - String springProfilesActive = 'spring.profiles.active' - systemProperty springProfilesActive, System.getProperty(springProfilesActive) -} -// enable if you wish to package this plugin as a standalone application -bootJar.enabled = false - -publishing { - repositories { - maven { - name 'Nexus' - url "https://nexus.ala.org.au/content/repositories/${project.version.endsWith('-SNAPSHOT') ? 'snapshots' : 'releases' }" - credentials { - username = System.getenv('TRAVIS_DEPLOY_USERNAME') - password = System.getenv('TRAVIS_DEPLOY_PASSWORD') - } - } - } - publications { - maven(MavenPublication) { - from components.java - artifact sourcesJar - artifact javadocJar - - pom { - name = 'ALA WS Security Plugin' - description = 'Plugin for authenticating web service calls for ALA systems' - url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin' - licenses { - license { - name = 'MPL-1.1' - url = 'https://www.mozilla.org/en-US/MPL/1.1/' - } - } - developers { - } - scm { - connection = 'scm:git:git://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin.git' - developerConnection = 'scm:git:ssh://github.com:AtlasOfLivingAustralia/ala-ws-security-plugin.git' - url = 'https://github.com/AtlasOfLivingAustralia/ala-ws-security-plugin/tree/main' - } - } - } - } -} +allprojects { + version=projectVersion +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index d761557d..5b81da5e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,5 @@ -#Mon Jul 24 00:31:11 AEST 2017 -grailsVersion=4.0.13 -exploded=true +projectVersion=6.0.0 + org.gradle.daemon=true org.gradle.parallel=true -org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1024M \ No newline at end of file +org.gradle.jvmargs=-Dfile.encoding=UTF-8 -Xmx1024M diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 410164db..7c08e4f0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Sun Jul 23 23:57:38 AEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-bin.zip +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/grails-app/controllers/au/org/ala/ws/security/ApiKeyInterceptor.groovy b/grails-app/controllers/au/org/ala/ws/security/ApiKeyInterceptor.groovy deleted file mode 100644 index fe9f0b11..00000000 --- a/grails-app/controllers/au/org/ala/ws/security/ApiKeyInterceptor.groovy +++ /dev/null @@ -1,206 +0,0 @@ -package au.org.ala.ws.security - -import au.ala.org.ws.security.RequireApiKey -import au.ala.org.ws.security.SkipApiKeyCheck -import au.org.ala.grails.AnnotationMatcher -import au.org.ala.ws.security.service.ApiKeyService -import grails.core.GrailsApplication -import groovy.transform.CompileStatic -import groovy.util.logging.Slf4j -import org.pac4j.core.config.Config -import org.pac4j.core.context.WebContext -import org.pac4j.core.profile.ProfileManager -import org.pac4j.core.util.FindBest -import org.pac4j.http.client.direct.DirectBearerAuthClient -import org.pac4j.jee.context.JEEContextFactory -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.http.HttpStatus - -import javax.annotation.PostConstruct -import javax.servlet.http.HttpServletRequest - -@CompileStatic -@Slf4j -@EnableConfigurationProperties(JwtProperties) -class ApiKeyInterceptor { - ApiKeyService apiKeyService - - static final int STATUS_UNAUTHORISED = 403 - static final String API_KEY_HEADER_NAME = "apiKey" - static final List LOOPBACK_ADDRESSES = ["127.0.0.1", - "0:0:0:0:0:0:0:1", // IP v6 - "::1"] // IP v6 short form - - @Autowired - JwtProperties jwtProperties - @Autowired(required = false) - DirectBearerAuthClient bearerAuthClient // Could be any DirectClient? - @Autowired(required = false) - Config config - GrailsApplication grailsApplication - - ApiKeyInterceptor() { -// matchAll() - } - - @PostConstruct - def init() { - AnnotationMatcher.matchAnnotation(this, grailsApplication, RequireApiKey) - } - - /** - * Executed before a matched action - * - * @return Whether the action should continue and execute - */ - boolean before() { - def matchResult = AnnotationMatcher.getAnnotation(grailsApplication, controllerNamespace, controllerName, actionName, RequireApiKey, SkipApiKeyCheck) - def effectiveAnnotation = matchResult.effectiveAnnotation() - def skipAnnotation = matchResult.overrideAnnotation - - def result = true - if (effectiveAnnotation && !skipAnnotation) { - if (jwtProperties.enabled) { - def fallbackToLegacy = jwtProperties.fallbackToLegacyBehaviour - result = jwtApiKeyInterceptor(effectiveAnnotation, fallbackToLegacy) - } else { - result = legacyApiKeyInterceptor() - } - } - return result - } - - /** - * Executed after the action executes but prior to view rendering - * - * @return True if view rendering should continue, false otherwise - */ - boolean after() { true } - - /** - * Executed after view rendering completes - */ - void afterView() {} - - /** - * Validate a JWT Bearer token instead of the API key. - * @param requireApiKey The RequireApiKey annotation - * @param fallbackToLegacy Whether to fall back to legacy API keys if the JWT is not present. - * @return true if the request is authorised - */ - boolean jwtApiKeyInterceptor(RequireApiKey requireApiKey, boolean fallbackToLegacy) { - def result = false - - def context = context() - ProfileManager profileManager = new ProfileManager(context, config.sessionStore) - profileManager.setConfig(config) - - def credentials = bearerAuthClient.getCredentials(context, config.sessionStore) - if (credentials.isPresent()) { - def profile = bearerAuthClient.getUserProfile(credentials.get(), context, config.sessionStore) - if (profile.isPresent()) { - def userProfile = profile.get() - profileManager.save( - bearerAuthClient.getSaveProfileInSession(context, userProfile), - userProfile, - bearerAuthClient.isMultiProfile(context, userProfile) - ) - - result = true - - if (result && requireApiKey.roles()) { - def roles = userProfile.roles - result = requireApiKey.roles().every() { - roles.contains(it) - } - } - - def requiredScopes = requireApiKey.scopes() + jwtProperties.requiredScopes - if (result && requiredScopes) { - def scope = userProfile.permissions //attributes['scope'] as List - result = requiredScopes.every { - scope.contains(it) - } - } - - } else { - log.info("Bearer token present but no user info found: {}", credentials) - result = false - } - - if (!result) { - response.status = STATUS_UNAUTHORISED - response.sendError(STATUS_UNAUTHORISED, "Forbidden") - } - } else if (fallbackToLegacy) { - result = legacyApiKeyInterceptor() - } else { - response.status = HttpStatus.UNAUTHORIZED.value() - response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()) - result = false - } - return result - } - - private WebContext context() { - final WebContext context = FindBest.webContextFactory(null, config, JEEContextFactory.INSTANCE).newContext(request, response) - return context - } - - boolean legacyApiKeyInterceptor() { - List whiteList = buildWhiteList() - String clientIp = getClientIP(request) - boolean ipOk = checkClientIp(clientIp, whiteList) - def result = true - if (!ipOk) { - String headerName = grailsApplication.config.getProperty('security.apikey.header.override', API_KEY_HEADER_NAME) - List otherHeaderNames = grailsApplication.config.getProperty('security.apikey.header.alternatives', List, []) - def apikey = request.getHeader(headerName) ?: otherHeaderNames.findResult { name -> request.getHeader(name.toString()) } - boolean keyOk = apiKeyService.checkApiKey(apikey).valid - log.debug "IP ${clientIp} ${ipOk ? 'is' : 'is not'} ok. Key ${keyOk ? 'is' : 'is not'} ok." - - if (!keyOk) { - log.warn(ipOk ? "No valid api key for ${controllerName}/${actionName}" : - "Non-authorised IP address - ${clientIp}") - response.status = STATUS_UNAUTHORISED - response.sendError(STATUS_UNAUTHORISED, "Forbidden") - result = false - } - } else { - log.debug("IP ${clientIp} is exempt from the API Key check. Authorising.") - } - return result - } - - /** - * Client IP passes if it is in the whitelist - * @param clientIp - * @return - */ - def checkClientIp(clientIp, List whiteList) { - whiteList.contains(clientIp) - } - - List buildWhiteList() { - List whiteList = [] - whiteList.addAll(LOOPBACK_ADDRESSES) // allow calls from localhost to make testing easier - String config = grailsApplication.config.getProperty('security.apikey.ip.whitelist') - if (config) { - whiteList.addAll(config.split(',').collect({ String s -> s.trim() })) - } - log.debug('{}', whiteList) - return whiteList - } - - def getClientIP(HttpServletRequest request) { - // External requests may be proxied by Apache, which uses X-Forwarded-For to identify the original IP. - String ip = request.getHeader("X-Forwarded-For") - if (!ip || LOOPBACK_ADDRESSES.contains(ip)) { - // don't accept localhost from the X-Forwarded-For header, since it can be easily spoofed. - ip = request.getRemoteHost() - } - return ip - } - -} diff --git a/grails-app/services/au/org/ala/ws/security/service/ApiKeyService.groovy b/grails-app/services/au/org/ala/ws/security/service/ApiKeyService.groovy deleted file mode 100644 index 32665936..00000000 --- a/grails-app/services/au/org/ala/ws/security/service/ApiKeyService.groovy +++ /dev/null @@ -1,33 +0,0 @@ -package au.org.ala.ws.security.service - -import grails.converters.JSON - -class ApiKeyService { - def grailsApplication - WsService wsService - - static final int STATUS_OK = 200 - - Map checkApiKey(String key) { - Map response - - try { - def conn = wsService.get("${grailsApplication.config.getProperty('security.apikey.check.serviceUrl')}${key}") - - if (conn.responseCode == STATUS_OK) { - response = JSON.parse(conn.content.text as String) - if (!response.valid) { - log.info "Rejected - " + (key ? "using key ${key}" : "no key present") - } - } else { - log.info "Rejected - " + (key ? "using key ${key}" : "no key present") - response = [valid: false] - } - } catch (Exception e) { - log.error "Failed to lookup key ${key}", e - response = [valid: false] - } - - return response - } -} diff --git a/grails-app/services/au/org/ala/ws/security/service/WsService.groovy b/grails-app/services/au/org/ala/ws/security/service/WsService.groovy deleted file mode 100644 index f0ca63cf..00000000 --- a/grails-app/services/au/org/ala/ws/security/service/WsService.groovy +++ /dev/null @@ -1,8 +0,0 @@ -package au.org.ala.ws.security.service - -class WsService { - - def get(String url) { - return new URL(url).openConnection() - } -} diff --git a/settings.gradle b/settings.gradle index 753c9222..6aa5a541 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,21 @@ -rootProject.name='ala-ws-security-plugin' +rootProject.name='ala-security-project' +include 'userdetails-service-client' +include 'ala-ws-security' +include 'ala-ws-security-plugin' +include 'ala-ws-spring-security' +include 'ala-auth' +include 'ala-ws-plugin' + +// in gradle 7.5+ this syntax will change +enableFeaturePreview('VERSION_CATALOGS') +dependencyResolutionManagement { + versionCatalogs { + pac4j { + alias('oidc').to('org.pac4j:pac4j-oidc:5.7.0') + alias('jwt').to('org.pac4j:pac4j-jwt:5.7.0') + alias('http').to('org.pac4j:pac4j-http:5.7.0') + alias('jee').to('org.pac4j:pac4j-javaee:5.7.0') + alias('jee-support').to('org.pac4j:javaee-pac4j:7.1.0') + } + } +} diff --git a/src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPluginConfiguration.groovy b/src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPluginConfiguration.groovy deleted file mode 100644 index f681d173..00000000 --- a/src/main/groovy/au/ala/org/ws/security/AlaWsSecurityGrailsPluginConfiguration.groovy +++ /dev/null @@ -1,150 +0,0 @@ -package au.ala.org.ws.security - -import au.org.ala.ws.security.JwtAuthenticator -import au.org.ala.ws.security.JwtProperties -import au.org.ala.ws.security.Pac4jProfileManagerHttpRequestWrapperFilter -import com.nimbusds.jose.jwk.source.JWKSource -import com.nimbusds.jose.jwk.source.RemoteJWKSet -import com.nimbusds.jose.proc.SecurityContext -import com.nimbusds.jose.util.DefaultResourceRetriever -import com.nimbusds.jose.util.ResourceRetriever -import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata -import grails.util.Metadata -import org.pac4j.core.authorization.generator.FromAttributesAuthorizationGenerator -import org.pac4j.core.client.Client -import org.pac4j.core.config.Config -import org.pac4j.core.context.WebContextFactory -import org.pac4j.core.context.session.SessionStore -import org.pac4j.core.engine.DefaultSecurityLogic -import org.pac4j.http.client.direct.DirectBearerAuthClient -import org.pac4j.jee.context.JEEContextFactory -import org.pac4j.jee.context.session.JEESessionStore -import org.pac4j.jee.filter.SecurityFilter -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty -import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.boot.web.servlet.FilterRegistrationBean -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.core.Ordered - -import javax.servlet.DispatcherType - -@Configuration -@EnableConfigurationProperties(JwtProperties) -class AlaWsSecurityGrailsPluginConfiguration { - - static final String JWT_CLIENT = 'JwtClient' - - @Autowired - JwtProperties jwtProperties - - @Bean - @ConditionalOnMissingBean - SessionStore sessionStore() { - JEESessionStore.INSTANCE - } - - @Bean - @ConditionalOnMissingBean - WebContextFactory webContextFactory() { - JEEContextFactory.INSTANCE - } - - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(prefix='security.jwt',name='enabled') - Config pac4jConfig(List clients, SessionStore sessionStore, WebContextFactory webContextFactory) { - Config config = new Config(clients) - - config.sessionStore = sessionStore - config.webContextFactory = webContextFactory - config - } - - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(prefix='security.jwt',name='enabled') - ResourceRetriever resourceRetriever() { - new DefaultResourceRetriever(jwtProperties.connectTimeoutMs, jwtProperties.readTimeoutMs); - } - - @Bean - @ConditionalOnMissingBean - @ConditionalOnProperty(prefix='security.jwt',name='enabled') - OIDCProviderMetadata oidcProviderMetadata(ResourceRetriever resourceRetriever) { - OIDCProviderMetadata.parse(resourceRetriever.retrieveResource(jwtProperties.discoveryUri.toURL()).getContent()) - } - - @Bean - @ConditionalOnProperty(prefix='security.jwt',name='enabled') - JWKSource jwkSource(OIDCProviderMetadata oidcProviderMetadata, ResourceRetriever resourceRetriever) { - return new RemoteJWKSet(oidcProviderMetadata.JWKSetURI.toURL(), resourceRetriever) - } - - - @Bean - @ConditionalOnProperty(prefix='security.jwt',name='enabled') - JwtAuthenticator jwtAuthenticator(OIDCProviderMetadata oidcProviderMetadata, JWKSource jwkSource) { - def ja = new JwtAuthenticator(oidcProviderMetadata.issuer.toString(), jwtProperties.requiredClaims, oidcProviderMetadata.IDTokenJWSAlgs.toSet(), jwkSource) - ja.setJwtType(jwtProperties.jwtType) - return ja - } - - @Bean - @ConditionalOnProperty(prefix='security.jwt',name='enabled') - DirectBearerAuthClient bearerClient(JwtAuthenticator jwtAuthenticator) { - def client = new DirectBearerAuthClient(jwtAuthenticator) - client.addAuthorizationGenerator(new FromAttributesAuthorizationGenerator(jwtProperties.roleAttributes,jwtProperties.permissionAttributes)) -// client.addAuthorizationGenerator(new DefaultRolesPermissionsAuthorizationGenerator(['ROLE_USER'] , [])) // client credentials probably doesn't get ROLE_USER? - client.name = JWT_CLIENT - - client - } - - @ConditionalOnProperty(prefix= 'security.jwt', name='enabled') - @Bean - FilterRegistrationBean pac4jJwtFilter(Config pac4jConfig) { - final name = 'Pac4j JWT Security Filter' - def frb = new FilterRegistrationBean() - frb.name = name - SecurityFilter securityFilter = new SecurityFilter(pac4jConfig, - JWT_CLIENT, - '', // Equivalent to isAuthenticated - '') // Matches everything. - securityFilter.setSecurityLogic(new DefaultSecurityLogic().tap { loadProfilesFromSession = false }) - frb.filter = securityFilter - frb.dispatcherTypes = EnumSet.of(DispatcherType.REQUEST) - frb.order = filterOrder() + 1 - frb.urlPatterns = jwtProperties.urlPatterns - frb.enabled = !frb.urlPatterns.empty - frb.asyncSupported = true - return frb - } - - @Bean - @ConditionalOnProperty(prefix='security.jwt',name='enabled') - FilterRegistrationBean pac4jHttpRequestWrapper(Config config) { - FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean() - filterRegistrationBean.filter = new Pac4jProfileManagerHttpRequestWrapperFilter(config) - filterRegistrationBean.order = filterOrder() + 6 // This is to place this filter after the request wrapper filter in the ala-auth-plugin - filterRegistrationBean.initParameters = [:] - filterRegistrationBean.addUrlPatterns('/*') - filterRegistrationBean - } - - // The filter chain has to be before grailsWebRequestFilter but after the encoding filter. - // Its order changed in 3.1 (from Ordered.HIGHEST_PRECEDENCE + 30 (-2147483618) to - // FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER + 30 (30)) - static int filterOrder() { - String grailsVersion = Metadata.current.getGrailsVersion() - if (grailsVersion.startsWith('3.0')) { - return Ordered.HIGHEST_PRECEDENCE + 21 - } - else { - return 21 // FilterRegistrationBean.REQUEST_WRAPPER_FILTER_MAX_ORDER + 21 - } - } - -} diff --git a/src/test/groovy/au/org/ala/ws/security/ApiKeyServiceSpec.groovy b/src/test/groovy/au/org/ala/ws/security/ApiKeyServiceSpec.groovy deleted file mode 100644 index 9f15f9e1..00000000 --- a/src/test/groovy/au/org/ala/ws/security/ApiKeyServiceSpec.groovy +++ /dev/null @@ -1,82 +0,0 @@ -package au.org.ala.ws.security - -import au.org.ala.ws.security.service.ApiKeyService -import au.org.ala.ws.security.service.WsService -import grails.testing.services.ServiceUnitTest -import org.grails.spring.beans.factory.InstanceFactoryBean -import org.springframework.http.HttpStatus -import spock.lang.Specification -import spock.lang.Unroll - -@Unroll -class ApiKeyServiceSpec extends Specification implements ServiceUnitTest { - - - def setup() { - defineBeans { - wsService(InstanceFactoryBean, new MockWebService(200)) - } - } - - void "Should return valid = false when the API Key service returns a HTTP code other than 200"() { - setup: - service.grailsApplication.config.put('security.apikey.check.serviceUrl', 'bla') - - when: - service.wsService = new MockWebService(status) - Map result = service.checkApiKey("bla") - - then: - - if (status == HttpStatus.OK.value()) { - result.valid?.toBoolean() == true - } else { - result.valid?.toBoolean() == false - } - - where: status << HttpStatus.values().collect { it.value() } - } - - void "Should return valid = true if the API Key service returns a HTTP 200 and a response JSON of '{valid: true}'"() { - setup: - service.grailsApplication.config.put('security.apikey.check.serviceUrl', 'bla') - - when: - service.wsService = new MockWebService(HttpStatus.OK.value(), "{valid: true}") - Map result = service.checkApiKey("bla") - - then: - result.valid?.toBoolean() == true - } - - void "Should return valid = false if the API Key service returns a HTTP 200 and a response JSON of '{valid: false}'"() { - setup: - service.grailsApplication.config.put('security.apikey.check.serviceUrl', 'bla') - - when: - service.wsService = new MockWebService(HttpStatus.OK.value(), "{valid: false}") - Map result = service.checkApiKey("bla") - - then: - result.valid?.toBoolean() == false - } -} - -class MockWebService extends WsService { - int statusCode - String responseJSON - - MockWebService(int statusCode) { - this(statusCode, "{valid: true}") - } - - MockWebService(int statusCode, String responseJSON) { - this.statusCode = statusCode - this.responseJSON = responseJSON - } - - @Override - def get(String url) { - return [responseCode: statusCode, content: [text: responseJSON]] - } -} diff --git a/userdetails-service-client/.gitignore b/userdetails-service-client/.gitignore new file mode 100644 index 00000000..164c3991 --- /dev/null +++ b/userdetails-service-client/.gitignore @@ -0,0 +1,70 @@ +.gradle +build/ + +# Ignore Gradle GUI config +gradle-app.setting + +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar + +# Cache of project +.gradletasknamecache + +# # Work around https://youtrack.jetbrains.com/issue/IDEA-116898 +# gradle/wrapper/gradle-wrapper.properties + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +.idea/ + +# User-specific stuff: +.idea/workspace.xml +.idea/tasks.xml + +# Sensitive or high-churn files: +.idea/dataSources/ +.idea/dataSources.ids +.idea/dataSources.xml +.idea/dataSources.local.xml +.idea/sqlDataSources.xml +.idea/dynamic.xml +.idea/uiDesigner.xml + +# Gradle: +.idea/gradle.xml +.idea/libraries + +# Mongo Explorer plugin: +.idea/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# End of https://www.gitignore.io/api/intellij diff --git a/userdetails-service-client/.travis.yml b/userdetails-service-client/.travis.yml new file mode 100644 index 00000000..da711bec --- /dev/null +++ b/userdetails-service-client/.travis.yml @@ -0,0 +1,20 @@ +sudo: false +language: java +jdk: + - openjdk8 +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +cache: + directories: + - $HOME/.m2 + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ +before_install: + - JAVA_HOME=$(jdk_switcher home openjdk8) ./gradlew classes testClasses +after_success: + - '[ "${TRAVIS_PULL_REQUEST}" = "false" ] && travis_retry ./gradlew publish' +env: + global: + - secure: Acgrl3jRFWlESYZQCtEZWZfxWZMmyvU7E8iw0TthHhu0ya9MuLSM5lzpCcoOd+jUIsMXQwJTeWJzcGBUEliGUL1HL4oiS02BilNOXHr56+CKZcho++a+4+aG3Gks84yqBilJVrw3datbxac6n83qk6FDvmWSiSDOQyjY+Xw3Jaex9jXfftGE/3jvNqKYCZaOHPiY/+I/ZVYygZJcg8wwXRUQNWekYWHfaf8tQICAyic2+1BLW2j9ueOA0ginYNtURf9G3flikOpYl9Mdty+fAJvqCLavKNUpFFMMxUzplJZ9t3KTqXH4rfSb1x/kuZQIT0L17YKjECS90j4MIVGUAGAwPC5wg2lLTOUvefvWCX8XUWPPLlLa3TsZ5y4oo3FZTTqRHfcLLFHf3o5nGP+D3x8b5/dGR6g00MMjO6F0qVQqXx3sCTOh2vypAgUHS9wcxwUdWM5ZZnwC61aIi/q3ODr8ke8L2ZjXDHG22bHQy6s1/AtLE9i13nRzqDDWmBpLWzSBBRRNTPcHdA+8heazYRAXoaX6+yxTjmrcw9J25i39rJdg7V7hMDecNf4MzoaVy7AGgUQ9F6QFAOsAbm6FuoWlfKBmeJ6ZQOl/HO2JFHolJMtyjFwwr95/wGGH+y15iMIss89/YRppgF8sXlZ19g6cLMEyy7/Almj2YpI5rmM= + - secure: hWKVISGX1X996NWCxU7/LqinCKTTi4ILanZ7c0CvB9N6I3bqB7+mo54dBJzF+RggimjR/ZPnBRptR7vmBVe9zfLctTRGHwOuYLGpMKzFJjxdyIecOomXFeVidyxBH4Qp4DmvqWRdWwETcRDQe7By0ABj/5Jb+3nV1VSClrUcbyDHCl1qYsIAMvgQyKLVp8CL23uw1EECy1I7ENqkG1XKrjHiKgRjMr71VpqTLiI9M64EuYf9ANwrKBmq6yoV+BaAYXXDieUeaxHIcRuP9SMD0X6z01ZPEW7OtamDRB4fSzovAbplQ4MSY4yWVoT8qqDX8kHujYqUcGsbHnQbumlzWkmjc7mKLOJkGhofoINioYkJvIeLVErvFjvLQaLN20EDl7HINquxTm1vTcQY68jtZSE8+4xJTtw08+Y+ShiLVXHjMygANz98XY0t5wl52pstPVDylb9XMecAF1TOXO9O2Di1mnWXjCdveyC4OqEpRHzspZJSmmk/bGIUPzksSyjEEOnWJ1LwB6+sdfSK+rqtaYUL/QBG4L1KLhMkzXurmV9ncfdt4+3i4Xj6rjCnLR9oibFxvbeZmZjADoY85Nf9CeDVasapwlqNboz3f01iimYIcwSY4S5fwx/48xHHcgK2ihTAX16Pj8bu64NvaMs+k5NlfZ0ihUtKyEK+6UDUZ9M= diff --git a/userdetails-service-client/LICENSE b/userdetails-service-client/LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/userdetails-service-client/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/userdetails-service-client/README.md b/userdetails-service-client/README.md new file mode 100644 index 00000000..7a37887e --- /dev/null +++ b/userdetails-service-client/README.md @@ -0,0 +1,7 @@ +# userdetails-service-client [![Build Status](https://travis-ci.org/AtlasOfLivingAustralia/userdetails-service-client.svg?branch=master)](https://travis-ci.org/AtlasOfLivingAustralia/userdetails-service-client) + +A Java client for the [UserDetails](https://github.com/AtlasOfLivingAustralia/userdetails) webservices. + +This client uses [Square's](https://square.github.io/) [Retrofit](http://square.github.io/retrofit/) to generate a client based on the high performance [OkHttp](http://square.github.io/okhttp/) and [Moshi JSON parser](https://github.com/square/moshi) libraries. + +To use this client you will also need to provide a Bearer token header in your Call.Factory, see ala-auth-plugin or ala-ws-security-plugin for an example Grails implementation. diff --git a/userdetails-service-client/build.gradle b/userdetails-service-client/build.gradle new file mode 100644 index 00000000..15aacbb4 --- /dev/null +++ b/userdetails-service-client/build.gradle @@ -0,0 +1,87 @@ +plugins { + id( 'java-library' ) + id( 'maven-publish' ) +} + +group 'au.org.ala' + +sourceCompatibility = '1.8' +targetCompatibility = '1.8' + +repositories { +// mavenLocal() + maven { url "https://nexus.ala.org.au/nexus/content/groups/public" } + mavenCentral() +} + +sourceSets { + integrationTest { + compileClasspath += main.output + test.output + runtimeClasspath += main.output + test.output + java.srcDir file('src/integration-test/java') + resources.srcDir file('src/integration-test/resources') + } +} + +configurations { + integrationTestCompile.extendsFrom testCompile + integrationTestRuntime.extendsFrom testRuntime +} + +dependencies { + compileOnly group: 'org.projectlombok', name:'lombok', version: '1.16.12' + annotationProcessor 'org.projectlombok:lombok:1.18.24' + + api group: 'com.squareup.retrofit2', name: 'retrofit', version: '2.9.0' + api group: 'com.squareup.retrofit2', name: 'converter-moshi', version: '2.9.0' + api group: 'com.squareup.moshi', name: 'moshi', version: '1.8.0' + api group: 'com.squareup.moshi', name: 'moshi-adapters', version: '1.8.0' + + testImplementation group: 'com.google.guava', name: 'guava', version: '20.0' + testImplementation group: 'com.squareup.okhttp3', name: 'logging-interceptor', version: '3.14.9' + testImplementation group: 'com.squareup.okhttp3', name: 'mockwebserver', version: '3.14.9' + testImplementation group: 'junit', name: 'junit', version: '4.11' + testImplementation group: 'org.assertj', name: 'assertj-core', version: '2.6.0' +} + +task integrationTest(type: Test) { + mustRunAfter test + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath + outputs.upToDateWhen { false } +} + +task sourcesJar(type: Jar, dependsOn: classes) { + classifier = 'sources' + from sourceSets.main.allSource +} + +task javadocJar(type: Jar, dependsOn: javadoc) { + classifier = 'javadoc' + from javadoc.destinationDir +} + +publishing { + repositories { + maven { + name 'Nexus' + url "https://nexus.ala.org.au/content/repositories/${project.version.endsWith('-SNAPSHOT') ? 'snapshots' : 'releases' }" + credentials { + username = System.getenv('TRAVIS_DEPLOY_USERNAME') + password = System.getenv('TRAVIS_DEPLOY_PASSWORD') + } + } + } + publications { + mavenJava(MavenPublication) { + from components.java + + artifact sourcesJar { + classifier "sources" + } + artifact javadocJar { + classifier "javadoc" + } + } + } +} diff --git a/userdetails-service-client/gradle/wrapper/gradle-wrapper.jar b/userdetails-service-client/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..41d9927a Binary files /dev/null and b/userdetails-service-client/gradle/wrapper/gradle-wrapper.jar differ diff --git a/userdetails-service-client/gradle/wrapper/gradle-wrapper.properties b/userdetails-service-client/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..aa991fce --- /dev/null +++ b/userdetails-service-client/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/userdetails-service-client/gradlew b/userdetails-service-client/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/userdetails-service-client/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/userdetails-service-client/gradlew.bat b/userdetails-service-client/gradlew.bat new file mode 100644 index 00000000..ac1b06f9 --- /dev/null +++ b/userdetails-service-client/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/userdetails-service-client/settings.gradle b/userdetails-service-client/settings.gradle new file mode 100644 index 00000000..a4bc18e7 --- /dev/null +++ b/userdetails-service-client/settings.gradle @@ -0,0 +1,3 @@ +//rootProject.buildFileName = 'build.gradle.kts' +rootProject.name = 'userdetails-service-client' + diff --git a/userdetails-service-client/src/integration-test/java/au/org/ala/UserDetailsClientIntegrationTest.java b/userdetails-service-client/src/integration-test/java/au/org/ala/UserDetailsClientIntegrationTest.java new file mode 100644 index 00000000..de3ad821 --- /dev/null +++ b/userdetails-service-client/src/integration-test/java/au/org/ala/UserDetailsClientIntegrationTest.java @@ -0,0 +1,163 @@ +package au.org.ala; + +import au.org.ala.userdetails.UserDetailsFromIdListRequest; +import au.org.ala.userdetails.UserDetailsFromIdListResponse; +import au.org.ala.userdetails.UserStatsResponse; +import au.org.ala.web.UserDetails; +import au.org.ala.userdetails.UserDetailsClient; +import okhttp3.OkHttpClient; +import okhttp3.logging.HttpLoggingInterceptor; +import org.assertj.core.api.Condition; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import retrofit2.Call; +import retrofit2.Response; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; + +// Remove this and replace constants below to run the test against the actual service +@Ignore +public class UserDetailsClientIntegrationTest { + + // Replace these to run test + static final String USER_ID = "0"; + static final String EMAIL = "replace@me"; + static final String DISPLAY_NAME = "Replace Me"; + static final String STATE = "NSW"; + static final String BASE_URL = "https://auth.ala.org.au/userdetails/"; + + OkHttpClient okHttpClient; + UserDetailsClient userDetailsClient; + + @Before + public void setup() { + this.okHttpClient = new OkHttpClient.Builder().addInterceptor(new HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)).build(); + this.userDetailsClient = new UserDetailsClient.Builder(okHttpClient, BASE_URL).build(); + } + + @Test + public void testGetUserDetails() throws IOException { + Call userDetailsCall = userDetailsClient.getUserDetails(EMAIL, true); + Response response = userDetailsCall.execute(); + assertThat(response.isSuccessful()).isTrue(); + UserDetails userDetails = response.body(); + assertThat(userDetails).isNotNull().hasFieldOrPropertyWithValue("displayName", DISPLAY_NAME).hasFieldOrPropertyWithValue("userName", EMAIL).hasFieldOrPropertyWithValue("primaryUserType", STATE); + } + + @Test + public void testGetUserDetailsNoProps() throws IOException { + Call userDetailsCall = userDetailsClient.getUserDetails(EMAIL, false); + Response response = userDetailsCall.execute(); + assertThat(response.isSuccessful()).isTrue(); + UserDetails userDetails = response.body(); + assertThat(userDetails).isNotNull().hasFieldOrPropertyWithValue("displayName", DISPLAY_NAME).hasFieldOrPropertyWithValue("userName", EMAIL).hasFieldOrPropertyWithValue("primaryUserType", null); + } + + @Test + public void testGetUserDetailsFromIdList() throws IOException { + Call allUserDetailsCall = userDetailsClient.getUserDetailsFromIdList(new UserDetailsFromIdListRequest(Arrays.asList(USER_ID), true)); + Response response = allUserDetailsCall.execute(); + assertThat(response.isSuccessful()).isTrue(); + UserDetailsFromIdListResponse userDetails = response.body(); + assertThat(userDetails).isNotNull(); + assertThat(userDetails.isSuccess()).isTrue(); + assertThat(userDetails.getUsers()).containsKeys(USER_ID); + assertThat(userDetails.getUsers().get(USER_ID)) + .hasFieldOrPropertyWithValue("displayName", DISPLAY_NAME) + .hasFieldOrPropertyWithValue("userName", EMAIL) + .hasFieldOrPropertyWithValue("userId", USER_ID) + .hasFieldOrPropertyWithValue("state", STATE); + } + + @Test + public void testGetUserDetailsFromIdListNoProps() throws IOException { + Call allUserDetailsCall = userDetailsClient.getUserDetailsFromIdList(new UserDetailsFromIdListRequest(Arrays.asList(USER_ID), false)); + Response response = allUserDetailsCall.execute(); + assertThat(response.isSuccessful()).isTrue(); + UserDetailsFromIdListResponse userDetails = response.body(); + assertThat(userDetails).isNotNull(); + assertThat(userDetails.isSuccess()).isTrue(); + assertThat(userDetails.getUsers()).containsKeys(USER_ID); + assertThat(userDetails.getUsers().get(USER_ID)) + .hasFieldOrPropertyWithValue("displayName", DISPLAY_NAME) + .hasFieldOrPropertyWithValue("userName", EMAIL) + .hasFieldOrPropertyWithValue("userId", USER_ID) + .hasFieldOrPropertyWithValue("primaryUserType", null); + } + + @Test + public void testFailedGetUserDetailsFromIdList() throws IOException { + Call allUserDetailsCall = userDetailsClient.getUserDetailsFromIdList(new UserDetailsFromIdListRequest(Arrays.asList(EMAIL), true)); + UserDetailsFromIdListResponse userDetails = allUserDetailsCall.execute().body(); + assertThat(userDetails).isNotNull(); + assertThat(userDetails.isSuccess()).isFalse(); + assertThat(userDetails.getMessage()).isNotBlank(); + } + + @Test + public void testGetUserListFull() throws IOException { + Condition onlyDigits = new Condition() { + + @Override + public boolean matches(String value) { + try { + Long.parseLong(value); + return true; + } catch (NumberFormatException e) { + return false; + } + } + }; + Call> listUserDetailsCall = userDetailsClient.getUserListFull(); + List userDetailsList = listUserDetailsCall.execute().body(); + assertThat(userDetailsList).isNotNull().hasAtLeastOneElementOfType(UserDetails.class).first().hasFieldOrProperty("userName").hasFieldOrProperty("userId"); + assertThat(userDetailsList).extracting("userId", String.class).doesNotContainNull().are(onlyDigits); + } + + @Test + public void testByRole() throws IOException { + Call> usersCall = userDetailsClient.getUserDetailsByRole("ROLE_ADMIN", false, Arrays.asList(EMAIL, USER_ID)); + List users = usersCall.execute().body(); + assertThat(users).isNotNull().isNotEmpty(); + } + + @Test + public void testSearch() throws IOException { + Call> searchCall = userDetailsClient.searchUserDetails(EMAIL, 10); + List users = searchCall.execute().body(); + assertThat(users).isNotNull().isNotEmpty(); + } + + @Ignore + @Test + public void testGetUserList() throws IOException { + Call> userListCall = userDetailsClient.getUserList(); + Map userList = userListCall.execute().body(); + assertThat(userList).isNotNull().containsEntry(EMAIL, DISPLAY_NAME); + } + + @Ignore + @Test + public void testGetUserListWithIds() throws IOException { + Call> userListWithIdsCall = userDetailsClient.getUserListWithIds(); + Map userList = userListWithIdsCall.execute().body(); + assertThat(userList).isNotNull().containsValue(DISPLAY_NAME); + } + + @Test + public void testGetUserStats() throws IOException { + Call call = userDetailsClient.getUserStats(); + UserStatsResponse userStatsResponse = call.execute().body(); + assertThat(userStatsResponse).isNotNull(); + assertThat(userStatsResponse.getDescription()).isNotBlank(); + assertThat(userStatsResponse.getTotalUsers()).isGreaterThan(0); + assertThat(userStatsResponse.getTotalUsersOneYearAgo()).isGreaterThan(0); + assertThat(userStatsResponse.getTotalUsers()).isGreaterThanOrEqualTo(userStatsResponse.getTotalUsersOneYearAgo()); + } +} diff --git a/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsClient.java b/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsClient.java new file mode 100644 index 00000000..c971c9f3 --- /dev/null +++ b/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsClient.java @@ -0,0 +1,172 @@ +package au.org.ala.userdetails; + +import au.org.ala.web.UserDetails; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Rfc3339DateJsonAdapter; +import lombok.*; +import lombok.experimental.Accessors; +import lombok.extern.java.Log; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import retrofit2.Call; +import retrofit2.Retrofit; +import retrofit2.converter.moshi.MoshiConverterFactory; +import retrofit2.http.Body; +import retrofit2.http.GET; +import retrofit2.http.POST; +import retrofit2.http.Query; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * An interface that represents the exposed web services of the UserDetails application. + * + * Use the UserDetailsClient.Builder or Retrofit to generate an instance. + */ +public interface UserDetailsClient { + + String GET_USER_DETAILS_PATH = "userDetails/getUserDetails"; + String GET_USER_DETAILS_FROM_ID_LIST_PATH = "userDetails/getUserDetailsFromIdList"; + String GET_USER_LIST_FULL_PATH = "userDetails/getUserListFull"; + String GET_USER_LIST_PATH = "userDetails/getUserList"; + String GET_USER_LIST_WITH_IDS_PATH = "userDetails/getUserListWithIds"; + String GET_USER_DETAILS_BY_ROLE_PATH = "userDetails/byRole"; + String SEARCH_USERDETAILS_PATH = "userDetails/search"; + String GET_USER_STATS_PATH = "ws/getUserStats"; + + /** + * Return a JSON object containing id, email and display name for a given user, use includeProps=true to get additional information such as organisation + * + * @param username Can be either a numeric id or an email address id + * @param includeProps True to include extended properties such as organisation, telephone, etc. + * @return A call that will return a UserDetails object. + */ + @POST(GET_USER_DETAILS_PATH) + Call getUserDetails(@Query("userName") String username, @Query("includeProps") boolean includeProps); + + /** + * return the UserDetails objects for a list of user ids. + * + * @param request The request body - accepts numeric ids only. + * @return A response object with the matched UserDetails and any missing ids and or error messages. + */ + @POST(GET_USER_DETAILS_FROM_ID_LIST_PATH) + Call getUserDetailsFromIdList(@Body UserDetailsFromIdListRequest request); + + /** + * return the User stats + * @return A response object with the user stats + */ + @GET(GET_USER_STATS_PATH) + Call getUserStats(); + + /** + * Get the user details for all users with a given role, with optional filtering by user id / username / email + * @param role The role to filter for (eg ROLE_USER) + * @param includeProps Whether to include extended properties or not + * @param ids List of numeric ids as Strings / user names / passwords + * @return The list of users that match the restrictions + */ + @GET(GET_USER_DETAILS_BY_ROLE_PATH) + Call> getUserDetailsByRole(@Query("role") String role, @Query("includeProps") boolean includeProps, @Query("id") List ids); + + /** + * Search the users for all users whose email or name matches the query. + * @param query The query string to search for + * @param max Max number of results to return + * @return The list of users that match the query + */ + @GET(SEARCH_USERDETAILS_PATH) + Call> searchUserDetails(@Query("q") String query, @Query("max") int max); + + /** + * Return all the UserDetails. This will be super slow probably so caching the result is advised. + * + * @return A call that returns all the UserDetails. + */ + @Deprecated + @POST(GET_USER_LIST_FULL_PATH) + Call> getUserListFull(); + + /** + * Return a map of User email to User display name. Returns all known users. + * + * @return A map of User email to User display name + */ + @Deprecated + @POST(GET_USER_LIST_PATH) + Call> getUserList(); + + /** + * Return a map of User numeric id to User display name. Returns all known users. + * + * @return A map of User numeric id to User display name + */ + @Deprecated + @POST(GET_USER_LIST_WITH_IDS_PATH) + Call> getUserListWithIds(); + + /** + * A Builder for generating UserDetailsClient instances. + */ + @Log + @Getter + @Setter + @Accessors(fluent = true, chain = true) + @RequiredArgsConstructor + class Builder { + /** + * The Call.Factory to use for calling the web services. Most of the time this will be an OkHttpClient but + * this accepts a Call.Factory to allow the OkHttpClient to be proxied via a Call.Factory in order to allow + * health checks and metrics gathering, for example. + */ + private final okhttp3.Call.Factory callFactory; + private final HttpUrl baseUrl; + + private Moshi moshi = null; + + public static Builder from(okhttp3.Call.Factory callFactory, String baseUrl) { + return new Builder(callFactory, baseUrl); + } + + /** + * Create a Builder using an okHttpClient and String baseUrl. The baseUrl will be + * converted to an HttpUrl and a trailing / will be added if required. + * + * @param callFactory The call factory to use (usually an {@link okhttp3.OkHttpClient}) + * @param baseUrl The base URL of the User Details service + */ + public Builder(okhttp3.Call.Factory callFactory, String baseUrl) { + this.callFactory = callFactory; + if (!baseUrl.endsWith("/")) { + log.warning("User Details Client Base URL (" + baseUrl + ") does not end with a /"); + baseUrl += "/"; + } + this.baseUrl = HttpUrl.parse(baseUrl); + } + + Moshi defaultMoshi() { + return new Moshi.Builder().add(Date.class, new Rfc3339DateJsonAdapter().nullSafe()).build(); + } + + /** + * Create the UserDetailsClient instance. If a moshi instance is not supplied, one will + * be created. + * + * @return A UserDetailsClient using the supplied okhttpclient, baseUrl and moshi. + */ + public UserDetailsClient build() { + val moshi = this.moshi != null ? this.moshi : defaultMoshi(); + + return new Retrofit.Builder() + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .callFactory(callFactory) + .baseUrl(baseUrl) + .build() + .create(UserDetailsClient.class); + } + } + +} diff --git a/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsFromIdListRequest.java b/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsFromIdListRequest.java new file mode 100644 index 00000000..3cb990fb --- /dev/null +++ b/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsFromIdListRequest.java @@ -0,0 +1,19 @@ +package au.org.ala.userdetails; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserDetailsFromIdListRequest implements Serializable { + + private static final long serialVersionUID = 327334009042532174L; + + private List userIds; + private boolean includeProps = false; +} diff --git a/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsFromIdListResponse.java b/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsFromIdListResponse.java new file mode 100644 index 00000000..5a680878 --- /dev/null +++ b/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserDetailsFromIdListResponse.java @@ -0,0 +1,47 @@ +package au.org.ala.userdetails; + +import au.org.ala.web.UserDetails; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserDetailsFromIdListResponse implements Serializable { + + private static final long serialVersionUID = -1565081443900911680L; + //{"success":false,"message":"Exception: java.lang.NumberFormatException: For input string: \"simon.bear@csiro.au\""} + /* + "users": { + "1":{ + "userId":"1", + "userName":"user@email.address", + "firstName":"User Given Name", + "lastName":"User Surname", + "email":"user@email.address", + "locked": false, + "props":{ + "secondaryUserType":"Citizen scientist", + "organisation":"User Organisation", + "telephone":"555-123456", + "city":"User City", + "state":"User State", + "primaryUserType":"IT specialist" + } + } + }, + "invalidIds":[2], + */ + private boolean success; + private String message; + + private Map users = new HashMap(); + private List invalidIds = new ArrayList(); +} diff --git a/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserStatsResponse.java b/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserStatsResponse.java new file mode 100644 index 00000000..821f857c --- /dev/null +++ b/userdetails-service-client/src/main/java/au/org/ala/userdetails/UserStatsResponse.java @@ -0,0 +1,26 @@ +package au.org.ala.userdetails; + + +import java.io.Serializable; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UserStatsResponse implements Serializable { + + private static final long serialVersionUID = -5571343647860420043L; + +// { +// "description": "'totalUsers' count excludes locked and non-activated accounts. 'totalUsersOneYearAgo' count is calculated from the 'created' date being earlier than 1 year from today.", +// "totalUsers": 35658, +// "totalUsersOneYearAgo": 26992 +// } + + private String description; + private int totalUsers; + private int totalUsersOneYearAgo; +} diff --git a/userdetails-service-client/src/main/java/au/org/ala/web/UserDetails.java b/userdetails-service-client/src/main/java/au/org/ala/web/UserDetails.java new file mode 100644 index 00000000..0afe11b3 --- /dev/null +++ b/userdetails-service-client/src/main/java/au/org/ala/web/UserDetails.java @@ -0,0 +1,174 @@ +package au.org.ala.web; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.beans.ConstructorProperties; +import java.io.Serializable; +import java.util.*; + +/** + * ALA User Details object, many properties are optional and could be null. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor() +public class UserDetails implements Serializable { + + public static final String PRIMARY_USER_TYPE_PROPERTY = "primaryUserType"; + public static final String SECONDARY_USER_TYPE_PROPERTY = "secondaryUserType"; + public static final String ORGANISATION_PROPERTY = "organisation"; + public static final String CITY_PROPERTY = "city"; + public static final String STATE_PROPERTY = "state"; + public static final String COUNTRY_PROPERTY = "country"; + public static final String TELEPHONE_PROPERTY = "telephone"; + + private static final long serialVersionUID = 46L; + + // Some old services return userId as an number id + private Long id; + + private String firstName; + private String lastName; + private String userName; // may not be email in cognito + private String email; + private String userId; // numeric id + private Boolean locked; + private Boolean activated; + + private Map props = new LinkedHashMap<>(); // optional props + + private Set roles = new HashSet(); + + @ConstructorProperties({"id", "firstName", "lastName", "userName", "email", "userId", "locked", "activated", "roles"}) + public UserDetails(Long id, String firstName, String lastName, String userName, String email, String userId, Boolean locked, Boolean activated, Set roles) { + this(id, firstName, lastName, userName, userId, locked, roles); + this.email = email; + this.activated = activated; + } + + @ConstructorProperties({"id", "firstName", "lastName", "userName", "userId", "locked", "roles"}) + @Deprecated + public UserDetails(Long id, String firstName, String lastName, String userName, String userId, Boolean locked, Set roles) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.userName = userName; + this.email = userName; // to support older clients that don't add the email param, to be overridden + this.userId = userId; + this.locked = locked; + this.roles = roles; + } + + @ConstructorProperties({"id", "firstName", "lastName", "userName", "userId", "locked", "primaryUserType", "secondaryUserType", "organisation", "city", "state", "telephone", "roles"}) + @Deprecated + public UserDetails(Long id, String firstName, String lastName, String userName, String userId, Boolean locked, @Deprecated String primaryUserType, @Deprecated String secondaryUserType, String organisation, String city, String state, @Deprecated String telephone, Set roles) { + this(id, firstName, lastName, userName, userId, locked, roles); + setPrimaryUserType(primaryUserType); + setSecondaryUserTypeProperty(secondaryUserType); + setOrganisation(organisation); + setCity(city); + setState(state); + setTelephone(telephone); + } + + @ConstructorProperties({"id", "firstName", "lastName", "userName", "userId", "locked", "organisation", "city", "state", "country", "roles"}) + @Deprecated + public UserDetails(Long id, String firstName, String lastName, String userName, String userId, Boolean locked, String organisation, String city, String state, String country, Set roles) { + this(id, firstName, lastName, userName, userId, locked, roles); + setOrganisation(organisation); + setCity(city); + setState(state); + setCountry(country); + } + + @ConstructorProperties({"id", "firstName", "lastName", "userName", "email", "userId", "locked", "activated", "organisation", "city", "state", "country", "roles"}) + public UserDetails(Long id, String firstName, String lastName, String userName, String email, String userId, Boolean locked, Boolean activated, String organisation, String city, String state, String country, Set roles) { + this(id, firstName, lastName, userName, email, userId, locked, activated, roles); + setOrganisation(organisation); + setCity(city); + setState(state); + setCountry(country); + } + + public String getUserId() { + return userId != null ? userId : id != null ? String.valueOf(id) : null; + } + + public String getDisplayName() { + return firstName + " " + lastName; + } + + @Deprecated + public String getPrimaryUserType() { + return props.get(PRIMARY_USER_TYPE_PROPERTY); + } + + @Deprecated + public void setPrimaryUserType(String primaryUserType) { + props.put(PRIMARY_USER_TYPE_PROPERTY, primaryUserType); + } + + @Deprecated + public String getSecondaryUserType() { + return props.get(SECONDARY_USER_TYPE_PROPERTY); + } + + @Deprecated + public void setSecondaryUserTypeProperty(String secondaryUserType) { + props.put(SECONDARY_USER_TYPE_PROPERTY, secondaryUserType); + } + + public String getOrganisation() { + return props.get(ORGANISATION_PROPERTY); + } + + public void setOrganisation(String organisation) { + props.put(ORGANISATION_PROPERTY, organisation); + } + + public String getCity() { + return props.get(CITY_PROPERTY); + } + + public void setCity(String city) { + props.put(CITY_PROPERTY, city); + } + + public String getState() { + return props.get(STATE_PROPERTY); + } + + public void setState(String state) { + props.put(STATE_PROPERTY, state); + } + + public String getCountry() { + return props.get(COUNTRY_PROPERTY); + } + + public void setCountry(String country) { + props.put(COUNTRY_PROPERTY, country); + } + + @Deprecated + public String getTelephone() { + return props.get(TELEPHONE_PROPERTY); + } + + @Deprecated + public void setTelephone(String telephone) { + props.put(TELEPHONE_PROPERTY, telephone); + } + + /** + * Returns true if the user represented by this UserDetails has the supplied role. + * @param role the role to check. + * @return true if this user has the supplied role. + */ + boolean hasRole(String role) { + return roles.contains(role); + } +} diff --git a/userdetails-service-client/src/test/java/au/org/ala/userdetails/UserDetailsClientTest.java b/userdetails-service-client/src/test/java/au/org/ala/userdetails/UserDetailsClientTest.java new file mode 100644 index 00000000..a19bef90 --- /dev/null +++ b/userdetails-service-client/src/test/java/au/org/ala/userdetails/UserDetailsClientTest.java @@ -0,0 +1,210 @@ +package au.org.ala.userdetails; + +import au.org.ala.web.UserDetails; +import com.google.common.collect.ImmutableMap; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import okhttp3.OkHttpClient; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import retrofit2.Call; +import retrofit2.Response; + +import java.io.IOException; +import java.util.List; + +import static au.org.ala.userdetails.UserDetailsClient.*; +import static com.google.common.collect.Lists.newArrayList; +import static com.google.common.collect.Sets.newHashSet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +public class UserDetailsClientTest { + + MockWebServer mockWebServer; + Moshi moshi; + UserDetailsClient userDetailsClient; + + static final UserDetails test = new UserDetails(1l, "Test", "Tester", "test@test.com", "test@test.com", "1", false, true, "Test Org", "City of Test", "TST", "country", newHashSet("ROLE_POTATO")); + + @Before + public void setup() throws IOException { + mockWebServer = new MockWebServer(); + + Dispatcher dispatcher = new Dispatcher() { + + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + MockResponse response = new MockResponse(); + try { + String[] pathComponents = request.getPath().substring(1).split("\\?"); + String path = pathComponents[0]; + + System.out.println("Path: " + path); + switch (path) { + case GET_USER_DETAILS_PATH: + response.setResponseCode(200).setBody(moshi.adapter(UserDetails.class).toJson(test)); + break; + case GET_USER_DETAILS_FROM_ID_LIST_PATH: + UserDetailsFromIdListRequest body = moshi.adapter(UserDetailsFromIdListRequest.class).fromJson(request.getBody()); + UserDetailsFromIdListResponse responseBody; + try { + for (String id : body.getUserIds()) { + Integer.parseInt(id); + } + responseBody = new UserDetailsFromIdListResponse(true, "", ImmutableMap.of(test.getUserId(), test), newArrayList(123)); + } catch (NumberFormatException e) { + // This is the same as the userdetails web service :S + responseBody = new UserDetailsFromIdListResponse(false, e.getMessage(), null, null); + } + response.setResponseCode(200).setBody(moshi.adapter(UserDetailsFromIdListResponse.class).toJson(responseBody)); + break; + case GET_USER_DETAILS_BY_ROLE_PATH: + response.setResponseCode(200).setBody(moshi.adapter(Types.newParameterizedType(List.class, UserDetails.class)).toJson(newArrayList(test))); + break; + case GET_USER_LIST_FULL_PATH: + response.setResponseCode(200).setBody(moshi.adapter(Types.newParameterizedType(List.class, UserDetails.class)).toJson(newArrayList(test))); + break; + case GET_USER_STATS_PATH: + response.setResponseCode(200).setBody(moshi.adapter(UserStatsResponse.class).toJson(new UserStatsResponse("description", 2, 1))); + break; + case SEARCH_USERDETAILS_PATH: + response.setResponseCode(200).setBody(moshi.adapter(Types.newParameterizedType(List.class, UserDetails.class)).toJson(newArrayList(test))); + break; + case GET_USER_LIST_PATH: + case GET_USER_LIST_WITH_IDS_PATH: + // not implemented + default: + response.setResponseCode(404); + } + } catch (Exception e) { + e.printStackTrace(); + response.setResponseCode(500).setBody(e.getMessage()); + } + return response; + } + }; + mockWebServer.setDispatcher(dispatcher); + mockWebServer.start(); + + moshi = new Moshi.Builder().build(); + OkHttpClient client = new OkHttpClient.Builder().build(); + userDetailsClient = new UserDetailsClient.Builder(client, mockWebServer.url("/")).moshi(moshi).build(); + } + + @After + public void teardown() throws IOException { + mockWebServer.shutdown(); + } + + @Test + public void testGetUserDetails() throws IOException { + Call userDetailsCall = userDetailsClient.getUserDetails("test@test.com", true); + Response response = userDetailsCall.execute(); + + assertThat(response.isSuccessful()).isTrue(); + + UserDetails userDetails = response.body(); + assertThat(userDetails).isNotNull().isEqualTo(test); + } + + @Test + public void testGetAllUserDetails() throws IOException { + UserDetailsFromIdListRequest request = new UserDetailsFromIdListRequest(newArrayList(test.getUserId(), "123"), true); + Call call = userDetailsClient.getUserDetailsFromIdList(request); + Response response = call.execute(); + + assertThat(response.isSuccessful()).isTrue(); + + UserDetailsFromIdListResponse usersDetails = response.body(); + + assertThat(usersDetails).isNotNull(); + assertThat(usersDetails.isSuccess()).isTrue(); + assertThat(usersDetails.getInvalidIds()).contains(123); + assertThat(usersDetails.getUsers()).contains(entry(test.getUserId(), test)); + assertThat(usersDetails.getUsers().get(test.getUserId())) + .hasFieldOrPropertyWithValue("id", test.getId()) + .hasFieldOrPropertyWithValue("userId", test.getUserId()) + .hasFieldOrPropertyWithValue("firstName", test.getFirstName()) + .hasFieldOrPropertyWithValue("lastName", test.getLastName()) + .hasFieldOrPropertyWithValue("userName", test.getUserName()) + .hasFieldOrPropertyWithValue("locked", test.getLocked()) + .hasFieldOrPropertyWithValue("organisation", test.getOrganisation()) + .hasFieldOrPropertyWithValue("city", test.getCity()) + .hasFieldOrPropertyWithValue("state", test.getState()) + .hasFieldOrPropertyWithValue("country", test.getCountry()) + .hasFieldOrPropertyWithValue("roles", test.getRoles()) + .hasFieldOrPropertyWithValue("props", test.getProps()); + } + + @Test + public void testGetUsersByRole() throws IOException { + Call> usersCall = userDetailsClient.getUserDetailsByRole("ROLE_USER", false, newArrayList("test@test.com")); + Response> response = usersCall.execute(); + + assertThat(response.isSuccessful()).isTrue(); + + List usersDetails = response.body(); + + assertThat(usersDetails).isNotEmpty(); + } + + @Test + public void testSearch() throws IOException { + Call> usersCall = userDetailsClient.searchUserDetails("test test", 10); + Response> response = usersCall.execute(); + + assertThat(response.isSuccessful()).isTrue(); + + List usersDetails = response.body(); + + assertThat(usersDetails).isNotEmpty(); + } + + @Test + public void testGetAllUserDetailsWithInvalidId() throws IOException { + UserDetailsFromIdListRequest request = new UserDetailsFromIdListRequest(newArrayList("test@test.com"), true); + Call call = userDetailsClient.getUserDetailsFromIdList(request); + Response response = call.execute(); + + assertThat(response.isSuccessful()).isTrue(); + + UserDetailsFromIdListResponse usersDetails = response.body(); + + assertThat(usersDetails).isNotNull(); + assertThat(usersDetails.isSuccess()).isFalse(); + assertThat(usersDetails.getMessage()).isNotBlank(); + } + + @Test + public void testGetFullList() throws IOException { + Call> call = userDetailsClient.getUserListFull(); + Response> response = call.execute(); + + assertThat(response.isSuccessful()).isTrue(); + + List userDetailsList = response.body(); + + assertThat(userDetailsList).isNotNull().contains(test); + } + + @Test + public void testGetUserStats() throws IOException { + Call call = userDetailsClient.getUserStats(); + Response response = call.execute(); + assertThat(response.isSuccessful()).isTrue(); + + UserStatsResponse body = response.body(); + + assertThat(body).isNotNull(); + assertThat(body.getDescription()).isEqualTo("description"); + assertThat(body.getTotalUsers()).isEqualTo(2); + assertThat(body.getTotalUsersOneYearAgo()).isEqualTo(1); + } + +} diff --git a/userdetails-service-client/src/test/java/au/org/ala/web/UserDetailsTest.java b/userdetails-service-client/src/test/java/au/org/ala/web/UserDetailsTest.java new file mode 100644 index 00000000..68b92640 --- /dev/null +++ b/userdetails-service-client/src/test/java/au/org/ala/web/UserDetailsTest.java @@ -0,0 +1,37 @@ +package au.org.ala.web; + +import org.junit.Test; +import static org.junit.Assert.*; + +public class UserDetailsTest { + + @Test + public void testMapProperties() { + UserDetails userDetails = new UserDetails(); + final String city = "city"; + final String state = "state"; + final String country = "country"; + final String organisation = "organisation"; + + userDetails.setOrganisation(organisation); + userDetails.setCity(city); + userDetails.setState(state); + userDetails.setCountry(country); + + assertEquals(organisation, userDetails.getOrganisation()); + assertEquals(city, userDetails.getCity()); + assertEquals(state, userDetails.getState()); + assertEquals(country, userDetails.getCountry()); + + userDetails.setCountry(country); + userDetails.setState(state); + userDetails.setCity(city); + userDetails.setOrganisation(organisation); + + assertEquals(country, userDetails.getCountry()); + assertEquals(state, userDetails.getState()); + assertEquals(city, userDetails.getCity()); + assertEquals(organisation, userDetails.getOrganisation()); + } + +}