asxpio/lib/admin_auth.rb
Sergei Poljanski 9a802106ce
All checks were successful
Build and Deploy to Production / test (push) Successful in 26s
Build and Deploy to Production / build (push) Successful in 1m1s
Build and Deploy to Production / deploy (push) Successful in 36s
admin: send /admin/ to /admin, also after login
Opening /admin/ logged out stored next=/admin/ in the login form, and a
successful login then redirected to /admin/, which has no route (404).

Redirect trailing-slash admin URLs to the slashless route with a 301,
and drop trailing slashes from the post-login target too.

Assisted-by: Claude:opus-5-5
2026-10-18 10:39:10 +04:00

162 lines
5.7 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
# Trailing slashes dropped: /admin/ has no route (see the before filter).
def safe_next(target)
target = target.to_s.sub(%r{(?<=.)/+\z}, '')
target.match?(%r{\A/admin(/[\w\-/]*)?\z}) ? target : '/admin'
end
end
def self.registered(app)
app.helpers Helpers
app.before do
next unless admin_path?
# One URL per page: /admin/ and /admin/bingo/ go to the slashless route.
redirect request.fullpath.sub(%r{/+(?=\?|\z)}, ''), 301 if request.path_info.match?(%r{./\z})
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