Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
271 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|