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 --- app/models/node_action.rb | 7 +++++ app/models/user.rb | 71 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) (limited to 'app') 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 -- cgit v1.3