1030 lines
32 KiB
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,
|
|
})
|
|
}
|