First crack
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-10-23 16:45:00 +11:00
parent 1ff0a95392
commit 56f7dd7b3c
54 changed files with 1249 additions and 30 deletions

View File

@@ -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 ]

View File

@@ -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

258
README.md
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,6 @@
class PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
end

70
app/models/application.rb Normal file
View File

@@ -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

View File

@@ -0,0 +1,6 @@
class ApplicationGroup < ApplicationRecord
belongs_to :application
belongs_to :group
validates :application_id, uniqueness: { scope: :group_id }
end

4
app/models/current.rb Normal file
View File

@@ -0,0 +1,4 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end

9
app/models/group.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

33
app/models/session.rb Normal file
View File

@@ -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

78
app/models/user.rb Normal file
View File

@@ -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

6
app/models/user_group.rb Normal file
View File

@@ -0,0 +1,6 @@
class UserGroup < ApplicationRecord
belongs_to :user
belongs_to :group
validates :user_id, uniqueness: { scope: :group_id }
end

View File

@@ -0,0 +1,21 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<h1 class="font-bold text-4xl">Update your password</h1>
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
<div class="my-5">
<%= 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" %>
</div>
<div class="my-5">
<%= 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" %>
</div>
<div class="inline">
<%= 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" %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,17 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<h1 class="font-bold text-4xl">Forgot your password?</h1>
<%= form_with url: passwords_path, class: "contents" do |form| %>
<div class="my-5">
<%= 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" %>
</div>
<div class="inline">
<%= 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" %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,6 @@
<p>
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) %>.
</p>

View File

@@ -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) %>.

View File

@@ -0,0 +1,31 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>
<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<h1 class="font-bold text-4xl">Sign in</h1>
<%= form_with url: session_url, class: "contents" do |form| %>
<div class="my-5">
<%= 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" %>
</div>
<div class="my-5">
<%= 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" %>
</div>
<div class="col-span-6 sm:flex sm:items-center sm:gap-4">
<div class="inline">
<%= 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" %>
</div>
<div class="mt-4 text-sm text-gray-500 sm:mt-0">
<%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline hover:no-underline" %>
</div>
</div>
<% end %>
</div>

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

116
db/schema.rb generated
View File

@@ -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

View File

@@ -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

View File

@@ -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

9
test/fixtures/application_groups.yml vendored Normal file
View File

@@ -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

21
test/fixtures/applications.yml vendored Normal file
View File

@@ -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

9
test/fixtures/groups.yml vendored Normal file
View File

@@ -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

15
test/fixtures/oidc_access_tokens.yml vendored Normal file
View File

@@ -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

View File

@@ -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

9
test/fixtures/user_groups.yml vendored Normal file
View File

@@ -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

9
test/fixtures/users.yml vendored Normal file
View File

@@ -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 %>

View File

@@ -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

View File

@@ -0,0 +1,7 @@
require "test_helper"
class ApplicationGroupTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class ApplicationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class GroupTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class OidcAccessTokenTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class OidcAuthorizationCodeTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class UserGroupTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

8
test/models/user_test.rb Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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