diff --git a/.gitignore b/.gitignore index 66be49f..66786ca 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ yarn-debug.log* /docker-volumes/* !/docker-volumes/.keep stats.json + +# ignore local settings +config/settings.local.yml +config/settings/*.local.yml +config/environments/*.local.yml diff --git a/Gemfile b/Gemfile index 00a4041..858b9b8 100644 --- a/Gemfile +++ b/Gemfile @@ -110,3 +110,10 @@ gem 'cancancan' # Settings plugin for Rails that makes managing a table of global keys. (https://github.com/huacnlee/rails-settings-cached) gem 'rails-settings-cached', '~> 2.0' + +gem 'omniauth', '~> 1.9' +gem 'omniauth-twitter', '~> 1.4' +gem 'omniauth-github', '~> 1.3' +gem 'omniauth-google-oauth2', '~> 0.8' + +gem 'config' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 3b36038..23e0948 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,7 +85,12 @@ GEM chunky_png (1.3.11) coderay (1.1.2) concurrent-ruby (1.1.5) + config (2.0.0) + activesupport (>= 4.2) + deep_merge (~> 1.2, >= 1.2.1) + dry-schema (~> 1.0) crass (1.0.5) + deep_merge (1.2.1) devise (4.7.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -94,10 +99,43 @@ GEM warden (~> 1.2.3) devise-i18n (1.8.2) devise (>= 4.6) + dry-configurable (0.9.0) + concurrent-ruby (~> 1.0) + dry-core (~> 0.4, >= 0.4.7) + dry-container (0.7.2) + concurrent-ruby (~> 1.0) + dry-configurable (~> 0.1, >= 0.1.3) + dry-core (0.4.9) + concurrent-ruby (~> 1.0) + dry-equalizer (0.3.0) + dry-inflector (0.2.0) + dry-initializer (3.0.2) + dry-logic (1.0.5) + concurrent-ruby (~> 1.0) + dry-core (~> 0.2) + dry-equalizer (~> 0.2) + dry-schema (1.4.1) + concurrent-ruby (~> 1.0) + dry-configurable (~> 0.8, >= 0.8.3) + dry-core (~> 0.4) + dry-equalizer (~> 0.2) + dry-initializer (~> 3.0) + dry-logic (~> 1.0) + dry-types (~> 1.2) + dry-types (1.2.1) + concurrent-ruby (~> 1.0) + dry-container (~> 0.3) + dry-core (~> 0.4, >= 0.4.4) + dry-equalizer (~> 0.2, >= 0.2.2) + dry-inflector (~> 0.1, >= 0.1.2) + dry-logic (~> 1.0, >= 1.0.2) erubi (1.9.0) + faraday (0.17.1) + multipart-post (>= 1.2, < 3) ffi (1.11.1) globalid (0.4.2) activesupport (>= 4.2.0) + hashie (3.6.0) htmlentities (4.3.4) i18n (1.7.0) concurrent-ruby (~> 1.0) @@ -107,6 +145,7 @@ GEM jaro_winkler (1.5.3) jbuilder (2.9.1) activesupport (>= 4.2.0) + jwt (2.2.1) kramdown (1.17.0) listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) @@ -125,9 +164,38 @@ GEM mini_portile2 (2.4.0) minitest (5.12.2) msgpack (1.3.1) + multi_json (1.14.1) + multi_xml (0.6.0) + multipart-post (2.1.1) nio4r (2.5.1) nokogiri (1.10.5) mini_portile2 (~> 2.4.0) + oauth (0.5.4) + oauth2 (1.4.2) + faraday (>= 0.8, < 2.0) + jwt (>= 1.0, < 3.0) + multi_json (~> 1.3) + multi_xml (~> 0.5) + rack (>= 1.2, < 3) + omniauth (1.9.0) + hashie (>= 3.4.6, < 3.7.0) + rack (>= 1.6.2, < 3) + omniauth-github (1.3.0) + omniauth (~> 1.5) + omniauth-oauth2 (>= 1.4.0, < 2.0) + omniauth-google-oauth2 (0.8.0) + jwt (>= 2.0) + omniauth (>= 1.1.1) + omniauth-oauth2 (>= 1.6) + omniauth-oauth (1.1.0) + oauth + omniauth (~> 1.0) + omniauth-oauth2 (1.6.0) + oauth2 (~> 1.1) + omniauth (~> 1.9) + omniauth-twitter (1.4.0) + omniauth-oauth (~> 1.1) + rack orm_adapter (0.5.0) paper_trail (10.3.1) activerecord (>= 4.2) @@ -286,12 +354,17 @@ DEPENDENCIES byebug cancancan capybara (>= 2.15) + config devise devise-i18n identicon initial_avatar jbuilder (~> 2.7) listen (>= 3.0.5, < 3.2) + omniauth (~> 1.9) + omniauth-github (~> 1.3) + omniauth-google-oauth2 (~> 0.8) + omniauth-twitter (~> 1.4) paper_trail (~> 10.3.0) pg (~> 1.1) pry diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb new file mode 100644 index 0000000..cb6ef12 --- /dev/null +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -0,0 +1,25 @@ +# refs: https://qiita.com/mnishiguchi/items/e15bbef61287f84b546e +class OmniauthCallbacksController < Devise::OmniauthCallbacksController + # いくつプロバイダーを利用しようが処理は共通しているので本メソッドをエイリアスとして流用。 + def callback_for_all_providers + unless request.env['omniauth.auth'].present? + flash[:danger] = 'Authentication data was not provided' + redirect_to root_url && return + end + provider = __callee__.to_s + user = OAuthService::GetOAuthUser.call(request.env['omniauth.auth']) + # ユーザーがデータベースに保存されており、且つemailを確認済みであれば、ユーザーをログインする。 + if user.persisted? && user.email_verified? + sign_in_and_redirect user, event: :authentication + set_flash_message(:notice, :success, kind: provider.capitalize) if is_navigational_format? + else + # user.reset_confirmation! + flash[:warning] = t('.need_info_before_signup', default: 'We need your email address before proceeding.') + redirect_to finish_signup_path(user, provider: provider) + end + end + + alias twitter callback_for_all_providers + alias github callback_for_all_providers + alias google_oauth2 callback_for_all_providers +end diff --git a/app/controllers/omniauth_finished_controller.rb b/app/controllers/omniauth_finished_controller.rb new file mode 100644 index 0000000..f226031 --- /dev/null +++ b/app/controllers/omniauth_finished_controller.rb @@ -0,0 +1,25 @@ +# refs: https://qiita.com/mnishiguchi/items/e15bbef61287f84b546e +class OmniauthFinishedController < ApplicationController + skip_authorization_check only: [:finish_signup] + before_action :authenticate_user!, except: :finish_signup + + def finish_signup + @user = User.find(params[:id]) + @provider = params[:provider] + + if (request.post? || request.patch?) && @user.update(user_params) + # @user.send_confirmation_instructions unless @user.confirmed? + sign_in(@user, bypass: true) + redirect_to root_url, notice: t('devise.omniauth_callbacks.success', kind: @provider.capitalize) + end + end + + private + + # user_paramsにアクセスするため。 + def user_params + accessible = [ :username, :email ] + accessible << [ :password, :password_confirmation ] unless params[:user][:password].blank? + params.require(:user).permit(accessible) + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 0000000..6e09245 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,10 @@ +# refs: https://qiita.com/mnishiguchi/items/e15bbef61287f84b546e +class RegistrationsController < Devise::RegistrationsController + + protected + + # Override + def update_resource(resource, params) + resource.update_without_password(params) + end +end diff --git a/app/helpers/o_auth/o_auth_policy.rb b/app/helpers/o_auth/o_auth_policy.rb new file mode 100644 index 0000000..6a2f16d --- /dev/null +++ b/app/helpers/o_auth/o_auth_policy.rb @@ -0,0 +1,55 @@ +# refs: https://qiita.com/mnishiguchi/items/e15bbef61287f84b546e +module OAuthPolicy + class Base + attr_reader :provider, :uid, :name, :nickname, :email, :url, :image_url, + :description, :other, :credentials, :raw_info + end + + class Twitter < OAuthPolicy::Base + def initialize(auth) + @provider = auth['provider'] + @uid = auth['uid'] + @name = auth['info']['name'] + @nickname = auth['info']['nickname'] + @email = '' + @url = auth['info']['urls']['Twitter'] + @image_url = auth['info']['image'] + @description = auth['info']['description'].try(:truncate, 255) + @credentials = auth['credentials'].to_json + @raw_info = auth['extra']['raw_info'].to_json + freeze + end + end + + class Github < OAuthPolicy::Base + def initialize(auth) + @provider = auth['provider'] + @uid = auth['uid'] + @name = auth['info']['name'] + @nickname = '' + @email = '' + @url = 'https://github.com/' + @image_url = auth['info']['image'] + @description = '' + @credentials = auth['credentials'].to_json + @raw_info = auth['extra']['raw_info'].to_json + freeze + end + end + + class GoogleOauth2 < OAuthPolicy::Base + def initialize(auth) + @provider = auth['provider'] + @uid = auth['uid'] + @name = auth['info']['name'] + @nickname = '' + @email = '' + @url = 'https://google.com/' + @image_url = auth['info']['image'] + @description = '' + @credentials = auth['credentials'].to_json + @raw_info = auth['extra']['raw_info'].to_json + freeze + end + end +end diff --git a/app/javascript/stylesheets/sb-admin/_login.scss b/app/javascript/stylesheets/sb-admin/_login.scss index b942f42..b071b3c 100755 --- a/app/javascript/stylesheets/sb-admin/_login.scss +++ b/app/javascript/stylesheets/sb-admin/_login.scss @@ -41,6 +41,22 @@ form.user { } +.omniauth { + .btn-user { + font-size: 0.8rem; + border-radius: 10rem; + padding: 0.75rem 1rem; + } +} + +.btn-twitter { + @include button-variant($brand-twitter, $white); +} + +.btn-github { + @include button-variant($brand-github, $white); +} + .btn-google { @include button-variant($brand-google, $white); } diff --git a/app/javascript/stylesheets/sb-admin/_variables.scss b/app/javascript/stylesheets/sb-admin/_variables.scss index 6474aae..f2ec6fe 100755 --- a/app/javascript/stylesheets/sb-admin/_variables.scss +++ b/app/javascript/stylesheets/sb-admin/_variables.scss @@ -30,6 +30,8 @@ $cyan: #36b9cc !default; // Custom Colors $brand-google: #ea4335; $brand-facebook: #3b5998; +$brand-github: #24292e; +$brand-twitter: rgba(29,161,242,1.00); // Set Contrast Threshold $yiq-contrasted-threshold: 195 !default; diff --git a/app/models/social_profile.rb b/app/models/social_profile.rb new file mode 100644 index 0000000..78de49b --- /dev/null +++ b/app/models/social_profile.rb @@ -0,0 +1,62 @@ +# refs: https://qiita.com/mnishiguchi/items/e15bbef61287f84b546e +# == Schema Information +# +# Table name: social_profiles +# +# id :integer not null, primary key +# user_id :integer +# provider :string +# uid :string +# name :string +# nickname :string +# email :string +# url :string +# image_url :string +# description :string +# others :text +# credentials :text +# raw_info :text +# created_at :datetime not null +# updated_at :datetime not null +# + +class SocialProfile < ApplicationRecord + belongs_to :user + store :others + + validates_uniqueness_of :uid, scope: :provider + + def self.find_for_oauth(auth) + profile = find_or_create_by(uid: auth.uid, provider: auth.provider) + profile.save_oauth_data!(auth) + profile + end + + def save_oauth_data!(auth) + return unless valid_oauth?(auth) + + provider = auth["provider"] + policy = policy(provider, auth) + + self.update_attributes( uid: policy.uid, + name: policy.name, + nickname: policy.nickname, + email: policy.email, + url: policy.url, + image_url: policy.image_url, + description: policy.description, + credentials: policy.credentials, + raw_info: policy.raw_info ) + end + + private + + def policy(provider, auth) + class_name = "#{provider}".classify + "OAuthPolicy::#{class_name}".constantize.new(auth) + end + + def valid_oauth?(auth) + (self.provider.to_s == auth['provider'].to_s) && (self.uid == auth['uid']) + end +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index b4c6e37..52bf6dd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,10 +20,13 @@ class User < ApplicationRecord attr_writer :login + TEMP_EMAIL_PREFIX = 'change@me' + TEMP_EMAIL_REGEX = /\Achange@me/ + # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable + :recoverable, :rememberable, :validatable, :omniauthable validates :username, presence: true, uniqueness: { case_sensitive: false } validates_format_of :username, with: /^[a-zA-Z0-9_-]*$/, :multiline => true @@ -33,6 +36,10 @@ class User < ApplicationRecord has_many :groups, through: :group_users has_many :tickets, foreign_key: :assignee_id, dependent: :nullify has_one_attached :image + has_many :social_profiles, dependent: :destroy + + # emailの登録状況を判定するカスタムvalidatorを使用するためのおまじない。 + validates :email, presence: true, email: true def validate_username if User.where(email: username).exists? @@ -72,4 +79,33 @@ def user_image_url def assign_default_role self.add_role(:developer) if self.roles.blank? end + + ### + # refs: https://qiita.com/mnishiguchi/items/e15bbef61287f84b546e + ### + def social_profile(provider) + social_profiles.select { |sp| sp.provider == provider.to_s }.first + end + + # 本物の email がセットされているか確認。 + def email_verified? + self.email && self.email !~ TEMP_EMAIL_REGEX + end + + # email 確認がされていない状態にする。 + # def reset_confirmation! + # self.update_column(:confirmed_at, nil) + # end + + # Userモデル経由でcurrent_userを参照できるようにする。 + def self.current_user=(user) + # Set current user in Thread. + Thread.current[:current_user] = user + end + + # Userモデル経由でcurrent_userを参照する。 + def self.current_user + # Get current user from Thread. + Thread.current[:current_user] + end end diff --git a/app/services/o_auth_service.rb b/app/services/o_auth_service.rb new file mode 100644 index 0000000..232a6e6 --- /dev/null +++ b/app/services/o_auth_service.rb @@ -0,0 +1,60 @@ +# refs: https://qiita.com/mnishiguchi/items/e15bbef61287f84b546e +module OAuthService + class GetOAuthUser + def self.call(auth) + # 認証データに対応するSocialProfileが存在するか確認し、なければSocialProfileを新規作成。 + # 認証データをSocialProfileオブジェクトにセットし、データベースに保存。 + profile = SocialProfile.find_for_oauth(auth) + # ユーザーを探す。 + # 第1候補:ログイン中のユーザー、第2候補:SocialProfileオブジェクトに紐付けされているユーザー。 + user = current_or_profile_user(profile) + unless user + # 第3候補:認証データにemailが含まれていればそれを元にユーザーを探す。 + user = User.where(email: email).first if verified_email_from_oauth(auth) + # 見つからなければ、ユーザーを新規作成。 + user ||= find_or_create_new_user(auth) + end + associate_user_with_profile!(user, profile) + user + end + + private + + class << self + def current_or_profile_user(profile) + user = User.current_user.presence || profile.user + end + + # 見つからなければ、ユーザーを新規作成。emailは後に確認するので今は仮のものを入れておく。 + # TEMP_EMAIL_PREFIXを手掛かりに後に仮のものかどうかの判別が可能。 + # OmniAuth認証時はパスワード入力は免除するので、ランダムのパスワードを入れておく。 + def find_or_create_new_user(auth) + # Query for user if verified email is provided + email = verified_email_from_oauth(auth) + user = User.where(email: email).first if email + if user.nil? + temp_email = "#{User::TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com" + user = User.new( + username: auth.extra.raw_info.name, + email: email ? email : temp_email, + password: Devise.friendly_token[0, 20] + ) + # email確認メール送信を延期するために一時的にemail確認済みの状態にする。 + # user.skip_confirmation! + # email仮をデータベースに保存するため、validationを一時的に無効化。 + user.save(validate: false) + user + end + end + + def verified_email_from_oauth(auth) + auth.info.email if auth.info.email && (auth.info.verified || auth.info.verified_email) + end + + # ユーザーとSocialProfileオブジェクトを関連づける。 + def associate_user_with_profile!(user, profile) + profile.update!(user_id: user.id) if profile.user != user + end + end + end +end \ No newline at end of file diff --git a/app/validators/email_validator.rb b/app/validators/email_validator.rb new file mode 100644 index 0000000..7e5ef1d --- /dev/null +++ b/app/validators/email_validator.rb @@ -0,0 +1,18 @@ +require 'mail' + +class EmailValidator < ActiveModel::EachValidator + def validate_each(record,attribute,value) + begin + m = Mail::Address.new(value) + # We must check that value contains a domain, the domain has at least + # one '.' and that value is an email address + r = !m.domain.nil? && m.domain.match('\.') && m.address == value + rescue Exception => e + r = false + end + record.errors[attribute] << (options[:message] || 'is invalid') unless r + + # 仮emailから変更しないとエラーになるようにする。 + record.errors[attribute] << 'must be given. Please give us a real one!!!' unless value !~ User::TEMP_EMAIL_REGEX + end +end diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index 4b98345..75495d2 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -1,21 +1,31 @@ -
<%= t('.forgot_your_password_message') %>
-<%= t('.forgot_your_password_message', default: 'Enter the email address used for registration.') %>
+