First crack
This commit is contained in:
@@ -20,7 +20,13 @@ gem "tailwindcss-rails"
|
|||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
|
|
||||||
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
|
# 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
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ GEM
|
|||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
|
bcrypt (3.1.20)
|
||||||
bcrypt_pbkdf (1.1.1)
|
bcrypt_pbkdf (1.1.1)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
@@ -99,6 +100,7 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.4)
|
connection_pool (2.5.4)
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
@@ -271,6 +273,11 @@ GEM
|
|||||||
reline (0.6.2)
|
reline (0.6.2)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
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)
|
rubocop (1.81.6)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
@@ -396,6 +403,7 @@ PLATFORMS
|
|||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
|
bcrypt (~> 3.1.7)
|
||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
bundler-audit
|
bundler-audit
|
||||||
@@ -408,6 +416,8 @@ DEPENDENCIES
|
|||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.0)
|
rails (~> 8.1.0)
|
||||||
|
rotp (~> 6.3)
|
||||||
|
rqrcode (~> 2.0)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
solid_cable
|
solid_cable
|
||||||
|
|||||||
@@ -1,44 +1,246 @@
|
|||||||
# README
|
# Clinch
|
||||||
|
|
||||||
Clinch is a lightweight, self-hosted identity & SSO portal for home-labs.
|
**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.
|
|
||||||
|
|
||||||
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:
|
**Clinch is a lightweight alternative to Authelia and Authentik**, designed for simplicity and ease of deployment.
|
||||||
– invitation links (one-time token)
|
|
||||||
– password-reset links
|
|
||||||
– 2FA back-up codes
|
|
||||||
|
|
||||||
Optional per-user TOTP (QR code + scratch codes).
|
---
|
||||||
|
|
||||||
Auth mechanisms exposed to client apps
|
## Features
|
||||||
|
|
||||||
OpenID Connect (OIDC)
|
### User Management
|
||||||
Standard OAuth2/OIDC provider endpoints (/.well-known/openid-configuration, /authorize, /token, /userinfo).
|
- **First-run wizard** - Initial user automatically becomes admin
|
||||||
Client apps (Audiobookshelf, Kavita, Grafana, …) redirect to Clinch for login; Clinch returns ID- and access-tokens.
|
- **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)
|
### Authentication Methods
|
||||||
Reverse-proxy (Caddy, Traefik, Nginx) sends every request to clinch:9000/api/verify.
|
- **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.
|
### SSO Protocols
|
||||||
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.
|
|
||||||
|
|
||||||
* Configuration
|
#### OpenID Connect (OIDC)
|
||||||
ENV files
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
|
- `/authorize` - Authorization endpoint
|
||||||
|
- `/token` - Token endpoint
|
||||||
|
- `/userinfo` - User info endpoint
|
||||||
|
|
||||||
* Database creation
|
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens.
|
||||||
SQLite only
|
|
||||||
|
|
||||||
* 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
|
### SMTP Integration
|
||||||
Docker
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
|
include Authentication
|
||||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
allow_browser versions: :modern
|
allow_browser versions: :modern
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class PasswordsMailer < ApplicationMailer
|
||||||
|
def reset(user)
|
||||||
|
@user = user
|
||||||
|
mail subject: "Reset your password", to: user.email_address
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class ApplicationGroup < ApplicationRecord
|
||||||
|
belongs_to :application
|
||||||
|
belongs_to :group
|
||||||
|
|
||||||
|
validates :application_id, uniqueness: { scope: :group_id }
|
||||||
|
end
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
class Current < ActiveSupport::CurrentAttributes
|
||||||
|
attribute :session
|
||||||
|
delegate :user, to: :session, allow_nil: true
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class UserGroup < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :group
|
||||||
|
|
||||||
|
validates :user_id, uniqueness: { scope: :group_id }
|
||||||
|
end
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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) %>.
|
||||||
@@ -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>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
Rails.application.routes.draw do
|
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
|
# 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.
|
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+115
-1
@@ -10,5 +10,119 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Vendored
+9
@@ -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
|
||||||
Vendored
+21
@@ -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
|
||||||
Vendored
+9
@@ -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
|
||||||
Vendored
+15
@@ -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
|
||||||
+19
@@ -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
|
||||||
Vendored
+9
@@ -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
|
||||||
Vendored
+9
@@ -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 %>
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationGroupTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class GroupTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcAccessTokenTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcAuthorizationCodeTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class UserGroupTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
ENV["RAILS_ENV"] ||= "test"
|
ENV["RAILS_ENV"] ||= "test"
|
||||||
require_relative "../config/environment"
|
require_relative "../config/environment"
|
||||||
require "rails/test_help"
|
require "rails/test_help"
|
||||||
|
require_relative "test_helpers/session_test_helper"
|
||||||
|
|
||||||
module ActiveSupport
|
module ActiveSupport
|
||||||
class TestCase
|
class TestCase
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user