diff --git a/.env.production.sample b/.env.production.sample index 3f0edd72ff..777336de1d 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -13,7 +13,7 @@ DB_PORT=5432 # Federation # Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation. # LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com. -LOCAL_DOMAIN=example.com +LOCAL_DOMAIN=example.com # Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links) @@ -58,7 +58,7 @@ VAPID_PUBLIC_KEY= # E-mail configuration # Note: Mailgun and SparkPost (https://sparkpo.st/smtp) each have good free tiers # If you want to use an SMTP server without authentication (e.g local Postfix relay) -# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and +# then set SMTP_AUTH_METHOD and SMTP_OPENSSL_VERIFY_MODE to 'none' and # *comment* SMTP_LOGIN and SMTP_PASSWORD (leaving them blank is not enough). SMTP_SERVER=smtp.mailgun.org SMTP_PORT=587 @@ -135,3 +135,43 @@ STREAMING_CLUSTER_NUM=1 # If you use Docker, you may want to assign UID/GID manually. # UID=1000 # GID=1000 + +# Optional CAS authentication (cf. omniauth-cas) : +# CAS_ENABLED=true +# CAS_URL=https://sso.myserver.com/ +# CAS_HOST=sso.myserver.com/ +# CAS_PORT=443 +# CAS_SSL=true +# CAS_VALIDATE_URL= +# CAS_CALLBACK_URL= +# CAS_LOGOUT_URL= +# CAS_LOGIN_URL= +# CAS_UID_FIELD='user' +# CAS_CA_PATH= +# CAS_DISABLE_SSL_VERIFICATION=false +# CAS_UID_KEY='user' +# CAS_NAME_KEY='name' +# CAS_EMAIL_KEY='email' +# CAS_NICKNAME_KEY='nickname' +# CAS_FIRST_NAME_KEY='firstname' +# CAS_LAST_NAME_KEY='lastname' +# CAS_LOCATION_KEY='location' +# CAS_IMAGE_KEY='image' +# CAS_PHONE_KEY='phone' + +# Optional SAML authentication (cf. omniauth-saml) +# SAML_ENABLED=true +# SAML_ACS_URL= +# SAML_ISSUER=http://localhost:3000/auth/auth/saml/metadata +# SAML_IDP_SSO_TARGET_URL=https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO +# SAML_IDP_CERT= +# SAML_IDP_CERT_FINGERPRINT= +# SAML_NAME_IDENTIFIER_FORMAT= +# SAML_CERT= +# SAML_PRIVATE_KEY= +# SAML_SECURITY_WANT_ASSERTION_SIGNED=true +# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true +# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1" +# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6" +# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.5.4.42" +# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1" diff --git a/Gemfile b/Gemfile index f3844aca6f..5b6ae707db 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,9 @@ gem 'devise', '~> 4.4' gem 'devise-two-factor', '~> 3.0' gem 'devise_pam_authenticatable2', '~> 8.0' +gem 'omniauth-cas', '~> 1.1', install_if: -> { ENV['CAS_ENABLED'] == 'true' } +gem 'omniauth-saml', '~> 1.8', install_if: -> { ENV['SAML_ENABLED'] == 'true' } +gem 'omniauth', '~> 1.2' gem 'doorkeeper', '~> 4.2' gem 'fast_blank', '~> 1.0' diff --git a/Gemfile.lock b/Gemfile.lock index 7da9bfe394..c357bfbd1c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -201,6 +201,7 @@ GEM hamster (3.0.0) concurrent-ruby (~> 1.0) hashdiff (0.3.7) + hashie (3.5.7) highline (1.7.10) hiredis (0.6.1) hkdf (0.3.0) @@ -304,6 +305,16 @@ GEM sidekiq (>= 3.5.0) statsd-ruby (~> 1.2.0) oj (3.3.10) + omniauth (1.8.1) + hashie (>= 3.4.6, < 3.6.0) + rack (>= 1.6.2, < 3) + omniauth-cas (1.1.1) + addressable (~> 2.3) + nokogiri (~> 1.5) + omniauth (~> 1.2) + omniauth-saml (1.9.0) + omniauth (~> 1.3, >= 1.3.2) + ruby-saml (~> 1.4, >= 1.4.3) orm_adapter (0.5.0) ostatus2 (2.0.3) addressable (~> 2.5) @@ -455,6 +466,8 @@ GEM unicode-display_width (~> 1.0, >= 1.0.1) ruby-oembed (0.12.0) ruby-progressbar (1.9.0) + ruby-saml (1.6.1) + nokogiri (>= 1.5.10) rufus-scheduler (3.4.2) et-orbi (~> 1.0) safe_yaml (1.0.4) @@ -606,6 +619,9 @@ DEPENDENCIES nokogiri (~> 1.8) nsa (~> 0.2) oj (~> 3.3) + omniauth (~> 1.2) + omniauth-cas (~> 1.1) + omniauth-saml (~> 1.8) ostatus2 (~> 2.0) ox (~> 2.8) paperclip (~> 5.1) diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 2fdb281f40..a240425cd8 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -2,4 +2,28 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' + + before_action :set_user, only: [:finish_signup] + + # GET/PATCH /users/:id/finish_signup + def finish_signup + return unless request.patch? && params[:user] + if @user.update(user_params) + @user.skip_reconfirmation! + sign_in(@user, bypass: true) + redirect_to root_path, notice: I18n.t('devise.confirmations.send_instructions') + else + @show_errors = true + end + end + + private + + def set_user + @user = current_user + end + + def user_params + params.require(:user).permit(:email) + end end diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb new file mode 100644 index 0000000000..bbf63bed30 --- /dev/null +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController + skip_before_action :verify_authenticity_token + + def self.provides_callback_for(provider) + provider_id = provider.to_s.chomp '_oauth2' + + define_method provider do + @user = User.find_for_oauth(request.env['omniauth.auth'], current_user) + + if @user.persisted? + sign_in_and_redirect @user, event: :authentication + set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format? + else + session["devise.#{provider}_data"] = request.env['omniauth.auth'] + redirect_to new_user_registration_url + end + end + end + + Devise.omniauth_configs.each_key do |provider| + provides_callback_for provider + end + + def after_sign_in_path_for(resource) + if resource.email_verified? + root_path + else + finish_signup_path + end + end +end diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 2bef53cff7..dec7d22843 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -568,3 +568,21 @@ code { margin-bottom: 4px; } } + +.alternative-login { + margin-top: 20px; + margin-bottom: 20px; + + h4 { + font-size: 16px; + color: $ui-base-lighter-color; + text-align: center; + margin-bottom: 20px; + border: 0; + padding: 0; + } + + .button { + display: block; + } +} diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb new file mode 100644 index 0000000000..a3d55108d2 --- /dev/null +++ b/app/models/concerns/omniauthable.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Omniauthable + extend ActiveSupport::Concern + + TEMP_EMAIL_PREFIX = 'change@me' + TEMP_EMAIL_REGEX = /\Achange@me/ + + included do + def omniauth_providers + Devise.omniauth_configs.keys + end + + def email_verified? + email && email !~ TEMP_EMAIL_REGEX + end + end + + class_methods do + def find_for_oauth(auth, signed_in_resource = nil) + # EOLE-SSO Patch + auth.uid = (auth.uid[0][:uid] || auth.uid[0][:user]) if auth.uid.is_a? Hashie::Array + identity = Identity.find_for_oauth(auth) + + # If a signed_in_resource is provided it always overrides the existing user + # to prevent the identity being locked with accidentally created accounts. + # Note that this may leave zombie accounts (with no associated identity) which + # can be cleaned up at a later date. + user = signed_in_resource ? signed_in_resource : identity.user + user = create_for_oauth(auth) if user.nil? + + if identity.user.nil? + identity.user = user + identity.save! + end + + user + end + + def create_for_oauth(auth) + # Check if the user exists with provided email if the provider gives us a + # verified email. If no verified email was provided or the user already + # exists, we assign a temporary email and ask the user to verify it on + # the next step via Auth::ConfirmationsController.finish_signup + + user = User.new(user_params_from_auth(auth)) + user.account.avatar_remote_url = auth.info.image if auth.info.image =~ /\A#{URI.regexp(%w(http https))}\z/ + user.skip_confirmation! + user.save! + user + end + + private + + def user_params_from_auth(auth) + email_is_verified = auth.info.email && (auth.info.verified || auth.info.verified_email) + email = auth.info.email if email_is_verified && !User.exists?(email: auth.info.email) + + { + email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com", + password: Devise.friendly_token[0, 20], + account_attributes: { + username: ensure_unique_username(auth.uid), + display_name: [auth.info.first_name, auth.info.last_name].join(' '), + }, + } + end + + def ensure_unique_username(starting_username) + username = starting_username + i = 0 + + while Account.exists?(username: username) + i += 1 + username = "#{starting_username}_#{i}" + end + + username + end + end +end diff --git a/app/models/identity.rb b/app/models/identity.rb new file mode 100644 index 0000000000..a5e0c09ec1 --- /dev/null +++ b/app/models/identity.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: identities +# +# id :integer not null, primary key +# user_id :integer +# provider :string default(""), not null +# uid :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Identity < ApplicationRecord + belongs_to :user, dependent: :destroy + validates :uid, presence: true, uniqueness: { scope: :provider } + validates :provider, presence: true + + def self.find_for_oauth(auth) + find_or_create_by(uid: auth.uid, provider: auth.provider) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index fa4ebfc717..fba4784538 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -39,6 +39,7 @@ class User < ApplicationRecord include Settings::Extend + include Omniauthable ACTIVE_DURATION = 14.days @@ -52,6 +53,7 @@ class User < ApplicationRecord :confirmable devise :pam_authenticatable + devise :omniauthable belongs_to :account, inverse_of: :user belongs_to :invite, counter_cache: :uses, optional: true diff --git a/app/views/auth/confirmations/finish_signup.html.haml b/app/views/auth/confirmations/finish_signup.html.haml new file mode 100644 index 0000000000..4b5161d6b0 --- /dev/null +++ b/app/views/auth/confirmations/finish_signup.html.haml @@ -0,0 +1,14 @@ +- content_for :page_title do + = t('auth.confirm_email') + += simple_form_for(current_user, as: 'user', url: finish_signup_path, html: { role: 'form'}) do |f| + - if @show_errors && current_user.errors.any? + #error_explanation + - current_user.errors.full_messages.each do |msg| + = msg + %br + + = f.input :email + + .actions + = f.submit t('auth.confirm_email'), class: 'button' diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml index 3edb0d2d4f..1c3a0b6b4a 100644 --- a/app/views/auth/sessions/new.html.haml +++ b/app/views/auth/sessions/new.html.haml @@ -14,4 +14,13 @@ .actions = f.button :button, t('auth.login'), type: :submit +- if devise_mapping.omniauthable? and resource_class.omniauth_providers.any? + .simple_form.alternative-login + %h4= t('auth.or_log_in_with') + + .actions + - resource_class.omniauth_providers.each do |provider| + = link_to omniauth_authorize_path(resource_name, provider), class: "button button-#{provider}" do + = t("auth.providers.#{provider}", default: provider.to_s.chomp("_oauth2").capitalize) + .form-footer= render 'auth/shared/links' diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 014055804e..bcd816d304 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -46,6 +46,7 @@ ignore_missing: - 'terms.body_html' - 'application_mailer.salutation' - 'errors.500' + - 'auth.providers.*' ignore_unused: - 'activemodel.errors.*' diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb new file mode 100644 index 0000000000..97f32c0a42 --- /dev/null +++ b/config/initializers/omniauth.rb @@ -0,0 +1,59 @@ +Rails.application.config.middleware.use OmniAuth::Builder do + # Vanilla omniauth stategies +end + +Devise.setup do |config| + # Devise omniauth strategies + + # CAS strategy + if ENV['CAS_ENABLED'] == 'true' + cas_options = {} + cas_options[:url] = ENV['CAS_URL'] if ENV['CAS_URL'] + cas_options[:host] = ENV['CAS_HOST'] if ENV['CAS_HOST'] + cas_options[:port] = ENV['CAS_PORT'] if ENV['CAS_PORT'] + cas_options[:ssl] = ENV['CAS_SSL'] == 'true' if ENV['CAS_SSL'] + cas_options[:validate_url] = ENV['CAS_VALIDATE_URL'] if ENV['CAS_VALIDATE_URL'] + cas_options[:callback_url] = ENV['CAS_CALLBACK_URL'] if ENV['CAS_CALLBACK_URL'] + cas_options[:logout_url] = ENV['CAS_LOGOUT_URL'] if ENV['CAS_LOGOUT_URL'] + cas_options[:login_url] = ENV['CAS_LOGIN_URL'] if ENV['CAS_LOGIN_URL'] + cas_options[:uid_field] = ENV['CAS_UID_FIELD'] || 'user' if ENV['CAS_UID_FIELD'] + cas_options[:ca_path] = ENV['CAS_CA_PATH'] if ENV['CAS_CA_PATH'] + cas_options[:disable_ssl_verification] = ENV['CAS_DISABLE_SSL_VERIFICATION'] == 'true' if ENV['CAS_DISABLE_SSL_VERIFICATION'] + cas_options[:uid_key] = ENV['CAS_UID_KEY'] || 'user' + cas_options[:name_key] = ENV['CAS_NAME_KEY'] || 'name' + cas_options[:email_key] = ENV['CAS_EMAIL_KEY'] || 'email' + cas_options[:nickname_key] = ENV['CAS_NICKNAME_KEY'] || 'nickname' + cas_options[:first_name_key] = ENV['CAS_FIRST_NAME_KEY'] || 'firstname' + cas_options[:last_name_key] = ENV['CAS_LAST_NAME_KEY'] || 'lastname' + cas_options[:location_key] = ENV['CAS_LOCATION_KEY'] || 'location' + cas_options[:image_key] = ENV['CAS_IMAGE_KEY'] || 'image' + cas_options[:phone_key] = ENV['CAS_PHONE_KEY'] || 'phone' + config.omniauth :cas, cas_options + end + + # SAML strategy + if ENV['SAML_ENABLED'] == 'true' + saml_options = {} + saml_options[:assertion_consumer_service_url] = ENV['SAML_ACS_URL'] if ENV['SAML_ACS_URL'] + saml_options[:issuer] = ENV['SAML_ISSUER'] if ENV['SAML_ISSUER'] + saml_options[:idp_sso_target_url] = ENV['SAML_IDP_SSO_TARGET_URL'] if ENV['SAML_IDP_SSO_TARGET_URL'] + saml_options[:idp_sso_target_url_runtime_params] = ENV['SAML_IDP_SSO_TARGET_PARAMS'] if ENV['SAML_IDP_SSO_TARGET_PARAMS'] # FIXME: Should be parsable Hash + saml_options[:idp_cert] = ENV['SAML_IDP_CERT'] if ENV['SAML_IDP_CERT'] + saml_options[:idp_cert_fingerprint] = ENV['SAML_IDP_CERT_FINGERPRINT'] if ENV['SAML_IDP_CERT_FINGERPRINT'] + saml_options[:idp_cert_fingerprint_validator] = ENV['SAML_IDP_CERT_FINGERPRINT_VALIDATOR'] if ENV['SAML_IDP_CERT_FINGERPRINT_VALIDATOR'] # FIXME: Should be Lambda { |fingerprint| } + saml_options[:name_identifier_format] = ENV['SAML_NAME_IDENTIFIER_FORMAT'] if ENV['SAML_NAME_IDENTIFIER_FORMAT'] + saml_options[:request_attributes] = {} + saml_options[:certificate] = ENV['SAML_CERT'] if ENV['SAML_CERT'] + saml_options[:private_key] = ENV['SAML_PRIVATE_KEY'] if ENV['SAML_PRIVATE_KEY'] + saml_options[:security] = {} + saml_options[:security][:want_assertions_signed] = ENV['SAML_SECURITY_WANT_ASSERTION_SIGNED'] == 'true' + saml_options[:security][:want_assertions_encrypted] = ENV['SAML_SECURITY_WANT_ASSERTION_ENCRYPTED'] == 'true' + saml_options[:attribute_statements] = {} + saml_options[:attribute_statements][:uid] = [ENV['SAML_ATTRIBUTES_STATEMENTS_UID']] if ENV['SAML_ATTRIBUTES_STATEMENTS_UID'] + saml_options[:attribute_statements][:email] = [ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL']] if ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL'] + saml_options[:attribute_statements][:full_name] = [ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME']] if ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME'] + saml_options[:uid_attribute] = ENV['SAML_UID_ATTRIBUTE'] if ENV['SAML_UID_ATTRIBUTE'] + config.omniauth :saml, saml_options + end + +end diff --git a/config/locales/en.yml b/config/locales/en.yml index cd6138ff23..6805a6e877 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -355,6 +355,7 @@ en: auth: agreement_html: By signing up you agree to follow the rules of the instance and our terms of service. change_password: Security + confirm_email: Confirm email delete_account: Delete account delete_account_html: If you wish to delete your account, you can proceed here. You will be asked for confirmation. didnt_get_confirmation: Didn't receive confirmation instructions? @@ -364,6 +365,10 @@ en: logout: Logout migrate_account: Move to a different account migrate_account_html: If you wish to redirect this account to a different one, you can configure it here. + or_log_in_with: Or log in with + providers: + cas: CAS + saml: SAML register: Sign up resend_confirmation: Resend confirmation instructions reset_password: Reset password diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 3ad535f285..f0fc07f7a1 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -355,6 +355,7 @@ fr: auth: agreement_html: En vous inscrivant, vous souscrivez aux règles de l’instance et à nos conditions d’utilisation. change_password: Sécurité + confirm_email: Confirmer mon adresse mail delete_account: Supprimer le compte delete_account_html: Si vous désirez supprimer votre compte, vous pouvez cliquer ici. Il vous sera demandé de confirmer cette action. didnt_get_confirmation: Vous n’avez pas reçu les consignes de confirmation ? @@ -364,6 +365,7 @@ fr: logout: Se déconnecter migrate_account: Déplacer vers un compte différent migrate_account_html: Si vous voulez rediriger ce compte vers un autre, vous pouvez le configurer ici. + or_log_in_with: Ou authentifiez-vous avec register: S’inscrire resend_confirmation: Envoyer à nouveau les consignes de confirmation reset_password: Réinitialiser le mot de passe diff --git a/config/routes.rb b/config/routes.rb index 80a2c6d13d..34f33fa958 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,9 +24,11 @@ Rails.application.routes.draw do devise_scope :user do get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite + match '/auth/finish_signup' => 'auth/confirmations#finish_signup', via: [:get, :patch], as: :finish_signup end devise_for :users, path: 'auth', controllers: { + omniauth_callbacks: 'auth/omniauth_callbacks', sessions: 'auth/sessions', registrations: 'auth/registrations', passwords: 'auth/passwords', diff --git a/db/migrate/20180204034416_create_identities.rb b/db/migrate/20180204034416_create_identities.rb new file mode 100644 index 0000000000..f6f5da910b --- /dev/null +++ b/db/migrate/20180204034416_create_identities.rb @@ -0,0 +1,11 @@ +class CreateIdentities < ActiveRecord::Migration[5.0] + def change + create_table :identities do |t| + t.references :user, foreign_key: { on_delete: :cascade } + t.string :provider, null: false, default: '' + t.string :uid, null: false, default: '' + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index a411de20ff..02e84cbd19 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180109143959) do +ActiveRecord::Schema.define(version: 20180204034416) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -173,6 +173,15 @@ ActiveRecord::Schema.define(version: 20180109143959) do t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true end + create_table "identities", id: :serial, force: :cascade do |t| + t.integer "user_id" + t.string "provider", default: "", null: false + t.string "uid", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_identities_on_user_id" + end + create_table "imports", force: :cascade do |t| t.integer "type", null: false t.boolean "approved", default: false, null: false @@ -526,6 +535,7 @@ ActiveRecord::Schema.define(version: 20180109143959) do add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade + add_foreign_key "identities", "users", on_delete: :cascade add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade add_foreign_key "invites", "users", on_delete: :cascade add_foreign_key "list_accounts", "accounts", on_delete: :cascade diff --git a/spec/fabricators/identity_fabricator.rb b/spec/fabricators/identity_fabricator.rb new file mode 100644 index 0000000000..bc832df9f7 --- /dev/null +++ b/spec/fabricators/identity_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:identity) do + user nil + provider "MyString" + uid "MyString" +end diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb new file mode 100644 index 0000000000..53f3554102 --- /dev/null +++ b/spec/models/identity_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Identity, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end