From be96c4478e05554b2bd6caf4bd51445607fa797e Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 30 Aug 2019 10:54:19 +0100 Subject: [PATCH] FEATURE: Login with Discord (#8053) This migrates the functionality of discourse-plugin-discord-auth into core. The plugin will automatically disable itself when core is updated: https://github.com/discourse/discourse-plugin-discord-auth/commit/fd0867844d21d5bd3d698efaa6fad6d81e3d37e1?w=1 For setup instructions, visit https://meta.discourse.org/t/configuring-discord-login-for-discourse/127129 --- .../common/components/buttons.scss | 6 ++ .../common/foundation/variables.scss | 1 + config/locales/client.en.yml | 4 + config/locales/server.en.yml | 8 ++ config/site_settings.yml | 10 +++ lib/auth.rb | 1 + lib/auth/discord_authenticator.rb | 73 +++++++++++++++++++ lib/discourse.rb | 3 +- lib/svg_sprite/svg_sprite.rb | 1 + .../auth/discord_authenticator_spec.rb | 60 +++++++++++++++ 10 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 lib/auth/discord_authenticator.rb create mode 100644 spec/components/auth/discord_authenticator_spec.rb diff --git a/app/assets/stylesheets/common/components/buttons.scss b/app/assets/stylesheets/common/components/buttons.scss index f72a14d3397..de22978dadf 100644 --- a/app/assets/stylesheets/common/components/buttons.scss +++ b/app/assets/stylesheets/common/components/buttons.scss @@ -231,6 +231,12 @@ background: lighten($github, 20%); } } + &.discord { + background: $discord; + &:hover { + background: darken($discord, 10%); + } + } } // Button Sizes diff --git a/app/assets/stylesheets/common/foundation/variables.scss b/app/assets/stylesheets/common/foundation/variables.scss index 644d3ee5995..0895022002e 100644 --- a/app/assets/stylesheets/common/foundation/variables.scss +++ b/app/assets/stylesheets/common/foundation/variables.scss @@ -23,6 +23,7 @@ $facebook: #4267b2 !default; $cas: #70ba61 !default; $twitter: #1da1f2 !default; $github: #100e0f !default; +$discord: #7289da !default; // Badge color variables // -------------------------------------------------- diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index c2dbb4c4d49..e5b295088c9 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1469,6 +1469,10 @@ en: name: "GitHub" title: "with GitHub" message: "Authenticating with GitHub (make sure pop up blockers are not enabled)" + discord: + name: "Discord" + title: "with Discord" + message: "Authenticating with Discord" invites: accept_title: "Invitation" welcome_to: "Welcome to %{site_name}!" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 7030ded0f87..caf705c4859 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1551,6 +1551,11 @@ en: github_client_id: "Client id for Github authentication, registered at https://github.com/settings/developers" github_client_secret: "Client secret for Github authentication, registered at https://github.com/settings/developers" + enable_discord_logins: 'Allow users to authenticate using Discord?' + discord_client_id: 'Discord Client ID (need one? visit the Discord developer portal)' + discord_secret: 'Discord Secret Key' + discord_trusted_guilds: 'Only allow members of these Discord guilds to login via Discord. Use the numeric ID for the guild. For more information, check the instructions here. Leave blank to allow any guild.' + readonly_mode_during_backup: "Enable read only mode while taking a backup" enable_backups: "Allow administrators to create backups of the forum" allow_restore: "Allow restore, which can replace ALL site data! Leave false unless you plan to restore a backup" @@ -4569,3 +4574,6 @@ en: email_style: html_missing_placeholder: "The html template must include %{placeholder}" + + discord: + not_in_allowed_guild: 'Authentication failed. You are not a member of a permitted Discord guild.' \ No newline at end of file diff --git a/config/site_settings.yml b/config/site_settings.yml index 1129cc0a836..67683bccef5 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -390,6 +390,16 @@ login: default: "" regex: "^[a-f0-9]+$" secret: true + enable_discord_logins: + default: false + discord_client_id: + default: '' + discord_secret: + default: '' + secret: true + discord_trusted_guilds: + default: '' + type: list enable_sso: client: true default: false diff --git a/lib/auth.rb b/lib/auth.rb index 2c4594de9d5..74e924bf46a 100644 --- a/lib/auth.rb +++ b/lib/auth.rb @@ -12,3 +12,4 @@ require_dependency 'auth/github_authenticator' require_dependency 'auth/twitter_authenticator' require_dependency 'auth/google_oauth2_authenticator' require_dependency 'auth/instagram_authenticator' +require_dependency 'auth/discord_authenticator' diff --git a/lib/auth/discord_authenticator.rb b/lib/auth/discord_authenticator.rb new file mode 100644 index 00000000000..0d54f24bc06 --- /dev/null +++ b/lib/auth/discord_authenticator.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class Auth::DiscordAuthenticator < Auth::ManagedAuthenticator + class DiscordStrategy < OmniAuth::Strategies::OAuth2 + option :name, 'discord' + option :scope, 'identify email guilds' + + option :client_options, + site: 'https://discordapp.com/api', + authorize_url: 'oauth2/authorize', + token_url: 'oauth2/token' + + option :authorize_options, %i[scope permissions] + + uid { raw_info['id'] } + + info do + { + name: raw_info['username'], + email: raw_info['verified'] ? raw_info['email'] : nil, + image: "https://cdn.discordapp.com/avatars/#{raw_info['id']}/#{raw_info['avatar']}" + } + end + + extra do + { + 'raw_info' => raw_info + } + end + + def raw_info + @raw_info ||= access_token.get('users/@me').parsed. + merge(guilds: access_token.get('users/@me/guilds').parsed) + end + + def callback_url + full_host + script_name + callback_path + end + end + + def name + 'discord' + end + + def enabled? + SiteSetting.enable_discord_logins? + end + + def register_middleware(omniauth) + omniauth.provider DiscordStrategy, + setup: lambda { |env| + strategy = env["omniauth.strategy"] + strategy.options[:client_id] = SiteSetting.discord_client_id + strategy.options[:client_secret] = SiteSetting.discord_secret + } + end + + def after_authenticate(auth_token, existing_account: nil) + allowed_guild_ids = SiteSetting.discord_trusted_guilds.split("|") + + if allowed_guild_ids.length > 0 + user_guild_ids = auth_token.extra[:raw_info][:guilds].map { |g| g['id'] } + if (user_guild_ids & allowed_guild_ids).empty? # User is not in any allowed guilds + return Auth::Result.new.tap do |auth_result| + auth_result.failed = true + auth_result.failed_reason = I18n.t("discord.not_in_allowed_guild") + end + end + end + + super + end +end diff --git a/lib/discourse.rb b/lib/discourse.rb index 5a13c19d356..d44f028c71c 100644 --- a/lib/discourse.rb +++ b/lib/discourse.rb @@ -254,7 +254,8 @@ module Discourse Auth::AuthProvider.new(authenticator: Auth::GoogleOAuth2Authenticator.new, frame_width: 850, frame_height: 500), # Custom icon implemented in client Auth::AuthProvider.new(authenticator: Auth::GithubAuthenticator.new, icon: "fab-github"), Auth::AuthProvider.new(authenticator: Auth::TwitterAuthenticator.new, icon: "fab-twitter"), - Auth::AuthProvider.new(authenticator: Auth::InstagramAuthenticator.new, icon: "fab-instagram") + Auth::AuthProvider.new(authenticator: Auth::InstagramAuthenticator.new, icon: "fab-instagram"), + Auth::AuthProvider.new(authenticator: Auth::DiscordAuthenticator.new, icon: "fab-discord", full_screen_login: true) ] def self.auth_providers diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index 413c0cedda2..6bdc6d26530 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -71,6 +71,7 @@ module SvgSprite "fab-android", "fab-apple", "fab-chrome", + "fab-discord", "fab-discourse", "fab-facebook-square", "fab-facebook", diff --git a/spec/components/auth/discord_authenticator_spec.rb b/spec/components/auth/discord_authenticator_spec.rb new file mode 100644 index 00000000000..3a9ad73a1e9 --- /dev/null +++ b/spec/components/auth/discord_authenticator_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Auth::DiscordAuthenticator do + let(:hash) { + OmniAuth::AuthHash.new( + provider: "facebook", + extra: { + raw_info: { + id: "100", + username: "bobbob", + guilds: [ + { + "id": "80351110224678912", + "name": "1337 Krew", + "icon": "8342729096ea3675442027381ff50dfe", + "owner": true, + "permissions": 36953089 + } + ] + } + }, + info: { + email: "bob@bob.com", + name: "bobbob" + }, + uid: "100" + ) + } + + let(:authenticator) { described_class.new } + + context 'after_authenticate' do + it 'works normally' do + result = authenticator.after_authenticate(hash) + expect(result.user).to eq(nil) + expect(result.failed).to eq(false) + expect(result.name).to eq("bobbob") + expect(result.email).to eq("bob@bob.com") + end + + it 'denies access when guilds are restricted' do + SiteSetting.discord_trusted_guilds = ["someguildid", "someotherguildid"].join("|") + result = authenticator.after_authenticate(hash) + expect(result.user).to eq(nil) + expect(result.failed).to eq(true) + expect(result.failed_reason).to eq(I18n.t("discord.not_in_allowed_guild")) + end + + it 'allows access when in an allowed guild' do + SiteSetting.discord_trusted_guilds = ["80351110224678912", "anothertrustedguild"].join("|") + result = authenticator.after_authenticate(hash) + expect(result.user).to eq(nil) + expect(result.failed).to eq(false) + expect(result.name).to eq("bobbob") + expect(result.email).to eq("bob@bob.com") + end + end +end