mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 16:46:57 +08:00
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.
659 lines
25 KiB
Go
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
|
|
}
|