refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* 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
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user