diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index aa3563e90..146f271f5 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -4,13 +4,12 @@ module Authentication included do before_action :require_account # Checking and setting account must happen first before_action :require_authentication - after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? helper_method :email_address_pending_authentication etag { Current.identity.id if authenticated? } - include LoginHelper + include Authentication::ViaMagicLink, LoginHelper end class_methods do @@ -101,52 +100,4 @@ module Authentication Current.session.destroy cookies.delete(:session_token) end - - def ensure_development_magic_link_not_leaked - unless Rails.env.development? - raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? - end - end - - def email_address_pending_authentication - pending_authentication_token_verifier.verified(pending_authentication_token) - end - - def pending_authentication_token_verifier - Rails.application.message_verifier(:pending_authentication) - end - - def pending_authentication_token - cookies[:pending_authentication_token] - end - - def clear_pending_authentication_token - cookies.delete(:pending_authentication_token) - end - - def redirect_to_session_magic_link(magic_link, email_address: magic_link&.identity&.email_address, return_to: nil) - expires_at = magic_link&.expires_at || MagicLink::EXPIRATION_TIME.from_now - - cookies[:pending_authentication_token] = { - value: pending_authentication_token_verifier.generate(email_address, expires_at: expires_at), - httponly: true, - same_site: :lax, - expires: expires_at - } - - serve_development_magic_link(magic_link) - session[:return_to_after_authenticating] = return_to if return_to - - respond_to do |format| - format.html { redirect_to main_app.session_magic_link_url(script_name: nil) } - format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created } - end - end - - def serve_development_magic_link(magic_link) - if Rails.env.development? && magic_link.present? - flash[:magic_link_code] = magic_link.code - response.set_header("X-Magic-Link-Code", magic_link.code) - end - end end diff --git a/app/controllers/concerns/authentication/via_magic_link.rb b/app/controllers/concerns/authentication/via_magic_link.rb new file mode 100644 index 000000000..669de15c1 --- /dev/null +++ b/app/controllers/concerns/authentication/via_magic_link.rb @@ -0,0 +1,76 @@ +module Authentication::ViaMagicLink + extend ActiveSupport::Concern + + FakeIdentity = Data.define(:email_address) + FakeMagicLink = Data.define(:email_address) do + def identity + FakeIdentity.new(email_address) + end + + def code + "XPHONY" + end + + def expires_at + MagicLink::EXPIRATION_TIME.from_now + end + end + + included do + after_action :ensure_development_magic_link_not_leaked + end + + private + def ensure_development_magic_link_not_leaked + unless Rails.env.development? + raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? + end + end + + def redirect_to_fake_session_magic_link(email_address, **options) + redirect_to_session_magic_link FakeMagicLink.new(email_address), **options + end + + def redirect_to_session_magic_link(magic_link, return_to: nil) + serve_development_magic_link(magic_link) + set_pending_authentication_token(magic_link) + session[:return_to_after_authenticating] = return_to if return_to + + respond_to do |format| + format.html { redirect_to main_app.session_magic_link_url(script_name: nil) } + format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created } + end + end + + def serve_development_magic_link(magic_link) + if Rails.env.development? && magic_link.present? + flash[:magic_link_code] = magic_link.code + response.set_header("X-Magic-Link-Code", magic_link.code) + end + end + + def set_pending_authentication_token(magic_link) + cookies[:pending_authentication_token] = { + value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at), + httponly: true, + same_site: :lax, + expires: magic_link.expires_at + } + end + + def email_address_pending_authentication + pending_authentication_token_verifier.verified(pending_authentication_token) + end + + def pending_authentication_token_verifier + Rails.application.message_verifier(:pending_authentication) + end + + def pending_authentication_token + cookies[:pending_authentication_token] + end + + def clear_pending_authentication_token + cookies.delete(:pending_authentication_token) + end +end diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 38d1350bb..b3a376d49 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -11,9 +11,9 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - respond_to_valid_code_from magic_link + handle_valid_code_for magic_link else - respond_to_invalid_code + handle_invalid_code end end @@ -32,41 +32,41 @@ class Sessions::MagicLinksController < ApplicationController params.expect(:code) end - def respond_to_valid_code_from(magic_link) + def handle_valid_code_for(magic_link) if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) - clear_pending_authentication_token - start_new_session_for magic_link.identity - - respond_to do |format| - format.html { redirect_to after_sign_in_url(magic_link) } - format.json { render json: { session_token: cookies[:session_token] } } - end + handle_sign_in_with magic_link else - clear_pending_authentication_token - alert_message = "Something went wrong. Please try again." - - respond_to do |format| - format.html { redirect_to new_session_path, alert: alert_message } - format.json { render json: { message: alert_message }, status: :unauthorized } - end + handle_email_address_mismatch end end - def respond_to_invalid_code + def handle_sign_in_with(magic_link) + clear_pending_authentication_token + start_new_session_for magic_link.identity + + respond_to do |format| + format.html { redirect_to after_sign_in_url(magic_link) } + format.json { render json: { session_token: cookies[:session_token] } } + end + end + + def handle_email_address_mismatch + clear_pending_authentication_token + alert_message = "Something went wrong. Please try again." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end + end + + def handle_invalid_code respond_to do |format| format.html { redirect_to session_magic_link_path, flash: { shake: true } } format.json { render json: { message: "Try another code." }, status: :unauthorized } end end - def pending_authentication_token - if request.format.json? - params[:pending_authentication_token] - else - super - end - end - def after_sign_in_url(magic_link) if magic_link.for_sign_up? new_signup_completion_path diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 29d795fb2..820ac81ed 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,22 +9,12 @@ class SessionsController < ApplicationController end def create - if identity = Identity.find_by_email_address(email_address) - magic_link = identity.send_magic_link + if identity = Identity.find_by(email_address: email_address) + handle_sign_in_for identity elsif Account.accepting_signups? - signup = Signup.new(email_address: email_address) - magic_link = signup.create_identity if signup.valid?(:identity_creation) - end - - if magic_link - redirect_to_session_magic_link magic_link - elsif !Account.accepting_signups? - redirect_to_session_magic_link nil, email_address: email_address + handle_sign_up else - respond_to do |format| - format.html { redirect_to new_session_path, alert: "Something went wrong" } - format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } - end + redirect_to_fake_session_magic_link email_address end end @@ -55,4 +45,22 @@ class SessionsController < ApplicationController format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } end end + + def handle_sign_in_for(identity) + redirect_to_session_magic_link identity.send_magic_link + end + + def handle_sign_up + signup = Signup.new(email_address: email_address) + + if signup.valid?(:identity_creation) + magic_link = signup.create_identity + redirect_to_session_magic_link magic_link + else + respond_to do |format| + format.html { redirect_to new_session_path, alert: "Something went wrong" } + format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } + end + end + end end diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb index 6d07a9c4a..83b228416 100644 --- a/test/controllers/sessions/magic_links_controller_test.rb +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -83,10 +83,10 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest test "create via JSON" do identity = identities(:david) magic_link = identity.send_magic_link - pending_token = pending_authentication_token_for(identity.email_address) untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :success assert @response.parsed_body["session_token"].present? end @@ -105,10 +105,10 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest test "create via JSON with invalid code" do identity = identities(:david) - pending_token = pending_authentication_token_for(identity.email_address) untenanted do - post session_magic_link_path(format: :json), params: { code: "INVALID", pending_authentication_token: pending_token } + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: "INVALID" } assert_response :unauthorized assert_equal "Try another code.", @response.parsed_body["message"] end @@ -118,10 +118,10 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest identity = identities(:david) other_identity = identities(:jason) magic_link = other_identity.send_magic_link - pending_token = pending_authentication_token_for(identity.email_address) untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + post session_path(format: :json), params: { email_address: identity.email_address } + post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :unauthorized assert_equal "Something went wrong. Please try again.", @response.parsed_body["message"] end @@ -131,20 +131,14 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest identity = identities(:david) magic_link = identity.send_magic_link - expired_token = nil - travel_to 15.minutes.ago do - expired_token = pending_authentication_token_for(identity.email_address) - end - untenanted do - post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: expired_token } + travel_to 20.minutes.ago do + post session_path(format: :json), params: { email_address: identity.email_address } + end + + post session_magic_link_path(format: :json), params: { code: magic_link.code } assert_response :unauthorized assert_equal "Enter your email address to sign in.", @response.parsed_body["message"] end end - - private - def pending_authentication_token_for(email_address) - Sessions::MagicLinksController.new.send(:pending_authentication_token_verifier).generate(email_address, expires_in: 10.minutes) - end end