2021-07-13 18:21:16 +01:00
|
|
|
// Copyright (C) 2021 Storj Labs, Inc.
|
|
|
|
// See LICENSE for copying information.
|
|
|
|
|
|
|
|
package console
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"crypto/rand"
|
|
|
|
"math/big"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/pquerna/otp"
|
|
|
|
"github.com/pquerna/otp/totp"
|
|
|
|
"github.com/zeebo/errs"
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
// MFARecoveryCodeCount specifies how many MFA recovery codes to generate.
|
|
|
|
MFARecoveryCodeCount = 10
|
|
|
|
)
|
|
|
|
|
|
|
|
// Error messages.
|
|
|
|
const (
|
|
|
|
mfaPasscodeInvalidErrMsg = "The MFA passcode is not valid or has expired"
|
2021-08-16 22:23:06 +01:00
|
|
|
mfaRequiredErrMsg = "A MFA passcode or recovery code is required"
|
2021-07-13 18:21:16 +01:00
|
|
|
mfaRecoveryInvalidErrMsg = "The MFA recovery code is not valid or has been previously used"
|
|
|
|
mfaRecoveryGenerationErrMsg = "MFA recovery codes cannot be generated while MFA is disabled."
|
2021-08-16 22:23:06 +01:00
|
|
|
mfaConflictErrMsg = "Expected either passcode or recovery code, but got both"
|
2021-07-13 18:21:16 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
2021-08-16 22:23:06 +01:00
|
|
|
// ErrMFAMissing is error type that occurs when a request is incomplete
|
2021-11-18 18:55:37 +00:00
|
|
|
// due to missing MFA passcode or recovery code.
|
|
|
|
ErrMFAMissing = errs.Class("MFA credentials missing")
|
2021-08-16 22:23:06 +01:00
|
|
|
|
|
|
|
// ErrMFAConflict is error type that occurs when both a passcode and recovery code are given.
|
|
|
|
ErrMFAConflict = errs.Class("MFA conflict")
|
|
|
|
|
|
|
|
// ErrMFARecoveryCode is error type that represents usage of invalid MFA recovery code.
|
|
|
|
ErrMFARecoveryCode = errs.Class("MFA recovery code")
|
|
|
|
|
|
|
|
// ErrMFAPasscode is error type that represents usage of invalid MFA passcode.
|
|
|
|
ErrMFAPasscode = errs.Class("MFA passcode")
|
2021-07-13 18:21:16 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
// NewMFAValidationOpts returns the options used to validate TOTP passcodes.
|
|
|
|
// These settings are also used to generate MFA secret keys for use in testing.
|
|
|
|
func NewMFAValidationOpts() totp.ValidateOpts {
|
|
|
|
return totp.ValidateOpts{
|
|
|
|
Period: 30,
|
|
|
|
Skew: 1,
|
|
|
|
Digits: 6,
|
|
|
|
Algorithm: otp.AlgorithmSHA1,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// ValidateMFAPasscode returns whether the TOTP passcode is valid for the secret key at the given time.
|
|
|
|
func ValidateMFAPasscode(passcode string, secretKey string, t time.Time) (bool, error) {
|
|
|
|
valid, err := totp.ValidateCustom(passcode, secretKey, t, NewMFAValidationOpts())
|
|
|
|
return valid, Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewMFAPasscode derives a TOTP passcode from a secret key using a timestamp.
|
|
|
|
func NewMFAPasscode(secretKey string, t time.Time) (string, error) {
|
|
|
|
code, err := totp.GenerateCodeCustom(secretKey, t, NewMFAValidationOpts())
|
|
|
|
return code, Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewMFASecretKey generates a new TOTP secret key.
|
|
|
|
func NewMFASecretKey() (string, error) {
|
|
|
|
opts := NewMFAValidationOpts()
|
|
|
|
key, err := totp.Generate(totp.GenerateOpts{
|
|
|
|
Issuer: " ",
|
|
|
|
AccountName: " ",
|
|
|
|
Period: opts.Period,
|
|
|
|
Digits: otp.DigitsSix,
|
|
|
|
Algorithm: opts.Algorithm,
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return "", Error.Wrap(err)
|
|
|
|
}
|
|
|
|
return key.Secret(), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// EnableUserMFA enables multi-factor authentication for the user if the given secret key and password are valid.
|
|
|
|
func (s *Service) EnableUserMFA(ctx context.Context, passcode string, t time.Time) (err error) {
|
|
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
user, err := s.getUserAndAuditLog(ctx, "enable MFA")
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
|
|
|
return Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
valid, err := ValidateMFAPasscode(passcode, user.MFASecretKey, t)
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
2021-08-16 22:23:06 +01:00
|
|
|
return ErrValidation.Wrap(ErrMFAPasscode.Wrap(err))
|
2021-07-13 18:21:16 +01:00
|
|
|
}
|
|
|
|
if !valid {
|
2021-08-16 22:23:06 +01:00
|
|
|
return ErrValidation.Wrap(ErrMFAPasscode.New(mfaPasscodeInvalidErrMsg))
|
2021-07-13 18:21:16 +01:00
|
|
|
}
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
user.MFAEnabled = true
|
2022-06-01 22:15:37 +01:00
|
|
|
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
|
|
|
|
MFAEnabled: &user.MFAEnabled,
|
|
|
|
})
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
|
|
|
return Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// DisableUserMFA disables multi-factor authentication for the user if the given secret key and password are valid.
|
2021-08-16 22:23:06 +01:00
|
|
|
func (s *Service) DisableUserMFA(ctx context.Context, passcode string, t time.Time, recoveryCode string) (err error) {
|
2021-07-13 18:21:16 +01:00
|
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
user, err := s.getUserAndAuditLog(ctx, "disable MFA")
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
|
|
|
return Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
2021-08-16 22:23:06 +01:00
|
|
|
if !user.MFAEnabled {
|
|
|
|
return nil
|
2021-07-13 18:21:16 +01:00
|
|
|
}
|
2021-08-16 22:23:06 +01:00
|
|
|
|
|
|
|
if recoveryCode != "" && passcode != "" {
|
|
|
|
return ErrMFAConflict.New(mfaConflictErrMsg)
|
|
|
|
}
|
|
|
|
|
|
|
|
if recoveryCode != "" {
|
|
|
|
found := false
|
|
|
|
for _, code := range user.MFARecoveryCodes {
|
|
|
|
if code == recoveryCode {
|
|
|
|
found = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !found {
|
|
|
|
return ErrUnauthorized.Wrap(ErrMFARecoveryCode.New(mfaRecoveryInvalidErrMsg))
|
|
|
|
}
|
|
|
|
} else if passcode != "" {
|
2022-06-05 23:41:38 +01:00
|
|
|
valid, err := ValidateMFAPasscode(passcode, user.MFASecretKey, t)
|
2021-08-16 22:23:06 +01:00
|
|
|
if err != nil {
|
|
|
|
return ErrValidation.Wrap(ErrMFAPasscode.Wrap(err))
|
|
|
|
}
|
|
|
|
if !valid {
|
|
|
|
return ErrValidation.Wrap(ErrMFAPasscode.New(mfaPasscodeInvalidErrMsg))
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
return ErrMFAMissing.New(mfaRequiredErrMsg)
|
2021-07-13 18:21:16 +01:00
|
|
|
}
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
user.MFAEnabled = false
|
|
|
|
user.MFASecretKey = ""
|
|
|
|
user.MFARecoveryCodes = nil
|
2022-06-01 22:15:37 +01:00
|
|
|
|
|
|
|
secretKeyPtr := &user.MFASecretKey
|
|
|
|
|
|
|
|
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
|
|
|
|
MFAEnabled: &user.MFAEnabled,
|
|
|
|
MFASecretKey: &secretKeyPtr,
|
|
|
|
MFARecoveryCodes: &user.MFARecoveryCodes,
|
|
|
|
})
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
|
|
|
return Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewMFARecoveryCode returns a randomly generated MFA recovery code.
|
|
|
|
// Recovery codes are uppercase and alphanumeric. They are of the form XXXX-XXXX-XXXX.
|
|
|
|
func NewMFARecoveryCode() (string, error) {
|
|
|
|
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
|
|
b := make([]byte, 14)
|
|
|
|
max := big.NewInt(int64(len(chars)))
|
|
|
|
for i := 0; i < 14; i++ {
|
|
|
|
if (i+1)%5 == 0 {
|
|
|
|
b[i] = '-'
|
|
|
|
} else {
|
|
|
|
num, err := rand.Int(rand.Reader, max)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
b[i] = chars[num.Int64()]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return string(b), nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// ResetMFASecretKey creates a new TOTP secret key for the user.
|
|
|
|
func (s *Service) ResetMFASecretKey(ctx context.Context) (key string, err error) {
|
|
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
user, err := s.getUserAndAuditLog(ctx, "reset MFA secret key")
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
|
|
|
return "", Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
key, err = NewMFASecretKey()
|
|
|
|
if err != nil {
|
|
|
|
return "", Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
user.MFASecretKey = key
|
2022-06-01 22:15:37 +01:00
|
|
|
mfaSecretKeyPtr := &user.MFASecretKey
|
|
|
|
|
|
|
|
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
|
|
|
|
MFASecretKey: &mfaSecretKeyPtr,
|
|
|
|
})
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
|
|
|
return "", Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return key, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// ResetMFARecoveryCodes creates a new set of MFA recovery codes for the user.
|
|
|
|
func (s *Service) ResetMFARecoveryCodes(ctx context.Context) (codes []string, err error) {
|
|
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
user, err := s.getUserAndAuditLog(ctx, "reset MFA recovery codes")
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
|
|
|
return nil, Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
2022-06-05 23:41:38 +01:00
|
|
|
if !user.MFAEnabled {
|
2021-07-13 18:21:16 +01:00
|
|
|
return nil, ErrUnauthorized.New(mfaRecoveryGenerationErrMsg)
|
|
|
|
}
|
|
|
|
|
|
|
|
codes = make([]string, MFARecoveryCodeCount)
|
|
|
|
for i := 0; i < MFARecoveryCodeCount; i++ {
|
|
|
|
code, err := NewMFARecoveryCode()
|
|
|
|
if err != nil {
|
|
|
|
return nil, Error.Wrap(err)
|
|
|
|
}
|
|
|
|
codes[i] = code
|
|
|
|
}
|
|
|
|
|
2022-06-01 22:15:37 +01:00
|
|
|
err = s.store.Users().Update(ctx, user.ID, UpdateUserRequest{
|
|
|
|
MFARecoveryCodes: &codes,
|
|
|
|
})
|
2021-07-13 18:21:16 +01:00
|
|
|
if err != nil {
|
|
|
|
return nil, Error.Wrap(err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return codes, nil
|
|
|
|
}
|