From 02e1aefa24cdd0339995d14431713822f4bf4718 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 24 Jul 2026 13:11:51 +0200 Subject: Add TOTP enrollment and verification to User, witnessed in the action log --- Gemfile | 8 ++- Gemfile.lock | 12 ++++ app/models/node_action.rb | 7 +++ app/models/user.rb | 71 +++++++++++++++++++++++ config/initializers/otp.rb | 2 + db/migrate/20260724110241_add_otp_to_users.rb | 8 +++ test/models/user_otp_test.rb | 83 +++++++++++++++++++++++++++ 7 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 config/initializers/otp.rb create mode 100644 db/migrate/20260724110241_add_otp_to_users.rb create mode 100644 test/models/user_otp_test.rb diff --git a/Gemfile b/Gemfile index cad34dfa..0d7f91c6 100644 --- a/Gemfile +++ b/Gemfile @@ -38,7 +38,7 @@ gem 'rails_icons' gem 'globalize', '~> 7.0' # translated model attributes (Page title/abstract/body) gem 'acts_as_list' # page revision ordering gem 'will_paginate', '~> 3.0' -gem 'bcrypt', '~> 3.1' + # Pinned to git until a release widens the activerecord < 8.1 ceiling. # Both gems work correctly on Rails 8.1; the gemspec constraint is overly conservative. @@ -47,6 +47,12 @@ gem 'acts-as-taggable-on', git: 'https://github.com/mbleigh/acts-as-taggable-on.git', branch: 'master' +# ── Auth and sessions ───────────────────────────────────────────────────────── + +gem "rotp", ">= 6.3.0" +gem "rqrcode", "~> 3.1.0" +gem 'bcrypt', '~> 3.1' + # ── XML / parsing / diffing ─────────────────────────────────────────────────── gem 'libxml-ruby', '~> 5.0', require: 'xml' # body link rewriting in Page model diff --git a/Gemfile.lock b/Gemfile.lock index 8e4d5afa..a1046d0d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -98,6 +98,7 @@ GEM bcrypt (3.1.22) bigdecimal (4.1.2) builder (3.3.0) + chunky_png (1.4.0) coffee-rails (4.2.2) coffee-script (>= 2.2.0) railties (>= 4.0.0) @@ -275,6 +276,11 @@ GEM io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) + rotp (6.3.0) + rqrcode (3.1.1) + chunky_png (~> 1.0) + rqrcode_core (~> 2.0) + rqrcode_core (2.1.0) sass-rails (6.0.0) sassc-rails (~> 2.1, >= 2.1.1) sassc (2.4.0) @@ -353,6 +359,8 @@ DEPENDENCIES rails-controller-testing rails-i18n (~> 8.1) rails_icons + rotp (>= 6.3.0) + rqrcode (~> 3.1.0) sass-rails (~> 6.0) sprockets-rails test-unit (~> 3.5) @@ -382,6 +390,7 @@ CHECKSUMS builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f bundler (4.0.15) sha256=a4ceb882fe94a0e0ac63cd0813932bbfd631a14e5ac0b7975189b19a4d28d9e7 chaos_calendar (0.1.3) + chunky_png (1.4.0) sha256=89d5b31b55c0cf4da3cf89a2b4ebc3178d8abe8cbaf116a1dba95668502fdcfe coffee-rails (4.2.2) sha256=080a5c49e9568019018a8699cf80f7ea8e7b841744d832a98ac6b4c8e40be84a coffee-script (2.4.1) sha256=82fe281e11b93c8117b98c5ea8063e71741870f1c4fbb27177d7d6333dd38765 coffee-script-source (1.12.2) sha256=e12b16fd8927fbbf8b87cb2e9a85a6cf457c6881cc7ff8b1af15b31f70da07a4 @@ -468,6 +477,9 @@ CHECKSUMS rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb + rotp (6.3.0) sha256=75d40087e65ed0d8022c33055a6306c1c400d1c12261932533b5d6cbcd868854 + rqrcode (3.1.1) sha256=4c2e1e36dab80720062388cfc827986ab622dc652437214a5fb6382985b6f00f + rqrcode_core (2.1.0) sha256=f303b85df89c1b8fc5ee8dc19808c9dc4330e6329b660d99d4a8cbb36ca13051 sass-rails (6.0.0) sha256=e0b6448ea1c7929fd5929fc7a8eb2d78045e44cc82fc0765a249d3fa1c5810d3 sassc (2.4.0) sha256=4c60a2b0a3b36685c83b80d5789401c2f678c1652e3288315a1551d811d9f83e sassc-rails (2.1.2) sha256=5f4fdf3881fc9bdc8e856ffbd9850d70a2878866feae8114aa45996179952db5 diff --git a/app/models/node_action.rb b/app/models/node_action.rb index afa2195c..a9bb0e45 100644 --- a/app/models/node_action.rb +++ b/app/models/node_action.rb @@ -98,6 +98,13 @@ class NodeAction < ApplicationRecord # "detached_from" -- array of unique_names, only when any # "headline_removed_from" -- array of unique_names, only when any # + # "otp_enroll" / "otp_disable" / "otp_reset" (second-factor lifecycle; + # node column nil; participants: the affected User -- the table's first + # User-typed subject. otp_disable is self-service; otp_reset is an + # administrator clearing someone else's factor, where actor and + # participant differ): + # "target_login" -- flat string, the affected account's login + # # Reserved: "demote" (via "trash" | "depublish") for an explicit # depublish workflow, if ever built. # diff --git a/app/models/user.rb b/app/models/user.rb index 5e47ae7d..4d712f6c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -105,6 +105,77 @@ class User < ApplicationRecord def is_admin? !!admin end + + # otp_secret present == enrolled. otp_pending_secret holds the secret + # between QR display and first-code confirmation. otp_consumed_timestep + # makes every accepted code single-use (replay guard within the drift + # window). + + def otp_enrolled? + otp_secret.present? + end + + # Starts (or restarts) enrollment. Returns the provisioning URI the QR + # encodes; otp_pending_secret itself doubles as the manual-entry string. + def begin_otp_enrollment! + update!(:otp_pending_secret => ROTP::Base32.random) + pending_otp_provisioning_uri + end + + def pending_otp_provisioning_uri + return nil if otp_pending_secret.blank? + ROTP::TOTP.new(otp_pending_secret, :issuer => OTP_ISSUER) + .provisioning_uri(login) + end + + # Confirms enrollment with the first generated code. Promotion and + # witnessing are one transaction; the consumed timestep is recorded so + # the confirmation code cannot be replayed at login. + def confirm_otp_enrollment!(code, actor: self) + return false if otp_pending_secret.blank? + timestep = ROTP::TOTP.new(otp_pending_secret) + .verify(code.to_s.strip, + :drift_behind => OTP_DRIFT, + :drift_ahead => OTP_DRIFT) + return false unless timestep + + transaction do + update!(:otp_secret => otp_pending_secret, + :otp_pending_secret => nil, + :otp_consumed_timestep => timestep) + NodeAction.record!(:participants => [self], :user => actor, + :action => "otp_enroll", :target_login => login) + end + true + end + + # Login-time verification. Each code is accepted at most once. + def verify_otp!(code) + return false unless otp_enrolled? + timestep = ROTP::TOTP.new(otp_secret) + .verify(code.to_s.strip, + :drift_behind => OTP_DRIFT, + :drift_ahead => OTP_DRIFT, + :after => otp_consumed_timestep) + return false unless timestep + + update!(:otp_consumed_timestep => timestep) + true + end + + # Self-service disable and administrative reset share one witnessed + # teardown; the verb records which of the two it was. The controller + # is responsible for the self-service guards (password + current code). + def disable_otp!(actor:) + verb = (actor == self) ? "otp_disable" : "otp_reset" + transaction do + update!(:otp_secret => nil, :otp_pending_secret => nil, + :otp_consumed_timestep => nil) + NodeAction.record!(:participants => [self], :user => actor, + :action => verb, :target_login => login) + end + true + end private diff --git a/config/initializers/otp.rb b/config/initializers/otp.rb new file mode 100644 index 00000000..0d727277 --- /dev/null +++ b/config/initializers/otp.rb @@ -0,0 +1,2 @@ +OTP_ISSUER = "CCC CMS" +OTP_DRIFT = 30 # seconds of clock skew accepted behind and ahead diff --git a/db/migrate/20260724110241_add_otp_to_users.rb b/db/migrate/20260724110241_add_otp_to_users.rb new file mode 100644 index 00000000..b43542bf --- /dev/null +++ b/db/migrate/20260724110241_add_otp_to_users.rb @@ -0,0 +1,8 @@ +class AddOtpToUsers < ActiveRecord::Migration[8.1] + def change + add_column :users, :otp_secret, :string + add_column :users, :otp_pending_secret, :string + add_column :users, :otp_consumed_timestep, :integer + add_column :users, :otp_required, :boolean, :null => false, :default => false + end +end diff --git a/test/models/user_otp_test.rb b/test/models/user_otp_test.rb new file mode 100644 index 00000000..81f25575 --- /dev/null +++ b/test/models/user_otp_test.rb @@ -0,0 +1,83 @@ +require "test_helper" + +class UserOtpTest < ActiveSupport::TestCase + fixtures :users + + def setup + @user = users(:quentin) + end + + test "begin_otp_enrollment! stores a pending secret and yields a provisioning URI" do + uri = @user.begin_otp_enrollment! + assert @user.otp_pending_secret.present? + assert_not @user.otp_enrolled? + assert_match %r{\Aotpauth://totp/}, uri + assert_includes uri, "issuer=" + end + + test "confirm_otp_enrollment! promotes the pending secret and witnesses it" do + @user.begin_otp_enrollment! + code = ROTP::TOTP.new(@user.otp_pending_secret).now + + assert @user.confirm_otp_enrollment!(code) + assert @user.otp_enrolled? + assert_nil @user.otp_pending_secret + + action = NodeAction.where(:action => "otp_enroll").last + assert_equal @user, action.user + assert_equal [["User", @user.id]], + action.action_participants.map { |p| [p.subject_type, p.subject_id] } + end + + test "confirm_otp_enrollment! rejects a wrong code and stays unenrolled" do + @user.begin_otp_enrollment! + assert_not @user.confirm_otp_enrollment!("000000") + assert_not @user.otp_enrolled? + assert @user.otp_pending_secret.present? + end + + test "verify_otp! accepts a current code exactly once" do + @user.update!(:otp_secret => ROTP::Base32.random) + code = ROTP::TOTP.new(@user.otp_secret).now + + assert @user.verify_otp!(code) + assert_not @user.verify_otp!(code), "replayed code must be rejected" + end + + test "the confirmation code cannot be replayed at login" do + @user.begin_otp_enrollment! + code = ROTP::TOTP.new(@user.otp_pending_secret).now + @user.confirm_otp_enrollment!(code) + assert_not @user.verify_otp!(code) + end + + test "verify_otp! rejects wrong codes and unenrolled users" do + assert_not @user.verify_otp!("123456") + enroll!(@user) + assert_not @user.verify_otp!("000000") + end + + test "disable_otp! by the user themselves is witnessed as otp_disable" do + enroll!(@user) + assert @user.disable_otp!(:actor => @user) + assert_not @user.otp_enrolled? + assert_equal "otp_disable", NodeAction.last.action + end + + test "an admin clearing another user's factor is witnessed as otp_reset" do + enroll!(@user) + admin = users(:aaron) + assert @user.disable_otp!(:actor => admin) + + action = NodeAction.where(:action => "otp_reset").last + assert_equal admin, action.user + assert_equal @user.login, action.metadata["target_login"] + end + + private + + def enroll!(user) + user.begin_otp_enrollment! + user.confirm_otp_enrollment!(ROTP::TOTP.new(user.otp_pending_secret).now) + end +end -- cgit v1.3