* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
237 lines
7.4 KiB
Go
237 lines
7.4 KiB
Go
package model
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
const (
|
|
AuthFlowPurposeOAuth = "oauth"
|
|
AuthFlowPurposeTwoFALogin = "2fa_login"
|
|
AuthFlowPurposePasskeyLogin = "passkey_login"
|
|
AuthFlowPurposePasskeyRegister = "passkey_register"
|
|
AuthFlowPurposePasskeyStepUp = "passkey_step_up"
|
|
AuthFlowPurposeTelegramBind = "telegram_bind"
|
|
AuthFlowPurposeTelegramAssertion = "telegram_assertion"
|
|
AuthFlowIntentLogin = "login"
|
|
AuthFlowIntentBind = "bind"
|
|
AuthFlowTokenBytes = 32
|
|
AuthFlowDefaultCleanupRetention = 24 * time.Hour
|
|
)
|
|
|
|
var (
|
|
ErrAuthFlowInvalid = errors.New("auth flow is invalid")
|
|
ErrAuthFlowExpired = errors.New("auth flow has expired")
|
|
ErrAuthFlowConsumed = errors.New("auth flow has already been consumed")
|
|
)
|
|
|
|
// AuthFlow stores one-time, short-lived state for authentication ceremonies.
|
|
// TokenHash is an HMAC of the opaque token; the token itself is never persisted.
|
|
type AuthFlow struct {
|
|
Id int64 `json:"id" gorm:"primaryKey"`
|
|
TokenHash string `json:"-" gorm:"type:char(64);not null;uniqueIndex"`
|
|
Purpose string `json:"purpose" gorm:"type:varchar(32);not null;index:idx_auth_flow_purpose_expiry"`
|
|
Provider string `json:"provider,omitempty" gorm:"type:varchar(64)"`
|
|
Intent string `json:"intent,omitempty" gorm:"type:varchar(16)"`
|
|
UserId int `json:"user_id,omitempty" gorm:"index"`
|
|
SessionId string `json:"session_id,omitempty" gorm:"type:varchar(64);index"`
|
|
Payload string `json:"-" gorm:"type:text"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_auth_flow_purpose_expiry"`
|
|
ConsumedAt *time.Time `json:"consumed_at,omitempty" gorm:"index"`
|
|
}
|
|
|
|
func (AuthFlow) TableName() string {
|
|
return "auth_flows"
|
|
}
|
|
|
|
type AuthFlowCreate struct {
|
|
Purpose string
|
|
Provider string
|
|
Intent string
|
|
UserId int
|
|
SessionId string
|
|
Payload string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
type AuthFlowMatch struct {
|
|
Purpose string
|
|
Provider string
|
|
Intent string
|
|
UserId int
|
|
SessionId string
|
|
}
|
|
|
|
func applyAuthFlowMatch(query *gorm.DB, token string, match AuthFlowMatch) *gorm.DB {
|
|
query = query.Where("token_hash = ? AND purpose = ?", authFlowTokenHash(token), match.Purpose)
|
|
if match.Provider != "" {
|
|
query = query.Where("provider = ?", match.Provider)
|
|
}
|
|
if match.Intent != "" {
|
|
query = query.Where("intent = ?", match.Intent)
|
|
}
|
|
if match.UserId != 0 {
|
|
query = query.Where("user_id = ?", match.UserId)
|
|
}
|
|
if match.SessionId != "" {
|
|
query = query.Where("session_id = ?", match.SessionId)
|
|
}
|
|
return query
|
|
}
|
|
|
|
func authFlowTokenHash(token string) string {
|
|
return common.GenerateHMACWithKey([]byte("auth-flow-v1:"+common.SessionSecret), token)
|
|
}
|
|
|
|
func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) {
|
|
if strings.TrimSpace(input.Purpose) == "" || input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) {
|
|
return "", nil, ErrAuthFlowInvalid
|
|
}
|
|
random := make([]byte, AuthFlowTokenBytes)
|
|
if _, err := rand.Read(random); err != nil {
|
|
return "", nil, fmt.Errorf("generate auth flow token: %w", err)
|
|
}
|
|
token := base64.RawURLEncoding.EncodeToString(random)
|
|
flow := &AuthFlow{
|
|
TokenHash: authFlowTokenHash(token),
|
|
Purpose: input.Purpose,
|
|
Provider: input.Provider,
|
|
Intent: input.Intent,
|
|
UserId: input.UserId,
|
|
SessionId: input.SessionId,
|
|
Payload: input.Payload,
|
|
ExpiresAt: input.ExpiresAt,
|
|
}
|
|
if err := DB.Create(flow).Error; err != nil {
|
|
return "", nil, err
|
|
}
|
|
return token, flow, nil
|
|
}
|
|
|
|
// ClaimExternalAuthAssertion records a signed provider assertion as consumed.
|
|
// The assertion is HMACed before storage and the unique token_hash index makes
|
|
// replay rejection atomic on SQLite, MySQL and PostgreSQL.
|
|
func ClaimExternalAuthAssertion(purpose, assertion string, expiresAt time.Time) error {
|
|
return DB.Transaction(func(tx *gorm.DB) error {
|
|
return ClaimExternalAuthAssertionWithTx(tx, purpose, assertion, expiresAt)
|
|
})
|
|
}
|
|
|
|
// ClaimExternalAuthAssertionWithTx records a provider assertion in the
|
|
// caller's transaction so replay protection can commit atomically with the
|
|
// authentication flow and its resulting state change.
|
|
func ClaimExternalAuthAssertionWithTx(tx *gorm.DB, purpose, assertion string, expiresAt time.Time) error {
|
|
purpose = strings.TrimSpace(purpose)
|
|
assertion = strings.TrimSpace(assertion)
|
|
now := time.Now()
|
|
if tx == nil || purpose == "" || assertion == "" || !expiresAt.After(now) {
|
|
return ErrAuthFlowInvalid
|
|
}
|
|
flow := AuthFlow{
|
|
TokenHash: authFlowTokenHash("external:" + purpose + ":" + assertion),
|
|
Purpose: purpose,
|
|
ExpiresAt: expiresAt,
|
|
ConsumedAt: &now,
|
|
}
|
|
result := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "token_hash"}},
|
|
DoNothing: true,
|
|
}).Create(&flow)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrAuthFlowConsumed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAuthFlow validates a flow without consuming it. Callers must still use
|
|
// ConsumeAuthFlow with all identity-bound fields before performing the action.
|
|
func GetAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) {
|
|
if token == "" || match.Purpose == "" {
|
|
return nil, ErrAuthFlowInvalid
|
|
}
|
|
var flow AuthFlow
|
|
if err := applyAuthFlowMatch(DB, token, match).First(&flow).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrAuthFlowInvalid
|
|
}
|
|
return nil, err
|
|
}
|
|
if flow.ConsumedAt != nil {
|
|
return nil, ErrAuthFlowConsumed
|
|
}
|
|
if !flow.ExpiresAt.After(time.Now()) {
|
|
return nil, ErrAuthFlowExpired
|
|
}
|
|
return &flow, nil
|
|
}
|
|
|
|
// ConsumeAuthFlow atomically validates and consumes a flow. Optional match
|
|
// fields are enforced when non-zero so tokens cannot cross purposes or users.
|
|
func ConsumeAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) {
|
|
return ConsumeAuthFlowWithAction(token, match, nil)
|
|
}
|
|
|
|
// ConsumeAuthFlowWithAction consumes a flow and runs action in the same
|
|
// database transaction. An action failure rolls the consumption back.
|
|
func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx *gorm.DB, flow *AuthFlow) error) (*AuthFlow, error) {
|
|
if token == "" || match.Purpose == "" {
|
|
return nil, ErrAuthFlowInvalid
|
|
}
|
|
var consumed AuthFlow
|
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
|
query := applyAuthFlowMatch(lockForUpdate(tx), token, match)
|
|
if err := query.First(&consumed).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrAuthFlowInvalid
|
|
}
|
|
return err
|
|
}
|
|
if consumed.ConsumedAt != nil {
|
|
return ErrAuthFlowConsumed
|
|
}
|
|
now := time.Now()
|
|
if !consumed.ExpiresAt.After(now) {
|
|
return ErrAuthFlowExpired
|
|
}
|
|
result := tx.Model(&AuthFlow{}).
|
|
Where("id = ? AND consumed_at IS NULL AND expires_at > ?", consumed.Id, now).
|
|
Update("consumed_at", now)
|
|
if result.Error != nil {
|
|
return result.Error
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return ErrAuthFlowConsumed
|
|
}
|
|
consumed.ConsumedAt = &now
|
|
if action != nil {
|
|
if err := action(tx, &consumed); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &consumed, nil
|
|
}
|
|
|
|
func DeleteExpiredAuthFlows(now time.Time) error {
|
|
cutoff := now.Add(-AuthFlowDefaultCleanupRetention)
|
|
return DB.Where("expires_at < ? OR (consumed_at IS NOT NULL AND consumed_at < ?)", cutoff, cutoff).
|
|
Delete(&AuthFlow{}).Error
|
|
}
|