asxpio/lib/user.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

114 lines
3.8 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

require 'sequel'
require 'openssl'
require 'securerandom'
require 'base64'
require 'rack/utils'
require 'uri'
# Admin accounts. Passwords are scrypt hashes from Ruby's OpenSSL (no gem):
# "scrypt$<logN>$<r>$<p>$<salt>$<hash>", base64 salt/hash.
class User < Sequel::Model(:users)
AREAS = %w[invoices bingo users].freeze
PERMS = %w[read write delete].freeze
NAME_RE = /\A[a-z0-9][a-z0-9_.@-]{1,63}\z/
MIN_PASS = 12
# 2^15 → 32 MiB, ~0.1 s per check; tests lower it via SCRYPT_LOG_N.
SCRYPT = { log_n: Integer(ENV.fetch('SCRYPT_LOG_N', '15')), r: 8, p: 1 }.freeze
one_to_many :permissions, class: 'UserPermission', key: :user_id
def self.hash_password(pass, log_n: SCRYPT[:log_n], r: SCRYPT[:r], p: SCRYPT[:p])
salt = SecureRandom.random_bytes(16)
dk = OpenSSL::KDF.scrypt(pass.to_s, salt: salt, N: 2**log_n, r: r, p: p, length: 32)
['scrypt', log_n, r, p, Base64.strict_encode64(salt), Base64.strict_encode64(dk)].join('$')
end
def self.verify_password(pass, digest)
algo, log_n, r, p, salt, hash = digest.to_s.split('$')
return false unless algo == 'scrypt' && hash
dk = OpenSSL::KDF.scrypt(pass.to_s, salt: Base64.strict_decode64(salt), N: 2**log_n.to_i,
r: r.to_i, p: p.to_i, length: 32)
Rack::Utils.secure_compare(Base64.strict_encode64(dk), hash)
end
# Same cost as a real check, so unknown usernames aren't faster to reject.
DUMMY_DIGEST = hash_password(SecureRandom.hex(16))
def self.authenticate(username, pass)
user = self[username: username.to_s.strip.downcase]
ok = verify_password(pass, user&.password_digest || DUMMY_DIGEST)
user if ok && user&.active && user.password_digest
end
def self.active_superuser?
where(superuser: true, active: true).exclude(password_digest: nil).any?
end
# First boot: turn ADMIN_USER/ADMIN_PASSWORD into the first superuser.
def self.bootstrap!(username, pass)
return if count.positive? || username.to_s.strip.empty? || pass.to_s.empty?
create(username: username.strip.downcase, superuser: true, password: pass)
$logger.info("users: bootstrapped superuser #{username.strip.downcase}")
end
def before_create
self.created_at ||= Time.now.utc
super
end
def validate
super
errors.add(:username, 'lowercase letters, digits, . _ @ - (2–64)') unless username.to_s.match?(NAME_RE)
errors.add(:email, 'invalid') if email && !email.match?(URI::MailTo::EMAIL_REGEXP)
end
def password=(pass)
self.password_digest = self.class.hash_password(pass)
end
def authenticate(pass)
password_digest && self.class.verify_password(pass, password_digest)
end
def can?(area, perm)
return true if superuser
row = permissions.find { |p| p.area == area.to_s }
row ? row[:"can_#{perm}"] : false
end
def logout_everywhere!
update(session_version: session_version + 1)
end
end
class UserPermission < Sequel::Model(:user_permissions)
unrestrict_primary_key
many_to_one :user
end
# One-time invite / reset links; see migration 008.
class UserToken < Sequel::Model(:user_tokens)
TTL = { 'invite' => 48 * 3600, 'reset' => 3600 }.freeze
many_to_one :user
def self.digest(raw) = OpenSSL::Digest::SHA256.hexdigest(raw.to_s)
# Returns the raw token; earlier unused tokens of the same purpose die.
def self.issue!(user, purpose)
where(user_id: user.id, purpose: purpose, used_at: nil).delete
raw = SecureRandom.urlsafe_base64(32)
create(user_id: user.id, purpose: purpose, token_digest: digest(raw),
expires_at: Time.now.utc + TTL.fetch(purpose), created_at: Time.now.utc)
raw
end
def self.find_valid(raw, purpose)
t = self[token_digest: digest(raw), purpose: purpose]
t if t && t.used_at.nil? && t.expires_at > Time.now && t.user&.active
end
def use!
update(used_at: Time.now.utc)
end
end