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
|
|
@ -1,40 +1,129 @@
|
|||
require_relative 'test_helper'
|
||||
|
||||
require 'base64'
|
||||
|
||||
class AdminAuthTest < Minitest::Test
|
||||
OK_APP = ->(_env) { [200, {}, ['ok']] }
|
||||
include Rack::Test::Methods
|
||||
include AdminLogin
|
||||
|
||||
def app
|
||||
AsxpioWeb
|
||||
end
|
||||
|
||||
def setup
|
||||
@auth = AdminAuth.new(OK_APP)
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
TestDb.clean_users!
|
||||
end
|
||||
|
||||
def env_for(path, user: nil, pass: nil)
|
||||
env = { 'PATH_INFO' => path }
|
||||
if user
|
||||
env['HTTP_AUTHORIZATION'] = "Basic #{Base64.strict_encode64("#{user}:#{pass}")}"
|
||||
end
|
||||
env
|
||||
def login(username, password)
|
||||
get '/admin/login'
|
||||
token = last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||
post '/admin/login', { authenticity_token: token, username: username, password: password }
|
||||
end
|
||||
|
||||
def test_correct_credentials_pass
|
||||
assert_equal 200, @auth.call(env_for('/admin/invoices', user: 'admin', pass: 'test-password'))[0]
|
||||
def test_password_hashing
|
||||
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
|
||||
|
||||
def test_wrong_password_rejected
|
||||
assert_equal 401, @auth.call(env_for('/admin/invoices', user: 'admin', pass: 'nope'))[0]
|
||||
def test_bootstrap_only_once
|
||||
User.bootstrap!('Boss', 'first-password-1')
|
||||
assert User[username: 'boss'].superuser
|
||||
User.bootstrap!('other', 'x' * 12)
|
||||
assert_equal 1, User.count
|
||||
end
|
||||
|
||||
def test_wrong_user_rejected
|
||||
assert_equal 401, @auth.call(env_for('/admin/invoices', user: 'other', pass: 'test-password'))[0]
|
||||
def test_login_and_logout
|
||||
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
|
||||
|
||||
def test_missing_header_rejected
|
||||
assert_equal 401, @auth.call(env_for('/admin'))[0]
|
||||
def test_wrong_password_and_unknown_user
|
||||
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
|
||||
|
||||
def test_sibling_paths_not_gated
|
||||
assert_equal 200, @auth.call(env_for('/'))[0]
|
||||
assert_equal 200, @auth.call(env_for('/admin-invoice-form.js'))[0]
|
||||
def test_inactive_user_cannot_log_in
|
||||
User.create(username: 'alice', superuser: true, active: false, password: 'alice-password-1')
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ require_relative 'test_helper'
|
|||
|
||||
class AppTest < Minitest::Test
|
||||
include Rack::Test::Methods
|
||||
include AdminLogin
|
||||
|
||||
def app
|
||||
AsxpioWeb
|
||||
|
|
@ -19,10 +20,6 @@ class AppTest < Minitest::Test
|
|||
flunk("no CSRF token found on #{path}")
|
||||
end
|
||||
|
||||
def admin_env(extra = {})
|
||||
{ 'HTTP_AUTHORIZATION' =>
|
||||
"Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" }.merge(extra)
|
||||
end
|
||||
|
||||
# --- public pages ---------------------------------------------------------
|
||||
|
||||
|
|
@ -55,9 +52,13 @@ class AppTest < Minitest::Test
|
|||
|
||||
# --- admin ------------------------------------------------------------
|
||||
|
||||
def test_admin_requires_auth
|
||||
def test_admin_requires_login
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
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
|
||||
|
||||
def test_admin_list_with_auth
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ require_relative 'test_helper'
|
|||
|
||||
class BingoTest < Minitest::Test
|
||||
include Rack::Test::Methods
|
||||
include AdminLogin
|
||||
|
||||
# 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
|
||||
|
|
@ -20,10 +21,6 @@ class BingoTest < Minitest::Test
|
|||
end
|
||||
end
|
||||
|
||||
def admin_env
|
||||
{ 'HTTP_AUTHORIZATION' =>
|
||||
"Basic #{Base64.strict_encode64("#{ENV['ADMIN_USER']}:#{ENV['ADMIN_PASSWORD']}")}" }
|
||||
end
|
||||
|
||||
def hit!(idx, kind: 'outage')
|
||||
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) -------------------------------------------
|
||||
|
||||
def test_admin_requires_auth
|
||||
def test_admin_requires_login
|
||||
@app = AsxpioWeb
|
||||
get '/admin/bingo'
|
||||
assert_equal 401, last_response.status
|
||||
assert_equal 302, last_response.status
|
||||
end
|
||||
|
||||
def test_admin_dashboard
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ ENV['MAIL_FROM'] ||= 'Test <test@example.com>'
|
|||
ENV['MAIL_TO'] ||= 'owner@example.com'
|
||||
ENV['ADMIN_USER'] ||= 'admin'
|
||||
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
|
||||
# provisions an ephemeral one). Never fall through to a developer DATABASE_URL.
|
||||
|
|
@ -33,6 +35,26 @@ require 'rack/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
|
||||
def self.available?
|
||||
!ENV['DATABASE_URL'].nil?
|
||||
|
|
@ -41,4 +63,8 @@ module TestDb
|
|||
def self.clean!
|
||||
DB.connection[:invoices].delete
|
||||
end
|
||||
|
||||
def self.clean_users!
|
||||
DB.connection[:users].delete
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue