From c9767e76d79abbad839a19e656d4c38f8c8d34ad Mon Sep 17 00:00:00 2001 From: Adi Raju Date: Tue, 8 Sep 2015 11:42:34 +0530 Subject: [PATCH] MIFOSX-1746: Add support for Oauth2 and make it build time configurable --- .gitignore | 1 + api-docs/apiLive.htm | 385 +++++++++++++++++- mifosng-provider/build.gradle | 16 + mifosng-provider/dependencies.gradle | 2 + mifosng-provider/dev-dependencies.gradle | 1 + .../basicauth/application.properties | 7 + .../properties/oauth/application.properties | 8 + .../AbstractApplicationConfiguration.java | 2 +- .../core/boot/WebXmlConfiguration.java | 2 + .../core/boot/WebXmlOauthConfiguration.java | 61 +++ .../api/AuthenticationApiResource.java | 2 + .../security/api/UserDetailsApiResource.java | 98 +++++ .../data/AuthenticatedOauthUserData.java | 90 ++++ .../TenantAwareBasicAuthenticationFilter.java | 2 + .../TenantAwareTenantIdentifierFilter.java | 148 +++++++ .../META-INF/spring/securityContext.xml | 253 +++++++++--- .../src/main/resources/application.properties | 7 + .../core_db/V273__oauth_changes.sql | 43 ++ .../provider/CommandHandlerProviderTest.java | 3 + .../spring/SpringConfigurationTest.java | 2 + ...pringBootWithMariaDB4jIntegrationTest.java | 2 + 21 files changed, 1077 insertions(+), 58 deletions(-) create mode 100644 mifosng-provider/properties/basicauth/application.properties create mode 100644 mifosng-provider/properties/oauth/application.properties create mode 100644 mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/WebXmlOauthConfiguration.java create mode 100644 mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/api/UserDetailsApiResource.java create mode 100644 mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/data/AuthenticatedOauthUserData.java create mode 100644 mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/filter/TenantAwareTenantIdentifierFilter.java create mode 100644 mifosng-provider/src/main/resources/application.properties create mode 100644 mifosng-provider/src/main/resources/sql/migrations/core_db/V273__oauth_changes.sql diff --git a/.gitignore b/.gitignore index 4a407e1f7..30afd61f0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ rebel.xml *.gradletasknamecache *~ .metadata +mifosng-provider\src\main\resources\application.properties \ No newline at end of file diff --git a/api-docs/apiLive.htm b/api-docs/apiLive.htm index 86d875414..451da8843 100644 --- a/api-docs/apiLive.htm +++ b/api-docs/apiLive.htm @@ -104,6 +104,8 @@
  • Updating Dates and Numbers
  • Field Descriptions
  • Authentication Overview
  • +
  • Authentication HTTP Basic
  • +
  • Authentication Oauth2
  • Errors
  • Batch API
  • Full API Matrix
  • @@ -2141,13 +2143,37 @@ - Authentication + Authentication HTTP Basic authentication - Verify Authentication + Verify Authentication + + Authentication Oauth2 + oauth/token + OAuth2 Access and refresh Token Request + + + + + + + + OAuth2 Access Token Request from refresh token + + + + + + + authenticated user + + Fetch Authenticated user details + + + User users @@ -3039,11 +3065,14 @@

    Authentication Overview

    - Authentication to the API occurs via HTTP - Basic Auth. + Basic Auth or OAuth2.

    +

    + Default authentication is using HTTP Basic Auth. Oauth2 can be enabled by using -Psecurity=oauth option on gradle build command. +

    The platform has been configured to reject plain HTTP requests and to expect all API requests to be made over

    +
    + +   +
    +
    +

    Authentication HTTP Basic

    +

    + Authentication to the API occurs via HTTP + Basic Auth. +

    + +
    +
    + // A Javascript/Jquery example of how to login to Mifos X and use its api. // Typically, the javascript application would // 1) Display a login page to retrieve the username and password. @@ -3098,6 +3147,63 @@ function executeAjaxRequest(url, verbType, jsonData, basicAuthKey, successFuncti
    +   +
    +
    +

    Authentication Oauth2

    +

    + Authentication to the API occurs via OAuth2. 'Resource Owner Password Credentials Grant' type is used. +

    + +
    +
    + +// A Javascript/Jquery example of how to login to Mifos X and use its api. +// Typically, the javascript application would +// 1) Display a login page to retrieve the username and password. +// 2) Send the username, password, client_id , grant_type and client_secret to a function +// such as getOauthToken below which sets the HTTP bearer Auth key. +// 3) The HTTP bearer Auth key is used in all subsequent requests +// (see the function executeAjaxRequest below). + +function getOauthToken(username, password) { + + var jqxhr = $.ajax({ + url : "/mifosng-provider/api/oauth/token?username=" + credentials.username + "&password=" + credentials.password +"&client_id=community-app&grant_type=password&client_secret=123, + type : 'POST', + contentType : "application/json; charset=utf-8", + dataType : 'json', + data : "{}", + cache : false, + success : function(data, textStatus, jqXHR) { + authKey = data.access_token; + }, + error : function(jqXHR, textStatus, errorThrown) { + //error processing + } + }); +} + +function executeAjaxRequest(url, verbType, jsonData, authKey, successFunction, errorFunction) { + var jqxhr = $.ajax({ + url : url, + type : verbType, //POST, GET, PUT or DELETE + contentType : "application/json; charset=utf-8", + dataType : 'json', + data : jsonData, + cache : false, + beforeSend : function(xhr) { + xhr.setRequestHeader("Authorization", "bearer " + authKey); + }, + success : successFunction, + error : errorFunction + }); +} + +
    +
    +  
    @@ -30584,12 +30690,12 @@ GET https://DomainName/api/v1/runreports/{reportName}
    -   +  
    -

    Authentication

    +

    Authentication HTTP Basic

    An API capability that allows client applications to verify - authentication details.

    + authentication details using HTTP Basic Authentication.

    @@ -30608,7 +30714,7 @@ GET https://DomainName/api/v1/runreports/{reportName} -   +  

    Verify authentication

    @@ -30695,6 +30801,269 @@ No Request Body
    +   +
    +
    +

    Authentication Oauth2

    +

    An API capability that allows client applications to fetch current user details details using Oauth2.

    +
    Field Descriptions
    + + + + + + + + + + +
    Field Descriptions
    accessToken
    HTTP Auth bearer key. See Authentication Overview for + an example of its use. +
    +
    +
    + +   +
    +
    +

    Authentication via OAuth2

    +

    API for requesting OAuth2 access token and refresh token.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Field Descriptions
    grant_type
    Mandatory field. + Indicates the requested grant type. Supported values: + password. +
    client_id
    Mandatory field. + Indicates the client application identity. +
    client_secret
    Optional field. + Indicates the client application password. +
    username
    Mandatory field. + Application User(resource) login name. +
    password
    Mandatory field. + Application User(resource) password. +
    +
    +
    + +   +
    +
    +

    OAuth2 Refresh and Access Token Request

    +
    +
    + +POST https://DomainName/api/oauth/token?username={username}&password={password}&client_id={clientId}&grant_type={grant_type}&client_secret={client_secret} + + +POST api/oauth/token?username=mifos&password=password&client_id=community-app&grant_type=password&client_secret=123 +Content-Type: application/json +No Request Body + + +{ + ""access_token": "b771987f-82fc-45ba-b521-bfe280c4e603", + "token_type": "bearer", + "refresh_token":"a2a89b23-8d22-4d90-8585-8f464db433b0", + "expires_in": 3599, + "scope": "all" +} + +
    +
    + + +   +
    +
    +

    Authentication via OAuth2

    +

    API for requesting OAuth2 access tokens through oAuth2 refresh tokens.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Field Descriptions
    grant_type
    Mandatory field. + Indicates the requested grant type. Supported values: + refresh_token. +
    client_id
    Mandatory field. + Indicates the client application identity. +
    client_secret
    Optional field. + Indicates the client application password. +
    refresh_token
    Mandatory field. + Application refresh token to generate access token. +
    +
    +
    + +   +
    +
    +

    OAuth2 Token Request through Refresh Token

    +
    +
    + +POST https://DomainName/api/oauth/token?refresh_token={refresh_token}&client_id={clientId}&grant_type={grant_type}&client_secret={client_secret} + + +POST api/oauth/token?client_id=community-app&grant_type=refresh_token&client_secret=123&refresh_token=a2a89b23-8d22-4d90-8585-8f464db433b0 +Content-Type: application/json +No Request Body + + +{ + ""access_token": "b771987f-82fc-45ba-b521-bfe280c4e643", + "token_type": "bearer", + "refresh_token":"a2a89b23-8d22-4d90-8585-8f464db433b0", + "expires_in": 3599, + "scope": "all" +} + +
    +
    + +   +
    +
    +

    Fetch authenticated user details

    +

    checks the Authentication and returns the set roles and permissions allowed.

    +
    +
    + +POST https://DomainName/api/v1/userdetails?access_token={access_token} + + +POST userdetails?access_token=bWlmb3M6cGFzc3dvcmQ= +Content-Type: application/json +No Request Body + +

    Example response of authenticated user user that is not linked with any staff.

    + +{ + "username": "mifos", + "userId": 1, + "accessToken": "bWlmb3M6cGFzc3dvcmQ=", + "authenticated": true, + "officeId": 1, + "officeName": "Head Office", + "roles": [ + { + "id": 1, + "name": "Super user", + "description": "This role provides all application permissions." + } + ], + "permissions": [ + "ALL_FUNCTIONS" + ] +} + +

    Example response of authenticated user that is linked with a staff member and role.

    + +{ + "username": "mifos", + "userId": 1, + "accessToken": "bWlmb3M6cGFzc3dvcmQ=", + "authenticated": true, + "officeId": 1, + "officeName": "Head Office", + "staffId": 1, + "staffDisplayName": "Director, Program", + "organisationalRole": { + "id": 100, + "code": "staffOrganisationalRoleType.programDirector", + "value": "Program Director" + }, + "roles": [ + { + "id": 1, + "name": "Super user", + "description": "This role provides all application permissions." + } + ], + "permissions": [ + "ALL_FUNCTIONS" + ] +} + + + + + + + +POST api/oauth/token?username=mifos&password=password&client_id=community-app&grant_type=password&client_secret=123 +Content-Type: application/json +No Request Body + + +{ + "developerMessage": "Invalid authentication details were passed in api request.", + "developerDocLink": "https://github.com/openMF/mifosx/wiki/HTTP-API-Error-codes", + "httpStatusCode": "401", + "defaultUserMessage": "Unauthenticated. Please login.", + "userMessageGlobalisationCode": "error.msg.not.authenticated", + "errors": [] +} + +
    +
    +  
    diff --git a/mifosng-provider/build.gradle b/mifosng-provider/build.gradle index d17d54a32..4ff144f12 100644 --- a/mifosng-provider/build.gradle +++ b/mifosng-provider/build.gradle @@ -33,6 +33,7 @@ targetCompatibility = JavaVersion.VERSION_1_7 project.ext.springBootVersion = '1.1.6.RELEASE' project.ext.springVersion = '4.0.7.RELEASE' +project.ext.springOauthVersion = '2.0.4.RELEASE' project.ext.jerseyVersion = '1.17' project.ext.springDataJpaVersion = '1.7.0.RELEASE' // also change spring-boot-gradle-plugin version above @@ -64,6 +65,21 @@ if (project.hasProperty('env') && project.getProperty('env') == 'dev') { apply from: 'dependencies.gradle' } +/* Enable Oauth2 authentication based on environment, default to HTTP basic auth */ +if (project.hasProperty('security') && project.getProperty('security') == 'oauth') { + copy { + from './properties/oauth/' + into 'src/main/resources/' + include '*.properties' + } +} else { + copy { + from './properties/basicauth/' + into 'src/main/resources/' + include '*.properties' + } +} + task dist(type:Zip){ baseName = 'mifosplatform' version = qualifyVersionIfNecessary(releaseVersion) diff --git a/mifosng-provider/dependencies.gradle b/mifosng-provider/dependencies.gradle index 19bf90bdd..cd7273207 100644 --- a/mifosng-provider/dependencies.gradle +++ b/mifosng-provider/dependencies.gradle @@ -21,6 +21,8 @@ dependencies { [group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: springBootVersion], [group: 'org.springframework', name: 'spring-context-support', version: springVersion], + + [group: 'org.springframework.security.oauth', name: 'spring-security-oauth2', version: springOauthVersion], [group: 'javax.ws.rs', name: 'jsr311-api', version: '1.1.1'], [group: 'com.sun.jersey', name: 'jersey-core', version: jerseyVersion], diff --git a/mifosng-provider/dev-dependencies.gradle b/mifosng-provider/dev-dependencies.gradle index aa3aa9a7e..05dd8dc6f 100644 --- a/mifosng-provider/dev-dependencies.gradle +++ b/mifosng-provider/dev-dependencies.gradle @@ -21,6 +21,7 @@ dependencies { [group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: springBootVersion], [group: 'org.springframework', name: 'spring-context-support', version: springVersion], + [group: 'org.springframework.security.oauth', name: 'spring-security-oauth2', version: springOauthVersion], [group: 'javax.ws.rs', name: 'jsr311-api', version: '1.1.1'], [group: 'com.sun.jersey', name: 'jersey-core', version: jerseyVersion], diff --git a/mifosng-provider/properties/basicauth/application.properties b/mifosng-provider/properties/basicauth/application.properties new file mode 100644 index 000000000..4a1ef88aa --- /dev/null +++ b/mifosng-provider/properties/basicauth/application.properties @@ -0,0 +1,7 @@ +# +# 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/. +# + +spring.profiles.default=basicauth \ No newline at end of file diff --git a/mifosng-provider/properties/oauth/application.properties b/mifosng-provider/properties/oauth/application.properties new file mode 100644 index 000000000..6d05eafa6 --- /dev/null +++ b/mifosng-provider/properties/oauth/application.properties @@ -0,0 +1,8 @@ +# +# 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/. +# + +spring.profiles.default=basicauth +spring.profiles.active=oauth \ No newline at end of file diff --git a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/AbstractApplicationConfiguration.java b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/AbstractApplicationConfiguration.java index 5c210dc39..72b280cf1 100644 --- a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/AbstractApplicationConfiguration.java +++ b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/AbstractApplicationConfiguration.java @@ -26,7 +26,7 @@ import org.springframework.context.annotation.ImportResource; * and MariaDB4j (because those differ in the subclasses). */ @Configuration -@Import({ WebXmlConfiguration.class, WebFrontEndConfiguration.class }) +@Import({ WebXmlConfiguration.class, WebXmlOauthConfiguration.class, WebFrontEndConfiguration.class }) @ImportResource({ "classpath*:META-INF/spring/appContext.xml" }) @EnableAutoConfiguration(exclude = { DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class, diff --git a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/WebXmlConfiguration.java b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/WebXmlConfiguration.java index 0d89388d8..31f58c6c0 100644 --- a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/WebXmlConfiguration.java +++ b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/WebXmlConfiguration.java @@ -15,6 +15,7 @@ import org.springframework.boot.context.embedded.FilterRegistrationBean; import org.springframework.boot.context.embedded.ServletRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.web.filter.DelegatingFilterProxy; import com.sun.jersey.spi.spring.container.servlet.SpringServlet; @@ -26,6 +27,7 @@ import com.sun.jersey.spi.spring.container.servlet.SpringServlet; * href="http://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-convert-an-existing-application-to-spring-boot">#howto-convert-an-existing-application-to-spring-boot */ @Configuration +@Profile("basicauth") public class WebXmlConfiguration { @Autowired diff --git a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/WebXmlOauthConfiguration.java b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/WebXmlOauthConfiguration.java new file mode 100644 index 000000000..386dc129b --- /dev/null +++ b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/core/boot/WebXmlOauthConfiguration.java @@ -0,0 +1,61 @@ +/** + * 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/. + */ +package org.mifosplatform.infrastructure.core.boot; + +import javax.servlet.Filter; +import javax.servlet.Servlet; + +import org.springframework.boot.context.embedded.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.filter.DelegatingFilterProxy; +import org.springframework.web.servlet.DispatcherServlet; + +import com.sun.jersey.spi.spring.container.servlet.SpringServlet; + +/** + * This Configuration replaces what formerly was in web.xml. Beans are loaded only when "oauth" Profile is enabled. + * + * @see #howto-convert-an-existing-application-to-spring-boot + */ +@Configuration +@Profile("oauth") +public class WebXmlOauthConfiguration { + + + @Bean + public Filter springSecurityFilterChain() { + return new DelegatingFilterProxy(); + } + + @Bean + public ServletRegistrationBean jersey() { + Servlet jerseyServlet = new SpringServlet(); + ServletRegistrationBean jerseyServletRegistration = new ServletRegistrationBean(); + jerseyServletRegistration.setServlet(jerseyServlet); + jerseyServletRegistration.addUrlMappings("/api/v1/*"); + jerseyServletRegistration.setName("jersey-servlet"); + jerseyServletRegistration.setLoadOnStartup(1); + jerseyServletRegistration.addInitParameter("com.sun.jersey.api.json.POJOMappingFeature", "true"); +// jerseyServletRegistration.addInitParameter("com.sun.jersey.spi.container.ContainerResponseFilters", +// ResponseCorsFilter.class.getName()); + jerseyServletRegistration.addInitParameter("com.sun.jersey.config.feature.DisableWADL", "true"); + // debugging for development: + // jerseyServletRegistration.addInitParameter("com.sun.jersey.spi.container.ContainerRequestFilters", + // LoggingFilter.class.getName()); + return jerseyServletRegistration; + } + + @Bean + public ServletRegistrationBean dispatcherRegistration(DispatcherServlet dispatcherServlet) { + ServletRegistrationBean registrationBean = new ServletRegistrationBean(dispatcherServlet); + registrationBean.addUrlMappings("/api/oauth/token"); + return registrationBean; + } + +} \ No newline at end of file diff --git a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/api/AuthenticationApiResource.java b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/api/AuthenticationApiResource.java index 25648f6e8..e8a00a8ce 100644 --- a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/api/AuthenticationApiResource.java +++ b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/api/AuthenticationApiResource.java @@ -24,6 +24,7 @@ import org.mifosplatform.useradministration.domain.AppUser; import org.mifosplatform.useradministration.domain.Role; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Scope; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; @@ -35,6 +36,7 @@ import com.sun.jersey.core.util.Base64; @Path("/authentication") @Component +@Profile("basicauth") @Scope("singleton") public class AuthenticationApiResource { diff --git a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/api/UserDetailsApiResource.java b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/api/UserDetailsApiResource.java new file mode 100644 index 000000000..ebdd15552 --- /dev/null +++ b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/api/UserDetailsApiResource.java @@ -0,0 +1,98 @@ +/** + * 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/. + */ +package org.mifosplatform.infrastructure.security.api; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Set; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; + +import org.mifosplatform.infrastructure.core.data.EnumOptionData; +import org.mifosplatform.infrastructure.core.serialization.ToApiJsonSerializer; +import org.mifosplatform.infrastructure.security.data.AuthenticatedOauthUserData; +import org.mifosplatform.infrastructure.security.service.SpringSecurityPlatformSecurityContext; +import org.mifosplatform.useradministration.data.RoleData; +import org.mifosplatform.useradministration.domain.AppUser; +import org.mifosplatform.useradministration.domain.Role; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Profile; +import org.springframework.context.annotation.Scope; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices; +import org.springframework.stereotype.Component; + +/* + * Implementation of Oauth2 authentication APIs, loaded only when "oauth" profile is enabled. + */ +@Path("/userdetails") +@Component +@Profile("oauth") +@Scope("singleton") +public class UserDetailsApiResource { + + private final ResourceServerTokenServices tokenServices; + private final ToApiJsonSerializer apiJsonSerializerService; + private final SpringSecurityPlatformSecurityContext springSecurityPlatformSecurityContext; + + @Autowired + public UserDetailsApiResource(@Qualifier("tokenServices") final ResourceServerTokenServices tokenServices, + final ToApiJsonSerializer apiJsonSerializerService, + final SpringSecurityPlatformSecurityContext springSecurityPlatformSecurityContext) { + this.tokenServices = tokenServices; + this.apiJsonSerializerService = apiJsonSerializerService; + this.springSecurityPlatformSecurityContext = springSecurityPlatformSecurityContext; + } + + @GET + @Produces({ MediaType.APPLICATION_JSON }) + public String fetchAuthenticatedUserData(@QueryParam("access_token") final String accessToken) { + + final Authentication authentication = this.tokenServices.loadAuthentication(accessToken); + if (authentication.isAuthenticated()) { + final AppUser principal = (AppUser) authentication.getPrincipal(); + + final Collection permissions = new ArrayList<>(); + AuthenticatedOauthUserData authenticatedUserData = new AuthenticatedOauthUserData(principal.getUsername(), permissions); + + final Collection authorities = new ArrayList<>(authentication.getAuthorities()); + for (final GrantedAuthority grantedAuthority : authorities) { + permissions.add(grantedAuthority.getAuthority()); + } + + final Collection roles = new ArrayList<>(); + final Set userRoles = principal.getRoles(); + for (final Role role : userRoles) { + roles.add(role.toData()); + } + + final Long officeId = principal.getOffice().getId(); + final String officeName = principal.getOffice().getName(); + + final Long staffId = principal.getStaffId(); + final String staffDisplayName = principal.getStaffDisplayName(); + + final EnumOptionData organisationalRole = principal.organisationalRoleData(); + + if (this.springSecurityPlatformSecurityContext.doesPasswordHasToBeRenewed(principal)) { + authenticatedUserData = new AuthenticatedOauthUserData(principal.getUsername(), principal.getId(), accessToken); + } else { + + authenticatedUserData = new AuthenticatedOauthUserData(principal.getUsername(), officeId, officeName, staffId, staffDisplayName, + organisationalRole, roles, permissions, principal.getId(), accessToken); + } + return this.apiJsonSerializerService.serialize(authenticatedUserData); + } + return null; + + } +} \ No newline at end of file diff --git a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/data/AuthenticatedOauthUserData.java b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/data/AuthenticatedOauthUserData.java new file mode 100644 index 000000000..b6c900102 --- /dev/null +++ b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/data/AuthenticatedOauthUserData.java @@ -0,0 +1,90 @@ +/** + * 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/. + */ +package org.mifosplatform.infrastructure.security.data; + +import java.util.Collection; + +import org.mifosplatform.infrastructure.core.data.EnumOptionData; +import org.mifosplatform.useradministration.data.RoleData; + +/** + * Immutable data object for authentication. Used in case of Oauth2. + */ +public class AuthenticatedOauthUserData { + + @SuppressWarnings("unused") + private final String username; + @SuppressWarnings("unused") + private final Long userId; + @SuppressWarnings("unused") + private final String accessToken; + @SuppressWarnings("unused") + private final boolean authenticated; + @SuppressWarnings("unused") + private final Long officeId; + @SuppressWarnings("unused") + private final String officeName; + @SuppressWarnings("unused") + private final Long staffId; + @SuppressWarnings("unused") + private final String staffDisplayName; + @SuppressWarnings("unused") + private final EnumOptionData organisationalRole; + @SuppressWarnings("unused") + private final Collection roles; + @SuppressWarnings("unused") + private final Collection permissions; + + @SuppressWarnings("unused") + private final boolean shouldRenewPassword; + + public AuthenticatedOauthUserData(final String username, final Collection permissions) { + this.username = username; + this.userId = null; + this.accessToken = null; + this.authenticated = false; + this.officeId = null; + this.officeName = null; + this.staffId = null; + this.staffDisplayName = null; + this.organisationalRole = null; + this.roles = null; + this.permissions = permissions; + this.shouldRenewPassword = false; + } + + public AuthenticatedOauthUserData(final String username, final Long officeId, final String officeName, final Long staffId, + final String staffDisplayName, final EnumOptionData organisationalRole, final Collection roles, + final Collection permissions, final Long userId, final String accessToken) { + this.username = username; + this.officeId = officeId; + this.officeName = officeName; + this.staffId = staffId; + this.staffDisplayName = staffDisplayName; + this.organisationalRole = organisationalRole; + this.userId = userId; + this.accessToken = accessToken; + this.authenticated = true; + this.roles = roles; + this.permissions = permissions; + this.shouldRenewPassword = false; + } + + public AuthenticatedOauthUserData(final String username, final Long userId, final String accessToken) { + this.username = username; + this.officeId = null; + this.officeName = null; + this.staffId = null; + this.staffDisplayName = null; + this.organisationalRole = null; + this.userId = userId; + this.accessToken = accessToken; + this.authenticated = true; + this.roles = null; + this.permissions = null; + this.shouldRenewPassword = true; + } +} \ No newline at end of file diff --git a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/filter/TenantAwareBasicAuthenticationFilter.java b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/filter/TenantAwareBasicAuthenticationFilter.java index 1e4bb9aba..a95b9c96d 100644 --- a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/filter/TenantAwareBasicAuthenticationFilter.java +++ b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/filter/TenantAwareBasicAuthenticationFilter.java @@ -27,6 +27,7 @@ import org.mifosplatform.infrastructure.security.service.BasicAuthTenantDetailsS import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.AuthenticationEntryPoint; @@ -49,6 +50,7 @@ import org.springframework.stereotype.Service; * is returned. */ @Service(value = "basicAuthenticationProcessingFilter") +@Profile("basicauth") public class TenantAwareBasicAuthenticationFilter extends BasicAuthenticationFilter { private static boolean firstRequestProcessed = false; diff --git a/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/filter/TenantAwareTenantIdentifierFilter.java b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/filter/TenantAwareTenantIdentifierFilter.java new file mode 100644 index 000000000..017776240 --- /dev/null +++ b/mifosng-provider/src/main/java/org/mifosplatform/infrastructure/security/filter/TenantAwareTenantIdentifierFilter.java @@ -0,0 +1,148 @@ +/** + * 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/. + */ +package org.mifosplatform.infrastructure.security.filter; + +import java.io.IOException; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.lang.time.StopWatch; +import org.mifosplatform.infrastructure.cache.domain.CacheType; +import org.mifosplatform.infrastructure.cache.service.CacheWritePlatformService; +import org.mifosplatform.infrastructure.configuration.domain.ConfigurationDomainService; +import org.mifosplatform.infrastructure.core.domain.MifosPlatformTenant; +import org.mifosplatform.infrastructure.core.serialization.ToApiJsonSerializer; +import org.mifosplatform.infrastructure.core.service.ThreadLocalContextUtil; +import org.mifosplatform.infrastructure.security.data.PlatformRequestLog; +import org.mifosplatform.infrastructure.security.exception.InvalidTenantIdentiferException; +import org.mifosplatform.infrastructure.security.service.BasicAuthTenantDetailsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.filter.GenericFilterBean; + +/** + * + * This filter is responsible for extracting multi-tenant from the request and + * setting Cross-Origin details to response. + * + * If multi-tenant are valid, the details of the tenant are stored in + * {@link MifosPlatformTenant} and stored in a {@link ThreadLocal} variable for + * this request using {@link ThreadLocalContextUtil}. + * + * If multi-tenant are invalid, a http error response is returned. + * + * Used to support Oauth2 authentication and the service is loaded only when "oauth" profile is active. + */ +@Service(value = "tenantIdentifierProcessingFilter") +@Profile("oauth") +public class TenantAwareTenantIdentifierFilter extends GenericFilterBean { + + private static boolean firstRequestProcessed = false; + private final static Logger logger = LoggerFactory.getLogger(TenantAwareTenantIdentifierFilter.class); + + private final BasicAuthTenantDetailsService basicAuthTenantDetailsService; + private final ToApiJsonSerializer toApiJsonSerializer; + private final ConfigurationDomainService configurationDomainService; + private final CacheWritePlatformService cacheWritePlatformService; + + private final String tenantRequestHeader = "X-Mifos-Platform-TenantId"; + private final boolean exceptionIfHeaderMissing = true; + private final String apiUri = "/api/v1/"; + + @Autowired + public TenantAwareTenantIdentifierFilter(final BasicAuthTenantDetailsService basicAuthTenantDetailsService, + final ToApiJsonSerializer toApiJsonSerializer, final ConfigurationDomainService configurationDomainService, + final CacheWritePlatformService cacheWritePlatformService) { + this.basicAuthTenantDetailsService = basicAuthTenantDetailsService; + this.toApiJsonSerializer = toApiJsonSerializer; + this.configurationDomainService = configurationDomainService; + this.cacheWritePlatformService = cacheWritePlatformService; + } + + @Override + public void doFilter(final ServletRequest req, final ServletResponse res, final FilterChain chain) throws IOException, ServletException { + + final HttpServletRequest request = (HttpServletRequest) req; + final HttpServletResponse response = (HttpServletResponse) res; + + final StopWatch task = new StopWatch(); + task.start(); + + try { + + // allows for Cross-Origin + // Requests (CORs) to be performed against the platform API. + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + final String reqHead = request.getHeader("Access-Control-Request-Headers"); + + if (null != reqHead && !reqHead.equals(null)) { + response.setHeader("Access-Control-Allow-Headers", reqHead); + } + + if (!"OPTIONS".equalsIgnoreCase(request.getMethod())) { + + String tenantIdentifier = request.getHeader(this.tenantRequestHeader); + if (org.apache.commons.lang.StringUtils.isBlank(tenantIdentifier)) { + tenantIdentifier = request.getParameter("tenantIdentifier"); + } + + if (tenantIdentifier == null && this.exceptionIfHeaderMissing) { throw new InvalidTenantIdentiferException( + "No tenant identifier found: Add request header of '" + this.tenantRequestHeader + + "' or add the parameter 'tenantIdentifier' to query string of request URL."); } + + String pathInfo = request.getRequestURI(); + boolean isReportRequest = false; + if (pathInfo != null && pathInfo.contains("report")) { + isReportRequest = true; + } + final MifosPlatformTenant tenant = this.basicAuthTenantDetailsService.loadTenantById(tenantIdentifier, isReportRequest); + + ThreadLocalContextUtil.setTenant(tenant); + String authToken = request.getHeader("Authorization"); + + if (authToken != null && authToken.startsWith("bearer ")) { + ThreadLocalContextUtil.setAuthToken(authToken.replaceFirst("bearer ", "")); + } + + if (!firstRequestProcessed) { + final String baseUrl = request.getRequestURL().toString() + .replace(request.getRequestURI(), request.getContextPath() + apiUri); + System.setProperty("baseUrl", baseUrl); + + final boolean ehcacheEnabled = this.configurationDomainService.isEhcacheEnabled(); + if (ehcacheEnabled) { + this.cacheWritePlatformService.switchToCache(CacheType.SINGLE_NODE); + } else { + this.cacheWritePlatformService.switchToCache(CacheType.NO_CACHE); + } + TenantAwareTenantIdentifierFilter.firstRequestProcessed = true; + } + chain.doFilter(request, response); + } + } catch (final InvalidTenantIdentiferException e) { + // deal with exception at low level + SecurityContextHolder.getContext().setAuthentication(null); + + response.addHeader("WWW-Authenticate", "Basic realm=\"" + "Mifos Platform API" + "\""); + response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); + } finally { + task.stop(); + final PlatformRequestLog log = PlatformRequestLog.from(task, request); + logger.info(this.toApiJsonSerializer.serialize(log)); + } + + } +} \ No newline at end of file diff --git a/mifosng-provider/src/main/resources/META-INF/spring/securityContext.xml b/mifosng-provider/src/main/resources/META-INF/spring/securityContext.xml index 136a536e3..abb47a67e 100644 --- a/mifosng-provider/src/main/resources/META-INF/spring/securityContext.xml +++ b/mifosng-provider/src/main/resources/META-INF/spring/securityContext.xml @@ -11,55 +11,210 @@ You can obtain one at http://mozilla.org/MPL/2.0/. --> + http://www.springframework.org/schema/beans + http://www.springframework.org/schema/beans/spring-beans-3.1.xsd + http://www.springframework.org/schema/security/oauth2 + http://www.springframework.org/schema/security/spring-security-oauth2.xsd + http://www.springframework.org/schema/security + http://www.springframework.org/schema/security/spring-security-3.2.xsd"> - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/mifosng-provider/src/main/resources/application.properties b/mifosng-provider/src/main/resources/application.properties new file mode 100644 index 000000000..4a1ef88aa --- /dev/null +++ b/mifosng-provider/src/main/resources/application.properties @@ -0,0 +1,7 @@ +# +# 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/. +# + +spring.profiles.default=basicauth \ No newline at end of file diff --git a/mifosng-provider/src/main/resources/sql/migrations/core_db/V273__oauth_changes.sql b/mifosng-provider/src/main/resources/sql/migrations/core_db/V273__oauth_changes.sql new file mode 100644 index 000000000..6197606e9 --- /dev/null +++ b/mifosng-provider/src/main/resources/sql/migrations/core_db/V273__oauth_changes.sql @@ -0,0 +1,43 @@ + +CREATE TABLE `oauth_client_details` ( + `client_id` varchar(128) NOT NULL, + `resource_ids` varchar(256) DEFAULT NULL, + `client_secret` varchar(256) DEFAULT NULL, + `scope` varchar(256) DEFAULT NULL, + `authorized_grant_types` varchar(256) DEFAULT NULL, + `web_server_redirect_uri` varchar(256) DEFAULT NULL, + `authorities` varchar(256) DEFAULT NULL, + `access_token_validity` int(11) DEFAULT NULL, + `refresh_token_validity` int(11) DEFAULT NULL, + `additional_information` varchar(4096) DEFAULT NULL, + `autoapprove` BIT(1) NULL DEFAULT NULL, + PRIMARY KEY (`client_id`) +) +COLLATE='utf8_general_ci' +ENGINE=InnoDB +AUTO_INCREMENT=1; + +INSERT INTO `oauth_client_details` (`client_id`, `client_secret`, `scope`, `authorized_grant_types`) VALUES ('community-app', '123', 'all', 'password,refresh_token'); + + +CREATE TABLE `oauth_access_token` ( + `token_id` varchar(256) DEFAULT NULL, + `token` blob, + `authentication_id` varchar(256) DEFAULT NULL, + `user_name` varchar(256) DEFAULT NULL, + `client_id` varchar(256) DEFAULT NULL, + `authentication` blob, + `refresh_token` varchar(256) DEFAULT NULL +) +COLLATE='utf8_general_ci' +ENGINE=InnoDB +AUTO_INCREMENT=1; + +CREATE TABLE `oauth_refresh_token` ( + `token_id` varchar(256) DEFAULT NULL, + `token` blob, + `authentication` blob +) +COLLATE='utf8_general_ci' +ENGINE=InnoDB; + diff --git a/mifosng-provider/src/test/java/org/mifosplatform/commands/provider/CommandHandlerProviderTest.java b/mifosng-provider/src/test/java/org/mifosplatform/commands/provider/CommandHandlerProviderTest.java index c618c3260..ec575b78b 100644 --- a/mifosng-provider/src/test/java/org/mifosplatform/commands/provider/CommandHandlerProviderTest.java +++ b/mifosng-provider/src/test/java/org/mifosplatform/commands/provider/CommandHandlerProviderTest.java @@ -14,12 +14,15 @@ import org.mifosplatform.infrastructure.configuration.spring.TestsWithoutDatabas import org.mifosplatform.infrastructure.core.api.JsonCommand; import org.mifosplatform.infrastructure.core.data.CommandProcessingResult; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; @RunWith(SpringJUnit4ClassRunner.class) @WebAppConfiguration +@ActiveProfiles("basicauth") @ContextConfiguration(classes = TestsWithoutDatabaseAndNoJobsConfiguration.class) public class CommandHandlerProviderTest { diff --git a/mifosng-provider/src/test/java/org/mifosplatform/infrastructure/configuration/spring/SpringConfigurationTest.java b/mifosng-provider/src/test/java/org/mifosplatform/infrastructure/configuration/spring/SpringConfigurationTest.java index e926d21f5..fdb2e5199 100644 --- a/mifosng-provider/src/test/java/org/mifosplatform/infrastructure/configuration/spring/SpringConfigurationTest.java +++ b/mifosng-provider/src/test/java/org/mifosplatform/infrastructure/configuration/spring/SpringConfigurationTest.java @@ -7,6 +7,7 @@ package org.mifosplatform.infrastructure.configuration.spring; import org.junit.Test; import org.junit.runner.RunWith; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; @@ -25,6 +26,7 @@ import org.springframework.test.context.web.WebAppConfiguration; */ @RunWith(SpringJUnit4ClassRunner.class) @WebAppConfiguration +@ActiveProfiles("basicauth") @ContextConfiguration(classes = TestsWithoutDatabaseAndNoJobsConfiguration.class) public class SpringConfigurationTest { diff --git a/mifosng-provider/src/test/java/org/mifosplatform/infrastructure/core/boot/tests/AbstractSpringBootWithMariaDB4jIntegrationTest.java b/mifosng-provider/src/test/java/org/mifosplatform/infrastructure/core/boot/tests/AbstractSpringBootWithMariaDB4jIntegrationTest.java index 5d3160208..93642c22d 100644 --- a/mifosng-provider/src/test/java/org/mifosplatform/infrastructure/core/boot/tests/AbstractSpringBootWithMariaDB4jIntegrationTest.java +++ b/mifosng-provider/src/test/java/org/mifosplatform/infrastructure/core/boot/tests/AbstractSpringBootWithMariaDB4jIntegrationTest.java @@ -9,12 +9,14 @@ import org.junit.runner.RunWith; import org.mifosplatform.ServerWithMariaDB4jApplication; import org.springframework.boot.test.IntegrationTest; import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = ServerWithMariaDB4jApplication.Configuration.class) @WebAppConfiguration +@ActiveProfiles("basicauth") @IntegrationTest({ "server.port=0", "management.port=0", "mariaDB4j.port=0", "mariaDB4j.dataDir=null" }) public abstract class AbstractSpringBootWithMariaDB4jIntegrationTest {