fix #1981, Support OAuth2 Social login
Project: http://git-wip-us.apache.org/repos/asf/incubator-gearpump/repo Commit: http://git-wip-us.apache.org/repos/asf/incubator-gearpump/commit/099842ad Tree: http://git-wip-us.apache.org/repos/asf/incubator-gearpump/tree/099842ad Diff: http://git-wip-us.apache.org/repos/asf/incubator-gearpump/diff/099842ad Branch: refs/heads/master Commit: 099842ada64d64a5286fdc45ee9b634c5e82db62 Parents: 95d3c61 Author: Sean Zhong <[email protected]> Authored: Tue Mar 15 12:01:23 2016 +0800 Committer: manuzhang <[email protected]> Committed: Tue Apr 26 14:22:29 2016 +0800 ---------------------------------------------------------------------- conf/gear.conf | 115 ++++++- core/src/main/resources/geardefault.conf | 116 ++++++- .../serializer/GearpumpSerialization.scala | 2 +- .../main/scala/io/gearpump/util/Constants.scala | 14 +- docs/deployment-ui-authentication.md | 309 +++++++++++++++++-- docs/dev-rest-api.md | 52 +++- project/Build.scala | 3 + services/dashboard/icons/google.png | Bin 0 -> 885 bytes services/dashboard/icons/uaa.png | Bin 0 -> 546 bytes services/dashboard/login/login.html | 14 + services/dashboard/login/login.js | 78 +++-- .../io/gearpump/services/RestServices.scala | 2 +- .../io/gearpump/services/SecurityService.scala | 111 +++++-- .../security/oauth2/OAuth2Authenticator.scala | 144 +++++++++ .../oauth2/impl/BaseOAuth2Authenticator.scala | 217 +++++++++++++ .../CloudFoundryUAAOAuth2Authenticator.scala | 137 ++++++++ .../oauth2/impl/GoogleOAuth2Authenticator.scala | 100 ++++++ ...CloudFoundryUAAOAuth2AuthenticatorSpec.scala | 130 ++++++++ .../oauth2/GoogleOAuth2AuthenticatorSpec.scala | 153 +++++++++ .../security/oauth2/MockOAuth2Server.scala | 66 ++++ 20 files changed, 1685 insertions(+), 78 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/conf/gear.conf ---------------------------------------------------------------------- diff --git a/conf/gear.conf b/conf/gear.conf index e5898e9..84694a8 100644 --- a/conf/gear.conf +++ b/conf/gear.conf @@ -62,7 +62,10 @@ gearpump { ### When the resource cannot be allocated in the timeout, then ### the appmaster will shutdown itself. resource-allocation-timeout-seconds = 120 - + + ## + ## Executor share same process of worker + worker.executor-share-same-jvm-as-worker = false ########################### ### Change the dispather for tasks @@ -178,7 +181,7 @@ gearpump { ### For shared NFS folder, use file:///your_nfs_mapping_directory ### jarstore.rootpath = "jarstore/" will points to relative directory where master is started. ### jarstore.rootpath = "/jarstore/" will points to absolute directory on master server - # jarstore.rootpath = "jarstore/" + jarstore.rootpath = "jarstore/" ######################### ### Scheduller for master, it will use this scheduler to schedule resource for @@ -376,10 +379,19 @@ gearpump-ui { gearpump.ui-security { ## Whether enable authentication for UI Server - authentication-enabled = true - - ## What authenticator to use. The class must implement interface - ## io.gearpump.security.Authenticator + authentication-enabled = false + + ## User-Password based authenticator + ## + ## User-Password based authenticator is always enabled when + ## gearpump.ui-security.authentication-enabled = true + ## + ## With User-Password based authenticator inplace, user can still enable auxiliary + ## authentication channel like OAuth2. + ## + ## User can replace this with a custom User-Password based authenticator, + ## which implements interface io.gearpump.security.Authenticator + ## authenticator = "io.gearpump.security.ConfigFileBasedAuthenticator" ## Configuration options for authenticator io.gearpump.security.ConfigFileBasedAuthenticator @@ -408,6 +420,97 @@ gearpump-ui { "guest" = "ws+2Dy/FHX4cBb3uKGTR64kZWlWbC91XZRRoew==" } } + + ## Whether to enable auxiliary OAuth2 Authentication channel. + ## + ## NOTE: This requires config {{{gearpump.ui-security.authentication-enabled = true}}} + ## + ## NOTE: User-Password based authenticator will also be enabled. + ## + ## NOTE: OAuth2 authentication requires that the Gearpump server can directly access the OAuth2 server. + ## Please make sure you have configured web proxy properly if applies. + ## To configure http proxy on Windows: + ## {{{ + ## > set JAVA_OPTS=-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088 + ## > bin\services + ## }}} + ## + ## To configure http proxy on Linux: + ## {{{ + ## $ export JAVA_OPTS="-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088" + ## $ bin/services + ## }}} + oauth2-authenticator-enabled = true + oauth2-authenticators { + ## Please modify the list if you have customized OAuth2 provider, like Facebook, Twitter... + + ## OAuth2 Authenticator with Google Plus+ + ## + ## For steps to enable OAuth2 Authentication on Google, please view docs/deployment-ui-authentication.md + ## + "google" { + "class" = "io.gearpump.services.security.oauth2.impl.GoogleOAuth2Authenticator" + + ## Please replace "127.0.0.1:8090" with your address of UI service. + "callback" = "http://127.0.0.1:8090/login/oauth2/google/callback" + + ## Client Id and client secret you applied on Google. + ## + ## !!NOTE!! Replace clientID and clientSecret with your own application to avoid + ## potential privacy leakage, the values set here represents as a test application. + "clientid" = "170234147043-a1tag68jtq6ab4bi11jvsj7vbaqcmhkt.apps.googleusercontent.com" + "clientsecret" = "ioeWLLDipz2S7aTDXym2-obe" + + ## The default role we assign to user when user get authenticated by Google. + ## + ## TODO: should allow some user group have a different role, like admin. + ## + ## Available values: guest, user, admin, with: + ## 1. guest can only view the application status, + ## 2. user can submit and modify application. + ## 3. admin can manage the cluster resource, like adding or removing machines. + "default-userrole" = "guest" + + ## Login icon disiplayed on UI server frontend + icon = "/icons/google.png" + } + + ## OAuth2 Authenticator for CloudFoundry UAA server (https://github.com/cloudfoundry/uaa/). + ## + ## For steps to enable OAuth2 Authentication for UAA, please view docs/deployment-ui-authentication.md + ## + "cloudfoundryuaa" { + "class" = "io.gearpump.services.security.oauth2.impl.CloudFoundryUAAOAuth2Authenticator" + + ## Please replace "127.0.0.1:8090" with your address of UI service. + "callback" = "http://127.0.0.1:8090/login/oauth2/cloudfoundryuaa/callback" + + ## Client Id and client secret you applied on Google. + ## + ## !!NOTE!! Replace clientID and clientSecret with your own application to avoid + ## potential privacy leakage, the values set here serves as a test application. + "clientid" = "gearpump_test2" + "clientsecret" = "gearpump_test2" + + ## The default role we assign to user when user get authenticated by UAA. + ## + ## TODO: should allow some user group have a different role, like admin. + ## + ## Available values: guest, user, admin, with: + ## 1. guest can only view the application status, + ## 2. user can submit and modify application. + ## 3. admin can manage the cluster resource, like adding or removing machines. + "default-userrole" = "guest" + + ## Login icon disiplayed on UI server frontend + icon = "/icons/uaa.png" + + ## The hostname of cloudfoudry UAA server prefixed by "http://" or "https://" + ## !!NOTE!! Please relace uaahost with your actual Cloudfounudry UAA server, the + ## value set here serves as an example. + uaahost = "http://login.gearpump.gotapaas.eu" + } + } } } http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/core/src/main/resources/geardefault.conf ---------------------------------------------------------------------- diff --git a/core/src/main/resources/geardefault.conf b/core/src/main/resources/geardefault.conf index 065e5e0..16bd8c0 100644 --- a/core/src/main/resources/geardefault.conf +++ b/core/src/main/resources/geardefault.conf @@ -99,7 +99,6 @@ gearpump { ### If you want to use metrics, please change ########################### - ### Flag to enable metrics metrics { enabled = false @@ -370,8 +369,17 @@ gearpump-ui { ## Whether enable authentication for UI Server authentication-enabled = false - ## What authenticator to use. The class must implement interface - ## io.gearpump.security.Authenticator + ## User-Password based authenticator + ## + ## User-Password based authenticator is always enabled when + ## gearpump.ui-security.authentication-enabled = true + ## + ## With User-Password based authenticator inplace, user can still enable auxiliary + ## authentication channel like OAuth2. + ## + ## User can replace this with a custom User-Password based authenticator, + ## which implements interface io.gearpump.security.Authenticator + ## authenticator = "io.gearpump.security.ConfigFileBasedAuthenticator" ## Configuration options for authenticator io.gearpump.security.ConfigFileBasedAuthenticator @@ -383,7 +391,8 @@ gearpump-ui { ## Admin users have super permission to do everything admins = { ## Default Admin. Username: admin, password: admin - # "admin" = "AeGxGOxlU8QENdOXejCeLxy+isrCv0TrS37HwA==" + ## !!! Please replace this builtin account for production cluster for security reason. !!! + "admin" = "AeGxGOxlU8QENdOXejCeLxy+isrCv0TrS37HwA==" } ## normal user have special permission for certain operations. @@ -395,7 +404,100 @@ gearpump-ui { ## a running application. guests = { ## Default guest. Username: guest, Password: guest - #"guest" = "ws+2Dy/FHX4cBb3uKGTR64kZWlWbC91XZRRoew==" + ## !!! Please replace this builtin account for production cluster for security reason. !!! + "guest" = "ws+2Dy/FHX4cBb3uKGTR64kZWlWbC91XZRRoew==" + } + } + + ## Whether to enable auxiliary OAuth2 Authentication channel. + ## + ## NOTE: This requires config {{{gearpump.ui-security.authentication-enabled = true}}} + ## + ## NOTE: User-Password based authenticator will also be enabled. + ## + ## NOTE: OAuth2 authentication requires that the Gearpump server can directly access the OAuth2 server. + ## Please make sure you have configured web proxy properly if applies. + ## To configure http proxy on Windows: + ## {{{ + ## > set JAVA_OPTS=-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088 + ## > bin\services + ## }}} + ## + ## To configure http proxy on Linux: + ## {{{ + ## $ export JAVA_OPTS="-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088" + ## $ bin/services + ## }}} + + oauth2-authenticator-enabled = false + oauth2-authenticators { + ## Please modify the list if you have customized OAuth2 provider, like Facebook, Twitter... + + ## OAuth2 Authenticator with Google Plus+ + ## + ## For steps to enable OAuth2 Authentication on Google, please view docs/deployment-ui-authentication.md + ## + "google" { + "class" = "io.gearpump.services.security.oauth2.impl.GoogleOAuth2Authenticator" + + ## Please replace "127.0.0.1:8090" with your address of UI service. + "callback" = "http://127.0.0.1:8090/login/oauth2/google/callback" + + ## Client Id and client secret you applied on Google. + ## + ## !!NOTE!! Replace clientID and clientSecret with your own application to avoid + ## potential privacy leakage, the values set here represents as a test application. + "clientid" = "170234147043-a1tag68jtq6ab4bi11jvsj7vbaqcmhkt.apps.googleusercontent.com" + "clientsecret" = "ioeWLLDipz2S7aTDXym2-obe" + + ## The default role we assign to user when user get authenticated by Google. + ## + ## TODO: should allow some user group have a different role, like admin. + ## + ## Available values: guest, user, admin, with: + ## 1. guest can only view the application status, + ## 2. user can submit and modify application. + ## 3. admin can manage the cluster resource, like adding or removing machines. + "default-userrole" = "guest" + + ## Login icon disiplayed on UI server frontend + icon = "/icons/google.png" + } + + ## OAuth2 Authenticator for CloudFoundry UAA server (https://github.com/cloudfoundry/uaa/). + ## + ## For steps to enable OAuth2 Authentication for UAA, please view docs/deployment-ui-authentication.md + ## + "cloudfoundryuaa" { + "class" = "io.gearpump.services.security.oauth2.impl.CloudFoundryUAAOAuth2Authenticator" + + ## Please replace "127.0.0.1:8090" with your address of UI service. + "callback" = "http://127.0.0.1:8090/login/oauth2/cloudfoundryuaa/callback" + + ## Client Id and client secret you applied on Google. + ## + ## !!NOTE!! Replace clientID and clientSecret with your own application to avoid + ## potential privacy leakage, the values set here serves as a test application. + "clientid" = "gearpump_test2" + "clientsecret" = "gearpump_test2" + + ## The default role we assign to user when user get authenticated by UAA. + ## + ## TODO: should allow some user group have a different role, like admin. + ## + ## Available values: guest, user, admin, with: + ## 1. guest can only view the application status, + ## 2. user can submit and modify application. + ## 3. admin can manage the cluster resource, like adding or removing machines. + "default-userrole" = "guest" + + ## Login icon disiplayed on UI server frontend + icon = "/icons/uaa.png" + + ## The hostname of cloudfoudry UAA server prefixed by "http://" or "https://" + ## !!NOTE!! Please relace uaahost with your actual Cloudfounudry UAA server, the + ## value set here serves as an example. + uaahost = "http://login.gearpump.gotapaas.eu" } } } @@ -447,8 +549,8 @@ akka { httpOnly = true } - ## Session lifetime. Default value is about 1 month - maxAgeSeconds = 2592000 + ## Session lifetime. Default value is about 1 week + maxAgeSeconds = 604800 encryptData = true } } http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/core/src/main/scala/io/gearpump/serializer/GearpumpSerialization.scala ---------------------------------------------------------------------- diff --git a/core/src/main/scala/io/gearpump/serializer/GearpumpSerialization.scala b/core/src/main/scala/io/gearpump/serializer/GearpumpSerialization.scala index 3f7a042..41ccaa4 100644 --- a/core/src/main/scala/io/gearpump/serializer/GearpumpSerialization.scala +++ b/core/src/main/scala/io/gearpump/serializer/GearpumpSerialization.scala @@ -53,6 +53,6 @@ class GearpumpSerialization(config: Config) { private final def configToMap(config : Config, path: String) = { import scala.collection.JavaConverters._ - config.getConfig(path).root.unwrapped.asScala.toMap map { case (k, v) â k -> v.toString } + config.getConfig(path).root.unwrapped.asScala.toMap map { case (k, v) => k -> v.toString } } } \ No newline at end of file http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/core/src/main/scala/io/gearpump/util/Constants.scala ---------------------------------------------------------------------- diff --git a/core/src/main/scala/io/gearpump/util/Constants.scala b/core/src/main/scala/io/gearpump/util/Constants.scala index 7d066e9..084d99b 100644 --- a/core/src/main/scala/io/gearpump/util/Constants.scala +++ b/core/src/main/scala/io/gearpump/util/Constants.scala @@ -147,8 +147,20 @@ object Constants { val GEARPUMP_METRICS_AGGREGATORS = "gearpump.metrics.akka.metrics-aggregator-class" val GEARPUMP_UI_SECURITY = "gearpump.ui-security" - val GEARPUMP_UI_SECURITY_ENABLED = "gearpump.ui-security.authentication-enabled" + val GEARPUMP_UI_SECURITY_AUTHENTICATION_ENABLED = "gearpump.ui-security.authentication-enabled" val GEARPUMP_UI_AUTHENTICATOR_CLASS = "gearpump.ui-security.authenticator" + // OAuth Authentication Factory for UI server. + val GEARPUMP_UI_OAUTH2_AUTHENTICATOR_ENABLED = "gearpump.ui-security.oauth2-authenticator-enabled" + val GEARPUMP_UI_OAUTH2_AUTHENTICATORS = "gearpump.ui-security.oauth2-authenticators" + val GEARPUMP_UI_OAUTH2_AUTHENTICATOR_CLASS = "class" + val GEARPUMP_UI_OAUTH2_AUTHENTICATOR_CALLBACK = "callback" + val GEARPUMP_UI_OAUTH2_AUTHENTICATOR_CLIENT_ID = "clientid" + val GEARPUMP_UI_OAUTH2_AUTHENTICATOR_CLIENT_SECRET = "clientsecret" + val GEARPUMP_UI_OAUTH2_AUTHENTICATOR_DEFAULT_USER_ROLE = "default-userrole" + val GEARPUMP_UI_OAUTH2_AUTHENTICATOR_AUTHORIZATION_CODE = "code" + val GEARPUMP_UI_OAUTH2_AUTHENTICATOR_ACCESS_TOKEN = "accesstoken" val PREFER_IPV4 = "java.net.preferIPv4Stack" + + } http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/docs/deployment-ui-authentication.md ---------------------------------------------------------------------- diff --git a/docs/deployment-ui-authentication.md b/docs/deployment-ui-authentication.md index 0ef44a0..ac5a511 100644 --- a/docs/deployment-ui-authentication.md +++ b/docs/deployment-ui-authentication.md @@ -3,6 +3,9 @@ layout: global title: UI Dashboard Authentication and Authorization --- +## What is this about? + + ## How to enable UI authentication? 1. Change config file gear.conf, find entry `gearpump-ui.gearpump.ui-security.authentication-enabled`, change the value to true @@ -12,8 +15,36 @@ title: UI Dashboard Authentication and Authorization ``` Restart the UI dashboard, and then the UI authentication is enabled. It will prompt for user name and password. - -## How to add or remove user? + +## How many authentication methods Gearpump UI server support? + +Currently, It supports: + +1. Username-Password based authentication and +2. OAuth2 based authentication. + +User-Password based authentication is enabled when `gearpump-ui.gearpump.ui-security.authentication-enabled`, + and **CANNOT** be disabled. + +UI server admin can also choose to enable **auxiliary** OAuth2 authentication channel. + +## User-Password based authentication + + User-Password based authentication covers all authentication scenarios which requires + user to enter an explicit username and password. + + Gearpump provides a built-in ConfigFileBasedAuthenticator which verify user name and password + against password hashcode stored in config files. + + However, developer can choose to extends the ```io.gearpump.security.Authenticator``` to provide a custom + User-Password based authenticator, to support LDAP, Kerberos, and Database-based authentication... + +### ConfigFileBasedAuthenticator: built-in User-Password Authenticator + +ConfigFileBasedAuthenticator store all user name and password hashcode in configuration file gear.conf. Here +is the steps to configure ConfigFileBasedAuthenticator. + +#### How to add or remove user? For the default authentication plugin, it has three categories of users: admins, users, and guests. @@ -59,33 +90,275 @@ Suppose we want to add user jerry as an administrator, here are the steps: 5. See description at `conf/gear.conf` to find more information. -## What is the default user and password? +#### What is the default user and password? -Gearpump distribution is shipped with two default users: +For ConfigFileBasedAuthenticator, Gearpump distribution is shipped with two default users: 1. username: admin, password: admin 2. username: guest, password: guest -User `admin:admin` has unlimited permissions, while guest can only view the application status. Guest account cannot -submit or kill the application by UI console. +User `admin` has unlimited permissions, while `guest` can only view the application status. -For security reason, you need to remove the default user "admin" and "guest" for production cluster. +For security reason, you need to remove the default users `admin` and `guest` for cluster in production. -## Is this secure? +#### Is this secure? Firstly, we will NOT store any user password in any way so only the user himself knows the password. -We will use one-way sha1 digest to verify the user input password. As it is a one-way hashing, -so generally it is safe. +We will use one-way hash digest to verify the user input password. + +### How to develop a custom User-Password Authenticator for LDAP, Database, and etc.. + +If developer choose to define his/her own User-Password based authenticator, it is required that user + modify configuration option: + +``` +## Replace "io.gearpump.security.CustomAuthenticator" with your real authenticator class. +gearpump.ui-security.authenticator = "io.gearpump.security.CustomAuthenticator" +``` + +Make sure CustomAuthenticator extends interface: +```scala +trait Authenticator { + + def authenticate(user: String, password: String, ec: ExecutionContext): Future[AuthenticationResult] +} +``` + +## OAuth2 based authentication + +OAuth2 based authentication is commonly use to achieve social login with social network account. + +Gearpump provides generic OAuth2 Authentication support which allow user to extend to support new authentication sources. + +Basically, OAuth2 based Authentication contains these steps: + 1. User accesses Gearpump UI website, and choose to login with OAuth2 server. + 2. Gearpump UI website redirects user to OAuth2 server domain authorization endpoint. + 3. End user complete the authorization in the domain of OAuth2 server. + 4. OAuth2 server redirects user back to Gearpump UI server. + 5. Gearpump UI server verify the tokens and extract credentials from query + parameters and form fields. + +### Terminologies + +For terms like client Id, and client secret, please refers to guide [RFC 6749](https://tools.ietf.org/html/rfc6749) + +### Enable web proxy for UI server + +To enable OAuth2 authentication, the Gearpump UI server should have network access to OAuth2 server, as + some requests are initiated directly inside Gearpump UI server. So, if you are behind a firewall, make + sure you have configured the proxy properly for UI server. + +#### If you are on Windows + +```bash + > set JAVA_OPTS=-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088 + > bin\services +``` + +#### If you are on Linux + +```bash + $ export JAVA_OPTS="-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088" + $ bin/services +``` + +### Google Plus OAuth2 Authenticator + +Google Plus OAuth2 Authenticator does authentication with Google OAuth2 service. It extracts the email address +from Google user profile as credentials. + +To use Google OAuth2 Authenticator, there are several steps: + +1. Register your application (Gearpump UI server here) as an application to Google developer console. +2. Configure the Google OAuth2 information in gear.conf +3. Configure network proxy for Gearpump UI server if applies. + +#### Step1: Register your website as an OAuth2 Application on Google + +1. Create an application representing your website at [https://console.developers.google.com](https://console.developers.google.com) +2. In "API Manager" of your created application, enable API "Google+ API" +3. Create OAuth client ID for this application. In "Credentials" tab of "API Manager", +choose "Create credentials", and then select OAuth client ID. Follow the wizard +to set callback URL, and generate client ID, and client Secret. + +**NOTE:** Callback URL is NOT optional. + +#### Step2: Configure the OAuth2 information in gear.conf + +1. Enable OAuth2 authentication by setting `gearpump.ui-security.oauth2-authenticator-enabled` +as true. +2. Configure section `gearpump.ui-security.oauth2-authenticators.google` in gear.conf. Please make sure +class name, client ID, client Secret, and callback URL are set properly. + +**NOTE:** Callback URL set here should match what is configured on Google in step1. + +#### Step3: Configure the network proxy if applies. + +To enable OAuth2 authentication, the Gearpump UI server should have network access to Google service, as + some requests are initiated directly inside Gearpump UI server. So, if you are behind a firewall, make + sure you have configured the proxy properly for UI server. + +For guide of how to configure web proxy for UI server, please refer to section "Enable web proxy for UI server" above. + +#### Step4: Restart the UI server and try to click the Google login icon on UI server. + +### CloudFoundry UAA server OAuth2 Authenticator + +CloudFoundryUaaAuthenticator does authentication by using CloudFoundry UAA OAuth2 service. It extracts the email address + from Google user profile as credentials. + +For what is UAA (User Account and Authentication Service), please see guide: [UAA](https://github.com/cloudfoundry/uaa) + +To use Google OAuth2 Authenticator, there are several steps: + +1. Register your application (Gearpump UI server here) as an application to UAA with helper tool `uaac`. +2. Configure the Google OAuth2 information in gear.conf +3. Configure network proxy for Gearpump UI server if applies. + +#### Step1: Register your application to UAA with `uaac` + +1. Check tutorial on uaac at [https://docs.cloudfoundry.org/adminguide/uaa-user-management.html](https://docs.cloudfoundry.org/adminguide/uaa-user-management.html) +2. Open a bash shell, and login in as user admin by -1. Digest flow(from original password to digest): - ``` - random salt byte array of length 8 -> byte array of (salt + sha1(salt, password)) -> base64Encode + uaac token client get admin -s MyAdminPassword ``` - -2. Verification user input password with stored digest: - +3. Create a new Application (Client) in UAA, + ``` + uaac client add [your_client_id] + --scope openid + --authorized_grant_types "authorization_code client_credentials refresh_token" + --authorities openid + --redirect_uri [your_redirect_url] + --autoapprove true + --secret [your_client_secret] + ``` +#### Step2: Configure the OAuth2 information in gear.conf + +1. Enable OAuth2 authentication by setting `gearpump.ui-security.oauth2-authenticator-enabled` as true. +2. Navigate to section `gearpump.ui-security.oauth2-authenticators.cloudfoundryuaa` +3. Config gear.conf `gearpump.ui-security.oauth2-authenticators.cloudfoundryuaa` section. +Please make sure class name, client ID, client Secret, and callback URL are set properly. + +**NOTE:** The callback URL here should matche what you set on CloudFoundry UAA in step1. + +#### Step3: Configure network proxy for Gearpump UI server if applies + +To enable OAuth2 authentication, the Gearpump UI server should have network access to Google service, as + some requests are initiated directly inside Gearpump UI server. So, if you are behind a firewall, make + sure you have configured the proxy properly for UI server. + +For guide of how to configure web proxy for UI server, please refer to please refer to section "Enable web proxy for UI server" above. + +#### Step4: Restart the UI server and try to click the CloudFoundry login icon on UI server. + +#### Extends OAuth2Authenticator to support new Authorization service like Facebook, or Twitter. + +You can follow the Google OAuth2 example code to define a custom OAuth2Authenticator. Basically, the steps includes: + +1. Define an OAuth2Authenticator implementation. + + ```scala + /** + * + * Uses OAuth2 social-login as the mechanism for authentication. + * @see [[https://tools.ietf.org/html/rfc6749]] to find what is OAuth2, and how it works. + * + * Basically flow for OAuth2 Authentication: + * 1. User accesses Gearpump UI website, and choose to login with OAuth2 server. + * 2. Gearpump UI website redirects user to OAuth2 server domain authorization endpoint. + * 3. End user complete the authorization in the domain of OAuth2 server. + * 4. OAuth2 server redirects user back to Gearpump UI server. + * 5. Gearpump UI server verify the tokens and extract credentials from query + * parameters and form fields. + * + * @note '''Thread-safety''' is a MUST requirement. Developer need to ensure the sub-class is thread-safe. + * Sub-class should have a parameterless constructor. + * + * @note OAuth2 Authenticator requires access of Internet. Please make sure HTTP proxy are + * set properly if applied. + * + * @example Config proxy when UI server is started on Windows: + * {{{ + * > set JAVA_OPTS=-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088 + * > bin\services + * }}} + * + * @example Config proxy when UI server is started on Linux: + * {{{ + * $ export JAVA_OPTS="-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088" + * $ bin/services + * }}} + * + */ + trait OAuth2Authenticator { + + /** + * Inits authenticator with config which contains client ID, client secret, and etc.. + * + * Typically, the client key and client secret is provided by OAuth2 Authorization server when user + * register an application there. + * @see [[https://tools.ietf.org/html/rfc6749]] for definition of client, client Id, + * and client secret. + * + * See [[https://developer.github.com/v3/oauth/]] for an actual example of how Github + * use client key, and client secret. + * + * @note '''Thread-Safety''': Framework ensures this call is synchronized. + * + * @param config Client Id, client secret, callback URL and etc.. + */ + def init(config: Config): Unit + + /** + * Returns the OAuth Authorization URL so for redirection to that address to do OAuth2 + * authorization. + * + * @note '''Thread-Safety''': This can be called in a multi-thread environment. Developer + * need to ensure thread safety. + */ + def getAuthorizationUrl: String + + /** + * After authorization, OAuth2 server redirects user back with tokens. This verify the + * tokens, retrieve the profiles, and return [[UserSession]] information. + * + * @note This is an Async call. + * @note This call requires external internet access. + * @note '''Thread-Safety''': This can be called in a multi-thread environment. Developer + * need to ensure thread safety. + * + * @param parameters HTTP Query and Post parameters, which typically contains Authorization code. + * @return UserSession if pass authentication. + */ + def authenticate(parameters: Map[String, String]): Future[UserSession] + + /** + * Clean resource + */ + def close(): Unit + } + ``` - base64Decode -> extract salt -> do sha1(salt, password) -> generate digest: salt + sha1 -> - compare the generated digest with the stored digest. + +2. Add an configuration entry under `gearpump.ui-security.oauth2-authenticators`. For example: + + ``` + ## name of this authenticator + "socialnetworkx" { + "class" = "io.gearpump.services.security.oauth2.impl.SocialNetworkXAuthenticator" + + ## Please make sure this URL matches the name + "callback" = "http://127.0.0.1:8090/login/oauth2/socialnetworkx/callback" + + "clientId" = "gearpump_test2" + "clientSecret" = "gearpump_test2" + "defaultUserRole" = "guest" + + ## Make sure socialnetworkx.png exists under dashboard/icons + icon = "/icons/socialnetworkx.png" + } ``` + The configuration entry is supposed to be used by class `SocialNetworkXAuthenticator`. + + http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/docs/dev-rest-api.md ---------------------------------------------------------------------- diff --git a/docs/dev-rest-api.md b/docs/dev-rest-api.md index df514bc..36eeb0b 100644 --- a/docs/dev-rest-api.md +++ b/docs/dev-rest-api.md @@ -12,6 +12,9 @@ To disable Authentication, you can set `gearpump-ui.gearpump.ui-security.authent in gear.conf, please check [UI Authentication](deployment-ui-authentication.html) for details. ### How to authenticate if Authentication is enabled. + +#### For User-Password based authentication + If Authentication is enabled, then you need to login before calling REST API. ``` @@ -28,9 +31,56 @@ curl --cookie outputAuthenticationCookie.txt http://127.0.0.1/api/v1.0/master for more information, please check [UI Authentication](deployment-ui-authentication.html). +#### For OAuth2 based authentication -## Query version +For OAuth2 based authentication, it requires you to have an access token in place. + +Different OAuth2 service provider have different way to return an access token. + +**For Google**, you can refer to [OAuth Doc](https://developers.google.com/identity/protocols/OAuth2). + +**For CloudFoundry UAA**, you can use the uaac command to get the access token. + +``` +$ uaac target http://login.gearpump.gotapaas.eu/ +$ uaac token get <user_email_address> + +### Find access token +$ uaac context + +[0]*[http://login.gearpump.gotapaas.eu] + + [0]*[<user_email_address>] + user_id: 34e33a79-42c6-479b-a8c1-8c471ff027fb + client_id: cf + token_type: bearer + access_token: eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOiI + expires_in: 599 + scope: password.write openid cloud_controller.write cloud_controller.read + jti: 74ea49e4-1001-4757-9f8d-a66e52a27557 +``` + +For more information on uaac, please check [UAAC guide](https://docs.cloudfoundry.org/adminguide/uaa-user-management.html) +Now, we have the access token, then let's login to Gearpump UI server with this access token: + +``` +## Please replace cloudfoundryuaa with actual OAuth2 service name you have configured in gear.conf +curl -X POST --data accesstoken=eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOiI --cookie-jar outputAuthenticationCookie.txt http://127.0.0.1:8090/login/oauth2/cloudfoundryuaa/accesstoken +``` + +This will use user `user_email_address` to login, and store the authentication cookie to file outputAuthenticationCookie.txt. + +In All subsequent Rest API calls, you need to add the authentication cookie. For example + +``` +curl --cookie outputAuthenticationCookie.txt http://127.0.0.1/api/v1.0/master +``` + +**NOTE:** You can default the default permission level for OAuth2 user. for more information, +please check [UI Authentication](deployment-ui-authentication.html). + +## Query version ### GET version http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/project/Build.scala ---------------------------------------------------------------------- diff --git a/project/Build.scala b/project/Build.scala index eae876b..0d08718 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -278,6 +278,9 @@ object Build extends sbt.Build { "org.scalatest" %% "scalatest" % scalaTestVersion % "test", "com.lihaoyi" %% "upickle" % upickleVersion, "com.softwaremill" %% "akka-http-session" % "0.1.4", + "com.typesafe.akka" %% "akka-http-spray-json-experimental"% "1.0", + "com.github.scribejava" % "scribejava-apis" % "2.4.0", + "com.ning" % "async-http-client" % "1.9.33", "org.webjars" % "angularjs" % "1.4.9", "org.webjars.npm" % "angular-touch" % "1.5.0", // angular 1.5 breaks ui-select, but we need ng-touch 1.5 "org.webjars" % "angular-ui-router" % "0.2.15", http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/dashboard/icons/google.png ---------------------------------------------------------------------- diff --git a/services/dashboard/icons/google.png b/services/dashboard/icons/google.png new file mode 100644 index 0000000..d25b94a Binary files /dev/null and b/services/dashboard/icons/google.png differ http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/dashboard/icons/uaa.png ---------------------------------------------------------------------- diff --git a/services/dashboard/icons/uaa.png b/services/dashboard/icons/uaa.png new file mode 100644 index 0000000..c16083f Binary files /dev/null and b/services/dashboard/icons/uaa.png differ http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/dashboard/login/login.html ---------------------------------------------------------------------- diff --git a/services/dashboard/login/login.html b/services/dashboard/login/login.html index aad51e6..d80984d 100644 --- a/services/dashboard/login/login.html +++ b/services/dashboard/login/login.html @@ -41,6 +41,18 @@ border-bottom-right-radius: 0; } + .social-login { + + position: relative; + font-size: 14px; + height: auto; + + margin-top: 10px; + margin-bottom: 10px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + .welcome-message { color: #ffffff; @@ -88,6 +100,8 @@ <input type="text" class="form-control" name="username" placeholder="User Name" required autofocus /> <input type="password" class="form-control" name="password" placeholder="Password" required /> <button class="btn btn-lg btn-primary btn-block" type="button" onclick="login()">Sign In</button> + + <div class="social-login" id="social_login"></div> <div class="error" id="error"></div> <a href="#" class="pull-left" style="margin-top: 15px;"><!--Need help? --></a> http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/dashboard/login/login.js ---------------------------------------------------------------------- diff --git a/services/dashboard/login/login.js b/services/dashboard/login/login.js index 3c8d8a6..5a520f9 100644 --- a/services/dashboard/login/login.js +++ b/services/dashboard/login/login.js @@ -9,35 +9,67 @@ */ function login() { - var loginUrl = $("#loginUrl").attr('href'); - var index = $("#index").attr('href'); - - $.post(loginUrl, $("#loginForm").serialize() ).done( - function(msg) { - var user = $.parseJSON(msg); - $.cookie("username", user.user, { expires: 365, path: '/' }); - // clear the errors - $("#error").text(""); - // redirect to index.html - $(location).attr('href', index); - } - ) - .fail( function(xhr, textStatus, errorThrown) { - var elem = $("#error"); - elem.html(xhr.responseText); - elem.text(textStatus + "(" + xhr.status + "): " + elem.text()); - }); + var loginUrl = $("#loginUrl").attr('href'); + var index = $("#index").attr('href'); + + $.post(loginUrl, $("#loginForm").serialize()).done( + function (msg) { + var user = $.parseJSON(msg); + // clear the errors + $("#error").text(""); + // redirect to index.html + $(location).attr('href', index); + } + ) + .fail(function (xhr, textStatus, errorThrown) { + var elem = $("#error"); + elem.html(xhr.responseText); + elem.text(textStatus + "(" + xhr.status + "): " + elem.text()); + }); } /** * call rest service /logout to clear the session tokens. */ function logout() { - var logoutUrl = $("#logoutUrl").attr('href'); - $.post(logoutUrl) + var logoutUrl = $("#logoutUrl").attr('href'); + $.post(logoutUrl) } -$(document).ready(function() { - // Send a initial logout to clear the sessions. - logout(); +function displaySocialLoginIcons() { + var loginUrl = $("#loginUrl").attr('href'); + var oauth2Root = loginUrl + "/oauth2"; + var providersUrl = oauth2Root + "/providers"; + + var socialLogin = $("#social_login"); + + $.get(providersUrl).done( + function (msg) { + var providers = $.parseJSON(msg); + console.log(providers); + + var body = ""; + + for (var provider in providers) { + var icon = providers[provider]; + body += "<a href=" + oauth2Root + "/" + provider + "/" + "authorize>"; + body += "<img src=" + icon + " alt=" + provider + "/> "; + body += "</a>"; + } + + if (body != "") { + body = "Social login: " + body + } + + socialLogin.html(body); + } + ) +} + +$(document).ready(function () { + // Send a initial logout to clear the sessions. + logout(); + + // Fetch and display social login icons. + displaySocialLoginIcons() }); \ No newline at end of file http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/jvm/src/main/scala/io/gearpump/services/RestServices.scala ---------------------------------------------------------------------- diff --git a/services/jvm/src/main/scala/io/gearpump/services/RestServices.scala b/services/jvm/src/main/scala/io/gearpump/services/RestServices.scala index bf6a7fa..f2761d6 100644 --- a/services/jvm/src/main/scala/io/gearpump/services/RestServices.scala +++ b/services/jvm/src/main/scala/io/gearpump/services/RestServices.scala @@ -42,7 +42,7 @@ class RestServices(master: ActorRef, mat: ActorMaterializer, system: ActorSystem private val LOG = LogUtil.getLogger(getClass) - private val securityEnabled = config.getBoolean(Constants.GEARPUMP_UI_SECURITY_ENABLED) + private val securityEnabled = config.getBoolean(Constants.GEARPUMP_UI_SECURITY_AUTHENTICATION_ENABLED) private val supervisorPath = system.settings.config.getString(Constants.GEARPUMP_SERVICE_SUPERVISOR_PATH) http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/jvm/src/main/scala/io/gearpump/services/SecurityService.scala ---------------------------------------------------------------------- diff --git a/services/jvm/src/main/scala/io/gearpump/services/SecurityService.scala b/services/jvm/src/main/scala/io/gearpump/services/SecurityService.scala index 317273c..387ff45 100644 --- a/services/jvm/src/main/scala/io/gearpump/services/SecurityService.scala +++ b/services/jvm/src/main/scala/io/gearpump/services/SecurityService.scala @@ -19,8 +19,8 @@ package io.gearpump.services import akka.actor.{ActorSystem} -import akka.http.scaladsl.model.RemoteAddress -import akka.http.scaladsl.model.headers.HttpChallenge +import akka.http.scaladsl.model.{Uri, StatusCodes, RemoteAddress} +import akka.http.scaladsl.model.headers.{HttpCookiePair, HttpCookie, HttpChallenge} import akka.http.scaladsl.server.AuthenticationFailedRejection.{CredentialsMissing} import akka.http.scaladsl.server._ import akka.http.scaladsl.server.Directives._ @@ -31,10 +31,12 @@ import com.softwaremill.session.SessionDirectives._ import com.softwaremill.session._ import com.typesafe.config.Config import io.gearpump.services.SecurityService.{User, UserSession} +import io.gearpump.services.security.oauth2.OAuth2Authenticator import io.gearpump.util.{Constants, LogUtil} import upickle.default.{write} import io.gearpump.security.{Authenticator => BaseAuthenticator} import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} /** * When user cannot be authenticated, will reject with 401 AuthenticationFailedRejection @@ -71,6 +73,23 @@ class SecurityService(inner: RouteService, implicit val system: ActorSystem) ext authenticator } + private def configToMap(config : Config, path: String) = { + import scala.collection.JavaConverters._ + config.getConfig(path).root.unwrapped.asScala.toMap map { case (k, v) => k -> v.toString } + } + + private val oauth2Providers: Map[String, String] = { + if (config.getBoolean(Constants.GEARPUMP_UI_OAUTH2_AUTHENTICATOR_ENABLED)) { + val map = configToMap(config, Constants.GEARPUMP_UI_OAUTH2_AUTHENTICATORS) + map.keys.toList.map { key => + val iconPath = config.getString(s"${Constants.GEARPUMP_UI_OAUTH2_AUTHENTICATORS}.$key.icon") + (key, iconPath) + }.toMap + } else { + Map.empty[String, String] + } + } + private def authenticate(user: String, pass: String)(implicit ec: ExecutionContext): Future[Option[UserSession]] = { authenticator.authenticate(user, pass, ec).map{ result => if (result.authenticated) { @@ -97,11 +116,18 @@ class SecurityService(inner: RouteService, implicit val system: ActorSystem) ext } } - private def login(session: UserSession, ip: String): Route = { - setSession(session) { ctx => + private def login(session: UserSession, ip: String, redirectToRoot: Boolean = false): Route = { + setSession(session) { val user = session.user - LOG.info(s"user $user login from $ip") - ctx.complete(write(new User(user))) + val maxAgeMs = 1000 * sessionConfig.clientSessionMaxAgeSeconds.getOrElse(24 * 3600L) // default 1 day + setCookie(HttpCookie.fromPair(HttpCookiePair("username", user), path = Some("/"), maxAge = Some(maxAgeMs))) { + LOG.info(s"user $user login from $ip") + if (redirectToRoot) { + redirect(Uri("/"), StatusCodes.TemporaryRedirect) + } else { + complete(write(new User(user))) + } + } } } @@ -129,7 +155,6 @@ class SecurityService(inner: RouteService, implicit val system: ActorSystem) ext } } - private val unknownIp: Directive1[RemoteAddress] = { Directive[Tuple1[RemoteAddress]]{ inner => inner(new Tuple1(RemoteAddress.Unknown)) @@ -141,22 +166,68 @@ class SecurityService(inner: RouteService, implicit val system: ActorSystem) ext extractExecutionContext{implicit ec: ExecutionContext => extractMaterializer{implicit mat: Materializer => (extractClientIP | unknownIp) { ip => - path("login") { - get { - pathEndOrSingleSlash { + pathPrefix("login") { + pathEndOrSingleSlash { + get { getFromResource("login/login.html") + } ~ + post { + // Guest account don't have permission to submit new application in UI + formField(FieldMagnet('username.as[String])) {user: String => + formFields(FieldMagnet('password.as[String])) {pass: String => + val result = authenticate(user, pass) + onSuccess(result){ + case Some(session) => + login(session, ip.toString) + case None => + authenticationFailed + } + } + } } } ~ - post { - // Guest account don't have permission to submit new application in UI - formField(FieldMagnet('username.as[String])) {user: String => - formFields(FieldMagnet('password.as[String])) {pass: String => - val result = authenticate(user, pass) - onSuccess(result){ - case Some(session) => - login(session, ip.toString) - case None => - authenticationFailed + path ("oauth2" / "providers") { + // respond with a list of OAuth2 providers. + complete(write(oauth2Providers)) + } ~ + // Support OAUTH Authentication + pathPrefix ("oauth2"/ Segment) {providerName => + // Resolve OAUTH Authentication Provider + val oauthService = OAuth2Authenticator.get(config, providerName) + + if (oauthService == null) { + // OAuth2 is disabled. + complete(StatusCodes.NotFound) + } else { + + def loginWithOAuth2Parameters(parameters: Map[String, String]): Route = { + val result = oauthService.authenticate(parameters) + onComplete(result){ + case Success(session) => + login(session, ip.toString, redirectToRoot = true) + case Failure(ex) => { + LOG.info(s"Failed to login user from ${ip.toString}", ex) + failWith(ex) + } + } + } + + path ("authorize") { + // redirect to OAuth2 service provider for authorization. + redirect(Uri(oauthService.getAuthorizationUrl), StatusCodes.TemporaryRedirect) + } ~ + path ("accesstoken") { + post { + // Guest account don't have permission to submit new application in UI + formField(FieldMagnet('accesstoken.as[String])) {accesstoken: String => + loginWithOAuth2Parameters(Map("accesstoken" -> accesstoken)) + } + } + } ~ + path("callback") { + // Login with authorization code or access token. + parameterMap {parameters => + loginWithOAuth2Parameters(parameters) } } } http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/OAuth2Authenticator.scala ---------------------------------------------------------------------- diff --git a/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/OAuth2Authenticator.scala b/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/OAuth2Authenticator.scala new file mode 100644 index 0000000..f44d67a --- /dev/null +++ b/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/OAuth2Authenticator.scala @@ -0,0 +1,144 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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. + */ + +package io.gearpump.services.security.oauth2 + +import com.typesafe.config.Config +import io.gearpump.services.SecurityService.UserSession +import io.gearpump.util.Constants +import io.gearpump.util.Constants._ +import scala.concurrent.Future + +/** + * + * Uses OAuth2 social-login as the mechanism for authentication. + * @see [[https://tools.ietf.org/html/rfc6749]] to find what is OAuth2, and how it works. + * + * Basically flow for OAuth2 Authentication: + * 1. User accesses Gearpump UI website, and choose to login with OAuth2 server. + * 2. Gearpump UI website redirects user to OAuth2 server domain authorization endpoint. + * 3. End user complete the authorization in the domain of OAuth2 server. + * 4. OAuth2 server redirects user back to Gearpump UI server. + * 5. Gearpump UI server verify the tokens and extract credentials from query + * parameters and form fields. + * + * @note '''Thread-safety''' is a MUST requirement. Developer need to ensure the sub-class is thread-safe. + * Sub-class should have a parameterless constructor. + * + * @note OAuth2 Authenticator requires access of Internet. Please make sure HTTP proxy are + * set properly if applied. + * + * @example Config proxy when UI server is started on Windows: + * {{{ + * > set JAVA_OPTS=-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088 + * > bin\services + * }}} + * + * @example Config proxy when UI server is started on Linux: + * {{{ + * $ export JAVA_OPTS="-Dhttp.proxyHost=xx.com -Dhttp.proxyPort=8088 -Dhttps.proxyHost=xx.com -Dhttps.proxyPort=8088" + * $ bin/services + * }}} + * + */ +trait OAuth2Authenticator { + + /** + * Inits authenticator with config which contains client ID, client secret, and etc.. + * + * Typically, the client key and client secret is provided by OAuth2 Authorization server when user + * register an application there. + * @see [[https://tools.ietf.org/html/rfc6749]] for definition of client, client Id, + * and client secret. + * + * See [[https://developer.github.com/v3/oauth/]] for an actual example of how Github + * use client key, and client secret. + * + * @note '''Thread-Safety''': Framework ensures this call is synchronized. + * + * @param config Client Id, client secret, callback URL and etc.. + */ + def init(config: Config): Unit + + /** + * Returns the OAuth Authorization URL so for redirection to that address to do OAuth2 + * authorization. + * + * @note '''Thread-Safety''': This can be called in a multi-thread environment. Developer + * need to ensure thread safety. + */ + def getAuthorizationUrl: String + + /** + * After authorization, OAuth2 server redirects user back with tokens. This verify the + * tokens, retrieve the profiles, and return [[UserSession]] information. + * + * @note This is an Async call. + * @note This call requires external internet access. + * @note '''Thread-Safety''': This can be called in a multi-thread environment. Developer + * need to ensure thread safety. + * + * @param parameters HTTP Query and Post parameters, which typically contains Authorization code. + * @return UserSession if authentication pass. + */ + def authenticate(parameters: Map[String, String]): Future[UserSession] + + /** + * Clean resource + */ + def close(): Unit +} + +object OAuth2Authenticator { + + // Serves as a quick immutable lookup cache + private var providers = Map.empty[String, OAuth2Authenticator] + + /** + * Load Authenticator from [[Constants.GEARPUMP_UI_OAUTH2_AUTHENTICATORS]] + * + * @param provider, Name for the OAuth2 Authentication Service. + * @return Returns null if the OAuth2 Authentication is disabled. + */ + def get(config: Config, provider: String): OAuth2Authenticator = { + + if (providers.contains(provider)) { + providers(provider) + } else { + val path = s"${Constants.GEARPUMP_UI_OAUTH2_AUTHENTICATORS}.$provider" + val enabled = config.getBoolean(Constants.GEARPUMP_UI_OAUTH2_AUTHENTICATOR_ENABLED) + if (enabled && config.hasPath(path)) { + this.synchronized { + if (providers.contains(provider)) { + providers(provider) + } else { + val authenticatorConfig = config.getConfig(path) + val authenticatorClass = authenticatorConfig.getString(GEARPUMP_UI_OAUTH2_AUTHENTICATOR_CLASS) + val clazz = Thread.currentThread().getContextClassLoader.loadClass(authenticatorClass) + val authenticator = clazz.newInstance().asInstanceOf[OAuth2Authenticator] + authenticator.init(authenticatorConfig) + providers += provider -> authenticator + authenticator + } + } + } else { + null + } + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/BaseOAuth2Authenticator.scala ---------------------------------------------------------------------- diff --git a/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/BaseOAuth2Authenticator.scala b/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/BaseOAuth2Authenticator.scala new file mode 100644 index 0000000..851053d --- /dev/null +++ b/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/BaseOAuth2Authenticator.scala @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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. + */ + +package io.gearpump.services.security.oauth2.impl + +import java.util.concurrent.atomic.AtomicBoolean + +import com.github.scribejava.core.builder.ServiceBuilderAsync +import com.github.scribejava.core.builder.api.DefaultApi20 +import com.github.scribejava.core.model._ +import com.github.scribejava.core.oauth.OAuth20Service +import com.github.scribejava.core.utils.OAuthEncoder +import com.ning.http.client.AsyncHttpClientConfig +import com.typesafe.config.Config +import io.gearpump.security.Authenticator +import io.gearpump.services.SecurityService.UserSession +import io.gearpump.services.security.oauth2.OAuth2Authenticator +import io.gearpump.services.security.oauth2.impl.BaseOAuth2Authenticator.BaseApi20 +import io.gearpump.util.Constants._ +import io.gearpump.util.Util + +import scala.collection.mutable.StringBuilder +import scala.concurrent.{Future, Promise} + +/** + * Uses Ning AsyncClient to connect to OAuth2 service. + * + * @see [[OAuth2Authenticator]] for more API information. + */ +abstract class BaseOAuth2Authenticator extends OAuth2Authenticator { + + // Authorize Url for end user to authorize + protected def authorizeUrl: String + + // Used to fetch the Access Token. + protected def accessTokenEndpoint: String + + // Protected resource Url to get the user profile + protected def protectedResourceUrl: String + + // Extracts the username information from response of protectedResourceUrl + protected def extractUserName(body: String): String + + // Scope required to access protectedResourceUrl + protected def scope: String + + // OAuth2 endpoint definition for ScribeJava. + protected def oauth2Api(): DefaultApi20 = { + new BaseApi20(authorizeUrl, accessTokenEndpoint) + } + + private var oauthService: OAuth20Service = null + + private var defaultPermissionLevel = Authenticator.Guest.permissionLevel + + // Synchronization ensured by the caller + override def init(config: Config): Unit = { + if (this.oauthService == null) { + val callback = config.getString(GEARPUMP_UI_OAUTH2_AUTHENTICATOR_CALLBACK) + val clientId = config.getString(GEARPUMP_UI_OAUTH2_AUTHENTICATOR_CLIENT_ID) + val clientSecret = config.getString(GEARPUMP_UI_OAUTH2_AUTHENTICATOR_CLIENT_SECRET) + defaultPermissionLevel = { + val role = config.getString(GEARPUMP_UI_OAUTH2_AUTHENTICATOR_DEFAULT_USER_ROLE) + role match { + case "guest" => Authenticator.Guest.permissionLevel + case "user" => Authenticator.User.permissionLevel + case "admin" => Authenticator.Admin.permissionLevel + case _ => Authenticator.UnAuthenticated.permissionLevel + } + } + this.oauthService = buildOAuth2Service(clientId, clientSecret, callback) + } + } + + private val isClosed: AtomicBoolean = new AtomicBoolean(false) + + override def close(): Unit = { + if (isClosed.compareAndSet(false, true)) { + if (null != oauthService && null != oauthService.getAsyncHttpClient()) { + oauthService.getAsyncHttpClient().close() + } + } + } + + override def getAuthorizationUrl(): String = { + oauthService.getAuthorizationUrl() + } + + override def authenticate(parameters: Map[String, String]): Future[UserSession] = { + + val promise = Promise[UserSession]() + val code = parameters.get(GEARPUMP_UI_OAUTH2_AUTHENTICATOR_AUTHORIZATION_CODE) + val accessToken = parameters.get(GEARPUMP_UI_OAUTH2_AUTHENTICATOR_ACCESS_TOKEN) + + def authenticateWithAccessToken(accessToken: OAuth2AccessToken): Unit = { + + val request = new OAuthRequestAsync(Verb.GET, protectedResourceUrl, oauthService) + oauthService.signRequest(accessToken, request) + request.sendAsync { + new OAuthAsyncRequestCallback[Response] { + override def onCompleted(response: Response): Unit = { + try { + val user = extractUserName(response.getBody) + promise.success(new UserSession(user, defaultPermissionLevel)) + } catch { + case ex: Throwable => + promise.failure(ex) + } + } + + override def onThrowable(throwable: Throwable): Unit = { + promise.failure(throwable) + } + } + } + } + + def authenticateWithAuthorizationCode(code: String): Unit = { + oauthService.getAccessTokenAsync(code, + + new OAuthAsyncRequestCallback[OAuth2AccessToken] { + override def onCompleted(accessToken: OAuth2AccessToken): Unit = { + authenticateWithAccessToken(accessToken) + } + + override def onThrowable(throwable: Throwable): Unit = { + promise.failure(throwable) + } + }) + } + + if (accessToken.isDefined) { + authenticateWithAccessToken(new OAuth2AccessToken(accessToken.get)) + } + else if (code.isDefined) { + authenticateWithAuthorizationCode(code.get) + } else { + // Fails authentication if code not exist + promise.failure(new Exception("Fail to authenticate user as there is no code parameter in URL")) + } + + promise.future + } + + private def buildOAuth2Service(clientId: String, clientSecret: String, callback: String): OAuth20Service = { + val state: String = "state" + Util.randInt + ScribeJavaConfig.setForceTypeOfHttpRequests(ForceTypeOfHttpRequest.FORCE_ASYNC_ONLY_HTTP_REQUESTS) + val clientConfig: AsyncHttpClientConfig = new AsyncHttpClientConfig.Builder() + .setMaxConnections(5) + .setUseProxyProperties(true) + .setRequestTimeout(60000) + .setAllowPoolingConnections(false) + .setPooledConnectionIdleTimeout(60000) + .setReadTimeout(60000).build + + val service: OAuth20Service = new ServiceBuilderAsync() + .apiKey(clientId) + .apiSecret(clientSecret) + .scope(scope) + .state(state) + .callback(callback) + .asyncHttpClientConfig(clientConfig) + .build(oauth2Api()) + + service + } +} + +object BaseOAuth2Authenticator { + + class BaseApi20(authorizeUrl: String, accessTokenEndpoint: String) extends DefaultApi20 { + def getAccessTokenEndpoint: String = { + accessTokenEndpoint + } + + def getAuthorizationUrl(config: OAuthConfig): String = { + val sb: StringBuilder = new StringBuilder(String.format(authorizeUrl, config.getResponseType, config.getApiKey, OAuthEncoder.encode(config.getCallback), OAuthEncoder.encode(config.getScope))) + val state: String = config.getState + if (state != null) { + sb.append('&').append(OAuthConstants.STATE).append('=').append(OAuthEncoder.encode(state)) + } + return sb.toString + } + + override def createService(config: OAuthConfig): OAuth20Service = { + return new OAuth20Service(this, config) { + + protected override def createAccessTokenRequest[T <: AbstractRequest](code: String, request: T): T = { + super.createAccessTokenRequest(code, request) + + if (!getConfig.hasGrantType) { + request.addParameter(OAuthConstants.GRANT_TYPE, OAuthConstants.AUTHORIZATION_CODE) + } + + // Work-around for issue https://github.com/scribejava/scribejava/issues/641 + request.addHeader("Content-Type", "application/x-www-form-urlencoded") + request + } + } + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/CloudFoundryUAAOAuth2Authenticator.scala ---------------------------------------------------------------------- diff --git a/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/CloudFoundryUAAOAuth2Authenticator.scala b/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/CloudFoundryUAAOAuth2Authenticator.scala new file mode 100644 index 0000000..75fa07a --- /dev/null +++ b/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/CloudFoundryUAAOAuth2Authenticator.scala @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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. + */ + +package io.gearpump.services.security.oauth2.impl + +import com.github.scribejava.core.builder.api.DefaultApi20 +import com.github.scribejava.core.model.{AbstractRequest, OAuthConfig, OAuthConstants} +import com.github.scribejava.core.oauth.OAuth20Service +import com.typesafe.config.Config +import io.gearpump.services.security.oauth2.OAuth2Authenticator +import io.gearpump.services.security.oauth2.impl.BaseOAuth2Authenticator.BaseApi20 +import io.gearpump.services.security.oauth2.impl.CloudFoundryUAAOAuth2Authenticator.CloudFoundryUAAService +import spray.json.{JsString, _} +import sun.misc.BASE64Encoder + +/** + * + * Does authentication with CloudFoundry UAA service. Currently it only + * extract the email address of end user. + * + * For what is UAA, please see: + * @see [[https://github.com/cloudfoundry/uaa for information about CloudFoundry UAA]] + * (User Account and Authentication Service) + * + * Pre-requisite steps to use this Authenticator: + * + * Step1: Register your website to UAA with tool uaac. + * 1) Check tutorial on uaac at [[https://docs.cloudfoundry.org/adminguide/uaa-user-management.html]] + * 2) Open a bash shell, and login in as user admin by + * {{{ + * uaac token client get admin -s MyAdminPassword + * }}} + * 3) Create a new Application (Client) in UAA, + * {{{ + * uaac client add [your_client_id] + * --scope openid + * --authorized_grant_types "authorization_code client_credentials refresh_token" + * --authorities openid + * --redirect_uri [your_redirect_url] + * --autoapprove true + * --secret [your_client_secret] + * }}} + * + * Step2: Configure the OAuth2 information in gear.conf + * 1) Enable OAuth2 authentication by setting "gearpump.ui-security.oauth2-authenticator-enabled" + * as true. + * 2) Navigate to section "gearpump.ui-security.oauth2-authenticators.cloudfoundryuaa" + * 3) Config gear.conf "gearpump.ui-security.oauth2-authenticators.cloudfoundryuaa" section. + * Please make sure class name, client ID, client Secret, and callback URL are set properly. + * + * @note The callback URL here should matche what you set on CloudFoundry UAA in step1. + * + * Step3: Restart the UI service and try the "social login" button for UAA. + * + * @note OAuth requires Internet access, @see [[OAuth2Authenticator]] to find tutorials to configure + * Internet proxy. + * + * @see [[OAuth2Authenticator]] for more background information of OAuth2. + */ +class CloudFoundryUAAOAuth2Authenticator extends BaseOAuth2Authenticator { + + private var host: String = null + + protected override def authorizeUrl: String = s"$host/oauth/authorize?response_type=%s&client_id=%s&redirect_uri=%s&scope=%s" + + protected override def accessTokenEndpoint: String = s"$host/oauth/token" + + protected override def protectedResourceUrl: String = s"$host/userinfo" + + protected override def scope: String = "openid" + + override def init(config: Config): Unit = { + host = config.getString("uaahost") + super.init(config) + } + + protected override def extractUserName(body: String): String = { + val email = body.parseJson.asJsObject.fields("email").asInstanceOf[JsString] + email.value + } + + + protected override def oauth2Api(): DefaultApi20 = { + new CloudFoundryUAAService(authorizeUrl, accessTokenEndpoint) + } +} + +object CloudFoundryUAAOAuth2Authenticator { + private val RESPONSE_TYPE = "response_type" + + private class CloudFoundryUAAService(authorizeUrl: String, accessTokenEndpoint: String) + extends BaseApi20(authorizeUrl, accessTokenEndpoint) { + + private def base64(in: String): String = { + val encoder = new BASE64Encoder() + val utf8 = "UTF-8" + encoder.encode(in.getBytes(utf8)) + } + + override def createService(config: OAuthConfig): OAuth20Service = { + return new OAuth20Service(this, config) { + + protected override def createAccessTokenRequest[T <: AbstractRequest](code: String, request: T): T = { + val config: OAuthConfig = getConfig() + + request.addParameter(OAuthConstants.GRANT_TYPE, OAuthConstants.AUTHORIZATION_CODE) + request.addParameter(OAuthConstants.CODE, code) + request.addParameter(RESPONSE_TYPE, "token") + request.addParameter(OAuthConstants.REDIRECT_URI, config.getCallback) + + // Work around issue https://github.com/scribejava/scribejava/issues/641 + request.addHeader("Content-Type", "application/x-www-form-urlencoded") + + // CloudFoundry requires a Authorization header encoded with client Id and secret. + val authorizationHeader = "Basic " + base64(config.getApiKey + ":" + config.getApiSecret) + request.addHeader("Authorization", authorizationHeader) + request + } + } + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/GoogleOAuth2Authenticator.scala ---------------------------------------------------------------------- diff --git a/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/GoogleOAuth2Authenticator.scala b/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/GoogleOAuth2Authenticator.scala new file mode 100644 index 0000000..54e6eef --- /dev/null +++ b/services/jvm/src/main/scala/io/gearpump/services/security/oauth2/impl/GoogleOAuth2Authenticator.scala @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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. + */ + +package io.gearpump.services.security.oauth2.impl + +import com.github.scribejava.apis.google.GoogleJsonTokenExtractor +import com.github.scribejava.core.builder.api.DefaultApi20 +import com.github.scribejava.core.extractors.TokenExtractor +import com.github.scribejava.core.model._ +import io.gearpump.services.security.oauth2.OAuth2Authenticator +import io.gearpump.services.security.oauth2.impl.GoogleOAuth2Authenticator.AsyncGoogleApi20 +import spray.json._ + +/** + * + * Does authentication with Google OAuth2 service. It only extract the email address + * from user profile of Google. + * + * Pre-requisite steps to use this Authenticator: + * + * Step1: Register your website as an OAuth2 Application on Google + * 1) Create an application representing your website at [[https://console.developers.google.com]] + * 2) In "API Manager" of your created application, enable API "Google+ API" + * 3) Create OAuth client ID for this application. In "Credentials" tab of "API Manager", + * choose "Create credentials", and then select OAuth client ID. Follow the wizard + * to set callback URL, and generate client ID, and client Secret. Callback URL is NOT optional. + * + * Step2: Configure the OAuth2 information in gear.conf + * 1) Enable OAuth2 authentication by setting "gearpump.ui-security.oauth2-authenticator-enabled" + * as true. + * 2) Configure section "gearpump.ui-security.oauth2-authenticators.google". Please make sure + * class name, client ID, client Secret, and callback URL are set properly. + * + * @note callback URL set here should match what is configured on Google in step1. + * + * Step3: Restart the UI service and try out the Google social login button in UI. + * + * @note OAuth requires Internet access, @see [[OAuth2Authenticator]] to find some helpful tutorials. + * + * @note Google use scope to define what data can be fetched by OAuth2. Currently we use profile + * [[https://www.googleapis.com/auth/userinfo.email]]. However, Google may change the profile in future. + * + * @todo Currently, this doesn't verify the state from Google OAuth2 response. + * + * @see [[OAuth2Authenticator]] for more API information. + */ +class GoogleOAuth2Authenticator extends BaseOAuth2Authenticator { + + import GoogleOAuth2Authenticator._ + + protected override def authorizeUrl: String = AuthorizeUrl + + protected override def accessTokenEndpoint: String = AccessEndpoint + + protected override def protectedResourceUrl: String = ResourceUrl + + protected override def scope: String = GoogleOAuth2Authenticator.Scope + + protected override def extractUserName(body: String): String = { + val emails = body.parseJson.asJsObject.fields("emails").asInstanceOf[JsArray] + val email = emails.elements(0).asJsObject("Cannot find email account") + .fields("value").asInstanceOf[JsString].value + email + } + + override def oauth2Api(): DefaultApi20 = new AsyncGoogleApi20(authorizeUrl, accessTokenEndpoint) +} + +object GoogleOAuth2Authenticator { + + import BaseOAuth2Authenticator._ + + val AuthorizeUrl = "https://accounts.google.com/o/oauth2/auth?response_type=%s&client_id=%s&redirect_uri=%s&scope=%s" + val AccessEndpoint = "https://www.googleapis.com/oauth2/v4/token" + val ResourceUrl = "https://www.googleapis.com/plus/v1/people/me" + val Scope = "https://www.googleapis.com/auth/userinfo.email" + + private class AsyncGoogleApi20(authorizeUrl: String, accessEndpoint: String) + extends BaseApi20(authorizeUrl, accessEndpoint) { + + override def getAccessTokenExtractor: TokenExtractor[OAuth2AccessToken] = { + GoogleJsonTokenExtractor.instance + } + } +} \ No newline at end of file http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/jvm/src/test/scala/io/gearpump/services/security/oauth2/CloudFoundryUAAOAuth2AuthenticatorSpec.scala ---------------------------------------------------------------------- diff --git a/services/jvm/src/test/scala/io/gearpump/services/security/oauth2/CloudFoundryUAAOAuth2AuthenticatorSpec.scala b/services/jvm/src/test/scala/io/gearpump/services/security/oauth2/CloudFoundryUAAOAuth2AuthenticatorSpec.scala new file mode 100644 index 0000000..3e3e0b0 --- /dev/null +++ b/services/jvm/src/test/scala/io/gearpump/services/security/oauth2/CloudFoundryUAAOAuth2AuthenticatorSpec.scala @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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. + */ + +package io.gearpump.services.security.oauth2 + +import akka.actor.ActorSystem +import akka.http.javadsl.model.HttpEntityStrict +import akka.http.scaladsl.model.MediaTypes._ +import akka.http.scaladsl.model.Uri.Path +import akka.http.scaladsl.model._ +import akka.http.scaladsl.testkit.ScalatestRouteTest +import com.typesafe.config.ConfigFactory +import io.gearpump.security.Authenticator +import io.gearpump.services.security.oauth2.impl.{CloudFoundryUAAOAuth2Authenticator, GoogleOAuth2Authenticator} +import org.scalatest.FlatSpec +import scala.concurrent.Await +import scala.concurrent.duration._ +import scala.collection.JavaConverters._ + +class CloudFoundryUAAOAuth2AuthenticatorSpec extends FlatSpec with ScalatestRouteTest { + + implicit val actorSystem: ActorSystem = system + private val server = new MockOAuth2Server(system, null) + server.start() + private val serverHost = s"http://127.0.0.1:${server.port}" + + val configMap = Map( + "class" -> "io.gearpump.services.security.oauth2.impl.CloudFoundryUAAOAuth2Authenticator", + "callback" -> s"$serverHost/login/oauth2/cloudfoundryuaa/callback", + "clientid" -> "gearpump_test2", + "clientsecret" -> "gearpump_test2", + "default-userrole" -> "user", + "icon" -> "/icons/uaa.png", + "uaahost" -> serverHost) + + val configString = ConfigFactory.parseMap(configMap.asJava) + + private val uaa = new CloudFoundryUAAOAuth2Authenticator + uaa.init(configString) + + it should "generate the correct authorization request" in { + val parameters = Uri(uaa.getAuthorizationUrl()).query.toMap + assert(parameters("response_type") == "code") + assert(parameters("client_id") == configMap("clientid")) + assert(parameters("redirect_uri") == configMap("callback")) + assert(parameters("scope") == "openid") + } + + it should "authenticate the authorization code and return the correct profile" in { + val code = Map("code" -> "QGGVeA") + val accessToken = "e2922002-0218-4513-a62d-1da2ba64ee4c" + val refreshToken = "eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOiI2Nm" + val mail = "[email protected]" + + def accessTokenEndpoint(request: HttpRequest) = { + assert(request.getHeader("Authorization").get.value() == "Basic Z2VhcnB1bXBfdGVzdDI6Z2VhcnB1bXBfdGVzdDI=") + assert(request.entity.contentType().mediaType.value == "application/x-www-form-urlencoded") + + val body = request.entity.asInstanceOf[HttpEntityStrict].data().decodeString("UTF-8") + val form = Uri./.withQuery(body).query.toMap + + assert(form("grant_type") == "authorization_code") + assert(form("code") == "QGGVeA") + assert(form("response_type") == "token") + assert(form("redirect_uri") == configMap("callback")) + + val response = + s""" + |{ + | "access_token": "$accessToken", + | "token_type": "bearer", + | "refresh_token": "$refreshToken", + | "expires_in": 43199, + | "scope": "openid", + | "jti": "e8739474-b2fa-42eb-a9ad-e065bf79d7e9" + |} + """.stripMargin + HttpResponse(entity = HttpEntity(ContentType(`application/json`), response)) + } + + def protectedResourceEndpoint(request: HttpRequest) = { + assert(request.getUri().parameter("access_token").get == accessToken) + val response = + s""" + |{ + | "user_id": "e2922002-0218-4513-a62d-1da2ba64ee4c", + | "user_name": "user", + | "email": "$mail" + |} + """.stripMargin + HttpResponse(entity = HttpEntity(ContentType(`application/json`), response)) + } + + server.requestHandler = (request: HttpRequest) => { + if (request.uri.path.startsWith(Path("/oauth/token"))) { + accessTokenEndpoint(request) + } else if (request.uri.path.startsWith(Path("/userinfo"))) { + protectedResourceEndpoint(request) + } else { + fail("Unexpected access to " + request.uri.toString()) + } + } + + val userFuture = uaa.authenticate(code) + val user = Await.result(userFuture, 30 seconds) + assert(user.user == mail) + assert(user.permissionLevel == Authenticator.User.permissionLevel) + } + + override def cleanUp(): Unit = { + server.stop() + uaa.close() + super.cleanUp() + } +} http://git-wip-us.apache.org/repos/asf/incubator-gearpump/blob/099842ad/services/jvm/src/test/scala/io/gearpump/services/security/oauth2/GoogleOAuth2AuthenticatorSpec.scala ---------------------------------------------------------------------- diff --git a/services/jvm/src/test/scala/io/gearpump/services/security/oauth2/GoogleOAuth2AuthenticatorSpec.scala b/services/jvm/src/test/scala/io/gearpump/services/security/oauth2/GoogleOAuth2AuthenticatorSpec.scala new file mode 100644 index 0000000..8fbe43f --- /dev/null +++ b/services/jvm/src/test/scala/io/gearpump/services/security/oauth2/GoogleOAuth2AuthenticatorSpec.scala @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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. + */ + +package io.gearpump.services.security.oauth2 + +import akka.actor.ActorSystem +import akka.http.javadsl.model.HttpEntityStrict +import akka.http.scaladsl.model.MediaTypes._ +import akka.http.scaladsl.model.Uri.Path +import akka.http.scaladsl.model._ +import akka.http.scaladsl.testkit.ScalatestRouteTest +import com.typesafe.config.ConfigFactory +import io.gearpump.security.Authenticator +import io.gearpump.services.security.oauth2.GoogleOAuth2AuthenticatorSpec.MockGoogleAuthenticator +import io.gearpump.services.security.oauth2.impl.{GoogleOAuth2Authenticator, CloudFoundryUAAOAuth2Authenticator} +import org.scalatest.FlatSpec + +import scala.collection.JavaConverters._ +import scala.concurrent.Await +import scala.concurrent.duration._ + +class GoogleOAuth2AuthenticatorSpec extends FlatSpec with ScalatestRouteTest { + + implicit val actorSystem: ActorSystem = system + private val server = new MockOAuth2Server(system, null) + server.start() + private val serverHost = s"http://127.0.0.1:${server.port}" + + val configMap = Map( + "class" -> "io.gearpump.services.security.oauth2.impl.GoogleOAuth2Authenticator", + "callback" -> s"$serverHost/login/oauth2/google/callback", + "clientid" -> "170234147043-a1tag68jtq6ab4bi11jvsj7vbaqcmhkt.apps.googleusercontent.com", + "clientsecret" -> "ioeWLLDipz2S7aTDXym2-obe", + "default-userrole" -> "guest", + "icon" -> "/icons/google.png") + + val configString = ConfigFactory.parseMap(configMap.asJava) + + private val google = new MockGoogleAuthenticator(serverHost) + google.init(configString) + + it should "generate the correct authorization request" in { + val parameters = Uri(google.getAuthorizationUrl()).query.toMap + assert(parameters("response_type") == "code") + assert(parameters("client_id") == configMap("clientid")) + assert(parameters("redirect_uri") == configMap("callback")) + assert(parameters("scope") == GoogleOAuth2Authenticator.Scope) + } + + it should "authenticate the authorization code and return the correct profile" in { + val code = Map("code" -> "4/PME0pfxjiBA42SukR-OTGl7fpFzTWzvZPf1TbkpXL4M#") + val accessToken = "e2922002-0218-4513-a62d-1da2ba64ee4c" + val refreshToken = "eyJhbGciOiJSUzI1NiJ9.eyJqdGkiOiI2Nm" + val mail = "[email protected]" + + def accessTokenEndpoint(request: HttpRequest) = { + + assert(request.entity.contentType().mediaType.value == "application/x-www-form-urlencoded") + + val body = request.entity.asInstanceOf[HttpEntityStrict].data().decodeString("UTF-8") + val form = Uri./.withQuery(body).query.toMap + + assert(form("client_id") == configMap("clientid")) + assert(form("client_secret") == configMap("clientsecret")) + assert(form("grant_type") == "authorization_code") + assert(form("code") == code("code")) + assert(form("redirect_uri") == configMap("callback")) + assert(form("scope") == GoogleOAuth2Authenticator.Scope) + + val response = s""" + |{ + | "access_token": "$accessToken", + | "token_type": "Bearer", + | "expires_in": 3591, + | "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImY1NjQyYzY2MzdhYWQyOTJiOThlOGIwN2MwMzIxN2QwMzBmOTdkODkifQ.eyJpc3" + |} + """.stripMargin + + HttpResponse(entity = HttpEntity(ContentType(`application/json`), response)) + } + + def protectedResourceEndpoint(request: HttpRequest) = { + assert(request.getUri().parameter("access_token").get == accessToken) + val response =s""" + |{ + | "kind": "plus#person", + | "etag": "4OZ_Kt6ujOh1jaML_U6RM6APqoE/mZ57HcMOYXaNXYXS5XEGJ9yVsI8", + | "nickname": "gearpump", + | "gender": "female", + | "emails": [ + | { + | "value": "$mail", + | "type": "account" + | } + | ] + | } + """.stripMargin + HttpResponse(entity = HttpEntity(ContentType(`application/json`), response)) + } + + server.requestHandler = (request: HttpRequest) => { + if (request.uri.path.startsWith(Path("/oauth2/v4/token"))) { + accessTokenEndpoint(request) + } else if (request.uri.path.startsWith(Path("/plus/v1/people/me"))) { + protectedResourceEndpoint(request) + } else { + fail("Unexpected access to " + request.uri.toString()) + } + } + + val userFuture = google.authenticate(code) + val user = Await.result(userFuture, 30 seconds) + assert(user.user == mail) + assert(user.permissionLevel == Authenticator.Guest.permissionLevel) + } + + override def cleanUp(): Unit = { + server.stop() + google.close() + super.cleanUp() + } +} + +object GoogleOAuth2AuthenticatorSpec { + class MockGoogleAuthenticator(host: String) extends GoogleOAuth2Authenticator { + protected override def authorizeUrl: String = { + super.authorizeUrl.replace("https://accounts.google.com", host) + } + + protected override def accessTokenEndpoint: String = { + super.accessTokenEndpoint.replace("https://www.googleapis.com", host) + } + + protected override def protectedResourceUrl: String = { + super.protectedResourceUrl.replace("https://www.googleapis.com", host) + } + } +} \ No newline at end of file
