diff options
Diffstat (limited to 'app/models')
| -rw-r--r-- | app/models/node_action.rb | 7 | ||||
| -rw-r--r-- | app/models/user.rb | 71 |
2 files changed, 78 insertions, 0 deletions
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 | |||
| 98 | # "detached_from" -- array of unique_names, only when any | 98 | # "detached_from" -- array of unique_names, only when any |
| 99 | # "headline_removed_from" -- array of unique_names, only when any | 99 | # "headline_removed_from" -- array of unique_names, only when any |
| 100 | # | 100 | # |
| 101 | # "otp_enroll" / "otp_disable" / "otp_reset" (second-factor lifecycle; | ||
| 102 | # node column nil; participants: the affected User -- the table's first | ||
| 103 | # User-typed subject. otp_disable is self-service; otp_reset is an | ||
| 104 | # administrator clearing someone else's factor, where actor and | ||
| 105 | # participant differ): | ||
| 106 | # "target_login" -- flat string, the affected account's login | ||
| 107 | # | ||
| 101 | # Reserved: "demote" (via "trash" | "depublish") for an explicit | 108 | # Reserved: "demote" (via "trash" | "depublish") for an explicit |
| 102 | # depublish workflow, if ever built. | 109 | # depublish workflow, if ever built. |
| 103 | # | 110 | # |
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 | ||
