diff --git a/README.md b/README.md index 4caf9fe..8544b2d 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,170 @@ -# PresideCMS Extension: Social Media Login +# PresideCMS Social Login -This is an extension for [PresideCMS](http://github.com/pixl8/Preside-CMS/) that enables signing up / logging in using third party/social media login such as Facebook, Twitter and Google +This PresideCMS extension supports secure website-user login with: -## How it works +- Facebook Login +- Google OpenID Connect +- X OAuth 2.0 +- Sign in with LinkedIn using OpenID Connect -The extension extends the current Login functionality for Website users in preside to enable them to sign up or login using social media credentials. +The implementation uses authorization-code flows, one-time session-bound `state`, PKCE where the provider supports it, short callback lifetimes, server-side token exchange, and explicit account linking. -You will need to create an application/project on the social media platform from the steps below to obtain API keys for respective platform. The key can be then keyed in in the Social Media Login settings under System > Settings in preside. +## Requirements -### Facebook +- A PresideCMS application with website users enabled +- HTTPS for every non-local environment +- A provider application for each enabled login option +- A stable public hostname; callback URLs must match exactly -Create a Facebook application at https://developers.facebook.com/apps/. The crendentials for Facebook can be obtained from the Facebook application settings. +Twitter4j is no longer used or required. -### Twitter +## Install -Create a new Twitter application at https://apps.twitter.com. The credentials for Twitter can be obtained from your Twitter application's Key and Access Tokens +From the root of the Preside application: -### Google +```bash +box install seakchiew/preside-ext-social-login +``` -Create a new project at https://console.developers.google.com/ and create a new OAuth 2.0 client ID and an API Key under credentials. +Alternatively, install this repository under: -## Installation +```text +application/extensions/preside-ext-social-login +``` -Install the extension to your application via either of the methods detailed below (Git submodule / CommandBox) and then enable the extension by opening up the Preside developer console and entering: +Enable and reload it in the Preside developer console: - extension enable preside-ext-social-login - reload all +```text +extension enable preside-ext-social-login +reload all +``` -### Git Submodule method +Run the Preside database synchronisation after upgrading. Version 1 adds `label` and `email_verified` to `social_account` and creates a unique `(type, social_id)` identity constraint. -From the root of your application, type the following command: +## Callback URLs - git submodule add https://github.com/seakchiew/preside-ext-social-login.git application/extensions/preside-ext-social-login +Replace `https://example.com` with the canonical site URL. Register each URL exactly, including its query string: -### CommandBox (box.json) method +```text +https://example.com/login/auth/?type=facebook +https://example.com/login/auth/?type=google +https://example.com/login/auth/?type=twitter +https://example.com/login/auth/?type=linkedin +``` -From the root of your application, type the following command: +Do not register wildcard callback URLs. If a provider normalises away the slash before `?`, verify the URL emitted by the running site and register that exact value. - box install seakchiew/preside-ext-social-login +## Preside configuration -# Reference +Open **System → Settings → Social login**. -This script is based on multiple login script by cfjquery - https://github.com/cfjquery/multiLogin +1. Enter the client ID and client secret for a provider. +2. Enable that provider. +3. Save the settings. +4. Reload the login page and confirm the provider button appears. +A provider button remains hidden when its toggle is enabled but either credential is blank. Restrict access to the settings category because provider secrets are stored in Preside system configuration. -## Dependency +## Facebook -The Twitter API authentication uses Twitter4j java library -Please download the library from http://twitter4j.org/en/index.html and place twitter4j-core.*.jar into /WEB-INF/lib/ folder in your application +1. Open [Meta for Developers](https://developers.facebook.com/apps/) and create an app. +2. Add the **Facebook Login** product and select the web platform. +3. Under Facebook Login settings, add the Facebook callback URL to **Valid OAuth Redirect URIs**. +4. Request the `email` and `public_profile` permissions. Complete App Review if Meta requires it for the app’s audience. +5. Keep **Client OAuth Login**, **Web OAuth Login**, and strict redirect URI matching enabled. +6. Copy the app ID and app secret into the Facebook tab in Preside. +7. Enable Facebook login. +The extension targets Graph API `v25.0`, sends PKCE and `state`, uses the bearer-token header for `/me`, and signs the profile request with `appsecret_proof`. - +## Google +1. Open [Google Cloud Console](https://console.cloud.google.com/apis/credentials). +2. Configure the OAuth consent screen. +3. Create an **OAuth client ID** with application type **Web application**. +4. Add the Google callback URL under **Authorized redirect URIs**. +5. Add production domains and test users as required by the consent-screen publishing status. +6. Copy the client ID and client secret into the Google tab in Preside. +7. Enable Google login. +The requested scopes are `openid email profile`. The extension uses Google’s current authorization, token, and OpenID Connect UserInfo endpoints. +## X + +1. Open the [X Developer Portal](https://developer.x.com/) and create or select a project and app. +2. Enable **OAuth 2.0** user authentication. +3. Select a confidential **Web App** client. +4. Add the X callback URL. +5. Add the site URL as the website URL. +6. Grant the `tweet.read` and `users.read` scopes. +7. Copy the OAuth 2.0 client ID and client secret into the X tab in Preside. +8. Enable X login. + +Legacy OAuth 1.0a API keys are not compatible. Upgrades must enter new OAuth 2.0 client credentials. X does not return an email for this flow; the extension uses the stable X user ID as the website-user login ID and a unique, non-deliverable `@social-login.invalid` address until the application collects and verifies a local address. + +## LinkedIn + +1. Open [LinkedIn Developers](https://www.linkedin.com/developers/apps) and create an app. +2. Associate and verify the app with a LinkedIn Page. +3. On **Products**, request **Sign in with LinkedIn using OpenID Connect**. +4. Add the LinkedIn callback URL under **Authorized redirect URLs for your app**. +5. Confirm the app has `openid`, `profile`, and `email`. +6. Copy the client ID and client secret into the LinkedIn tab in Preside. +7. Enable LinkedIn login. + +LinkedIn’s web OIDC endpoint does not consistently accept PKCE parameters. This provider therefore uses a confidential-client authorization-code flow with an exact callback URI, one-time `state`, a client secret, and a ten-minute local flow lifetime. + +## Explicitly link an existing account + +Social login never attaches an identity to an existing website user merely because email addresses match. The user must first sign in normally and submit a CSRF-protected link request. + +Add a form like this to the authenticated account page for each enabled provider: + +```cfm + +
+ + + + +
+
+``` + +Valid `type` values are `facebook`, `google`, `twitter`, and `linkedin`. Linking is rejected for anonymous, remember-me-only, and administrator-impersonated sessions. A social identity already linked to another website user cannot be moved. + +## Verification checklist + +Run this for each provider in a test environment: + +1. Disable the provider and confirm its button disappears and direct initiation is rejected. +2. Enable it with valid credentials and complete a first-time login. +3. Confirm one `social_account` record and one linked `website_user` record are created. +4. Log out and repeat; confirm no duplicate records are created. +5. Start login in one browser and alter `state` in the callback; confirm login fails. +6. Reuse a successful callback; confirm login fails because flow state is single-use. +7. Start a flow, wait more than ten minutes, and confirm callback rejection. +8. Use a social account whose email already belongs to a website user; confirm it is not logged in or linked. +9. Sign in normally and use the CSRF-protected linking form; confirm linking succeeds. +10. Supply an external `postLoginUrl`; confirm the redirect remains on the application origin. +11. Deny consent and confirm the user returns to the login page without provider details or tokens in logs. + +## Automated tests + +Security regression specs are under `tests/specs`. Run them from a Preside/TestBox test harness that maps this extension at the application root: + +```bash +box testbox run +``` + +Provider end-to-end tests require real sandbox applications and secrets, so keep them in a protected deployment environment rather than the repository. + +## Upgrade notes from 0.1 + +- X OAuth 1.0a and Twitter4j were removed. +- Facebook and Google legacy credential names are read as fallbacks, but save credentials into the new fields. +- Base URL, redirect URL, API key, and configurable Facebook field settings were removed because they were unused and unsafe. +- Existing email-matched accounts are not changed automatically. Users must explicitly link future social identities. +- Providers that do not return a verified email receive a unique `@social-login.invalid` placeholder because Preside requires `website_user.email_address`; applications should replace it through their normal verified-email workflow. + +See [SECURITY_AUDIT.md](SECURITY_AUDIT.md) for findings, remediation details, residual risks, and standards references. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..7650df2 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,147 @@ +# Social login security audit + +Audit date: 13 August 2026 + +Scope: + +- `handlers/Login.cfc` +- `services/SocialAuthService.cfc` +- the removed OAuth 1.0a `services/TwitterAuthService.cfc` +- social-account persistence and website-user linking +- provider and Preside configuration +- login UI, callback handling, secrets, errors, redirects, and tests + +The original code was written in 2016. It could not be considered secure or provider-compatible before this remediation. + +## Executive result + +| Severity | Original finding | Remediation | +|---|---|---| +| Critical | OAuth 2.0 `state` was generated but never checked | One-time, session-bound, provider-bound state is checked with a timing-safe comparison and expires after ten minutes | +| Critical | Website users were silently selected and linked by matching email | Email matching no longer authenticates or links; collisions require a CSRF-protected flow initiated by the authenticated user | +| Critical | Twitter callback tokens were not bound to the initiating OAuth 1.0a session | OAuth 1.0a implementation removed; X now uses OAuth 2.0 authorization code with PKCE | +| High | No PKCE for authorization-code exchanges | S256 PKCE added for Facebook, Google, and X | +| High | Legacy Google OAuth and UserInfo endpoints | Replaced with current OAuth v2 and OIDC UserInfo endpoints | +| High | Facebook bearer token was placed in a query string | Bearer token moved to the Authorization header; `appsecret_proof` added | +| High | Arbitrary `postLoginUrl` enabled an open redirect | Redirects are restricted to relative application paths or the current site root | +| High | Disabled providers remained callable directly | Initiation checks both the provider toggle and required credentials | +| High | Social identities were selected by `social_id` without provider namespace | Lookups and a database unique constraint now use `(type, social_id)` | +| Medium | Provider parameters were concatenated without encoding | OAuth query and form parameters are encoded independently | +| Medium | Provider access tokens remained in session | Tokens are held only in local variables and discarded after profile retrieval | +| Medium | Predictable OAuth nonce and hard-coded GMT+8 | Obsolete custom OAuth 1.0a implementation removed | +| Medium | HTTP success used a substring search for `200` | Responses require a numeric 2xx status and valid JSON | +| Medium | Provider error bodies could be logged or exposed | Logs contain provider, request stage, and status only; secrets and response bodies are excluded | +| Medium | Authentication responses could be cached | Initiation and callback handlers disable Preside page caching and browser caching | +| Medium | Callback failure used undefined password variables | Failure handling now persists only a generic social-login failure message | +| Medium | Link initiation could be triggered through GET | Explicit linking uses a POST action with `event.validateCsrfToken()` | +| Low | Configuration exposed obsolete and unused endpoint fields | Unused base URL, redirect URL, API key, and field-list settings removed | +| Low | No LinkedIn support | LinkedIn OIDC authorization code and UserInfo support added | +| Low | No automated security tests | State expiry/mismatch and account-linking regression specs added | + +## Current control design + +### Authorization request + +1. The handler rejects unknown, disabled, or incompletely configured providers. +2. A cryptographically random state value is generated per attempt. +3. A 96-character verifier and S256 challenge are generated for providers supporting PKCE. +4. State, provider, verifier, linking user ID, local return URL, and creation time are stored server-side. +5. OAuth parameters are individually encoded. + +### Callback + +1. Provider errors, missing code, and missing state fail closed. +2. Stored and returned provider/state values must match. +3. Attempts expire after ten minutes. +4. Flow state is deleted before token exchange, making callbacks single-use even if a later provider call fails. +5. Codes are exchanged server-to-server over fixed HTTPS endpoints. +6. Profile calls use bearer authorization headers. +7. Access and refresh tokens are neither persisted nor logged. + +### Account identity and linking + +- Provider subject IDs are namespaced by provider. +- Existing linked subjects authenticate only their linked website user. +- A new subject may provision a new website user. +- An email collision with an existing website user fails with `explicitLinkRequired`. +- Explicit linking requires: + - a CSRF-protected POST; + - a fully authenticated, non-remember-me website session; + - no administrator impersonation; + - the same website-user session at initiation and callback; + - an identity not already linked to another user. + +This prevents login CSRF, pre-registration takeover, provider-ID collision, and confused-deputy account movement. + +## Standards comparison + +| Control | OAuth 2.0 Security BCP / provider guidance | Implementation | +|---|---|---| +| Authorization code | Required for server-side login | All providers | +| Exact callback URI | Required | Handler-generated fixed route; setup requires exact registration | +| State / CSRF binding | Required | All providers | +| PKCE S256 | Recommended for all authorization-code clients | Facebook, Google, X | +| One-time callback | Required | Server-side state deleted before exchange | +| Short transaction lifetime | Recommended | Ten minutes | +| Bearer token in header | RFC 6750 recommendation | All profile calls | +| Minimal scopes | Recommended | Provider-specific identity/profile scopes only | +| Stable issuer subject | OIDC identity key | `sub` for Google/LinkedIn; provider `id` for Facebook/X | +| Email linking | Must not be trusted as sole proof of account ownership | Never automatic | +| Secret handling | Confidential clients keep secrets server-side | Preside system configuration only | +| Redirect allowlist | Prevent unvalidated redirects | Same-origin or root-relative only | +| Token retention | Minimise access-token lifetime and storage | Discarded after profile lookup | + +LinkedIn exception: the web OIDC implementation has documented interoperability failures when PKCE parameters are sent to its standard web token endpoint. LinkedIn’s separate native PKCE flow uses different endpoints and is not the web OIDC product. The extension uses `state`, an exact redirect URI, a confidential client secret, and a short single-use flow for LinkedIn. Re-test this when LinkedIn publishes PKCE support for its web OIDC endpoint. + +## Residual risks and operational requirements + +### Live provider verification + +This repository is an extension, not a runnable Preside application. It contains no host application, database, hostname, or provider credentials. Static review and local regression specs cannot prove that a provider application is approved and correctly configured. Complete the provider-by-provider checklist in `README.md` against test applications before production release. + +### Session security inherited from Preside + +The extension relies on Preside’s session cookie, CSRF, and normal `websiteLoginService.login()` behavior. Social authentication uses `skipPasswordCheck=true` only after the provider identity has been validated; Preside still filters inactive users, records the login, promotes visitor actions, and rotates the session. The host application must: + +- enforce HTTPS and secure, HTTP-only cookies; +- use an appropriate SameSite policy that permits top-level OAuth callbacks; +- rotate/authenticate sessions according to the deployed Preside version; +- remain on a supported Preside/Lucee release. + +### Secrets at rest + +Provider secrets are held by Preside’s system-configuration store. Restrict settings permissions, database access, backups, support exports, and logs. Rotate a secret immediately if it is exposed. A deployment-specific secret manager would be stronger, but no standard secret-manager abstraction exists in this extension. + +### Provider API lifecycle + +Meta versions Graph endpoints and X changes product access and pricing independently of code releases. Review provider deprecations at least quarterly and run live smoke tests after provider-console changes. + +### Profile data + +Only identity data required to create the local account is stored. Provider email claims are recorded with their verification indicator but are not used to attach an existing account. Because Preside requires a unique website-user email, providers without a verified email receive a deterministic, non-deliverable `@social-login.invalid` address. Applications should replace it through a separate local email-verification/update workflow. + +## Verification evidence + +Automated regression tests cover: + +- mismatched callback state; +- expired callback state; +- email-collision refusal; +- refusal to move a linked identity. + +The manual matrix in `README.md` additionally covers provider cancellation, replay, disabled-provider access, first/repeat login, explicit linking, and redirect enforcement. + +## References + +- [RFC 6749: OAuth 2.0](https://www.rfc-editor.org/rfc/rfc6749) +- [RFC 7636: Proof Key for Code Exchange](https://www.rfc-editor.org/rfc/rfc7636) +- [RFC 8252: OAuth 2.0 for Native Apps](https://www.rfc-editor.org/rfc/rfc8252) +- [RFC 9207: Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207) +- [RFC 9700: OAuth 2.0 Security Best Current Practice](https://www.rfc-editor.org/rfc/rfc9700) +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +- [Google OpenID Connect](https://developers.google.com/identity/openid-connect/openid-connect) +- [Meta manual login flow](https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow/) +- [Meta login security](https://developers.facebook.com/docs/facebook-login/security) +- [X OAuth 2.0 authorization-code flow with PKCE](https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code) +- [Sign in with LinkedIn using OpenID Connect](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2) +- [Preside CSRF protection](https://docs.preside.org/devguides/csrf.html) diff --git a/forms/system-config/social-login.xml b/forms/system-config/social-login.xml index e7dbe43..c307200 100644 --- a/forms/system-config/social-login.xml +++ b/forms/system-config/social-login.xml @@ -1,60 +1,40 @@ -
- -
- - - - - - - - - - + + +
+ + + + +
- -
- - - - - - - - - - + +
+ +
- -
- - - - - - - + +
+ +
- -
- - - - - - - - - + +
+ +
- + +
+ + +
+
diff --git a/handlers/Login.cfc b/handlers/Login.cfc index f2024b4..aaf3f21 100644 --- a/handlers/Login.cfc +++ b/handlers/Login.cfc @@ -1,117 +1,195 @@ component extends="preside.system.handlers.Login" { + property name="websiteLoginService" inject="websiteLoginService"; - property name="passwordPolicyService" inject="passwordPolicyService"; - property name="SocialAuthService" inject="SocialAuthService"; - property name="TwitterAuthService" inject="TwitterAuthService"; + property name="socialAuthService" inject="socialAuthService"; property name="systemConfigurationService" inject="systemConfigurationService"; private string function loginPage( event, rc, prc, args={} ) output=false { event.include( "social-icons-css" ); + var socialConfig = systemConfigurationService.getCategorySettings( "social-login" ); - prc.enableWebsiteLogin = socialConfig.website_login?:false; - prc.enableFacebookLogin = socialConfig.facebook_login?:false; - prc.enableTwitterLogin = socialConfig.twitter_login?:false; - prc.enableGoogleLogin = socialConfig.google_login?:false; + prc.enableWebsiteLogin = socialConfig.website_login ?: false; + prc.enableFacebookLogin = socialAuthService.isProviderEnabled( "facebook" ); + prc.enableTwitterLogin = socialAuthService.isProviderEnabled( "twitter" ); + prc.enableGoogleLogin = socialAuthService.isProviderEnabled( "google" ); + prc.enableLinkedinLogin = socialAuthService.isProviderEnabled( "linkedin" ); - return super.loginPage(argumentCollection=arguments); + return super.loginPage( argumentCollection=arguments ); } public void function social( event, rc, prc ) output=false { - //announceInterception( "preAttemptSocialLogin" ); - event.include( "social-icons-css" ); + _preventAuthenticationCaching( event ); if ( websiteLoginService.isLoggedIn() && !websiteLoginService.isAutoLoggedIn() ) { setNextEvent( url=_getDefaultPostLoginUrl( argumentCollection=arguments ) ); } - var type = rc.type?:""; - - switch(type){ - case 'facebook': - socialAuthService.initiateFacebookLogin(redirectURI=event.buildLink(linkTo="/login/auth/",querystring="type=facebook")); - break; - case 'twitter': - TwitterAuthService.initiateTwitterLogin(redirectURI=event.buildLink(linkTo="/login/auth/",querystring="type=twitter")); - break; - case 'google': - socialAuthService.initiateGoogleLogin(redirectURI=event.buildLink(linkTo="/login/auth/",querystring="type=google")); - break; - default: - throw (type="SocialLogin.error", message="Social login (#type#) not found."); - break; - } + _initiateSocialLogin( + event = event + , rc = rc + , provider = LCase( Trim( rc.type ?: "" ) ) + , postLoginUrl = rc.postLoginUrl ?: _getDefaultPostLoginUrl( argumentCollection=arguments ) + ); } - public void function auth( event, rc, prc ) output=false { - announceInterception( "preAttemptSocialLogin" ); + public void function linkSocialAccountAction( event, rc, prc ) output=false { + _preventAuthenticationCaching( event ); - if ( websiteLoginService.isLoggedIn() && !websiteLoginService.isAutoLoggedIn() ) { - setNextEvent( url=_getDefaultPostLoginUrl( argumentCollection=arguments ) ); + if ( + !event.validateCsrfToken() + || !websiteLoginService.isLoggedIn() + || websiteLoginService.isAutoLoggedIn() + || websiteLoginService.isImpersonated() + ) { + event.accessDenied( "LOGIN_REQUIRED" ); } - var type = rc.type?:""; + _initiateSocialLogin( + event = event + , rc = rc + , provider = LCase( Trim( rc.type ?: "" ) ) + , linkUserId = websiteLoginService.getLoggedInUserId() + , postLoginUrl = rc.postLoginUrl ?: "/" + ); + } - var socialAccount = {}; + private void function _initiateSocialLogin( + required any event + , required struct rc + , required string provider + , string linkUserId = "" + , string postLoginUrl = "" + ) output=false { + var safePostLoginUrl = _getSafePostLoginUrl( + event = arguments.event + , requestedUrl = arguments.postLoginUrl + ); + var redirectUri = arguments.event.buildLink( + linkTo = "/login/auth/" + , queryString = "type=" & EncodeForUrl( arguments.provider ) + ); + + announceInterception( "preAttemptSocialLogin", { provider=arguments.provider, linking=Len( arguments.linkUserId ) > 0 } ); + socialAuthService.initiateLogin( + provider = arguments.provider + , redirectUri = redirectUri + , linkUserId = arguments.linkUserId + , postLoginUrl = safePostLoginUrl + ); + } - switch(type){ - case 'facebook': - socialAccount = socialAuthService.authoriseFacebookLogin( - redirectURI = event.buildLink(linkTo="/login/auth/",querystring="type=#type#") - , code = event.getValue("code") - , state = event.getValue("state") - - ); + public void function auth( event, rc, prc ) output=false { + _preventAuthenticationCaching( event ); - break; - case 'twitter': - socialAccount = TwitterAuthService.authoriseTwitterLogin( - oauth_token = event.getValue("oauth_token") - , oauth_verifier = event.getValue("oauth_verifier") - - ); - break; - case 'google': - socialAccount = socialAuthService.authoriseGoogleLogin( - redirectURI = event.buildLink(linkTo="/login/auth/",querystring="type=#type#") - , code = event.getValue("code") - , state = event.getValue("state") - - ); - break; - default: - throw (type="SocialAuth.error", message="Social login (#type#) not found."); - break; + var provider = LCase( Trim( rc.type ?: "" ) ); + + if ( Len( rc.error ?: "" ) || !Len( rc.code ?: "" ) || !Len( rc.state ?: "" ) ) { + _socialLoginFailed( event, provider ); } + try { + var redirectUri = event.buildLink( + linkTo = "/login/auth/" + , queryString = "type=" & EncodeForUrl( provider ) + ); + var loginResult = socialAuthService.completeLogin( + provider = provider + , code = rc.code + , state = rc.state + , redirectUri = redirectUri + ); + + if ( Len( loginResult.linkUserId ) ) { + if ( + !websiteLoginService.isLoggedIn() + || websiteLoginService.isAutoLoggedIn() + || websiteLoginService.isImpersonated() + || websiteLoginService.getLoggedInUserId() != loginResult.linkUserId + ) { + throw( + type = "SocialAuthService.account.linkSessionChanged" + , message = "The authenticated account changed during social account linking." + ); + } + } + + var loggedIn = socialAuthService.loginOrLinkAccount( + socialAccountId = loginResult.socialAccountId + , linkUserId = loginResult.linkUserId + ); + + if ( loggedIn ) { + announceInterception( "onLoginSuccess", { provider=provider, linked=Len( loginResult.linkUserId ) > 0 } ); + websiteLoginService.clearPostLoginUrl(); + setNextEvent( url=_getSafePostLoginUrl( event=event, requestedUrl=loginResult.postLoginUrl ) ); + } + } catch ( SocialAuthService e ) { + _socialLoginFailed( event, provider ); + } catch ( any e ) { + _socialLoginFailed( event, provider ); + } - + _socialLoginFailed( event, provider ); + } + private void function _socialLoginFailed( + required any event + , required string provider + ) output=false { + announceInterception( "onLoginFailure", { provider=arguments.provider } ); + setNextEvent( + url = arguments.event.buildLink( page="login" ) + , persistStruct = { message="SOCIAL_LOGIN_FAILED" } + ); + } - loggedIn = socialAuthService.socialLogin( socialAccount ); + private string function _getSafePostLoginUrl( + required any event + , string requestedUrl = "" + ) output=false { + var requestedUrl = Trim( arguments.requestedUrl ); + var siteRoot = arguments.event.buildLink( linkTo="/" ); + + if ( + Left( requestedUrl, 1 ) == "/" + && Left( requestedUrl, 2 ) != "//" + && !Find( "\", requestedUrl ) + && !ReFind( "[\x00-\x1f\x7f]", requestedUrl ) + ) { + return requestedUrl; + } - var postLoginUrl = Len( Trim( rc.postLoginUrl ?: "" ) ) ? rc.postLoginUrl : websiteLoginService.getPostLoginUrl( cgi.http_referer ); - var rememberMe = _getRememberMeAllowed() && IsBoolean( rc.rememberMe ?: "" ) && rc.rememberMe; - + try { + var requestedUri = CreateObject( "java", "java.net.URI" ).init( requestedUrl ); + var siteUri = CreateObject( "java", "java.net.URI" ).init( siteRoot ); + + if ( + !CompareNoCase( requestedUri.getScheme() ?: "", siteUri.getScheme() ?: "" ) + && !CompareNoCase( requestedUri.getHost() ?: "", siteUri.getHost() ?: "" ) + && _getEffectivePort( requestedUri ) == _getEffectivePort( siteUri ) + && IsNull( requestedUri.getUserInfo() ) + ) { + return requestedUrl; + } + } catch ( any e ) { + } + return siteRoot; + } - if ( loggedIn ) { - announceInterception( "onLoginSuccess" ); + private numeric function _getEffectivePort( required any uri ) output=false { + var port = arguments.uri.getPort(); - websiteLoginService.clearPostLoginUrl(); - setNextEvent( url=postLoginUrl ); + if ( port >= 0 ) { + return port; } - announceInterception( "onLoginFailure" ); - - websiteLoginService.setPostLoginUrl( postLoginUrl ); - setNextEvent( url=event.buildLink( page="login" ), persistStruct={ - loginId = loginId - , password = password - , postLoginUrl = postLoginUrl - , rememberMe = rememberMe - , message = "LOGIN_FAILED" - } ); + return !CompareNoCase( arguments.uri.getScheme() ?: "", "https" ) ? 443 : 80; } + private void function _preventAuthenticationCaching( required any event ) output=false { + arguments.event.cachePage( false ); + arguments.event.preventPageCache(); + } } diff --git a/i18n/social-login.properties b/i18n/social-login.properties new file mode 100644 index 0000000..88e3bb7 --- /dev/null +++ b/i18n/social-login.properties @@ -0,0 +1,9 @@ +loginId.label=Username +password.label=Password +rememberMe.label=Keep me logged in +login.button=Sign in +forgottenPassword.link=Forgotten password +facebook.button=Continue with Facebook +google.button=Continue with Google +twitter.button=Continue with X +linkedin.button=Continue with LinkedIn diff --git a/i18n/system-config/social-login.properties b/i18n/system-config/social-login.properties index c8c2a90..03f9c3b 100644 --- a/i18n/system-config/social-login.properties +++ b/i18n/system-config/social-login.properties @@ -1,58 +1,44 @@ -name=Social Media Login -description=Social Media Login settings for Facebook, Twitter and Google -iconClass=fa-smile-o +name=Social login +description=Configure OAuth login providers for website users +iconClass=fa-sign-in -setting.tab.title=Settings -website_login.label=Enable Website Login -website_login.help=Enable website default login using email and password. -facebook_login.label=Enable Facebook Login -twitter_login.label=Enable Twitter Login -google_login.label=Enable Google Login +tab.settings.title=Settings +tab.facebook.title= Facebook +tab.facebook.description=Create and configure the app at https://developers.facebook.com/apps/ +tab.google.title= Google +tab.google.description=Create and configure the OAuth client at https://console.cloud.google.com/apis/credentials +tab.twitter.title= X (Twitter) +tab.twitter.description=Create and configure the OAuth 2.0 client at https://developer.x.com/ +tab.linkedin.title= LinkedIn +tab.linkedin.description=Create an app and enable Sign in with LinkedIn using OpenID Connect at https://www.linkedin.com/developers/apps -facebook.tab.title= Facebook -facebook.tab.description=Please create an appplication at https://developers.facebook.com/apps/. The details below can be obtained from the Facebook application settings. -facebook_appid.label=Facebook App ID -facebook_appid.help=Application ID setting on Facebook -facebook_appid.placeholder=1234009876543210 -facebook_secret.label=Facebook App Secret -facebook_secret.help=Secret Key in Facebook Application setting -facebook_secret.placeholder=98b176a3c3a28321ae968177320c7e6d -facebook_baseurl.label=Facebook Base URL -facebook_baseurl.help=Facebook OAuth authentication URL -facebook_baseurl.placeholder=https://www.facebook.com/dialog/oauth -facebook_redirecturl.label=Facebook Redirect URL -facebook_redirecturl.help=URL to redirect after authentication. Leave blank for application default. -facebook_redirecturl.placeholder=Leave blank for application default redirect page -facebook_return_fields.label=Facebook Return fields -facebook_return_fields.help=A comma delimted list of facebook fields the query should return E.G name, email etc -facebook_return_fields.placeholder=name, first_name, last_name, email, link, gender etc +field.website_login.title=Enable password login +field.website_login.help=Allow website users to sign in with their login ID and password. +field.facebook_login.title=Enable Facebook login +field.facebook_login.help=The button is only shown when both Facebook credentials are configured. +field.google_login.title=Enable Google login +field.google_login.help=The button is only shown when both Google credentials are configured. +field.twitter_login.title=Enable X login +field.twitter_login.help=The button is only shown when both X OAuth 2.0 credentials are configured. +field.linkedin_login.title=Enable LinkedIn login +field.linkedin_login.help=The button is only shown when both LinkedIn credentials are configured. -twitter.tab.title= Twitter -twitter.tab.description=Please create a new application at https://apps.twitter.com. The details below can be obtained from your Twitter application's Key and Access Tokens -twitter_consumer_key.label=Twitter Consumer Key (API Key) -twitter_consumer_key.help=Public API key for your Twitter application. -twitter_consumer_key.placeholder=eo7SHfKWyUS4fNt0b11aw -twitter_consumer_secret.label=Twitter Consumer Secret (API Secret) -twitter_consumer_secret.help=Keep the "Consumer Secret" a secret. This key should never be human-readable in your application. -twitter_consumer_secret.placeholder=PkFyTteqdgvRrKRtnH259N9X1tecQsguVqRF9cSRxE -twitter_redirecturl.label=Twitter Redirect URL -twitter_redirecturl.help=URL to redirect after authentication. Leave blank for application default. -twitter_redirecturl.placeholder=Leave blank for application default redirect page +field.facebook_app_id.title=Facebook app ID +field.facebook_app_id.help=The app ID from Meta App settings. +field.facebook_app_secret.title=Facebook app secret +field.facebook_app_secret.help=The app secret from Meta App settings. Restrict access to this settings category. -google.tab.title= Google -google.tab.description=Please create a new project at https://console.developers.google.com/ and create a new OAuth 2.0 client ID and an API Key under credentials. -google_client_id.label=Google Client ID -google_client_id.help=Google Application Client ID -google_client_id.placeholder=your-client-id.apps.googleusercontent.com -google_apikey.label=Google API Key -google_apikey.help=This key should be kept secret on your server -google_apikey.placeholder=AIzaSyDjouNT3kPGG0WL0qjrfk4mn11xQ4KSlE4 -google_secretkey.label=Google Client Secret Key -google_secretkey.help=Client secret based on your application's Client ID -google_secretkey.placeholder=GbOLem3TrfDpP3DMVtGVxSoZ -google_baseurl.label=Google Base URL -google_baseurl.help=Google OAuth authentication URL -google_baseurl.placeholder=https://accounts.google.com/o/oauth2/auth -google_redirecturl.label=Google Redirect URL -google_redirecturl.help=URL to redirect after authentication. Leave blank for application default. -google_redirecturl.placeholder=Leave blank for application default redirect page +field.google_client_id.title=Google client ID +field.google_client_id.help=The OAuth 2.0 web application client ID. +field.google_client_secret.title=Google client secret +field.google_client_secret.help=The OAuth 2.0 web application client secret. + +field.twitter_client_id.title=X client ID +field.twitter_client_id.help=The OAuth 2.0 client ID, not the legacy OAuth 1.0a API key. +field.twitter_client_secret.title=X client secret +field.twitter_client_secret.help=The OAuth 2.0 confidential client secret. + +field.linkedin_client_id.title=LinkedIn client ID +field.linkedin_client_id.help=The client ID for an app with the OpenID Connect product enabled. +field.linkedin_client_secret.title=LinkedIn client secret +field.linkedin_client_secret.help=The LinkedIn application client secret. diff --git a/manifest.json b/manifest.json index dc35e70..a9af6ff 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { - "id" : "preside-ext-social-login" - , "title" : "Social Media Login" - , "author" : "Login using Facebook,Twitter and Google. Requires Twitter4j library from http://twitter4j.org/en/index.html" - , "version" : "0.1.0" + "id" : "preside-ext-social-login" + , "title" : "Social Login" + , "author" : "Seak Chiew Lee" + , "version" : "1.0.0" } \ No newline at end of file diff --git a/preside-objects/social_account.cfc b/preside-objects/social_account.cfc index ce67209..add392b 100644 --- a/preside-objects/social_account.cfc +++ b/preside-objects/social_account.cfc @@ -1,15 +1,12 @@ -/** - * social media account - * - */ component output=false { - property name="social_id" type="string" required="true"; - property name="firstname" type="string" required="true"; - property name="lastname" type="string" required="true"; - property name="email" type="string" required="true"; - property name="social_link" type="string" required="false"; - property name="image_link" type="string" required="false"; - property name="gender" type="string" required="false"; - property name="type" type="string" required="true"; - property name="website_user" relationship="many-to-one" relatedTo="website_user" required=false; + property name="type" type="string" dbtype="varchar" maxlength=20 required=true uniqueindexes="providerIdentity|1"; + property name="social_id" type="string" dbtype="varchar" maxlength=255 required=true uniqueindexes="providerIdentity|2"; + property name="label" type="string" dbtype="varchar" maxlength=255 required=true; + property name="firstname" type="string" dbtype="varchar" maxlength=255 required=false; + property name="lastname" type="string" dbtype="varchar" maxlength=255 required=false; + property name="email" type="string" dbtype="varchar" maxlength=255 required=false; + property name="email_verified" type="boolean" dbtype="boolean" required=true default=false; + property name="social_link" type="string" dbtype="varchar" maxlength=500 required=false; + property name="image_link" type="string" dbtype="varchar" maxlength=500 required=false; + property name="website_user" relationship="many-to-one" relatedto="website_user" required=false indexes="websiteUser"; } \ No newline at end of file diff --git a/services/SocialAuthService.cfc b/services/SocialAuthService.cfc index 5b900cf..cb581de 100644 --- a/services/SocialAuthService.cfc +++ b/services/SocialAuthService.cfc @@ -1,346 +1,602 @@ component output=false singleton=true { -// CONSTRUCTOR /** - * @sessionStorage.inject coldbox:plugin:sessionStorage * @systemConfigurationService.inject systemConfigurationService * @logger.inject logbox:logger:SocialAuthService - * @socialAccountDao.inject presidecms:object:social_account + * @sessionStorage.inject coldbox:plugin:sessionStorage + * @socialAccountDao.inject presidecms:object:social_account * @websiteLoginService.inject websiteLoginService * @userDao.inject presidecms:object:website_user */ - public any function init( required any systemConfigurationService, required any logger, required any sessionStorage, required any socialAccountDao, required any websiteLoginService, required any userDao ) output=false { + public any function init( + required any systemConfigurationService + , required any logger + , required any sessionStorage + , required any socialAccountDao + , required any websiteLoginService + , required any userDao + ) output=false { _setSystemConfigurationService( arguments.systemConfigurationService ); - _setLogger( arguments.logger ); - _setSessionStorage( arguments.sessionStorage ); - _setSocialAccountDao( arguments.socialAccountDao ); - _setWebsiteLoginService( arguments.websiteLoginService ); - _setUserDao( arguments.userDao ); - _setSessionKey( "social_login" ); - - + _setLogger( arguments.logger ); + _setSessionStorage( arguments.sessionStorage ); + _setSocialAccountDao( arguments.socialAccountDao ); + _setWebsiteLoginService( arguments.websiteLoginService ); + _setUserDao( arguments.userDao ); + _setSessionKey( "social_login" ); + return this; } - // INITIAL OAUTH CALL TO FACEBOOK,LINKEDIN OR GOOGLE - public function initiateOAuthLogin( - required string loginUrlBase - , required string loginClientID - , required string loginRedirectURI - , required string loginScope - ) output=false{ - var socialSession = _getSessionStorage().getVar(_getSessionKey()); - var socialSession = { login_state = socialSession.login_state?:createUUID() }; - - _getSessionStorage().setVar(name=_getSessionKey(),value=socialSession); - var urlString = ""; - urlString = urlString & arguments.loginUrlBase; - urlString = urlString & "?client_id="; - urlString = urlString & arguments.loginClientID; - urlString = urlString & "&redirect_uri="; - urlString = urlString & arguments.loginRedirectURI; - urlString = urlString & "&state="; - urlString = urlString & socialSession.login_state; - urlString = urlString & "&scope="; - urlString = urlString & arguments.loginScope; - urlString = urlString & "&response_type=code"; - location url=urlString addtoken="false"; - - } - - // AUTHORISATION CALL TO FACEBOOK,LINKEDIN OR GOOGLE - public function authoriseOauthLogin( - required string authUrlBase - , required string authRedirectURI - , required string authMethod - , required string authCode - , required string authClientId - , required string authClientSecret - , required string authGrantType - ){ - var urlBody = ""; - var httpResult = ""; - urlBody = urlBody & "code="; - urlBody = urlBody & arguments.authCode; - urlBody = urlBody & "&redirect_uri="; - urlBody = urlBody & arguments.authRedirectURI; - urlBody = urlBody & "&client_id="; - urlBody = urlBody & arguments.authClientId; - urlBody = urlBody & "&client_secret="; - urlBody = urlBody & arguments.authClientSecret; - urlBody = urlBody & "&grant_type="; - urlBody = urlBody & arguments.authGrantType; - // Get the ACCESS TOKEN - http url=arguments.authUrlBase result="httpResult" method=arguments.authMethod resolveurl="true" { - httpparam type="header" name="Content-Type" value="application/x-www-form-urlencoded"; - httpparam type="body" value=urlBody; - } - return httpResult; - } - - //FACEBOOK - public function initiateFacebookLogin(required string redirectURI) output=false{ - - var config = _getSystemConfigurationService().getCategorySettings( "social-login" ); - - initiateOAuthLogin( - loginUrlBase = "https://www.facebook.com/dialog/oauth", - loginClientID = config.facebook_appid, - loginRedirectURI = arguments.redirectURI, - loginScope = "public_profile,email" - ); + public array function getSupportedProviders() output=false { + return [ "facebook", "google", "twitter", "linkedin" ]; } - public function authoriseFacebookLogin(required string code,required string redirectURI){ - var config = _getSystemConfigurationService().getCategorySettings( "social-login" ); - + public boolean function isProviderEnabled( required string provider ) output=false { + var providerConfig = _getProviderConfig( arguments.provider ); - var fbAuthResult = authoriseOauthLogin( - authUrlBase = "https://graph.facebook.com/oauth/access_token" - , authRedirectURI = arguments.redirectURI - , authMethod = "post" - , authCode = arguments.code - , authClientId = config.facebook_appid - , authClientSecret = config.facebook_secret - , authGrantType = "authorization_code" - - ); - - if(Find(fbAuthResult.status_code,"200")){ - //get auth token - var part1 = listGetAt(fbAuthResult.filecontent, 1, "&"); - var access_token = listGetAt(part1, 2, "="); - - var userInfo = _facebookGetUserInfo(access_token = access_token,return_fields = config.facebook_return_fields); - var socialSession = _getSessionStorage().getVar(_getSessionKey()); + return _isTrue( providerConfig.enabled ) && Len( providerConfig.clientId ) && ( !providerConfig.requiresSecret || Len( providerConfig.clientSecret ) ); + } - socialSession.facebook_access_token = access_token; - - _getSessionStorage().setVar(name=_getSessionKey(),value=socialSession); - if(structCount(userInfo)){ - var socialUser = { - social_id = userInfo.id - , label = userInfo.name - , firstname = userInfo.first_name ?:"" - , lastname = userInfo.last_name ?:"" - , email = userInfo.email ?:"" - , social_link = userInfo.link ?:"" - , image_link = "" - , gender = userInfo.gender ?:"" - , type = "facebook" - } + public void function initiateLogin( + required string provider + , required string redirectUri + , string linkUserId = "" + , string postLoginUrl = "" + ) output=false { + var providerConfig = _getProviderConfig( arguments.provider ); + + if ( !isProviderEnabled( providerConfig.id ) ) { + throw( + type = "SocialAuthService.provider.disabled" + , message = "The requested social login provider is disabled or incomplete." + ); + } - return _createSocialAccount(socialUser); - } else { - if ( _getLogger().canError() ) { _getLogger().error( "Facebook error : unable to retrieve user info. " ); } + var socialSession = _getSessionStorage().getVar( name=_getSessionKey(), default={} ); + var oauthFlows = socialSession.oauthFlows ?: {}; + var oauthFlow = { + provider = providerConfig.id + , state = _generateRandomValue() + , codeVerifier = providerConfig.usePkce ? _generateCodeVerifier() : "" + , linkUserId = arguments.linkUserId + , postLoginUrl = arguments.postLoginUrl + , createdAt = Now() + }; + var queryParams = { + client_id = providerConfig.clientId + , redirect_uri = arguments.redirectUri + , response_type = "code" + , scope = providerConfig.scope + , state = oauthFlow.state + }; + + if ( providerConfig.usePkce ) { + queryParams.code_challenge = _createCodeChallenge( oauthFlow.codeVerifier ); + queryParams.code_challenge_method = "S256"; + } - throw( type="SocialAuthService.facebook.error", message="Facebook error : unable to retrieve user info. " ); + for ( var flowState in oauthFlows ) { + if ( DateDiff( "n", oauthFlows[ flowState ].createdAt ?: CreateDate( 1970, 1, 1 ), Now() ) > 10 ) { + StructDelete( oauthFlows, flowState ); + } + } + oauthFlows[ oauthFlow.state ] = oauthFlow; + _getSessionStorage().setVar( name=_getSessionKey(), value={ oauthFlows=oauthFlows } ); + location url=providerConfig.authorizationUrl & "?" & _buildQueryString( queryParams ) addtoken="false"; + } - } + public struct function completeLogin( + required string provider + , required string code + , required string state + , required string redirectUri + ) output=false { + var providerConfig = _getProviderConfig( arguments.provider ); + var oauthFlow = _consumeOAuthFlow( arguments.state ); + + _validateOAuthCallback( + oauthFlow = oauthFlow + , provider = providerConfig.id + , state = arguments.state + ); + var tokenResponse = _exchangeAuthorizationCode( + providerConfig = providerConfig + , code = arguments.code + , redirectUri = arguments.redirectUri + , codeVerifier = oauthFlow.codeVerifier ?: "" + ); + var providerUser = _getProviderUser( + providerConfig = providerConfig + , accessToken = tokenResponse.access_token + ); + var socialAccountId = _createSocialAccount( _normaliseProviderUser( providerConfig.id, providerUser ) ); + return { + socialAccountId = socialAccountId + , linkUserId = oauthFlow.linkUserId ?: "" + , postLoginUrl = oauthFlow.postLoginUrl ?: "" + }; + } - } else { - var result = deserializeJSON(fbAuthResult.filecontent); + public boolean function loginOrLinkAccount( + required string socialAccountId + , string linkUserId = "" + ) output=false { + var socialAccount = _getSocialAccountDao().selectData( + filter = "id = :id" + , filterParams = { id=arguments.socialAccountId } + ); - if ( _getLogger().canError() ) { _getLogger().error( "Facebook error #result.error.type?:""# : #result.error.message?:"Facebook authorisation error."# " ); } + if ( !socialAccount.recordCount ) { + throw( type="SocialAuthService.account.notFound", message="The social account could not be found." ); + } - throw( type="SocialAuthService.facebook.#result.error.type?:"error"#", message=result.error.message?:"Facebook authorisation error.", code=result.error.code?:0 ); + if ( Len( arguments.linkUserId ) ) { + return _linkSocialAccount( socialAccount, arguments.linkUserId ); + } - } + if ( Len( socialAccount.website_user ?: "" ) ) { + return _authenticateWebsiteUser( socialAccount.website_user ); + } - return {}; - } + if ( + _isTrue( socialAccount.email_verified ?: false ) + && Len( socialAccount.email ?: "" ) + && _websiteUserExistsForEmail( socialAccount.email ) + ) { + throw( + type = "SocialAuthService.account.explicitLinkRequired" + , message = "An account already uses this email address. Sign in normally and explicitly link the social account." + ); + } - private function _facebookGetUserInfo(required string access_token, required string return_fields){ + var userId = _createWebsiteUser( socialAccount ); + var updatedCount = _getSocialAccountDao().updateData( + data = { website_user=userId } + , filter = "id = :id and website_user is null" + , filterParams = { id=socialAccount.id } + ); - var userInfo = {}; + if ( !updatedCount ) { + var linkedAccount = _getSocialAccountDao().selectData( id=socialAccount.id ); + _getUserDao().deleteData( id=userId ); - http url="https://graph.facebook.com/me?fields=#arguments.return_fields#&access_token=#arguments.access_token#" result="userInfo"; + if ( !linkedAccount.recordCount || !Len( linkedAccount.website_user ?: "" ) ) { + throw( type="SocialAuthService.account.provisionConflict", message="The social account changed during provisioning." ); + } - if (isJSON(userInfo.filecontent)){ - return deserializeJSON(userInfo.filecontent); - } else { - return {}; + userId = linkedAccount.website_user; } + + return _authenticateWebsiteUser( userId ); } - //GOOGLE - public function initiateGoogleLogin(required string redirectURI) output=false{ + private struct function _consumeOAuthFlow( required string state ) output=false { + var socialSession = _getSessionStorage().getVar( name=_getSessionKey(), default={} ); + var oauthFlows = socialSession.oauthFlows ?: {}; + var oauthFlow = oauthFlows[ arguments.state ] ?: {}; - var config = _getSystemConfigurationService().getCategorySettings( "social-login" ); - - initiateOAuthLogin( - loginUrlBase = "https://accounts.google.com/o/oauth2/auth", - loginClientID = config.google_client_id, - loginRedirectURI = arguments.redirectURI, - loginScope = "https://www.googleapis.com/auth/userinfo.email" - ); - } + if ( StructKeyExists( oauthFlows, arguments.state ) ) { + StructDelete( oauthFlows, arguments.state ); + _getSessionStorage().setVar( name=_getSessionKey(), value={ oauthFlows=oauthFlows } ); + } - public function authoriseGoogleLogin(required string code,required string redirectURI){ - var config = _getSystemConfigurationService().getCategorySettings( "social-login" ); - + return oauthFlow; + } - var gAuthResult = authoriseOauthLogin( - authUrlBase = "https://accounts.google.com/o/oauth2/token" - , authRedirectURI = arguments.redirectURI - , authMethod = "post" - , authCode = arguments.code - , authClientId = config.google_client_id - , authClientSecret = config.google_secretkey - , authGrantType = "authorization_code" - - ); + private void function _validateOAuthCallback( + required struct oauthFlow + , required string provider + , required string state + ) output=false { + if ( !StructCount( arguments.oauthFlow ) || !Len( arguments.state ) ) { + throw( type="SocialAuthService.callback.invalid", message="The social login request is missing or expired." ); + } - if(Find(gAuthResult.status_code,"200")){ - //get auth token - var authResult = deserializeJSON(gAuthResult.filecontent); - var access_token = authResult.access_token?:""; + if ( DateDiff( "n", arguments.oauthFlow.createdAt ?: CreateDate( 1970, 1, 1 ), Now() ) > 10 ) { + throw( type="SocialAuthService.callback.expired", message="The social login request has expired." ); + } - var userInfo = _googleGetUserInfo(access_token); + if ( + !_secureEquals( arguments.oauthFlow.provider ?: "", arguments.provider ) + || !_secureEquals( arguments.oauthFlow.state ?: "", arguments.state ) + ) { + throw( type="SocialAuthService.callback.stateMismatch", message="The social login response did not match the initiating session." ); + } + } - var socialSession = _getSessionStorage().getVar(_getSessionKey()); - - socialSession.google_access_token = access_token; + private struct function _exchangeAuthorizationCode( + required struct providerConfig + , required string code + , required string redirectUri + , string codeVerifier = "" + ) output=false { + var formFields = { + client_id = arguments.providerConfig.clientId + , code = arguments.code + , grant_type = "authorization_code" + , redirect_uri = arguments.redirectUri + }; + var headers = { Accept="application/json" }; + + if ( Len( arguments.codeVerifier ) ) { + formFields.code_verifier = arguments.codeVerifier; + } - _getSessionStorage().setVar(name=_getSessionKey(),value=socialSession); + if ( arguments.providerConfig.tokenAuthMethod == "basic" ) { + headers.Authorization = "Basic " & ToBase64( + _urlEncode( arguments.providerConfig.clientId ) & ":" & _urlEncode( arguments.providerConfig.clientSecret ) + ); + } else if ( Len( arguments.providerConfig.clientSecret ) ) { + formFields.client_secret = arguments.providerConfig.clientSecret; + } - if(structCount(userInfo)){ - var socialUser = { - social_id = userInfo.id - , label = userInfo.name - , firstname = userInfo.given_name - , lastname = userInfo.family_name - , email = userInfo.email - , social_link = userInfo.link?:"" - , image_link = userInfo.picture?:"" - , gender = userInfo.gender?:"" - , type = "google" - } + var tokenResult = _makeHttpRequest( + method = "post" + , url = arguments.providerConfig.tokenUrl + , formFields = formFields + , headers = headers + ); + var tokenResponse = _parseJsonResponse( tokenResult, "token" ); - return _createSocialAccount(socialUser); - } else { - if ( _getLogger().canError() ) { _getLogger().error( "Facebook error : unable to retrieve user info. " ); } + if ( !Len( tokenResponse.access_token ?: "" ) ) { + throw( type="SocialAuthService.token.invalid", message="The provider did not return an access token." ); + } - throw( type="SocialAuthService.facebook.error", message="Facebook error : unable to retrieve user info. " ); + return tokenResponse; + } - } + private struct function _getProviderUser( + required struct providerConfig + , required string accessToken + ) output=false { + var headers = { + Accept = "application/json" + , Authorization = "Bearer " & arguments.accessToken + }; + var userInfoUrl = arguments.providerConfig.userInfoUrl; + + if ( arguments.providerConfig.id == "facebook" ) { + var appSecretProof = LCase( Hmac( arguments.accessToken, arguments.providerConfig.clientSecret, "HmacSHA256" ) ); + userInfoUrl &= "?" & _buildQueryString( { + appsecret_proof = appSecretProof + , fields = "id,name,first_name,last_name,email,picture" + } ); + } + return _parseJsonResponse( + httpResult = _makeHttpRequest( method="get", url=userInfoUrl, headers=headers ) + , requestType = "profile" + ); + } - } else { - var result = deserializeJSON(gAuthResult.filecontent); + private struct function _normaliseProviderUser( + required string provider + , required struct providerUser + ) output=false { + var userData = arguments.provider == "twitter" ? ( arguments.providerUser.data ?: {} ) : arguments.providerUser; + var socialId = userData.sub ?: ( userData.id ?: "" ); + var emailVerified = arguments.provider == "facebook" && Len( userData.email ?: "" ) + ? true + : _isTrue( userData.email_verified ?: ( userData.verified_email ?: false ) ); + + if ( !Len( socialId ) ) { + throw( type="SocialAuthService.profile.invalid", message="The provider profile did not contain a stable user identifier." ); + } - if ( _getLogger().canError() ) { _getLogger().error( "Facebook error #result.error?:""# : #result.error_description?:"Google authorisation error."# " ); } + return { + social_id = socialId + , label = userData.name ?: ( userData.username ?: socialId ) + , firstname = userData.given_name ?: ( userData.first_name ?: ( userData.name ?: "" ) ) + , lastname = userData.family_name ?: ( userData.last_name ?: "" ) + , email = userData.email ?: "" + , email_verified = emailVerified + , social_link = arguments.provider == "twitter" && Len( userData.username ?: "" ) ? "https://x.com/" & userData.username : "" + , image_link = _getProfileImage( userData ) + , type = arguments.provider + }; + } - throw( type="SocialAuthService.google.#result.error?:"error"#", message=result.error_description?:"Google authorisation error." ); + private string function _getProfileImage( required struct userData ) output=false { + if ( + IsStruct( arguments.userData.picture ?: "" ) + && IsStruct( arguments.userData.picture.data ?: "" ) + && Len( arguments.userData.picture.data.url ?: "" ) + ) { + return arguments.userData.picture.data.url; + } - } + if ( IsSimpleValue( arguments.userData.picture ?: "" ) ) { + return arguments.userData.picture; + } - return {}; + return arguments.userData.profile_image_url ?: ""; } - private function _googleGetUserInfo(required string access_token){ - var userInfo = {}; + private string function _createSocialAccount( required struct socialUser ) output=false { + var existingAccount = _getSocialAccountDao().selectData( + filter = "type = :type and social_id = :social_id" + , filterParams = { + type = arguments.socialUser.type + , social_id = arguments.socialUser.social_id + } + ); - http url="https://www.googleapis.com/oauth2/v1/userinfo" result="userInfo"{ - httpparam type="header" name="Authorization" value="OAuth #arguments.access_token#"; - httpparam type="header" name="GData-Version" value="3"; + if ( existingAccount.recordCount ) { + _getSocialAccountDao().updateData( id=existingAccount.id, data=arguments.socialUser ); + return existingAccount.id; } - if (isJSON(userInfo.filecontent)){ - return deserializeJSON(userInfo.filecontent); - } else { - return {}; + try { + return _getSocialAccountDao().insertData( arguments.socialUser ); + } catch ( any e ) { + existingAccount = _getSocialAccountDao().selectData( + filter = "type = :type and social_id = :social_id" + , filterParams = { + type = arguments.socialUser.type + , social_id = arguments.socialUser.social_id + } + ); + + if ( existingAccount.recordCount ) { + _getSocialAccountDao().updateData( id=existingAccount.id, data=arguments.socialUser ); + return existingAccount.id; + } + + rethrow; } } - private function _createSocialAccount(required struct socialUser){ - //check if social account exists - var exists = _getSocialAccountDao().selectData( - filter = "social_id = :social_id" - , filterParams = {social_id = arguments.socialUser.social_id }); + private boolean function _linkSocialAccount( + required query socialAccount + , required string userId + ) output=false { + if ( Len( arguments.socialAccount.website_user ?: "" ) && arguments.socialAccount.website_user != arguments.userId ) { + throw( type="SocialAuthService.account.alreadyLinked", message="This social account is already linked to another website account." ); + } - if(exists.recordcount){ - _getSocialAccountDao().updateData( id=exists.id, data=arguments.socialUser ); - - return exists.id; + if ( !_websiteUserExistsForId( arguments.userId ) ) { + throw( type="SocialAuthService.account.invalidUser", message="The authenticated website account no longer exists." ); + } - } else { - //create new record - var newRecord = _getSocialAccountDao().insertData(arguments.socialUser); + var updatedCount = _getSocialAccountDao().updateData( + data = { website_user=arguments.userId } + , filter = "id = :id and website_user is null" + , filterParams = { id=arguments.socialAccount.id } + ); - return newRecord + if ( !updatedCount ) { + var linkedAccount = _getSocialAccountDao().selectData( id=arguments.socialAccount.id ); + if ( !linkedAccount.recordCount || linkedAccount.website_user != arguments.userId ) { + throw( type="SocialAuthService.account.linkConflict", message="The social account was linked by another request." ); + } } + return true; } - //login functions - public function socialLogin(required string socialAccountId){ - //check if social account links to existing user - var socialAccount = _getSocialAccountDao().selectData( - filter = "id = :id" - , filterParams = {"id" = arguments.socialAccountId }); + private string function _createWebsiteUser( required query socialAccount ) output=false { + var displayName = Trim( arguments.socialAccount.firstname & " " & arguments.socialAccount.lastname ); + var loginId = arguments.socialAccount.type & ":" & arguments.socialAccount.social_id; + var userData = { + login_id = loginId + , email_address = _getWebsiteUserEmailAddress( arguments.socialAccount ) + , display_name = Len( displayName ) ? displayName : arguments.socialAccount.label + }; + + return _getUserDao().insertData( userData ); + } + private string function _getWebsiteUserEmailAddress( required query socialAccount ) output=false { + if ( _isTrue( arguments.socialAccount.email_verified ?: false ) && Len( arguments.socialAccount.email ?: "" ) ) { + return arguments.socialAccount.email; + } + return LCase( Hash( arguments.socialAccount.type & ":" & arguments.socialAccount.social_id, "SHA-256" ) ) & "@social-login.invalid"; + } - var user = _getUserDao().selectData( - filter = "social_account.id = :social_account.id or website_user.email_address = :email_address" - , filterParams = { - "social_account.id" = socialAccount.id - , email_address = socialAccount.email + private boolean function _authenticateWebsiteUser( required string userId ) output=false { + var users = _getUserDao().selectData( + selectFields = [ "login_id" ] + , filter = { id=arguments.userId, active=true } + , useCache = false + ); - }); + if ( !users.recordCount ) { + return false; + } - var userId = ""; + return _getWebsiteLoginService().login( + loginId = users.login_id + , skipPasswordCheck = true + ); + } + private boolean function _websiteUserExistsForEmail( required string emailAddress ) output=false { + var users = _getUserDao().selectData( + selectFields = [ "id" ] + , filter = "email_address = :email_address" + , filterParams = { email_address=arguments.emailAddress } + ); + return users.recordCount > 0; + } + private boolean function _websiteUserExistsForId( required string userId ) output=false { + var users = _getUserDao().selectData( + selectFields = [ "id" ] + , filter = { id=arguments.userId } + ); - if(!user.recordcount){ - //create new record - var newUser = { - login_id = socialAccount.email - , email_address = socialAccount.email - , display_name = socialAccount.firstname & " " & socialAccount.lastname + return users.recordCount > 0; + } + private struct function _getProviderConfig( required string provider ) output=false { + var socialConfig = _getSystemConfigurationService().getCategorySettings( "social-login" ); + var providerId = LCase( Trim( arguments.provider ) ); + var providers = { + facebook = { + id = "facebook" + , enabled = socialConfig.facebook_login ?: false + , clientId = socialConfig.facebook_app_id ?: ( socialConfig.facebook_appid ?: "" ) + , clientSecret = socialConfig.facebook_app_secret ?: ( socialConfig.facebook_secret ?: "" ) + , authorizationUrl = "https://www.facebook.com/v25.0/dialog/oauth" + , tokenUrl = "https://graph.facebook.com/v25.0/oauth/access_token" + , userInfoUrl = "https://graph.facebook.com/v25.0/me" + , scope = "public_profile,email" + , usePkce = true + , requiresSecret = true + , tokenAuthMethod = "post" } - if(socialAccount.type eq "twitter" && !Len(newUser.login_id)){ - newUser.login_id = socialAccount.label; //set it as twitter handler if there is no email + , google = { + id = "google" + , enabled = socialConfig.google_login ?: false + , clientId = socialConfig.google_client_id ?: "" + , clientSecret = socialConfig.google_client_secret ?: ( socialConfig.google_secretkey ?: "" ) + , authorizationUrl = "https://accounts.google.com/o/oauth2/v2/auth" + , tokenUrl = "https://oauth2.googleapis.com/token" + , userInfoUrl = "https://openidconnect.googleapis.com/v1/userinfo" + , scope = "openid email profile" + , usePkce = true + , requiresSecret = true + , tokenAuthMethod = "post" } + , twitter = { + id = "twitter" + , enabled = socialConfig.twitter_login ?: false + , clientId = socialConfig.twitter_client_id ?: "" + , clientSecret = socialConfig.twitter_client_secret ?: "" + , authorizationUrl = "https://x.com/i/oauth2/authorize" + , tokenUrl = "https://api.x.com/2/oauth2/token" + , userInfoUrl = "https://api.x.com/2/users/me?user.fields=id,name,username,profile_image_url" + , scope = "tweet.read users.read" + , usePkce = true + , requiresSecret = true + , tokenAuthMethod = "basic" + } + , linkedin = { + id = "linkedin" + , enabled = socialConfig.linkedin_login ?: false + , clientId = socialConfig.linkedin_client_id ?: "" + , clientSecret = socialConfig.linkedin_client_secret ?: "" + , authorizationUrl = "https://www.linkedin.com/oauth/v2/authorization" + , tokenUrl = "https://www.linkedin.com/oauth/v2/accessToken" + , userInfoUrl = "https://api.linkedin.com/v2/userinfo" + , scope = "openid profile email" + , usePkce = false + , requiresSecret = true + , tokenAuthMethod = "post" + } + }; + if ( !StructKeyExists( providers, providerId ) ) { + throw( type="SocialAuthService.provider.invalid", message="The requested social login provider is not supported." ); + } - userId = _getUserDao().insertData(newUser); - + return providers[ providerId ]; + } + private struct function _makeHttpRequest( + required string method + , required string url + , struct formFields = {} + , struct headers = {} + ) output=false { + var httpResult = {}; + var fieldName = ""; + var headerName = ""; + + try { + http method=arguments.method url=arguments.url result="httpResult" timeout="20" { + for ( headerName in arguments.headers ) { + httpparam type="header" name=headerName value=arguments.headers[ headerName ]; + } + for ( fieldName in arguments.formFields ) { + httpparam type="formfield" name=fieldName value=arguments.formFields[ fieldName ]; + } + } + } catch ( any e ) { + throw( + type = "SocialAuthService.providerRequest.unavailable" + , message = "The social login provider could not be reached." + ); + } - //update social account - _getSocialAccountDao().updateData(id=socialAccount.id, data={website_user = userId}) + return httpResult; + } - + private struct function _parseJsonResponse( + required struct httpResult + , required string requestType + ) output=false { + var statusCode = Val( ListFirst( arguments.httpResult.status_code ?: "0", " " ) ); - } else { - userId = user.id; - - //update the social account to link to website user - if(!Len(socialAccount.website_user)){ - _getSocialAccountDao().updateData(id=socialAccount.id, data={website_user = userId}) + if ( statusCode < 200 || statusCode >= 300 || !IsJson( arguments.httpResult.fileContent ?: "" ) ) { + if ( _getLogger().canError() ) { + _getLogger().error( "Social login #arguments.requestType# request failed with HTTP status [#statusCode#]." ); } - + throw( + type = "SocialAuthService.providerRequest.failed" + , message = "The social login provider rejected the #arguments.requestType# request." + ); } - return _getWebsiteLoginService().impersonate(userId); + return DeserializeJson( arguments.httpResult.fileContent ); + } + + private string function _buildQueryString( required struct params ) output=false { + var pairs = []; + var key = ""; + + for ( key in arguments.params ) { + ArrayAppend( pairs, _urlEncode( key ) & "=" & _urlEncode( arguments.params[ key ] ) ); + } + + return ArrayToList( pairs, "&" ); + } + + private string function _urlEncode( required string value ) output=false { + return Replace( EncodeForUrl( arguments.value ), "%20", "+", "all" ); + } + + private string function _generateRandomValue() output=false { + return _toBase64Url( GenerateSecretKey( "AES", 256 ) ); + } + + private string function _generateCodeVerifier() output=false { + return Left( _generateRandomValue() & _generateRandomValue(), 96 ); + } + private string function _createCodeChallenge( required string codeVerifier ) output=false { + var digest = BinaryDecode( Hash( arguments.codeVerifier, "SHA-256", "UTF-8" ), "hex" ); + return _toBase64Url( ToBase64( digest ) ); + } + private string function _toBase64Url( required string value ) output=false { + return ReReplace( Replace( Replace( arguments.value, "+", "-", "all" ), "/", "_", "all" ), "=+$", "" ); } + private boolean function _secureEquals( + required string expected + , required string actual + ) output=false { + var messageDigest = CreateObject( "java", "java.security.MessageDigest" ); + var expectedBytes = JavaCast( "string", arguments.expected ).getBytes( "UTF-8" ); + var actualBytes = JavaCast( "string", arguments.actual ).getBytes( "UTF-8" ); + + return messageDigest.isEqual( expectedBytes, actualBytes ); + } + private boolean function _isTrue( required any value ) output=false { + return IsBoolean( arguments.value ) && arguments.value; + } -// GETTERS AND SETTERS private any function _getSystemConfigurationService() output=false { return _systemConfigurationService; } + private void function _setSystemConfigurationService( required any systemConfigurationService ) output=false { _systemConfigurationService = arguments.systemConfigurationService; } @@ -348,43 +604,48 @@ component output=false singleton=true { private any function _getLogger() output=false { return _logger; } + private void function _setLogger( required any logger ) output=false { _logger = arguments.logger; } - private any function _getSessionStorage() { + private any function _getSessionStorage() output=false { return _sessionStorage; } - private void function _setSessionStorage( required any sessionStorage ) { + + private void function _setSessionStorage( required any sessionStorage ) output=false { _sessionStorage = arguments.sessionStorage; } - private any function _getSocialAccountDao() { + private any function _getSocialAccountDao() output=false { return _socialAccountDao; } - private void function _setSocialAccountDao( required any socialAccountDao ) { + + private void function _setSocialAccountDao( required any socialAccountDao ) output=false { _socialAccountDao = arguments.socialAccountDao; } private any function _getWebsiteLoginService() output=false { return _websiteLoginService; } + private void function _setWebsiteLoginService( required any websiteLoginService ) output=false { _websiteLoginService = arguments.websiteLoginService; } - private any function _getUserDao() { + private any function _getUserDao() output=false { return _userDao; } - private void function _setUserDao( required any userDao ) { + + private void function _setUserDao( required any userDao ) output=false { _userDao = arguments.userDao; } - private string function _getSessionKey() { + private string function _getSessionKey() output=false { return _sessionKey; } - private void function _setSessionKey( required string sessionKey ) { + + private void function _setSessionKey( required string sessionKey ) output=false { _sessionKey = arguments.sessionKey; } - } diff --git a/services/TwitterAuthService.cfc b/services/TwitterAuthService.cfc deleted file mode 100644 index 17423ee..0000000 --- a/services/TwitterAuthService.cfc +++ /dev/null @@ -1,304 +0,0 @@ -component output=false singleton=true extends="SocialAuthService" { - -// CONSTRUCTOR - /** - * @sessionStorage.inject coldbox:plugin:sessionStorage - * @systemConfigurationService.inject systemConfigurationService - * @logger.inject logbox:logger:TwitterAuthService - * @socialAccountDao.inject presidecms:object:social_account - * @websiteLoginService.inject websiteLoginService - * @userDao.inject presidecms:object:website_user - */ - - public any function init( required any systemConfigurationService, required any logger, required any sessionStorage, required any socialAccountDao, required any websiteLoginService, required any userDao ) output=false { - super.init(argumentCollection=arguments) - return this; - } - - - //twitter functions - //originally getTwitterRequestToken - public function initiateTwitterLogin( required string redirectURI) output=false{ - // Variables - var config = _getSystemConfigurationService().getCategorySettings( "social-login" ); - var gmt_time_zone = "8"; // Greenwich mean time offset at server - var http_method = "POST"; - var request_url = "https://api.twitter.com/oauth/request_token"; - var oauth_consumer_secret = config.twitter_consumer_secret; - var params = {}; - params["oauth_callback"] = arguments.redirectURI; - params["oauth_consumer_key"] = config.twitter_consumer_key; - params["oauth_nonce"] = DateFormat(Now(),'yymmdd') & TimeFormat (Now(),'hhmmssl'); - params["oauth_signature_method"] = "HMAC-SHA1"; - params["oauth_timestamp"] = DateDiff("s", "January 1 1970 00:00", (Now()+(gmt_time_zone/24))); - params["oauth_version"] = "1.0"; - // Submit OAuth request - var oauth_response = _oauthRequest(oauth_consumer_secret,"",http_method,request_url,params); - // Parse and store the results - // Request Token (variable-length) - oauth_token_start = Find("oauth_token=",oauth_response)+12; - oauth_token_end = Find("&",oauth_response,oauth_token_start); - - var twitterSession = _getSessionStorage().getVar( name=_getSessionKey(), default={} ); - - var twitterSession.oauth_request_token = Mid(oauth_response,oauth_token_start,(oauth_token_end-oauth_token_start)); - // Request Token secret (variable-length) - oauth_token_secret_start = Find("oauth_token_secret=",oauth_response)+19; - oauth_token_secret_end = Find("&",oauth_response,oauth_token_secret_start); - twitterSession.oauth_request_token_secret = Mid(oauth_response,oauth_token_secret_start,(oauth_token_secret_end-oauth_token_secret_start)); - //save twitterSession into session - _getSessionStorage().setVar( name=_getSessionKey(), value=twitterSession ); - // Callback confirmation flag (true/false) - // ignored - // Forward user to Twitter for authentication - location url="https://api.twitter.com/oauth/authorize?oauth_token=#twitterSession.oauth_request_token#"; - } - - public function authoriseTwitterLogin( - required string oauth_token - , required string oauth_verifier - ){ - var config = _getSystemConfigurationService().getCategorySettings( "social-login" ); - var twitterSession = _getSessionStorage().getVar( name=_getSessionKey(), default={} ); - - - var getAccessToken = getTwitterAccessToken(oauth_token=arguments.oauth_token, oauth_verifier=arguments.oauth_verifier); - // Get the basics user details so that we have a screen_name we can use - var getTwitterDetails = getTwitterDetails(); - var twitterData = DeserializeJSON(getTwitterDetails); - twitterSession.twitter_screen_name = twitterData.screen_name; - // Build the twitter4j stuff - var configBuilder = createObject("java", "twitter4j.conf.ConfigurationBuilder"); - configBuilder.setOAuthConsumerKey(config.twitter_consumer_key); - configBuilder.setOAuthConsumerSecret(config.twitter_consumer_secret); - configBuilder.setOAuthAccessToken(twitterSession.twitter_access_token); - configBuilder.setOAuthAccessTokenSecret(twitterSession.twitter_access_token_secret); - var twitterConfig = configBuilder.build(); - twitterFactory = createObject("java", "twitter4j.TwitterFactory").init(twitterConfig); - twitter = twitterFactory.getInstance(); - // Now we can get the User ID, Real Name, User Image etc... - twitterUserDetails = twitter.showUser(twitterSession.twitter_screen_name); - - _getSessionStorage().setVar( name=_getSessionKey(), value=twitterSession ); - - var socialUser = { - social_id = twitterUserDetails.getID() - , label = twitterUserDetails.getScreenName() - , firstname = twitterUserDetails.getName() - , lastname = "" - , email = "" - , social_link = "https://twitter.com/" & twitterUserDetails.getScreenName() - , image_link = twitterUserDetails.getBiggerProfileImageURL() - , gender = "" - , type = "twitter" - } - - - return _createSocialAccount(socialUser); - } - - public function getTwitterAccessToken( - required string oauth_token - , required string oauth_verifier - ) output=false{ - // Variables - var config = _getSystemConfigurationService().getCategorySettings( "social-login" ); - var twitterSession = _getSessionStorage().getVar( name=_getSessionKey(), default={} ); - - var gmt_time_zone = "8";// Greenwich mean time offset at server - var http_method = "POST"; - var request_url = "https://api.twitter.com/oauth/access_token"; - var oauth_consumer_secret = config.twitter_consumer_secret; - params = {}; - params["oauth_consumer_key"] = config.twitter_consumer_key; - params["oauth_nonce"] = DateFormat(Now(),'yymmdd') & TimeFormat (Now(),'hhmmssl'); - params["oauth_signature_method"] = "HMAC-SHA1"; - params["oauth_timestamp"] = DateDiff("s", "January 1 1970 00:00", (Now()+(gmt_time_zone/24))); - params["oauth_token"] = arguments.oauth_token; - params["oauth_verifier"] = arguments.oauth_verifier; - params["oauth_version"] = "1.0"; - // Submit OAuth request - var oauth_response = _oauthRequest(oauth_consumer_secret,twitterSession.oauth_request_token_secret?:"",http_method,request_url,params); - // Get token (variable-length) - var oauth_token_start = Find("oauth_token=",oauth_response)+12; - var oauth_token_end = Find("&",oauth_response,oauth_token_start); - var oauth_access_token = Mid(oauth_response,oauth_token_start,(oauth_token_end-oauth_token_start)); - // Get token secret (variable-length) - oauth_token_secret_start = Find("oauth_token_secret=",oauth_response)+19; - oauth_token_secret_end = Find("&",oauth_response,oauth_token_secret_start); - var oauth_access_token_secret = Mid(oauth_response,oauth_token_secret_start,(oauth_token_secret_end-oauth_token_secret_start)); - // Set up the SESSION vars - - twitterSession.twitter_access_token = oauth_access_token; - twitterSession.twitter_access_token_secret = oauth_access_token_secret; - - _getSessionStorage().setVar( name=_getSessionKey(), value=twitterSession ); - - } - - //GET BASICS TWITTER ACCOUNT SETTINGS FOR THIS USER - public function getTwitterDetails(){ - var config = _getSystemConfigurationService().getCategorySettings( "social-login" ); - var twitterSession = _getSessionStorage().getVar( name=_getSessionKey(), default={} ); - - var gmt_time_zone = "8"; // Greenwich mean time offset at server - var http_method = "POST"; - var request_url = "https://api.twitter.com/1.1/account/settings.json"; - var oauth_consumer_secret = config.twitter_consumer_secret; - params = {}; - params["oauth_consumer_key"] = config.twitter_consumer_key; - params["oauth_nonce"] = DateFormat(Now(),'yymmdd') & TimeFormat (Now(),'hhmmssl'); - params["oauth_signature_method"] = "HMAC-SHA1"; - params["oauth_token"] = twitterSession.twitter_access_token; - params["oauth_timestamp"] = DateDiff("s", "January 1 1970 00:00", (Now()+(gmt_time_zone/24))); - params["oauth_version"] = "1.0"; - // Submit OAuth request - var oauth_response = _OauthRequest(oauth_consumer_secret,twitterSession.twitter_access_token_secret,http_method,request_url,params); - // Display the results - return oauth_response; - } - - - - //RFC 3986-compliant Urlencodedformat() Function - private string function _URLEncodedFormat3986( - required string str - ){ - var rfc_3986_bad_chars = "%2D,%2E,%5F,%7E"; - var rfc_3986_good_chars = "-,.,_,~"; - arguments.str = ReplaceList(URLEncodedFormat(arguments.str),rfc_3986_bad_chars,rfc_3986_good_chars); - - return arguments.str; - } - - // HMAC-SHA1 Authentication - private binary function _HMAC_SHA1( - required string signKey , required string signMessage - ) output="false" { - var jMsg = JavaCast("string",arguments.signMessage).getBytes("iso-8859-1"); - var jKey = JavaCast("string",arguments.signKey).getBytes("iso-8859-1"); - var key = createObject("java","javax.crypto.spec.SecretKeySpec"); - var mac = createObject("java","javax.crypto.Mac"); - key = key.init(jKey,"HmacSHA1"); - mac = mac.getInstance(key.getAlgorithm()); - mac.init(key); - mac.update(jMsg); - - return mac.doFinal(); - } - - //OAuth Signature Base String Function - private string function _OauthBaseString ( - required string http_method - , required string base_uri - , required struct parameters - ) output=false{ - // Concatenate http_method & URL-encoded base_uri - var oauth_signature_base_string = arguments.http_method & "&" & _URLEncodedFormat3986(arguments.base_uri) & "&"; - // Create sorted list of parameter keys - var key_list = StructKeyArray(arguments.parameters); - - ArraySort(key_list,"text"); //optional sort, for debugging purpose - - var amp = ""; // first iteration requires no ampersand - // Repeat for each parameter - for(key in key_list){ - // Concatenate URL-encoded parameter (key/value pair) - oauth_signature_base_string = oauth_signature_base_string & _URLEncodedFormat3986(amp & LCase(key) & "=" & arguments.parameters[key]); - amp = "&"; // successive iterations require a starting ampersand - } - //Return with OAuth signature base string - return oauth_signature_base_string; - } - /* OAUTH REQUEST FUNCTION - * - * Per OAuth specification, sends specified request and - * parameters to the specified provider (e.g., Twitter). - * Response is returned in a string. - */ - - private string function _oauthRequest( - required string consumer_secret - , required string token_secret - , required string http_method - , required string request_url - , required struct params - ) output=false { - - // Backup parameters for later - var params_backup = Duplicate(arguments.params); - // Copy URL variables (if any) to parameters - // Parse address and parameters from request URL - var request_url_address = arguments.request_url; - var request_url_query_string = ""; - var question_mark = Find("?",arguments.request_url,1); - - if (question_mark neq 0){ - request_url_address = Left(arguments.request_url,question_mark-1); - request_url_query_string = Right(arguments.request_url,(len(arguments.request_url)-question_mark)); - - //Repeat for each key/value pair - request_url_query_string = Replace(request_url_query_string, "&&", "PLACEHOLDER_AMPERSAND", "ALL"); // save escaped ampersand (&) symbols - request_url_query_string = Replace(request_url_query_string, "==", "PLACEHOLDER_EQUALS", "ALL"); // save escaped equals (=) symbols - var params_list = ListChangeDelims(request_url_query_string,",","&,="); - loop from="1" to=ListLen(params_list) index="index" step="2"{ - // Add parameter to Params structure - arguments.params[ListGetAt(params_list,index)] = ListGetAt(params_list,index+1); - arguments.params[ListGetAt(params_list,index)] = Replace(arguments.params[ListGetAt(params_list,index)], "PLACEHOLDER_AMPERSAND", "&", "ALL"); // restore escaped ampersand (&) symbols as non-escaped - arguments.params[ListGetAt(params_list,index)] = Replace(arguments.params[ListGetAt(params_list,index)], "PLACEHOLDER_EQUALS", "=", "ALL"); // restore escaped equals (=) symbols as non-escaped - } - } - // Generate signature base string - // All parameters must be URL-encoded - var key = ""; - var param_keys =StructKeyArray(arguments.params); - for ( key in param_keys ){ - arguments.params[key] = _URLEncodedFormat3986(arguments.params[key]) - } - // Get the base string - var signature_base_string = _OauthBaseString(arguments.http_method,request_url_address,arguments.params); - // Generate composite signing key - var composite_signing_key = arguments.consumer_secret & "&" & arguments.token_secret; - //Generate the SHA1 hash - var signature = ToBase64(_HMAC_SHA1(composite_signing_key,signature_base_string)); - // Hash (now that we have it) must also be URL encoded - signature = _URLEncodedFormat3986(signature); - // Submit request to provider (e.g., Twitter) - // Generate header parameters string - var oauth_header = "OAuth "; - // Parameters (minus URL parameters) - var comma = ""; - param_keys = StructKeyArray(params_backup); - for (key in param_keys){ // use backup list of parameter keys to remove query parameters - oauth_header = oauth_header & comma & key & "=""" & params[key] & """"; // ...but use current (URL-encoded) parameter values - comma = ", "; - } - //Signature - oauth_header = oauth_header & ", oauth_signature=""" & signature & """"; - - param_keys = StructKeyArray(params); - - var httpResult = ""; - - http method="post" url=request_url_address result="httpResult" { - // Header - httpparam type="header" name="Authorization" value=oauth_header encoded="no"; - // Parameters - for(key in param_keys){ - if(!StructKeyExists(params_backup,key)){ // just the query parameters - httpparam type="formfield" name=key value=params[key] encoded="no"; - } - } - } - if (httpResult.Statuscode neq "200 OK"){ - if ( _getLogger().canError() ) { _getLogger().error( "Twitter error - invalid request : #httpResult.filecontent# " ); } - - - throw(type="TwitterAuthService.inValidOauthRequest", message="Invalid request : #httpResult.filecontent#") - - } else { - return httpResult.filecontent; - } - } - -} diff --git a/tests/specs/services/SocialAuthServiceSpec.cfc b/tests/specs/services/SocialAuthServiceSpec.cfc new file mode 100644 index 0000000..271e392 --- /dev/null +++ b/tests/specs/services/SocialAuthServiceSpec.cfc @@ -0,0 +1,124 @@ +component extends="testbox.system.BaseSpec" { + + function run() { + describe( "SocialAuthService security controls", function() { + beforeEach( function() { + variables.systemConfigurationService = CreateStub(); + variables.logger = CreateStub(); + variables.sessionStorage = CreateStub(); + variables.socialAccountDao = CreateStub(); + variables.websiteLoginService = CreateStub(); + variables.userDao = CreateStub(); + variables.systemConfigurationService.$( "getCategorySettings", { + facebook_login = true + , facebook_app_id = "facebook-client-id" + , facebook_app_secret = "facebook-client-secret" + , google_login = true + , google_client_id = "google-client-id" + , google_client_secret = "google-client-secret" + } ); + variables.sessionStorage.$( "setVar" ); + variables.service = CreateObject( "component", "services.SocialAuthService" ).init( + systemConfigurationService = variables.systemConfigurationService + , logger = variables.logger + , sessionStorage = variables.sessionStorage + , socialAccountDao = variables.socialAccountDao + , websiteLoginService = variables.websiteLoginService + , userDao = variables.userDao + ); + } ); + + it( "rejects callbacks whose state does not match the initiating session", function() { + variables.sessionStorage.$( "getVar", { + oauthFlows = { + "expected-state" = { + provider = "google" + , state = "expected-state" + , codeVerifier = "verifier" + , createdAt = Now() + } + } + } ); + + expect( function() { + variables.service.completeLogin( + provider = "facebook" + , code = "authorization-code" + , state = "expected-state" + , redirectUri = "https://example.com/login/auth/?type=facebook" + ); + } ).toThrow( type="SocialAuthService.callback.stateMismatch" ); + } ); + + it( "rejects callbacks after the ten minute lifetime", function() { + variables.sessionStorage.$( "getVar", { + oauthFlows = { + "expected-state" = { + provider = "google" + , state = "expected-state" + , codeVerifier = "verifier" + , createdAt = DateAdd( "n", -11, Now() ) + } + } + } ); + + expect( function() { + variables.service.completeLogin( + provider = "google" + , code = "authorization-code" + , state = "expected-state" + , redirectUri = "https://example.com/login/auth/?type=google" + ); + } ).toThrow( type="SocialAuthService.callback.expired" ); + } ); + + it( "requires explicit linking when an existing user owns the social email", function() { + variables.socialAccountDao.$( "selectData", QueryNew( + "id,website_user,email,email_verified,firstname,lastname,label,type,social_id" + , "varchar,varchar,varchar,boolean,varchar,varchar,varchar,varchar,varchar" + , [ { + id = "social-account-id" + , website_user = "" + , email = "member@example.com" + , email_verified = true + , firstname = "Example" + , lastname = "Member" + , label = "Example Member" + , type = "google" + , social_id = "provider-user-id" + } ] + ) ); + variables.userDao.$( "selectData", QueryNew( "id", "varchar", [ { id="existing-user-id" } ] ) ); + + expect( function() { + variables.service.loginOrLinkAccount( "social-account-id" ); + } ).toThrow( type="SocialAuthService.account.explicitLinkRequired" ); + } ); + + it( "does not move a linked social identity to another user", function() { + variables.socialAccountDao.$( "selectData", QueryNew( + "id,website_user,email,email_verified,firstname,lastname,label,type,social_id" + , "varchar,varchar,varchar,boolean,varchar,varchar,varchar,varchar,varchar" + , [ { + id = "social-account-id" + , website_user = "original-user-id" + , email = "member@example.com" + , email_verified = true + , firstname = "Example" + , lastname = "Member" + , label = "Example Member" + , type = "google" + , social_id = "provider-user-id" + } ] + ) ); + + expect( function() { + variables.service.loginOrLinkAccount( + socialAccountId = "social-account-id" + , linkUserId = "different-user-id" + ); + } ).toThrow( type="SocialAuthService.account.alreadyLinked" ); + } ); + } ); + } +} diff --git a/views/login/loginPage.cfm b/views/login/loginPage.cfm index a30b859..74f5168 100644 --- a/views/login/loginPage.cfm +++ b/views/login/loginPage.cfm @@ -1,60 +1,61 @@ -enableWebsiteLogin = isBoolean(prc.enableWebsiteLogin)?prc.enableWebsiteLogin:false; -enableFacebookLogin = isBoolean(prc.enableFacebookLogin)?prc.enableFacebookLogin:false; -enableTwitterLogin = isBoolean(prc.enableTwitterLogin)?prc.enableTwitterLogin:false; -enableGoogleLogin = isBoolean(prc.enableGoogleLogin)?prc.enableGoogleLogin:false; + enableWebsiteLogin = IsBoolean( prc.enableWebsiteLogin ?: "" ) && prc.enableWebsiteLogin; + enableFacebookLogin = IsBoolean( prc.enableFacebookLogin ?: "" ) && prc.enableFacebookLogin; + enableTwitterLogin = IsBoolean( prc.enableTwitterLogin ?: "" ) && prc.enableTwitterLogin; + enableGoogleLogin = IsBoolean( prc.enableGoogleLogin ?: "" ) && prc.enableGoogleLogin; + enableLinkedinLogin = IsBoolean( prc.enableLinkedinLogin ?: "" ) && prc.enableLinkedinLogin; - +
-
- -
-
- + +
+ + - -
\ No newline at end of file +