First crack
This commit is contained in:
8
Gemfile
8
Gemfile
@@ -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 ]
|
||||||
|
|||||||
10
Gemfile.lock
10
Gemfile.lock
@@ -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
|
||||||
|
|||||||
258
README.md
258
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
16
app/channels/application_cable/connection.rb
Normal file
16
app/channels/application_cable/connection.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
52
app/controllers/concerns/authentication.rb
Normal file
52
app/controllers/concerns/authentication.rb
Normal 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
|
||||||
35
app/controllers/passwords_controller.rb
Normal file
35
app/controllers/passwords_controller.rb
Normal 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
|
||||||
21
app/controllers/sessions_controller.rb
Normal file
21
app/controllers/sessions_controller.rb
Normal 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
|
||||||
6
app/mailers/passwords_mailer.rb
Normal file
6
app/mailers/passwords_mailer.rb
Normal 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
70
app/models/application.rb
Normal 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
|
||||||
6
app/models/application_group.rb
Normal file
6
app/models/application_group.rb
Normal 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
4
app/models/current.rb
Normal 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
9
app/models/group.rb
Normal 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
|
||||||
34
app/models/oidc_access_token.rb
Normal file
34
app/models/oidc_access_token.rb
Normal 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
|
||||||
35
app/models/oidc_authorization_code.rb
Normal file
35
app/models/oidc_authorization_code.rb
Normal 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
33
app/models/session.rb
Normal 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
78
app/models/user.rb
Normal 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
6
app/models/user_group.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class UserGroup < ApplicationRecord
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :group
|
||||||
|
|
||||||
|
validates :user_id, uniqueness: { scope: :group_id }
|
||||||
|
end
|
||||||
21
app/views/passwords/edit.html.erb
Normal file
21
app/views/passwords/edit.html.erb
Normal 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>
|
||||||
17
app/views/passwords/new.html.erb
Normal file
17
app/views/passwords/new.html.erb
Normal 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>
|
||||||
6
app/views/passwords_mailer/reset.html.erb
Normal file
6
app/views/passwords_mailer/reset.html.erb
Normal 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>
|
||||||
4
app/views/passwords_mailer/reset.text.erb
Normal file
4
app/views/passwords_mailer/reset.text.erb
Normal 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) %>.
|
||||||
31
app/views/sessions/new.html.erb
Normal file
31
app/views/sessions/new.html.erb
Normal 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>
|
||||||
@@ -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.
|
||||||
|
|||||||
11
db/migrate/20251023053651_create_users.rb
Normal file
11
db/migrate/20251023053651_create_users.rb
Normal 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
|
||||||
11
db/migrate/20251023053652_create_sessions.rb
Normal file
11
db/migrate/20251023053652_create_sessions.rb
Normal 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
|
||||||
11
db/migrate/20251023053722_add_auth_fields_to_users.rb
Normal file
11
db/migrate/20251023053722_add_auth_fields_to_users.rb
Normal 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
|
||||||
11
db/migrate/20251023053740_add_device_tracking_to_sessions.rb
Normal file
11
db/migrate/20251023053740_add_device_tracking_to_sessions.rb
Normal 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
|
||||||
11
db/migrate/20251023053836_create_groups.rb
Normal file
11
db/migrate/20251023053836_create_groups.rb
Normal 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
|
||||||
12
db/migrate/20251023053837_create_user_groups.rb
Normal file
12
db/migrate/20251023053837_create_user_groups.rb
Normal 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
|
||||||
19
db/migrate/20251023053927_create_applications.rb
Normal file
19
db/migrate/20251023053927_create_applications.rb
Normal 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
|
||||||
12
db/migrate/20251023053938_create_application_groups.rb
Normal file
12
db/migrate/20251023053938_create_application_groups.rb
Normal 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
|
||||||
18
db/migrate/20251023054038_create_oidc_authorization_codes.rb
Normal file
18
db/migrate/20251023054038_create_oidc_authorization_codes.rb
Normal 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
|
||||||
16
db/migrate/20251023054039_create_oidc_access_tokens.rb
Normal file
16
db/migrate/20251023054039_create_oidc_access_tokens.rb
Normal 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
116
db/schema.rb
generated
@@ -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
|
||||||
|
|||||||
67
test/controllers/passwords_controller_test.rb
Normal file
67
test/controllers/passwords_controller_test.rb
Normal 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
|
||||||
33
test/controllers/sessions_controller_test.rb
Normal file
33
test/controllers/sessions_controller_test.rb
Normal 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
9
test/fixtures/application_groups.yml
vendored
Normal 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
21
test/fixtures/applications.yml
vendored
Normal 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
9
test/fixtures/groups.yml
vendored
Normal 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
15
test/fixtures/oidc_access_tokens.yml
vendored
Normal 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
|
||||||
19
test/fixtures/oidc_authorization_codes.yml
vendored
Normal file
19
test/fixtures/oidc_authorization_codes.yml
vendored
Normal 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
9
test/fixtures/user_groups.yml
vendored
Normal 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
9
test/fixtures/users.yml
vendored
Normal 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 %>
|
||||||
7
test/mailers/previews/passwords_mailer_preview.rb
Normal file
7
test/mailers/previews/passwords_mailer_preview.rb
Normal 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
|
||||||
7
test/models/application_group_test.rb
Normal file
7
test/models/application_group_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationGroupTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/application_test.rb
Normal file
7
test/models/application_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/group_test.rb
Normal file
7
test/models/group_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class GroupTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/oidc_access_token_test.rb
Normal file
7
test/models/oidc_access_token_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcAccessTokenTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/oidc_authorization_code_test.rb
Normal file
7
test/models/oidc_authorization_code_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcAuthorizationCodeTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
7
test/models/user_group_test.rb
Normal file
7
test/models/user_group_test.rb
Normal 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
8
test/models/user_test.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
19
test/test_helpers/session_test_helper.rb
Normal file
19
test/test_helpers/session_test_helper.rb
Normal 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
|
||||||
Reference in New Issue
Block a user