asxpio/lib/admin_auth.rb
Sergei Poljanski d91a72f603
admin: user management, invites, password reset, audit log
Add /admin/users to create, edit, disable and delete accounts with the
per-area permission grid. New users get a one-time invite link (48 h)
by email; the admin also sees it once, so an account without email can
still be onboarded. Forgotten passwords get a 1 h reset link with the
same answer for unknown accounts. Only token hashes are stored.

Guards against escalation and lockout: a user manager can only grant
flags they hold and cannot touch flags they lack; only superusers change
superuser accounts; nobody can disable or delete themselves; the last
active superuser stays.

Logins, user changes, and bingo and invoice actions are recorded in an
audit log at /admin/audit.

Read timestamps as UTC: they are all written with Time.now.utc, and on a
UTC+4 host a fresh 1 h token read back as already expired.

Assisted-by: Claude:opus-5
2026-10-10 08:31:42 +04:00

158 lines
5.4 KiB
Ruby

require 'rack/utils'
require_relative 'rate_limit'
require_relative 'admin_acl'
# Session login for /admin/*, replacing the old shared HTTP Basic gate.
#
# - Accounts live in `users` (lib/user.rb); the session stores user id +
# session_version, so bumping the version logs that user out everywhere.
# - ADMIN_USER/ADMIN_PASSWORD only work while no active superuser exists
# (first boot, or recovery); using them (re)creates that superuser.
# - Paths in PUBLIC need no login (login itself, invite/reset links).
module AdminAuth
PUBLIC = %r{\A/admin/(login|invite/[^/]+|reset(/[^/]+)?)\z}
# Reachable by any logged-in user regardless of permissions.
OPEN = %r{\A/admin(/account/password|/logout)?\z}
class << self
attr_writer :login_limit
def login_limit
@login_limit ||= RateLimit.new(limit: Integer(ENV.fetch('LOGIN_RATE_LIMIT', '10')), window: 900)
end
end
module Helpers
def current_user
return @current_user if defined?(@current_user)
uid = session[:uid]
user = uid && User[uid]
@current_user = user if user&.active && user.session_version == session[:uv]
end
# For views: show a form/button only if the user may use it.
def can?(area, perm)
current_user&.can?(area, perm) || false
end
def admin_path?(path = request.path_info)
path == '/admin' || path.start_with?('/admin/')
end
def log_in!(user)
session.clear # fresh session id/CSRF token after authentication
session[:uid] = user.id
session[:uv] = user.session_version
user.update(last_login_at: Time.now.utc)
@current_user = user
end
def env_credentials?(username, pass)
u, p = ENV['ADMIN_USER'].to_s, ENV['ADMIN_PASSWORD'].to_s
return false if u.empty? || p.empty?
Rack::Utils.secure_compare(u.downcase, username.to_s.strip.downcase) &
Rack::Utils.secure_compare(p, pass.to_s)
end
def audit!(action, target = nil, detail = nil, user: current_user)
Audit.log(action: action, user: user, target: target, detail: detail, ip: client_ip)
end
def safe_next(target)
target.to_s.match?(%r{\A/admin(/[\w\-/]*)?\z}) ? target : '/admin'
end
end
def self.registered(app)
app.helpers Helpers
app.before do
next unless admin_path?
halt 503, 'Admin needs DATABASE_URL.' unless defined?(User)
next if request.path_info.match?(PUBLIC)
unless current_user
redirect "/admin/login?next=#{Rack::Utils.escape(request.fullpath)}" if request.get?
halt 401, 'Login required'
end
if current_user.must_change_password && !request.path_info.match?(%r{\A/admin/(account/password|logout)\z})
redirect '/admin/account/password'
end
unless AdminACL.allowed?(current_user, request.request_method, request.path_info)
halt 403, 'You do not have access to this.'
end
end
app.get '/admin/login' do
redirect '/admin' if current_user
@page_title = 'Log in · admin'
@noindex = true
@next = safe_next(params[:next])
erb :'admin/login'
end
app.post '/admin/login' do
@next = safe_next(params[:next])
unless AdminAuth.login_limit.allow?(client_ip)
@error = 'Too many attempts. Try again in 15 minutes.'
status 429
return erb :'admin/login'
end
user = User.authenticate(params[:username], params[:password])
if !user && !User.active_superuser? && env_credentials?(params[:username], params[:password])
# Recovery: no usable superuser left, so the env credentials restore one.
name = ENV['ADMIN_USER'].strip.downcase
user = User[username: name] || User.new(username: name)
user.set(superuser: true, active: true)
user.password = ENV['ADMIN_PASSWORD']
user.save
$logger.warn("users: emergency login restored superuser #{name} from #{client_ip}")
audit!('auth.emergency_login', name, user: user)
end
if user
log_in!(user)
audit!('auth.login')
redirect @next
else
$logger.info("users: failed login for #{params[:username].to_s[0, 64].inspect} from #{client_ip}")
Audit.log(action: 'auth.login_failed', username: params[:username].to_s[0, 64], ip: client_ip)
@error = 'Wrong username or password.'
status 401
erb :'admin/login'
end
end
app.post '/admin/logout' do
audit!('auth.logout') if current_user
session.clear
redirect '/admin/login'
end
app.get '/admin/account/password' do
@page_title = 'Change password · admin'
@noindex = true
erb :'admin/account_password'
end
app.post '/admin/account/password' do
u = current_user
@error =
if !u.authenticate(params[:current]) then 'Current password is wrong.'
elsif params[:password].to_s.length < User::MIN_PASS then "New password needs at least #{User::MIN_PASS} characters."
elsif params[:password] != params[:confirm] then 'Passwords do not match.'
end
if @error
@page_title = 'Change password · admin'
@noindex = true
status 422
return erb :'admin/account_password'
end
u.password = params[:password]
u.must_change_password = false
u.session_version += 1 # other sessions out
u.save
log_in!(u)
audit!('auth.password_changed')
redirect '/admin'
end
end
end