chatdesk-ui/auth_v2.169.0/internal/api/mfa.go

1030 lines
32 KiB
Go

package api
import (
"bytes"
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/aaronarduino/goqrsvg"
svg "github.com/ajstarks/svgo"
"github.com/boombuler/barcode/qr"
wbnprotocol "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gofrs/uuid"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"github.com/supabase/auth/internal/api/sms_provider"
"github.com/supabase/auth/internal/conf"
"github.com/supabase/auth/internal/crypto"
"github.com/supabase/auth/internal/hooks"
"github.com/supabase/auth/internal/metering"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/storage"
"github.com/supabase/auth/internal/utilities"
)
const DefaultQRSize = 3
type EnrollFactorParams struct {
FriendlyName string `json:"friendly_name"`
FactorType string `json:"factor_type"`
Issuer string `json:"issuer"`
Phone string `json:"phone"`
}
type TOTPObject struct {
QRCode string `json:"qr_code,omitempty"`
Secret string `json:"secret,omitempty"`
URI string `json:"uri,omitempty"`
}
type EnrollFactorResponse struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
FriendlyName string `json:"friendly_name"`
TOTP *TOTPObject `json:"totp,omitempty"`
Phone string `json:"phone,omitempty"`
}
type ChallengeFactorParams struct {
Channel string `json:"channel"`
WebAuthn *WebAuthnParams `json:"web_authn,omitempty"`
}
type VerifyFactorParams struct {
ChallengeID uuid.UUID `json:"challenge_id"`
Code string `json:"code"`
WebAuthn *WebAuthnParams `json:"web_authn,omitempty"`
}
type ChallengeFactorResponse struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
ExpiresAt int64 `json:"expires_at,omitempty"`
CredentialRequestOptions *wbnprotocol.CredentialAssertion `json:"credential_request_options,omitempty"`
CredentialCreationOptions *wbnprotocol.CredentialCreation `json:"credential_creation_options,omitempty"`
}
type UnenrollFactorResponse struct {
ID uuid.UUID `json:"id"`
}
type WebAuthnParams struct {
RPID string `json:"rp_id,omitempty"`
// Can encode multiple origins as comma separated values like: "origin1,origin2"
RPOrigins string `json:"rp_origins,omitempty"`
AssertionResponse json.RawMessage `json:"assertion_response,omitempty"`
CreationResponse json.RawMessage `json:"creation_response,omitempty"`
}
func (w *WebAuthnParams) GetRPOrigins() []string {
if w.RPOrigins == "" {
return nil
}
return strings.Split(w.RPOrigins, ",")
}
func (w *WebAuthnParams) ToConfig() (*webauthn.WebAuthn, error) {
if w.RPID == "" {
return nil, fmt.Errorf("webAuthn RP ID cannot be empty")
}
origins := w.GetRPOrigins()
if len(origins) == 0 {
return nil, fmt.Errorf("webAuthn RP Origins cannot be empty")
}
var validOrigins []string
var invalidOrigins []string
for _, origin := range origins {
parsedURL, err := url.Parse(origin)
if err != nil || (parsedURL.Scheme != "https" && !(parsedURL.Scheme == "http" && parsedURL.Hostname() == "localhost")) || parsedURL.Host == "" {
invalidOrigins = append(invalidOrigins, origin)
} else {
validOrigins = append(validOrigins, origin)
}
}
if len(invalidOrigins) > 0 {
return nil, fmt.Errorf("invalid RP origins: %s", strings.Join(invalidOrigins, ", "))
}
wconfig := &webauthn.Config{
// DisplayName is optional in spec but required to be non-empty in libary, we use the RPID as a placeholder.
RPDisplayName: w.RPID,
RPID: w.RPID,
RPOrigins: validOrigins,
}
return webauthn.New(wconfig)
}
const (
QRCodeGenerationErrorMessage = "Error generating QR Code"
)
func validateFactors(db *storage.Connection, user *models.User, newFactorName string, config *conf.GlobalConfiguration, session *models.Session) error {
if err := models.DeleteExpiredFactors(db, config.MFA.FactorExpiryDuration); err != nil {
return err
}
if err := db.Load(user, "Factors"); err != nil {
return err
}
factorCount := len(user.Factors)
numVerifiedFactors := 0
for _, factor := range user.Factors {
if factor.FriendlyName == newFactorName {
return unprocessableEntityError(
ErrorCodeMFAFactorNameConflict,
fmt.Sprintf("A factor with the friendly name %q for this user already exists", newFactorName),
)
}
if factor.IsVerified() {
numVerifiedFactors++
}
}
if factorCount >= int(config.MFA.MaxEnrolledFactors) {
return unprocessableEntityError(ErrorCodeTooManyEnrolledMFAFactors, "Maximum number of verified factors reached, unenroll to continue")
}
if numVerifiedFactors >= config.MFA.MaxVerifiedFactors {
return unprocessableEntityError(ErrorCodeTooManyEnrolledMFAFactors, "Maximum number of verified factors reached, unenroll to continue")
}
if numVerifiedFactors > 0 && session != nil && !session.IsAAL2() {
return forbiddenError(ErrorCodeInsufficientAAL, "AAL2 required to enroll a new factor")
}
return nil
}
func (a *API) enrollPhoneFactor(w http.ResponseWriter, r *http.Request, params *EnrollFactorParams) error {
ctx := r.Context()
user := getUser(ctx)
session := getSession(ctx)
db := a.db.WithContext(ctx)
if params.Phone == "" {
return badRequestError(ErrorCodeValidationFailed, "Phone number required to enroll Phone factor")
}
phone, err := validatePhone(params.Phone)
if err != nil {
return badRequestError(ErrorCodeValidationFailed, "Invalid phone number format (E.164 required)")
}
var factorsToDelete []models.Factor
for _, factor := range user.Factors {
if factor.IsPhoneFactor() && factor.Phone.String() == phone {
if factor.IsVerified() {
return unprocessableEntityError(
ErrorCodeMFAVerifiedFactorExists,
"A verified phone factor already exists, unenroll the existing factor to continue",
)
} else if factor.IsUnverified() {
factorsToDelete = append(factorsToDelete, factor)
}
}
}
if err := db.Destroy(&factorsToDelete); err != nil {
return internalServerError("Database error deleting unverified phone factors").WithInternalError(err)
}
if err := validateFactors(db, user, params.FriendlyName, a.config, session); err != nil {
return err
}
factor := models.NewPhoneFactor(user, phone, params.FriendlyName)
err = db.Transaction(func(tx *storage.Connection) error {
if terr := tx.Create(factor); terr != nil {
return terr
}
if terr := models.NewAuditLogEntry(r, tx, user, models.EnrollFactorAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"factor_type": factor.FactorType,
}); terr != nil {
return terr
}
return nil
})
if err != nil {
return err
}
return sendJSON(w, http.StatusOK, &EnrollFactorResponse{
ID: factor.ID,
Type: models.Phone,
FriendlyName: factor.FriendlyName,
Phone: params.Phone,
})
}
func (a *API) enrollWebAuthnFactor(w http.ResponseWriter, r *http.Request, params *EnrollFactorParams) error {
ctx := r.Context()
user := getUser(ctx)
session := getSession(ctx)
db := a.db.WithContext(ctx)
if err := validateFactors(db, user, params.FriendlyName, a.config, session); err != nil {
return err
}
factor := models.NewWebAuthnFactor(user, params.FriendlyName)
err := db.Transaction(func(tx *storage.Connection) error {
if terr := tx.Create(factor); terr != nil {
return terr
}
if terr := models.NewAuditLogEntry(r, tx, user, models.EnrollFactorAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"factor_type": factor.FactorType,
}); terr != nil {
return terr
}
return nil
})
if err != nil {
return err
}
return sendJSON(w, http.StatusOK, &EnrollFactorResponse{
ID: factor.ID,
Type: models.WebAuthn,
FriendlyName: factor.FriendlyName,
})
}
func (a *API) enrollTOTPFactor(w http.ResponseWriter, r *http.Request, params *EnrollFactorParams) error {
ctx := r.Context()
user := getUser(ctx)
db := a.db.WithContext(ctx)
config := a.config
session := getSession(ctx)
issuer := ""
if params.Issuer == "" {
u, err := url.ParseRequestURI(config.SiteURL)
if err != nil {
return internalServerError("site url is improperly formatted")
}
issuer = u.Host
} else {
issuer = params.Issuer
}
if err := validateFactors(db, user, params.FriendlyName, config, session); err != nil {
return err
}
var factor *models.Factor
var buf bytes.Buffer
var key *otp.Key
key, err := totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: user.GetEmail(),
})
if err != nil {
return internalServerError(QRCodeGenerationErrorMessage).WithInternalError(err)
}
svgData := svg.New(&buf)
qrCode, _ := qr.Encode(key.String(), qr.H, qr.Auto)
qs := goqrsvg.NewQrSVG(qrCode, DefaultQRSize)
qs.StartQrSVG(svgData)
if err = qs.WriteQrSVG(svgData); err != nil {
return internalServerError(QRCodeGenerationErrorMessage).WithInternalError(err)
}
svgData.End()
factor = models.NewTOTPFactor(user, params.FriendlyName)
if err := factor.SetSecret(key.Secret(), config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil {
return err
}
err = db.Transaction(func(tx *storage.Connection) error {
if terr := tx.Create(factor); terr != nil {
return terr
}
if terr := models.NewAuditLogEntry(r, tx, user, models.EnrollFactorAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
}); terr != nil {
return terr
}
return nil
})
if err != nil {
return err
}
return sendJSON(w, http.StatusOK, &EnrollFactorResponse{
ID: factor.ID,
Type: models.TOTP,
FriendlyName: factor.FriendlyName,
TOTP: &TOTPObject{
// See: https://css-tricks.com/probably-dont-base64-svg/
QRCode: buf.String(),
Secret: key.Secret(),
URI: key.URL(),
},
})
}
func (a *API) EnrollFactor(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
user := getUser(ctx)
session := getSession(ctx)
config := a.config
if session == nil || user == nil {
return internalServerError("A valid session and a registered user are required to enroll a factor")
}
params := &EnrollFactorParams{}
if err := retrieveRequestParams(r, params); err != nil {
return err
}
switch params.FactorType {
case models.Phone:
if !config.MFA.Phone.EnrollEnabled {
return unprocessableEntityError(ErrorCodeMFAPhoneEnrollDisabled, "MFA enroll is disabled for Phone")
}
return a.enrollPhoneFactor(w, r, params)
case models.TOTP:
if !config.MFA.TOTP.EnrollEnabled {
return unprocessableEntityError(ErrorCodeMFATOTPEnrollDisabled, "MFA enroll is disabled for TOTP")
}
return a.enrollTOTPFactor(w, r, params)
case models.WebAuthn:
if !config.MFA.WebAuthn.EnrollEnabled {
return unprocessableEntityError(ErrorCodeMFAWebAuthnEnrollDisabled, "MFA enroll is disabled for WebAuthn")
}
return a.enrollWebAuthnFactor(w, r, params)
default:
return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be totp, phone, or webauthn")
}
}
func (a *API) challengePhoneFactor(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
config := a.config
db := a.db.WithContext(ctx)
user := getUser(ctx)
factor := getFactor(ctx)
ipAddress := utilities.GetIPAddress(r)
params := &ChallengeFactorParams{}
if err := retrieveRequestParams(r, params); err != nil {
return err
}
channel := params.Channel
if channel == "" {
channel = sms_provider.SMSProvider
}
if !sms_provider.IsValidMessageChannel(channel, config) {
return badRequestError(ErrorCodeValidationFailed, InvalidChannelError)
}
if factor.IsPhoneFactor() && factor.LastChallengedAt != nil {
if !factor.LastChallengedAt.Add(config.MFA.Phone.MaxFrequency).Before(time.Now()) {
return tooManyRequestsError(ErrorCodeOverSMSSendRateLimit, generateFrequencyLimitErrorMessage(factor.LastChallengedAt, config.MFA.Phone.MaxFrequency))
}
}
otp := crypto.GenerateOtp(config.MFA.Phone.OtpLength)
challenge, err := factor.CreatePhoneChallenge(ipAddress, otp, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey)
if err != nil {
return internalServerError("error creating SMS Challenge")
}
message, err := generateSMSFromTemplate(config.MFA.Phone.SMSTemplate, otp)
if err != nil {
return internalServerError("error generating sms template").WithInternalError(err)
}
if config.Hook.SendSMS.Enabled {
input := hooks.SendSMSInput{
User: user,
SMS: hooks.SMS{
OTP: otp,
SMSType: "mfa",
},
}
output := hooks.SendSMSOutput{}
err := a.invokeHook(a.db, r, &input, &output)
if err != nil {
return internalServerError("error invoking hook")
}
} else {
smsProvider, err := sms_provider.GetSmsProvider(*config)
if err != nil {
return internalServerError("Failed to get SMS provider").WithInternalError(err)
}
// We omit messageID for now, can consider reinstating if there are requests.
if _, err = smsProvider.SendMessage(factor.Phone.String(), message, channel, otp); err != nil {
return internalServerError("error sending message").WithInternalError(err)
}
}
if err := db.Transaction(func(tx *storage.Connection) error {
if terr := factor.WriteChallengeToDatabase(tx, challenge); terr != nil {
return terr
}
if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"factor_status": factor.Status,
}); terr != nil {
return terr
}
return nil
}); err != nil {
return err
}
return sendJSON(w, http.StatusOK, &ChallengeFactorResponse{
ID: challenge.ID,
Type: factor.FactorType,
ExpiresAt: challenge.GetExpiryTime(config.MFA.ChallengeExpiryDuration).Unix(),
})
}
func (a *API) challengeTOTPFactor(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
config := a.config
db := a.db.WithContext(ctx)
user := getUser(ctx)
factor := getFactor(ctx)
ipAddress := utilities.GetIPAddress(r)
challenge := factor.CreateChallenge(ipAddress)
if err := db.Transaction(func(tx *storage.Connection) error {
if terr := factor.WriteChallengeToDatabase(tx, challenge); terr != nil {
return terr
}
if terr := models.NewAuditLogEntry(r, tx, user, models.CreateChallengeAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"factor_status": factor.Status,
}); terr != nil {
return terr
}
return nil
}); err != nil {
return err
}
return sendJSON(w, http.StatusOK, &ChallengeFactorResponse{
ID: challenge.ID,
Type: factor.FactorType,
ExpiresAt: challenge.GetExpiryTime(config.MFA.ChallengeExpiryDuration).Unix(),
})
}
func (a *API) challengeWebAuthnFactor(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
db := a.db.WithContext(ctx)
config := a.config
user := getUser(ctx)
factor := getFactor(ctx)
ipAddress := utilities.GetIPAddress(r)
params := &ChallengeFactorParams{}
if err := retrieveRequestParams(r, params); err != nil {
return err
}
if params.WebAuthn == nil {
return badRequestError(ErrorCodeValidationFailed, "web_authn config required")
}
webAuthn, err := params.WebAuthn.ToConfig()
if err != nil {
return err
}
var response *ChallengeFactorResponse
var ws *models.WebAuthnSessionData
var challenge *models.Challenge
if factor.IsUnverified() {
options, session, err := webAuthn.BeginRegistration(user)
if err != nil {
return internalServerError("Failed to generate WebAuthn registration data").WithInternalError(err)
}
ws = &models.WebAuthnSessionData{
SessionData: session,
}
challenge = ws.ToChallenge(factor.ID, ipAddress)
response = &ChallengeFactorResponse{
CredentialCreationOptions: options,
Type: factor.FactorType,
ID: challenge.ID,
}
} else if factor.IsVerified() {
options, session, err := webAuthn.BeginLogin(user)
if err != nil {
return err
}
ws = &models.WebAuthnSessionData{
SessionData: session,
}
challenge = ws.ToChallenge(factor.ID, ipAddress)
response = &ChallengeFactorResponse{
CredentialRequestOptions: options,
Type: factor.FactorType,
ID: challenge.ID,
}
}
if err := factor.WriteChallengeToDatabase(db, challenge); err != nil {
return err
}
response.ExpiresAt = challenge.GetExpiryTime(config.MFA.ChallengeExpiryDuration).Unix()
return sendJSON(w, http.StatusOK, response)
}
func (a *API) validateChallenge(r *http.Request, db *storage.Connection, factor *models.Factor, challengeID uuid.UUID) (*models.Challenge, error) {
config := a.config
currentIP := utilities.GetIPAddress(r)
challenge, err := factor.FindChallengeByID(db, challengeID)
if err != nil {
if models.IsNotFoundError(err) {
return nil, unprocessableEntityError(ErrorCodeMFAFactorNotFound, "MFA factor with the provided challenge ID not found")
}
return nil, internalServerError("Database error finding Challenge").WithInternalError(err)
}
if challenge.VerifiedAt != nil || challenge.IPAddress != currentIP {
return nil, unprocessableEntityError(ErrorCodeMFAIPAddressMismatch, "Challenge and verify IP addresses mismatch.")
}
if challenge.HasExpired(config.MFA.ChallengeExpiryDuration) {
if err := db.Destroy(challenge); err != nil {
return nil, internalServerError("Database error deleting challenge").WithInternalError(err)
}
return nil, unprocessableEntityError(ErrorCodeMFAChallengeExpired, "MFA challenge %v has expired, verify against another challenge or create a new challenge.", challenge.ID)
}
return challenge, nil
}
func (a *API) ChallengeFactor(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
config := a.config
factor := getFactor(ctx)
switch factor.FactorType {
case models.Phone:
if !config.MFA.Phone.VerifyEnabled {
return unprocessableEntityError(ErrorCodeMFAPhoneVerifyDisabled, "MFA verification is disabled for Phone")
}
return a.challengePhoneFactor(w, r)
case models.TOTP:
if !config.MFA.TOTP.VerifyEnabled {
return unprocessableEntityError(ErrorCodeMFATOTPVerifyDisabled, "MFA verification is disabled for TOTP")
}
return a.challengeTOTPFactor(w, r)
case models.WebAuthn:
if !config.MFA.WebAuthn.VerifyEnabled {
return unprocessableEntityError(ErrorCodeMFAWebAuthnVerifyDisabled, "MFA verification is disabled for WebAuthn")
}
return a.challengeWebAuthnFactor(w, r)
default:
return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be totp, phone, or webauthn")
}
}
func (a *API) verifyTOTPFactor(w http.ResponseWriter, r *http.Request, params *VerifyFactorParams) error {
var err error
ctx := r.Context()
user := getUser(ctx)
factor := getFactor(ctx)
config := a.config
db := a.db.WithContext(ctx)
challenge, err := a.validateChallenge(r, db, factor, params.ChallengeID)
if err != nil {
return err
}
secret, shouldReEncrypt, err := factor.GetSecret(config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID)
if err != nil {
return internalServerError("Database error verifying MFA TOTP secret").WithInternalError(err)
}
valid, verr := totp.ValidateCustom(params.Code, secret, time.Now().UTC(), totp.ValidateOpts{
Period: 30,
Skew: 1,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if config.Hook.MFAVerificationAttempt.Enabled {
input := hooks.MFAVerificationAttemptInput{
UserID: user.ID,
FactorID: factor.ID,
Valid: valid,
}
output := hooks.MFAVerificationAttemptOutput{}
err := a.invokeHook(nil, r, &input, &output)
if err != nil {
return err
}
if output.Decision == hooks.HookRejection {
if err := models.Logout(db, user.ID); err != nil {
return err
}
if output.Message == "" {
output.Message = hooks.DefaultMFAHookRejectionMessage
}
return forbiddenError(ErrorCodeMFAVerificationRejected, output.Message)
}
}
if !valid {
if shouldReEncrypt && config.Security.DBEncryption.Encrypt {
if err := factor.SetSecret(secret, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil {
return err
}
if err := db.UpdateOnly(factor, "secret"); err != nil {
return err
}
}
return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid TOTP code entered").WithInternalError(verr)
}
var token *AccessTokenResponse
err = db.Transaction(func(tx *storage.Connection) error {
var terr error
if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"challenge_id": challenge.ID,
"factor_type": factor.FactorType,
}); terr != nil {
return terr
}
if terr = challenge.Verify(tx); terr != nil {
return terr
}
if !factor.IsVerified() {
if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil {
return terr
}
}
if shouldReEncrypt && config.Security.DBEncryption.Encrypt {
es, terr := crypto.NewEncryptedString(factor.ID.String(), []byte(secret), config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey)
if terr != nil {
return terr
}
factor.Secret = es.String()
if terr := tx.UpdateOnly(factor, "secret"); terr != nil {
return terr
}
}
user, terr = models.FindUserByID(tx, user.ID)
if terr != nil {
return terr
}
token, terr = a.updateMFASessionAndClaims(r, tx, user, models.TOTPSignIn, models.GrantParams{
FactorID: &factor.ID,
})
if terr != nil {
return terr
}
if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil {
return internalServerError("Failed to update sessions. %s", terr)
}
if terr = models.DeleteUnverifiedFactors(tx, user, factor.FactorType); terr != nil {
return internalServerError("Error removing unverified factors. %s", terr)
}
return nil
})
if err != nil {
return err
}
metering.RecordLogin(string(models.MFACodeLoginAction), user.ID)
return sendJSON(w, http.StatusOK, token)
}
func (a *API) verifyPhoneFactor(w http.ResponseWriter, r *http.Request, params *VerifyFactorParams) error {
ctx := r.Context()
config := a.config
user := getUser(ctx)
factor := getFactor(ctx)
db := a.db.WithContext(ctx)
currentIP := utilities.GetIPAddress(r)
challenge, err := a.validateChallenge(r, db, factor, params.ChallengeID)
if err != nil {
return err
}
if challenge.VerifiedAt != nil || challenge.IPAddress != currentIP {
return unprocessableEntityError(ErrorCodeMFAIPAddressMismatch, "Challenge and verify IP addresses mismatch")
}
if challenge.HasExpired(config.MFA.ChallengeExpiryDuration) {
if err := db.Destroy(challenge); err != nil {
return internalServerError("Database error deleting challenge").WithInternalError(err)
}
return unprocessableEntityError(ErrorCodeMFAChallengeExpired, "MFA challenge %v has expired, verify against another challenge or create a new challenge.", challenge.ID)
}
var valid bool
var otpCode string
var shouldReEncrypt bool
if config.Sms.IsTwilioVerifyProvider() {
smsProvider, err := sms_provider.GetSmsProvider(*config)
if err != nil {
return internalServerError("Failed to get SMS provider").WithInternalError(err)
}
if err := smsProvider.VerifyOTP(factor.Phone.String(), params.Code); err != nil {
return forbiddenError(ErrorCodeOTPExpired, "Token has expired or is invalid").WithInternalError(err)
}
valid = true
} else {
otpCode, shouldReEncrypt, err = challenge.GetOtpCode(config.Security.DBEncryption.DecryptionKeys, config.Security.DBEncryption.Encrypt, config.Security.DBEncryption.EncryptionKeyID)
if err != nil {
return internalServerError("Database error verifying MFA TOTP secret").WithInternalError(err)
}
valid = subtle.ConstantTimeCompare([]byte(otpCode), []byte(params.Code)) == 1
}
if config.Hook.MFAVerificationAttempt.Enabled {
input := hooks.MFAVerificationAttemptInput{
UserID: user.ID,
FactorID: factor.ID,
FactorType: factor.FactorType,
Valid: valid,
}
output := hooks.MFAVerificationAttemptOutput{}
err := a.invokeHook(nil, r, &input, &output)
if err != nil {
return err
}
if output.Decision == hooks.HookRejection {
if err := models.Logout(db, user.ID); err != nil {
return err
}
if output.Message == "" {
output.Message = hooks.DefaultMFAHookRejectionMessage
}
return forbiddenError(ErrorCodeMFAVerificationRejected, output.Message)
}
}
if !valid {
if shouldReEncrypt && config.Security.DBEncryption.Encrypt {
if err := challenge.SetOtpCode(otpCode, true, config.Security.DBEncryption.EncryptionKeyID, config.Security.DBEncryption.EncryptionKey); err != nil {
return err
}
if err := db.UpdateOnly(challenge, "otp_code"); err != nil {
return err
}
}
return unprocessableEntityError(ErrorCodeMFAVerificationFailed, "Invalid MFA Phone code entered")
}
var token *AccessTokenResponse
err = db.Transaction(func(tx *storage.Connection) error {
var terr error
if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"challenge_id": challenge.ID,
"factor_type": factor.FactorType,
}); terr != nil {
return terr
}
if terr = challenge.Verify(tx); terr != nil {
return terr
}
if !factor.IsVerified() {
if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil {
return terr
}
}
user, terr = models.FindUserByID(tx, user.ID)
if terr != nil {
return terr
}
token, terr = a.updateMFASessionAndClaims(r, tx, user, models.MFAPhone, models.GrantParams{
FactorID: &factor.ID,
})
if terr != nil {
return terr
}
if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil {
return internalServerError("Failed to update sessions. %s", terr)
}
if terr = models.DeleteUnverifiedFactors(tx, user, factor.FactorType); terr != nil {
return internalServerError("Error removing unverified factors. %s", terr)
}
return nil
})
if err != nil {
return err
}
metering.RecordLogin(string(models.MFACodeLoginAction), user.ID)
return sendJSON(w, http.StatusOK, token)
}
func (a *API) verifyWebAuthnFactor(w http.ResponseWriter, r *http.Request, params *VerifyFactorParams) error {
ctx := r.Context()
user := getUser(ctx)
factor := getFactor(ctx)
db := a.db.WithContext(ctx)
var webAuthn *webauthn.WebAuthn
var credential *webauthn.Credential
var err error
switch {
case params.WebAuthn == nil:
return badRequestError(ErrorCodeValidationFailed, "WebAuthn config required")
case factor.IsVerified() && params.WebAuthn.AssertionResponse == nil:
return badRequestError(ErrorCodeValidationFailed, "creation_response required to login")
case factor.IsUnverified() && params.WebAuthn.CreationResponse == nil:
return badRequestError(ErrorCodeValidationFailed, "assertion_response required to login")
default:
webAuthn, err = params.WebAuthn.ToConfig()
if err != nil {
return err
}
}
challenge, err := a.validateChallenge(r, db, factor, params.ChallengeID)
if err != nil {
return err
}
webAuthnSession := *challenge.WebAuthnSessionData.SessionData
// Once the challenge is validated, we consume the challenge
if err := db.Destroy(challenge); err != nil {
return internalServerError("Database error deleting challenge").WithInternalError(err)
}
if factor.IsUnverified() {
parsedResponse, err := wbnprotocol.ParseCredentialCreationResponseBody(bytes.NewReader(params.WebAuthn.CreationResponse))
if err != nil {
return badRequestError(ErrorCodeValidationFailed, "Invalid credential_creation_response")
}
credential, err = webAuthn.CreateCredential(user, webAuthnSession, parsedResponse)
if err != nil {
return err
}
} else if factor.IsVerified() {
parsedResponse, err := wbnprotocol.ParseCredentialRequestResponseBody(bytes.NewReader(params.WebAuthn.AssertionResponse))
if err != nil {
return badRequestError(ErrorCodeValidationFailed, "Invalid credential_request_response")
}
credential, err = webAuthn.ValidateLogin(user, webAuthnSession, parsedResponse)
if err != nil {
return internalServerError("Failed to validate WebAuthn MFA response").WithInternalError(err)
}
}
var token *AccessTokenResponse
err = db.Transaction(func(tx *storage.Connection) error {
var terr error
if terr = models.NewAuditLogEntry(r, tx, user, models.VerifyFactorAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"challenge_id": challenge.ID,
"factor_type": factor.FactorType,
}); terr != nil {
return terr
}
// Challenge verification not needed as the challenge is destroyed on use
if !factor.IsVerified() {
if terr = factor.UpdateStatus(tx, models.FactorStateVerified); terr != nil {
return terr
}
if terr = factor.SaveWebAuthnCredential(tx, credential); terr != nil {
return terr
}
}
user, terr = models.FindUserByID(tx, user.ID)
if terr != nil {
return terr
}
token, terr = a.updateMFASessionAndClaims(r, tx, user, models.MFAWebAuthn, models.GrantParams{
FactorID: &factor.ID,
})
if terr != nil {
return terr
}
if terr = models.InvalidateSessionsWithAALLessThan(tx, user.ID, models.AAL2.String()); terr != nil {
return internalServerError("Failed to update session").WithInternalError(terr)
}
if terr = models.DeleteUnverifiedFactors(tx, user, models.WebAuthn); terr != nil {
return internalServerError("Failed to remove unverified MFA WebAuthn factors").WithInternalError(terr)
}
return nil
})
if err != nil {
return err
}
metering.RecordLogin(string(models.MFACodeLoginAction), user.ID)
return sendJSON(w, http.StatusOK, token)
}
func (a *API) VerifyFactor(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
factor := getFactor(ctx)
config := a.config
params := &VerifyFactorParams{}
if err := retrieveRequestParams(r, params); err != nil {
return err
}
if params.Code == "" && factor.FactorType != models.WebAuthn {
return badRequestError(ErrorCodeValidationFailed, "Code needs to be non-empty")
}
switch factor.FactorType {
case models.Phone:
if !config.MFA.Phone.VerifyEnabled {
return unprocessableEntityError(ErrorCodeMFAPhoneVerifyDisabled, "MFA verification is disabled for Phone")
}
return a.verifyPhoneFactor(w, r, params)
case models.TOTP:
if !config.MFA.TOTP.VerifyEnabled {
return unprocessableEntityError(ErrorCodeMFATOTPVerifyDisabled, "MFA verification is disabled for TOTP")
}
return a.verifyTOTPFactor(w, r, params)
case models.WebAuthn:
if !config.MFA.WebAuthn.VerifyEnabled {
return unprocessableEntityError(ErrorCodeMFAWebAuthnEnrollDisabled, "MFA verification is disabled for WebAuthn")
}
return a.verifyWebAuthnFactor(w, r, params)
default:
return badRequestError(ErrorCodeValidationFailed, "factor_type needs to be totp, phone, or webauthn")
}
}
func (a *API) UnenrollFactor(w http.ResponseWriter, r *http.Request) error {
var err error
ctx := r.Context()
user := getUser(ctx)
factor := getFactor(ctx)
session := getSession(ctx)
db := a.db.WithContext(ctx)
if factor == nil || session == nil || user == nil {
return internalServerError("A valid session and factor are required to unenroll a factor")
}
if factor.IsVerified() && !session.IsAAL2() {
return unprocessableEntityError(ErrorCodeInsufficientAAL, "AAL2 required to unenroll verified factor")
}
err = db.Transaction(func(tx *storage.Connection) error {
var terr error
if terr := tx.Destroy(factor); terr != nil {
return terr
}
if terr = models.NewAuditLogEntry(r, tx, user, models.UnenrollFactorAction, r.RemoteAddr, map[string]interface{}{
"factor_id": factor.ID,
"factor_status": factor.Status,
"session_id": session.ID,
}); terr != nil {
return terr
}
if terr = factor.DowngradeSessionsToAAL1(tx); terr != nil {
return terr
}
return nil
})
if err != nil {
return err
}
return sendJSON(w, http.StatusOK, &UnenrollFactorResponse{
ID: factor.ID,
})
}