summaryrefslogtreecommitdiff
path: root/app/models/user.rb
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-24 13:11:51 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-24 13:11:51 +0200
commit02e1aefa24cdd0339995d14431713822f4bf4718 (patch)
treea622727150214f8e9dab165ae48803180c38403d /app/models/user.rb
parent38920de3910705dac42af2370da7b3ba504577a9 (diff)
Add TOTP enrollment and verification to User, witnessed in the action log
Diffstat (limited to 'app/models/user.rb')
-rw-r--r--app/models/user.rb71
1 files changed, 71 insertions, 0 deletions
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
105 def is_admin? 105 def is_admin?
106 !!admin 106 !!admin
107 end 107 end
108
109 # otp_secret present == enrolled. otp_pending_secret holds the secret
110 # between QR display and first-code confirmation. otp_consumed_timestep
111 # makes every accepted code single-use (replay guard within the drift
112 # window).
113
114 def otp_enrolled?
115 otp_secret.present?
116 end
117
118 # Starts (or restarts) enrollment. Returns the provisioning URI the QR
119 # encodes; otp_pending_secret itself doubles as the manual-entry string.
120 def begin_otp_enrollment!
121 update!(:otp_pending_secret => ROTP::Base32.random)
122 pending_otp_provisioning_uri
123 end
124
125 def pending_otp_provisioning_uri
126 return nil if otp_pending_secret.blank?
127 ROTP::TOTP.new(otp_pending_secret, :issuer => OTP_ISSUER)
128 .provisioning_uri(login)
129 end
130
131 # Confirms enrollment with the first generated code. Promotion and
132 # witnessing are one transaction; the consumed timestep is recorded so
133 # the confirmation code cannot be replayed at login.
134 def confirm_otp_enrollment!(code, actor: self)
135 return false if otp_pending_secret.blank?
136 timestep = ROTP::TOTP.new(otp_pending_secret)
137 .verify(code.to_s.strip,
138 :drift_behind => OTP_DRIFT,
139 :drift_ahead => OTP_DRIFT)
140 return false unless timestep
141
142 transaction do
143 update!(:otp_secret => otp_pending_secret,
144 :otp_pending_secret => nil,
145 :otp_consumed_timestep => timestep)
146 NodeAction.record!(:participants => [self], :user => actor,
147 :action => "otp_enroll", :target_login => login)
148 end
149 true
150 end
151
152 # Login-time verification. Each code is accepted at most once.
153 def verify_otp!(code)
154 return false unless otp_enrolled?
155 timestep = ROTP::TOTP.new(otp_secret)
156 .verify(code.to_s.strip,
157 :drift_behind => OTP_DRIFT,
158 :drift_ahead => OTP_DRIFT,
159 :after => otp_consumed_timestep)
160 return false unless timestep
161
162 update!(:otp_consumed_timestep => timestep)
163 true
164 end
165
166 # Self-service disable and administrative reset share one witnessed
167 # teardown; the verb records which of the two it was. The controller
168 # is responsible for the self-service guards (password + current code).
169 def disable_otp!(actor:)
170 verb = (actor == self) ? "otp_disable" : "otp_reset"
171 transaction do
172 update!(:otp_secret => nil, :otp_pending_secret => nil,
173 :otp_consumed_timestep => nil)
174 NodeAction.record!(:participants => [self], :user => actor,
175 :action => verb, :target_login => login)
176 end
177 true
178 end
108 179
109 private 180 private
110 181