Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ PATH
devise
devise-bootstrap-views
devise-i18n
devise-passwordless
devise_invitable
dotenv-rails
dragonfly
Expand Down Expand Up @@ -279,6 +280,9 @@ GEM
devise-i18n (1.13.0)
devise (>= 4.9.0)
rails-i18n
devise-passwordless (1.1.0)
devise
globalid
devise_invitable (2.0.10)
actionmailer (>= 5.0)
devise (>= 4.6)
Expand Down Expand Up @@ -758,6 +762,7 @@ GEM
PLATFORMS
arm64-darwin-22
arm64-darwin-23
arm64-darwin-24
x86_64-linux

DEPENDENCIES
Expand Down
18 changes: 18 additions & 0 deletions app/controllers/folio/users/magic_links_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

class Folio::Users::MagicLinksController < Devise::MagicLinksController
def show
self.resource, _data = Devise::Passwordless::SignedGlobalIDTokenizer.decode(params[:token], resource_class)

if resource
set_flash_message!(:notice, :signed_in)
sign_in(resource_name, resource)
yield resource if block_given?
redirect_to after_sign_in_path_for(resource)
else
redirect_to new_session_path(resource_name), alert: I18n.t("devise.failure.magic_link_invalid")
end
rescue Devise::Passwordless::ExpiredTokenError, Devise::Passwordless::InvalidTokenError
redirect_to new_session_path(resource_name), alert: I18n.t("devise.failure.magic_link_invalid")
end
end
96 changes: 82 additions & 14 deletions app/controllers/folio/users/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,42 +29,63 @@ def new
end

def create
# User is logged in before this happens and it messes with the last_sign_in_at timestamp
warden.logout(resource_name) if warden.authenticated?(resource_name)
reset_session

if invited_user?
render plain: invitation_controller.process("create")
else
respond_to do |format|
format.html do
exception_message = try_to_authenticate_resource
resource = find_and_validate_user

if resource
set_flash_message!(:notice, :signed_in)
sign_in(resource_name, resource)

yield resource if block_given?
respond_with resource, location: after_sign_in_path_for(resource)
if resource.needs_magic_link_verification?
resource.send_magic_link(email: resource.email)

session[:login_confirmation_user_id] = resource.id
session[:login_confirmation_email] = resource.email
return redirect_to main_app.users_auth_login_confirmation_path
else
sign_in(resource_name, resource)
set_flash_message!(:notice, :signed_in)

yield resource if block_given?
respond_with resource, location: after_sign_in_path_for(resource)
end
else
session[:user_email] = params[:user][:email]
redirect_to main_app.new_user_session_path, flash: { alert: exception_message }
redirect_to main_app.new_user_session_path, flash: { alert: @exception_message || I18n.t("folio.devise.sessions.create.invalid") }
end
end

format.json do
store_sign_in_location
exception_message = try_to_authenticate_resource
resource = find_and_validate_user

if resource
sign_in(resource_name, resource)

@force_flash = true
set_flash_message!(:notice, :signed_in)
render json: { data: { url: after_sign_in_path_for(resource) } }, status: 200
if resource.needs_magic_link_verification?(request)
resource.send_magic_link(email: resource.email)

render json: {
data: { redirect: main_app.users_auth_login_confirmation_path }
}, status: 200
else
sign_in(resource_name, resource)

@force_flash = true
set_flash_message!(:notice, :signed_in)
render json: { data: { url: after_sign_in_path_for(resource) } }, status: 200
end
else
exception_message = @exception_message || I18n.t("folio.devise.sessions.create.invalid")
errors = [{ status: 401, title: "Unauthorized", detail: exception_message }]
cell_flash = ActionDispatch::Flash::FlashHash.new
cell_flash[:alert] = exception_message

html = cell("folio/devise/sessions/new",
resource: resource || Folio::User.new(email: params[:user][:email]),
resource: Folio::User.new(email: params[:user][:email]),
resource_name: :user,
modal: true,
flash: cell_flash,
Expand All @@ -77,6 +98,31 @@ def create
end
end

def login_confirmation
@user_id = session[:login_confirmation_user_id]
@email = session[:login_confirmation_email]

redirect_to main_app.new_user_session_path unless @user_id
end

def resend_login_confirmation
user_id = session[:login_confirmation_user_id]

if user_id
user = Folio::User.find_by(id: user_id)

if user
user.send_magic_link(email: user.email)
flash[:notice] = t("folio.users.sessions.login_confirmation.resent")
else
flash[:error] = I18n.t("folio.devise.sessions.create.invalid")
return redirect_to main_app.new_user_session_path
end
end

redirect_to main_app.users_auth_login_confirmation_path
end

def get_failure_flash_message(warden_exception_or_user)
if warden_exception_or_user.is_a?(Hash)
if warden_exception_or_user[:message].nil?
Expand Down Expand Up @@ -157,4 +203,26 @@ def store_sign_in_location
def turnstile_failure_redirect_path
new_user_session_path
end

def find_and_validate_user
@exception_message = nil

return nil unless params[:user]&.dig(:email).present? && params[:user]&.dig(:password).present?

user = resource_class.find_for_database_authentication(email: params[:user][:email], auth_site_id: params[:user][:auth_site_id])

return nil unless user

if user.valid_password?(params[:user][:password])
if user.respond_to?(:active_for_authentication?) && !user.active_for_authentication?
@exception_message = get_failure_flash_message({ message: user.inactive_message })
return nil
end

user
else
@exception_message = get_failure_flash_message({ message: nil })
nil
end
end
end
35 changes: 34 additions & 1 deletion app/mailers/folio/devise_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,44 @@ def omniauth_conflict(authentication, opts = {})
end
end

def magic_link(record, token, remember_me = nil, opts = {})
@token = token
@resource = record
@scope_name = Devise::Mapping.find_scope!(record)

@site = record.auth_site
opts = { site: @site }.merge(opts)

initialize_from_record(record)

with_user_locale(record, locale: opts[:locale]) do |locale|
template_data = {
LOCALE: locale,
VALID_UNTIL_TIME: I18n.l(Time.now + Devise.passwordless_login_within, format: :short),
USER_MAGIC_LINK_URL: scoped_url_method(record,
:magic_link_url,
token: @token,
user: {
email: record.email,
auth_site_id: @site.id,
},
host: @site.env_aware_domain,
locale: locale)
}

email_template_mail template_data,
headers_for(:magic_link, opts).merge(
subject: t("devise.mailer.magic_link.subject"),
mailer: "Devise::Mailer"
)
end
end

private
def scoped_url_method(record, method, *args)
scoped = "user"

method_name = if method.to_s.include?("confirmation")
method_name = if method.to_s.include?("confirmation") || method.to_s.include?("magic_link")
"#{scoped}_#{method}"
else
method.to_s.gsub(/\A([a-z]+)_/, "\\1_#{scoped}_")
Expand Down
6 changes: 6 additions & 0 deletions app/models/folio/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class Folio::User < Folio::ApplicationRecord
rememberable
trackable
invitable
magic_link_authenticatable
]

if Rails.application.config.folio_users_confirmable
Expand Down Expand Up @@ -399,6 +400,11 @@ def born_at_required?
false
end

def needs_magic_link_verification?
return false if superadmin?
last_sign_in_at.nil? || last_sign_in_at < 1.month.ago
end

private
# Override of Devise method to scope authentication by zone.
def self.find_for_authentication(warden_params)
Expand Down
7 changes: 7 additions & 0 deletions app/views/folio/users/sessions/login_confirmation.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.container.justify-content-center
h1 = t('.title')

p = t('.message', email: @email)

.mt-4
= link_to t('.resend_button'), "/users/auth/resend_login_confirmation", method: :post
27 changes: 27 additions & 0 deletions config/initializers/devise.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "devise/passwordless"

module Folio
module DeviseMapping
private
Expand Down Expand Up @@ -343,4 +345,29 @@ def default_controllers(options)
# When using OmniAuth, Devise cannot automatically set OmniAuth path,
# so you need to do it manually. For the users scope, it would be:
# config.omniauth_path_prefix = '/my_engine/users/auth'

# ==> Configuration for :magic_link_authenticatable

# Need to use a custom Devise mailer in order to send magic links.
# If you're already using a custom mailer just have it inherit from
# Devise::Passwordless::Mailer instead of Devise::Mailer
# config.mailer = "Devise::Passwordless::Mailer"

# Which algorithm to use for tokenizing magic links. See README for descriptions
config.passwordless_tokenizer = "SignedGlobalIDTokenizer"

# Time period after a magic login link is sent out that it will be valid for.
config.passwordless_login_within = 10.minutes

# The secret key used to generate passwordless login tokens. The default value
# is nil, which means defer to Devise's `secret_key` config value. Changing this
# key will render invalid all existing passwordless login tokens. You can
# generate your own secret value with e.g. `rake secret`
# config.passwordless_secret_key = nil

# When using the :trackable module and MessageEncryptorTokenizer, set to true to
# consider magic link tokens generated before the user's current sign in time to
# be expired. In other words, each time you sign in, all existing magic links
# will be considered invalid.
# config.passwordless_expire_old_tokens_on_sign_in = false
end
13 changes: 13 additions & 0 deletions config/locales/devise.cs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ cs:
instructions: "pro dokončení %{provider} přihlášení klikněte na odkaz níže:"
button: Dokončit přihlášení
footer: Pokud jste si toto nevyžádali, ignorujte tento e-mail. Váš účet se nezmění dokud neotevřete výše uvedený odkaz.
magic_link:
subject: Ověření přihlášení
failure:
user_locked: Váš účet byl zablokován. Prosím kontaktujte správce.
magic_link_invalid: Neplatný nebo expirovaný přihlašovací odkaz.
passwordless:
not_found_in_database: Uživatel s tímto emailem nebyl nalezen.
magic_link_sent: Přihlašovací odkaz byl odeslán na váš email. Pro přihlášení následujte odkaz v emailu.

folio:
devise:
Expand Down Expand Up @@ -141,3 +147,10 @@ cs:

resolve_conflict:
invalid_conflict_token_flash: Odkaz již není platný. Zkuste se přihlásit znovu.

sessions:
login_confirmation:
title: Ověření přihlášení
message: Prosím potvrďte přihlášení kliknutím na odkaz, který jsme zaslali na zadaný e-mail %{email}. Potvrzující e-mail by měl přijít během několika minut.
resend_button: Znovu zaslat ověřovací email
resent: Ověřovací e-mail byl znovu zaslán.
14 changes: 13 additions & 1 deletion config/locales/devise.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ en:
instructions: "to finish signing in via %{provider} click the link bellow:"
button: Sign in
footer: If you don't want to sign in, ignore this e-mail. Your account will not be updated unless you click the link.

magic_link:
subject: Login confirmation
omniauth_callbacks:
signed_in: Signed in successfully.
signed_up: Signed up successfully.
signed_up_but_locked: Signed up successfully. Unfortunately, you cannot sign in because your account is blocked.
failure:
user_locked: Your account was blocked. Please contact the site administrator.
magic_link_invalid: Invalid or expired login link.
passwordless:
not_found_in_database: Could not find a user for that email address
magic_link_sent: A login link has been sent to your email address. Please follow the link to log in to your account.

folio:
devise:
Expand Down Expand Up @@ -132,3 +137,10 @@ en:

resolve_conflict:
invalid_conflict_token_flash: Link is no longer valid. Try to sign in again.

sessions:
login_confirmation:
title: Login verification
message: Please confirm your login by clicking the link we have sent to the provided email %{email}. The confirmation email should arrive within a few minutes.
resend_button: Resend verification email
resent: Verification email was resent.
Loading