From 42cd312d076bacec7c3c6cc9aec85c17628ee8a7 Mon Sep 17 00:00:00 2001 From: Robin Ward Date: Thu, 24 Sep 2015 15:28:06 -0400 Subject: [PATCH] Initial stab at basic OAuth2 --- .gitignore | 7 ++ README.md | 129 +++++++++++++++++++++++++++++++++++ config/locales/server.en.yml | 13 ++++ config/settings.yml | 17 +++++ plugin.rb | 93 +++++++++++++++++++++++++ 5 files changed, 259 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/locales/server.en.yml create mode 100644 config/settings.yml create mode 100644 plugin.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8335aab --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.bundle/ +log/*.log +pkg/ +auto_generated +Gemfile.lock +.DS_Store +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..c6691c9 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +## discourse-oauth2-basic + +This plugin allows you to use a basic OAuth2 provider as authentication for +Discourse. It should work with many providers, with the caveat that they +must provide a JSON endpoint for retriving information about the user +you are logging in. + +This is mainly useful for people who are using login providers that aren't +very popular. If you want to use Google, Facebook or Twitter, those are +included out of the box and you don't need this plugin. You can also +look for other login providers in our [Github Repo](https://github.com/discourse). + + +## Usage + +## Part 1: Basic Configuration + +First, set up your Discourse application remotely on your OAuth2 provider. +It will require a **Redirect URI** which should be: + +`http://DISCOURSE_HOST/auth/oauth2_basic/callback` + +Replace `DISCOURSE_HOST` with the approriate value, and make sure you are +using `https` if enabled. The OAuth2 provider should supply you with a +client ID and secret, as well as a couple of URLs. + +Visit your **Admin** > **Settings** > **Login** and fill in the basic +configuration for the OAuth2 provider: + +* `oauth2_enabled` - check this off to enable the feature + +* `oauth2_client_id` - the client ID from your provider + +* `oauth2_client_secret` - the client secret from your provider + +* `oauth2_authorize_url` - your provider's authorization URL + +* `oauth2_token_url` - your provider's token URL. + +If you can't figure out the values for the above settings, check the +developer documentation from your provider or contact their customer +support. + + +## Part 2: Configuring the JSON User Endpoint + +Discourse is now capable of receiving an authorization token from your +OAuth2 provider. Unfortunately, Discourse requires more information to +be able to complete the authentication. + +We require an API endpoint that can be contacted to retrieve information +about the user based on the token. + +For example, the OAuth2 provider [SoundCloud provides such a URL](https://developers.soundcloud.com/docs/api/reference#me). +If you have an OAuth2 token for SoundCloud, you can make a GET request +to `https://api.soundcloud.com/me?oauth_token=A_VALID_TOKEN` and +will get back a JSON object containing information on the user. + +To configure this on Discourse, we need to set the value of the +`oauth2_user_json_url` setting. In this case, we'll input the value of: + +``` +https://api.soundcloud.com/me?oauth_token=:token` +``` + +The part with `:token` tells Discourse that it needs to replace that value +with the authorization token it received when the authentication completed. + +There is one last step to complete. We need to tell Discourse what +attributes are available in the JSON it received. Here's a sample +response from SoundCloud: + +```json +{ + "id": 3207, + "permalink": "jwagener", + "username": "Johannes Wagener", + "uri": "https://api.soundcloud.com/users/3207", + "permalink_url": "http://soundcloud.com/jwagener", + "avatar_url": "http://i1.sndcdn.com/avatars-000001552142-pbw8yd-large.jpg?142a848", + "country": "Germany", + "full_name": "Johannes Wagener", + "city": "Berlin", + ... +} +``` + +The `oauth2_json_user_id_path`, `oauth2_json_username_path`, `oauth2_json_name_path` and +`oauth2_json_email_path` variables should be set to point to the appropriate attributes +in the JSON. + +The only mandatory attribute is *id* - we need that so when the user logs on in the future +that we can pull up the correct account. The others are great if available -- they will +make the signup process faster for the user as they will be pre-populated in the form. + +Here's how I configured the JSON path settings: + +``` + oauth2_json_user_id_path: 'id' + oauth2_json_username_path: 'permalink' + oauth2_json_name_path: 'full_name' +``` + +I used `permalink` because it seems more similar to what Discourse expects for a username +than the username in their JSON. Notice I omitted the email path: SoundCloud do not +provide an email so the user will have to provide and verify this when they sign up +the first time on Discourse. + +If the properties you want from your JSON object are nested, you can use periods. +So for example if the API returned a different structure like this: + +```json +{ + user: { + id: 1234, + email: { + address: 'test@example.com' + } + } +} +``` + +You could use `user.id` for the `oauth2_json_user_id_path` and `user.email.address` for `oauth2_json_email_path`. + +Good luck setting up custom OAuth2 on your Discourse! + +### License + +MIT diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml new file mode 100644 index 0000000..8b13ba5 --- /dev/null +++ b/config/locales/server.en.yml @@ -0,0 +1,13 @@ +en: + site_settings: + oauth2_enabled: "Custom OAuth2 is enabled" + oauth2_client_id: 'Client ID for custom OAuth2' + oauth2_client_secret: 'Client Secret for custom OAuth2' + oauth2_authorize_url: 'Authorization URL for OAuth2' + oauth2_token_url: 'Token URL for OAuth2' + oauth2_user_json_url: 'URL to fetch user JSON for OAuth2' + oauth2_json_user_id_path: 'Path in the OAuth2 User JSON to the user id. eg: user.id' + oauth2_json_username_path: 'Path in the OAuth2 User JSON to the username. eg: user.username' + oauth2_json_name_path: "Path in the OAuth2 User JSON to the user's full: user.name.full" + oauth2_json_email_path: "Path in the OAuth2 User JSON to the user's email: user.email.primary" + diff --git a/config/settings.yml b/config/settings.yml new file mode 100644 index 0000000..1cd3494 --- /dev/null +++ b/config/settings.yml @@ -0,0 +1,17 @@ +login: + oauth2_enabled: + default: false + client: true + oauth2_client_id: '' + oauth2_client_secret: '' + oauth2_authorize_url: '' + oauth2_token_url: '' + oauth2_user_json_url: '' + oauth2_json_user_id_path: '' + oauth2_json_username_path: '' + oauth2_json_name_path: '' + oauth2_json_email_path: '' + oauth2_email_verified: false, + oauth2_button_title: + default: 'with OAuth2' + client: true diff --git a/plugin.rb b/plugin.rb new file mode 100644 index 0000000..20cf4b9 --- /dev/null +++ b/plugin.rb @@ -0,0 +1,93 @@ +# name: discourse-oauth2-basic +# about: Generic OAuth2 Plugin +# version: 0.1 +# authors: Robin Ward + +require_dependency 'auth/oauth2_authenticator.rb' + +enabled_site_setting :oauth2_enabled + +class OAuth2BasicAuthenticator < ::Auth::OAuth2Authenticator + def register_middleware(omniauth) + omniauth.provider :oauth2, + name: 'oauth2_basic', + setup: lambda {|env| + opts = env['omniauth.strategy'].options + opts[:client_id] = SiteSetting.oauth2_client_id + opts[:client_secret] = SiteSetting.oauth2_client_secret + opts[:provider_ignores_state] = true + opts[:client_options] = { + authorize_url: SiteSetting.oauth2_authorize_url, + token_url: SiteSetting.oauth2_token_url + } + } + end + + def walk_path(fragment, segments) + first_seg = segments[0] + return if first_seg.blank? || fragment.blank? + return nil unless fragment.is_a?(Hash) + deref = fragment[first_seg] || fragment[first_seg.to_sym] + + return (deref.blank? || segments.size == 1) ? deref : walk_path(deref, segments[1..-1]) + end + + def json_walk(result, user_json, prop) + path = SiteSetting.send("oauth2_json_#{prop}_path") + if path.present? + segments = path.split('.') + val = walk_path(user_json, segments) + result[prop] = val if val.present? + end + end + + def fetch_user_details(token) + user_json_url = SiteSetting.oauth2_user_json_url.sub(':token', token) + user_json = JSON.parse(open(user_json_url).read) + + result = {} + if user_json.present? + json_walk(result, user_json, :user_id) + json_walk(result, user_json, :username) + json_walk(result, user_json, :name) + json_walk(result, user_json, :email) + end + + result + end + + def after_authenticate(auth) + result = Auth::Result.new + token = auth['credentials']['token'] + user_details = fetch_user_details(token) + + result.name = user_details[:name] + result.username = user_details[:username] + result.email = user_details[:email] + result.email_valid = result.email.present? && SiteSetting.oauth2_email_verified + + current_info = ::PluginStore.get("oauth2_basic", "oauth2_basic_user_#{user_details[:id]}") + if current_info + result.user = User.where(id: current_info[:user_id]).first + end + result.extra_data = { oauth2_basic_user_id: user_details[:id] } + result + end + + def after_create_account(user, auth) + ::PluginStore.set("oauth2_basic", "oauth2_basic_user_#{auth[:extra_data][:oauth2_basic_user_id]}", {user_id: user.id }) + end +end + +auth_provider title_setting: "oauth2_button_title", + enabled_setting: "oauth2_enabled", + authenticator: OAuth2BasicAuthenticator.new('oauth2_basic'), + message: "OAuth2" + +register_css <