diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index f98808b6e..f9a462b7f 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -38,13 +38,31 @@ module Authentication
def require_tenant
unless ApplicationRecord.current_tenant.present?
- set_current_identity_token
- render "sessions/login_menu"
+ resume_identity
+ redirect_to session_login_menu_url(script_name: nil)
end
end
+ def require_identity
+ resume_identity || request_authentication
+ end
+
def require_authentication
- resume_session || request_authentication
+ if resume_identity
+ resume_session || request_session_for_identity
+ else
+ request_authentication
+ end
+ end
+
+ def request_session_for_identity
+ redirect_to session_login_menu_url(script_name: nil)
+ end
+
+ def resume_identity
+ if identity = find_identity_by_cookie
+ set_current_identity(identity)
+ end
end
def resume_session
@@ -53,11 +71,15 @@ module Authentication
end
end
+ def find_identity_by_cookie
+ Identity.find_signed(cookies.signed[:identity_token]&.dig("id"))
+ end
+
def find_session_by_cookie
Session.find_signed(cookies.signed[:session_token])
end
- def request_authentication(untenanted: false)
+ def request_authentication
if ApplicationRecord.current_tenant.present?
session[:return_to_after_authenticating] = request.url
end
@@ -84,26 +106,37 @@ module Authentication
end
end
- def link_identity(user)
+ def link_identity(user_or_membership)
token_value = cookies.signed[:identity_token]
identity = Identity.find_signed(token_value["id"]) if token_value.present?
- identity = user.set_identity(identity)
- cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax }
+
+ if user_or_membership.is_a?(User)
+ identity = user_or_membership.set_identity(identity)
+ elsif user_or_membership.is_a?(Membership) && identity
+ user_or_membership.update!(identity: identity)
+ end
+
+ set_current_identity(identity)
end
- def set_current_identity_token
- Current.identity_token = Identity::Mock.new(**cookies.signed[:identity_token])
+ def set_current_identity(identity)
+ Current.identity_token = if identity
+ cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax }
+ Identity::Mock.new(**cookies.signed[:identity_token])
+ else
+ nil
+ end
end
def set_current_session(session)
logger.struct " Authorized User##{session.user.id}", authentication: { user: { id: session.user.id } }
Current.session = session
- set_current_identity_token
cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax, path: Account.sole.slug }
end
def terminate_session
Current.session.destroy
cookies.delete(:session_token)
+ cookies.delete(:identity_token)
end
end
diff --git a/app/controllers/sessions/login_menus_controller.rb b/app/controllers/sessions/login_menus_controller.rb
new file mode 100644
index 000000000..f65ab3c5c
--- /dev/null
+++ b/app/controllers/sessions/login_menus_controller.rb
@@ -0,0 +1,25 @@
+class Sessions::LoginMenusController < ApplicationController
+ require_untenanted_access only: :show
+ allow_unauthenticated_access only: :create
+ before_action :require_identity
+ before_action :set_identity
+
+ def show
+ @memberships = @identity.memberships
+ end
+
+ def create
+ membership = @identity.memberships.find(membership_id)
+ start_new_session_for(membership.user)
+ redirect_to after_authentication_url
+ end
+
+ private
+ def set_identity
+ @identity = Current.identity_token.identity
+ end
+
+ def membership_id
+ params.expect :membership_id
+ end
+end
diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb
new file mode 100644
index 000000000..2039f527c
--- /dev/null
+++ b/app/controllers/sessions/magic_links_controller.rb
@@ -0,0 +1,34 @@
+class Sessions::MagicLinksController < ApplicationController
+ require_untenanted_access
+ rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." }
+
+ def show
+ end
+
+ def create
+ membership = MagicLink.consume(code)
+
+ if membership.blank?
+ redirect_to session_magic_link_path, alert: "Try another code."
+ else
+ resume_identity
+
+ if Current.identity_token
+ link_identity(membership)
+ else
+ set_current_identity(membership.identity)
+ end
+
+ if membership.account.setup_pending?
+ redirect_to saas.new_signup_completion_url(script_name: "/#{membership.user_tenant}")
+ else
+ redirect_to session_login_menu_path
+ end
+ end
+ end
+
+ private
+ def code
+ params.expect(:code)
+ end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 16d368c59..5db073e97 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,21 +1,25 @@
class SessionsController < ApplicationController
- require_unauthenticated_access only: %i[ new create ]
+ require_untenanted_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
end
def create
- if user = User.active.authenticate_by(params.permit(:email_address, :password))
- start_new_session_for user
- redirect_to after_authentication_url
- else
- redirect_to new_session_path, alert: "Try another email address or password."
+ if membership = Membership.find_by(email_address: email_address)
+ membership.send_magic_link
end
+
+ redirect_to session_magic_link_path
end
def destroy
terminate_session
redirect_to_logout_url
end
+
+ private
+ def email_address
+ params.expect(:email_address)
+ end
end
diff --git a/app/helpers/login_helper.rb b/app/helpers/login_helper.rb
index 0eed1781b..56627d968 100644
--- a/app/helpers/login_helper.rb
+++ b/app/helpers/login_helper.rb
@@ -1,6 +1,6 @@
module LoginHelper
def login_url
- new_session_path
+ new_session_path(script_name: nil)
end
def logout_url
diff --git a/app/helpers/magic_link_helper.rb b/app/helpers/magic_link_helper.rb
new file mode 100644
index 000000000..d34d71115
--- /dev/null
+++ b/app/helpers/magic_link_helper.rb
@@ -0,0 +1,5 @@
+module MagicLinkHelper
+ def magic_link_code(magic_link)
+ magic_link.code.scan(/.{3}/).join("-")
+ end
+end
diff --git a/app/jobs/magic_link/cleanup_job.rb b/app/jobs/magic_link/cleanup_job.rb
new file mode 100644
index 000000000..eea054fd0
--- /dev/null
+++ b/app/jobs/magic_link/cleanup_job.rb
@@ -0,0 +1,5 @@
+class MagicLink::CleanupJob < ApplicationJob
+ def perform
+ MagicLink.cleanup
+ end
+end
diff --git a/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb
new file mode 100644
index 000000000..11f0f5705
--- /dev/null
+++ b/app/mailers/magic_link_mailer.rb
@@ -0,0 +1,15 @@
+class MagicLinkMailer < ApplicationMailer
+ helper MagicLinkHelper
+
+ def sign_in_instructions(magic_link)
+ @magic_link = magic_link
+ @membership = @magic_link.membership
+
+ mail to: @membership.email_address, subject: "Sign in to Fizzy"
+ end
+
+ private
+ def default_url_options
+ Rails.application.config.action_mailer.default_url_options
+ end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index da2298c88..3e9b0fe19 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -3,6 +3,8 @@ class Account < ApplicationRecord
has_many_attached :uploads
+ enum :setup_status, %w[ pending complete ].index_by(&:itself), prefix: :setup, default: :pending
+
class << self
def create_with_admin_user(account:, owner:)
User.system
diff --git a/app/models/identity.rb b/app/models/identity.rb
index bbf798dd1..74aa3830b 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -1,7 +1,11 @@
class Identity < UntenantedRecord
# This is used to instantiate an Identity-like object from the `identity_token` without hitting
# the untenanted database. It is intended to be used with caching/etagging methods.
- Mock = Struct.new(:id, :updated_at)
+ Mock = Struct.new(:id, :updated_at) do
+ def identity
+ Identity.find_signed(id)
+ end
+ end
has_many :memberships, dependent: :destroy
end
diff --git a/app/models/magic_link.rb b/app/models/magic_link.rb
new file mode 100644
index 000000000..bb06313f7
--- /dev/null
+++ b/app/models/magic_link.rb
@@ -0,0 +1,41 @@
+class MagicLink < UntenantedRecord
+ CODE_LENGTH = 6
+ EXPIRATION_TIME = 15.minutes
+
+ belongs_to :membership
+
+ scope :active, -> { where(expires_at: Time.current...) }
+ scope :stale, -> { where(expires_at: ..Time.current) }
+
+ before_validation :generate_code, on: :create
+ before_validation :set_expiration, on: :create
+
+ validates :code, uniqueness: true, presence: true
+
+ class << self
+ def consume(code)
+ active.find_by(code: Code.sanitize(code))&.consume
+ end
+
+ def cleanup
+ stale.delete_all
+ end
+ end
+
+ def consume
+ destroy
+ membership
+ end
+
+ private
+ def generate_code
+ self.code = loop do
+ candidate = Code.generate(CODE_LENGTH)
+ break candidate unless self.class.exists?(code: candidate)
+ end
+ end
+
+ def set_expiration
+ self.expires_at = EXPIRATION_TIME.from_now
+ end
+end
diff --git a/app/models/magic_link/code.rb b/app/models/magic_link/code.rb
new file mode 100644
index 000000000..6af1619d8
--- /dev/null
+++ b/app/models/magic_link/code.rb
@@ -0,0 +1,31 @@
+module MagicLink::Code
+ CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze
+ CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
+
+ class << self
+ def generate(length)
+ length.times.map { CODE_ALPHABET.sample }.join
+ end
+
+ def sanitize(code)
+ return nil if code.blank?
+
+ normalize_code(code)
+ .then { apply_substitutions(_1) }
+ .then { remove_invalid_characters(_1) }
+ end
+
+ private
+ def normalize_code(code)
+ code.to_s.upcase
+ end
+
+ def apply_substitutions(code)
+ CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) }
+ end
+
+ def remove_invalid_characters(code)
+ code.gsub(/[^#{CODE_ALPHABET.join}]/, "")
+ end
+ end
+end
diff --git a/app/models/membership.rb b/app/models/membership.rb
index 2289443f1..9233de99c 100644
--- a/app/models/membership.rb
+++ b/app/models/membership.rb
@@ -1,6 +1,6 @@
class Membership < UntenantedRecord
belongs_to :identity, touch: true
- has_many :magic_links, dependent: :delete
+ has_many :magic_links, dependent: :delete_all
# I want this to be `belongs_to :user`, but ActiveRecord::Tenanted doesn't yet support
# associations from untenanted to untenanted models.
@@ -11,4 +11,13 @@ class Membership < UntenantedRecord
def user
User.with_tenant(user_tenant) { User.find_by(id: user_id) }
end
+
+ def account
+ Account.with_tenant(user_tenant) { Account.sole }
+ end
+
+ def send_magic_link
+ magic_link = magic_links.create!
+ MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
+ end
end
diff --git a/app/models/user/identifiable.rb b/app/models/user/identifiable.rb
index 0b3f0abbc..c90247b74 100644
--- a/app/models/user/identifiable.rb
+++ b/app/models/user/identifiable.rb
@@ -4,6 +4,8 @@ module User::Identifiable
included do
has_one :membership, ->(user) { where(user_tenant: user.tenant) }
has_one :identity, through: :membership
+
+ scope :with_identity, ->(identity) { where(id: identity.memberships.where(user_tenant: ApplicationRecord.current_tenant).pluck(:user_id)) }
end
def set_identity(token_identity)
diff --git a/app/models/user/role.rb b/app/models/user/role.rb
index aaa4bd5ff..5c89fdd25 100644
--- a/app/models/user/role.rb
+++ b/app/models/user/role.rb
@@ -4,6 +4,7 @@ module User::Role
included do
enum :role, %i[ admin member system ].index_by(&:itself), scopes: false
+ scope :admin, -> { where(role: :admin) }
scope :member, -> { where(role: :member) }
scope :active, -> { where(active: true, role: %i[ admin member ]) }
end
diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb
new file mode 100644
index 000000000..69cb84cf8
--- /dev/null
+++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.html.erb
@@ -0,0 +1,13 @@
+
Sign in to Fizzy
+
+
+
Hit the button below to sign in to Fizzy on this device
+
+
<%= link_to "Sign in to Fizzy", session_magic_link_url(code: @magic_link.code), class: "btn" %>
+
+
+
+
If you're signing in on another device, enter the code below:
+
+ <%= magic_link_code(@magic_link) %>
+
diff --git a/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb
new file mode 100644
index 000000000..129bce961
--- /dev/null
+++ b/app/views/mailers/magic_link_mailer/sign_in_instructions.text.erb
@@ -0,0 +1,8 @@
+Sign in to Fizzy
+<%= "=" * 80 %>
+
+Open this link in your browser to login on this device:
+<%= session_magic_link_url(code: @magic_link.code) %>
+
+If you're signing in on another device, enter the code below:
+<%= magic_link_code(@magic_link) %>
diff --git a/app/views/sessions/login_menus/show.html.erb b/app/views/sessions/login_menus/show.html.erb
new file mode 100644
index 000000000..b66b2490d
--- /dev/null
+++ b/app/views/sessions/login_menus/show.html.erb
@@ -0,0 +1,27 @@
+<% cache [ @identity, @memberships ] do %>
+
+
+ <% if @memberships.present? %>
+
Your Fizzy Accounts
+
+ <% @memberships.each do |membership| %>
+ <%= button_to session_login_menu_url(membership_id: membership, script_name: "/#{membership.user_tenant}"), method: :post, class: "btn center txt-medium" do %>
+ <%= membership.account_name %>
+ :
+ <%= membership.email_address %>
+ <% end %>
+ <% end %>
+
+ <% else %>
+
You don't have any existing Fizzy accounts.
+ <% end %>
+
+
+
+ <%= link_to login_url, class: "btn center btn--reversed" do %>
+ <%= icon_tag "add" %>
+ Add another account
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/sessions/magic_links/create.html.erb b/app/views/sessions/magic_links/create.html.erb
new file mode 100644
index 000000000..c73b393eb
--- /dev/null
+++ b/app/views/sessions/magic_links/create.html.erb
@@ -0,0 +1,22 @@
+<% @page_title = "Log in" %>
+
+
+
Log in
+
+
Pick where you want to log in.
+
+
+ <% @memberships.each do |membership| %>
+ <%= link_to membership.email_address, session_transfer_path(membership.user.transfer_id) %>
+ <% end %>
+
+
+
+<% content_for :footer do %>
+
+ Fizzy™ version 0
+
+<% end %>
diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb
new file mode 100644
index 000000000..a47db86fb
--- /dev/null
+++ b/app/views/sessions/magic_links/show.html.erb
@@ -0,0 +1,29 @@
+<% @page_title = "Magic link" %>
+
+