admin: user accounts and session login
The admin was one shared HTTP Basic login: no way to give someone partial access, revoke one person, or tell who changed what. Add a users table and a session login form. Passwords are scrypt hashes from Ruby's OpenSSL, so no native gem is needed. Sessions carry a per-user version that is bumped on password change, which logs that user out everywhere. ADMIN_USER/ADMIN_PASSWORD now bootstrap the first superuser on an empty table and afterwards only work while no active superuser exists, as a recovery path that re-creates one. Until per-area permissions land, non-superusers only reach the dashboard and their own password page. Assisted-by: Claude:opus-5
This commit is contained in:
parent
c2020bf903
commit
9bf40690e5
13 changed files with 436 additions and 59 deletions
|
|
@ -36,7 +36,7 @@ Banking details and registration number are deliberately not on the public page
|
||||||
- **aws-sdk-s3** against MinIO on the storage stack
|
- **aws-sdk-s3** against MinIO on the storage stack
|
||||||
- In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb`
|
- In-memory `RateLimit` (5 req/hour/IP) — see `lib/rate_limit.rb`
|
||||||
- `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token)
|
- `Rack::Protection::AuthenticityToken` for CSRF (uses session-stored token)
|
||||||
- `AdminAuth` Rack middleware: HTTP Basic on `/admin/*`, credentials from env. Greedy-prefix bug fixed; only `/admin` exact and `/admin/` paths are gated, so sibling assets like `/admin-invoice-form.js` stay public.
|
- `AdminAuth` (`lib/admin_auth.rb`, a Sinatra extension): session login at `/admin/login` against the `users` table (`lib/user.rb`, scrypt via OpenSSL, no gem). Session holds user id + `session_version` (bump = logged out everywhere). `ADMIN_USER`/`ADMIN_PASSWORD` bootstrap the first superuser on an empty table and otherwise work only while no active superuser exists (recovery; it re-creates that superuser). Login is rate-limited per IP (`LOGIN_RATE_LIMIT`, default 10/15 min).
|
||||||
- Honeypot field named `website` for spam
|
- Honeypot field named `website` for spam
|
||||||
- Static assets in `public/`; views in `views/`
|
- Static assets in `public/`; views in `views/`
|
||||||
- Deploy: `Dockerfile` + `docker-compose.yml` (Traefik labels for asxp.io / www.asxp.io). Container joins two external Docker networks: `traefik` (ingress) and `storage` (Postgres + MinIO).
|
- Deploy: `Dockerfile` + `docker-compose.yml` (Traefik labels for asxp.io / www.asxp.io). Container joins two external Docker networks: `traefik` (ingress) and `storage` (Postgres + MinIO).
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ if ENV['DATABASE_URL']
|
||||||
require_relative 'lib/invoice_pdf'
|
require_relative 'lib/invoice_pdf'
|
||||||
require_relative 'lib/bingo'
|
require_relative 'lib/bingo'
|
||||||
Bingo.seed!
|
Bingo.seed!
|
||||||
|
require_relative 'lib/user'
|
||||||
|
User.bootstrap!(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
|
||||||
else
|
else
|
||||||
$logger.warn('DATABASE_URL not set — invoicing disabled')
|
$logger.warn('DATABASE_URL not set — invoicing disabled')
|
||||||
end
|
end
|
||||||
|
|
@ -63,7 +65,7 @@ class AsxpioWeb < Sinatra::Base
|
||||||
same_site: :lax
|
same_site: :lax
|
||||||
|
|
||||||
use Rack::Protection::AuthenticityToken
|
use Rack::Protection::AuthenticityToken
|
||||||
use AdminAuth
|
register AdminAuth
|
||||||
register BingoAdmin
|
register BingoAdmin
|
||||||
|
|
||||||
Mailer.configure!
|
Mailer.configure!
|
||||||
|
|
|
||||||
28
db/migrations/007_users.rb
Normal file
28
db/migrations/007_users.rb
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
Sequel.migration do
|
||||||
|
change do
|
||||||
|
create_table(:users) do
|
||||||
|
primary_key :id
|
||||||
|
String :username, null: false, unique: true # login, lowercase
|
||||||
|
String :email, unique: true # invites / resets
|
||||||
|
String :name
|
||||||
|
String :password_digest # NULL until an invite is accepted
|
||||||
|
TrueClass :superuser, null: false, default: false
|
||||||
|
TrueClass :active, null: false, default: true
|
||||||
|
Integer :session_version, null: false, default: 1 # bump = log out everywhere
|
||||||
|
TrueClass :must_change_password, null: false, default: false
|
||||||
|
DateTime :last_login_at
|
||||||
|
DateTime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
# One row per user and area; flags are independent (read + delete
|
||||||
|
# without write is allowed). Superusers ignore this table.
|
||||||
|
create_table(:user_permissions) do
|
||||||
|
foreign_key :user_id, :users, null: false, on_delete: :cascade
|
||||||
|
String :area, null: false
|
||||||
|
TrueClass :can_read, null: false, default: false
|
||||||
|
TrueClass :can_write, null: false, default: false
|
||||||
|
TrueClass :can_delete, null: false, default: false
|
||||||
|
primary_key [:user_id, :area]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,35 +1,141 @@
|
||||||
require 'rack/auth/basic'
|
|
||||||
require 'rack/utils'
|
require 'rack/utils'
|
||||||
|
require_relative 'rate_limit'
|
||||||
|
|
||||||
# Rack middleware that gates everything under /admin/* behind HTTP Basic.
|
# Session login for /admin/*, replacing the old shared HTTP Basic gate.
|
||||||
# Credentials come from ENV at boot; missing vars fail closed (401 always).
|
#
|
||||||
class AdminAuth
|
# - Accounts live in `users` (lib/user.rb); the session stores user id +
|
||||||
def initialize(app)
|
# session_version, so bumping the version logs that user out everywhere.
|
||||||
@app = app
|
# - ADMIN_USER/ADMIN_PASSWORD only work while no active superuser exists
|
||||||
@user = ENV['ADMIN_USER']
|
# (first boot, or recovery); using them (re)creates that superuser.
|
||||||
@pass = ENV['ADMIN_PASSWORD']
|
# - 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
|
end
|
||||||
|
|
||||||
def call(env)
|
module Helpers
|
||||||
path = env['PATH_INFO'].to_s
|
def current_user
|
||||||
return @app.call(env) unless path == '/admin' || path.start_with?('/admin/')
|
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
|
||||||
|
|
||||||
auth = Rack::Auth::Basic::Request.new(env)
|
def admin_path?(path = request.path_info)
|
||||||
if @user && @pass && auth.provided? && auth.basic? && credentials_match?(auth.credentials)
|
path == '/admin' || path.start_with?('/admin/')
|
||||||
@app.call(env)
|
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
|
||||||
|
halt 403, 'Forbidden' unless current_user.superuser || request.path_info.match?(OPEN)
|
||||||
|
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
|
else
|
||||||
[401,
|
$logger.info("users: failed login for #{params[:username].to_s[0, 64].inspect} from #{client_ip}")
|
||||||
{ 'content-type' => 'text/plain', 'www-authenticate' => 'Basic realm="asxp.io admin"' },
|
@error = 'Wrong username or password.'
|
||||||
["Unauthorized\n"]]
|
status 401
|
||||||
|
erb :'admin/login'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
app.post '/admin/logout' do
|
||||||
|
session.clear
|
||||||
|
redirect '/admin/login'
|
||||||
|
end
|
||||||
|
|
||||||
def credentials_match?(creds)
|
app.get '/admin/account/password' do
|
||||||
user, pass = creds
|
@page_title = 'Change password · admin'
|
||||||
# Single & so both comparisons always run (no short-circuit timing signal).
|
@noindex = true
|
||||||
Rack::Utils.secure_compare(@user, user.to_s) &
|
erb :'admin/account_password'
|
||||||
Rack::Utils.secure_compare(@pass, pass.to_s)
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
87
lib/user.rb
Normal file
87
lib/user.rb
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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
|
||||||
|
|
@ -356,6 +356,9 @@ a:hover {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.admin-nav a { color: var(--text-dim); text-decoration: none; }
|
.admin-nav a { color: var(--text-dim); text-decoration: none; }
|
||||||
|
.admin-nav-user { display: flex; gap: 8px; align-items: baseline; margin-left: 16px; padding-left: 16px; border-left: 1px solid var(--border); }
|
||||||
|
.admin-nav-user form { display: inline; }
|
||||||
|
.auth-form { max-width: 360px; margin: 0 auto; }
|
||||||
.admin-nav a.active,
|
.admin-nav a.active,
|
||||||
.admin-nav a:hover { color: var(--text-bright); }
|
.admin-nav a:hover { color: var(--text-bright); }
|
||||||
.dash { display: grid; gap: 24px; }
|
.dash { display: grid; gap: 24px; }
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,129 @@
|
||||||
require_relative 'test_helper'
|
require_relative 'test_helper'
|
||||||
|
|
||||||
require 'base64'
|
|
||||||
|
|
||||||
class AdminAuthTest < Minitest::Test
|
class AdminAuthTest < Minitest::Test
|
||||||
OK_APP = ->(_env) { [200, {}, ['ok']] }
|
include Rack::Test::Methods
|
||||||
|
include AdminLogin
|
||||||
|
|
||||||
|
def app
|
||||||
|
AsxpioWeb
|
||||||
|
end
|
||||||
|
|
||||||
def setup
|
def setup
|
||||||
@auth = AdminAuth.new(OK_APP)
|
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||||
|
TestDb.clean_users!
|
||||||
end
|
end
|
||||||
|
|
||||||
def env_for(path, user: nil, pass: nil)
|
def login(username, password)
|
||||||
env = { 'PATH_INFO' => path }
|
get '/admin/login'
|
||||||
if user
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||||
env['HTTP_AUTHORIZATION'] = "Basic #{Base64.strict_encode64("#{user}:#{pass}")}"
|
post '/admin/login', { authenticity_token: token, username: username, password: password }
|
||||||
end
|
|
||||||
env
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_correct_credentials_pass
|
def test_password_hashing
|
||||||
assert_equal 200, @auth.call(env_for('/admin/invoices', user: 'admin', pass: 'test-password'))[0]
|
d = User.hash_password('correct horse')
|
||||||
|
assert_match(/\Ascrypt\$\d+\$8\$1\$/, d)
|
||||||
|
assert User.verify_password('correct horse', d)
|
||||||
|
refute User.verify_password('wrong', d)
|
||||||
|
refute_equal d, User.hash_password('correct horse') # salted
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_wrong_password_rejected
|
def test_bootstrap_only_once
|
||||||
assert_equal 401, @auth.call(env_for('/admin/invoices', user: 'admin', pass: 'nope'))[0]
|
User.bootstrap!('Boss', 'first-password-1')
|
||||||
|
assert User[username: 'boss'].superuser
|
||||||
|
User.bootstrap!('other', 'x' * 12)
|
||||||
|
assert_equal 1, User.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_wrong_user_rejected
|
def test_login_and_logout
|
||||||
assert_equal 401, @auth.call(env_for('/admin/invoices', user: 'other', pass: 'test-password'))[0]
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
||||||
|
login('alice', 'alice-password-1')
|
||||||
|
assert_equal 302, last_response.status
|
||||||
|
get '/admin'
|
||||||
|
assert last_response.ok?
|
||||||
|
assert_includes last_response.body, 'alice'
|
||||||
|
get '/admin'
|
||||||
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||||
|
post '/admin/logout', { authenticity_token: token }
|
||||||
|
get '/admin'
|
||||||
|
assert_equal 302, last_response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_missing_header_rejected
|
def test_wrong_password_and_unknown_user
|
||||||
assert_equal 401, @auth.call(env_for('/admin'))[0]
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
||||||
|
login('alice', 'nope')
|
||||||
|
assert_equal 401, last_response.status
|
||||||
|
login('mallory', 'alice-password-1')
|
||||||
|
assert_equal 401, last_response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_sibling_paths_not_gated
|
def test_inactive_user_cannot_log_in
|
||||||
assert_equal 200, @auth.call(env_for('/'))[0]
|
User.create(username: 'alice', superuser: true, active: false, password: 'alice-password-1')
|
||||||
assert_equal 200, @auth.call(env_for('/admin-invoice-form.js'))[0]
|
User.create(username: 'root', superuser: true, password: 'root-password-11')
|
||||||
|
login('alice', 'alice-password-1')
|
||||||
|
assert_equal 401, last_response.status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_env_credentials_only_without_active_superuser
|
||||||
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
||||||
|
login(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
|
||||||
|
assert_equal 401, last_response.status # dormant: alice exists
|
||||||
|
|
||||||
|
User[username: 'alice'].update(active: false)
|
||||||
|
login(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
|
||||||
|
assert_equal 302, last_response.status # recovery
|
||||||
|
assert User[username: ENV['ADMIN_USER']].superuser
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_session_revoked_by_version_bump
|
||||||
|
u = User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
||||||
|
login('alice', 'alice-password-1')
|
||||||
|
get '/admin'
|
||||||
|
assert last_response.ok?
|
||||||
|
u.logout_everywhere!
|
||||||
|
get '/admin'
|
||||||
|
assert_equal 302, last_response.status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_non_superuser_limited_to_open_pages_in_phase_one
|
||||||
|
User.create(username: 'bob', password: 'bob-password-12')
|
||||||
|
login('bob', 'bob-password-12')
|
||||||
|
get '/admin'
|
||||||
|
assert last_response.ok?
|
||||||
|
get '/admin/account/password'
|
||||||
|
assert last_response.ok?
|
||||||
|
get '/admin/invoices'
|
||||||
|
assert_equal 403, last_response.status
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_must_change_password_flow
|
||||||
|
User.create(username: 'alice', superuser: true, must_change_password: true, password: 'temporary-pass-1')
|
||||||
|
login('alice', 'temporary-pass-1')
|
||||||
|
get '/admin/invoices'
|
||||||
|
assert_equal '/admin/account/password', URI(last_response.location).path
|
||||||
|
get '/admin/account/password'
|
||||||
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||||
|
post '/admin/account/password', { authenticity_token: token, current: 'temporary-pass-1', password: 'short', confirm: 'short' }
|
||||||
|
assert_equal 422, last_response.status
|
||||||
|
post '/admin/account/password', { authenticity_token: token, current: 'temporary-pass-1',
|
||||||
|
password: 'brand-new-pass-1', confirm: 'brand-new-pass-1' }
|
||||||
|
assert_equal 302, last_response.status
|
||||||
|
refute User[username: 'alice'].must_change_password
|
||||||
|
assert User[username: 'alice'].authenticate('brand-new-pass-1')
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_next_param_is_restricted_to_admin
|
||||||
|
User.create(username: 'alice', superuser: true, password: 'alice-password-1')
|
||||||
|
get '/admin/login?next=https://evil.example/'
|
||||||
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||||
|
post '/admin/login', { authenticity_token: token, username: 'alice', password: 'alice-password-1', next: 'https://evil.example/' }
|
||||||
|
assert_equal '/admin', URI(last_response.location).path
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_login_rate_limited
|
||||||
|
AdminAuth.login_limit = RateLimit.new(limit: 2, window: 900)
|
||||||
|
3.times { login('x', 'y') }
|
||||||
|
assert_equal 429, last_response.status
|
||||||
|
ensure
|
||||||
|
AdminAuth.login_limit = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ require_relative 'test_helper'
|
||||||
|
|
||||||
class AppTest < Minitest::Test
|
class AppTest < Minitest::Test
|
||||||
include Rack::Test::Methods
|
include Rack::Test::Methods
|
||||||
|
include AdminLogin
|
||||||
|
|
||||||
def app
|
def app
|
||||||
AsxpioWeb
|
AsxpioWeb
|
||||||
|
|
@ -19,10 +20,6 @@ class AppTest < Minitest::Test
|
||||||
flunk("no CSRF token found on #{path}")
|
flunk("no CSRF token found on #{path}")
|
||||||
end
|
end
|
||||||
|
|
||||||
def admin_env(extra = {})
|
|
||||||
{ 'HTTP_AUTHORIZATION' =>
|
|
||||||
"Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" }.merge(extra)
|
|
||||||
end
|
|
||||||
|
|
||||||
# --- public pages ---------------------------------------------------------
|
# --- public pages ---------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -55,9 +52,13 @@ class AppTest < Minitest::Test
|
||||||
|
|
||||||
# --- admin ------------------------------------------------------------
|
# --- admin ------------------------------------------------------------
|
||||||
|
|
||||||
def test_admin_requires_auth
|
def test_admin_requires_login
|
||||||
|
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||||
get '/admin/invoices'
|
get '/admin/invoices'
|
||||||
assert_equal 401, last_response.status
|
assert_equal 302, last_response.status
|
||||||
|
assert_match %r{/admin/login\?next=%2Fadmin%2Finvoices\z}, last_response.location
|
||||||
|
post '/admin/invoices'
|
||||||
|
assert_includes [401, 403], last_response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_list_with_auth
|
def test_admin_list_with_auth
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ require_relative 'test_helper'
|
||||||
|
|
||||||
class BingoTest < Minitest::Test
|
class BingoTest < Minitest::Test
|
||||||
include Rack::Test::Methods
|
include Rack::Test::Methods
|
||||||
|
include AdminLogin
|
||||||
|
|
||||||
# Minimal well-formed JPEG: SOI, start-of-scan, data, EOI.
|
# Minimal well-formed JPEG: SOI, start-of-scan, data, EOI.
|
||||||
JPEG = ("\xFF\xD8\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6data\xFF\xD9".b).freeze
|
JPEG = ("\xFF\xD8\xFF\xDA".b + [8].pack('n') + "\1\2\3\4\5\6data\xFF\xD9".b).freeze
|
||||||
|
|
@ -20,10 +21,6 @@ class BingoTest < Minitest::Test
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def admin_env
|
|
||||||
{ 'HTTP_AUTHORIZATION' =>
|
|
||||||
"Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" }
|
|
||||||
end
|
|
||||||
|
|
||||||
def hit!(idx, kind: 'outage')
|
def hit!(idx, kind: 'outage')
|
||||||
Bingo::Incident.create(datacenter_id: @dcs[idx].id, occurred_on: Date.today, kind: kind,
|
Bingo::Incident.create(datacenter_id: @dcs[idx].id, occurred_on: Date.today, kind: kind,
|
||||||
|
|
@ -247,10 +244,10 @@ class BingoTest < Minitest::Test
|
||||||
|
|
||||||
# --- admin (served by AsxpioWeb) -------------------------------------------
|
# --- admin (served by AsxpioWeb) -------------------------------------------
|
||||||
|
|
||||||
def test_admin_requires_auth
|
def test_admin_requires_login
|
||||||
@app = AsxpioWeb
|
@app = AsxpioWeb
|
||||||
get '/admin/bingo'
|
get '/admin/bingo'
|
||||||
assert_equal 401, last_response.status
|
assert_equal 302, last_response.status
|
||||||
end
|
end
|
||||||
|
|
||||||
def test_admin_dashboard
|
def test_admin_dashboard
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ ENV['MAIL_FROM'] ||= 'Test <test@example.com>'
|
||||||
ENV['MAIL_TO'] ||= 'owner@example.com'
|
ENV['MAIL_TO'] ||= 'owner@example.com'
|
||||||
ENV['ADMIN_USER'] ||= 'admin'
|
ENV['ADMIN_USER'] ||= 'admin'
|
||||||
ENV['ADMIN_PASSWORD'] ||= 'test-password'
|
ENV['ADMIN_PASSWORD'] ||= 'test-password'
|
||||||
|
ENV['SCRYPT_LOG_N'] ||= '10' # cheap hashes in tests
|
||||||
|
ENV['LOGIN_RATE_LIMIT'] ||= '10000'
|
||||||
|
|
||||||
# DB-backed tests run only against an explicit test database (bin/test
|
# DB-backed tests run only against an explicit test database (bin/test
|
||||||
# provisions an ephemeral one). Never fall through to a developer DATABASE_URL.
|
# provisions an ephemeral one). Never fall through to a developer DATABASE_URL.
|
||||||
|
|
@ -33,6 +35,26 @@ require 'rack/test'
|
||||||
|
|
||||||
Mail.defaults { delivery_method :test }
|
Mail.defaults { delivery_method :test }
|
||||||
|
|
||||||
|
# Logs the rack-test session in through the real form (CSRF included).
|
||||||
|
# `admin_env` keeps the old call sites working: it logs in once per test
|
||||||
|
# and returns extra env to merge.
|
||||||
|
module AdminLogin
|
||||||
|
def admin_login!(username: ENV['ADMIN_USER'], password: ENV['ADMIN_PASSWORD'])
|
||||||
|
User[username: username] ||
|
||||||
|
User.create(username: username, superuser: true, password: password)
|
||||||
|
get '/admin/login'
|
||||||
|
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||||
|
post '/admin/login', { authenticity_token: token, username: username, password: password }
|
||||||
|
raise "login failed: #{last_response.status}" unless last_response.redirect?
|
||||||
|
@admin_logged_in = true
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_env(extra = {})
|
||||||
|
admin_login! unless @admin_logged_in
|
||||||
|
extra
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
module TestDb
|
module TestDb
|
||||||
def self.available?
|
def self.available?
|
||||||
!ENV['DATABASE_URL'].nil?
|
!ENV['DATABASE_URL'].nil?
|
||||||
|
|
@ -41,4 +63,8 @@ module TestDb
|
||||||
def self.clean!
|
def self.clean!
|
||||||
DB.connection[:invoices].delete
|
DB.connection[:invoices].delete
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.clean_users!
|
||||||
|
DB.connection[:users].delete
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,10 @@
|
||||||
<% [['/admin', 'dashboard'], ['/admin/invoices', 'invoices'], ['/admin/bingo', 'bingo']].each do |href, label| %>
|
<% [['/admin', 'dashboard'], ['/admin/invoices', 'invoices'], ['/admin/bingo', 'bingo']].each do |href, label| %>
|
||||||
<a href="<%= href %>" class="<%= 'active' if href == '/admin' ? request.path == '/admin' : request.path.start_with?(href) %>"><%= label %></a>
|
<a href="<%= href %>" class="<%= 'active' if href == '/admin' ? request.path == '/admin' : request.path.start_with?(href) %>"><%= label %></a>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if current_user %>
|
||||||
|
<span class="admin-nav-user">
|
||||||
|
<a href="/admin/account/password" title="Change password"><%= current_user.username %></a>
|
||||||
|
<form method="post" action="/admin/logout"><input type="hidden" name="authenticity_token" value="<%= csrf_token %>" /><button type="submit" class="link-button">log out</button></form>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
17
views/admin/account_password.erb
Normal file
17
views/admin/account_password.erb
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<%== erb :'admin/_nav', layout: false %>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Change password</h1>
|
||||||
|
<% if current_user.must_change_password %><p class="tagline">Set a new password to continue.</p><% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<% if @error %><div class="form-error-base"><div><%= @error %></div></div><% end %>
|
||||||
|
<form class="invoice-form auth-form" method="post" action="/admin/account/password">
|
||||||
|
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||||
|
<input type="text" name="username" value="<%= current_user.username %>" autocomplete="username" hidden />
|
||||||
|
<div class="field"><label for="current">Current password</label><input type="password" id="current" name="current" autocomplete="current-password" required /></div>
|
||||||
|
<div class="field"><label for="password">New password <span class="optional">(at least <%= User::MIN_PASS %> characters)</span></label><input type="password" id="password" name="password" autocomplete="new-password" minlength="<%= User::MIN_PASS %>" required /></div>
|
||||||
|
<div class="field"><label for="confirm">Repeat new password</label><input type="password" id="confirm" name="confirm" autocomplete="new-password" required /></div>
|
||||||
|
<button type="submit" class="download-button">Change password</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
15
views/admin/login.erb
Normal file
15
views/admin/login.erb
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<div class="header">
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<p class="tagline">asxp.io</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<% if @error %><div class="form-error-base"><div><%= @error %></div></div><% end %>
|
||||||
|
<form class="invoice-form auth-form" method="post" action="/admin/login">
|
||||||
|
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
|
||||||
|
<input type="hidden" name="next" value="<%= @next %>" />
|
||||||
|
<div class="field"><label for="username">Username</label><input type="text" id="username" name="username" value="<%= params[:username] %>" autocomplete="username" autocapitalize="none" required autofocus /></div>
|
||||||
|
<div class="field"><label for="password">Password</label><input type="password" id="password" name="password" autocomplete="current-password" required /></div>
|
||||||
|
<button type="submit" class="download-button">Log in</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue