diff --git a/app/controllers/magic_links_controller.rb b/app/controllers/magic_links_controller.rb new file mode 100644 index 0000000..dfa0ff9 --- /dev/null +++ b/app/controllers/magic_links_controller.rb @@ -0,0 +1,42 @@ +class MagicLinksController < ApplicationController + allow_unauthenticated_access only: %i[ new create show ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_magic_link_path, alert: "Try again later." } + + def new + end + + def create + # Honeypot: real users never fill this hidden field, bots do + if params[:nickname].present? + redirect_to new_session_path, notice: generic_notice + return + end + + user = User.find_by(email_address: params[:email_address]) + + # Only send to members of this organization + if user&.member_of?(Current.organization) + MagicLinkMailer.sign_in_link(user, Current.organization).deliver_later + end + + redirect_to new_session_path, notice: generic_notice + end + + def show + user = User.find_by_token_for(:magic_link, params[:token]) + + if user&.member_of?(Current.organization) + user.confirm! unless user.confirmed? + start_new_session_for user + redirect_to after_authentication_url, notice: "You're signed in." + else + redirect_to new_session_path, alert: "Sign-in link is invalid or has expired." + end + end + + private + + def generic_notice + "If an account exists for that email, we've sent a sign-in link." + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 0fa74e7..e5c7ca1 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -10,7 +10,7 @@ def create # Honeypot: real users never fill this hidden field, bots do. Silently # pretend success so spammers can't tell their submission was rejected. if params[:nickname].present? - redirect_to new_session_path, notice: "Check your email to confirm your account before signing in." + redirect_to new_session_path, notice: confirmation_notice return end @@ -29,8 +29,14 @@ def create Current.organization.organization_memberships.create!(user: @user) end - RegistrationMailer.confirmation(@user, Current.organization).deliver_later - redirect_to new_session_path, notice: "Check your email to confirm your account before signing in." + if @user.password_set? + RegistrationMailer.confirmation(@user, Current.organization).deliver_later + redirect_to new_session_path, notice: confirmation_notice + else + # Passwordless signup: send a magic link that confirms and signs them in. + MagicLinkMailer.sign_in_link(@user, Current.organization).deliver_later + redirect_to new_session_path, notice: "Check your email for a sign-in link." + end else render :new, status: :unprocessable_entity end @@ -38,6 +44,10 @@ def create private + def confirmation_notice + "Check your email to confirm your account before signing in." + end + def registration_params params.require(:user).permit(:email_address, :password, :password_confirmation) end diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb new file mode 100644 index 0000000..0ea53fc --- /dev/null +++ b/app/controllers/users/passwords_controller.rb @@ -0,0 +1,29 @@ +class Users::PasswordsController < ApplicationController + def show + @user = Current.user + end + + def update + @user = Current.user + + # Users who already have a password must confirm it before changing it. + # Passwordless users are adding their first password, so none is required. + if @user.password_set? && !@user.authenticate(params[:current_password].to_s) + @user.errors.add(:current_password, "is incorrect") + render :show, status: :unprocessable_entity + return + end + + if @user.update(password_params) + redirect_to users_password_path, notice: "Password saved." + else + render :show, status: :unprocessable_entity + end + end + + private + + def password_params + params.require(:user).permit(:password, :password_confirmation) + end +end diff --git a/app/mailers/magic_link_mailer.rb b/app/mailers/magic_link_mailer.rb new file mode 100644 index 0000000..e2f01e7 --- /dev/null +++ b/app/mailers/magic_link_mailer.rb @@ -0,0 +1,7 @@ +class MagicLinkMailer < ApplicationMailer + def sign_in_link(user, organization) + @user = user + @organization = organization + mail subject: "Your sign-in link", to: user.email_address + end +end diff --git a/app/models/user.rb b/app/models/user.rb index e0df843..9d1aba4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,8 @@ class User < ApplicationRecord - has_secure_password + # validations: false so a password isn't required on create — passwordless + # (magic-link) accounts have none. The presence-on-create check is dropped; + # the length/confirmation validations below replace the rest. + has_secure_password validations: false has_many :sessions, dependent: :destroy has_many :organization_memberships, dependent: :destroy has_many :organizations, through: :organization_memberships @@ -9,15 +12,26 @@ class User < ApplicationRecord confirmed_at end + # Magic-link sign-in token. Tied to the password salt so the link + # auto-invalidates the moment a password is set or changed. + generates_token_for :magic_link, expires_in: 30.minutes do + password_salt&.last(10) + end + normalizes :email_address, with: ->(e) { e.strip.downcase } validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :password, length: { minimum: 8 }, allow_nil: true + validates :password, length: { minimum: 8, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED }, allow_nil: true + validates :password, confirmation: true, allow_blank: true def member_of?(organization) organization && organizations.exists?(organization.id) end + def password_set? + password_digest.present? + end + def confirmed? confirmed_at.present? end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2f59544..8f2d5ba 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -31,6 +31,18 @@ <%= render "shared/flash" %> diff --git a/app/views/magic_link_mailer/sign_in_link.html.erb b/app/views/magic_link_mailer/sign_in_link.html.erb new file mode 100644 index 0000000..36dfb15 --- /dev/null +++ b/app/views/magic_link_mailer/sign_in_link.html.erb @@ -0,0 +1,6 @@ +
+ Sign in by visiting + <%= link_to "this sign-in page", magic_link_url(token: @user.generate_token_for(:magic_link), subdomain: @organization.subdomain) %>. + + This link will expire in 30 minutes. +
diff --git a/app/views/magic_link_mailer/sign_in_link.text.erb b/app/views/magic_link_mailer/sign_in_link.text.erb new file mode 100644 index 0000000..a05d990 --- /dev/null +++ b/app/views/magic_link_mailer/sign_in_link.text.erb @@ -0,0 +1,4 @@ +Sign in by visiting +<%= magic_link_url(token: @user.generate_token_for(:magic_link), subdomain: @organization.subdomain) %> + +This link will expire in 30 minutes. diff --git a/app/views/magic_links/new.html.erb b/app/views/magic_links/new.html.erb new file mode 100644 index 0000000..4c8d775 --- /dev/null +++ b/app/views/magic_links/new.html.erb @@ -0,0 +1,29 @@ +We'll email you a link that signs you in — no password needed.
+ + <%= form_with url: magic_link_path, class: "contents" do |form| %> + <%# Honeypot: hidden from real users, but bots tend to fill every field. Submissions with this set are silently dropped. %> + + +Optional — leave blank to sign in with a link emailed to you instead.
+ <%= @user.password_set? ? "Update the password you use to sign in." : "Add a password so you can sign in without a magic link." %> +
+ + <%= form_with url: users_password_path, method: :patch, scope: :user, class: "contents" do |form| %> + <% if @user.errors.any? %> +