Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
500 lines
18 KiB
Go
500 lines
18 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"encoding/json"
|
|
"errors"
|
|
"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"
|
|
VerificationScopeAccountBind = "account.binding.bind"
|
|
VerificationScopeAccountUnbind = "account.binding.unbind"
|
|
VerificationScopePasswordSet = "account.password.set"
|
|
VerificationScopePasswordChange = "account.password.change"
|
|
VerificationScopeAccountDelete = "account.delete"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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 VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup,
|
|
VerificationScopeTwoFADisable, VerificationScopeTwoFABackupCodes,
|
|
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
|
|
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, VerificationScopeAccessTokenRevoke,
|
|
VerificationScopeAccountBind, VerificationScopeAccountUnbind,
|
|
VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete:
|
|
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.GetPasskeySettings().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
|
|
}
|
|
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:
|
|
methods[i].Available, methods[i].Reason = false, "Password authentication is disabled."
|
|
}
|
|
}
|
|
if methods[i].Method != VerificationMethodOAuth {
|
|
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 := ValidateLoginSession(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 := ValidateLoginSession(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 := ValidateLoginSession(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
|
|
}
|