Files
cneicy ceddfd3d6b feat: sync deployed v1.0.0-rc.38-custom.42
Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
2026-09-26 04:49:11 +08:00

271 lines
10 KiB
Go

package model
import (
"crypto/hmac"
"errors"
"maps"
"slices"
"strings"
"sync"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var passkeyOptionMutex sync.Mutex
var errPasskeyDomainPreview = errors.New("passkey domain preview")
var ErrPasskeyDomainRemovalConfirmation = errors.New("Review the affected Passkeys and confirm the domain removal.")
// PasskeyDomainChange is both the preview and the authoritative saved result.
// Unknown credentials may belong to a removed domain; they are not a known loss.
type PasskeyDomainChange struct {
RPID string `json:"rp_id"`
LegacyRPIDs string `json:"legacy_rp_ids"`
Origins string `json:"origins"`
PreviousRPID string `json:"previous_rp_id"`
EffectiveRPID string `json:"effective_rp_id"`
RemovedRPIDs []string `json:"removed_rp_ids"`
AffectedCredentials int64 `json:"affected_credentials"`
UnknownCredentials int64 `json:"unknown_credentials"`
ConfirmationRequired bool `json:"confirmation_required"`
RemovalConfirmation string `json:"removal_confirmation"`
}
type PasskeyDomainRemovalError struct {
Change *PasskeyDomainChange
}
func (e *PasskeyDomainRemovalError) Error() string {
return ErrPasskeyDomainRemovalConfirmation.Error()
}
func (e *PasskeyDomainRemovalError) Unwrap() error { return ErrPasskeyDomainRemovalConfirmation }
// ServerAddress and origins participate because either can change an implicit RP ID.
func IsPasskeyDomainOption(key string) bool {
return key == "passkey.rp_id" || key == "passkey.legacy_rp_ids" || key == "passkey.origins" || key == "ServerAddress"
}
// lockPasskeyDomainSettings serializes trust changes and credential writes across
// nodes. Callers also hold passkeyOptionMutex for SQLite and local cache ordering.
// Always acquire these option rows before any user/session/credential row locks.
func lockPasskeyDomainSettings(tx *gorm.DB) (system_setting.PasskeySettings, string, error) {
common.OptionMapRWMutex.RLock()
settings, serverAddress := *system_setting.GetPasskeySettings(), system_setting.ServerAddress
common.OptionMapRWMutex.RUnlock()
rows := []Option{
{Key: "ServerAddress", Value: serverAddress},
{Key: "passkey.legacy_rp_ids", Value: settings.LegacyRPIDs},
{Key: "passkey.origins", Value: settings.Origins},
{Key: "passkey.rp_id", Value: settings.RPID},
}
// The first write also acquires SQLite's writer lock before any reads.
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil {
return settings, serverAddress, err
}
for i := range rows {
if err := lockForUpdate(tx).Where(&Option{Key: rows[i].Key}).First(&rows[i]).Error; err != nil {
return settings, serverAddress, err
}
}
serverAddress = rows[0].Value
settings.LegacyRPIDs, settings.Origins, settings.RPID = rows[1].Value, rows[2].Value, rows[3].Value
return settings, serverAddress, nil
}
func validatePasskeyRPIDWithTx(tx *gorm.DB, rpID string) error {
settings, serverAddress, err := lockPasskeyDomainSettings(tx)
if err != nil {
return err
}
// Preserve the existing request-derived default only when no domain or
// origin has ever been configured. Finish still checks the signed origin.
if settings.RPID == "" && settings.LegacyRPIDs == "" && settings.Origins == "" && serverAddress == "" {
return nil
}
if !slices.Contains(settings.WithDefaults(serverAddress).RelyingPartyIDs(), rpID) {
return system_setting.ErrPasskeyRPIDUnavailable
}
return nil
}
// UpdatePasskeyDomainOptions keeps every supplied option in one transaction,
// including unrelated keys from UpdateOptionsBulk. Preview rolls back even the
// initial default rows and never publishes a local configuration change.
func UpdatePasskeyDomainOptions(values map[string]string, preview bool, confirmation string) (*PasskeyDomainChange, error) {
passkeyOptionMutex.Lock()
defer passkeyOptionMutex.Unlock()
values = maps.Clone(values)
for key, value := range values {
if err := validateOptionValue(key, value); err != nil {
return nil, err
}
}
var change *PasskeyDomainChange
err := DB.Transaction(func(tx *gorm.DB) error {
settings, serverAddress, err := lockPasskeyDomainSettings(tx)
if err != nil {
return err
}
previous := settings.WithDefaults(serverAddress)
before := [4]string{serverAddress, settings.RPID, settings.LegacyRPIDs, settings.Origins}
if value, changed := values["ServerAddress"]; changed {
serverAddress = value
}
if value, changed := values["passkey.origins"]; changed {
origins := []string{}
for _, origin := range strings.FieldsFunc(value, func(c rune) bool { return c == ',' || c == '\n' || c == '\r' }) {
origin = strings.TrimSpace(origin)
if origin != "" && !slices.Contains(origins, origin) {
origins = append(origins, origin)
}
}
settings.Origins = strings.Join(origins, ",")
} else if before[0] != serverAddress && (settings.Origins == "" || settings.Origins == "[]") {
// Retain the previously trusted implicit website as well as the new
// default, so retaining its RP ID does not leave its keys unusable.
origins := []string{}
for _, origin := range []string{previous.Origins, settings.WithDefaults(serverAddress).Origins} {
if origin != "" && !slices.Contains(origins, origin) {
origins = append(origins, origin)
}
}
settings.Origins = strings.Join(origins, ",")
}
if value, changed := values["passkey.rp_id"]; changed {
settings.RPID = strings.TrimSpace(value)
if settings.RPID != "" {
settings.RPID, err = system_setting.NormalizePasskeyRPID(settings.RPID, settings.WithDefaults(serverAddress).Origins)
if err != nil {
return err
}
}
}
if value, changed := values["passkey.legacy_rp_ids"]; changed {
settings.LegacyRPIDs = value
}
effective := settings.WithDefaults(serverAddress)
legacy, err := system_setting.ParsePasskeyRPIDs(settings.LegacyRPIDs, effective.Origins)
if err != nil {
return err
}
oldRPID, nextRPID := previous.EffectiveRPID(), effective.EffectiveRPID()
_, previousDomainError := system_setting.NormalizePasskeyRPID(oldRPID, previous.Origins)
if previousDomainError == nil && oldRPID != nextRPID && !slices.Contains(legacy, oldRPID) {
legacy = append(legacy, oldRPID)
}
settings.LegacyRPIDs = strings.Join(legacy, ",")
// Validate retained single-label domains against the complete proposed
// origin set too. Do not normalize their historical hash inputs.
if _, err := system_setting.ParsePasskeyRPIDs(settings.LegacyRPIDs, effective.Origins); err != nil {
return err
}
// ServerAddress also supports non-WebAuthn deployments (for example an
// IP during initial setup). Restrict explicit RP IDs, not that URL.
if settings.RPID != "" {
if _, err := system_setting.NormalizePasskeyRPID(nextRPID, effective.Origins); err != nil {
return err
}
}
change = &PasskeyDomainChange{
RPID: settings.RPID, LegacyRPIDs: settings.LegacyRPIDs, Origins: settings.Origins,
PreviousRPID: oldRPID, EffectiveRPID: nextRPID, RemovedRPIDs: []string{},
}
nextIDs := settings.WithDefaults(serverAddress).RelyingPartyIDs()
for _, id := range previous.RelyingPartyIDs() {
if !slices.Contains(nextIDs, id) {
change.RemovedRPIDs = append(change.RemovedRPIDs, id)
}
}
if len(change.RemovedRPIDs) > 0 {
// MySQL may compare/group text case-insensitively. Select only
// candidate RP values, then count exact bytes without credential IDs.
rows, err := tx.Model(&PasskeyCredential{}).Select("rp_id").
Where("rp_id IS NULL OR rp_id = ? OR rp_id IN ?", "", change.RemovedRPIDs).Rows()
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var rpID *string
if err := rows.Scan(&rpID); err != nil {
return err
}
if rpID == nil || *rpID == "" {
change.UnknownCredentials++
} else if slices.Contains(change.RemovedRPIDs, *rpID) {
change.AffectedCredentials++
}
}
if err := rows.Err(); err != nil {
return err
}
if err := rows.Close(); err != nil {
return err
}
}
change.ConfirmationRequired = change.AffectedCredentials > 0 || change.UnknownCredentials > 0
// This is an intent/freshness binding, not an authentication credential.
// Root authorization remains mandatory. No map iteration affects its hash.
payload, err := common.Marshal(struct {
Before [4]string
After [4]string
Removed []string
Known, Unknown int64
}{before, [4]string{serverAddress, settings.RPID, settings.LegacyRPIDs, settings.Origins}, change.RemovedRPIDs, change.AffectedCredentials, change.UnknownCredentials})
if err != nil {
return err
}
change.RemovalConfirmation = common.GenerateHMACWithKey([]byte("passkey-domains-v1:"+common.SessionSecret), string(payload))
if preview {
return errPasskeyDomainPreview
}
if (change.ConfirmationRequired || confirmation != "") && !hmac.Equal([]byte(confirmation), []byte(change.RemovalConfirmation)) {
return &PasskeyDomainRemovalError{Change: change}
}
values["passkey.rp_id"], values["passkey.legacy_rp_ids"], values["passkey.origins"] = settings.RPID, settings.LegacyRPIDs, settings.Origins
values["ServerAddress"] = serverAddress
keys := slices.Sorted(maps.Keys(values))
for _, key := range keys {
if err := tx.Save(&Option{Key: key, Value: values[key]}).Error; err != nil {
return err
}
}
return nil
})
if errors.Is(err, errPasskeyDomainPreview) {
return change, nil
}
if err != nil {
return change, err
}
applyPasskeyDomainOptions(values)
for key, value := range values {
if !IsPasskeyDomainOption(key) {
if err := updateOptionMap(key, value); err != nil {
return change, err
}
}
}
return change, nil
}
func applyPasskeyDomainOptions(values map[string]string) {
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
if common.OptionMap == nil {
common.OptionMap = make(map[string]string)
}
for _, key := range []string{"ServerAddress", "passkey.legacy_rp_ids", "passkey.origins", "passkey.rp_id"} {
if value, ok := values[key]; ok {
common.OptionMap[key] = value
if key == "ServerAddress" {
system_setting.ServerAddress = value
} else {
handleConfigUpdate(key, value)
}
}
}
}