Add minimal authentication flow to get started

This commit is contained in:
Kevin McConnell
2024-06-21 16:07:49 +01:00
parent b9cf8c5e9d
commit c4fb1bdc76
26 changed files with 546 additions and 7 deletions
@@ -1,4 +1,20 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
include Authentication::SessionLookup
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if verified_session = find_session_by_cookie
verified_session.user
else
reject_unauthorized_connection
end
end
end
end
@@ -1,2 +1,5 @@
class ApplicationController < ActionController::Base
include Authentication
allow_browser versions: :modern
end
@@ -0,0 +1,80 @@
module Authentication
extend ActiveSupport::Concern
include SessionLookup
included do
before_action :require_authentication
helper_method :signed_in?
protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
end
class_methods do
def require_unauthenticated_access(**options)
allow_unauthenticated_access **options
before_action :redirect_signed_in_user_to_root, **options
end
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
before_action :restore_authentication, **options
end
end
private
def signed_in?
Current.user.present?
end
def require_authentication
restore_authentication || request_authentication
end
def restore_authentication
if session = find_session_by_cookie
resume_session session
end
end
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_url
end
def redirect_signed_in_user_to_root
redirect_to root_url if signed_in?
end
def start_new_session_for(user)
user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
authenticated_as session
end
end
def resume_session(session)
session.resume user_agent: request.user_agent, ip_address: request.remote_ip
authenticated_as session
end
def authenticated_as(session)
Current.user = session.user
set_authenticated_by(:session)
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax }
end
def post_authenticating_url
session.delete(:return_to_after_authenticating) || root_url
end
def reset_authentication
cookies.delete(:session_token)
end
def set_authenticated_by(method)
@authenticated_by = method.to_s.inquiry
end
def authenticated_by
@authenticated_by ||= "".inquiry
end
end
@@ -0,0 +1,7 @@
module Authentication::SessionLookup
def find_session_by_cookie
if token = cookies.signed[:session_token]
Session.find_by(token: token)
end
end
end
+32
View File
@@ -0,0 +1,32 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render_rejection :too_many_requests }
def new
end
def create
if user = User.active.authenticate_by(user_params)
start_new_session_for user
redirect_to post_authenticating_url
else
render_rejection :unauthorized
end
end
def destroy
reset_authentication
redirect_to root_url
end
private
def user_params
params.require(:user).permit(:email_address, :password)
end
def render_rejection(status)
flash[:alert] = "Too many requests or unauthorized."
render :new, status: status
end
end
+2
View File
@@ -0,0 +1,2 @@
module SessionsHelper
end
+7
View File
@@ -0,0 +1,7 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user
def organization
user.organization
end
end
+3
View File
@@ -0,0 +1,3 @@
class Organization < ApplicationRecord
has_many :users
end
+19
View File
@@ -0,0 +1,19 @@
class Session < ApplicationRecord
ACTIVITY_REFRESH_RATE = 1.hour
has_secure_token
belongs_to :user
before_create { self.last_active_at ||= Time.now }
def self.start!(user_agent:, ip_address:)
create! user_agent: user_agent, ip_address: ip_address
end
def resume(user_agent:, ip_address:)
if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago)
update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now
end
end
end
+24
View File
@@ -0,0 +1,24 @@
class User < ApplicationRecord
belongs_to :organization
has_many :sessions, dependent: :destroy
has_secure_password validations: false
scope :active, -> { where(active: true) }
def current?
self == Current.user
end
def deactivate
transaction do
sessions.delete_all
update! active: false, email_address: deactived_email_address
end
end
private
def deactived_email_address
email_address&.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
end
+9
View File
@@ -0,0 +1,9 @@
<%= form_with model: User.new, url: session_path do |f| %>
<%= f.label :email_address %>
<%= f.email_field :email_address %>
<%= f.label :password %>
<%= f.password_field :password %>
<%= f.submit "Sign in" %>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<h1>Hello <%= Current.user.name %></h1>
<%= button_to "Sign out", session_path, method: :delete %>
+3
View File
@@ -61,4 +61,7 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
# Load test helpers
config.autoload_paths += %w[ test/test_helpers ]
end
+3 -6
View File
@@ -1,10 +1,7 @@
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
root "sessions#show"
resource :session
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
# Defines the root path route ("/")
# root "posts#index"
end
@@ -0,0 +1,9 @@
class CreateOrganizations < ActiveRecord::Migration[8.0]
def change
create_table :organizations do |t|
t.string :name, null: false, index: { unique: true }
t.timestamps
end
end
end
+14
View File
@@ -0,0 +1,14 @@
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.references :organization, null: false, foreign_key: true
t.string :name, null: false
t.string :email_address, null: false, index: { unique: true }
t.string :password_digest, null: false
t.boolean :active, null: false, default: true
t.timestamps
end
end
end
@@ -0,0 +1,13 @@
class CreateSessions < ActiveRecord::Migration[8.0]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :token, null: false, index: { unique: true }
t.string :ip_address
t.string :user_agent
t.datetime :last_active_at, null: false
t.timestamps
end
end
end
Generated
+47
View File
@@ -0,0 +1,47 @@
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2024_06_21_150944) do
create_table "organizations", force: :cascade do |t|
t.string "name", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name"], name: "index_organizations_on_name", unique: true
end
create_table "sessions", force: :cascade do |t|
t.integer "user_id", null: false
t.string "token", null: false
t.string "ip_address"
t.string "user_agent"
t.datetime "last_active_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["token"], name: "index_sessions_on_token", unique: true
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "users", force: :cascade do |t|
t.integer "organization_id", null: false
t.string "name", null: false
t.string "email_address", null: false
t.string "password_digest", null: false
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["organization_id"], name: "index_users_on_organization_id"
end
add_foreign_key "sessions", "users"
add_foreign_key "users", "organizations"
end
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html>
<head>
<title>Your browser is not supported (406)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta charset="UTF-8">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
--lch-black: 0% 0 0;
--lch-gray: 75% 0.005 96;
--lch-white: 100% 0 0;
--color-border: oklch(var(--lch-gray));
--color-bg: oklch(var(--lch-white));
--color-text: oklch(var(--lch-black));
--btn-size: 2.65em;
background-color: var(--color-bg);
block-size: 100dvh;
color: var(--color-text);
display: grid;
margin: 0;
padding: 0;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
@media (prefers-color-scheme: dark) {
--lch-black: 100% 0 0;
--lch-gray: 44.95% 0 0;
--lch-white: 0% 0 0;
}
}
svg {
fill: var(--color-text);
@media (prefers-color-scheme: dark) {
fill: var(--color-text);
}
}
.error {
align-items: center;
display: flex;
flex-direction: column;
gap: 2em;
justify-content: start;
margin-block: 2dvh 15dvh;
}
.error__img {
aspect-ratio: 1;
background-color: var(--color-text);
block-size: auto;
border-radius: 50%;
display: grid;
place-items: center;
inline-size: 30dvh;
svg {
fill: var(--color-bg);
grid-area: 1/1;
max-inline-size: 66%;
@media (prefers-color-scheme: dark) {
fill: var(--color-bg);
}
}
}
p {
max-inline-size: 50ch;
padding: 0 3dvw;
margin-inline: auto;
}
.buttons {
display: flex;
flex-direction: row;
gap: 1em;
justify-content: center;
}
.btn {
align-items: center;
aspect-ratio: 1;
background-color: var(--color-bg);
block-size: var(--btn-size);
border-radius: 50%;
border: 1px solid var(--color-border);
color: var(--color-text);
cursor: pointer;
display: grid;
font-weight: 600;
font-size: 1.4rem;
gap: 0.5em;
inline-size: var(--btn-size);
justify-content: center;
padding: 0;
place-items: center;
text-align: center;
svg {
-webkit-touch-callout: none;
grid-area: 1/1;
inline-size: 1.3em;
max-inline-size: unset;
user-select: none;
}
}
.for-screen-reader {
clip-path: inset(50%);
height: 1px;
width: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="error">
<div class="error__img">
<svg enable-background="new 0 0 24 24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-label="Your browser is not supported">
<path d="m17.2 14.4c.1-.8.2-1.6.2-2.4s-.1-1.6-.2-2.4h4.1c.2.8.3 1.6.3 2.4s-.1 1.6-.3 2.4m-6.2 6.7c.7-1.3 1.3-2.8 1.7-4.3h3.5c-1.1 2-3 3.5-5.2 4.3m-.3-6.7h-5.6c-.1-.8-.2-1.6-.2-2.4s.1-1.6.2-2.4h5.6c.1.8.2 1.6.2 2.4s-.1 1.6-.2 2.4m-2.8 7.2c-1-1.4-1.8-3-2.3-4.8h4.6c-.5 1.7-1.3 3.3-2.3 4.8m-4.8-14.4h-3.5c1.1-2 3-3.5 5.2-4.3-.7 1.4-1.3 2.8-1.7 4.3m-3.5 9.6h3.5c.4 1.5 1 2.9 1.7 4.3-2.2-.8-4.1-2.3-5.2-4.3m-1-2.4c-.2-.8-.3-1.6-.3-2.4s.1-1.6.3-2.4h4.1c-.1.8-.2 1.6-.2 2.4s.1 1.6.2 2.4m5.2-12c1 1.4 1.8 3 2.3 4.8h-4.6c.5-1.7 1.3-3.3 2.3-4.8m8.3 4.8h-3.5c-.4-1.5-.9-2.9-1.7-4.3 2.2.8 4.1 2.3 5.2 4.3m-8.3-7.2c-6.6 0-12 5.4-12 12s5.4 12 12 12 12-5.4 12-12-5.4-12-12-12z"/>
</svg>
</div>
<a href="/" class="btn" autofocus="true">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="m21.864 9.5h-11.607a.25.25 0 0 1 -.174-.43l3.864-3.721a2.609 2.609 0 0 0 -.075-3.682 2.612 2.612 0 0 0 -3.68-.077l-9.792 9.699a1 1 0 0 0 -.008 1.411l9.625 9.724a2.66 2.66 0 0 0 3.755-3.757l-3.729-3.733a.25.25 0 0 1 .177-.427h11.673c1.556 0 2-1.675 2-2.51a2.28 2.28 0 0 0 -2.029-2.497z" />
</svg>
<span class="for-screen-reader">Go back</span>
</a>
<div>
<p>🇺🇸 Upgrade to a supported web browser. Splat requires a modern web browser. Please use one of the browsers listed below and make sure auto-updates are enabled.</p>
<p>🇪🇸 Actualiza a un navegador web compatible. Splat requiere un navegador web moderno. Utiliza uno de los navegadores listados a continuación y asegúrate de que las actualizaciones automáticas estén habilitadas.</p>
<p>🇫🇷 Mettez à jour vers un navigateur web pris en charge. Splat nécessite un navigateur web moderne. Veuillez utiliser l'un des navigateurs répertoriés ci-dessous et assurez-vous que les mises à jour automatiques sont activées.</p>
<p>🇮🇳 समर्थित वेब ब्राउज़र में अपग्रेड करें। Splat को एक आधुनिक वेब ब्राउज़र की आवश्यकता है। कृपया नीचे सूचीबद्ध ब्राउज़रों में से कोई एक का उपयोग करें और सुनिश्चित करें कि स्वचालित अपडेट्स सक्षम हैं।</p>
<p>🇩🇪 Aktualisieren Sie auf einen unterstützten Webbrowser. Splat erfordert einen modernen Webbrowser. Verwenden Sie bitte einen der unten aufgeführten Browser und stellen Sie sicher, dass automatische Updates aktiviert sind.</p>
<p>🇧🇷 Atualize para um navegador compatível. O Splat requer um navegador moderno. Por favor, use um dos navegadores listados abaixo e certifique-se de que as atualizações automáticas estão ativadas.</p>
<p><strong>Safari 17.2+, Chrome 119+, Firefox 121+, Opera 104+</strong></p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,44 @@
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
ALLOWED_BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
DISALLOWED_BROWSER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0"
test "new" do
get new_session_url
assert_response :success
end
test "new denied with incompatible browser" do
get new_session_url, env: { "HTTP_USER_AGENT" => DISALLOWED_BROWSER }
assert_select "svg", message: /Your browser is not supported/
end
test "new allowed with compatible browser" do
get new_session_url, env: { "HTTP_USER_AGENT" => ALLOWED_BROWSER }
assert_select "svg", message: /Your browser is not supported/, count: 0
end
test "create with valid credentials" do
post session_url, params: { user: { email_address: "david@37signals.com", password: "secret123456" } }
assert_redirected_to root_url
assert parsed_cookies.signed[:session_token]
end
test "create with invalid credentials" do
post session_url, params: { user: { email_address: "david@37signals.com", password: "wrong" } }
assert_response :unauthorized
assert_nil parsed_cookies.signed[:session_token]
end
test "destroy" do
sign_in :kevin
delete session_url
assert_redirected_to root_url
assert_not cookies[:session_token].present?
end
end
+2
View File
@@ -0,0 +1,2 @@
37signals:
name: 37signals
+19
View File
@@ -0,0 +1,19 @@
<% digest = BCrypt::Password.create("secret123456") %>
david:
organization: 37signals
name: David
email_address: david@37signals.com
password_digest: <%= digest %>
jz:
organization: 37signals
name: JZ
email_address: jz@37signals.com
password_digest: <%= digest %>
kevin:
organization: 37signals
name: Kevin
email_address: kevin@37signals.com
password_digest: <%= digest %>
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class OrganizationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
+1 -1
View File
@@ -10,6 +10,6 @@ module ActiveSupport
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
# Add more helper methods to be used by all tests here...
include SessionTestHelper
end
end
+16
View File
@@ -0,0 +1,16 @@
module SessionTestHelper
def parsed_cookies
ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
end
def sign_in(user)
user = users(user) unless user.is_a? User
post session_url, params: { user: { email_address: user.email_address, password: "secret123456" } }
assert cookies[:session_token].present?
end
def sign_out
delete session_url
assert_not cookies[:session_token].present?
end
end