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/user.rb | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) (limited to 'app/models/user.rb') 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