diff --git a/Gemfile b/Gemfile index a3d13c47e..621026d5a 100644 --- a/Gemfile +++ b/Gemfile @@ -61,6 +61,7 @@ gem "nokogiri", "~> 1.10" # HTML, XML, SAX, and Reader parser gem "octokit", "~> 4.19" # Simple wrapper for the GitHub API gem "oj", "~> 3.10" # JSON parser and object serializer gem "omniauth", "~> 1.9" # A generalized Rack framework for multiple-provider authentication +gem "omniauth-apple", "~> 1.0" # OmniAuth strategy for Sign In with Apple gem "omniauth-facebook", "~> 8.0" # OmniAuth strategy for Facebook gem "omniauth-github", "~> 1.3" # OmniAuth strategy for GitHub gem "omniauth-twitter", "~> 1.4" # OmniAuth strategy for Twitter diff --git a/Gemfile.lock b/Gemfile.lock index efa917f9b..6be2bb3ab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -475,6 +475,9 @@ GEM omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) + omniauth-apple (1.0.1) + jwt + omniauth-oauth2 omniauth-facebook (8.0.0) omniauth-oauth2 (~> 1.2) omniauth-github (1.4.0) @@ -892,6 +895,7 @@ DEPENDENCIES octokit (~> 4.19) oj (~> 3.10) omniauth (~> 1.9) + omniauth-apple (~> 1.0) omniauth-facebook (~> 8.0) omniauth-github (~> 1.3) omniauth-twitter (~> 1.4) diff --git a/app/assets/images/apple-logo.svg b/app/assets/images/apple-logo.svg new file mode 100644 index 000000000..8fd241fed --- /dev/null +++ b/app/assets/images/apple-logo.svg @@ -0,0 +1,55 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/app/controllers/admin/configs_controller.rb b/app/controllers/admin/configs_controller.rb index 7de762afc..c7a16ff71 100644 --- a/app/controllers/admin/configs_controller.rb +++ b/app/controllers/admin/configs_controller.rb @@ -113,6 +113,10 @@ module Admin github_secret facebook_key facebook_secret + apple_client_id + apple_key_id + apple_pem + apple_team_id auth_providers_to_enable invite_only_mode allow_email_password_registration diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index ddd1429ae..e4fa8af38 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -1,6 +1,15 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController include Devise::Controllers::Rememberable + # Rails actionpack only allows POST requests that come with an ORIGIN header + # that matches `request.base_url`, it raises CSRF exception otherwise. + # There is no way to allow specific ORIGIN values in order to securely bypass + # trusted origins (i.e. Apple OAuth) so `protect_from_forgery` is skipped + # ONLY when it's safe to do so (i.e. ORIGIN == 'https://appleid.apple.com'). + # The hardcoded CSRF check can be found in the method `valid_request_origin?`: + # https://github.com/rails/rails/blob/901f12212c488f6edfcf6f8ad3230bce6b3d5792/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L449-L459 + protect_from_forgery unless: -> { safe_apple_callback_request? } + # Each available authentication method needs a related action that will be called # as a callback on successful redirect from the upstream OAuth provider Authentication::Providers.available.each do |provider_name| @@ -31,6 +40,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController super end + def passthru + redirect_to root_path(signin: "true") + end + private def callback_for(provider) @@ -96,4 +109,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def user_persisted_but_username_taken? @user.persisted? && @user.errors_as_sentence.include?("username has already been taken") end + + # We only bypass CSRF checks on Apple callback path & Apple trusted ORIGIN + def safe_apple_callback_request? + trusted_origin = Authentication::Providers::Apple::TRUSTED_CALLBACK_ORIGIN + request.fullpath == Authentication::Providers::Apple::CALLBACK_PATH && + request.headers["ORIGIN"] == trusted_origin + end end diff --git a/app/lib/constants/site_config.rb b/app/lib/constants/site_config.rb index b29d12a0b..66af7779f 100644 --- a/app/lib/constants/site_config.rb +++ b/app/lib/constants/site_config.rb @@ -120,6 +120,26 @@ module Constants "The \"App Secret\" portion of the Basic Settings section of the App page on the Facebook Developer Portal", placeholder: "" }, + apple_client_id: { + description: + "The \"App Bundle\" code for the Authentication Service configured in the Apple Developer Portal", + placeholder: "com.example.app" + }, + apple_team_id: { + description: + "The \"Team ID\" of your Apple Developer Account", + placeholder: "" + }, + apple_key_id: { + description: + "The \"Key ID\" from the Authentication Service configured in the Apple Developer Portal", + placeholder: "" + }, + apple_pem: { + description: + "The \"PEM\" key from the Authentication Service configured in the Apple Developer Portal", + placeholder: "-----BEGIN PRIVATE KEY-----\nMIGTAQrux...QPe8Yb\n-----END PRIVATE KEY-----\\n" + }, favicon_url: { description: "Used as the site favicon", placeholder: IMAGE_PLACEHOLDER diff --git a/app/models/site_config.rb b/app/models/site_config.rb index 7d1f44f4c..1fb0dba8e 100644 --- a/app/models/site_config.rb +++ b/app/models/site_config.rb @@ -38,6 +38,10 @@ class SiteConfig < RailsSettings::Base field :github_secret, type: :string, default: ApplicationConfig["GITHUB_SECRET"] field :facebook_key, type: :string field :facebook_secret, type: :string + field :apple_client_id, type: :string + field :apple_key_id, type: :string + field :apple_pem, type: :string + field :apple_team_id, type: :string # Campaign field :campaign_call_to_action, type: :string, default: "Share your project" @@ -209,4 +213,15 @@ class SiteConfig < RailsSettings::Base def self.dev_to? app_domain == "dev.to" end + + # Apple uses different keys than the usual `PROVIDER_NAME_key` or + # `PROVIDER_NAME_secret` so these will help the generalized authentication + # code to work, i.e. https://github.com/forem/forem/blob/master/app/helpers/authentication_helper.rb#L26-L29 + def self.apple_key + return unless apple_client_id.present? && apple_key_id.present? && + apple_pem.present? && apple_team_id.present? + + "present" + end + singleton_class.__send__(:alias_method, :apple_secret, :apple_key) end diff --git a/app/services/authentication/providers/apple.rb b/app/services/authentication/providers/apple.rb new file mode 100644 index 000000000..58812685b --- /dev/null +++ b/app/services/authentication/providers/apple.rb @@ -0,0 +1,88 @@ +module Authentication + module Providers + # Apple authentication provider, uses omniauth-apple as backend + class Apple < Provider + OFFICIAL_NAME = "Apple".freeze + SETTINGS_URL = "https://appleid.apple.com/account/manage".freeze + TRUSTED_CALLBACK_ORIGIN = "https://appleid.apple.com".freeze + CALLBACK_PATH = "/users/auth/apple/callback".freeze + + def new_user_data + # Apple sends `first_name` and `last_name` as separate fields + name = "#{info.first_name} #{info.last_name}" + timestamp = raw_info.id_info.auth_time + + user_data = { + email: info.email, + apple_created_at: Time.zone.at(timestamp), + apple_username: user_nickname, + name: name + } + + if Rails.env.test? + user_data[:profile_image] = SiteConfig.mascot_image_url + else + user_data[:remote_profile_image_url] = Users::ProfileImageGenerator.call + end + + user_data + end + + def existing_user_data + # Apple by default will send nil `first_name` and `last_name` after + # the first login. To cover the case where a user disconnects their + # Apple authorization, signs in again and then changes their name, + # we update the username only if the name is not nil + apple_username = info.first_name.present? ? info.first_name.downcase : nil + timestamp = raw_info.id_info.auth_time + + data = { apple_created_at: Time.zone.at(timestamp) } + data[:apple_username] = apple_username if apple_username + data + end + + # For Apple we override this method because the `info` payload doesn't + # include `nickname`. On top of not having a username, Apple allows users + # to 'choose' the first_name & last_name sent our way so they are + # definitely not assured to be unique. We still need `user_nickname` to + # always be the same on each login so we use the email hash as suffix to + # avoid collisions with other registrations with the same first_name + def user_nickname + if info.first_name.present? || info.last_name.present? + # We sometimes get `info.first_name` and `info.last_name` + [ + info.first_name&.downcase, + info.last_name&.downcase, + Digest::SHA512.hexdigest(info.email), + ].join("_")[0...25] + else + # This covers an edge case where the Apple Id has already given + # permissions to the forem auth and we don't have anything else + # to work with other than the email + ["user", Digest::SHA512.hexdigest(info.email)].join("_")[0...15] + end + end + + def self.official_name + OFFICIAL_NAME + end + + def self.settings_url + SETTINGS_URL + end + + def self.sign_in_path(**kwargs) + ::Authentication::Paths.sign_in_path( + provider_name, + **kwargs, + ) + end + + protected + + def cleanup_payload(auth_payload) + auth_payload + end + end + end +end diff --git a/app/views/admin/configs/_apple_auth_provider_settings.html.erb b/app/views/admin/configs/_apple_auth_provider_settings.html.erb new file mode 100644 index 000000000..49da9da65 --- /dev/null +++ b/app/views/admin/configs/_apple_auth_provider_settings.html.erb @@ -0,0 +1,77 @@ + +
+
"> + Note: This authentication provider will not be enabled if the key or secret are missing. Please make sure that you enter your key and secret correctly. +
+
+ <%= admin_config_label :apple_client_id %> +

+ <%= Constants::SiteConfig::DETAILS[:apple_client_id][:description] %> +

+ <%= f.text_field :apple_client_id, + class: "crayons-textfield", + value: SiteConfig.apple_client_id, + placeholder: Constants::SiteConfig::DETAILS[:apple_client_id][:placeholder] %> +
+
+ <%= admin_config_label :apple_key_id %> +

+ <%= Constants::SiteConfig::DETAILS[:apple_key_id][:description] %> +

+ <%= f.text_field :apple_key_id, + class: "crayons-textfield", + value: SiteConfig.apple_key_id, + placeholder: Constants::SiteConfig::DETAILS[:apple_key_id][:placeholder] %> +

+
+ <%= admin_config_label :apple_pem %> +

+ <%= Constants::SiteConfig::DETAILS[:apple_pem][:description] %> +

+ <%= f.text_field :apple_pem, + class: "crayons-textfield", + value: SiteConfig.apple_pem, + placeholder: Constants::SiteConfig::DETAILS[:apple_pem][:placeholder] %> +

+
+ <%= admin_config_label :apple_team_id %> +

+ <%= Constants::SiteConfig::DETAILS[:apple_team_id][:description] %> +

+ <%= f.text_field :apple_team_id, + class: "crayons-textfield", + value: SiteConfig.apple_team_id, + placeholder: Constants::SiteConfig::DETAILS[:apple_team_id][:placeholder] %> +

+
+ <% if authentication_enabled_providers.include?(provider) %> + + + <% else %> + + <% end %> +
+
diff --git a/app/views/admin/configs/show.html.erb b/app/views/admin/configs/show.html.erb index c89e437ad..02b4cd864 100644 --- a/app/views/admin/configs/show.html.erb +++ b/app/views/admin/configs/show.html.erb @@ -282,6 +282,7 @@ readonly: true %> <% authentication_available_providers.each do |provider| %> + <% next if provider.provider_name == :apple && !Flipper.enabled?(:apple_auth) %>
<%= inline_svg_tag("#{provider.provider_name}.svg", class: "crayons-icon", aria: true, title: "#{provider.official_name} logo") %> @@ -313,7 +314,8 @@