1
0
mirror of https://github.com/volatiletech/authboss.git synced 2024-12-10 10:40:07 +02:00

Add opt-in interface for totp code reuse blocking

- Add UserOneTime interface in totp2fa module for opting in to behavior
  that prevents users from re-using totp codes.
This commit is contained in:
Aaron L 2021-07-01 21:52:12 -07:00
parent 1ca5c1caf1
commit 2f24321e01
3 changed files with 92 additions and 15 deletions

View File

@ -36,6 +36,7 @@ type User struct {
OTPs string OTPs string
TOTPSecretKey string TOTPSecretKey string
TOTPLastCode string
SMSPhoneNumber string SMSPhoneNumber string
RecoveryCodes string RecoveryCodes string
@ -110,6 +111,9 @@ func (u User) GetOTPs() string { return u.OTPs }
// GetTOTPSecretKey from user // GetTOTPSecretKey from user
func (u User) GetTOTPSecretKey() string { return u.TOTPSecretKey } func (u User) GetTOTPSecretKey() string { return u.TOTPSecretKey }
// GetTOTPLastCode from user
func (u User) GetTOTPLastCode() string { return u.TOTPLastCode }
// GetSMSPhoneNumber from user // GetSMSPhoneNumber from user
func (u User) GetSMSPhoneNumber() string { return u.SMSPhoneNumber } func (u User) GetSMSPhoneNumber() string { return u.SMSPhoneNumber }
@ -184,6 +188,9 @@ func (u *User) PutOTPs(otps string) { u.OTPs = otps }
// PutTOTPSecretKey into user // PutTOTPSecretKey into user
func (u *User) PutTOTPSecretKey(key string) { u.TOTPSecretKey = key } func (u *User) PutTOTPSecretKey(key string) { u.TOTPSecretKey = key }
// PutTOTPLastCode into user
func (u *User) PutTOTPLastCode(key string) { u.TOTPLastCode = key }
// PutSMSPhoneNumber into user // PutSMSPhoneNumber into user
func (u *User) PutSMSPhoneNumber(number string) { u.SMSPhoneNumber = number } func (u *User) PutSMSPhoneNumber(number string) { u.SMSPhoneNumber = number }

View File

@ -49,6 +49,13 @@ const (
DataTOTPSecret = SessionTOTPSecret DataTOTPSecret = SessionTOTPSecret
) )
// validation constants
const (
validationSuccess = "success"
validationErrRepeatCode = "2fa code was previously used"
validationErrInvalidCode = "2fa code was invalid"
)
var ( var (
errNoTOTPEnabled = errors.New("user does not have totp 2fa enabled") errNoTOTPEnabled = errors.New("user does not have totp 2fa enabled")
) )
@ -61,6 +68,14 @@ type User interface {
PutTOTPSecretKey(string) PutTOTPSecretKey(string)
} }
// UserOneTime allows totp codes to be one-time use only
type UserOneTime interface {
User
GetTOTPLastCode() string
PutTOTPLastCode(string)
}
// TOTP implements time based one time passwords // TOTP implements time based one time passwords
type TOTP struct { type TOTP struct {
*authboss.Authboss *authboss.Authboss
@ -282,6 +297,9 @@ func (t *TOTP) PostConfirm(w http.ResponseWriter, r *http.Request) error {
// Save the user which activates 2fa // Save the user which activates 2fa
user.PutTOTPSecretKey(totpSecret) user.PutTOTPSecretKey(totpSecret)
user.PutRecoveryCodes(twofactor.EncodeRecoveryCodes(crypted)) user.PutRecoveryCodes(twofactor.EncodeRecoveryCodes(crypted))
if oneTime, ok := user.(UserOneTime); ok {
oneTime.PutTOTPLastCode(inputCode)
}
if err = t.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil { if err = t.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil {
return err return err
} }
@ -303,16 +321,19 @@ func (t *TOTP) GetRemove(w http.ResponseWriter, r *http.Request) error {
// PostRemove removes totp // PostRemove removes totp
func (t *TOTP) PostRemove(w http.ResponseWriter, r *http.Request) error { func (t *TOTP) PostRemove(w http.ResponseWriter, r *http.Request) error {
user, ok, err := t.validate(r) logger := t.RequestLogger(r)
user, status, err := t.validate(r)
switch { switch {
case err == errNoTOTPEnabled: case err == errNoTOTPEnabled:
data := authboss.HTMLData{authboss.DataErr: "totp 2fa not active"} data := authboss.HTMLData{authboss.DataErr: "totp 2fa not active"}
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPRemove, data) return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPRemove, data)
case err != nil: case err != nil:
return err return err
case !ok: case status != validationSuccess:
logger.Infof("user %s totp 2fa removal failure (%s)", user.GetPID(), status)
data := authboss.HTMLData{ data := authboss.HTMLData{
authboss.DataValidation: map[string][]string{FormValueCode: {"2fa code was invalid"}}, authboss.DataValidation: map[string][]string{FormValueCode: {status}},
} }
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPRemove, data) return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPRemove, data)
} }
@ -323,7 +344,6 @@ func (t *TOTP) PostRemove(w http.ResponseWriter, r *http.Request) error {
return err return err
} }
logger := t.RequestLogger(r)
logger.Infof("user %s disabled totp 2fa", user.GetPID()) logger.Infof("user %s disabled totp 2fa", user.GetPID())
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPRemoveSuccess, nil) return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPRemoveSuccess, nil)
@ -338,7 +358,7 @@ func (t *TOTP) GetValidate(w http.ResponseWriter, r *http.Request) error {
func (t *TOTP) PostValidate(w http.ResponseWriter, r *http.Request) error { func (t *TOTP) PostValidate(w http.ResponseWriter, r *http.Request) error {
logger := t.RequestLogger(r) logger := t.RequestLogger(r)
user, ok, err := t.validate(r) user, status, err := t.validate(r)
switch { switch {
case err == errNoTOTPEnabled: case err == errNoTOTPEnabled:
logger.Infof("user %s totp failure (not enabled)", user.GetPID()) logger.Infof("user %s totp failure (not enabled)", user.GetPID())
@ -346,7 +366,7 @@ func (t *TOTP) PostValidate(w http.ResponseWriter, r *http.Request) error {
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data) return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
case err != nil: case err != nil:
return err return err
case !ok: case status != validationSuccess:
r = r.WithContext(context.WithValue(r.Context(), authboss.CTXKeyUser, user)) r = r.WithContext(context.WithValue(r.Context(), authboss.CTXKeyUser, user))
handled, err := t.Authboss.Events.FireAfter(authboss.EventAuthFail, w, r) handled, err := t.Authboss.Events.FireAfter(authboss.EventAuthFail, w, r)
if err != nil { if err != nil {
@ -355,13 +375,22 @@ func (t *TOTP) PostValidate(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} }
logger.Infof("user %s totp 2fa failure (wrong code)", user.GetPID()) logger.Infof("user %s totp 2fa failure (%s)", user.GetPID(), status)
data := authboss.HTMLData{ data := authboss.HTMLData{
authboss.DataValidation: map[string][]string{FormValueCode: {"2fa code was invalid"}}, authboss.DataValidation: map[string][]string{FormValueCode: {status}},
} }
return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data) return t.Authboss.Core.Responder.Respond(w, r, http.StatusOK, PageTOTPValidate, data)
} }
// In the case where we care about re-using codes, validate will have set
// this and we need to preserve it. Normally there's no database hit
// required because we are only reading the secret and validating.
if _, ok := user.(UserOneTime); ok {
if err = t.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil {
return err
}
}
authboss.PutSession(w, authboss.SessionKey, user.GetPID()) authboss.PutSession(w, authboss.SessionKey, user.GetPID())
authboss.PutSession(w, authboss.Session2FA, "totp") authboss.PutSession(w, authboss.Session2FA, "totp")
@ -387,7 +416,12 @@ func (t *TOTP) PostValidate(w http.ResponseWriter, r *http.Request) error {
return t.Authboss.Core.Redirector.Redirect(w, r, ro) return t.Authboss.Core.Redirector.Redirect(w, r, ro)
} }
func (t *TOTP) validate(r *http.Request) (User, bool, error) { // validate returns the user, a string representing a validation status (see
// validation* constants) and an error. The string return is completely invalid
// if err != nil.
//
// validate will set the previously used code to the input
func (t *TOTP) validate(r *http.Request) (User, string, error) {
logger := t.RequestLogger(r) logger := t.RequestLogger(r)
// Look up CurrentUser first, otherwise session persistence can allow // Look up CurrentUser first, otherwise session persistence can allow
@ -401,19 +435,19 @@ func (t *TOTP) validate(r *http.Request) (User, bool, error) {
} }
} }
if err != nil { if err != nil {
return nil, false, err return nil, "", err
} }
user := abUser.(User) user := abUser.(User)
secret := user.GetTOTPSecretKey() secret := user.GetTOTPSecretKey()
if len(secret) == 0 { if len(secret) == 0 {
return user, false, errNoTOTPEnabled return user, "", errNoTOTPEnabled
} }
validator, err := t.Authboss.Config.Core.BodyReader.Read(PageTOTPValidate, r) validator, err := t.Authboss.Config.Core.BodyReader.Read(PageTOTPValidate, r)
if err != nil { if err != nil {
return nil, false, err return nil, "", err
} }
totpCodeValues := MustHaveTOTPCodeValues(validator) totpCodeValues := MustHaveTOTPCodeValues(validator)
@ -427,14 +461,26 @@ func (t *TOTP) validate(r *http.Request) (User, bool, error) {
logger.Infof("user %s used recovery code instead of sms2fa", user.GetPID()) logger.Infof("user %s used recovery code instead of sms2fa", user.GetPID())
user.PutRecoveryCodes(twofactor.EncodeRecoveryCodes(recoveryCodes)) user.PutRecoveryCodes(twofactor.EncodeRecoveryCodes(recoveryCodes))
if err := t.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil { if err := t.Authboss.Config.Storage.Server.Save(r.Context(), user); err != nil {
return nil, false, err return nil, "", err
} }
} }
return user, ok, nil return user, validationSuccess, nil
} }
input := totpCodeValues.GetCode() input := totpCodeValues.GetCode()
return user, totp.Validate(input, secret), nil if oneTime, ok := user.(UserOneTime); ok {
oldCode := oneTime.GetTOTPLastCode()
if oldCode == input {
return user, validationErrRepeatCode, nil
}
oneTime.PutTOTPLastCode(input)
}
if !totp.Validate(input, secret) {
return user, validationErrInvalidCode, nil
}
return user, validationSuccess, nil
} }

View File

@ -514,6 +514,30 @@ func TestPostValidate(t *testing.T) {
} }
}) })
t.Run("ReusedCode", func(t *testing.T) {
h := testSetup()
r, w, _ := h.newHTTP("POST")
h.loadClientState(w, &r)
user := setupMore(h)
secret := makeSecretKey(h, user.Email)
user.TOTPSecretKey = secret
user.TOTPLastCode = "duplicate"
h.bodyReader.Return = mocks.Values{Code: "duplicate"}
if err := h.totp.PostValidate(w, r); err != nil {
t.Error(err)
}
if h.responder.Page != PageTOTPValidate {
t.Error("page wrong:", h.responder.Page)
}
if got := h.responder.Data[authboss.DataValidation].(map[string][]string); got[FormValueCode][0] != "2fa code was previously used" {
t.Error("data wrong:", got)
}
})
t.Run("OkRecovery", func(t *testing.T) { t.Run("OkRecovery", func(t *testing.T) {
h := testSetup() h := testSetup()