mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-10 02:17:24 +08:00
feat(auth): add safe multi-RP ID passkey support
Preserve legacy credentials across primary RP ID changes and bind successful assertions to their verified RP ID. Share domain selection across sign-in, login verification and sensitive operations without replaying ceremonies. Save domain settings atomically, preview known and unknown credential impact, and require fresh confirmation before removing domains still in use. Recheck trusted RP IDs when persisting credentials and record safe operation audits. Use read-only configuration snapshots, accept trusted HTTPS intranet hosts, check registration origins early, and preserve historical capitalization. Update settings guidance and all supported translations. Validation: SQLite 3.50.4, MySQL 8.0.46 and PostgreSQL 16.15, including fresh and released-schema upgrade checks; Go regression and race checks; 79 frontend tests; TypeScript, lint, frontend build and root Go build. Security references: OWASP ASVS 5.0.0 V6.3.4 and V6.3.8, Authentication and Session Management Cheat Sheets, and the WebAuthn RP ID/origin rules.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
@@ -43,6 +45,11 @@ var auditContentTemplates = map[string]string{
|
||||
"user.reset_passkey": "Reset the user passkey",
|
||||
"option.update": "Updated system setting ${key}",
|
||||
|
||||
"option.passkey_domains": "Updated Passkey domains: removed ${domains}; affected ${known}; unknown ${unknown}",
|
||||
"option.passkey_domains_confirmed": "Confirmed removal of Passkey domains: ${domains}; affected ${known}; unknown ${unknown}",
|
||||
"option.passkey_domains_blocked": "Passkey domain change blocked: ${domains}; affected ${known}; unknown ${unknown}",
|
||||
"option.passkey_domains_failed": "Passkey domain update failed",
|
||||
|
||||
"channel.create": "Created channel ${name} (type ${type}, count ${count})",
|
||||
"channel.update": "Updated channel ${name} (ID: ${id})",
|
||||
"channel.delete": "Deleted channel ${name} (ID: ${id})",
|
||||
@@ -65,6 +72,33 @@ var auditContentTemplates = map[string]string{
|
||||
"subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}",
|
||||
}
|
||||
|
||||
func recordPasskeyDomainAudit(c *gin.Context, change *model.PasskeyDomainChange, confirmed bool, err error) {
|
||||
confirmed = confirmed && err == nil && change != nil && len(change.RemovedRPIDs) > 0
|
||||
params := map[string]any{"success": err == nil, "confirmed": confirmed}
|
||||
if change != nil {
|
||||
params["domains"] = strings.Join(change.RemovedRPIDs, ", ")
|
||||
params["removed_rp_ids"] = change.RemovedRPIDs
|
||||
params["known"] = change.AffectedCredentials
|
||||
params["unknown"] = change.UnknownCredentials
|
||||
params["previous_rp_id"] = change.PreviousRPID
|
||||
params["effective_rp_id"] = change.EffectiveRPID
|
||||
}
|
||||
action := "option.passkey_domains"
|
||||
if errors.Is(err, model.ErrPasskeyDomainRemovalConfirmation) {
|
||||
action = "option.passkey_domains_blocked"
|
||||
} else if err != nil {
|
||||
action = "option.passkey_domains_failed"
|
||||
} else if confirmed && change != nil && len(change.RemovedRPIDs) > 0 {
|
||||
action = "option.passkey_domains_confirmed"
|
||||
}
|
||||
auditInfo := &model.AuditRequestInfo{
|
||||
Method: c.Request.Method, Route: c.FullPath(), Path: c.FullPath(),
|
||||
Status: c.Writer.Status(), Success: err == nil,
|
||||
}
|
||||
model.RecordOperationAuditLog(c.GetInt("id"), c.GetInt("role"), auditContentEN(action, params), c.ClientIP(), action, params, auditOperatorInfo(c), auditInfo, c)
|
||||
markAuditLogged(c)
|
||||
}
|
||||
|
||||
// auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。
|
||||
func auditContentEN(action string, params map[string]any) string {
|
||||
tmpl, ok := auditContentTemplates[action]
|
||||
|
||||
@@ -41,6 +41,7 @@ func VerifyLogin(c *gin.Context) {
|
||||
func LoginPasskeyBegin(c *gin.Context) {
|
||||
var request struct {
|
||||
FlowToken string `json:"flow_token"`
|
||||
RPID string `json:"rp_id"`
|
||||
}
|
||||
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
@@ -56,7 +57,11 @@ func LoginPasskeyBegin(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
credentialRPID := ""
|
||||
if credential.RPID != nil {
|
||||
credentialRPID = *credential.RPID
|
||||
}
|
||||
wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, credentialRPID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
@@ -75,7 +80,7 @@ func LoginPasskeyBegin(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options})
|
||||
common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options, "rp_ids": rpIDs})
|
||||
}
|
||||
|
||||
func LoginPasskeyFinish(c *gin.Context) {
|
||||
@@ -104,6 +109,7 @@ func LoginPasskeyFinish(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
|
||||
if security.LoginFlowID != verification.Flow.Id || sessionData.UserVerification != protocol.VerificationRequired {
|
||||
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
||||
return
|
||||
@@ -113,17 +119,21 @@ func LoginPasskeyFinish(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
|
||||
writeSecurityOperationError(c, service.ErrVerificationFailed)
|
||||
return
|
||||
}
|
||||
validated, err := wa.ValidateLogin(passkeysvc.NewWebAuthnUser(&model.User{Id: identity.UserID}, credential), *sessionData, parsed)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now()); err != nil {
|
||||
if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now(), sessionData.RelyingPartyID); err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
+3
-2
@@ -44,10 +44,10 @@ func TestStatus(c *gin.Context) {
|
||||
func GetStatus(c *gin.Context) {
|
||||
|
||||
cs := console_setting.GetConsoleSetting()
|
||||
passkeySetting := system_setting.PasskeySettingsSnapshot()
|
||||
common.OptionMapRWMutex.RLock()
|
||||
defer common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
passkeySetting := system_setting.GetPasskeySettings()
|
||||
legalSetting := system_setting.GetLegalSettings()
|
||||
|
||||
data := gin.H{
|
||||
@@ -117,7 +117,8 @@ func GetStatus(c *gin.Context) {
|
||||
"oidc_display_name": system_setting.GetOIDCSettings().GetEffectiveDisplayName(),
|
||||
"passkey_login": passkeySetting.Enabled,
|
||||
"passkey_display_name": passkeySetting.RPDisplayName,
|
||||
"passkey_rp_id": passkeySetting.RPID,
|
||||
"passkey_rp_id": passkeySetting.EffectiveRPID(),
|
||||
"passkey_rp_ids": passkeySetting.RelyingPartyIDs(),
|
||||
"passkey_origins": passkeySetting.Origins,
|
||||
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
|
||||
"passkey_user_verification": passkeySetting.UserVerification,
|
||||
|
||||
+61
-1
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
@@ -135,6 +136,50 @@ type OptionUpdateRequest struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
func UpdatePasskeyDomains(c *gin.Context) {
|
||||
var request struct {
|
||||
RPID *string `json:"rp_id"`
|
||||
LegacyRPIDs *string `json:"legacy_rp_ids"`
|
||||
Origins *string `json:"origins"`
|
||||
Preview bool `json:"preview"`
|
||||
RemovalConfirmation string `json:"removal_confirmation"`
|
||||
}
|
||||
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.RPID == nil || request.LegacyRPIDs == nil || request.Origins == nil {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
change, err := model.UpdatePasskeyDomainOptions(map[string]string{
|
||||
"passkey.rp_id": *request.RPID, "passkey.legacy_rp_ids": *request.LegacyRPIDs, "passkey.origins": *request.Origins,
|
||||
}, request.Preview, request.RemovalConfirmation)
|
||||
if err != nil {
|
||||
writePasskeyDomainSettingsError(c, err)
|
||||
if !request.Preview {
|
||||
recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !request.Preview {
|
||||
recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", nil)
|
||||
}
|
||||
common.ApiSuccess(c, change)
|
||||
}
|
||||
|
||||
func writePasskeyDomainSettingsError(c *gin.Context, err error) {
|
||||
var removal *model.PasskeyDomainRemovalError
|
||||
if errors.As(err, &removal) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"success": false, "code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED",
|
||||
"message": i18n.T(c, i18n.MsgPasskeyRPIDRemovalConfirmation), "data": removal.Change,
|
||||
})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
}
|
||||
|
||||
func UpdateOption(c *gin.Context) {
|
||||
var option OptionUpdateRequest
|
||||
err := common.DecodeJson(c.Request.Body, &option)
|
||||
@@ -431,9 +476,24 @@ func UpdateOption(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if model.IsPasskeyDomainOption(option.Key) {
|
||||
change, updateErr := model.UpdatePasskeyDomainOptions(map[string]string{option.Key: option.Value.(string)}, false, "")
|
||||
if updateErr != nil {
|
||||
writePasskeyDomainSettingsError(c, updateErr)
|
||||
recordPasskeyDomainAudit(c, change, false, updateErr)
|
||||
return
|
||||
}
|
||||
recordPasskeyDomainAudit(c, change, false, nil)
|
||||
common.ApiSuccess(c, change)
|
||||
return
|
||||
}
|
||||
err = model.UpdateOption(option.Key, option.Value.(string))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) {
|
||||
writeSecurityOperationError(c, err)
|
||||
} else {
|
||||
common.ApiError(c, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
|
||||
|
||||
+53
-28
@@ -26,6 +26,7 @@ type passkeyFinishRequest struct {
|
||||
}
|
||||
|
||||
type passkeyVerifyBeginRequest struct {
|
||||
RPID string `json:"rp_id"`
|
||||
Scope string `json:"scope"`
|
||||
Context json.RawMessage `json:"context,omitempty"`
|
||||
}
|
||||
@@ -42,7 +43,7 @@ func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) {
|
||||
}
|
||||
|
||||
func PasskeyRegisterBegin(c *gin.Context) {
|
||||
if !system_setting.GetPasskeySettings().Enabled {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
@@ -118,7 +119,7 @@ func PasskeyRegisterBegin(c *gin.Context) {
|
||||
}
|
||||
|
||||
func PasskeyRegisterFinish(c *gin.Context) {
|
||||
if !system_setting.GetPasskeySettings().Enabled {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
@@ -142,12 +143,6 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
credentialRecord, err := model.GetPasskeyByUserID(user.Id)
|
||||
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
writeSecurityOperationError(c, err)
|
||||
@@ -171,6 +166,12 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
|
||||
if sessionData.UserVerification != protocol.VerificationRequired {
|
||||
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
||||
return
|
||||
@@ -193,6 +194,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
passkeyCredential.RPID = &sessionData.RelyingPartyID
|
||||
if err := model.RegisterPasskeyForSession(identity, passkeyCredential); err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
@@ -281,7 +283,7 @@ func PasskeyStatus(c *gin.Context) {
|
||||
}
|
||||
|
||||
func PasskeyLoginBegin(c *gin.Context) {
|
||||
if !system_setting.GetPasskeySettings().Enabled {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
@@ -289,7 +291,16 @@ func PasskeyLoginBegin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
var request struct {
|
||||
RPID string `json:"rp_id"`
|
||||
}
|
||||
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
||||
if common.DecodeJson(c.Request.Body, &request) != nil {
|
||||
common.ApiErrorMsg(c, "无效的 Passkey 验证请求")
|
||||
return
|
||||
}
|
||||
}
|
||||
wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, "")
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
@@ -316,6 +327,7 @@ func PasskeyLoginBegin(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"options": assertion,
|
||||
"rp_ids": rpIDs,
|
||||
"flow_token": flowToken,
|
||||
"expires_at": expiresAt,
|
||||
},
|
||||
@@ -323,7 +335,7 @@ func PasskeyLoginBegin(c *gin.Context) {
|
||||
}
|
||||
|
||||
func PasskeyLoginFinish(c *gin.Context) {
|
||||
if !system_setting.GetPasskeySettings().Enabled {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
@@ -342,12 +354,6 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
sessionData, _, err := passkeysvc.PopSessionDataFlow(
|
||||
request.FlowToken,
|
||||
model.AuthFlowPurposePasskeyLogin,
|
||||
@@ -357,6 +363,12 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
|
||||
if sessionData.UserVerification != protocol.VerificationRequired {
|
||||
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
||||
return
|
||||
@@ -369,6 +381,10 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
|
||||
}
|
||||
|
||||
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
|
||||
return nil, service.ErrVerificationFailed
|
||||
}
|
||||
|
||||
// 通过凭证获取用户
|
||||
user := &model.User{Id: credential.UserID}
|
||||
if err := user.FillUserById(); err != nil {
|
||||
@@ -415,7 +431,7 @@ func PasskeyLoginFinish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now()); err != nil {
|
||||
if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now(), sessionData.RelyingPartyID); err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -474,7 +490,7 @@ func AdminResetPasskey(c *gin.Context) {
|
||||
}
|
||||
|
||||
func PasskeyVerifyBegin(c *gin.Context) {
|
||||
if !system_setting.GetPasskeySettings().Enabled {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
@@ -516,7 +532,11 @@ func PasskeyVerifyBegin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
credentialRPID := ""
|
||||
if credential.RPID != nil {
|
||||
credentialRPID = *credential.RPID
|
||||
}
|
||||
wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, credentialRPID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
@@ -544,6 +564,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": gin.H{
|
||||
"options": assertion,
|
||||
"rp_ids": rpIDs,
|
||||
"flow_token": flowToken,
|
||||
"expires_at": expiresAt,
|
||||
},
|
||||
@@ -551,7 +572,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
|
||||
}
|
||||
|
||||
func PasskeyVerifyFinish(c *gin.Context) {
|
||||
if !system_setting.GetPasskeySettings().Enabled {
|
||||
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "管理员未启用 Passkey 登录",
|
||||
@@ -576,12 +597,6 @@ func PasskeyVerifyFinish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -605,11 +620,21 @@ func PasskeyVerifyFinish(c *gin.Context) {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
|
||||
if err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
|
||||
if sessionData.UserVerification != protocol.VerificationRequired {
|
||||
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
||||
return
|
||||
}
|
||||
|
||||
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
|
||||
writeSecurityOperationError(c, service.ErrVerificationFailed)
|
||||
return
|
||||
}
|
||||
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
||||
validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential)
|
||||
if err != nil {
|
||||
@@ -617,7 +642,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now()); err != nil {
|
||||
if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now(), sessionData.RelyingPartyID); err != nil {
|
||||
writeSecurityOperationError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -11,18 +17,705 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type passkeyTestBody struct {
|
||||
*strings.Reader
|
||||
}
|
||||
|
||||
type passkeyDomainBegin struct {
|
||||
FlowToken string `json:"flow_token"`
|
||||
RPIDs []string `json:"rp_ids"`
|
||||
Options struct {
|
||||
PublicKey struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RPID string `json:"rpId"`
|
||||
RP struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"rp"`
|
||||
} `json:"publicKey"`
|
||||
} `json:"options"`
|
||||
}
|
||||
|
||||
func passkeyDomainRequest(t *testing.T, path string, payload any, identity service.AuthIdentity, origin string, handler gin.HandlerFunc) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
return securityEnrollmentRequest(http.MethodPost, path, string(body), "", identity, func(c *gin.Context) {
|
||||
c.Request.Header.Set("Origin", origin)
|
||||
handler(c)
|
||||
})
|
||||
}
|
||||
|
||||
func decodePasskeyDomainBegin(t *testing.T, response *httptest.ResponseRecorder) passkeyDomainBegin {
|
||||
t.Helper()
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
var begin passkeyDomainBegin
|
||||
require.NoError(t, common.Unmarshal(result.Data, &begin))
|
||||
return begin
|
||||
}
|
||||
|
||||
// Reuse the existing software authenticator, replacing the domain-bound bytes
|
||||
// and recomputing the signature exactly as a browser/authenticator would.
|
||||
func passkeyDomainAssertion(t *testing.T, key *ecdsa.PrivateKey, challenge, rpID, origin string, userID int, verified bool) map[string]any {
|
||||
t.Helper()
|
||||
var assertion map[string]any
|
||||
require.NoError(t, common.Unmarshal(securityPasskeyResponse(t, key, challenge, false, 1, verified), &assertion))
|
||||
response := assertion["response"].(map[string]any)
|
||||
clientData, err := common.Marshal(map[string]string{"type": "webauthn.get", "challenge": challenge, "origin": origin})
|
||||
require.NoError(t, err)
|
||||
authData, err := base64.RawURLEncoding.DecodeString(response["authenticatorData"].(string))
|
||||
require.NoError(t, err)
|
||||
rpHash := sha256.Sum256([]byte(rpID))
|
||||
copy(authData, rpHash[:])
|
||||
clientHash := sha256.Sum256(clientData)
|
||||
signedHash := sha256.Sum256(append(append([]byte{}, authData...), clientHash[:]...))
|
||||
signature, err := ecdsa.SignASN1(rand.Reader, key, signedHash[:])
|
||||
require.NoError(t, err)
|
||||
response["clientDataJSON"] = base64.RawURLEncoding.EncodeToString(clientData)
|
||||
response["authenticatorData"] = base64.RawURLEncoding.EncodeToString(authData)
|
||||
response["signature"] = base64.RawURLEncoding.EncodeToString(signature)
|
||||
response["userHandle"] = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprint(userID)))
|
||||
return assertion
|
||||
}
|
||||
|
||||
func TestPasskeyDomainsPreserveCredentialsAcrossVerificationFlows(t *testing.T) {
|
||||
for _, kind := range []string{"direct", "login factor", "sensitive action", "historical mixed case"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
key := newSecurityLoginPasskey(t, user.Id)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
legacyRPID := "www.example.com"
|
||||
if kind == "historical mixed case" {
|
||||
legacyRPID = "WWW.example.com"
|
||||
}
|
||||
settings.LegacyRPIDs = legacyRPID
|
||||
settings.Origins = "https://example.com,https://www.example.com"
|
||||
beginPath, finishPath := "/api/user/passkey/login/begin", "/api/user/passkey/login/finish"
|
||||
beginHandler, finishHandler := PasskeyLoginBegin, PasskeyLoginFinish
|
||||
request := map[string]any{"rp_id": legacyRPID}
|
||||
if kind == "login factor" {
|
||||
pending, err := service.StartLoginVerification(user, "password")
|
||||
require.NoError(t, err)
|
||||
request["flow_token"] = pending.FlowToken
|
||||
beginPath, finishPath = "/api/user/login/passkey/begin", "/api/user/login/passkey/finish"
|
||||
beginHandler, finishHandler = LoginPasskeyBegin, LoginPasskeyFinish
|
||||
} else if kind == "sensitive action" {
|
||||
request["scope"] = service.VerificationScopeAccessTokenGenerate
|
||||
beginPath, finishPath = "/api/user/passkey/verify/begin", "/api/user/passkey/verify/finish"
|
||||
beginHandler, finishHandler = PasskeyVerifyBegin, PasskeyVerifyFinish
|
||||
}
|
||||
begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, beginPath, request, identity, "https://www.example.com", beginHandler))
|
||||
assert.Equal(t, legacyRPID, begin.Options.PublicKey.RPID)
|
||||
assert.Equal(t, []string{"example.com", legacyRPID}, begin.RPIDs)
|
||||
before, err := model.GetPasskeyByUserID(user.Id)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, before.RPID)
|
||||
finish := map[string]any{"flow_token": begin.FlowToken, "credential": passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, legacyRPID, "https://www.example.com", user.Id, true)}
|
||||
if kind == "login factor" {
|
||||
finish["flow_token"], finish["passkey_flow_token"] = request["flow_token"], begin.FlowToken
|
||||
}
|
||||
response := passkeyDomainRequest(t, finishPath, finish, identity, "https://www.example.com", finishHandler)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
after, err := model.GetPasskeyByUserID(user.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, after.RPID)
|
||||
assert.Equal(t, legacyRPID, *after.RPID)
|
||||
assert.Equal(t, before.CredentialID, after.CredentialID)
|
||||
assert.Equal(t, before.PublicKey, after.PublicKey)
|
||||
if kind == "sensitive action" {
|
||||
var proof service.SecurityProof
|
||||
require.NoError(t, common.Unmarshal(result.Data, &proof))
|
||||
assert.Equal(t, service.VerificationScopeAccessTokenGenerate, proof.Scope)
|
||||
}
|
||||
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
||||
require.NoError(t, err)
|
||||
replay := passkeyDomainRequest(t, finishPath, finish, identity, "https://www.example.com", finishHandler)
|
||||
require.NoError(t, common.Unmarshal(replay.Body.Bytes(), &result))
|
||||
assert.False(t, result.Success)
|
||||
afterCount, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count, afterCount)
|
||||
// Once identified, a browser hint cannot override the credential's binding.
|
||||
wa, ids, err := passkeysvc.BuildLoginWebAuthn(httptest.NewRequest(http.MethodPost, beginPath, nil), "example.com", *after.RPID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, legacyRPID, wa.Config.RPID)
|
||||
assert.Equal(t, []string{legacyRPID}, ids)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyDomainsRejectInvalidAssertions(t *testing.T) {
|
||||
for _, failure := range []string{"wrong RP ID", "unlisted origin", "origin outside RP scope", "wrong user", "bad signature", "missing user verification", "expired challenge", "removed domain", "known different domain"} {
|
||||
t.Run(failure, func(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
key := newSecurityLoginPasskey(t, user.Id)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.LegacyRPIDs = "www.example.com"
|
||||
settings.Origins = "https://example.com,https://www.example.com"
|
||||
begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": "www.example.com"}, identity, "https://www.example.com", PasskeyLoginBegin))
|
||||
rpID, origin, handle := "www.example.com", "https://www.example.com", user.Id
|
||||
switch failure {
|
||||
case "wrong RP ID":
|
||||
rpID = "example.com"
|
||||
case "unlisted origin":
|
||||
origin = "https://evil.example.com"
|
||||
case "origin outside RP scope":
|
||||
origin = "https://example.com"
|
||||
case "wrong user":
|
||||
handle++
|
||||
case "expired challenge":
|
||||
flow, err := model.GetAuthFlow(begin.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyLogin})
|
||||
require.NoError(t, err)
|
||||
var payload struct {
|
||||
SessionData webauthn.SessionData `json:"session_data"`
|
||||
Security passkeysvc.FlowSecurity `json:"security"`
|
||||
}
|
||||
require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload))
|
||||
payload.SessionData.Expires = time.Now().Add(-time.Second)
|
||||
encoded, err := common.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, model.DB.Model(flow).Update("payload", string(encoded)).Error)
|
||||
case "removed domain":
|
||||
settings.LegacyRPIDs = ""
|
||||
case "known different domain":
|
||||
require.NoError(t, model.DB.Model(&model.PasskeyCredential{}).Where("user_id = ?", user.Id).Update("rp_id", "example.com").Error)
|
||||
}
|
||||
assertion := passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, rpID, origin, handle, failure != "missing user verification")
|
||||
if failure == "bad signature" {
|
||||
assertion["response"].(map[string]any)["signature"] = "AA"
|
||||
}
|
||||
response := passkeyDomainRequest(t, "/api/user/passkey/login/finish", map[string]any{"flow_token": begin.FlowToken, "credential": assertion}, identity, "https://www.example.com", PasskeyLoginFinish)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.False(t, result.Success, response.Body.String())
|
||||
assert.Empty(t, response.Header().Values("Set-Cookie"))
|
||||
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 1, count)
|
||||
stored, err := model.GetPasskeyByUserID(user.Id)
|
||||
require.NoError(t, err)
|
||||
if failure == "known different domain" {
|
||||
assert.Equal(t, "example.com", *stored.RPID)
|
||||
} else {
|
||||
assert.Nil(t, stored.RPID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyDomainChoicesRespectOriginAndConfiguration(t *testing.T) {
|
||||
_, identity := setupSecurityEnrollmentTest(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.LegacyRPIDs = "www.example.com"
|
||||
settings.Origins = "https://example.com,https://www.example.com"
|
||||
for _, test := range []struct {
|
||||
origin, hint string
|
||||
success bool
|
||||
ids []string
|
||||
}{
|
||||
{"https://www.example.com", "", true, []string{"example.com", "www.example.com"}},
|
||||
{"https://example.com", "", true, []string{"example.com"}},
|
||||
{"https://example.com", "www.example.com", false, nil},
|
||||
{"https://www.example.com", "unconfigured.example.com", false, nil},
|
||||
{"https://attacker.test", "", false, nil},
|
||||
} {
|
||||
response := passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": test.hint}, identity, test.origin, PasskeyLoginBegin)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.Equal(t, test.success, result.Success, "%+v", test)
|
||||
if test.success {
|
||||
assert.Equal(t, test.ids, decodePasskeyDomainBegin(t, response).RPIDs)
|
||||
} else {
|
||||
assert.Equal(t, "PASSKEY_RP_ID_UNAVAILABLE", result.Code)
|
||||
}
|
||||
}
|
||||
settings.RPID, settings.LegacyRPIDs, settings.Origins, settings.AllowInsecureOrigin = "localhost", "", "http://localhost:3000,http://localhost:3001", true
|
||||
for _, origin := range []string{"http://localhost:3000", "http://localhost:3001"} {
|
||||
begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", nil, identity, origin, PasskeyLoginBegin))
|
||||
assert.Equal(t, "localhost", begin.Options.PublicKey.RPID)
|
||||
}
|
||||
}
|
||||
|
||||
func setupPasskeyDomainOptions(t *testing.T) {
|
||||
t.Helper()
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.Option{}))
|
||||
common.OptionMapRWMutex.RLock()
|
||||
previousOptions := maps.Clone(common.OptionMap)
|
||||
previousAddress := system_setting.ServerAddress
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
t.Cleanup(func() {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
common.OptionMap = previousOptions
|
||||
system_setting.ServerAddress = previousAddress
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasskeyDomainRemovalProtectsExistingAndUnknownCredentials(t *testing.T) {
|
||||
for _, binding := range []string{"www.example.com", ""} {
|
||||
t.Run("binding="+binding, func(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.LegacyRPIDs = "www.example.com"
|
||||
settings.Origins = "https://example.com,https://www.example.com"
|
||||
credential := &model.PasskeyCredential{UserID: user.Id, CredentialID: "existing", PublicKey: "key"}
|
||||
if binding != "" {
|
||||
credential.RPID = &binding
|
||||
}
|
||||
require.NoError(t, model.DB.Create(credential).Error)
|
||||
assert.Error(t, model.UpdateOption("passkey.legacy_rp_ids", ""))
|
||||
assert.Equal(t, "www.example.com", system_setting.PasskeySettingsSnapshot().LegacyRPIDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyDomainRemovalWithoutAffectedCredentialsNeedsNoOverride(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.LegacyRPIDs = "www.example.com,WWW.example.com"
|
||||
upper := "WWW.example.com"
|
||||
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, RPID: &upper, CredentialID: "uppercase", PublicKey: "key"}).Error)
|
||||
change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.legacy_rp_ids": upper}, false, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"www.example.com"}, change.RemovedRPIDs)
|
||||
assert.Zero(t, change.AffectedCredentials)
|
||||
assert.Zero(t, change.UnknownCredentials)
|
||||
assert.False(t, change.ConfirmationRequired)
|
||||
assert.Equal(t, upper, settings.LegacyRPIDs)
|
||||
}
|
||||
|
||||
func TestPasskeySnapshotDoesNotMaterializeDefaults(t *testing.T) {
|
||||
setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.RPID, settings.Origins = "", ""
|
||||
system_setting.ServerAddress = "https://www.example.com"
|
||||
assert.Equal(t, "www.example.com", system_setting.PasskeySettingsSnapshot().EffectiveRPID())
|
||||
assert.Empty(t, settings.RPID)
|
||||
assert.Empty(t, settings.Origins)
|
||||
}
|
||||
|
||||
func TestPasskeyDomainPreviewConfirmationAndAudit(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.LegacyRPIDs = "www.example.com,WWW.example.com"
|
||||
settings.Origins = "https://example.com,https://www.example.com"
|
||||
lower, upper, empty := "www.example.com", "WWW.example.com", ""
|
||||
for i, binding := range []*string{&lower, &upper, nil, &empty, &lower} {
|
||||
credential := &model.PasskeyCredential{UserID: user.Id + i, RPID: binding, CredentialID: fmt.Sprint(i), PublicKey: "key"}
|
||||
require.NoError(t, model.DB.Create(credential).Error)
|
||||
if i == 4 {
|
||||
require.NoError(t, model.DB.Delete(credential).Error)
|
||||
}
|
||||
}
|
||||
request := map[string]any{"rp_id": "example.com", "legacy_rp_ids": upper, "origins": settings.Origins, "preview": true}
|
||||
response := passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
var preview model.PasskeyDomainChange
|
||||
require.NoError(t, common.Unmarshal(result.Data, &preview))
|
||||
assert.Equal(t, []string{lower}, preview.RemovedRPIDs)
|
||||
assert.EqualValues(t, 1, preview.AffectedCredentials)
|
||||
assert.EqualValues(t, 2, preview.UnknownCredentials)
|
||||
assert.True(t, preview.ConfirmationRequired)
|
||||
require.NotEmpty(t, preview.RemovalConfirmation)
|
||||
options, err := model.AllOption()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, options, "preview must not materialize default rows")
|
||||
assert.Equal(t, "www.example.com,WWW.example.com", settings.LegacyRPIDs)
|
||||
assert.ErrorIs(t, model.UpdateOptionsBulk(map[string]string{"passkey.legacy_rp_ids": upper, "Notice": "must roll back"}), model.ErrPasskeyDomainRemovalConfirmation)
|
||||
blocked := passkeyDomainRequest(t, "/api/option/", map[string]string{"key": "passkey.legacy_rp_ids", "value": upper}, identity, "https://example.com", UpdateOption)
|
||||
assert.Equal(t, http.StatusConflict, blocked.Code)
|
||||
assert.Contains(t, blocked.Body.String(), "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED")
|
||||
request["preview"] = false
|
||||
request["removal_confirmation"] = preview.RemovalConfirmation
|
||||
// A new unknown credential makes the reviewed impact stale.
|
||||
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id + 10, CredentialID: "new", PublicKey: "key"}).Error)
|
||||
response = passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
|
||||
assert.Equal(t, http.StatusConflict, response.Code)
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.NoError(t, common.Unmarshal(result.Data, &preview))
|
||||
assert.EqualValues(t, 3, preview.UnknownCredentials)
|
||||
options, err = model.AllOption()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, options)
|
||||
request["removal_confirmation"] = preview.RemovalConfirmation
|
||||
response = passkeyDomainRequest(t, "/api/option/passkey/domains", request, identity, "https://example.com", UpdatePasskeyDomains)
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
assert.Equal(t, upper, settings.LegacyRPIDs)
|
||||
var audits []model.AuditLog
|
||||
require.NoError(t, model.LOG_DB.Find(&audits).Error)
|
||||
for _, audit := range audits {
|
||||
if audit.Action == "option.passkey_domains_blocked" {
|
||||
assert.False(t, audit.Success)
|
||||
assert.Equal(t, http.StatusConflict, audit.Status)
|
||||
}
|
||||
if audit.Action == "option.passkey_domains_confirmed" {
|
||||
assert.True(t, audit.Success)
|
||||
require.NotNil(t, audit.Other.Op)
|
||||
params, err := common.Marshal(audit.Other.Op.Params)
|
||||
require.NoError(t, err)
|
||||
var impact struct {
|
||||
Known, Unknown int64
|
||||
Confirmed bool
|
||||
RemovedRPIDs []string `json:"removed_rp_ids"`
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(params, &impact))
|
||||
assert.EqualValues(t, 1, impact.Known)
|
||||
assert.EqualValues(t, 3, impact.Unknown)
|
||||
assert.True(t, impact.Confirmed)
|
||||
assert.Equal(t, []string{lower}, impact.RemovedRPIDs)
|
||||
}
|
||||
}
|
||||
encoded, err := common.Marshal(audits)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(encoded), "option.passkey_domains_confirmed")
|
||||
assert.Contains(t, string(encoded), "option.passkey_domains_blocked")
|
||||
assert.NotContains(t, string(encoded), preview.RemovalConfirmation)
|
||||
// Simulate an old node's cache after removal: persistence must use DB trust.
|
||||
settings.LegacyRPIDs = lower + "," + upper
|
||||
assert.ErrorIs(t, model.UpdatePasskeyAssertionState(user.Id, &webauthn.Credential{ID: []byte("0")}, time.Now(), lower), system_setting.ErrPasskeyRPIDUnavailable)
|
||||
assert.ErrorIs(t, model.RegisterPasskeyForSession(identity, &model.PasskeyCredential{UserID: user.Id, RPID: &lower, CredentialID: "replacement", PublicKey: "key"}), system_setting.ErrPasskeyRPIDUnavailable)
|
||||
}
|
||||
|
||||
func TestPasskeyDomainSettingsAcceptExactHTTPSInternalHosts(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
rpID, origins string
|
||||
valid bool
|
||||
}{
|
||||
{"intranet", "https://intranet:8443", true},
|
||||
{"intranet", "https://child.intranet", false},
|
||||
{"intranet", "http://intranet", false},
|
||||
{"com", "https://com", false},
|
||||
{"127.0.0.1", "https://127.0.0.1", false},
|
||||
{"intranet:8443", "https://intranet:8443", false},
|
||||
{"localhost", "http://localhost:3000", true},
|
||||
} {
|
||||
t.Run(test.rpID+"/"+test.origins, func(t *testing.T) {
|
||||
setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.rp_id": test.rpID, "passkey.legacy_rp_ids": "", "passkey.origins": test.origins}, false, "")
|
||||
if !test.valid {
|
||||
assert.ErrorIs(t, err, system_setting.ErrPasskeyRPIDInvalid)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.rpID, change.RPID)
|
||||
assert.Equal(t, "example.com", change.LegacyRPIDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyImplicitDomainChangesRetainPreviousTrust(t *testing.T) {
|
||||
setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.RPID, settings.Origins = "", ""
|
||||
system_setting.ServerAddress = "https://www.example.com"
|
||||
require.NoError(t, model.UpdateOption("ServerAddress", "https://example.com"))
|
||||
assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
|
||||
assert.Equal(t, "https://www.example.com,https://example.com", settings.Origins)
|
||||
// Moving the retained RP into the primary is not a removal.
|
||||
change, err := model.UpdatePasskeyDomainOptions(map[string]string{"passkey.rp_id": "www.example.com", "passkey.legacy_rp_ids": ""}, false, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, change.RemovedRPIDs)
|
||||
assert.Equal(t, "example.com", change.LegacyRPIDs)
|
||||
}
|
||||
|
||||
func TestPasskeyRegistrationAndDomainRemovalSerialize(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
|
||||
require.NoError(t, model.UpdateOption("passkey.legacy_rp_ids", "www.example.com"))
|
||||
registering, release := make(chan struct{}), make(chan struct{})
|
||||
require.NoError(t, model.DB.Callback().Create().Before("gorm:create").Register("passkey_registration_barrier", func(tx *gorm.DB) {
|
||||
if _, ok := tx.Statement.Dest.(*model.PasskeyCredential); ok {
|
||||
close(registering)
|
||||
<-release
|
||||
}
|
||||
}))
|
||||
t.Cleanup(func() { _ = model.DB.Callback().Create().Remove("passkey_registration_barrier") })
|
||||
rpID := "www.example.com"
|
||||
registered, removed := make(chan error, 1), make(chan error, 1)
|
||||
go func() {
|
||||
registered <- model.RegisterPasskeyForSession(identity, &model.PasskeyCredential{UserID: user.Id, RPID: &rpID, CredentialID: "concurrent", PublicKey: "key"})
|
||||
}()
|
||||
<-registering
|
||||
removing := make(chan struct{})
|
||||
go func() {
|
||||
close(removing)
|
||||
removed <- model.UpdateOption("passkey.legacy_rp_ids", "")
|
||||
}()
|
||||
<-removing
|
||||
// A concurrent snapshot must remain read-only while the write is pending.
|
||||
assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
|
||||
close(release)
|
||||
require.NoError(t, <-registered)
|
||||
assert.ErrorIs(t, <-removed, model.ErrPasskeyDomainRemovalConfirmation)
|
||||
stored, err := model.GetPasskeyByUserID(user.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stored.RPID)
|
||||
assert.Equal(t, rpID, *stored.RPID)
|
||||
}
|
||||
|
||||
func TestPasskeyDomainEndpointRequiresRoot(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
bundle, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "domain-settings-test")
|
||||
require.NoError(t, err)
|
||||
router := gin.New()
|
||||
router.PUT("/api/option/passkey/domains", middleware.RootAuth(), UpdatePasskeyDomains)
|
||||
for _, bearer := range []string{"", bundle.AccessToken} {
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/option/passkey/domains", strings.NewReader(`{"rp_id":"example.com","legacy_rp_ids":"","origins":"https://example.com","preview":true}`))
|
||||
if bearer != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
assert.Contains(t, []int{http.StatusUnauthorized, http.StatusForbidden}, response.Code)
|
||||
}
|
||||
options, err := model.AllOption()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, options)
|
||||
}
|
||||
|
||||
func TestPasskeyDefaultsDoNotRestrictUnrelatedServerAddressSetup(t *testing.T) {
|
||||
setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.Enabled, settings.RPID, settings.LegacyRPIDs, settings.Origins = false, "", "", ""
|
||||
system_setting.ServerAddress = ""
|
||||
require.NoError(t, model.UpdateOption("ServerAddress", "http://127.0.0.1:3000"))
|
||||
assert.Equal(t, "http://127.0.0.1:3000", system_setting.ServerAddress)
|
||||
assert.Empty(t, settings.RPID)
|
||||
assert.Empty(t, settings.LegacyRPIDs)
|
||||
}
|
||||
|
||||
func TestPasskeyRegistrationRejectsUnavailableRequestOrigin(t *testing.T) {
|
||||
_, identity := setupSecurityEnrollmentTest(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.RPID = "www.example.com"
|
||||
settings.LegacyRPIDs = "example.com"
|
||||
settings.Origins = "https://example.com,https://www.example.com"
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
|
||||
response := securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/register/begin", "", proof, identity, func(c *gin.Context) {
|
||||
c.Request.Header.Set("Origin", "https://example.com")
|
||||
PasskeyRegisterBegin(c)
|
||||
})
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.False(t, result.Success)
|
||||
assert.Equal(t, "PASSKEY_RP_ID_UNAVAILABLE", result.Code)
|
||||
var count int64
|
||||
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposePasskeyRegister).Count(&count).Error)
|
||||
assert.Zero(t, count)
|
||||
}
|
||||
|
||||
func TestPasskeyRPIDRotationRetainsDefaultsAndRollsBackOnFailure(t *testing.T) {
|
||||
setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
settings := system_setting.GetPasskeySettings()
|
||||
settings.RPID, settings.LegacyRPIDs = "", ""
|
||||
settings.Origins = "https://www.example.com,https://example.com,https://login.example.com"
|
||||
system_setting.ServerAddress = "https://www.example.com"
|
||||
require.NoError(t, model.DB.Create(&model.Option{Key: "passkey.rp_id", Value: ""}).Error)
|
||||
require.NoError(t, model.UpdateOption("passkey.rp_id", "example.com"))
|
||||
assert.Equal(t, []string{"example.com", "www.example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
|
||||
// Simulate a second node with an older local cache. Persisted domains win.
|
||||
system_setting.GetPasskeySettings().RPID = "www.example.com"
|
||||
require.NoError(t, model.UpdateOption("passkey.rp_id", "login.example.com"))
|
||||
assert.Equal(t, []string{"login.example.com", "www.example.com", "example.com"}, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs())
|
||||
before, err := model.AllOption()
|
||||
require.NoError(t, err)
|
||||
snapshot := system_setting.PasskeySettingsSnapshot()
|
||||
failure := errors.New("synthetic option write failure")
|
||||
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("passkey_option_failure", func(tx *gorm.DB) {
|
||||
if option, ok := tx.Statement.Dest.(*model.Option); ok && option.Key == "passkey.legacy_rp_ids" {
|
||||
tx.AddError(failure)
|
||||
}
|
||||
}))
|
||||
assert.ErrorIs(t, model.UpdateOptionsBulk(map[string]string{"passkey.rp_id": "next.example.com", "passkey.legacy_rp_ids": "www.example.com"}), failure)
|
||||
require.NoError(t, model.DB.Callback().Update().Remove("passkey_option_failure"))
|
||||
after, err := model.AllOption()
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, before, after)
|
||||
assert.Equal(t, snapshot, system_setting.PasskeySettingsSnapshot())
|
||||
for _, invalid := range []string{"*.example.com", "https://example.com", "example.com:443", "example.com/path", "com"} {
|
||||
assert.ErrorIs(t, model.UpdateOption("passkey.rp_id", invalid), system_setting.ErrPasskeyRPIDInvalid)
|
||||
assert.ErrorIs(t, model.UpdateOption("passkey.legacy_rp_ids", invalid), system_setting.ErrPasskeyRPIDInvalid)
|
||||
}
|
||||
assert.Equal(t, snapshot, system_setting.PasskeySettingsSnapshot())
|
||||
// Older settings accepted mixed case. Rotation must preserve its hash input.
|
||||
require.NoError(t, model.DB.Model(&model.Option{}).Where(&model.Option{Key: "passkey.rp_id"}).Update("value", "WWW.example.com").Error)
|
||||
require.NoError(t, model.UpdateOption("passkey.rp_id", "next.example.com"))
|
||||
assert.Contains(t, system_setting.PasskeySettingsSnapshot().RelyingPartyIDs(), "WWW.example.com")
|
||||
}
|
||||
|
||||
func TestPasskeyRPIDRotationKeepsInFlightRegistration(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
|
||||
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: "passkey.register"}, "password")
|
||||
begin := decodePasskeyDomainBegin(t, securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/register/begin", "", proof, identity, PasskeyRegisterBegin))
|
||||
assert.Equal(t, "example.com", begin.Options.PublicKey.RP.ID)
|
||||
require.NoError(t, model.UpdateOption("passkey.rp_id", "www.example.com"))
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
response := passkeyDomainRequest(t, "/api/user/passkey/register/finish", passkeyFinishRequest{FlowToken: begin.FlowToken, Credential: securityPasskeyResponse(t, key, begin.Options.PublicKey.Challenge, true, 0)}, identity, "https://example.com", PasskeyRegisterFinish)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
require.True(t, result.Success, response.Body.String())
|
||||
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, credential.RPID)
|
||||
assert.Equal(t, "example.com", *credential.RPID)
|
||||
// A later registration uses the current primary, not the retained domain.
|
||||
wa, err := passkeysvc.BuildWebAuthn(httptest.NewRequest(http.MethodPost, "https://www.example.com/api/user/passkey/register/begin", nil))
|
||||
require.NoError(t, err)
|
||||
options, _, err := wa.BeginRegistration(passkeysvc.NewWebAuthnUser(user, credential))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "www.example.com", options.Response.RelyingParty.ID)
|
||||
}
|
||||
|
||||
func TestPasskeyDomainFailuresDoNotLogCredentials(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var output bytes.Buffer
|
||||
common.LogWriterMu.Lock()
|
||||
previousWriter := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &output
|
||||
common.LogWriterMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
common.LogWriterMu.Lock()
|
||||
gin.DefaultErrorWriter = previousWriter
|
||||
common.LogWriterMu.Unlock()
|
||||
})
|
||||
response := securityEnrollmentRequest(http.MethodPost, "/api/user/passkey/login/finish", "", "", service.AuthIdentity{}, func(c *gin.Context) {
|
||||
c.Set("passkey_rp_id", "www.example.com")
|
||||
writeSecurityOperationError(c, protocol.ErrVerification.WithDetails("private-flow-token").WithInfo("private-challenge"))
|
||||
})
|
||||
assert.Contains(t, response.Body.String(), "SECURITY_VERIFICATION_FAILED")
|
||||
assert.Contains(t, output.String(), "verification_error")
|
||||
assert.Contains(t, output.String(), "www.example.com")
|
||||
for _, secret := range []string{"private-flow-token", "private-challenge"} {
|
||||
assert.NotContains(t, response.Body.String(), secret)
|
||||
assert.NotContains(t, output.String(), secret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyDomainErrorsRespectRequestLanguage(t *testing.T) {
|
||||
user, _ := setupSecurityEnrollmentTest(t)
|
||||
setupPasskeyDomainOptions(t)
|
||||
system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com"
|
||||
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "unknown-domain", PublicKey: "key"}).Error)
|
||||
for _, locale := range []struct {
|
||||
language, invalid, unavailable, removal string
|
||||
}{
|
||||
{"zh-CN", "通行密钥域名无效。请填写域名,不包含协议、端口、路径或通配符。", "此通行密钥域名无法在当前网站使用。请前往原网站或选择其他验证方式。", "请核对受影响的通行密钥并确认删除域名。配置或影响范围发生变化后,需要重新确认。"},
|
||||
{"zh-TW", "通行金鑰網域無效。請填寫網域,不包含通訊協定、連接埠、路徑或萬用字元。", "此通行金鑰網域無法在目前網站使用。請前往原網站或選擇其他驗證方式。", "請核對受影響的通行金鑰並確認刪除網域。設定或影響範圍變更後,需要重新確認。"},
|
||||
{"en", "Invalid Passkey domain. Enter a domain without a scheme, port, path or wildcard.", "This Passkey domain is not available on this website. Use its original website or another verification method.", "Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."},
|
||||
} {
|
||||
t.Run(locale.language, func(t *testing.T) {
|
||||
for _, request := range []struct {
|
||||
path, body, code, message string
|
||||
handler gin.HandlerFunc
|
||||
}{
|
||||
{"/api/option/", `{"key":"passkey.rp_id","value":"localhost:3000"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption},
|
||||
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":"localhost:3001"}`, "PASSKEY_RP_ID_INVALID", locale.invalid, UpdateOption},
|
||||
{"/api/option/", `{"key":"passkey.legacy_rp_ids","value":""}`, "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED", locale.removal, UpdateOption},
|
||||
{"/api/user/passkey/login/begin", `{"rp_id":"unconfigured.example.com"}`, "PASSKEY_RP_ID_UNAVAILABLE", locale.unavailable, PasskeyLoginBegin},
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(response)
|
||||
method := http.MethodPost
|
||||
if request.path == "/api/option/" {
|
||||
method = http.MethodPut
|
||||
}
|
||||
c.Request = httptest.NewRequest(method, request.path, strings.NewReader(request.body))
|
||||
c.Request.Header.Set("Accept-Language", locale.language)
|
||||
c.Request.Header.Set("Origin", "https://example.com")
|
||||
c.Set("id", user.Id)
|
||||
c.Set("role", common.RoleRootUser)
|
||||
request.handler(c)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
expectedStatus := http.StatusOK
|
||||
if request.code == "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED" {
|
||||
expectedStatus = http.StatusConflict
|
||||
}
|
||||
assert.Equal(t, expectedStatus, response.Code)
|
||||
assert.False(t, result.Success)
|
||||
assert.Equal(t, request.code, result.Code)
|
||||
assert.Equal(t, request.message, result.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyRPIDMigrationPreservesExistingCredentials(t *testing.T) {
|
||||
for _, upgrade := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("upgrade=%t", upgrade), func(t *testing.T) {
|
||||
user, identity := setupSecurityEnrollmentTest(t)
|
||||
key := newSecurityLoginPasskey(t, user.Id)
|
||||
before, err := model.GetPasskeyByUserID(user.Id)
|
||||
require.NoError(t, err)
|
||||
if upgrade {
|
||||
// This schema is copied from the latest released model at
|
||||
// v1.0.0-rc.36, with the same GORM and database driver versions.
|
||||
require.NoError(t, model.DB.Migrator().DropTable(&model.PasskeyCredential{}))
|
||||
require.NoError(t, model.DB.AutoMigrate(&passkeyCredentialBeforeRPID{}))
|
||||
encoded, err := common.Marshal(before)
|
||||
require.NoError(t, err)
|
||||
var legacy passkeyCredentialBeforeRPID
|
||||
require.NoError(t, common.Unmarshal(encoded, &legacy))
|
||||
require.NoError(t, model.DB.Create(&legacy).Error)
|
||||
}
|
||||
pool, err := model.DB.DB()
|
||||
require.NoError(t, err)
|
||||
for range 2 {
|
||||
// Each migration represents a new application startup. Discard
|
||||
// PostgreSQL's old SELECT * prepared plans along with its old pool.
|
||||
pool.SetMaxIdleConns(0)
|
||||
pool.SetMaxIdleConns(2)
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.PasskeyCredential{}))
|
||||
}
|
||||
after, err := model.GetPasskeyByUserID(user.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before, after)
|
||||
assert.True(t, model.DB.Migrator().HasIndex(&model.PasskeyCredential{}, "idx_passkey_credentials_deleted_at"))
|
||||
assert.Error(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "another-key", PublicKey: "key"}).Error)
|
||||
assert.Error(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id + 1, CredentialID: before.CredentialID, PublicKey: "key"}).Error)
|
||||
system_setting.GetPasskeySettings().LegacyRPIDs = "www.example.com"
|
||||
system_setting.GetPasskeySettings().Origins = "https://example.com,https://www.example.com"
|
||||
begin := decodePasskeyDomainBegin(t, passkeyDomainRequest(t, "/api/user/passkey/login/begin", map[string]string{"rp_id": "www.example.com"}, identity, "https://www.example.com", PasskeyLoginBegin))
|
||||
response := passkeyDomainRequest(t, "/api/user/passkey/login/finish", map[string]any{"flow_token": begin.FlowToken, "credential": passkeyDomainAssertion(t, key, begin.Options.PublicKey.Challenge, "www.example.com", "https://www.example.com", user.Id, true)}, identity, "https://www.example.com", PasskeyLoginFinish)
|
||||
var result securityEnrollmentResponse
|
||||
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
|
||||
assert.True(t, result.Success, response.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (*passkeyTestBody) Close() error { return nil }
|
||||
|
||||
func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) {
|
||||
@@ -70,3 +763,26 @@ func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, flow.ConsumedAt)
|
||||
}
|
||||
|
||||
type passkeyCredentialBeforeRPID struct {
|
||||
ID int `json:"id" gorm:"primaryKey"`
|
||||
UserID int `json:"user_id" gorm:"uniqueIndex;not null"`
|
||||
CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded
|
||||
PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded
|
||||
AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"`
|
||||
AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded
|
||||
SignCount uint32 `json:"sign_count" gorm:"default:0"`
|
||||
CloneWarning bool `json:"clone_warning"`
|
||||
UserPresent bool `json:"user_present"`
|
||||
UserVerified bool `json:"user_verified"`
|
||||
BackupEligible bool `json:"backup_eligible"`
|
||||
BackupState bool `json:"backup_state"`
|
||||
Transports string `json:"transports" gorm:"type:text"`
|
||||
Attachment string `json:"attachment" gorm:"type:varchar(32)"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
func (passkeyCredentialBeforeRPID) TableName() string { return "passkey_credentials" }
|
||||
|
||||
@@ -3,12 +3,18 @@ package controller
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/middleware"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/oauth"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
)
|
||||
@@ -34,6 +40,10 @@ func writeSecurityOperationError(c *gin.Context, err error) {
|
||||
var code, message string
|
||||
var protocolError *protocol.Error
|
||||
switch {
|
||||
case errors.Is(err, passkeysvc.ErrRPIDUnavailable):
|
||||
code, message = "PASSKEY_RP_ID_UNAVAILABLE", i18n.T(c, i18n.MsgPasskeyRPIDUnavailable)
|
||||
case errors.Is(err, system_setting.ErrPasskeyRPIDInvalid):
|
||||
code, message = "PASSKEY_RP_ID_INVALID", i18n.T(c, i18n.MsgPasskeyRPIDInvalid)
|
||||
case errors.Is(err, service.ErrAccountEmailInvalid), errors.Is(err, service.ErrAccountEmailRestricted):
|
||||
code, message = "EMAIL_ADDRESS_REJECTED", err.Error()
|
||||
case errors.Is(err, model.ErrEmailAlreadyTaken):
|
||||
@@ -112,6 +122,15 @@ func writeSecurityOperationError(c *gin.Context, err error) {
|
||||
return
|
||||
}
|
||||
c.Set("security_error_code", code)
|
||||
if strings.Contains(c.Request.URL.Path, "/passkey/") {
|
||||
reason := "verification_failed"
|
||||
if errors.As(err, &protocolError) && slices.Contains([]string{"invalid_request", "challenge_mismatch", "parse_error", "auth_data", "verification_error", "invalid_signature", "invalid_key_type", "unsupported_key_algorithm"}, protocolError.Type) {
|
||||
reason = protocolError.Type
|
||||
}
|
||||
// Protocol details can contain challenges and client-controlled data.
|
||||
// Only fixed categories and the server-selected public RP ID are logged.
|
||||
logger.LogWarn(c.Request.Context(), "passkey verification rejected: code=%s reason=%s rp_id=%q", code, reason, c.GetString("passkey_rp_id"))
|
||||
}
|
||||
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ func setupSecurityEnrollmentTest(t *testing.T) (*model.User, service.AuthIdentit
|
||||
var version string
|
||||
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
|
||||
t.Logf("database: %s %s", dialect, version)
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}))
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.TwoFABackupCode{}, &model.PasskeyCredential{}, &model.AuthFlow{}, &model.UserOAuthBinding{}, &model.Option{}))
|
||||
require.NoError(t, logDB.AutoMigrate(&model.AuditLog{}))
|
||||
model.DB, model.LOG_DB = db, logDB
|
||||
dbType := common.DatabaseTypeSQLite
|
||||
|
||||
Reference in New Issue
Block a user