Give each user independent read, write and delete flags per area (invoices, bingo, users), so someone can e.g. review and remove bingo incidents without being able to add any. The request method and path decide the flag needed: GET is read except the new/duplicate forms, POST to .../delete is delete, any other POST is write. Unmapped admin paths are superuser-only, and a test enumerates every registered admin route so a new one cannot ship without a rule. Pages follow the same flags: nav and dashboard tiles show readable areas only, write forms and delete buttons are hidden, and the datacenter form is shown read-only. Assisted-by: Claude:opus-5
149 lines
4.9 KiB
Ruby
149 lines
4.9 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 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}")
|
|
end
|
|
if user
|
|
log_in!(user)
|
|
redirect @next
|
|
else
|
|
$logger.info("users: failed login for #{params[:username].to_s[0, 64].inspect} from #{client_ip}")
|
|
@error = 'Wrong username or password.'
|
|
status 401
|
|
erb :'admin/login'
|
|
end
|
|
end
|
|
|
|
app.post '/admin/logout' do
|
|
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)
|
|
redirect '/admin'
|
|
end
|
|
end
|
|
end
|