diff --git a/Gemfile b/Gemfile index fdca8a0..e4d7c06 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,13 @@ gem "tailwindcss-rails" gem "jbuilder" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" + +# TOTP for two-factor authentication +gem "rotp", "~> 6.3" + +# QR code generation for TOTP setup +gem "rqrcode", "~> 2.0" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] diff --git a/Gemfile.lock b/Gemfile.lock index d9bd7ce..316af9e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -79,6 +79,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) + bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) bigdecimal (3.3.1) bindex (0.8.1) @@ -99,6 +100,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + chunky_png (1.4.0) concurrent-ruby (1.3.5) connection_pool (2.5.4) crass (1.0.6) @@ -271,6 +273,11 @@ GEM reline (0.6.2) io-console (~> 0.5) rexml (3.4.4) + rotp (6.3.0) + rqrcode (2.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) rubocop (1.81.6) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -396,6 +403,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap brakeman bundler-audit @@ -408,6 +416,8 @@ DEPENDENCIES propshaft puma (>= 5.0) rails (~> 8.1.0) + rotp (~> 6.3) + rqrcode (~> 2.0) rubocop-rails-omakase selenium-webdriver solid_cable diff --git a/README.md b/README.md index 9a0c92a..6ace77b 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,246 @@ -# README +# Clinch -Clinch is a lightweight, self-hosted identity & SSO portal for home-labs. -It gives you one place to manage people and lets any web app authenticate against it without keeping its own user table. +**A lightweight, self-hosted identity & SSO portal for home-labs** -Core behaviour +Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table. -First-run wizard → initial user becomes admin. +## Why Clinch? -Admin dashboard → create / disable / delete users. +Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management. -SMTP integration → send: -– invitation links (one-time token) -– password-reset links -– 2FA back-up codes +**Clinch is a lightweight alternative to Authelia and Authentik**, designed for simplicity and ease of deployment. -Optional per-user TOTP (QR code + scratch codes). +--- -Auth mechanisms exposed to client apps +## Features -OpenID Connect (OIDC) -Standard OAuth2/OIDC provider endpoints (/.well-known/openid-configuration, /authorize, /token, /userinfo). -Client apps (Audiobookshelf, Kavita, Grafana, …) redirect to Clinch for login; Clinch returns ID- and access-tokens. +### User Management +- **First-run wizard** - Initial user automatically becomes admin +- **Admin dashboard** - Create, disable, and delete users +- **Group-based organization** - Organize users into groups (admin, family, friends, etc.) +- **User statuses** - Active, disabled, or pending invitation -Trusted-Header SSO (a.k.a. ForwardAuth) -Reverse-proxy (Caddy, Traefik, Nginx) sends every request to clinch:9000/api/verify. +### Authentication Methods +- **Password authentication** - Secure bcrypt-based password storage +- **Magic login links** - Passwordless login via email (15-minute expiry) +- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup +- **Backup codes** - 10 single-use recovery codes per user +- **Configurable 2FA enforcement** - Admins can require TOTP for specific users/groups -200 → proxy injects headers Remote-User, Remote-Groups, Remote-Email and forwards to the app. -401/403 → proxy redirects browser to Clinch login page; after login user is bounced back to the original URL. -Apps that speak OIDC use method 1; apps that only need “who is it?” headers behind a proxy use method 2. +### SSO Protocols -* Configuration -ENV files +#### OpenID Connect (OIDC) +Standard OAuth2/OIDC provider with endpoints: +- `/.well-known/openid-configuration` - Discovery endpoint +- `/authorize` - Authorization endpoint +- `/token` - Token endpoint +- `/userinfo` - User info endpoint -* Database creation -SQLite only +Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens. -* How to run the test suite +#### Trusted-Header SSO (ForwardAuth) +Works with reverse proxies (Caddy, Traefik, Nginx): +1. Proxy sends every request to `/api/verify` +2. **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app +3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL -* Services (job queues, cache servers, search engines, etc.) +Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth. -* Deployment instructions -Docker +### SMTP Integration +Send emails for: +- Invitation links (one-time token, 7-day expiry) +- Password reset links (one-time token, 1-hour expiry) +- 2FA backup codes + +### Session Management +- **Device tracking** - See all active sessions with device names and IPs +- **Remember me** - Long-lived sessions (30 days) for trusted devices +- **Session revocation** - Users and admins can revoke individual sessions + +### Access Control +- **Group-based allowlists** - Restrict applications to specific user groups +- **Per-application access** - Each app defines which groups can access it +- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth + +--- + +## Data Model + +### Core Models + +**User** +- Email address (unique, normalized to lowercase) +- Password (bcrypt hashed) +- Admin flag +- TOTP secret and backup codes (encrypted) +- TOTP enforcement flag +- Status (active, disabled, pending_invitation) +- Token generation for invitations, password resets, and magic logins + +**Group** +- Name (unique, normalized to lowercase) +- Description +- Many-to-many with Users and Applications + +**Session** +- User reference +- IP address and user agent +- Device name (parsed from user agent) +- Remember me flag +- Expiry (24 hours or 30 days if remembered) +- Last activity timestamp + +**Application** +- Name and slug (URL-safe identifier) +- Type (oidc, trusted_header, saml) +- Client ID and secret (for OIDC) +- Redirect URIs (JSON array) +- Metadata (flexible JSON storage) +- Active flag +- Many-to-many with Groups (allowlist) + +**OIDC Tokens** +- Authorization codes (10-minute expiry, one-time use) +- Access tokens (1-hour expiry, revocable) + +--- + +## Authentication Flows + +### OIDC Authorization Flow +1. Client redirects user to `/authorize` with client_id, redirect_uri, scope +2. User authenticates with Clinch (username/password + optional TOTP) +3. Access control check: Is user in an allowed group for this app? +4. If allowed, generate authorization code and redirect to client +5. Client exchanges code for access token at `/token` +6. Client uses access token to fetch user info from `/userinfo` + +### ForwardAuth Flow +1. User requests protected resource at `https://app.example.com/dashboard` +2. Reverse proxy sends request to Clinch at `/api/verify` +3. Clinch checks for valid session cookie +4. If valid session and user allowed: + - Return 200 with `Remote-User`, `Remote-Groups`, `Remote-Email` headers + - Proxy forwards request to app with injected headers +5. If no session or not allowed: + - Return 401/403 + - Proxy redirects to Clinch login page + - After login, redirect back to original URL + +--- + +## Setup & Installation + +### Requirements +- Ruby 3.3+ +- SQLite 3.8+ +- SMTP server (for sending emails) + +### Local Development + +```bash +# Install dependencies +bundle install + +# Setup database +bin/rails db:setup + +# Run migrations +bin/rails db:migrate + +# Start server +bin/dev +``` + +### Docker Deployment + +```bash +# Build image +docker build -t clinch . + +# Run container +docker run -p 9000:9000 \ + -v clinch-storage:/rails/storage \ + -e SECRET_KEY_BASE=your-secret-key \ + -e SMTP_ADDRESS=smtp.example.com \ + -e SMTP_PORT=587 \ + -e SMTP_USERNAME=your-username \ + -e SMTP_PASSWORD=your-password \ + clinch +``` + +--- + +## Configuration + +### Environment Variables + +Create a `.env` file (see `.env.example`): + +```bash +# Rails +SECRET_KEY_BASE=generate-with-bin-rails-secret +RAILS_ENV=production + +# Database +# SQLite database stored in storage/ directory (Docker volume mount point) + +# SMTP (for sending emails) +SMTP_ADDRESS=smtp.example.com +SMTP_PORT=587 +SMTP_DOMAIN=example.com +SMTP_USERNAME=your-username +SMTP_PASSWORD=your-password +SMTP_AUTHENTICATION=plain +SMTP_ENABLE_STARTTLS=true + +# Application +CLINCH_HOST=https://auth.example.com +CLINCH_FROM_EMAIL=noreply@example.com +``` + +### First Run +1. Visit Clinch at `http://localhost:9000` (or your configured domain) +2. First-run wizard creates initial admin user +3. Admin can then: + - Create groups + - Invite users + - Register applications + - Configure access control + +--- + +## Roadmap + +### In Progress +- OIDC provider implementation +- ForwardAuth endpoint +- Admin UI for user/group/app management +- First-run wizard + +### Planned Features +- **SAML support** - SAML 2.0 identity provider +- **Policy engine** - Rule-based access control + - Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY` + - Stored as JSON, evaluated after auth but before consent +- **Audit logging** - Track all authentication events +- **WebAuthn/Passkeys** - Hardware key support +- **LDAP sync** - Import users from LDAP/Active Directory + +--- + +## Technology Stack + +- **Rails 8.1** - Modern Rails with authentication generator +- **SQLite** - Lightweight database (production-ready with Rails 8) +- **Tailwind CSS** - Utility-first styling +- **Hotwire** - Turbo and Stimulus for reactive UI +- **ROTP** - TOTP implementation for 2FA +- **bcrypt** - Password hashing + +--- + +## License + +MIT diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..4264c74 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,16 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + def set_current_user + if session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session.user + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..5f38f02 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..3538f48 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,52 @@ +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || root_url + end + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 0000000..f95ec78 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,35 @@ +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: %i[ edit update ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } + + def new + end + + def create + if user = User.find_by(email_address: params[:email_address]) + PasswordsMailer.reset(user).deliver_later + end + + redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." + end + + def edit + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.destroy_all + redirect_to new_session_path, notice: "Password has been reset." + else + redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + end + end + + private + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_password_path, alert: "Password reset link is invalid or has expired." + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..cf7fccd --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,21 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + + def new + end + + def create + if user = User.authenticate_by(params.permit(:email_address, :password)) + start_new_session_for user + redirect_to after_authentication_url + else + redirect_to new_session_path, alert: "Try another email address or password." + end + end + + def destroy + terminate_session + redirect_to new_session_path, status: :see_other + end +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 0000000..4f0ac7f --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,6 @@ +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email_address + end +end diff --git a/app/models/application.rb b/app/models/application.rb new file mode 100644 index 0000000..62a86b2 --- /dev/null +++ b/app/models/application.rb @@ -0,0 +1,70 @@ +class Application < ApplicationRecord + has_many :application_groups, dependent: :destroy + has_many :allowed_groups, through: :application_groups, source: :group + has_many :oidc_authorization_codes, dependent: :destroy + has_many :oidc_access_tokens, dependent: :destroy + + validates :name, presence: true + validates :slug, presence: true, uniqueness: { case_sensitive: false }, + format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" } + validates :app_type, presence: true, + inclusion: { in: %w[oidc trusted_header saml] } + validates :client_id, uniqueness: { allow_nil: true } + + normalizes :slug, with: ->(slug) { slug.strip.downcase } + + before_validation :generate_client_credentials, on: :create, if: :oidc? + + # Scopes + scope :active, -> { where(active: true) } + scope :oidc, -> { where(app_type: "oidc") } + scope :trusted_header, -> { where(app_type: "trusted_header") } + scope :saml, -> { where(app_type: "saml") } + + # Type checks + def oidc? + app_type == "oidc" + end + + def trusted_header? + app_type == "trusted_header" + end + + def saml? + app_type == "saml" + end + + # Access control + def user_allowed?(user) + return false unless active? + return false unless user.active? + + # If no groups are specified, allow all active users + return true if allowed_groups.empty? + + # Otherwise, user must be in at least one of the allowed groups + (user.groups & allowed_groups).any? + end + + # OIDC helpers + def parsed_redirect_uris + return [] unless redirect_uris.present? + JSON.parse(redirect_uris) + rescue JSON::ParserError + redirect_uris.split("\n").map(&:strip).reject(&:blank?) + end + + def parsed_metadata + return {} unless metadata.present? + JSON.parse(metadata) + rescue JSON::ParserError + {} + end + + private + + def generate_client_credentials + self.client_id ||= SecureRandom.urlsafe_base64(32) + self.client_secret ||= SecureRandom.urlsafe_base64(48) + end +end diff --git a/app/models/application_group.rb b/app/models/application_group.rb new file mode 100644 index 0000000..4eb0e31 --- /dev/null +++ b/app/models/application_group.rb @@ -0,0 +1,6 @@ +class ApplicationGroup < ApplicationRecord + belongs_to :application + belongs_to :group + + validates :application_id, uniqueness: { scope: :group_id } +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..2bef56d --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/group.rb b/app/models/group.rb new file mode 100644 index 0000000..54a9543 --- /dev/null +++ b/app/models/group.rb @@ -0,0 +1,9 @@ +class Group < ApplicationRecord + has_many :user_groups, dependent: :destroy + has_many :users, through: :user_groups + has_many :application_groups, dependent: :destroy + has_many :applications, through: :application_groups + + validates :name, presence: true, uniqueness: { case_sensitive: false } + normalizes :name, with: ->(name) { name.strip.downcase } +end diff --git a/app/models/oidc_access_token.rb b/app/models/oidc_access_token.rb new file mode 100644 index 0000000..27e5c35 --- /dev/null +++ b/app/models/oidc_access_token.rb @@ -0,0 +1,34 @@ +class OidcAccessToken < ApplicationRecord + belongs_to :application + belongs_to :user + + before_validation :generate_token, on: :create + before_validation :set_expiry, on: :create + + validates :token, presence: true, uniqueness: true + + scope :valid, -> { where("expires_at > ?", Time.current) } + scope :expired, -> { where("expires_at <= ?", Time.current) } + + def expired? + expires_at <= Time.current + end + + def active? + !expired? + end + + def revoke! + update!(expires_at: Time.current) + end + + private + + def generate_token + self.token ||= SecureRandom.urlsafe_base64(48) + end + + def set_expiry + self.expires_at ||= 1.hour.from_now + end +end diff --git a/app/models/oidc_authorization_code.rb b/app/models/oidc_authorization_code.rb new file mode 100644 index 0000000..73042f9 --- /dev/null +++ b/app/models/oidc_authorization_code.rb @@ -0,0 +1,35 @@ +class OidcAuthorizationCode < ApplicationRecord + belongs_to :application + belongs_to :user + + before_validation :generate_code, on: :create + before_validation :set_expiry, on: :create + + validates :code, presence: true, uniqueness: true + validates :redirect_uri, presence: true + + scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) } + scope :expired, -> { where("expires_at <= ?", Time.current) } + + def expired? + expires_at <= Time.current + end + + def valid? + !used? && !expired? + end + + def consume! + update!(used: true) + end + + private + + def generate_code + self.code ||= SecureRandom.urlsafe_base64(32) + end + + def set_expiry + self.expires_at ||= 10.minutes.from_now + end +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 0000000..6156cd1 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,33 @@ +class Session < ApplicationRecord + belongs_to :user + + before_create :set_expiry + before_save :update_activity + + # Scopes + scope :active, -> { where("expires_at > ?", Time.current) } + scope :expired, -> { where("expires_at <= ?", Time.current) } + + def expired? + expires_at.present? && expires_at <= Time.current + end + + def active? + !expired? + end + + def touch_activity! + update_column(:last_activity_at, Time.current) + end + + private + + def set_expiry + self.expires_at ||= remember_me ? 30.days.from_now : 24.hours.from_now + self.last_activity_at ||= Time.current + end + + def update_activity + self.last_activity_at = Time.current if expires_at_changed? || new_record? + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..e67b459 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,78 @@ +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + has_many :user_groups, dependent: :destroy + has_many :groups, through: :user_groups + + # Token generation for passwordless flows + generates_token_for :invitation, expires_in: 7.days + generates_token_for :password_reset, expires_in: 1.hour + generates_token_for :magic_login, expires_in: 15.minutes + + normalizes :email_address, with: ->(e) { e.strip.downcase } + + validates :email_address, presence: true, uniqueness: { case_sensitive: false }, + format: { with: URI::MailTo::EMAIL_REGEXP } + validates :status, presence: true, + inclusion: { in: %w[active disabled pending_invitation] } + + # Scopes + scope :active, -> { where(status: "active") } + scope :admins, -> { where(admin: true) } + + # TOTP methods + def totp_enabled? + totp_secret.present? + end + + def enable_totp! + require "rotp" + self.totp_secret = ROTP::Base32.random + self.backup_codes = generate_backup_codes + save! + end + + def disable_totp! + update!(totp_secret: nil, totp_required: false, backup_codes: nil) + end + + def totp_provisioning_uri(issuer: "Clinch") + return nil unless totp_enabled? + + require "rotp" + totp = ROTP::TOTP.new(totp_secret, issuer: issuer) + totp.provisioning_uri(email_address) + end + + def verify_totp(code) + return false unless totp_enabled? + + require "rotp" + totp = ROTP::TOTP.new(totp_secret) + totp.verify(code, drift_behind: 30, drift_ahead: 30) + end + + def verify_backup_code(code) + return false unless backup_codes.present? + + codes = JSON.parse(backup_codes) + if codes.include?(code) + codes.delete(code) + update(backup_codes: codes.to_json) + true + else + false + end + end + + def parsed_backup_codes + return [] unless backup_codes.present? + JSON.parse(backup_codes) + end + + private + + def generate_backup_codes + Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json + end +end diff --git a/app/models/user_group.rb b/app/models/user_group.rb new file mode 100644 index 0000000..1550208 --- /dev/null +++ b/app/models/user_group.rb @@ -0,0 +1,6 @@ +class UserGroup < ApplicationRecord + belongs_to :user + belongs_to :group + + validates :user_id, uniqueness: { scope: :group_id } +end diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 0000000..65798f8 --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,21 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Update your password

+ + <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> +
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb new file mode 100644 index 0000000..8360e02 --- /dev/null +++ b/app/views/passwords/new.html.erb @@ -0,0 +1,17 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + +

Forgot your password?

+ + <%= form_with url: passwords_path, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ <% end %> +
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 0000000..1b09154 --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,6 @@ +

+ You can reset your password on + <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. + + This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. +

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb new file mode 100644 index 0000000..aecee82 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,4 @@ +You can reset your password on +<%= edit_password_url(@user.password_reset_token) %> + +This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..308b04b --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,31 @@ +
+ <% if alert = flash[:alert] %> +

<%= alert %>

+ <% end %> + + <% if notice = flash[:notice] %> +

<%= notice %>

+ <% end %> + +

Sign in

+ + <%= form_with url: session_url, class: "contents" do |form| %> +
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+ <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> +
+ +
+
+ <%= form.submit "Sign in", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> +
+ +
+ <%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %> +
+
+ <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 48254e8..29b007b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + resource :session + resources :passwords, param: :token # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/db/migrate/20251023053651_create_users.rb b/db/migrate/20251023053651_create_users.rb new file mode 100644 index 0000000..71f2ff1 --- /dev/null +++ b/db/migrate/20251023053651_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users do |t| + t.string :email_address, null: false + t.string :password_digest, null: false + + t.timestamps + end + add_index :users, :email_address, unique: true + end +end diff --git a/db/migrate/20251023053652_create_sessions.rb b/db/migrate/20251023053652_create_sessions.rb new file mode 100644 index 0000000..ec9efdb --- /dev/null +++ b/db/migrate/20251023053652_create_sessions.rb @@ -0,0 +1,11 @@ +class CreateSessions < ActiveRecord::Migration[8.1] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :ip_address + t.string :user_agent + + t.timestamps + end + end +end diff --git a/db/migrate/20251023053722_add_auth_fields_to_users.rb b/db/migrate/20251023053722_add_auth_fields_to_users.rb new file mode 100644 index 0000000..52b256a --- /dev/null +++ b/db/migrate/20251023053722_add_auth_fields_to_users.rb @@ -0,0 +1,11 @@ +class AddAuthFieldsToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :admin, :boolean, default: false, null: false + add_column :users, :totp_secret, :string + add_column :users, :totp_required, :boolean, default: false, null: false + add_column :users, :backup_codes, :text + add_column :users, :status, :string, default: "active", null: false + + add_index :users, :status + end +end diff --git a/db/migrate/20251023053740_add_device_tracking_to_sessions.rb b/db/migrate/20251023053740_add_device_tracking_to_sessions.rb new file mode 100644 index 0000000..be6672c --- /dev/null +++ b/db/migrate/20251023053740_add_device_tracking_to_sessions.rb @@ -0,0 +1,11 @@ +class AddDeviceTrackingToSessions < ActiveRecord::Migration[8.1] + def change + add_column :sessions, :device_name, :string + add_column :sessions, :remember_me, :boolean, default: false, null: false + add_column :sessions, :expires_at, :datetime + add_column :sessions, :last_activity_at, :datetime + + add_index :sessions, :expires_at + add_index :sessions, :last_activity_at + end +end diff --git a/db/migrate/20251023053836_create_groups.rb b/db/migrate/20251023053836_create_groups.rb new file mode 100644 index 0000000..1ac8e1a --- /dev/null +++ b/db/migrate/20251023053836_create_groups.rb @@ -0,0 +1,11 @@ +class CreateGroups < ActiveRecord::Migration[8.1] + def change + create_table :groups do |t| + t.string :name, null: false + t.text :description + + t.timestamps + end + add_index :groups, :name, unique: true + end +end diff --git a/db/migrate/20251023053837_create_user_groups.rb b/db/migrate/20251023053837_create_user_groups.rb new file mode 100644 index 0000000..8c5f128 --- /dev/null +++ b/db/migrate/20251023053837_create_user_groups.rb @@ -0,0 +1,12 @@ +class CreateUserGroups < ActiveRecord::Migration[8.1] + def change + create_table :user_groups do |t| + t.references :user, null: false, foreign_key: true + t.references :group, null: false, foreign_key: true + + t.timestamps + end + + add_index :user_groups, [ :user_id, :group_id ], unique: true + end +end diff --git a/db/migrate/20251023053927_create_applications.rb b/db/migrate/20251023053927_create_applications.rb new file mode 100644 index 0000000..7083129 --- /dev/null +++ b/db/migrate/20251023053927_create_applications.rb @@ -0,0 +1,19 @@ +class CreateApplications < ActiveRecord::Migration[8.1] + def change + create_table :applications do |t| + t.string :name, null: false + t.string :slug, null: false + t.string :app_type, null: false + t.string :client_id + t.string :client_secret + t.text :redirect_uris + t.text :metadata + t.boolean :active, default: true, null: false + + t.timestamps + end + add_index :applications, :slug, unique: true + add_index :applications, :client_id, unique: true + add_index :applications, :active + end +end diff --git a/db/migrate/20251023053938_create_application_groups.rb b/db/migrate/20251023053938_create_application_groups.rb new file mode 100644 index 0000000..827714b --- /dev/null +++ b/db/migrate/20251023053938_create_application_groups.rb @@ -0,0 +1,12 @@ +class CreateApplicationGroups < ActiveRecord::Migration[8.1] + def change + create_table :application_groups do |t| + t.references :application, null: false, foreign_key: true + t.references :group, null: false, foreign_key: true + + t.timestamps + end + + add_index :application_groups, [ :application_id, :group_id ], unique: true + end +end diff --git a/db/migrate/20251023054038_create_oidc_authorization_codes.rb b/db/migrate/20251023054038_create_oidc_authorization_codes.rb new file mode 100644 index 0000000..f266110 --- /dev/null +++ b/db/migrate/20251023054038_create_oidc_authorization_codes.rb @@ -0,0 +1,18 @@ +class CreateOidcAuthorizationCodes < ActiveRecord::Migration[8.1] + def change + create_table :oidc_authorization_codes do |t| + t.string :code, null: false + t.references :application, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.string :redirect_uri, null: false + t.string :scope + t.datetime :expires_at, null: false + t.boolean :used, default: false, null: false + + t.timestamps + end + add_index :oidc_authorization_codes, :code, unique: true + add_index :oidc_authorization_codes, :expires_at + add_index :oidc_authorization_codes, [ :application_id, :user_id ] + end +end diff --git a/db/migrate/20251023054039_create_oidc_access_tokens.rb b/db/migrate/20251023054039_create_oidc_access_tokens.rb new file mode 100644 index 0000000..b676d28 --- /dev/null +++ b/db/migrate/20251023054039_create_oidc_access_tokens.rb @@ -0,0 +1,16 @@ +class CreateOidcAccessTokens < ActiveRecord::Migration[8.1] + def change + create_table :oidc_access_tokens do |t| + t.string :token, null: false + t.references :application, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.string :scope + t.datetime :expires_at, null: false + + t.timestamps + end + add_index :oidc_access_tokens, :token, unique: true + add_index :oidc_access_tokens, :expires_at + add_index :oidc_access_tokens, [ :application_id, :user_id ] + end +end diff --git a/db/schema.rb b/db/schema.rb index 03e7368..3037e4d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,5 +10,119 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 0) do +ActiveRecord::Schema[8.1].define(version: 2025_10_23_054039) do + create_table "application_groups", force: :cascade do |t| + t.integer "application_id", null: false + t.datetime "created_at", null: false + t.integer "group_id", null: false + t.datetime "updated_at", null: false + t.index ["application_id", "group_id"], name: "index_application_groups_on_application_id_and_group_id", unique: true + t.index ["application_id"], name: "index_application_groups_on_application_id" + t.index ["group_id"], name: "index_application_groups_on_group_id" + end + + create_table "applications", force: :cascade do |t| + t.boolean "active", default: true, null: false + t.string "app_type", null: false + t.string "client_id" + t.string "client_secret" + t.datetime "created_at", null: false + t.text "metadata" + t.string "name", null: false + t.text "redirect_uris" + t.string "slug", null: false + t.datetime "updated_at", null: false + t.index ["active"], name: "index_applications_on_active" + t.index ["client_id"], name: "index_applications_on_client_id", unique: true + t.index ["slug"], name: "index_applications_on_slug", unique: true + end + + create_table "groups", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.string "name", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_groups_on_name", unique: true + end + + create_table "oidc_access_tokens", force: :cascade do |t| + t.integer "application_id", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "scope" + t.string "token", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id" + t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id" + t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at" + t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true + t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id" + end + + create_table "oidc_authorization_codes", force: :cascade do |t| + t.integer "application_id", null: false + t.string "code", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.string "redirect_uri", null: false + t.string "scope" + t.datetime "updated_at", null: false + t.boolean "used", default: false, null: false + t.integer "user_id", null: false + t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id" + t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id" + t.index ["code"], name: "index_oidc_authorization_codes_on_code", unique: true + t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at" + t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id" + end + + create_table "sessions", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "device_name" + t.datetime "expires_at" + t.string "ip_address" + t.datetime "last_activity_at" + t.boolean "remember_me", default: false, null: false + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["expires_at"], name: "index_sessions_on_expires_at" + t.index ["last_activity_at"], name: "index_sessions_on_last_activity_at" + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "user_groups", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "group_id", null: false + t.datetime "updated_at", null: false + t.integer "user_id", null: false + t.index ["group_id"], name: "index_user_groups_on_group_id" + t.index ["user_id", "group_id"], name: "index_user_groups_on_user_id_and_group_id", unique: true + t.index ["user_id"], name: "index_user_groups_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.boolean "admin", default: false, null: false + t.text "backup_codes" + t.datetime "created_at", null: false + t.string "email_address", null: false + t.string "password_digest", null: false + t.string "status", default: "active", null: false + t.boolean "totp_required", default: false, null: false + t.string "totp_secret" + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_users_on_email_address", unique: true + t.index ["status"], name: "index_users_on_status" + end + + add_foreign_key "application_groups", "applications" + add_foreign_key "application_groups", "groups" + add_foreign_key "oidc_access_tokens", "applications" + add_foreign_key "oidc_access_tokens", "users" + add_foreign_key "oidc_authorization_codes", "applications" + add_foreign_key "oidc_authorization_codes", "users" + add_foreign_key "sessions", "users" + add_foreign_key "user_groups", "groups" + add_foreign_key "user_groups", "users" end diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb new file mode 100644 index 0000000..e1a1b03 --- /dev/null +++ b/test/controllers/passwords_controller_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class PasswordsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "new" do + get new_password_path + assert_response :success + end + + test "create" do + post passwords_path, params: { email_address: @user.email_address } + assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "reset instructions sent" + end + + test "create for an unknown user redirects but sends no mail" do + post passwords_path, params: { email_address: "missing-user@example.com" } + assert_enqueued_emails 0 + assert_redirected_to new_session_path + + follow_redirect! + assert_notice "reset instructions sent" + end + + test "edit" do + get edit_password_path(@user.password_reset_token) + assert_response :success + end + + test "edit with invalid password reset token" do + get edit_password_path("invalid token") + assert_redirected_to new_password_path + + follow_redirect! + assert_notice "reset link is invalid" + end + + test "update" do + assert_changes -> { @user.reload.password_digest } do + put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" } + assert_redirected_to new_session_path + end + + follow_redirect! + assert_notice "Password has been reset" + end + + test "update with non matching passwords" do + token = @user.password_reset_token + assert_no_changes -> { @user.reload.password_digest } do + put password_path(token), params: { password: "no", password_confirmation: "match" } + assert_redirected_to edit_password_path(token) + end + + follow_redirect! + assert_notice "Passwords did not match" + end + + private + def assert_notice(text) + assert_select "div", /#{text}/ + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 0000000..07d72ef --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + setup { @user = User.take } + + test "new" do + get new_session_path + assert_response :success + end + + test "create with valid credentials" do + post session_path, params: { email_address: @user.email_address, password: "password" } + + assert_redirected_to root_path + assert cookies[:session_id] + end + + test "create with invalid credentials" do + post session_path, params: { email_address: @user.email_address, password: "wrong" } + + assert_redirected_to new_session_path + assert_nil cookies[:session_id] + end + + test "destroy" do + sign_in_as(User.take) + + delete session_path + + assert_redirected_to new_session_path + assert_empty cookies[:session_id] + end +end diff --git a/test/fixtures/application_groups.yml b/test/fixtures/application_groups.yml new file mode 100644 index 0000000..bd80855 --- /dev/null +++ b/test/fixtures/application_groups.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + application: one + group: one + +two: + application: two + group: two diff --git a/test/fixtures/applications.yml b/test/fixtures/applications.yml new file mode 100644 index 0000000..b1d9e50 --- /dev/null +++ b/test/fixtures/applications.yml @@ -0,0 +1,21 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + slug: MyString + app_type: MyString + client_id: MyString + client_secret: MyString + redirect_uris: MyText + metadata: MyText + active: false + +two: + name: MyString + slug: MyString + app_type: MyString + client_id: MyString + client_secret: MyString + redirect_uris: MyText + metadata: MyText + active: false diff --git a/test/fixtures/groups.yml b/test/fixtures/groups.yml new file mode 100644 index 0000000..382f6d8 --- /dev/null +++ b/test/fixtures/groups.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + description: MyText + +two: + name: MyString + description: MyText diff --git a/test/fixtures/oidc_access_tokens.yml b/test/fixtures/oidc_access_tokens.yml new file mode 100644 index 0000000..09d5ec7 --- /dev/null +++ b/test/fixtures/oidc_access_tokens.yml @@ -0,0 +1,15 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + token: MyString + application: one + user: one + scope: MyString + expires_at: 2025-10-23 16:40:39 + +two: + token: MyString + application: two + user: two + scope: MyString + expires_at: 2025-10-23 16:40:39 diff --git a/test/fixtures/oidc_authorization_codes.yml b/test/fixtures/oidc_authorization_codes.yml new file mode 100644 index 0000000..5127f03 --- /dev/null +++ b/test/fixtures/oidc_authorization_codes.yml @@ -0,0 +1,19 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + code: MyString + application: one + user: one + redirect_uri: MyString + scope: MyString + expires_at: 2025-10-23 16:40:38 + used: false + +two: + code: MyString + application: two + user: two + redirect_uri: MyString + scope: MyString + expires_at: 2025-10-23 16:40:38 + used: false diff --git a/test/fixtures/user_groups.yml b/test/fixtures/user_groups.yml new file mode 100644 index 0000000..45383c3 --- /dev/null +++ b/test/fixtures/user_groups.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + group: one + +two: + user: two + group: two diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..0951563 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,9 @@ +<% password_digest = BCrypt::Password.create("password") %> + +one: + email_address: one@example.com + password_digest: <%= password_digest %> + +two: + email_address: two@example.com + password_digest: <%= password_digest %> diff --git a/test/mailers/previews/passwords_mailer_preview.rb b/test/mailers/previews/passwords_mailer_preview.rb new file mode 100644 index 0000000..01d07ec --- /dev/null +++ b/test/mailers/previews/passwords_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer +class PasswordsMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset + def reset + PasswordsMailer.reset(User.take) + end +end diff --git a/test/models/application_group_test.rb b/test/models/application_group_test.rb new file mode 100644 index 0000000..ac50ae9 --- /dev/null +++ b/test/models/application_group_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ApplicationGroupTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/application_test.rb b/test/models/application_test.rb new file mode 100644 index 0000000..4103045 --- /dev/null +++ b/test/models/application_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ApplicationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/group_test.rb b/test/models/group_test.rb new file mode 100644 index 0000000..eddbcc8 --- /dev/null +++ b/test/models/group_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class GroupTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/oidc_access_token_test.rb b/test/models/oidc_access_token_test.rb new file mode 100644 index 0000000..3c4213d --- /dev/null +++ b/test/models/oidc_access_token_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class OidcAccessTokenTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/oidc_authorization_code_test.rb b/test/models/oidc_authorization_code_test.rb new file mode 100644 index 0000000..50717df --- /dev/null +++ b/test/models/oidc_authorization_code_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class OidcAuthorizationCodeTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_group_test.rb b/test/models/user_group_test.rb new file mode 100644 index 0000000..4eeae12 --- /dev/null +++ b/test/models/user_group_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserGroupTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..83445c4 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + test "downcases and strips email_address" do + user = User.new(email_address: " DOWNCASED@EXAMPLE.COM ") + assert_equal("downcased@example.com", user.email_address) + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470..85c54c6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,7 @@ ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" +require_relative "test_helpers/session_test_helper" module ActiveSupport class TestCase diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb new file mode 100644 index 0000000..0686378 --- /dev/null +++ b/test/test_helpers/session_test_helper.rb @@ -0,0 +1,19 @@ +module SessionTestHelper + def sign_in_as(user) + Current.session = user.sessions.create! + + ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar| + cookie_jar.signed[:session_id] = Current.session.id + cookies["session_id"] = cookie_jar[:session_id] + end + end + + def sign_out + Current.session&.destroy! + cookies.delete("session_id") + end +end + +ActiveSupport.on_load(:action_dispatch_integration_test) do + include SessionTestHelper +end