Files
new-api/service/security_verification.go
CaIon 4924361aef feat(auth): edit the name and permissions of an access token
The token menu now opens an edit dialog instead of a rename prompt.
Renaming still needs no verification. Changing the permissions requires
step-up verification bound to the token id and the new grant, so a
proof cannot be replayed for another token or another set of
permissions; the grant is checked against what the owner can do, the
same as at creation. PATCH /api/user/access_tokens/:id takes an
optional scopes list and is rate limited like the other token routes.

A token that holds permissions the catalog no longer offers can still
be renamed and keeps them; a new grant only contains offered
permissions. Edit stays disabled until the permission catalog has
loaded.

Grant changes are audited as access_token.update with the previous and
new scopes, and audit details show both as permission names.

The permission picker for tokens and admin users now uses toggle
buttons. Token permissions are grouped by personal, administration and
system, each group collapsible with a selected count and select-all or
clear.
2026-09-30 20:52:36 +08:00

659 lines
25 KiB
Go

package service
import (
"crypto/hmac"
"encoding/json"
"errors"
"slices"
"sort"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/setting/system_setting"
"gorm.io/gorm"
)
const (
VerificationMethodTwoFA = "2fa"
VerificationMethodPasskey = "passkey"
VerificationMethodPassword = "password"
VerificationMethodOAuth = "oauth"
VerificationMethodSession = "session"
VerificationScopeChannelKeyRead = "channel.key.read"
VerificationScopePasskeyRegister = "passkey.register"
VerificationScopePasskeyDelete = "passkey.delete"
VerificationScopeTwoFASetup = "2fa.setup"
VerificationScopeTwoFADisable = "2fa.disable"
VerificationScopeTwoFABackupCodes = "2fa.backup_codes.regenerate"
VerificationScopeLogin = "auth.login"
VerificationScopeAccessTokenGenerate = "access_token.generate"
VerificationScopeAccessTokenRevoke = "access_token.revoke"
VerificationScopeAccessTokenUpdate = "access_token.update"
VerificationScopeAccountBind = "account.binding.bind"
VerificationScopeAccountUnbind = "account.binding.unbind"
VerificationScopePasswordSet = "account.password.set"
VerificationScopePasswordChange = "account.password.change"
VerificationScopeAccountDelete = "account.delete"
// Administrative step-up scopes bind the managed user into the proof so a
// proof issued for one account cannot be replayed against another.
VerificationScopeAdminUserCreate = "admin.user.create"
VerificationScopeAdminUserUpdate = "admin.user.update"
VerificationScopeAdminUserDelete = "admin.user.delete"
VerificationScopeAdminUserManage = "admin.user.manage"
VerificationScopeAdminUserPasskeyReset = "admin.user.passkey.reset"
VerificationScopeAdminUserTwoFADisable = "admin.user.2fa.disable"
VerificationScopeAdminUserBindingClear = "admin.user.binding.clear"
verificationScopeAdminUserPrefix = "admin.user."
)
// adminUserManageActions are the ManageUser actions that change a user's
// status or role. Quota adjustments are not gated and deletion has its own scope.
var adminUserManageActions = []string{"disable", "enable", "promote", "demote"}
var (
ErrVerificationFailed = errors.New("Verification failed. Please try again.")
ErrVerificationUnavailable = errors.New("This verification method is currently unavailable.")
ErrVerificationLocked = errors.New("Two-factor authentication is temporarily locked.")
ErrVerificationFlowRequired = errors.New("This verification method requires its dedicated verification flow.")
ErrVerificationContextInvalid = errors.New("The action details are invalid.")
ErrVerificationForbidden = errors.New("You do not have permission to perform this action.")
)
// VerificationOperation describes the action being authorized, independently of
// its authentication method. Each scope owns a strict, normalized context schema.
type VerificationOperation struct {
Scope string `json:"scope"`
Context json.RawMessage `json:"context,omitempty"`
}
type ChannelKeyReadContext struct {
ChannelID int `json:"channel_id"`
}
type AccountBindingContext struct {
Provider string `json:"provider"`
Email string `json:"email,omitempty"`
Code string `json:"code,omitempty"`
}
type AccountUnbindingContext struct {
ProviderID int `json:"provider_id"`
}
type AdminUserContext struct {
UserID int `json:"user_id"`
}
type AdminUserManageContext struct {
UserID int `json:"user_id"`
Action string `json:"action"`
}
// AdminUserBindingContext names exactly one binding: a built-in binding type
// or a custom OAuth provider ID.
type AdminUserBindingContext struct {
UserID int `json:"user_id"`
BindingType string `json:"binding_type,omitempty"`
ProviderID int `json:"provider_id,omitempty"`
}
type AdminUserCreateContext struct {
Role int `json:"role"`
}
// AccessTokenGenerateContext binds a creation proof to the exact grant. Scopes
// are compared as a sorted, de-duplicated set.
type AccessTokenGenerateContext struct {
Scopes []string `json:"scopes"`
ExpiresAt int64 `json:"expires_at"`
}
// AccessTokenUpdateContext binds a grant change to one token and the exact new
// grant, compared as a sorted, de-duplicated set.
type AccessTokenUpdateContext struct {
TokenID int `json:"token_id"`
Scopes []string `json:"scopes"`
}
// AccessTokenRevokeContext names exactly one token: a scoped token ID or the
// legacy token.
type AccessTokenRevokeContext struct {
TokenID int `json:"token_id,omitempty"`
Legacy bool `json:"legacy,omitempty"`
}
const (
maxAccessTokenContextScopes = 128
maxAccessTokenContextScopeLen = 64
)
// NormalizeAccessTokenScopeList trims, de-duplicates and sorts scope keys. It
// only checks the shape; grantability is checked against the scope catalog.
func NormalizeAccessTokenScopeList(scopes []string) ([]string, bool) {
if len(scopes) == 0 || len(scopes) > maxAccessTokenContextScopes {
return nil, false
}
normalized := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" || len(scope) > maxAccessTokenContextScopeLen {
return nil, false
}
normalized = append(normalized, scope)
}
slices.Sort(normalized)
return slices.Compact(normalized), true
}
// VerificationBinding contains no original operation parameters. It can safely
// travel through a signed proof or a server-owned interactive verification flow.
type VerificationBinding struct {
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
}
func BindVerificationOperation(operation VerificationOperation) (VerificationBinding, error) {
var fields map[string]json.RawMessage
if len(operation.Context) > 0 {
if common.GetJsonType(operation.Context) != "object" || common.Unmarshal(operation.Context, &fields) != nil {
return VerificationBinding{}, ErrVerificationContextInvalid
}
}
var normalized any
switch operation.Scope {
case VerificationScopeChannelKeyRead:
var context ChannelKeyReadContext
if len(fields) != 1 || common.Unmarshal(fields["channel_id"], &context.ChannelID) != nil || context.ChannelID <= 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopeAccountBind:
var context AccountBindingContext
if common.Unmarshal(operation.Context, &context) != nil {
return VerificationBinding{}, ErrVerificationContextInvalid
}
context.Provider = strings.TrimSpace(context.Provider)
switch context.Provider {
case "email":
context.Email = model.NormalizeEmail(context.Email)
if len(fields) != 2 || common.Validate.Var(context.Email, "required,email") != nil || context.Code != "" {
return VerificationBinding{}, ErrVerificationContextInvalid
}
case "wechat":
context.Code = strings.TrimSpace(context.Code)
if len(fields) != 2 || context.Code == "" || len(context.Code) > 128 || context.Email != "" {
return VerificationBinding{}, ErrVerificationContextInvalid
}
default:
if len(fields) != 1 || context.Provider == "" || len(context.Provider) > 64 || oauth.GetProvider(context.Provider) == nil {
return VerificationBinding{}, ErrVerificationContextInvalid
}
}
normalized = context
case VerificationScopeAccountUnbind:
var context AccountUnbindingContext
if len(fields) != 1 || common.Unmarshal(fields["provider_id"], &context.ProviderID) != nil || context.ProviderID <= 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopeAdminUserUpdate, VerificationScopeAdminUserDelete, VerificationScopeAdminUserPasskeyReset, VerificationScopeAdminUserTwoFADisable:
var context AdminUserContext
if len(fields) != 1 || common.Unmarshal(fields["user_id"], &context.UserID) != nil || context.UserID <= 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopeAdminUserManage:
var context AdminUserManageContext
if len(fields) != 2 || common.Unmarshal(operation.Context, &context) != nil || context.UserID <= 0 || !slices.Contains(adminUserManageActions, context.Action) {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopeAdminUserBindingClear:
var context AdminUserBindingContext
if len(fields) != 2 || common.Unmarshal(operation.Context, &context) != nil || context.UserID <= 0 || context.ProviderID < 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
context.BindingType = strings.ToLower(strings.TrimSpace(context.BindingType))
if (context.BindingType == "") == (context.ProviderID == 0) || len(context.BindingType) > 32 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopeAdminUserCreate:
var context AdminUserCreateContext
if len(fields) != 1 || common.Unmarshal(fields["role"], &context.Role) != nil || context.Role < common.RoleAdminUser || !common.IsValidateRole(context.Role) {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopeAccessTokenGenerate:
var context AccessTokenGenerateContext
if len(fields) != 2 || fields["scopes"] == nil || fields["expires_at"] == nil ||
common.Unmarshal(operation.Context, &context) != nil || context.ExpiresAt < 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
scopes, ok := NormalizeAccessTokenScopeList(context.Scopes)
if !ok {
return VerificationBinding{}, ErrVerificationContextInvalid
}
context.Scopes = scopes
normalized = context
case VerificationScopeAccessTokenUpdate:
var context AccessTokenUpdateContext
if len(fields) != 2 || common.Unmarshal(operation.Context, &context) != nil || context.TokenID <= 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
scopes, ok := NormalizeAccessTokenScopeList(context.Scopes)
if !ok {
return VerificationBinding{}, ErrVerificationContextInvalid
}
context.Scopes = scopes
normalized = context
case VerificationScopeAccessTokenRevoke:
var context AccessTokenRevokeContext
if len(fields) != 1 || common.Unmarshal(operation.Context, &context) != nil || context.TokenID < 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
// Exactly one target: {"token_id": n} or {"legacy": true}.
if (context.TokenID > 0) == context.Legacy {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup,
VerificationScopeTwoFADisable, VerificationScopeTwoFABackupCodes,
VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete:
if len(fields) != 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = struct{}{}
default:
return VerificationBinding{}, ErrProofScope
}
payload, err := common.Marshal(struct {
Scope string `json:"scope"`
Context any `json:"context"`
}{Scope: operation.Scope, Context: normalized})
if err != nil {
return VerificationBinding{}, err
}
return VerificationBinding{
Scope: operation.Scope,
ContextHash: common.GenerateHMACWithKey(authSigningKey("verification-context"), string(payload)),
}, nil
}
type SecurityProof struct {
ProofToken string `json:"proof_token"`
ExpiresAt int64 `json:"expires_at"`
Method string `json:"method"`
Scope string `json:"scope"`
}
type VerificationMethodOption struct {
Method string `json:"method"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
type VerificationOAuthProvider struct {
Slug string `json:"slug"`
Name string `json:"name"`
UserID string `json:"-"`
}
type VerificationRequirements struct {
Scope string `json:"scope"`
Methods []VerificationMethodOption `json:"methods"`
OAuthProviders []VerificationOAuthProvider `json:"oauth_providers"`
PasswordEncryptionEnabled bool `json:"password_encryption_enabled"`
}
// securityVerificationPolicy is the only operation-to-method policy. Device
// support and disabled providers never turn an enrolled factor into an absent one.
func securityVerificationPolicy(scope string, state model.UserVerificationState) ([]VerificationMethodOption, error) {
var methods []string
if state.HasTwoFA {
methods = append(methods, VerificationMethodTwoFA)
}
if state.HasPasskey {
methods = append(methods, VerificationMethodPasskey)
}
switch scope {
case VerificationScopeChannelKeyRead, VerificationScopePasskeyDelete, VerificationScopeLogin:
case VerificationScopeTwoFADisable, VerificationScopeTwoFABackupCodes:
if !state.HasTwoFA {
return nil, model.ErrTwoFANotEnabled
}
case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenUpdate, VerificationScopeAccessTokenRevoke,
VerificationScopeAccountBind, VerificationScopeAccountUnbind,
VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete,
VerificationScopeAdminUserCreate, VerificationScopeAdminUserUpdate, VerificationScopeAdminUserDelete,
VerificationScopeAdminUserManage, VerificationScopeAdminUserPasskeyReset,
VerificationScopeAdminUserTwoFADisable, VerificationScopeAdminUserBindingClear:
if scope == VerificationScopeAccountDelete && state.Role == common.RoleRootUser {
return nil, ErrVerificationForbidden
}
if scope == VerificationScopeTwoFASetup && state.HasTwoFA {
return nil, model.ErrTwoFAAlreadyEnabled
}
if (scope == VerificationScopePasswordSet && state.HasPassword) || (scope == VerificationScopePasswordChange && !state.HasPassword) {
return nil, ErrVerificationForbidden
}
if len(methods) == 0 {
if state.HasPassword {
methods = []string{VerificationMethodPassword}
} else {
methods = []string{VerificationMethodOAuth}
}
}
default:
return nil, ErrProofScope
}
options := make([]VerificationMethodOption, 0, len(methods))
for _, method := range methods {
option := VerificationMethodOption{Method: method, Available: true}
if method == VerificationMethodTwoFA && state.TwoFALocked {
option.Available, option.Reason = false, ErrVerificationLocked.Error()
}
if !system_setting.PasskeySettingsSnapshot().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) {
option.Available, option.Reason = false, "Passkey authentication is disabled."
}
options = append(options, option)
}
return options, nil
}
func GetVerificationRequirements(identity AuthIdentity, scope string) (*VerificationRequirements, error) {
if scope == VerificationScopeLogin {
return nil, ErrProofScope
}
state, err := model.GetUserVerificationState(identity.UserID)
if err != nil {
return nil, err
}
if state.Status != common.UserStatusEnabled || state.AuthVersion != identity.UserAuthVersion {
return nil, ErrAuthTokenInvalid
}
if scope == VerificationScopeChannelKeyRead && state.Role != common.RoleRootUser {
return nil, ErrVerificationForbidden
}
if strings.HasPrefix(scope, verificationScopeAdminUserPrefix) && state.Role < common.RoleAdminUser {
return nil, ErrVerificationForbidden
}
if err := requireAccessTokenVerificationScope(identity, scope); err != nil {
return nil, err
}
methods, err := securityVerificationPolicy(scope, *state)
if err != nil {
return nil, err
}
requirements := &VerificationRequirements{Scope: scope, Methods: methods, OAuthProviders: []VerificationOAuthProvider{}, PasswordEncryptionEnabled: common.PasswordLoginEncryptionEnabled}
for i := range methods {
if methods[i].Method == VerificationMethodPassword && !common.PasswordLoginEnabled {
switch scope {
case VerificationScopeAccountBind, VerificationScopeAccountUnbind, VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete,
VerificationScopeAdminUserCreate, VerificationScopeAdminUserUpdate, VerificationScopeAdminUserDelete,
VerificationScopeAdminUserManage, VerificationScopeAdminUserPasskeyReset,
VerificationScopeAdminUserTwoFADisable, VerificationScopeAdminUserBindingClear:
methods[i].Available, methods[i].Reason = false, "Password authentication is disabled."
}
}
if methods[i].Method != VerificationMethodOAuth {
continue
}
// OAuth verification completes through a browser popup bound to a login
// session, which an access token cannot provide.
if _, ok := model.ParseAccessTokenSessionID(identity.SessionID); ok {
methods[i].Available, methods[i].Reason = false, "OAuth verification requires a browser sign-in. Enable two-factor authentication or a Passkey to continue."
continue
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
return nil, err
}
requirements.OAuthProviders, err = verificationOAuthProviders(user)
if err != nil {
return nil, err
}
if len(requirements.OAuthProviders) == 0 {
methods[i].Available, methods[i].Reason = false, "No linked OAuth provider is available."
if user.TelegramId != "" {
if err := oauth.TelegramConfigurationError(); err != nil {
methods[i].Reason = err.Error()
}
}
}
}
return requirements, nil
}
func verificationOAuthProviders(user *model.User) ([]VerificationOAuthProvider, error) {
bindings := map[int]string{}
if len(oauth.GetEnabledCustomProviders()) > 0 {
stored, err := model.GetUserOAuthBindingsByUserId(user.Id)
if err != nil {
return nil, err
}
for _, binding := range stored {
bindings[binding.ProviderId] = binding.ProviderUserId
}
}
providers := []VerificationOAuthProvider{}
for slug, provider := range oauth.GetAllProviders() {
if !provider.IsEnabled() {
continue
}
var userID string
if custom, ok := provider.(*oauth.GenericOAuthProvider); ok {
userID = bindings[custom.GetProviderId()]
} else {
switch provider.ProviderUserIDColumn() {
case "github_id":
userID = user.GitHubId
case "discord_id":
userID = user.DiscordId
case "oidc_id":
userID = user.OidcId
case "linux_do_id":
userID = user.LinuxDOId
case "telegram_id":
userID = user.TelegramId
}
}
if userID != "" {
providers = append(providers, VerificationOAuthProvider{Slug: slug, Name: provider.GetName(), UserID: userID})
}
}
sort.Slice(providers, func(i, j int) bool { return providers[i].Slug < providers[j].Slug })
return providers, nil
}
func RequireVerificationMethod(identity AuthIdentity, scope, method string) (*VerificationRequirements, error) {
requirements, err := GetVerificationRequirements(identity, scope)
if err != nil {
return nil, err
}
for _, option := range requirements.Methods {
if option.Method != method {
continue
}
if !option.Available {
return nil, ErrVerificationUnavailable
}
return requirements, nil
}
return nil, ErrProofMethod
}
// CompleteSecurityVerification runs after the concrete authentication ceremony.
// Recheck the session and policy after potentially slow external authentication.
func CompleteSecurityVerification(identity AuthIdentity, binding VerificationBinding, method string) (*SecurityProof, error) {
if err := ValidateStepUpIdentity(identity); err != nil {
return nil, err
}
if _, err := RequireVerificationMethod(identity, binding.Scope, method); err != nil {
return nil, err
}
token, expiresAt, err := IssueSecurityProof(identity, method, binding)
if err != nil {
return nil, err
}
return &SecurityProof{ProofToken: token, ExpiresAt: expiresAt, Method: method, Scope: binding.Scope}, nil
}
// ConsumeOperationProof accepts a proof at most once. The consumption commits
// before the caller performs its action; an action failure must not restore it.
func ConsumeOperationProof(raw string, identity AuthIdentity, operation VerificationOperation) (*model.AuthFlowAuthorization, error) {
binding, err := BindVerificationOperation(operation)
if err != nil {
return nil, err
}
claims, err := verifySecurityProof(raw, identity, binding)
if err != nil {
return nil, err
}
if err := ValidateStepUpIdentity(identity); err != nil {
return nil, err
}
if _, err := RequireVerificationMethod(identity, binding.Scope, claims.Method); err != nil {
return nil, err
}
flow, err := model.ConsumeAuthFlowWithAction(claims.ID, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeSecurityProof, UserId: identity.UserID, SessionId: identity.SessionID,
}, func(tx *gorm.DB, _ *model.AuthFlow) error {
return model.ValidateAuthSessionWithTx(tx, identity)
})
switch {
case errors.Is(err, model.ErrAuthFlowConsumed):
return nil, ErrProofConsumed
case errors.Is(err, model.ErrAuthFlowExpired):
return nil, ErrAuthTokenExpired
case errors.Is(err, model.ErrAuthFlowInvalid):
return nil, ErrAuthTokenInvalid
case err != nil:
return nil, err
}
return &model.AuthFlowAuthorization{
AuthSessionIdentity: identity, ProofID: flow.Id, Scope: binding.Scope,
ContextHash: binding.ContextHash, Method: claims.Method,
}, nil
}
// ValidateFlowAuthorization permits a dedicated configuration flow to outlive
// its consumed proof, while retaining its operation, session and method policy.
func ValidateFlowAuthorization(identity AuthIdentity, operation VerificationOperation, authorization *model.AuthFlowAuthorization) error {
if authorization == nil || authorization.ProofID <= 0 || authorization.AuthSessionIdentity != identity {
return model.ErrAuthFlowInvalid
}
binding, err := BindVerificationOperation(operation)
if err != nil {
return err
}
if authorization.Scope != binding.Scope || !hmac.Equal([]byte(authorization.ContextHash), []byte(binding.ContextHash)) {
return model.ErrAuthFlowInvalid
}
if err := ValidateStepUpIdentity(identity); err != nil {
return err
}
_, err = RequireVerificationMethod(identity, binding.Scope, authorization.Method)
return err
}
type VerificationInput struct {
Method string `json:"method"`
Scope string `json:"scope"`
Context json.RawMessage `json:"context,omitempty"`
Code string `json:"code,omitempty"`
Password string `json:"password,omitempty"`
PasswordEncrypted string `json:"password_encrypted,omitempty"`
EncryptionKeyID string `json:"encryption_key_id,omitempty"`
}
func VerifySecurityInput(identity AuthIdentity, input VerificationInput) (*SecurityProof, error) {
binding, err := BindVerificationOperation(VerificationOperation{Scope: input.Scope, Context: input.Context})
if err != nil {
return nil, err
}
if _, err := RequireVerificationMethod(identity, input.Scope, input.Method); err != nil {
return nil, err
}
switch input.Method {
case VerificationMethodPassword:
password := input.Password
if common.PasswordLoginEncryptionEnabled {
var err error
password, err = common.DecryptPassword(input.PasswordEncrypted, input.EncryptionKeyID)
if err != nil {
return nil, ErrVerificationFailed
}
}
user, err := model.GetUserById(identity.UserID, true)
if err != nil {
return nil, err
}
if password == "" || user.Password == "" || !common.ValidatePasswordAndHash(password, user.Password) {
return nil, ErrVerificationFailed
}
case VerificationMethodTwoFA:
if input.Scope == VerificationScopeTwoFABackupCodes {
if _, err := common.ValidateNumericCode(input.Code); err != nil {
return nil, ErrVerificationFailed
}
}
twoFA, err := model.GetTwoFAByUserId(identity.UserID)
if err != nil {
return nil, err
}
if err := VerifyTwoFactorCode(twoFA, input.Code); err != nil {
return nil, err
}
case VerificationMethodPasskey, VerificationMethodOAuth:
return nil, ErrVerificationFlowRequired
default:
return nil, ErrProofMethod
}
return CompleteSecurityVerification(identity, binding, input.Method)
}
// VerifyTwoFactorCode classifies the input before verification so one failed
// submission cannot increment the failure counter for both TOTP and backup codes.
func VerifyTwoFactorCode(twoFA *model.TwoFA, code string) error {
if twoFA == nil || !twoFA.IsEnabled {
return model.ErrTwoFANotEnabled
}
if twoFA.IsLocked() {
return ErrVerificationLocked
}
code = strings.TrimSpace(code)
var valid bool
var err error
if numeric, numericErr := common.ValidateNumericCode(code); numericErr == nil {
valid, err = twoFA.ValidateTOTPAndUpdateUsage(numeric)
} else if common.ValidateBackupCode(code) {
valid, err = twoFA.ValidateBackupCodeAndUpdateUsage(code)
} else {
err = twoFA.IncrementFailedAttempts()
}
if err != nil {
return err
}
if !valid {
return ErrVerificationFailed
}
return nil
}
func GetOAuthVerificationBinding(identity AuthIdentity, scope, provider string) (string, error) {
requirements, err := RequireVerificationMethod(identity, scope, VerificationMethodOAuth)
if err != nil {
return "", err
}
for _, linked := range requirements.OAuthProviders {
if linked.Slug == provider {
return linked.UserID, nil
}
}
return "", ErrVerificationUnavailable
}