diff --git a/Gemfile b/Gemfile index c26442f..374be9e 100644 --- a/Gemfile +++ b/Gemfile @@ -33,7 +33,7 @@ gem 'redis', '>= 4.0.1' # gem "kredis" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem 'bcrypt', '~> 3.1.7' # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: %i[windows jruby] diff --git a/Gemfile.lock b/Gemfile.lock index e1f0173..8278d10 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,15 +73,16 @@ GEM tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) base64 (0.3.0) + bcrypt (3.1.22) benchmark (0.5.0) bigdecimal (4.1.2) bindex (0.8.1) - bootsnap (1.24.2) + bootsnap (1.24.6) msgpack (~> 1.2) builder (3.3.0) - concurrent-ruby (1.3.6) + concurrent-ruby (1.3.7) connection_pool (3.0.2) - crass (1.0.6) + crass (1.0.7) cssbundling-rails (1.4.3) railties (>= 6.0.0) csv (3.3.5) @@ -95,14 +96,14 @@ GEM ffi (1.17.4-arm64-darwin) ffi (1.17.4-x86_64-darwin) ffi (1.17.4-x86_64-linux-gnu) - globalid (1.3.0) + globalid (1.4.0) activesupport (>= 6.1) honeybadger (5.30.0) logger ostruct - i18n (1.14.8) + i18n (1.15.2) concurrent-ruby (~> 1.0) - icalendar (2.12.2) + icalendar (2.12.3) base64 ice_cube (~> 0.16) logger @@ -117,7 +118,7 @@ GEM prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.14.1) + jbuilder (2.15.1) actionview (>= 7.0.0) activesupport (>= 7.0.0) jsbundling-rails (1.3.1) @@ -126,20 +127,20 @@ GEM loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.9.0) + mail (2.9.1) logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.1.0) + marcel (1.2.1) mini_magick (5.3.1) logger mini_mime (1.1.5) minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) - msgpack (1.8.0) + msgpack (1.8.3) net-imap (0.6.4.1) date net-protocol @@ -150,23 +151,20 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.3-arm64-darwin) + nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-darwin) + nokogiri (1.19.4-x86_64-darwin) racc (~> 1.4) - nokogiri (1.19.3-x86_64-linux-gnu) + nokogiri (1.19.4-x86_64-linux-gnu) racc (~> 1.4) ostruct (0.6.3) pg (1.6.3-arm64-darwin) pg (1.6.3-x86_64-darwin) pg (1.6.3-x86_64-linux) - pp (0.6.3) + pp (0.6.4) prettyprint prettyprint (0.2.0) prism (1.9.0) - psych (5.3.1) - date - stringio puma (8.0.2) nio4r (~> 2.0) racc (1.8.1) @@ -209,13 +207,18 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rake (13.4.2) - rdoc (7.2.0) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) erb - psych (>= 4.0.0) + prism (>= 1.6.0) + rbs (>= 4.0.0) tsort redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.28.0) + redis-client (0.30.0) connection_pool reline (0.6.3) io-console (~> 0.5) @@ -233,7 +236,6 @@ GEM sprockets (>= 3.0.0) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.2.0) thor (1.5.0) timeout (0.6.1) tsort (0.2.0) @@ -244,16 +246,15 @@ GEM concurrent-ruby (~> 1.0) uri (1.1.1) useragent (0.16.11) - web-console (4.2.1) - actionview (>= 6.0.0) - activemodel (>= 6.0.0) + web-console (4.3.0) + actionview (>= 8.0.0) bindex (>= 0.4.0) - railties (>= 6.0.0) - websocket-driver (0.8.0) + railties (>= 8.0.0) + websocket-driver (0.8.2) base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) - zeitwerk (2.7.5) + zeitwerk (2.8.2) PLATFORMS arm64-darwin-23 @@ -262,6 +263,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap cssbundling-rails csv diff --git a/README.md b/README.md index 0f61841..f4d0062 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,17 @@ The database and storage configurations are provided in a `.sample` file, make s This should get the application setup and ready to run. +### Admin authentication +Admin access (creating/editing events) is gated behind a login screen backed by a `User` model with `has_secure_password`. Admin users are seeded from the encrypted credentials under the `admins:` key — add or update entries there and re-run `bin/rails db:seed` to upsert. The credentials file also holds the `active_record_encryption` keys used to encrypt `password_digest` and `session_token` at rest. To rotate or add admins: + +``` +bin/rails credentials:edit +# then: +bin/rails db:seed +``` + +The default seeded admin is `trbadmin`. + To run the application locally, in two separate consoles: 1. Run `bin/dev` - watches and rebuilds JS and CSS (runs `Procfile.dev` via foreman) 1. Run `bin/rails s` - starts the Rails server diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 5d421f7..c17667f 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -1,8 +1,5 @@ module Admin class BaseController < ApplicationController - http_basic_authenticate_with( - name: Rails.env.production? ? Rails.application.credentials.auth.user : 'admin', - password: Rails.env.production? ? Rails.application.credentials.auth.password : 'admin' - ) + before_action :authenticate_admin! end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d1..3933e88 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,25 @@ class ApplicationController < ActionController::Base + helper_method :current_user, :signed_in? + + private + + def current_user + return @current_user if defined?(@current_user) + + token = session[:session_token] + @current_user = token && User.where(session_token: token).sole + rescue ActiveRecord::RecordNotFound + @current_user = nil + end + + def signed_in? + current_user.present? + end + + def authenticate_admin! + return if signed_in? + + session[:return_to] = request.fullpath if request.get? + redirect_to login_path, alert: 'Please sign in to continue.' + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..a7a85cc --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,26 @@ +class SessionsController < ApplicationController + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { redirect_to login_path, alert: 'Too many attempts. Try again in a few minutes.' } + + def new; end + + def create + user = User.authenticate_by(username: params[:username].to_s, password: params[:password].to_s) + if user + user.regenerate_session_token + return_to = session[:return_to] + reset_session + session[:session_token] = user.session_token + redirect_to(return_to || admin_events_path, notice: 'Signed in.') + else + flash.now[:alert] = 'Invalid username or password.' + render :new, status: :unprocessable_entity + end + end + + def destroy + current_user&.update!(session_token: nil) + reset_session + redirect_to login_path, notice: 'Signed out.' + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..5264bfb --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,11 @@ +class User < ApplicationRecord + has_secure_password + has_secure_token :session_token + + encrypts :password_digest + encrypts :session_token, deterministic: true + + normalizes :username, with: ->(v) { v.to_s.strip.downcase } + + validates :username, presence: true, uniqueness: true +end diff --git a/app/views/layouts/common/_navigation.html.erb b/app/views/layouts/common/_navigation.html.erb index 4d993ae..1d74806 100644 --- a/app/views/layouts/common/_navigation.html.erb +++ b/app/views/layouts/common/_navigation.html.erb @@ -21,6 +21,9 @@ <% end %> <%= link_to "Chat", chat_path, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors" %> <%= link_to "Past Events", past_events_path, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors" %> + <% if signed_in? %> + <%= button_to "Sign out", logout_path, method: :delete, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors bg-transparent border-0 p-0 cursor-pointer" %> + <% end %> @@ -47,6 +50,9 @@ <% end %> <%= link_to "Chat", chat_path, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors px-3 py-2 rounded-lg hover:bg-ruby-50" %> <%= link_to "Past Events", past_events_path, class: "text-gray-700 hover:text-ruby-500 font-medium transition-colors px-3 py-2 rounded-lg hover:bg-ruby-50" %> + <% if signed_in? %> + <%= button_to "Sign out", logout_path, method: :delete, class: "text-left text-gray-700 hover:text-ruby-500 font-medium transition-colors px-3 py-2 rounded-lg hover:bg-ruby-50 bg-transparent border-0 cursor-pointer w-full" %> + <% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..c6a5107 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,35 @@ +<% content_for :page_title, "Sign in" %> + +
+
+

Sign in

+ + <%= form_with url: login_path, scope: :session, local: true do |form| %> +
+
+ <%= form.label :username, class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= form.text_field :username, + name: :username, + value: params[:username], + autocomplete: "username", + autofocus: true, + required: true, + class: "block w-full px-4 py-3 rounded-lg border border-gray-300 shadow-sm focus:border-ruby-500 focus:ring-1 focus:ring-ruby-500 transition-colors" %> +
+ +
+ <%= form.label :password, class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= form.password_field :password, + name: :password, + autocomplete: "current-password", + required: true, + class: "block w-full px-4 py-3 rounded-lg border border-gray-300 shadow-sm focus:border-ruby-500 focus:ring-1 focus:ring-ruby-500 transition-colors" %> +
+ +
+ <%= form.submit "Sign in", class: "btn-primary w-full" %> +
+
+ <% end %> +
+
diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint index 67ef493..9b33b9c 100755 --- a/bin/docker-entrypoint +++ b/bin/docker-entrypoint @@ -1,8 +1,11 @@ #!/bin/bash -e -# If running the rails server then create or migrate existing database +# If running the rails server then create or migrate existing database, +# then upsert admins from encrypted credentials. admins:sync is idempotent +# (find_or_initialize_by on username) and does not touch event fixtures. if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then ./bin/rails db:prepare + ./bin/rails admins:sync fi exec "${@}" diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc index 9af46a3..d4d9eec 100644 --- a/config/credentials.yml.enc +++ b/config/credentials.yml.enc @@ -1 +1 @@ -L18YmnmQ7HVCtRlwFNxwc6o+I59OjZMzUA2KVdQ+TwesMDTOH7amkAqJHrILU9XvCaP+v3WBFdwZb8+MoLE6CS7AUiKh5tP99uNiw8Ri/iWSNIvX6jZorKlOdszaKs/KGr+0Exe3NVvOUeD6t/8CRWKTdQvTwOedpzloIhhlIz0r0Fi/+tT2dzezH91aKKyCr3hKYbkD7Wvai8/36zGC3JOWepTjsRxsHA6PCjwV24g+7oBnsH7KiboW1whGuSz8SOVyPHJZFQvwFi571oAxQLY0qUW7C0qkUA/8TMoIcEtmcLEPbLJE9Nya/pe07xR8MQetKb2q29HD1Aa4/leygO2EyONUMpDPg0Cm/XJTOswvkhGsmp+yXVeyjr8ISmVyKR7X25+a8Yhj9JuTnP+SRb0FIWnjWPD5rRE/VFKA/96JEiWfHp4IaZ9iHwhJ9C5AP7G2n280mwcrWiKTaqc0U2g2dkb6XDxfy+ozAl1T0wSRlCnB9twGSyVAH9eSaIzzeZx6i46uPlP6q/Y4jWp8lfNaKxCoGYixeQzlK6DwKHnmbdmtx/2xgJEmWdDt--VPOiYqTn+QXyR0PA--1bBCdyTlIYOVD/PPlfLLNA== \ No newline at end of file +Ukz+P33RYHWu9L3Y3cTWNa1OT6pp0XBxHF8fTmkOkWsTxtQ1vhcI55K2BuSg7WedwQa/CLaGQXVwn1qs/mO0HSglXYGp3T/x+EfNXjGOp3TDlwgwS+Q3fCqkwXpD4/50FXlEVU5MgsY+yEfwWyy/XpY/byHQacyoj+UIfPMiPE8WEyLA8ImDxEQLH6UxBmY9GqXUTRJiyD6fxiIChuHnyNabwIDklRaYvObwiJd1aZUgj4hh5bNl3YNCtN4mUbV1Vd9HjDQsoY/5mugkK0F4SSnnqL3/40pZQblRUmz9kKF5AEJS/eH4fOPgZPC3es+4gT8PllpF1mNSsl/pM6wuHmgcIgZA3Vp5mpiAfvjec8igUsbnBkYIj5TpcSRZjxWvubGuMMaQ455dN25fV77AD4bdCGtE55AI39v4fPQekOJFJRvhxWrdsSmPk60d0mBmiPNGKwdHKG33diylbfqIZe0KNWwIpawSmoVRK4YkAIYws24ZaRHp3vw7zK48ryx2lQ7Q+2vez7HklSTGOXWdHhKyXKMr3y9+PNxiEEQxqCGwrtcCTMrXXX2zFvpUra3hB27eNVapx2bXEJZKNNS46Copi7gCyziR00KrFIUSO1M=--U9HaVnhO9PdPHU2z--vPBFOb2my1bEWWgRaoWSGw== \ No newline at end of file diff --git a/config/environments/test.rb b/config/environments/test.rb index 1ab0001..7054193 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -52,4 +52,11 @@ # Raise error when a before_action's only/except options reference missing actions. config.action_controller.raise_on_missing_callback_actions = true + + # Static Active Record encryption keys for the test suite. Production keys + # live in encrypted credentials; test data is throwaway and CI doesn't have + # (nor should it need) the master key. + config.active_record.encryption.primary_key = 'test_primary_key_not_for_production_use_1234' + config.active_record.encryption.deterministic_key = 'test_deterministic_key_not_for_production_1234' + config.active_record.encryption.key_derivation_salt = 'test_key_derivation_salt_not_for_production_1234' end diff --git a/config/routes.rb b/config/routes.rb index 3c637d0..f99a8ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,8 @@ Rails.application.routes.draw do + get 'login', to: 'sessions#new', as: :login + post 'login', to: 'sessions#create' + delete 'logout', to: 'sessions#destroy', as: :logout + namespace :admin do root to: redirect('/admin/events') resources :events diff --git a/db/migrate/20260711022932_create_users.rb b/db/migrate/20260711022932_create_users.rb new file mode 100644 index 0000000..cddbe7b --- /dev/null +++ b/db/migrate/20260711022932_create_users.rb @@ -0,0 +1,14 @@ +class CreateUsers < ActiveRecord::Migration[8.0] + def change + create_table :users do |t| + t.string :username, null: false + t.string :password_digest, null: false + t.string :session_token + + t.timestamps + end + + add_index :users, :username, unique: true + add_index :users, :session_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 4a881eb..1304014 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_05_011213) do +ActiveRecord::Schema[8.0].define(version: 2026_07_11_022932) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -67,6 +67,16 @@ t.index ["slug"], name: "index_events_on_slug", unique: true end + create_table "users", force: :cascade do |t| + t.string "username", null: false + t.string "password_digest", null: false + t.string "session_token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["session_token"], name: "index_users_on_session_token", unique: true + t.index ["username"], name: "index_users_on_username", unique: true + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" end diff --git a/db/seeds.rb b/db/seeds.rb index ef5d066..1c91416 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -120,3 +120,11 @@ e.description = event[:description] e.save! end + +admins = Rails.application.credentials.admins || + (Rails.env.production? ? raise('admins missing from credentials') : [{ username: 'trbadmin', password: 'admin' }]) +admins.each do |attrs| + user = User.find_or_initialize_by(username: attrs[:username]) + user.password = attrs[:password] + user.save! +end diff --git a/lib/tasks/admins.rake b/lib/tasks/admins.rake new file mode 100644 index 0000000..1af6c63 --- /dev/null +++ b/lib/tasks/admins.rake @@ -0,0 +1,13 @@ +namespace :admins do + desc 'Upsert admin users from Rails.application.credentials.admins' + task sync: :environment do + admins = Rails.application.credentials.admins || + (Rails.env.production? ? raise('admins missing from credentials') : [{ username: 'trbadmin', password: 'admin' }]) + + admins.each do |attrs| + user = User.find_or_initialize_by(username: attrs[:username]) + user.password = attrs[:password] + user.save! + end + end +end diff --git a/test/controllers/admin/events_controller_test.rb b/test/controllers/admin/events_controller_test.rb index 3512f82..2e18c8a 100644 --- a/test/controllers/admin/events_controller_test.rb +++ b/test/controllers/admin/events_controller_test.rb @@ -3,6 +3,10 @@ class Admin::EventsControllerTest < ActionDispatch::IntegrationTest def setup Event.destroy_all + User.destroy_all + @admin = User.create!(username: 'trbadmin', password: 'admin') + sign_in_as(@admin) + begining = Date.parse('2024-01-01').beginning_of_year dates = 12.times.map { |i| begining + i.months } @events = dates.map do |date| @@ -20,27 +24,14 @@ def setup end test 'should load the index page' do - get admin_events_path, - headers: { - Authorization: - ActionController::HttpAuthentication::Basic.encode_credentials( - 'admin', 'admin' - ) - } - + get admin_events_path assert_response :success end test 'index should show draft events' do Event.update_all(status: :draft) - get admin_events_path, - headers: { - Authorization: - ActionController::HttpAuthentication::Basic.encode_credentials( - 'admin', 'admin' - ) - } + get admin_events_path assert_response :success assert_equal 'text/html; charset=utf-8', response.content_type @@ -48,26 +39,14 @@ def setup end test 'should show a single event' do - get admin_event_path(Event.first.slug), - headers: { - Authorization: - ActionController::HttpAuthentication::Basic.encode_credentials( - 'admin', 'admin' - ) - } + get admin_event_path(Event.first.slug) assert_response :ok assert response.body.include?(Event.first.name) end test 'should redirect to events page if event is not found' do - get admin_event_path('nonexistent-event'), - headers: { - Authorization: - ActionController::HttpAuthentication::Basic.encode_credentials( - 'admin', 'admin' - ) - } + get admin_event_path('nonexistent-event') assert_response :redirect assert_redirected_to admin_events_path @@ -75,13 +54,7 @@ def setup test 'edit form should show all fields for the event' do # Also tests the form has all our model's attributes - get admin_event_path(Event.first.slug), - headers: { - Authorization: - ActionController::HttpAuthentication::Basic.encode_credentials( - 'admin', 'admin' - ) - } + get admin_event_path(Event.first.slug) # Get all attributes and rich text associations editable = Event.rich_text_association_names.map { |assoc| @@ -116,12 +89,6 @@ def setup 'start_at' => '2024-11-25T19:30', 'status' => 'published' } - }, - headers: { - Authorization: - ActionController::HttpAuthentication::Basic.encode_credentials( - 'admin', 'admin' - ) } got = Event.last @@ -130,4 +97,10 @@ def setup } assert_equal(want[:start_at].utc, got.start_at) end + + test 'unauthenticated request redirects to login' do + reset! + get admin_events_path + assert_redirected_to login_path + end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 0000000..970fe6f --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,53 @@ +require 'test_helper' + +class SessionsControllerTest < ActionDispatch::IntegrationTest + def setup + User.destroy_all + @admin = User.create!(username: 'trbadmin', password: 'admin') + end + + test 'GET /login renders the form' do + get login_path + assert_response :success + assert_select 'input[name=username]' + assert_select 'input[name=password][type=password]' + end + + test 'POST /login with valid credentials signs in and rotates session token' do + original_token = @admin.session_token + + post login_path, params: { username: 'trbadmin', password: 'admin' } + + assert_redirected_to admin_events_path + @admin.reload + assert_not_equal original_token, @admin.session_token + end + + test 'POST /login normalizes username case and whitespace' do + post login_path, params: { username: ' TRBADMIN ', password: 'admin' } + assert_redirected_to admin_events_path + end + + test 'POST /login with wrong password re-renders' do + post login_path, params: { username: 'trbadmin', password: 'nope' } + assert_response :unprocessable_entity + end + + test 'POST /login with unknown user re-renders' do + post login_path, params: { username: 'nobody', password: 'admin' } + assert_response :unprocessable_entity + end + + test 'DELETE /logout clears session token and redirects' do + post login_path, params: { username: 'trbadmin', password: 'admin' } + delete logout_path + + assert_redirected_to login_path + assert_nil @admin.reload.session_token + end + + test 'unauthenticated request to admin redirects to login' do + get admin_events_path + assert_redirected_to login_path + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 62f75b9..7de7026 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -13,3 +13,15 @@ class TestCase # Add more helper methods to be used by all tests here... end end + +module SignInHelper + def sign_in_as(user, password: 'admin') + post login_path, params: { username: user.username, password: password } + end +end + +module ActionDispatch + class IntegrationTest + include SignInHelper + end +end