Skip to content

Commit 29cbeb7

Browse files
authored
fix: add last_challenged_at field to mfa factors (#1705)
## What kind of change does this PR introduce? Deprecates `sent_at` on Challenge in favour of the `last_challenged_at` field on factors. We use this to calculate whether it's appropriate to allow for more SMS-es to be sent. Base is pointed to #1693 as it depends on the PR and diffs are smaller when pointed against #1693
1 parent 70446cc commit 29cbeb7

5 files changed

+41
-27
lines changed

internal/api/mfa.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -303,26 +303,26 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error
303303
if !sms_provider.IsValidMessageChannel(channel, config) {
304304
return badRequestError(ErrorCodeValidationFailed, InvalidChannelError)
305305
}
306-
latestValidChallenge, err := factor.FindLatestUnexpiredChallenge(a.db, config.MFA.ChallengeExpiryDuration)
307-
if err != nil {
308-
if !models.IsNotFoundError(err) {
309-
return internalServerError("error finding latest unexpired challenge")
306+
307+
if factor.IsPhoneFactor() && factor.LastChallengedAt != nil {
308+
if !factor.LastChallengedAt.Add(config.MFA.Phone.MaxFrequency).Before(time.Now()) {
309+
return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(factor.LastChallengedAt, config.MFA.Phone.MaxFrequency))
310310
}
311-
} else if latestValidChallenge != nil && !latestValidChallenge.SentAt.Add(config.MFA.Phone.MaxFrequency).Before(time.Now()) {
312-
return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(latestValidChallenge.SentAt, config.MFA.Phone.MaxFrequency))
313311
}
314312
otp, err := crypto.GenerateOtp(config.MFA.Phone.OtpLength)
315313
if err != nil {
316314
panic(err)
317315
}
318-
message, err := generateSMSFromTemplate(config.MFA.Phone.SMSTemplate, otp)
319-
if err != nil {
320-
return internalServerError("error generating sms template").WithInternalError(err)
321-
}
322316
challenge, err := factor.CreatePhoneChallenge(ipAddress, otp, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey)
323317
if err != nil {
324318
return internalServerError("error creating SMS Challenge")
325319
}
320+
321+
message, err := generateSMSFromTemplate(config.MFA.Phone.SMSTemplate, otp)
322+
if err != nil {
323+
return internalServerError("error generating sms template").WithInternalError(err)
324+
}
325+
326326
if config.Hook.SendSMS.Enabled {
327327
input := hooks.SendSMSInput{
328328
User: user,
@@ -347,9 +347,10 @@ func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error
347347
}
348348
}
349349
if err := db.Transaction(func(tx *storage.Connection) error {
350-
if terr := tx.Create(challenge); terr != nil {
350+
if terr := factor.WriteChallengeToDatabase(tx, challenge); terr != nil {
351351
return terr
352352
}
353+
353354
if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{
354355
"factor_id": factor.ID,
355356
"factor_status": factor.Status,
@@ -376,8 +377,9 @@ func (a *API) challengeTOTPFactor(w http.ResponseWriter, r *http.Request) error
376377
ipAddress := utilities.GetIPAddress(r)
377378

378379
challenge := factor.CreateChallenge(ipAddress)
380+
379381
if err := db.Transaction(func(tx *storage.Connection) error {
380-
if terr := tx.Create(challenge); terr != nil {
382+
if terr := factor.WriteChallengeToDatabase(tx, challenge); terr != nil {
381383
return terr
382384
}
383385
if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{

internal/models/challenge.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ type Challenge struct {
1515
IPAddress string `json:"ip_address" db:"ip_address"`
1616
Factor *Factor `json:"factor,omitempty" belongs_to:"factor"`
1717
OtpCode string `json:"otp_code,omitempty" db:"otp_code"`
18-
SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"`
1918
}
2019

2120
func (Challenge) TableName() string {

internal/models/factor.go

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,17 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error)
119119
type Factor struct {
120120
ID uuid.UUID `json:"id" db:"id"`
121121
// TODO: Consider removing this nested user field. We don't use it.
122-
User User `json:"-" belongs_to:"user"`
123-
UserID uuid.UUID `json:"-" db:"user_id"`
124-
CreatedAt time.Time `json:"created_at" db:"created_at"`
125-
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
126-
Status string `json:"status" db:"status"`
127-
FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"`
128-
Secret string `json:"-" db:"secret"`
129-
FactorType string `json:"factor_type" db:"factor_type"`
130-
Challenge []Challenge `json:"-" has_many:"challenges"`
131-
Phone storage.NullString `json:"phone" db:"phone"`
122+
User User `json:"-" belongs_to:"user"`
123+
UserID uuid.UUID `json:"-" db:"user_id"`
124+
CreatedAt time.Time `json:"created_at" db:"created_at"`
125+
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
126+
Status string `json:"status" db:"status"`
127+
FriendlyName string `json:"friendly_name,omitempty" db:"friendly_name"`
128+
Secret string `json:"-" db:"secret"`
129+
FactorType string `json:"factor_type" db:"factor_type"`
130+
Challenge []Challenge `json:"-" has_many:"challenges"`
131+
Phone storage.NullString `json:"phone" db:"phone"`
132+
LastChallengedAt *time.Time `json:"last_challenged_at" db:"last_challenged_at"`
132133
}
133134

134135
func (Factor) TableName() string {
@@ -212,16 +213,29 @@ func (f *Factor) CreateChallenge(ipAddress string) *Challenge {
212213
FactorID: f.ID,
213214
IPAddress: ipAddress,
214215
}
216+
215217
return challenge
216218
}
219+
func (f *Factor) WriteChallengeToDatabase(tx *storage.Connection, challenge *Challenge) error {
220+
if challenge.FactorID != f.ID {
221+
return errors.New("Can only write challenges that you own")
222+
}
223+
now := time.Now()
224+
f.LastChallengedAt = &now
225+
if terr := tx.Create(challenge); terr != nil {
226+
return terr
227+
}
228+
if err := tx.UpdateOnly(f, "last_challenged_at"); err != nil {
229+
return err
230+
}
231+
return nil
232+
}
217233

218234
func (f *Factor) CreatePhoneChallenge(ipAddress string, otpCode string, encrypt bool, encryptionKeyID, encryptionKey string) (*Challenge, error) {
219235
phoneChallenge := f.CreateChallenge(ipAddress)
220236
if err := phoneChallenge.SetOtpCode(otpCode, encrypt, encryptionKeyID, encryptionKey); err != nil {
221237
return nil, err
222238
}
223-
now := time.Now()
224-
phoneChallenge.SentAt = &now
225239
return phoneChallenge, nil
226240
}
227241

migrations/20240729123726_add_mfa_phone_config.up.sql

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ end $$;
66

77

88
alter table {{ index .Options "Namespace" }}.mfa_factors add column if not exists phone text unique default null;
9-
alter table {{ index .Options "Namespace" }}.mfa_challenges add column if not exists sent_at timestamptz null;
109
alter table {{ index .Options "Namespace" }}.mfa_challenges add column if not exists otp_code text null;
1110

12-
create index if not exists idx_sent_at on {{ index .Options "Namespace" }}.mfa_challenges(sent_at);
1311

1412
create unique index if not exists unique_verified_phone_factor on {{ index .Options "Namespace" }}.mfa_factors (user_id, phone);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
alter table {{ index .Options "Namespace" }}.mfa_factors add column if not exists last_challenged_at timestamptz unique default null;

0 commit comments

Comments
 (0)