# A one-time sign-in link (and code) sent by mail. Also used for invitations: # then it additionally grants access to a document when redeemed. Only # digests of the token and the code are stored. class MagicLink < ApplicationRecord LOGIN_TTL = 15.minutes INVITATION_TTL = 7.days MAX_CODE_ATTEMPTS = 5 MAX_PER_EMAIL_PER_HOUR = 5 belongs_to :document, optional: true belongs_to :invited_by, class_name: "User", optional: true enum :purpose, { login: 0, invitation: 1 } enum :role, { viewer: 0, editor: 1 }, prefix: :grants normalizes :email, with: ->(e) { e.strip.downcase } validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :document, :role, :invited_by, presence: true, if: :invitation? # plain values, only available on the instance that created them (for the mail) attr_reader :token, :code scope :usable, -> { where(used_at: nil).where("expires_at > ?", Time.current) } def self.digest(value) = OpenSSL::Digest::SHA256.hexdigest(value.to_s) def self.issue!(email:, purpose: :login, return_to: nil, **attrs) link = new(email:, purpose:, return_to: safe_path(return_to), **attrs) link.send(:generate_secrets) link.expires_at = (link.invitation? ? INVITATION_TTL : LOGIN_TTL).from_now link.save! link end def self.throttled?(email) where(email: email.to_s.strip.downcase, purpose: :login).where("created_at > ?", 1.hour.ago).count >= MAX_PER_EMAIL_PER_HOUR end def self.find_by_token(token) usable.find_by(token_digest: digest(token)) if token.present? end # The newest usable login link for `email` whose code matches. Wrong codes # count against that link; after MAX_CODE_ATTEMPTS it is burned. def self.find_by_code(email, code) link = usable.login.where(email: email.to_s.strip.downcase).order(created_at: :desc).first return unless link && link.failed_attempts < MAX_CODE_ATTEMPTS if ActiveSupport::SecurityUtils.secure_compare(link.code_digest, digest(code.to_s.gsub(/\D/, ""))) link else link.increment!(:failed_attempts) nil end end # Only same-site paths, so a link can never redirect elsewhere. def self.safe_path(path) path = path.to_s path.start_with?("/") && !path.start_with?("//") ? path[0, 500] : nil end # Mark used and return the user (created on first sign-in). Invitations # also grant their document access. def redeem! transaction do lock! raise ActiveRecord::RecordNotFound if used_at || expires_at.past? update!(used_at: Time.current) user = User.find_or_create_by!(email:) DocumentMembership.grant!(document:, user:, role:, source: :invite) if invitation? user end end def destination return "/d/#{document_id}" if invitation? return_to.presence || "/" end private def generate_secrets @token = SecureRandom.urlsafe_base64(32) @code = format("%06d", SecureRandom.random_number(1_000_000)) self.token_digest = self.class.digest(@token) self.code_digest = self.class.digest(@code) end end