diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index deb8b3fa3..05efbf4fc 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 @@ -102,35 +101,7 @@ module Authentication 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_matches?(email_address) - if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "") - session.delete(:email_address_pending_authentication) - true - else - false - end - end - - def email_address_pending_authentication - session[:email_address_pending_authentication] - end - - def redirect_to_session_magic_link(magic_link, return_to: nil) - serve_development_magic_link(magic_link) - session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link - session[:return_to_after_authenticating] = return_to if return_to - redirect_to main_app.session_magic_link_path(script_name: nil) - end - - def serve_development_magic_link(magic_link) - if Rails.env.development? - flash[:magic_link_code] = magic_link&.code - end + def session_token + cookies[:session_token] 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..e9c0dc415 --- /dev/null +++ b/app/controllers/concerns/authentication/via_magic_link.rb @@ -0,0 +1,67 @@ +module Authentication::ViaMagicLink + extend ActiveSupport::Concern + + 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) + fake_magic_link = MagicLink.new( + identity: Identity.new(email_address: email_address), + code: SecureRandom.base32(6), + expires_at: MagicLink::EXPIRATION_TIME.from_now + ) + + redirect_to_session_magic_link fake_magic_link, **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 c0632407a..32257d941 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -1,7 +1,7 @@ class Sessions::MagicLinksController < ApplicationController disallow_account_scope require_unauthenticated_access - rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" } + rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded before_action :ensure_that_email_address_pending_authentication_exists layout "public" @@ -11,25 +11,20 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - authenticate_with magic_link + authenticate magic_link else - redirect_to session_magic_link_path, flash: { shake: true } + invalid_code end end private def ensure_that_email_address_pending_authentication_exists unless email_address_pending_authentication.present? - redirect_to new_session_path, alert: "Enter your email address to sign in." - end - end - - def authenticate_with(magic_link) - if email_address_pending_authentication_matches?(magic_link.identity.email_address) - start_new_session_for magic_link.identity - redirect_to after_sign_in_url(magic_link) - else - redirect_to new_session_path, alert: "Authentication failed. Please try again." + alert_message = "Enter your email address to sign in." + 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 end @@ -37,6 +32,41 @@ class Sessions::MagicLinksController < ApplicationController params.expect(:code) end + def authenticate(magic_link) + if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) + sign_in magic_link + else + email_address_mismatch + end + end + + def sign_in(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: session_token } } + end + end + + def 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 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 after_sign_in_url(magic_link) if magic_link.for_sign_up? new_signup_completion_path @@ -44,4 +74,12 @@ class Sessions::MagicLinksController < ApplicationController after_authentication_url end end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again in 15 minutes." + respond_to do |format| + format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f5044bd2a..d0de9e84c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,7 @@ class SessionsController < ApplicationController disallow_account_scope require_unauthenticated_access except: :destroy - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded layout "public" @@ -9,16 +9,12 @@ class SessionsController < ApplicationController end def create - if identity = Identity.find_by_email_address(email_address) - redirect_to_session_magic_link identity.send_magic_link + if identity = Identity.find_by(email_address: email_address) + sign_in identity + elsif Account.accepting_signups? + sign_up else - signup = Signup.new(email_address: email_address) - if signup.valid?(:identity_creation) - magic_link = signup.create_identity if Account.accepting_signups? - redirect_to_session_magic_link magic_link - else - head :unprocessable_entity - end + redirect_to_fake_session_magic_link email_address end end @@ -28,7 +24,43 @@ class SessionsController < ApplicationController end private + def magic_link_from_sign_in_or_sign_up + if identity = Identity.find_by_email_address(email_address) + identity.send_magic_link + else + signup = Signup.new(email_address: email_address) + signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups? + end + end + def email_address params.expect(:email_address) end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again later." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end + + def sign_in(identity) + redirect_to_session_magic_link identity.send_magic_link + end + + def 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/docs/API.md b/docs/API.md index 9cc95bffc..3c1d91977 100644 --- a/docs/API.md +++ b/docs/API.md @@ -5,6 +5,13 @@ a bot to perform various actions for you. ## Authentication +There are two ways to authenticate with the Fizzy API: + +1. **Personal access tokens** - Long-lived tokens for scripts and integrations +2. **Magic link authentication** - Session-based authentication for native apps + +### Personal Access Tokens + To use the API you'll need an access token. To get one, go to your profile, then, in the API section, click on "Personal access tokens" and then click on "Generate new access token". @@ -36,6 +43,81 @@ To authenticate a request using your access token, include it in the `Authorizat curl -H "Authorization: Bearer put-your-access-token-here" -H "Accept: application/json" https://app.fizzy.do/my/identity ``` +### Magic Link Authentication + +For native apps, you can authenticate users via magic links. This is a two-step process: + +#### 1. Request a magic link + +Send the user's email address to request a magic link be sent to them: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"email_address": "user@example.com"}' \ + https://app.fizzy.do/session +``` + +__Response:__ + +``` +HTTP/1.1 201 Created +Set-Cookie: pending_authentication_token=...; HttpOnly; SameSite=Lax +``` + +```json +{ + "pending_authentication_token": "eyJfcmFpbHMi..." +} +``` + +The response includes a `pending_authentication_token` both in the JSON body and as a cookie. +Native apps should store this token and include it as a cookie when submitting the magic link code. + +__Error responses:__ + +| Status Code | Description | +|--------|-------------| +| `422 Unprocessable entity` | Invalid email address, if sign ups are enabled and the value isn't a valid email address | +| `429 Too Many Requests` | Rate limit exceeded | + +#### 2. Submit the magic link code + +Once the user receives the magic link email, they'll have a 6-character code. Submit it to complete authentication: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Cookie: pending_authentication_token=eyJfcmFpbHMi..." \ + -d '{"code": "ABC123"}' \ + https://app.fizzy.do/session/magic_link +``` + +__Response:__ + +```json +{ + "session_token": "eyJfcmFpbHMi..." +} +``` + +The `session_token` can be used to authenticate subsequent requests by including it as a cookie: + +```bash +curl -H "Cookie: session_token=eyJfcmFpbHMi..." \ + -H "Accept: application/json" \ + https://app.fizzy.do/my/identity +``` + +__Error responses:__ + +| Status Code | Description | +|--------|-------------| +| `401 Unauthorized` | Invalid `pending_authentication_token` or `code` | +| `429 Too Many Requests` | Rate limit exceeded | + ## Caching Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data. diff --git a/test/controllers/api_test.rb b/test/controllers/api_test.rb index a48511cf8..f6bf18ca6 100644 --- a/test/controllers/api_test.rb +++ b/test/controllers/api_test.rb @@ -6,6 +6,22 @@ class ApiTest < ActionDispatch::IntegrationTest @jasons_bearer_token = bearer_token_env(identity_access_tokens(:jasons_api_token).token) end + test "authenticate with user credentials" do + identity = identities(:david) + + untenanted do + post session_path(format: :json), params: { email_address: identity.email_address } + assert_response :created + pending_token = @response.parsed_body["pending_authentication_token"] + assert pending_token.present? + + magic_link = MagicLink.last + post session_magic_link_path(format: :json), params: { code: magic_link.code, pending_authentication_token: pending_token } + assert_response :success + assert @response.parsed_body["session_token"].present? + end + end + test "authenticate with valid access token" do get boards_path(format: :json), env: @davids_bearer_token assert_response :success diff --git a/test/controllers/sessions/magic_links_controller_test.rb b/test/controllers/sessions/magic_links_controller_test.rb index 5a4a48e51..83b228416 100644 --- a/test/controllers/sessions/magic_links_controller_test.rb +++ b/test/controllers/sessions/magic_links_controller_test.rb @@ -79,4 +79,66 @@ class Sessions::MagicLinksControllerTest < ActionDispatch::IntegrationTest assert_response :redirect, "Expired magic link should redirect" assert MagicLink.exists?(expired_link.id), "Expired magic link should not be consumed" end + + test "create via JSON" do + identity = identities(:david) + magic_link = identity.send_magic_link + + untenanted do + 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 + end + + test "create via JSON without pending_authentication_token" do + identity = identities(:david) + magic_link = identity.send_magic_link + + untenanted do + 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 + + test "create via JSON with invalid code" do + identity = identities(:david) + + untenanted do + 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 + end + + test "create via JSON with cross-user code" do + identity = identities(:david) + other_identity = identities(:jason) + magic_link = other_identity.send_magic_link + + untenanted do + 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 + end + + test "create via JSON with expired pending_authentication_token" do + identity = identities(:david) + magic_link = identity.send_magic_link + + untenanted do + 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 end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index 74b336a4f..1a0ddb061 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -70,7 +70,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest post session_path, params: { email_address: "not-a-valid-email" } end - assert_response :unprocessable_entity + assert_response :redirect + assert_redirected_to new_session_path end end end @@ -85,4 +86,35 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_not cookies[:session_token].present? end end + + test "create via JSON" do + untenanted do + post session_path(format: :json), params: { email_address: identities(:david).email_address } + assert_response :created + end + end + + test "create for a new user via JSON" do + new_email = "new-user-#{SecureRandom.hex(6)}@example.com" + + untenanted do + assert_difference -> { Identity.count }, 1 do + assert_difference -> { MagicLink.count }, 1 do + post session_path(format: :json), params: { email_address: new_email } + end + end + assert_response :created + assert @response.parsed_body["pending_authentication_token"].present? + assert MagicLink.last.for_sign_up? + end + end + + test "create with invalid email address via JSON" do + untenanted do + assert_no_difference -> { Identity.count } do + post session_path(format: :json), params: { email_address: "not-a-valid-email" } + end + assert_response :unprocessable_entity + end + end end