Commit 45c3fbe8 by CaIon

fix(security): bind verification proofs to sessions and actions

Require single-use operation proofs for passkey enrollment, two-factor setup, and channel key access. Add password and OAuth verification flows, enforce session-bound enrollment, and redact OAuth callback secrets from logs.

Validation: affected Go packages pass; frontend typecheck, changed-file lint, and 111 tests pass. Security enrollment regressions pass on SQLite 3.50.4, MySQL 8.0.46, and PostgreSQL 16.15. Full frontend lint has pre-existing errors outside the changed files.
parent 9a867442
...@@ -139,6 +139,9 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag ...@@ -139,6 +139,9 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
### Frontend Rules ### Frontend Rules
- **Reuse existing UI components first (mandatory):** Before implementing or changing frontend UI, read `web/AGENTS.md` and the project `shadcn-ui` skill, search `web/src/components/` and the relevant feature for existing components, and read matching implementations and call sites. Do not start from custom markup or registry installation without checking the repository first.
- Prefer the project's shared business components over lower-level UI primitives when they cover the use case. Evaluate existing props, composition, and a compatible extension before introducing a replacement. Importing `Button` or `AlertDialog` does not satisfy this rule if the same behavior is already provided by a shared component such as `CopyButton` or `ConfirmDialog`.
- New implementations of common UI behavior require a concrete capability gap: identify the existing candidates and explain why reuse, composition, or a compatible extension is unsuitable in the change summary or PR description. Different text, dimensions, colors, or feature location alone do not justify duplication. Feature components may compose shared components with business data and actions. Follow the reuse workflow and component entry points in `web/AGENTS.md`; generic library or registry guidance does not override this project-specific priority.
- Use `bun` as the preferred package manager and script runner for the frontend (`web/`): - Use `bun` as the preferred package manager and script runner for the frontend (`web/`):
- `bun install` for dependency installation - `bun install` for dependency installation
- `bun run dev` for development server - `bun run dev` for development server
......
...@@ -4,6 +4,8 @@ import ( ...@@ -4,6 +4,8 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io" "io"
"github.com/gin-gonic/gin/binding"
) )
func Unmarshal(data []byte, v any) error { func Unmarshal(data []byte, v any) error {
...@@ -18,6 +20,18 @@ func DecodeJson(reader io.Reader, v any) error { ...@@ -18,6 +20,18 @@ func DecodeJson(reader io.Reader, v any) error {
return json.NewDecoder(reader).Decode(v) return json.NewDecoder(reader).Decode(v)
} }
// DecodeJsonWithValidation decodes JSON and applies Gin's configured binding-tag
// validator, including binding:"required" and any registered custom validators.
func DecodeJsonWithValidation(reader io.Reader, v any) error {
if err := DecodeJson(reader, v); err != nil {
return err
}
if binding.Validator == nil {
return nil
}
return binding.Validator.ValidateStruct(v)
}
func Marshal(v any) ([]byte, error) { func Marshal(v any) ([]byte, error) {
return json.Marshal(v) return json.Marshal(v)
} }
......
...@@ -2,8 +2,11 @@ package common ...@@ -2,8 +2,11 @@ package common
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"github.com/go-playground/validator/v10"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -41,3 +44,42 @@ func TestJsonRawMessageToString(t *testing.T) { ...@@ -41,3 +44,42 @@ func TestJsonRawMessageToString(t *testing.T) {
}) })
} }
} }
func TestDecodeJsonWithValidation(t *testing.T) {
type request struct {
Code string `json:"code" binding:"required"`
}
for _, test := range []struct {
name, body string
validationError bool
decodeError bool
}{
{name: "valid", body: `{"code":"123456"}`},
{name: "missing required field", body: `{}`, validationError: true},
{name: "empty required field", body: `{"code":""}`, validationError: true},
{name: "malformed JSON", body: `{"code":`, decodeError: true},
{name: "wrong field type", body: `{"code":123456}`, decodeError: true},
} {
t.Run(test.name, func(t *testing.T) {
var value request
err := DecodeJsonWithValidation(strings.NewReader(test.body), &value)
if test.validationError {
var validationErrors validator.ValidationErrors
require.ErrorAs(t, err, &validationErrors)
require.Len(t, validationErrors, 1)
assert.Equal(t, "Code", validationErrors[0].Field())
assert.Equal(t, "required", validationErrors[0].Tag())
return
}
if test.decodeError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, "123456", value.Code)
})
}
// Existing decoding callers opt into validation explicitly.
var unvalidated request
require.NoError(t, DecodeJson(strings.NewReader(`{}`), &unvalidated))
}
...@@ -422,19 +422,19 @@ func GetChannel(c *gin.Context) { ...@@ -422,19 +422,19 @@ func GetChannel(c *gin.Context) {
func GetChannelKey(c *gin.Context) { func GetChannelKey(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id")) channelId, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) common.ApiErrorMsg(c, "渠道ID格式错误")
return return
} }
// 获取渠道信息(包含密钥) // 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true) channel, err := model.GetChannelById(channelId, true)
if err != nil { if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) writeSecurityOperationError(c, err)
return return
} }
if channel == nil { if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在")) common.ApiErrorMsg(c, "渠道不存在")
return return
} }
...@@ -454,23 +454,6 @@ func GetChannelKey(c *gin.Context) { ...@@ -454,23 +454,6 @@ func GetChannelKey(c *gin.Context) {
}) })
} }
// validateTwoFactorAuth 统一的2FA验证函数
func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool {
// 尝试验证TOTP
if cleanCode, err := common.ValidateNumericCode(code); err == nil {
if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid {
return true
}
}
// 尝试验证备用码
if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid {
return true
}
return false
}
// validateChannel 通用的渠道校验函数 // validateChannel 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error { func validateChannel(channel *model.Channel, isAdd bool) error {
if channel == nil { if channel == nil {
......
package controller package controller
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
...@@ -13,6 +14,7 @@ import ( ...@@ -13,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth" "github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
) )
...@@ -20,13 +22,16 @@ import ( ...@@ -20,13 +22,16 @@ import (
const oauthAuthFlowTTL = 10 * time.Minute const oauthAuthFlowTTL = 10 * time.Minute
type oauthStateRequest struct { type oauthStateRequest struct {
Provider string `json:"provider"` Provider string `json:"provider"`
Intent string `json:"intent"` Intent string `json:"intent"`
Aff string `json:"aff,omitempty"` Aff string `json:"aff,omitempty"`
Scope string `json:"scope,omitempty"`
Context json.RawMessage `json:"context,omitempty"`
} }
type oauthFlowPayload struct { type oauthFlowPayload struct {
AffiliateCode string `json:"affiliate_code,omitempty"` AffiliateCode string `json:"affiliate_code,omitempty"`
Verification *service.OAuthVerificationFlow `json:"verification,omitempty"`
} }
// providerParams returns map with Provider key for i18n templates // providerParams returns map with Provider key for i18n templates
...@@ -45,15 +50,17 @@ func GenerateOAuthCode(c *gin.Context) { ...@@ -45,15 +50,17 @@ func GenerateOAuthCode(c *gin.Context) {
request.Intent = strings.TrimSpace(request.Intent) request.Intent = strings.TrimSpace(request.Intent)
request.Aff = strings.TrimSpace(request.Aff) request.Aff = strings.TrimSpace(request.Aff)
if oauth.GetProvider(request.Provider) == nil || if oauth.GetProvider(request.Provider) == nil ||
(request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind) || (request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind && request.Intent != model.AuthFlowIntentVerify) ||
len(request.Aff) > 32 || len(request.Aff) > 32 ||
(request.Intent == model.AuthFlowIntentBind && request.Aff != "") { (request.Intent != model.AuthFlowIntentLogin && request.Aff != "") ||
(request.Intent != model.AuthFlowIntentVerify && (request.Scope != "" || len(request.Context) != 0)) {
common.ApiErrorI18n(c, i18n.MsgInvalidParams) common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return return
} }
userID := 0 userID := 0
sessionID := "" sessionID := ""
if request.Intent == model.AuthFlowIntentBind { flowPayload := oauthFlowPayload{AffiliateCode: request.Aff}
if request.Intent == model.AuthFlowIntentBind || request.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c) identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok { if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"}) c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"})
...@@ -61,10 +68,18 @@ func GenerateOAuthCode(c *gin.Context) { ...@@ -61,10 +68,18 @@ func GenerateOAuthCode(c *gin.Context) {
} }
userID = identity.UserID userID = identity.UserID
sessionID = identity.SessionID sessionID = identity.SessionID
if request.Intent == model.AuthFlowIntentVerify {
verification, err := service.StartOAuthVerification(identity, service.VerificationOperation{Scope: request.Scope, Context: request.Context}, request.Provider)
if err != nil {
writeSecurityOperationError(c, err)
return
}
flowPayload.Verification = verification
}
} }
payload, err := common.Marshal(oauthFlowPayload{AffiliateCode: request.Aff}) payload, err := common.Marshal(flowPayload)
if err != nil { if err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
expiresAt := time.Now().Add(oauthAuthFlowTTL) expiresAt := time.Now().Add(oauthAuthFlowTTL)
...@@ -78,7 +93,7 @@ func GenerateOAuthCode(c *gin.Context) { ...@@ -78,7 +93,7 @@ func GenerateOAuthCode(c *gin.Context) {
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
}) })
if err != nil { if err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -122,8 +137,8 @@ func HandleOAuth(c *gin.Context) { ...@@ -122,8 +137,8 @@ func HandleOAuth(c *gin.Context) {
Provider: providerName, Provider: providerName,
Intent: pendingFlow.Intent, Intent: pendingFlow.Intent,
} }
// 2. Bind flows are bound to the live dashboard Session that created them. // Bind and verification callbacks must use the dashboard session that started them.
if pendingFlow.Intent == model.AuthFlowIntentBind { if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c) identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId { if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
c.JSON(http.StatusForbidden, gin.H{ c.JSON(http.StatusForbidden, gin.H{
...@@ -162,11 +177,6 @@ func HandleOAuth(c *gin.Context) { ...@@ -162,11 +177,6 @@ func HandleOAuth(c *gin.Context) {
}) })
return return
} }
if pendingFlow.Intent == model.AuthFlowIntentBind {
handleOAuthBind(c, provider, pendingFlow, state)
return
}
// 5. Exchange code for token // 5. Exchange code for token
code := c.Query("code") code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c) token, err := provider.ExchangeToken(c.Request.Context(), code, c)
...@@ -187,10 +197,37 @@ func HandleOAuth(c *gin.Context) { ...@@ -187,10 +197,37 @@ func HandleOAuth(c *gin.Context) {
return return
} }
switch flow.Intent {
case model.AuthFlowIntentLogin:
handleOAuthLogin(c, provider, oauthUser, flow)
case model.AuthFlowIntentBind:
handleOAuthBind(c, provider, oauthUser, flow)
case model.AuthFlowIntentVerify:
handleOAuthVerification(c, providerName, oauthUser, flow)
}
}
func handleOAuthVerification(c *gin.Context, provider string, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
writeSecurityOperationError(c, err)
return
}
identity, _ := middleware.GetSessionAuthIdentity(c)
proof, err := service.FinishOAuthVerification(identity, provider, oauthUser.ProviderUserID, payload.Verification)
if err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]interface{}{"method": proof.Method, "scope": proof.Scope, "provider": provider})
common.ApiSuccess(c, proof)
}
func handleOAuthLogin(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
// 7. Find or create user // 7. Find or create user
var payload oauthFlowPayload var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil { if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode) user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode)
...@@ -207,7 +244,7 @@ func HandleOAuth(c *gin.Context) { ...@@ -207,7 +244,7 @@ func HandleOAuth(c *gin.Context) {
case *OAuthEmailAlreadyTakenError: case *OAuthEmailAlreadyTakenError:
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken) common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
default: default:
common.ApiError(c, err) writeSecurityOperationError(c, err)
} }
return return
} }
...@@ -223,22 +260,7 @@ func HandleOAuth(c *gin.Context) { ...@@ -223,22 +260,7 @@ func HandleOAuth(c *gin.Context) {
} }
// handleOAuthBind handles binding OAuth account to existing user // handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model.AuthFlow, flowToken string) { func handleOAuthBind(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
// Exchange code for token
code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
if err != nil {
handleOAuthError(c, err)
return
}
// Get user info
oauthUser, err := provider.GetUserInfo(c.Request.Context(), token)
if err != nil {
handleOAuthError(c, err)
return
}
// Check if this OAuth account is already bound (check both new ID and legacy ID) // Check if this OAuth account is already bound (check both new ID and legacy ID)
if provider.IsUserIDTaken(oauthUser.ProviderUserID) { if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName())) common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
...@@ -252,25 +274,15 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model ...@@ -252,25 +274,15 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model
} }
} }
if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{ userId := flow.UserId
Purpose: model.AuthFlowPurposeOAuth, var err error
Provider: pendingFlow.Provider,
Intent: model.AuthFlowIntentBind,
UserId: pendingFlow.UserId,
SessionId: pendingFlow.SessionId,
}); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
userId := pendingFlow.UserId
// Handle binding based on provider type // Handle binding based on provider type
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok { if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
// Custom provider: use user_oauth_bindings table // Custom provider: use user_oauth_bindings table
err = model.UpdateUserOAuthBinding(userId, genericProvider.GetProviderId(), oauthUser.ProviderUserID) err = model.UpdateUserOAuthBinding(userId, genericProvider.GetProviderId(), oauthUser.ProviderUserID)
if err != nil { if err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
} else { } else {
...@@ -278,7 +290,7 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model ...@@ -278,7 +290,7 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model
// role/status/group 一并写回,覆盖并发发生的封禁、降权或分组变更。 // role/status/group 一并写回,覆盖并发发生的封禁、降权或分组变更。
err = model.UpdateUserBindColumn(userId, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID) err = model.UpdateUserBindColumn(userId, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
if err != nil { if err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
} }
...@@ -461,6 +473,6 @@ func handleOAuthError(c *gin.Context, err error) { ...@@ -461,6 +473,6 @@ func handleOAuthError(c *gin.Context, err error) {
case *oauth.TrustLevelError: case *oauth.TrustLevelError:
common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow) common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow)
default: default:
common.ApiError(c, err) writeSecurityOperationError(c, err)
} }
} }
package controller package controller
import ( import (
"fmt" "crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
...@@ -13,10 +15,8 @@ import ( ...@@ -13,10 +15,8 @@ import (
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting" "github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm"
) )
type passkeyTestBody struct { type passkeyTestBody struct {
...@@ -43,88 +43,30 @@ func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) { ...@@ -43,88 +43,30 @@ func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) {
assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength) assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength)
} }
func TestPasskeyRegisterFinishRejectsMissingOrWrongProofWithoutConsumingFlow(t *testing.T) { func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing.T) {
previousDB := model.DB _, identity := setupSecurityEnrollmentTest(t)
previousType := common.MainDatabaseType() system_setting.GetPasskeySettings().UserVerification = "required"
previousRedis := common.RedisEnabled key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
previousSecret := common.SessionSecret
settings := system_setting.GetPasskeySettings()
previousSettings := *settings
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TwoFA{}, &model.AuthFlow{})) payload, err := common.Marshal(map[string]any{"scope": service.VerificationScopePasskeyRegister})
model.DB = db require.NoError(t, err)
common.SetMainDatabaseType(common.DatabaseTypeSQLite) token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
common.RedisEnabled = false Purpose: model.AuthFlowPurposePasskeyRegister, UserId: identity.UserID, SessionId: identity.SessionID,
common.SessionSecret = "passkey-register-proof-test-secret" Payload: string(payload), ExpiresAt: time.Now().Add(time.Minute),
*settings = system_setting.PasskeySettings{Enabled: true}
t.Cleanup(func() {
model.DB = previousDB
common.SetMainDatabaseType(previousType)
common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret
*settings = previousSettings
sqlDB, dbErr := db.DB()
if dbErr == nil {
_ = sqlDB.Close()
}
}) })
user := &model.User{
Username: "passkey-proof-user", Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.TwoFA{UserId: user.Id, Secret: "totp-secret", IsEnabled: true}).Error)
identity := service.AuthIdentity{
UserID: user.Id, SessionID: "passkey-proof-session", UserAuthVersion: 1, SessionVersion: 1,
}
wrongScopeProof, _, err := service.IssueSecurityProof(identity, secureVerificationMethod2FA, []string{securityProofScopePasskeyDelete})
require.NoError(t, err) require.NoError(t, err)
body, err := common.Marshal(passkeyFinishRequest{
tests := []struct { FlowToken: token, Credential: securityPasskeyResponse(t, key, "test-challenge", true, 0),
name string })
proof string require.NoError(t, err)
expectedCode string response := securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
}{ var result securityEnrollmentResponse
{name: "missing proof", expectedCode: "SECURITY_PROOF_REQUIRED"}, require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
{name: "wrong scope proof", proof: wrongScopeProof, expectedCode: "SECURITY_PROOF_SCOPE_MISMATCH"}, assert.False(t, result.Success)
} assert.Equal(t, "AUTH_FLOW_INVALID", result.Code)
for _, test := range tests { _, err = model.GetPasskeyByUserID(identity.UserID)
t.Run(test.name, func(t *testing.T) { assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyRegister})
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID, require.NoError(t, err)
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute), assert.Nil(t, flow.ConsumedAt)
})
require.NoError(t, err)
body := fmt.Sprintf(`{"flow_token":%q,"credential":{}}`, flowToken)
request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
if test.proof != "" {
request.Header.Set("X-Security-Proof", test.proof)
}
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
context.Set("id", identity.UserID)
context.Set("session_id", identity.SessionID)
context.Set("auth_version", identity.UserAuthVersion)
context.Set("session_version", identity.SessionVersion)
PasskeyRegisterFinish(context)
assert.Equal(t, http.StatusForbidden, response.Code)
var responseBody struct {
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &responseBody))
assert.Equal(t, test.expectedCode, responseBody.Code)
flow, err := model.GetAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID,
})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
})
}
} }
...@@ -2,87 +2,97 @@ package controller ...@@ -2,87 +2,97 @@ package controller
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
) )
const ( func GetVerificationMethods(c *gin.Context) {
secureVerificationMethod2FA = "2fa"
secureVerificationMethodPasskey = "passkey"
)
type UniversalVerifyRequest struct {
Method string `json:"method"`
Code string `json:"code,omitempty"`
Scope string `json:"scope"`
}
func UniversalVerify(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c) identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok { if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"}) c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
return return
} }
var request UniversalVerifyRequest requirements, err := service.GetVerificationRequirements(identity, c.Query("scope"))
if err := common.DecodeJson(c.Request.Body, &request); err != nil { if err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err)) writeSecurityOperationError(c, err)
return
}
if request.Method != secureVerificationMethod2FA {
common.ApiError(c, errors.New("Passkey 验证必须使用 Passkey verify 流程"))
return
}
if !isAllowedSecurityProofScope(request.Scope) {
common.ApiError(c, errors.New("不支持的安全验证范围"))
return return
} }
if strings.TrimSpace(request.Code) == "" { common.ApiSuccess(c, requirements)
common.ApiError(c, errors.New("验证码不能为空")) }
// writeSecurityOperationError only exposes known, fixed business messages.
// Unexpected errors retain their cause for the existing server-side auth logger.
func writeSecurityOperationError(c *gin.Context, err error) {
status := http.StatusOK
var code, message string
var protocolError *protocol.Error
switch {
case errors.Is(err, service.ErrVerificationContextInvalid):
status = http.StatusBadRequest
code, message = "SECURITY_CONTEXT_INVALID", service.ErrVerificationContextInvalid.Error()
case errors.Is(err, service.ErrVerificationForbidden):
status = http.StatusForbidden
code, message = "SECURITY_ACTION_FORBIDDEN", service.ErrVerificationForbidden.Error()
case errors.Is(err, service.ErrVerificationFailed), errors.As(err, &protocolError):
code, message = "SECURITY_VERIFICATION_FAILED", service.ErrVerificationFailed.Error()
case errors.Is(err, service.ErrVerificationLocked):
code, message = "SECURITY_VERIFICATION_LOCKED", service.ErrVerificationLocked.Error()
case errors.Is(err, service.ErrVerificationUnavailable):
code, message = "SECURITY_METHOD_UNAVAILABLE", service.ErrVerificationUnavailable.Error()
case errors.Is(err, service.ErrVerificationFlowRequired):
status = http.StatusBadRequest
code, message = "SECURITY_VERIFICATION_FLOW_REQUIRED", service.ErrVerificationFlowRequired.Error()
case errors.Is(err, service.ErrProofMethod):
code, message = "SECURITY_PROOF_METHOD_MISMATCH", "This verification method is not allowed for this action."
case errors.Is(err, service.ErrProofScope):
code, message = "SECURITY_PROOF_SCOPE_MISMATCH", "Verification does not match this action."
case errors.Is(err, service.ErrOAuthAccountMismatch):
code, message = "OAUTH_ACCOUNT_MISMATCH", service.ErrOAuthAccountMismatch.Error()
case errors.Is(err, model.ErrTwoFASetupInvalid):
status = http.StatusConflict
code, message = "TWOFA_SETUP_INVALID", model.ErrTwoFASetupInvalid.Error()
case errors.Is(err, model.ErrTwoFACodeInvalid):
code, message = "TWOFA_CODE_INVALID", model.ErrTwoFACodeInvalid.Error()
case errors.Is(err, model.ErrTwoFAAlreadyEnabled):
code, message = "TWOFA_ALREADY_ENABLED", "Two-factor authentication is already enabled."
case errors.Is(err, model.ErrTwoFANotEnabled):
code, message = "TWOFA_NOT_ENABLED", "Two-factor authentication is not enabled."
case errors.Is(err, model.ErrPasskeyNotFound):
code, message = "PASSKEY_NOT_FOUND", "No Passkey is registered."
case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed):
code, message = "AUTH_FLOW_INVALID", "Verification flow expired"
case errors.Is(err, model.ErrUserSessionInvalid), errors.Is(err, model.ErrUserSessionInactive):
writeAuthSessionError(c, service.ErrAuthTokenInvalid)
return return
} default:
twoFA, err := model.GetTwoFAByUserId(identity.UserID) writeAuthSessionError(c, err)
if err != nil {
common.ApiError(c, err)
return return
} }
if twoFA == nil || !twoFA.IsEnabled { c.JSON(status, gin.H{"success": false, "code": code, "message": message})
common.ApiError(c, errors.New("用户未启用2FA")) }
func UniversalVerify(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
return return
} }
if !validateTwoFactorAuth(twoFA, request.Code) { var request service.VerificationInput
common.ApiError(c, errors.New("验证失败,请检查验证码")) if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorMsg(c, "参数错误")
return return
} }
proofToken, expiresAt, err := service.IssueSecurityProof(identity, request.Method, []string{request.Scope}) proof, err := service.VerifySecurityInput(identity, request)
if err != nil { if err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]interface{}{"method": "2fa"}) recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]interface{}{"method": proof.Method, "scope": proof.Scope})
c.JSON(http.StatusOK, gin.H{ common.ApiSuccess(c, proof)
"success": true,
"message": "验证成功",
"data": gin.H{
"proof_token": proofToken,
"expires_at": expiresAt,
"method": request.Method,
"scope": request.Scope,
},
})
}
func isAllowedSecurityProofScope(scope string) bool {
switch scope {
case securityProofScopeChannelKeyRead, securityProofScopePasskeyRegister, securityProofScopePasskeyDelete:
return true
default:
return false
}
} }
...@@ -156,7 +156,10 @@ func TestTryUserAuthCredentialClassification(t *testing.T) { ...@@ -156,7 +156,10 @@ func TestTryUserAuthCredentialClassification(t *testing.T) {
} }
accessToken, _, err := service.IssueAccessToken(identity) accessToken, _, err := service.IssueAccessToken(identity)
require.NoError(t, err) require.NoError(t, err)
securityProof, _, err := service.IssueSecurityProof(identity, "2fa", []string{"channel.key.read"}) require.NoError(t, model.DB.AutoMigrate(&model.AuthFlow{}))
binding, err := service.BindVerificationOperation(service.VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)})
require.NoError(t, err)
securityProof, _, err := service.IssueSecurityProof(identity, "2fa", binding)
require.NoError(t, err) require.NoError(t, err)
externalToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ externalToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "external-issuer", "iss": "external-issuer",
......
...@@ -2,6 +2,7 @@ package middleware ...@@ -2,6 +2,7 @@ package middleware
import ( import (
"fmt" "fmt"
"strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -27,6 +28,12 @@ func SetUpLogger(server *gin.Engine) { ...@@ -27,6 +28,12 @@ func SetUpLogger(server *gin.Engine) {
if tag == "" { if tag == "" {
tag = "web" tag = "web"
} }
path := param.Path
// OAuth callbacks carry one-time codes and state in the query string.
// Redact the log value only; the handler still needs the original query.
if strings.HasPrefix(path, "/api/oauth/") || strings.HasPrefix(path, "/oauth/") {
path, _, _ = strings.Cut(path, "?")
}
return fmt.Sprintf("[GIN] %s | %s | %s | %3d | %13v | %15s | %7s %s\n", return fmt.Sprintf("[GIN] %s | %s | %s | %3d | %13v | %15s | %7s %s\n",
param.TimeStamp.Format("2006/01/02 - 15:04:05"), param.TimeStamp.Format("2006/01/02 - 15:04:05"),
tag, tag,
...@@ -35,7 +42,7 @@ func SetUpLogger(server *gin.Engine) { ...@@ -35,7 +42,7 @@ func SetUpLogger(server *gin.Engine) {
param.Latency, param.Latency,
param.ClientIP, param.ClientIP,
param.Method, param.Method,
param.Path, path,
) )
})) }))
} }
...@@ -3,8 +3,11 @@ package middleware ...@@ -3,8 +3,11 @@ package middleware
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
...@@ -13,7 +16,17 @@ import ( ...@@ -13,7 +16,17 @@ import (
// operations validate their narrower proof scopes in their controller. // operations validate their narrower proof scopes in their controller.
func SecureVerificationRequired() gin.HandlerFunc { func SecureVerificationRequired() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if !RequireSecurityProof(c, "channel.key.read", []string{"2fa", "passkey"}) { channelID, err := strconv.Atoi(c.Param("id"))
if err != nil || channelID <= 0 {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"success": false, "code": "SECURITY_CONTEXT_INVALID", "message": service.ErrVerificationContextInvalid.Error()})
return
}
context, err := common.Marshal(service.ChannelKeyReadContext{ChannelID: channelID})
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
if RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeChannelKeyRead, Context: context}) == nil {
return return
} }
c.Set("secure_verified", true) c.Set("secure_verified", true)
...@@ -23,31 +36,43 @@ func SecureVerificationRequired() gin.HandlerFunc { ...@@ -23,31 +36,43 @@ func SecureVerificationRequired() gin.HandlerFunc {
// RequireSecurityProof validates a proof against the authenticated dashboard // RequireSecurityProof validates a proof against the authenticated dashboard
// session and writes the shared proof error contract on failure. // session and writes the shared proof error contract on failure.
func RequireSecurityProof(c *gin.Context, requiredScope string, allowedMethods []string) bool { func RequireSecurityProof(c *gin.Context, operation service.VerificationOperation) *model.AuthFlowAuthorization {
identity, ok := GetSessionAuthIdentity(c) identity, ok := GetSessionAuthIdentity(c)
if !ok { if !ok {
securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效") securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效")
return false return nil
} }
raw := strings.TrimSpace(c.GetHeader("X-Security-Proof")) raw := strings.TrimSpace(c.GetHeader("X-Security-Proof"))
if raw == "" { if raw == "" {
securityProofError(c, "SECURITY_PROOF_REQUIRED", "需要安全验证") securityProofError(c, "SECURITY_PROOF_REQUIRED", "需要安全验证")
return false return nil
} }
if _, err := service.VerifySecurityProof(raw, identity, requiredScope, allowedMethods); err != nil { authorization, err := service.ConsumeOperationProof(raw, identity, operation)
if err != nil {
switch { switch {
case errors.Is(err, service.ErrAuthTokenExpired): case errors.Is(err, service.ErrAuthTokenExpired):
securityProofError(c, "SECURITY_PROOF_EXPIRED", "安全验证已过期") securityProofError(c, "SECURITY_PROOF_EXPIRED", "安全验证已过期")
case errors.Is(err, service.ErrProofScope): case errors.Is(err, service.ErrProofScope):
securityProofError(c, "SECURITY_PROOF_SCOPE_MISMATCH", "安全验证范围不匹配") securityProofError(c, "SECURITY_PROOF_SCOPE_MISMATCH", "安全验证范围不匹配")
case errors.Is(err, service.ErrVerificationUnavailable):
securityProofError(c, "SECURITY_METHOD_UNAVAILABLE", service.ErrVerificationUnavailable.Error())
case errors.Is(err, service.ErrProofMethod): case errors.Is(err, service.ErrProofMethod):
securityProofError(c, "SECURITY_PROOF_METHOD_MISMATCH", "安全验证方式不匹配") securityProofError(c, "SECURITY_PROOF_METHOD_MISMATCH", "安全验证方式不匹配")
default: case errors.Is(err, service.ErrProofConsumed):
securityProofError(c, "SECURITY_PROOF_CONSUMED", "This verification has already been used. Please verify again.")
case errors.Is(err, service.ErrProofContext):
securityProofError(c, "SECURITY_PROOF_CONTEXT_MISMATCH", "Verification does not match this action's details. Please verify again.")
case errors.Is(err, service.ErrVerificationForbidden):
securityProofError(c, "SECURITY_ACTION_FORBIDDEN", service.ErrVerificationForbidden.Error())
case errors.Is(err, service.ErrAuthTokenInvalid), errors.Is(err, service.ErrLoginSessionInvalid), errors.Is(err, service.ErrLoginSessionRevoked), errors.Is(err, model.ErrUserSessionInactive):
securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效") securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效")
default:
_ = c.Error(err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"success": false, "code": "AUTH_INTERNAL_ERROR", "message": "Please try again later."})
} }
return false return nil
} }
return true return authorization
} }
func securityProofError(c *gin.Context, code, message string) { func securityProofError(c *gin.Context, code, message string) {
......
...@@ -24,6 +24,9 @@ const ( ...@@ -24,6 +24,9 @@ const (
AuthFlowPurposeTelegramAssertion = "telegram_assertion" AuthFlowPurposeTelegramAssertion = "telegram_assertion"
AuthFlowIntentLogin = "login" AuthFlowIntentLogin = "login"
AuthFlowIntentBind = "bind" AuthFlowIntentBind = "bind"
AuthFlowIntentVerify = "verify"
AuthFlowPurposeTwoFASetup = "2fa_setup"
AuthFlowPurposeSecurityProof = "security_proof"
AuthFlowTokenBytes = 32 AuthFlowTokenBytes = 32
AuthFlowDefaultCleanupRetention = 24 * time.Hour AuthFlowDefaultCleanupRetention = 24 * time.Hour
) )
...@@ -72,6 +75,47 @@ type AuthFlowMatch struct { ...@@ -72,6 +75,47 @@ type AuthFlowMatch struct {
SessionId string SessionId string
} }
// AuthSessionIdentity binds an authentication flow to a specific session version.
type AuthSessionIdentity struct {
UserID int `json:"user_id"`
SessionID string `json:"session_id"`
UserAuthVersion int64 `json:"auth_version"`
SessionVersion int64 `json:"session_version"`
}
// AuthFlowAuthorization is server-owned state carried into a configuration flow
// after a proof has been consumed. ProofID is a database ID, never the proof token.
type AuthFlowAuthorization struct {
AuthSessionIdentity
ProofID int64 `json:"proof_id"`
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
Method string `json:"method"`
}
// ValidateAuthSessionWithTx rechecks the authoritative identity while holding the
// user/session locks until the caller's credential change or flow consumption commits.
func ValidateAuthSessionWithTx(tx *gorm.DB, identity AuthSessionIdentity) error {
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 {
return ErrUserSessionInactive
}
var user User
if err := lockForUpdate(tx).First(&user, identity.UserID).Error; err != nil {
return err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion != identity.UserAuthVersion {
return ErrUserSessionInactive
}
var session UserSession
if err := lockForUpdate(tx).Where("sid = ? AND user_id = ?", identity.SessionID, identity.UserID).First(&session).Error; err != nil {
return err
}
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() || session.UserAuthVersion != identity.UserAuthVersion || session.Version != identity.SessionVersion {
return ErrUserSessionInactive
}
return nil
}
func applyAuthFlowMatch(query *gorm.DB, token string, match AuthFlowMatch) *gorm.DB { func applyAuthFlowMatch(query *gorm.DB, token string, match AuthFlowMatch) *gorm.DB {
query = query.Where("token_hash = ? AND purpose = ?", authFlowTokenHash(token), match.Purpose) query = query.Where("token_hash = ? AND purpose = ?", authFlowTokenHash(token), match.Purpose)
if match.Provider != "" { if match.Provider != "" {
...@@ -94,6 +138,10 @@ func authFlowTokenHash(token string) string { ...@@ -94,6 +138,10 @@ func authFlowTokenHash(token string) string {
} }
func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) { func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) {
return createAuthFlowWithTx(DB, input)
}
func createAuthFlowWithTx(tx *gorm.DB, input AuthFlowCreate) (string, *AuthFlow, error) {
if strings.TrimSpace(input.Purpose) == "" || input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) { if strings.TrimSpace(input.Purpose) == "" || input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) {
return "", nil, ErrAuthFlowInvalid return "", nil, ErrAuthFlowInvalid
} }
...@@ -112,7 +160,7 @@ func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) { ...@@ -112,7 +160,7 @@ func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) {
Payload: input.Payload, Payload: input.Payload,
ExpiresAt: input.ExpiresAt, ExpiresAt: input.ExpiresAt,
} }
if err := DB.Create(flow).Error; err != nil { if err := tx.Create(flow).Error; err != nil {
return "", nil, err return "", nil, err
} }
return token, flow, nil return token, flow, nil
...@@ -192,30 +240,31 @@ func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx ...@@ -192,30 +240,31 @@ func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx
} }
var consumed AuthFlow var consumed AuthFlow
err := DB.Transaction(func(tx *gorm.DB) error { err := DB.Transaction(func(tx *gorm.DB) error {
query := applyAuthFlowMatch(lockForUpdate(tx), token, match) // Claim with the first write, rather than upgrading a prior read lock.
// SQLite cannot reliably upgrade two concurrent deferred read transactions.
now := time.Now()
result := applyAuthFlowMatch(tx.Model(&AuthFlow{}), token, match).
Where("consumed_at IS NULL AND expires_at > ?", now).
Update("consumed_at", now)
if result.Error != nil {
return result.Error
}
query := applyAuthFlowMatch(tx, token, match)
if err := query.First(&consumed).Error; err != nil { if err := query.First(&consumed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAuthFlowInvalid return ErrAuthFlowInvalid
} }
return err return err
} }
if consumed.ConsumedAt != nil { if result.RowsAffected != 1 && consumed.ConsumedAt != nil {
return ErrAuthFlowConsumed return ErrAuthFlowConsumed
} }
now := time.Now() if !consumed.ExpiresAt.After(time.Now()) {
if !consumed.ExpiresAt.After(now) {
return ErrAuthFlowExpired 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 { if result.RowsAffected != 1 {
return ErrAuthFlowConsumed return ErrAuthFlowInvalid
} }
consumed.ConsumedAt = &now
if action != nil { if action != nil {
if err := action(tx, &consumed); err != nil { if err := action(tx, &consumed); err != nil {
return err return err
......
...@@ -201,10 +201,26 @@ func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) e ...@@ -201,10 +201,26 @@ func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) e
// UpsertPasskeyCredentialWithAuthVersion is reserved for enrollment changes; // UpsertPasskeyCredentialWithAuthVersion is reserved for enrollment changes;
// assertion sign-count updates must use UpdatePasskeyAssertionState. // assertion sign-count updates must use UpdatePasskeyAssertionState.
func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error { func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error {
return upsertPasskeyCredentialWithAuthVersion(credential, nil)
}
func RegisterPasskeyForSession(identity AuthSessionIdentity, credential *PasskeyCredential) error {
return upsertPasskeyCredentialWithAuthVersion(credential, &identity)
}
func upsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential, identity *AuthSessionIdentity) error {
if credential == nil || credential.UserID <= 0 { if credential == nil || credential.UserID <= 0 {
return fmt.Errorf("Passkey 保存失败,请重试") return fmt.Errorf("Passkey 保存失败,请重试")
} }
if err := DB.Transaction(func(tx *gorm.DB) error { if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if identity.UserID != credential.UserID {
return ErrUserSessionInactive
}
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
}
if _, err := IncrementUserAuthVersionWithTx(tx, credential.UserID); err != nil { if _, err := IncrementUserAuthVersionWithTx(tx, credential.UserID); err != nil {
return err return err
} }
...@@ -216,10 +232,23 @@ func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error ...@@ -216,10 +232,23 @@ func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error
} }
func DeletePasskeyByUserIDWithAuthVersion(userID int) error { func DeletePasskeyByUserIDWithAuthVersion(userID int) error {
return deletePasskeyWithAuthVersion(userID, nil)
}
func DeletePasskeyForSession(identity AuthSessionIdentity) error {
return deletePasskeyWithAuthVersion(identity.UserID, &identity)
}
func deletePasskeyWithAuthVersion(userID int, identity *AuthSessionIdentity) error {
if userID == 0 { if userID == 0 {
return fmt.Errorf("删除失败,请重试") return fmt.Errorf("删除失败,请重试")
} }
if err := DB.Transaction(func(tx *gorm.DB) error { if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
}
var credential PasskeyCredential var credential PasskeyCredential
if err := lockForUpdate(tx).Where("user_id = ?", userID).First(&credential).Error; err != nil { if err := lockForUpdate(tx).Where("user_id = ?", userID).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
......
...@@ -62,33 +62,6 @@ func IsTwoFAEnabled(userId int) (bool, error) { ...@@ -62,33 +62,6 @@ func IsTwoFAEnabled(userId int) (bool, error) {
return twoFA != nil && twoFA.IsEnabled, nil return twoFA != nil && twoFA.IsEnabled, nil
} }
// CreatePendingTwoFASetup stores a disabled factor while the user completes
// enrollment. Enabling a factor must use EnableWithAuthVersion.
func (t *TwoFA) CreatePendingTwoFASetup() error {
if t == nil || t.UserId <= 0 || t.IsEnabled {
return errors.New("无效的2FA待验证设置")
}
// 检查用户是否已存在2FA设置
existing, err := GetTwoFAByUserId(t.UserId)
if err != nil {
return err
}
if existing != nil {
return errors.New("用户已存在2FA设置")
}
// 验证用户存在
var user User
if err := DB.First(&user, t.UserId).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("用户不存在")
}
return err
}
return DB.Create(t).Error
}
func (t *TwoFA) updateUsageState() error { func (t *TwoFA) updateUsageState() error {
if t.Id == 0 { if t.Id == 0 {
return errors.New("2FA记录ID不能为空") return errors.New("2FA记录ID不能为空")
...@@ -100,27 +73,6 @@ func (t *TwoFA) updateUsageState() error { ...@@ -100,27 +73,6 @@ func (t *TwoFA) updateUsageState() error {
}).Error }).Error
} }
// DeletePendingTwoFASetup removes only an unverified setup. Enabled factors
// must use DisableTwoFAWithAuthVersion.
func (t *TwoFA) DeletePendingTwoFASetup() error {
if t == nil || t.Id == 0 || t.UserId <= 0 {
return errors.New("2FA记录ID不能为空")
}
return DB.Transaction(func(tx *gorm.DB) error {
var pending TwoFA
if err := lockForUpdate(tx).
Where("id = ? AND user_id = ? AND is_enabled = ?", t.Id, t.UserId, false).
First(&pending).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&pending).Error
})
}
// ResetFailedAttempts 重置失败尝试次数 // ResetFailedAttempts 重置失败尝试次数
func (t *TwoFA) ResetFailedAttempts() error { func (t *TwoFA) ResetFailedAttempts() error {
t.FailedAttempts = 0 t.FailedAttempts = 0
...@@ -184,18 +136,6 @@ func (t *TwoFA) IsLocked() bool { ...@@ -184,18 +136,6 @@ func (t *TwoFA) IsLocked() bool {
return time.Now().Before(*t.LockedUntil) return time.Now().Before(*t.LockedUntil)
} }
// CreatePendingTwoFASetupBackupCodes stores recovery codes for an unverified
// setup. Regeneration for an enabled factor must advance auth_version.
func CreatePendingTwoFASetupBackupCodes(userId int, codes []string) error {
return DB.Transaction(func(tx *gorm.DB) error {
var pending TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, false).First(&pending).Error; err != nil {
return err
}
return replaceBackupCodesWithTx(tx, userId, codes)
})
}
func replaceBackupCodesWithTx(tx *gorm.DB, userId int, codes []string) error { func replaceBackupCodesWithTx(tx *gorm.DB, userId int, codes []string) error {
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err return err
...@@ -298,41 +238,6 @@ func DisableTwoFAWithAuthVersion(userId int) error { ...@@ -298,41 +238,6 @@ func DisableTwoFAWithAuthVersion(userId int) error {
return PublishUserAuthCache(userId) return PublishUserAuthCache(userId)
} }
// EnableWithAuthVersion atomically enables this factor and advances the user
// authentication version so pre-enrollment sessions cannot remain valid.
func (t *TwoFA) EnableWithAuthVersion() error {
if t == nil || t.Id == 0 || t.UserId == 0 {
return errors.New("2FA记录ID不能为空")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
var pending TwoFA
if err := lockForUpdate(tx).Where("id = ? AND user_id = ? AND is_enabled = ?", t.Id, t.UserId, false).First(&pending).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFAAlreadyEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, t.UserId); err != nil {
return err
}
result := tx.Model(&pending).
Updates(map[string]interface{}{"is_enabled": true, "failed_attempts": 0, "locked_until": nil})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrTwoFAAlreadyEnabled
}
return nil
}); err != nil {
return err
}
t.IsEnabled = true
t.FailedAttempts = 0
t.LockedUntil = nil
return PublishUserAuthCache(t.UserId)
}
// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录 // ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录
func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) { func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
// 检查是否被锁定 // 检查是否被锁定
...@@ -344,7 +249,7 @@ func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) { ...@@ -344,7 +249,7 @@ func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
if !common.ValidateTOTPCode(t.Secret, code) { if !common.ValidateTOTPCode(t.Secret, code) {
// 增加失败次数 // 增加失败次数
if err := t.IncrementFailedAttempts(); err != nil { if err := t.IncrementFailedAttempts(); err != nil {
common.SysLog("更新2FA失败次数失败: " + err.Error()) return false, err
} }
return false, nil return false, nil
} }
...@@ -356,7 +261,7 @@ func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) { ...@@ -356,7 +261,7 @@ func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
t.LastUsedAt = &now t.LastUsedAt = &now
if err := t.updateUsageState(); err != nil { if err := t.updateUsageState(); err != nil {
common.SysLog("更新2FA使用记录失败: " + err.Error()) return false, err
} }
return true, nil return true, nil
...@@ -378,7 +283,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) { ...@@ -378,7 +283,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
if !valid { if !valid {
// 增加失败次数 // 增加失败次数
if err := t.IncrementFailedAttempts(); err != nil { if err := t.IncrementFailedAttempts(); err != nil {
common.SysLog("更新2FA失败次数失败: " + err.Error()) return false, err
} }
return false, nil return false, nil
} }
...@@ -390,7 +295,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) { ...@@ -390,7 +295,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
t.LastUsedAt = &now t.LastUsedAt = &now
if err := t.updateUsageState(); err != nil { if err := t.updateUsageState(); err != nil {
common.SysLog("更新2FA使用记录失败: " + err.Error()) return false, err
} }
return true, nil return true, nil
......
package model
import (
"crypto/hmac"
"errors"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
var ErrTwoFASetupInvalid = errors.New("The two-factor setup has expired or changed. Start setup again.")
var ErrTwoFACodeInvalid = errors.New("The authenticator code is incorrect.")
type twoFAEnrollmentPayload struct {
TwoFAID int `json:"twofa_id"`
SecretHash string `json:"secret_hash"`
Authorization *AuthFlowAuthorization `json:"authorization"`
}
// CreateTwoFAEnrollment stores the pending credential, recovery codes and
// session-bound flow atomically. Reinitialization invalidates the previous setup.
func CreateTwoFAEnrollment(identity AuthSessionIdentity, authorization *AuthFlowAuthorization, secret string, backupCodes []string, expiresAt time.Time) (string, error) {
if authorization == nil || authorization.ProofID <= 0 || authorization.AuthSessionIdentity != identity {
return "", ErrTwoFASetupInvalid
}
var token string
err := DB.Transaction(func(tx *gorm.DB) error {
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var existing TwoFA
err := lockForUpdate(tx).Where("user_id = ?", identity.UserID).First(&existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
if existing.IsEnabled {
return ErrTwoFAAlreadyEnabled
}
if err := tx.Unscoped().Delete(&existing).Error; err != nil {
return err
}
}
pending := &TwoFA{UserId: identity.UserID, Secret: secret}
if err := tx.Create(pending).Error; err != nil {
return err
}
if err := replaceBackupCodesWithTx(tx, identity.UserID, backupCodes); err != nil {
return err
}
payload, err := common.Marshal(twoFAEnrollmentPayload{
TwoFAID: pending.Id, SecretHash: common.GenerateHMACWithKey([]byte("twofa-enrollment:"+common.SessionSecret), secret),
Authorization: authorization,
})
if err != nil {
return err
}
token, _, err = createAuthFlowWithTx(tx, AuthFlowCreate{
Purpose: AuthFlowPurposeTwoFASetup, UserId: identity.UserID, SessionId: identity.SessionID,
Payload: string(payload), ExpiresAt: expiresAt,
})
return err
})
return token, err
}
// EnableTwoFAEnrollment validates and consumes one setup in the same transaction
// as factor activation and auth_version advancement. A bad code can be retried.
func EnableTwoFAEnrollment(identity AuthSessionIdentity, token, code string) error {
cleanCode, err := common.ValidateNumericCode(code)
if err != nil {
return ErrTwoFACodeInvalid
}
_, err = ConsumeAuthFlowWithAction(token, AuthFlowMatch{
Purpose: AuthFlowPurposeTwoFASetup, UserId: identity.UserID, SessionId: identity.SessionID,
}, func(tx *gorm.DB, flow *AuthFlow) error {
var payload twoFAEnrollmentPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return err
}
if payload.Authorization == nil || payload.Authorization.ProofID <= 0 || payload.Authorization.AuthSessionIdentity != identity {
return ErrTwoFASetupInvalid
}
if err := ValidateAuthSessionWithTx(tx, identity); err != nil {
return err
}
var pending TwoFA
if err := lockForUpdate(tx).Where("id = ? AND user_id = ? AND is_enabled = ?", payload.TwoFAID, identity.UserID, false).First(&pending).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFASetupInvalid
}
return err
}
secretHash := common.GenerateHMACWithKey([]byte("twofa-enrollment:"+common.SessionSecret), pending.Secret)
if !hmac.Equal([]byte(payload.SecretHash), []byte(secretHash)) {
return ErrTwoFASetupInvalid
}
if !common.ValidateTOTPCode(pending.Secret, cleanCode) {
return ErrTwoFACodeInvalid
}
if _, err := IncrementUserAuthVersionWithTx(tx, identity.UserID); err != nil {
return err
}
return tx.Model(&pending).Updates(map[string]any{"is_enabled": true, "failed_attempts": 0, "locked_until": nil}).Error
})
if errors.Is(err, ErrAuthFlowInvalid) || errors.Is(err, ErrAuthFlowExpired) || errors.Is(err, ErrAuthFlowConsumed) {
return ErrTwoFASetupInvalid
}
if err != nil {
return err
}
return PublishUserAuthCache(identity.UserID)
}
...@@ -12,6 +12,7 @@ import ( ...@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
...@@ -165,7 +166,9 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) { ...@@ -165,7 +166,9 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
user := User{Id: 123, Username: "backup-code-user", Password: "password", AuthVersion: 1} user := User{Id: 123, Username: "backup-code-user", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error) require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}).Error) require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}).Error)
require.NoError(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{code})) hashedCode, err := common.HashBackupCode(code)
require.NoError(t, err)
require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: hashedCode}).Error)
const attempts = 2 const attempts = 2
results := make(chan bool, attempts) results := make(chan bool, attempts)
...@@ -203,13 +206,17 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) { ...@@ -203,13 +206,17 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
func TestPendingTwoFASetupAPIsRejectEnabledFactor(t *testing.T) { func TestPendingTwoFASetupAPIsRejectEnabledFactor(t *testing.T) {
truncateTables(t) truncateTables(t)
user := User{Username: "enabled-twofa-guard", Password: "password", AuthVersion: 1} user := User{Username: "enabled-twofa-guard", Password: "password", Status: common.UserStatusEnabled, AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error) require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true} twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}
require.NoError(t, DB.Create(&twoFA).Error) require.NoError(t, DB.Create(&twoFA).Error)
require.Error(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{"ABCD-1234"})) session := UserSession{SID: "enabled-factor-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: UserSessionStatusActive, ExpiresAt: time.Now().Add(time.Hour).Unix()}
require.Error(t, twoFA.DeletePendingTwoFASetup()) require.NoError(t, DB.Create(&session).Error)
identity := AuthSessionIdentity{UserID: user.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1}
authorization := &AuthFlowAuthorization{AuthSessionIdentity: identity, ProofID: 1, Scope: "2fa.setup", ContextHash: "setup-context", Method: "password"}
_, err := CreateTwoFAEnrollment(identity, authorization, "replacement-secret", []string{"ABCD-1234"}, time.Now().Add(time.Minute))
require.ErrorIs(t, err, ErrTwoFAAlreadyEnabled)
var stored TwoFA var stored TwoFA
require.NoError(t, DB.First(&stored, twoFA.Id).Error) require.NoError(t, DB.First(&stored, twoFA.Id).Error)
...@@ -231,12 +238,17 @@ func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) { ...@@ -231,12 +238,17 @@ func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) {
AuthVersion: 1, AuthVersion: 1,
} }
require.NoError(t, DB.Create(&user).Error) require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false} session := UserSession{SID: "factor-version-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: UserSessionStatusActive, ExpiresAt: time.Now().Add(time.Hour).Unix()}
require.NoError(t, DB.Create(&twoFA).Error) require.NoError(t, DB.Create(&session).Error)
identity := AuthSessionIdentity{UserID: user.Id, SessionID: session.SID, UserAuthVersion: 1, SessionVersion: 1}
require.NoError(t, twoFA.EnableWithAuthVersion()) authorization := &AuthFlowAuthorization{AuthSessionIdentity: identity, ProofID: 1, Scope: "2fa.setup", ContextHash: "setup-context", Method: "password"}
token, err := CreateTwoFAEnrollment(identity, authorization, "JBSWY3DPEHPK3PXP", []string{"ABCD-1234"}, time.Now().Add(time.Minute))
require.NoError(t, err)
code, err := totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
require.NoError(t, err)
require.NoError(t, EnableTwoFAEnrollment(identity, token, code))
assertUserAuthVersion(t, user.Id, 2) assertUserAuthVersion(t, user.Id, 2)
assert.ErrorIs(t, twoFA.EnableWithAuthVersion(), ErrTwoFAAlreadyEnabled) assert.ErrorIs(t, EnableTwoFAEnrollment(identity, token, code), ErrTwoFASetupInvalid)
assertUserAuthVersion(t, user.Id, 2) assertUserAuthVersion(t, user.Id, 2)
require.NoError(t, ReplaceBackupCodesWithAuthVersion(user.Id, []string{"ABCD-1234"})) require.NoError(t, ReplaceBackupCodesWithAuthVersion(user.Id, []string{"ABCD-1234"}))
assertUserAuthVersion(t, user.Id, 3) assertUserAuthVersion(t, user.Id, 3)
......
...@@ -51,8 +51,6 @@ func (p *DiscordProvider) ExchangeToken(ctx context.Context, code string, c *gin ...@@ -51,8 +51,6 @@ func (p *DiscordProvider) ExchangeToken(ctx context.Context, code string, c *gin
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil) return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
} }
logger.LogDebug(ctx, "[OAuth-Discord] ExchangeToken: code=%s...", code[:min(len(code), 10)])
settings := system_setting.GetDiscordSettings() settings := system_setting.GetDiscordSettings()
redirectUri := fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress) redirectUri := fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress)
values := url.Values{} values := url.Values{}
......
...@@ -92,8 +92,6 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c ...@@ -92,8 +92,6 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil) return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
} }
logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: code=%s...", p.config.Slug, code[:min(len(code), 10)])
redirectUri := fmt.Sprintf("%s/oauth/%s", system_setting.ServerAddress, p.config.Slug) redirectUri := fmt.Sprintf("%s/oauth/%s", system_setting.ServerAddress, p.config.Slug)
values := url.Values{} values := url.Values{}
values.Set("grant_type", "authorization_code") values.Set("grant_type", "authorization_code")
...@@ -150,7 +148,6 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c ...@@ -150,7 +148,6 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c
} }
bodyStr := string(body) bodyStr := string(body)
logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken response body: %s", p.config.Slug, bodyStr[:min(len(bodyStr), 500)])
// Try to parse as JSON first // Try to parse as JSON first
var tokenResponse struct { var tokenResponse struct {
...@@ -236,7 +233,6 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke ...@@ -236,7 +233,6 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke
} }
bodyStr := string(body) bodyStr := string(body)
logger.LogDebug(ctx, "[OAuth-Generic-%s] GetUserInfo response body: %s", p.config.Slug, bodyStr[:min(len(bodyStr), 500)])
// Extract fields using gjson (supports JSONPath-like syntax) // Extract fields using gjson (supports JSONPath-like syntax)
userId := gjson.Get(bodyStr, p.config.UserIdField).String() userId := gjson.Get(bodyStr, p.config.UserIdField).String()
......
...@@ -50,8 +50,6 @@ func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string, c *gin. ...@@ -50,8 +50,6 @@ func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string, c *gin.
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil) return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
} }
logger.LogDebug(ctx, "[OAuth-GitHub] ExchangeToken: code=%s...", code[:min(len(code), 10)])
values := map[string]string{ values := map[string]string{
"client_id": common.GitHubClientId, "client_id": common.GitHubClientId,
"client_secret": common.GitHubClientSecret, "client_secret": common.GitHubClientSecret,
......
...@@ -47,8 +47,6 @@ func (p *LinuxDOProvider) ExchangeToken(ctx context.Context, code string, c *gin ...@@ -47,8 +47,6 @@ func (p *LinuxDOProvider) ExchangeToken(ctx context.Context, code string, c *gin
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil) return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
} }
logger.LogDebug(ctx, "[OAuth-LinuxDO] ExchangeToken: code=%s...", code[:min(len(code), 10)])
// Get access token using Basic auth // Get access token using Basic auth
tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token") tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
......
...@@ -53,8 +53,6 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co ...@@ -53,8 +53,6 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil) return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
} }
logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken: code=%s...", code[:min(len(code), 10)])
settings := system_setting.GetOIDCSettings() settings := system_setting.GetOIDCSettings()
redirectUri := fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress) redirectUri := fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress)
values := url.Values{} values := url.Values{}
......
...@@ -65,7 +65,8 @@ func SetApiRouter(router *gin.Engine) { ...@@ -65,7 +65,8 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/waffo-pancake/webhook/:env", anonymousRequestBodyLimit, controller.WaffoPancakeWebhook) apiRouter.POST("/waffo-pancake/webhook/:env", anonymousRequestBodyLimit, controller.WaffoPancakeWebhook)
// Universal secure verification routes // Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UniversalVerify) apiRouter.GET("/verify/methods", middleware.UserAuth(), middleware.DisableCache(), controller.GetVerificationMethods)
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.UniversalVerify)
userRoute := apiRouter.Group("/user") userRoute := apiRouter.Group("/user")
{ {
...@@ -98,10 +99,10 @@ func SetApiRouter(router *gin.Engine) { ...@@ -98,10 +99,10 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken) selfRoute.POST("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.DELETE("/token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RevokeAccessToken) selfRoute.DELETE("/token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RevokeAccessToken)
selfRoute.GET("/passkey", controller.PasskeyStatus) selfRoute.GET("/passkey", controller.PasskeyStatus)
selfRoute.POST("/passkey/register/begin", middleware.DisableCache(), controller.PasskeyRegisterBegin) selfRoute.POST("/passkey/register/begin", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterBegin)
selfRoute.POST("/passkey/register/finish", middleware.DisableCache(), controller.PasskeyRegisterFinish) selfRoute.POST("/passkey/register/finish", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterFinish)
selfRoute.POST("/passkey/verify/begin", middleware.DisableCache(), controller.PasskeyVerifyBegin) selfRoute.POST("/passkey/verify/begin", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyVerifyBegin)
selfRoute.POST("/passkey/verify/finish", middleware.DisableCache(), controller.PasskeyVerifyFinish) selfRoute.POST("/passkey/verify/finish", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyVerifyFinish)
selfRoute.DELETE("/passkey", middleware.DisableCache(), controller.PasskeyDelete) selfRoute.DELETE("/passkey", middleware.DisableCache(), controller.PasskeyDelete)
selfRoute.GET("/aff", controller.GetAffCode) selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.GET("/topup/info", controller.GetTopUpInfo) selfRoute.GET("/topup/info", controller.GetTopUpInfo)
...@@ -121,8 +122,8 @@ func SetApiRouter(router *gin.Engine) { ...@@ -121,8 +122,8 @@ func SetApiRouter(router *gin.Engine) {
// 2FA routes // 2FA routes
selfRoute.GET("/2fa/status", controller.Get2FAStatus) selfRoute.GET("/2fa/status", controller.Get2FAStatus)
selfRoute.POST("/2fa/setup", middleware.DisableCache(), controller.Setup2FA) selfRoute.POST("/2fa/setup", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.Setup2FA)
selfRoute.POST("/2fa/enable", middleware.DisableCache(), controller.Enable2FA) selfRoute.POST("/2fa/enable", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.Enable2FA)
selfRoute.POST("/2fa/disable", middleware.DisableCache(), controller.Disable2FA) selfRoute.POST("/2fa/disable", middleware.DisableCache(), controller.Disable2FA)
selfRoute.POST("/2fa/backup_codes", middleware.DisableCache(), controller.RegenerateBackupCodes) selfRoute.POST("/2fa/backup_codes", middleware.DisableCache(), controller.RegenerateBackupCodes)
......
...@@ -10,13 +10,14 @@ import ( ...@@ -10,13 +10,14 @@ import (
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid" "github.com/google/uuid"
) )
const ( const (
AccessTokenTTL = 15 * time.Minute AccessTokenTTL = 15 * time.Minute
SecurityProofTTL = 5 * time.Minute SecurityProofTTL = time.Minute
LoginSessionTTL = 30 * 24 * time.Hour LoginSessionTTL = 30 * 24 * time.Hour
RefreshReplayWindow = 30 * time.Second RefreshReplayWindow = 30 * time.Second
accessTokenUse = "access" accessTokenUse = "access"
...@@ -30,16 +31,13 @@ var ( ...@@ -30,16 +31,13 @@ var (
ErrAuthTokenExpired = errors.New("authentication token has expired") ErrAuthTokenExpired = errors.New("authentication token has expired")
ErrProofScope = errors.New("security proof scope mismatch") ErrProofScope = errors.New("security proof scope mismatch")
ErrProofMethod = errors.New("security proof method mismatch") ErrProofMethod = errors.New("security proof method mismatch")
ErrProofContext = errors.New("security proof context mismatch")
ErrProofConsumed = errors.New("security proof has already been consumed")
) )
// AuthIdentity is the server-validated identity attached to dashboard requests. // AuthIdentity is the server-validated identity attached to dashboard requests.
// Role, status and group are deliberately loaded from the user cache instead of JWT claims. // Role, status and group are deliberately loaded from the user cache instead of JWT claims.
type AuthIdentity struct { type AuthIdentity = model.AuthSessionIdentity
UserID int
SessionID string
UserAuthVersion int64
SessionVersion int64
}
type authClaims struct { type authClaims struct {
TokenUse string `json:"token_use"` TokenUse string `json:"token_use"`
...@@ -48,6 +46,7 @@ type authClaims struct { ...@@ -48,6 +46,7 @@ type authClaims struct {
SessionVersion int64 `json:"sv"` SessionVersion int64 `json:"sv"`
Method string `json:"method,omitempty"` Method string `json:"method,omitempty"`
Scopes []string `json:"scopes,omitempty"` Scopes []string `json:"scopes,omitempty"`
ContextHash string `json:"context_hash,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
...@@ -129,20 +128,28 @@ func ParseDashboardAccessToken(raw string) (identity AuthIdentity, internal bool ...@@ -129,20 +128,28 @@ func ParseDashboardAccessToken(raw string) (identity AuthIdentity, internal bool
return identity, true, err return identity, true, err
} }
func IssueSecurityProof(identity AuthIdentity, method string, scopes []string) (string, int64, error) { func IssueSecurityProof(identity AuthIdentity, method string, binding VerificationBinding) (string, int64, error) {
method = strings.TrimSpace(method) method = strings.TrimSpace(method)
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 || method == "" || len(scopes) == 0 { if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 || method == "" || binding.Scope == "" || binding.ContextHash == "" {
return "", 0, ErrAuthTokenInvalid return "", 0, ErrAuthTokenInvalid
} }
now := time.Now() now := time.Now()
expiresAt := now.Add(SecurityProofTTL) expiresAt := now.Add(SecurityProofTTL).Truncate(time.Second)
proofID, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeSecurityProof, UserId: identity.UserID,
SessionId: identity.SessionID, ExpiresAt: expiresAt,
})
if err != nil {
return "", 0, err
}
claims := authClaims{ claims := authClaims{
TokenUse: securityProofTokenUse, TokenUse: securityProofTokenUse,
SessionID: identity.SessionID, SessionID: identity.SessionID,
UserAuthVersion: identity.UserAuthVersion, UserAuthVersion: identity.UserAuthVersion,
SessionVersion: identity.SessionVersion, SessionVersion: identity.SessionVersion,
Method: method, Method: method,
Scopes: append([]string(nil), scopes...), Scopes: []string{binding.Scope},
ContextHash: binding.ContextHash,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Issuer: authTokenIssuer, Issuer: authTokenIssuer,
Subject: strconv.Itoa(identity.UserID), Subject: strconv.Itoa(identity.UserID),
...@@ -150,45 +157,32 @@ func IssueSecurityProof(identity AuthIdentity, method string, scopes []string) ( ...@@ -150,45 +157,32 @@ func IssueSecurityProof(identity AuthIdentity, method string, scopes []string) (
ExpiresAt: jwt.NewNumericDate(expiresAt), ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)), NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)),
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ID: uuid.NewString(), ID: proofID,
}, },
} }
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(securityProofTokenUse)) signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(securityProofTokenUse))
return signed, expiresAt.Unix(), err return signed, expiresAt.Unix(), err
} }
func VerifySecurityProof(raw string, identity AuthIdentity, requiredScope string, allowedMethods []string) (string, error) { func verifySecurityProof(raw string, identity AuthIdentity, binding VerificationBinding) (*authClaims, error) {
claims, err := parseAuthClaims(raw, securityProofTokenUse, authSigningKey(securityProofTokenUse)) claims, err := parseAuthClaims(raw, securityProofTokenUse, authSigningKey(securityProofTokenUse))
if err != nil { if err != nil {
return "", err return nil, err
} }
userID, err := strconv.Atoi(claims.Subject) userID, err := strconv.Atoi(claims.Subject)
if err != nil || userID != identity.UserID || claims.SessionID != identity.SessionID || claims.UserAuthVersion != identity.UserAuthVersion || claims.SessionVersion != identity.SessionVersion { if err != nil || userID != identity.UserID || claims.SessionID != identity.SessionID || claims.UserAuthVersion != identity.UserAuthVersion || claims.SessionVersion != identity.SessionVersion {
return "", ErrAuthTokenInvalid return nil, ErrAuthTokenInvalid
} }
methodAllowed := len(allowedMethods) == 0 if len(claims.Scopes) != 1 || claims.Scopes[0] != binding.Scope {
for _, method := range allowedMethods { return nil, ErrProofScope
if hmac.Equal([]byte(claims.Method), []byte(method)) {
methodAllowed = true
break
}
} }
if !methodAllowed { if claims.ContextHash == "" {
return "", ErrProofMethod return nil, ErrAuthTokenInvalid
} }
if requiredScope != "" { if !hmac.Equal([]byte(claims.ContextHash), []byte(binding.ContextHash)) {
found := false return nil, ErrProofContext
for _, scope := range claims.Scopes {
if hmac.Equal([]byte(scope), []byte(requiredScope)) {
found = true
break
}
}
if !found {
return "", ErrProofScope
}
} }
return claims.Method, nil return claims, nil
} }
func parseAuthClaims(raw, expectedUse string, key []byte) (*authClaims, error) { func parseAuthClaims(raw, expectedUse string, key []byte) (*authClaims, error) {
......
...@@ -19,6 +19,7 @@ func useTestSessionSecret(t *testing.T) { ...@@ -19,6 +19,7 @@ func useTestSessionSecret(t *testing.T) {
} }
func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) { func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t) useTestSessionSecret(t)
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2} identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
...@@ -30,7 +31,9 @@ func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) { ...@@ -30,7 +31,9 @@ func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, identity, parsed) assert.Equal(t, identity, parsed)
proof, _, err := IssueSecurityProof(identity, "2fa", []string{"channel.key.read"}) binding, err := BindVerificationOperation(VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)})
require.NoError(t, err)
proof, _, err := IssueSecurityProof(identity, "2fa", binding)
require.NoError(t, err) require.NoError(t, err)
_, err = ParseAccessToken(proof) _, err = ParseAccessToken(proof)
assert.ErrorIs(t, err, ErrAuthTokenInvalid) assert.ErrorIs(t, err, ErrAuthTokenInvalid)
...@@ -57,6 +60,7 @@ func TestAccessTokenRejectsTampering(t *testing.T) { ...@@ -57,6 +60,7 @@ func TestAccessTokenRejectsTampering(t *testing.T) {
} }
func TestDashboardAccessTokenClassification(t *testing.T) { func TestDashboardAccessTokenClassification(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t) useTestSessionSecret(t)
identity, internal, err := ParseDashboardAccessToken("opaque.key.with-dots") identity, internal, err := ParseDashboardAccessToken("opaque.key.with-dots")
...@@ -87,9 +91,11 @@ func TestDashboardAccessTokenClassification(t *testing.T) { ...@@ -87,9 +91,11 @@ func TestDashboardAccessTokenClassification(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.False(t, internal) assert.False(t, internal)
binding, err := BindVerificationOperation(VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)})
require.NoError(t, err)
proof, _, err := IssueSecurityProof(AuthIdentity{ proof, _, err := IssueSecurityProof(AuthIdentity{
UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1, UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1,
}, "2fa", []string{"channel.key.read"}) }, "2fa", binding)
require.NoError(t, err) require.NoError(t, err)
_, internal, err = ParseDashboardAccessToken(proof) _, internal, err = ParseDashboardAccessToken(proof)
assert.True(t, internal) assert.True(t, internal)
...@@ -117,24 +123,40 @@ func TestDashboardAccessTokenClassification(t *testing.T) { ...@@ -117,24 +123,40 @@ func TestDashboardAccessTokenClassification(t *testing.T) {
assert.ErrorIs(t, err, ErrAuthTokenExpired) assert.ErrorIs(t, err, ErrAuthTokenExpired)
} }
func TestSecurityProofBindsIdentityMethodAndScope(t *testing.T) { func TestSecurityProofBindsIdentityAndOperation(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t) useTestSessionSecret(t)
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2} identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
proof, _, err := IssueSecurityProof(identity, "2fa", []string{"channel.key.read"}) operation := VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":123}`)}
binding, err := BindVerificationOperation(operation)
require.NoError(t, err)
proof, _, err := IssueSecurityProof(identity, "2fa", binding)
require.NoError(t, err) require.NoError(t, err)
method, err := VerifySecurityProof(proof, identity, "channel.key.read", []string{"2fa", "passkey"}) claims, err := verifySecurityProof(proof, identity, binding)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "2fa", method) assert.Equal(t, "2fa", claims.Method)
_, err = VerifySecurityProof(proof, identity, "passkey.delete", []string{"2fa"}) wrongScope, err := BindVerificationOperation(VerificationOperation{Scope: "passkey.delete"})
require.NoError(t, err)
_, err = verifySecurityProof(proof, identity, wrongScope)
assert.ErrorIs(t, err, ErrProofScope) assert.ErrorIs(t, err, ErrProofScope)
_, err = VerifySecurityProof(proof, identity, "channel.key.read", []string{"passkey"}) otherChannel, err := BindVerificationOperation(VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":456}`)})
assert.ErrorIs(t, err, ErrProofMethod) require.NoError(t, err)
_, err = verifySecurityProof(proof, identity, otherChannel)
assert.ErrorIs(t, err, ErrProofContext)
otherSession := identity otherSession := identity
otherSession.SessionID = "session-2" otherSession.SessionID = "session-2"
_, err = VerifySecurityProof(proof, otherSession, "channel.key.read", []string{"2fa"}) _, err = verifySecurityProof(proof, otherSession, binding)
assert.True(t, errors.Is(err, ErrAuthTokenInvalid)) assert.True(t, errors.Is(err, ErrAuthTokenInvalid))
claims, err = parseAuthClaims(proof, securityProofTokenUse, authSigningKey(securityProofTokenUse))
require.NoError(t, err)
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-time.Minute))
expired, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(securityProofTokenUse))
require.NoError(t, err)
_, err = ConsumeOperationProof(expired, identity, operation)
assert.ErrorIs(t, err, ErrAuthTokenExpired)
} }
package service
import "errors"
var ErrOAuthAccountMismatch = errors.New("The OAuth account does not match the account linked to your profile.")
type OAuthVerificationFlow struct {
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
ProviderUserID string `json:"provider_user_id"`
UserAuthVersion int64 `json:"auth_version"`
SessionVersion int64 `json:"session_version"`
}
func StartOAuthVerification(identity AuthIdentity, operation VerificationOperation, provider string) (*OAuthVerificationFlow, error) {
binding, err := BindVerificationOperation(operation)
if err != nil {
return nil, err
}
providerUserID, err := GetOAuthVerificationBinding(identity, binding.Scope, provider)
if err != nil {
return nil, err
}
return &OAuthVerificationFlow{
Scope: binding.Scope, ContextHash: binding.ContextHash, ProviderUserID: providerUserID,
UserAuthVersion: identity.UserAuthVersion, SessionVersion: identity.SessionVersion,
}, nil
}
// FinishOAuthVerification only verifies an existing binding. It deliberately
// never calls OAuth's login or find-or-create-user paths.
func FinishOAuthVerification(identity AuthIdentity, provider, actualUserID string, flow *OAuthVerificationFlow) (*SecurityProof, error) {
if flow == nil || flow.ContextHash == "" || flow.UserAuthVersion != identity.UserAuthVersion || flow.SessionVersion != identity.SessionVersion {
return nil, ErrAuthTokenInvalid
}
expectedUserID, err := GetOAuthVerificationBinding(identity, flow.Scope, provider)
if err != nil {
return nil, err
}
if actualUserID == "" || actualUserID != expectedUserID || actualUserID != flow.ProviderUserID {
return nil, ErrOAuthAccountMismatch
}
return CompleteSecurityVerification(identity, VerificationBinding{Scope: flow.Scope, ContextHash: flow.ContextHash}, VerificationMethodOAuth)
}
...@@ -8,30 +8,42 @@ import ( ...@@ -8,30 +8,42 @@ import (
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
webauthn "github.com/go-webauthn/webauthn/webauthn" webauthn "github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
) )
var errSessionNotFound = errors.New("Passkey 会话不存在或已过期")
const passkeyFlowTTL = 5 * time.Minute const passkeyFlowTTL = 5 * time.Minute
type flowPayload struct { type flowPayload struct {
SessionData webauthn.SessionData `json:"session_data"` SessionData webauthn.SessionData `json:"session_data"`
Scope string `json:"scope,omitempty"` Security FlowSecurity `json:"security"`
}
type FlowSecurity struct {
model.AuthSessionIdentity
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
} }
func CreateSessionDataFlow(purpose string, userID int, sessionID, scope string, data *webauthn.SessionData) (string, int64, error) { func CreateSessionDataFlow(purpose string, security FlowSecurity, data *webauthn.SessionData) (string, int64, error) {
if data == nil { if data == nil {
return "", 0, errors.New("Passkey 会话数据不能为空") return "", 0, errors.New("Passkey 会话数据不能为空")
} }
payload, err := common.Marshal(flowPayload{SessionData: *data, Scope: scope}) if purpose != model.AuthFlowPurposePasskeyLogin && (security.UserID <= 0 || security.SessionID == "" || security.Scope == "" || security.ContextHash == "" || security.UserAuthVersion <= 0 || security.SessionVersion <= 0) {
return "", 0, model.ErrAuthFlowInvalid
}
if purpose == model.AuthFlowPurposePasskeyRegister && (security.Authorization == nil || security.Authorization.ProofID <= 0 || security.Authorization.AuthSessionIdentity != security.AuthSessionIdentity) {
return "", 0, model.ErrAuthFlowInvalid
}
payload, err := common.Marshal(flowPayload{SessionData: *data, Security: security})
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
expiresAt := time.Now().Add(passkeyFlowTTL) expiresAt := time.Now().Add(passkeyFlowTTL)
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: purpose, Purpose: purpose,
UserId: userID, UserId: security.UserID,
SessionId: sessionID, SessionId: security.SessionID,
Payload: string(payload), Payload: string(payload),
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
}) })
...@@ -41,21 +53,30 @@ func CreateSessionDataFlow(purpose string, userID int, sessionID, scope string, ...@@ -41,21 +53,30 @@ func CreateSessionDataFlow(purpose string, userID int, sessionID, scope string,
return token, expiresAt.Unix(), nil return token, expiresAt.Unix(), nil
} }
func PopSessionDataFlow(token, purpose string, userID int, sessionID string) (*webauthn.SessionData, string, error) { func PopSessionDataFlow(token, purpose string, identity model.AuthSessionIdentity) (*webauthn.SessionData, *FlowSecurity, error) {
flow, err := model.ConsumeAuthFlow(token, model.AuthFlowMatch{ var payload flowPayload
_, err := model.ConsumeAuthFlowWithAction(token, model.AuthFlowMatch{
Purpose: purpose, Purpose: purpose,
UserId: userID, UserId: identity.UserID,
SessionId: sessionID, SessionId: identity.SessionID,
}, func(tx *gorm.DB, flow *model.AuthFlow) error {
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return err
}
if purpose == model.AuthFlowPurposePasskeyLogin {
return nil
}
security := payload.Security
if security.AuthSessionIdentity != identity || security.Scope == "" || security.ContextHash == "" {
return model.ErrAuthFlowInvalid
}
if purpose == model.AuthFlowPurposePasskeyRegister && (security.Authorization == nil || security.Authorization.ProofID <= 0 || security.Authorization.AuthSessionIdentity != identity) {
return model.ErrAuthFlowInvalid
}
return model.ValidateAuthSessionWithTx(tx, identity)
}) })
if err != nil { if err != nil {
if errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowExpired) || errors.Is(err, model.ErrAuthFlowConsumed) { return nil, nil, err
return nil, "", errSessionNotFound
}
return nil, "", err
}
var payload flowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return nil, "", err
} }
return &payload.SessionData, payload.Scope, nil return &payload.SessionData, &payload.Security, nil
} }
package service
import (
"errors"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
type TwoFASetup struct {
Secret string `json:"secret"`
QRCodeData string `json:"qr_code_data"`
BackupCodes []string `json:"backup_codes"`
FlowToken string `json:"flow_token"`
ExpiresAt int64 `json:"expires_at"`
}
func StartTwoFASetup(identity AuthIdentity, authorization *model.AuthFlowAuthorization) (*TwoFASetup, error) {
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeTwoFASetup}, authorization); err != nil {
return nil, err
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
return nil, err
}
key, err := common.GenerateTOTPSecret(user.Username)
if err != nil {
return nil, err
}
codes, err := common.GenerateBackupCodes()
if err != nil {
return nil, err
}
expiresAt := time.Now().Add(5 * time.Minute)
flowToken, err := model.CreateTwoFAEnrollment(identity, authorization, key.Secret(), codes, expiresAt)
if err != nil {
return nil, err
}
return &TwoFASetup{Secret: key.Secret(), QRCodeData: common.GenerateQRCodeData(key.Secret(), user.Username), BackupCodes: codes, FlowToken: flowToken, ExpiresAt: expiresAt.Unix()}, nil
}
func FinishTwoFASetup(identity AuthIdentity, flowToken, code string) error {
flow, err := model.GetAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTwoFASetup, UserId: identity.UserID, SessionId: identity.SessionID,
})
if errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowExpired) || errors.Is(err, model.ErrAuthFlowConsumed) {
return model.ErrTwoFASetupInvalid
}
if err != nil {
return err
}
var payload struct {
Authorization *model.AuthFlowAuthorization `json:"authorization"`
}
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return err
}
if err := ValidateFlowAuthorization(identity, VerificationOperation{Scope: VerificationScopeTwoFASetup}, payload.Authorization); err != nil {
if errors.Is(err, model.ErrAuthFlowInvalid) {
return model.ErrTwoFASetupInvalid
}
return err
}
return model.EnableTwoFAEnrollment(identity, flowToken, code)
}
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
| 图表 | @visactor/vchart、@visactor/react-vchart | | 图表 | @visactor/vchart、@visactor/react-vchart |
| 工具 | qrcode.react、oxfmt、oxlint、vitest(可选) | | 工具 | qrcode.react、oxfmt、oxlint、vitest(可选) |
优先选用成熟、维护良好的开源库;仅在现有库无法满足或需特殊适配时自行实现,并评估可维护性与通用性 优先复用项目已有组件与能力(见 [3.3 组件](#33-组件));项目内无合适实现时,再评估已安装依赖及成熟、维护良好的开源库。仅在复用、组合或合理扩展仍无法满足需求时自行实现,并说明具体能力缺口
--- ---
...@@ -81,6 +81,26 @@ ...@@ -81,6 +81,26 @@
### 3.3 组件 ### 3.3 组件
**先检索,再复用,最后才新增(强制)**
- **开发前检索**:新增或修改 UI 前,必须读取项目 `shadcn-ui` 技能,使用 `rg --files` / `rg` 检索 `src/components/` 和相关 `src/features/`;涉及通用交互时同时检查 `src/hooks/`。按用途和行为查找,不能只查准备新增的组件名。阅读候选组件的实现、props 和实际调用示例后再确定方案。
- **复用顺序**:优先使用满足场景的项目业务封装;能力不足时,先评估现有 props、插槽、组合方式或兼容扩展。业务封装确实不适用时,再用 `src/components/ui/` 中已有基础组件组合。项目内仍有能力缺口时,才评估引入组件或新增实现;查外部 registry 不能替代项目内检索。
- **复用到行为层**:仅使用 `Button``Dialog``AlertDialog` 等基础组件,不代表已经复用了对应的复制、确认、弹窗布局等通用能力。已有业务封装能覆盖时,必须使用该封装,禁止在 feature 内重新拼装同一套交互、状态和样式。
- **新增条件**:新增替代实现前,必须明确候选组件缺少的具体能力,并在变更说明或 PR 中记录候选路径及不能复用、组合或兼容扩展的原因。文案、图标、尺寸、颜色或所在页面不同,不构成重复实现的理由;优先通过已有 API 和主题约定处理这些差异。
- **合理组合**:允许新增承载业务数据、权限、事件和内容的 feature 组件,但其中的通用 UI 与交互必须继续复用。扩展公共组件需保持已有调用行为,不能为单个页面塞入无关业务逻辑,也不能为消除少量相似代码建立过度通用的抽象。
- **基础元素边界**:业务代码中,已有基础组件覆盖的按钮、输入框、选择器、弹层等控件,必须使用项目组件,不能手写同等控件或直接绕过封装调用底层库。普通语义化布局、隐藏字段及现有组件无法表达的特殊控件可使用原生元素;特殊控件仍须说明能力缺口并满足可访问性要求。
以下为常用检索入口,不能把本表当作完整组件清单:
| 场景 | 优先检查的项目入口 |
| --- | --- |
| 通用弹窗布局 | `@/components/dialog` |
| 删除、危险操作及普通确认 | `@/components/confirm-dialog` |
| 复制按钮与剪贴板交互 | `@/components/copy-button``@/hooks/use-copy-to-clipboard` |
| 空状态、加载状态、错误状态 | `@/components/empty-state``@/components/loading-state``@/components/error-state` |
| 表格、分页、工具栏及列表布局 | `@/components/data-table`,先读该目录的 `README.md` 和公开导出 |
| 按钮、输入、选择、提示等基础控件 | `@/components/ui/`,以 `components.json` 和本地实现为准 |
- 使用函数式组件与 Hooks,单一职责;组件 props 须有明确类型(接口或类型别名)。 - 使用函数式组件与 Hooks,单一职责;组件 props 须有明确类型(接口或类型别名)。
- **Props 使用**:组件 props 非必要不要解构,直接使用 `props.xxx` 访问属性,保持代码清晰(详见 [3.2 代码风格与类型](#32-代码风格与类型))。 - **Props 使用**:组件 props 非必要不要解构,直接使用 `props.xxx` 访问属性,保持代码清晰(详见 [3.2 代码风格与类型](#32-代码风格与类型))。
- 单文件超过约 200 行时考虑拆分子组件或将逻辑抽到自定义 Hooks;类型定义可与组件同文件或放在同模块的 `types` 中。 - 单文件超过约 200 行时考虑拆分子组件或将逻辑抽到自定义 Hooks;类型定义可与组件同文件或放在同模块的 `types` 中。
...@@ -182,6 +202,7 @@ ...@@ -182,6 +202,7 @@
- 提交信息清晰、符合项目约定,描述变更内容与原因,中英文统一即可。 - 提交信息清晰、符合项目约定,描述变更内容与原因,中英文统一即可。
- 变更需经过代码审查,符合本文档规范,并关注质量、性能与安全。 - 变更需经过代码审查,符合本文档规范,并关注质量、性能与安全。
- UI 变更完成前必须检查新增组件、基础组件导入及手写交互是否绕过已有业务封装;发现重复实现应在本次变更范围内改为复用。保留的新实现需在变更说明中说明能力缺口,typecheck、lint 和测试通过不能替代此项检查。
- 重大功能或规范变更时更新相关文档与 `AGENTS.md` - 重大功能或规范变更时更新相关文档与 `AGENTS.md`
--- ---
...@@ -193,3 +214,4 @@ ...@@ -193,3 +214,4 @@
- **2026-01-29**:重组文档结构,合并重复内容,明确主次与交叉引用。 - **2026-01-29**:重组文档结构,合并重复内容,明确主次与交叉引用。
- **2026-01-31**:在 3.2 中补充「类型检查」要求:改动 TS/TSX 后须执行 typecheck 并修复至无错。 - **2026-01-31**:在 3.2 中补充「类型检查」要求:改动 TS/TSX 后须执行 typecheck 并修复至无错。
- **2026-06-21**:在 3.2 中补充「Lint 检查」要求:完成代码改动前须修复所涉及文件的所有 lint error。 - **2026-06-21**:在 3.2 中补充「Lint 检查」要求:完成代码改动前须修复所涉及文件的所有 lint error。
- **2026-09-06**:明确组件复用的强制检索流程、业务封装优先级、新增条件、常用入口及审查要求。
...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import axios from 'axios' import axios from 'axios'
import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api' import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api'
import { AuthOperationError } from '@/lib/secure-verification'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { import {
...@@ -27,6 +28,7 @@ import { ...@@ -27,6 +28,7 @@ import {
} from './lib/password-encryption' } from './lib/password-encryption'
import { getAffiliateCode } from './lib/storage' import { getAffiliateCode } from './lib/storage'
import type { TelegramAuthorization } from './lib/telegram-login' import type { TelegramAuthorization } from './lib/telegram-login'
import type { VerificationOperation } from './secure-verification/types'
import type { import type {
LoginPayload, LoginPayload,
LoginResponse, LoginResponse,
...@@ -166,13 +168,26 @@ export async function githubOAuthStart(clientId: string, state: string) { ...@@ -166,13 +168,26 @@ export async function githubOAuthStart(clientId: string, state: string) {
// Get OAuth state for CSRF protection // Get OAuth state for CSRF protection
export async function createOAuthFlow( export async function createOAuthFlow(
provider: string, provider: string,
intent: 'login' | 'bind' intent: 'login' | 'bind' | 'verify',
operation?: VerificationOperation,
signal?: AbortSignal
): Promise<string> { ): Promise<string> {
const aff = intent === 'login' ? getAffiliateCode() : '' const aff = intent === 'login' ? getAffiliateCode() : ''
const res = await api.post( const res = await api.post(
'/api/oauth/state', '/api/oauth/state',
{ provider, intent, aff: aff || undefined }, {
{ skipAuthRefresh: intent === 'login' } provider,
intent,
aff: aff || undefined,
scope: operation?.scope,
...(operation?.context ? { context: operation.context } : {}),
},
{
skipAuthRefresh: intent === 'login',
signal,
skipBusinessError: true,
skipErrorHandler: true,
}
) )
if (res.data?.success) { if (res.data?.success) {
if (typeof res.data.data === 'string') return res.data.data if (typeof res.data.data === 'string') return res.data.data
...@@ -180,7 +195,10 @@ export async function createOAuthFlow( ...@@ -180,7 +195,10 @@ export async function createOAuthFlow(
return res.data.data.flow_token return res.data.data.flow_token
} }
} }
throw new Error(res.data?.message || 'Failed to initialize OAuth') throw new AuthOperationError(
res.data?.message || 'Failed to initialize OAuth',
res.data?.code
)
} }
// WeChat login by authorization code // WeChat login by authorization code
......
...@@ -25,7 +25,7 @@ import { AuthLayout } from '../auth-layout' ...@@ -25,7 +25,7 @@ import { AuthLayout } from '../auth-layout'
type OAuthCallbackScreenProps = { type OAuthCallbackScreenProps = {
provider: string provider: string
mode: 'login' | 'bind' mode: 'login' | 'bind' | 'verify'
} }
type ProviderMeta = { type ProviderMeta = {
...@@ -72,23 +72,30 @@ export function OAuthCallbackScreen({ ...@@ -72,23 +72,30 @@ export function OAuthCallbackScreen({
}, [provider]) }, [provider])
const providerLabel = t(label) const providerLabel = t(label)
const isBindMode = mode === 'bind' let headline = t('Signing you in with {{provider}}', {
provider: providerLabel,
const headline = isBindMode })
? t('Binding your {{provider}} account', { provider: providerLabel }) let description = t('Hang tight while we finish connecting your account.')
: t('Signing you in with {{provider}}', { provider: providerLabel }) let secondaryNote = t(
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds."
const description = isBindMode )
? t('Hang tight while we securely link this account to your profile.') if (mode === 'bind') {
: t('Hang tight while we finish connecting your account.') headline = t('Binding your {{provider}} account', {
provider: providerLabel,
const secondaryNote = isBindMode })
? t( description = t(
'You can close this tab once the binding completes or a success message appears in the original window.' 'Hang tight while we securely link this account to your profile.'
) )
: t( }
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds." if (mode === 'verify') {
) headline = t('Verifying your {{provider}} account', {
provider: providerLabel,
})
description = t('Confirming the account linked to your profile.')
}
if (mode !== 'login') {
secondaryNote = t('Return to the original window to continue.')
}
return ( return (
<AuthLayout> <AuthLayout>
......
...@@ -75,6 +75,6 @@ export const PASSWORD_RESET_COUNTDOWN = 30 // seconds ...@@ -75,6 +75,6 @@ export const PASSWORD_RESET_COUNTDOWN = 30 // seconds
// OAuth Constants // OAuth Constants
// ============================================================================ // ============================================================================
export const OAUTH_BIND_CALLBACK_MESSAGE = 'oauth:binding:callback' export const OAUTH_POPUP_CALLBACK_MESSAGE = 'oauth:popup:callback'
export const OAUTH_BIND_RESULT_MESSAGE = 'oauth:binding:result' export const OAUTH_POPUP_RESULT_MESSAGE = 'oauth:popup:result'
export const TELEGRAM_BIND_RESULT_MESSAGE = 'telegram:binding:result' export const TELEGRAM_BIND_RESULT_MESSAGE = 'telegram:binding:result'
...@@ -20,7 +20,7 @@ import { describe, expect, test } from 'vitest' ...@@ -20,7 +20,7 @@ import { describe, expect, test } from 'vitest'
import { import {
getOAuthSessionStorage, getOAuthSessionStorage,
markOAuthBindPopup, markOAuthPopup,
resolveOAuthCallbackMode, resolveOAuthCallbackMode,
type OAuthModeStorage, type OAuthModeStorage,
} from '../oauth-callback-mode' } from '../oauth-callback-mode'
...@@ -39,7 +39,7 @@ const bindState = 'bind-state' ...@@ -39,7 +39,7 @@ const bindState = 'bind-state'
describe('resolveOAuthCallbackMode', () => { describe('resolveOAuthCallbackMode', () => {
test('matching provider and state mark is treated as a bind flow', () => { test('matching provider and state mark is treated as a bind flow', () => {
const storage = fakeStorage() const storage = fakeStorage()
expect(markOAuthBindPopup(storage, 'oidc', bindState)).toBe(true) expect(markOAuthPopup(storage, 'oidc', bindState, 'bind')).toBe(true)
expect( expect(
resolveOAuthCallbackMode('oidc', bindState, { resolveOAuthCallbackMode('oidc', bindState, {
...@@ -49,6 +49,25 @@ describe('resolveOAuthCallbackMode', () => { ...@@ -49,6 +49,25 @@ describe('resolveOAuthCallbackMode', () => {
).toBe('bind') ).toBe('bind')
}) })
test('verification markers cannot be confused with account binding', () => {
const storage = fakeStorage()
expect(
markOAuthPopup(storage, 'oidc', 'verification-state', 'verify')
).toBe(true)
expect(
resolveOAuthCallbackMode('oidc', 'verification-state', {
opener: openOpener,
storage,
})
).toBe('verify')
expect(
resolveOAuthCallbackMode('oidc', bindState, {
opener: openOpener,
storage,
})
).toBe('login')
})
// Regression: a tab opened from an external link (Slack, e-mail, another // Regression: a tab opened from an external link (Slack, e-mail, another
// site) keeps a live window.opener across the cross-origin round trip to the // site) keeps a live window.opener across the cross-origin round trip to the
// identity provider. Treating that opener as proof of a bind flow made every // identity provider. Treating that opener as proof of a bind flow made every
...@@ -66,7 +85,7 @@ describe('resolveOAuthCallbackMode', () => { ...@@ -66,7 +85,7 @@ describe('resolveOAuthCallbackMode', () => {
test('bind marker for another provider does not hijack this callback', () => { test('bind marker for another provider does not hijack this callback', () => {
const storage = fakeStorage() const storage = fakeStorage()
markOAuthBindPopup(storage, 'github', bindState) markOAuthPopup(storage, 'github', bindState, 'bind')
expect( expect(
resolveOAuthCallbackMode('oidc', bindState, { resolveOAuthCallbackMode('oidc', bindState, {
...@@ -78,7 +97,7 @@ describe('resolveOAuthCallbackMode', () => { ...@@ -78,7 +97,7 @@ describe('resolveOAuthCallbackMode', () => {
test('stale bind marker does not hijack a later callback', () => { test('stale bind marker does not hijack a later callback', () => {
const storage = fakeStorage() const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', 'previous-state') markOAuthPopup(storage, 'oidc', 'previous-state', 'bind')
expect( expect(
resolveOAuthCallbackMode('oidc', bindState, { resolveOAuthCallbackMode('oidc', bindState, {
...@@ -90,7 +109,7 @@ describe('resolveOAuthCallbackMode', () => { ...@@ -90,7 +109,7 @@ describe('resolveOAuthCallbackMode', () => {
test('bind marker without an opener falls back to login', () => { test('bind marker without an opener falls back to login', () => {
const storage = fakeStorage() const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', bindState) markOAuthPopup(storage, 'oidc', bindState, 'bind')
expect( expect(
resolveOAuthCallbackMode('oidc', bindState, { resolveOAuthCallbackMode('oidc', bindState, {
...@@ -102,7 +121,7 @@ describe('resolveOAuthCallbackMode', () => { ...@@ -102,7 +121,7 @@ describe('resolveOAuthCallbackMode', () => {
test('closed opener falls back to login', () => { test('closed opener falls back to login', () => {
const storage = fakeStorage() const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', bindState) markOAuthPopup(storage, 'oidc', bindState, 'bind')
expect( expect(
resolveOAuthCallbackMode('oidc', bindState, { resolveOAuthCallbackMode('oidc', bindState, {
...@@ -157,16 +176,17 @@ describe('OAuth bind popup storage', () => { ...@@ -157,16 +176,17 @@ describe('OAuth bind popup storage', () => {
}, },
} }
expect(markOAuthBindPopup(null, 'oidc', bindState)).toBe(false) expect(markOAuthPopup(null, 'oidc', bindState, 'bind')).toBe(false)
expect(markOAuthBindPopup(storage, 'oidc', bindState)).toBe(false) expect(markOAuthPopup(storage, 'oidc', bindState, 'bind')).toBe(false)
expect( expect(
markOAuthBindPopup( markOAuthPopup(
{ {
getItem: () => null, getItem: () => null,
setItem: () => undefined, setItem: () => undefined,
}, },
'oidc', 'oidc',
bindState bindState,
'bind'
) )
).toBe(false) ).toBe(false)
}) })
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { afterEach, expect, it, vi } from 'vitest'
import { AuthOperationError } from '@/lib/secure-verification'
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants'
import { openOAuthPopup } from '../oauth-popup'
function popupWindow() {
const storage = new Map<string, string>()
const popup = {
closed: false,
location: { replace: vi.fn() },
sessionStorage: {
setItem: (key: string, value: string) => storage.set(key, value),
getItem: (key: string) => storage.get(key) ?? null,
},
close: vi.fn(() => {
popup.closed = true
}),
postMessage: vi.fn(),
}
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
return popup
}
function callbackMessage(
popup: unknown,
overrides: Record<string, unknown> = {},
origin = window.location.origin
) {
const event = new MessageEvent('message', {
origin,
data: {
type: OAUTH_POPUP_CALLBACK_MESSAGE,
intent: 'verify',
provider: 'github',
state: 'state',
code: 'code',
...overrides,
},
})
Object.defineProperty(event, 'source', { value: popup })
window.dispatchEvent(event)
}
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('accepts only the matching popup, origin, intent, provider and state', async () => {
const popup = popupWindow()
const controller = new AbortController()
const prepared = Promise.resolve({
state: 'state',
url: 'https://example.com/authorize',
})
const resolved = vi.fn()
const result = openOAuthPopup({
provider: 'github',
intent: 'verify',
signal: controller.signal,
prepare: () => prepared,
}).then((exchange) => {
resolved(exchange)
return exchange
})
await prepared
expect(popup.location.replace).toHaveBeenCalledWith(
'https://example.com/authorize'
)
callbackMessage({}, {})
callbackMessage(popup, {}, 'https://untrusted.example')
callbackMessage(popup, { intent: 'bind' })
callbackMessage(popup, { provider: 'discord' })
callbackMessage(popup, { state: 'old-state' })
await Promise.resolve()
expect(resolved).not.toHaveBeenCalled()
callbackMessage(popup)
callbackMessage(popup)
const exchange = await result
expect(resolved).toHaveBeenCalledTimes(1)
expect(exchange.callback).toEqual({
provider: 'github',
state: 'state',
code: 'code',
error: undefined,
errorDescription: undefined,
})
exchange.finish({ success: true })
expect(popup.closed).toBe(true)
})
it('closes an aborted popup and ignores a late authorization response', async () => {
const popup = popupWindow()
const controller = new AbortController()
let complete!: (value: { state: string; url: string }) => void
const prepared = new Promise<{ state: string; url: string }>((resolve) => {
complete = resolve
})
const result = openOAuthPopup({
provider: 'github',
intent: 'bind',
signal: controller.signal,
prepare: () => prepared,
})
const rejected = expect(result).rejects.toBeInstanceOf(AuthOperationError)
controller.abort()
await rejected
complete({ state: 'state', url: 'https://example.com/authorize' })
await prepared
expect(popup.closed).toBe(true)
expect(popup.location.replace).not.toHaveBeenCalled()
})
it('aborts the callback request if the user closes the popup before it finishes', async () => {
vi.useFakeTimers()
const popup = popupWindow()
const prepared = Promise.resolve({
state: 'state',
url: 'https://example.com/authorize',
})
const result = openOAuthPopup({
provider: 'github',
intent: 'verify',
signal: new AbortController().signal,
prepare: () => prepared,
})
await prepared
callbackMessage(popup)
const exchange = await result
popup.closed = true
await vi.advanceTimersByTimeAsync(500)
expect(exchange.signal.aborted).toBe(true)
expect(exchange.signal.reason).toMatchObject({ code: 'AUTH_CANCELLED' })
expect(vi.getTimerCount()).toBe(0)
})
it('reports a blocked popup without starting authorization', async () => {
vi.spyOn(window, 'open').mockReturnValue(null)
const prepare = vi.fn()
await expect(
openOAuthPopup({
provider: 'github',
intent: 'verify',
signal: new AbortController().signal,
prepare,
})
).rejects.toThrow('OAuth pop-up was blocked')
expect(prepare).not.toHaveBeenCalled()
})
it('times out an unfinished authorization and clears its listeners and timers', async () => {
vi.useFakeTimers()
const popup = popupWindow()
const prepared = Promise.resolve({
state: 'state',
url: 'https://example.com/authorize',
})
const result = openOAuthPopup({
provider: 'github',
intent: 'verify',
signal: new AbortController().signal,
prepare: () => prepared,
})
const rejected = expect(result).rejects.toThrow(
'OAuth authorization timed out. Please try again.'
)
await prepared
await vi.advanceTimersByTimeAsync(10 * 60_000)
await rejected
expect(popup.closed).toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
...@@ -17,25 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,25 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
/** const OAUTH_POPUP_FLOW_KEY_PREFIX = 'oauth_popup_flow:'
* Tells apart the two OAuth callbacks that land on the same `/oauth/:provider`
* route: an account **bind**, which runs inside a popup we opened, and a plain
* **login** redirect, which runs in the user's own tab.
*
* `window.opener` alone cannot make that call. Any tab opened from an external
* link (`target="_blank"`, Slack, mail clients, another site) carries a live
* opener, and that opener survives the cross-origin round trip to the identity
* provider. Such a login callback used to be misread as a bind, so it posted a
* handshake to a window that speaks no such protocol and sat on the binding
* screen until the deadline elapsed.
*
* The popup we open for a bind is same-origin (`about:blank`) before it is sent
* to the provider, so we stamp its own sessionStorage. That stamp rides along
* through the provider round trip and is scoped to the popup alone, which makes
* it positive proof of a bind flow.
*/
const OAUTH_BIND_FLOW_KEY_PREFIX = 'oauth_bind_flow:'
/** Minimal shape of `sessionStorage`, kept structural so tests can fake it. */ /** Minimal shape of `sessionStorage`, kept structural so tests can fake it. */
export interface OAuthModeStorage { export interface OAuthModeStorage {
...@@ -58,7 +40,7 @@ export interface OAuthCallbackModeContext { ...@@ -58,7 +40,7 @@ export interface OAuthCallbackModeContext {
storage: OAuthModeStorage | null | undefined storage: OAuthModeStorage | null | undefined
} }
export type OAuthCallbackMode = 'login' | 'bind' export type OAuthCallbackMode = 'login' | 'bind' | 'verify'
/** /**
* Access `sessionStorage` without letting browser privacy settings crash the * Access `sessionStorage` without letting browser privacy settings crash the
...@@ -75,20 +57,22 @@ export function getOAuthSessionStorage( ...@@ -75,20 +57,22 @@ export function getOAuthSessionStorage(
} }
/** /**
* Stamp a freshly opened, still same-origin popup as an OAuth bind flow. * Stamp a freshly opened, still same-origin popup as an OAuth popup flow.
* Call this before navigating the popup to the provider. * Call this before navigating the popup to the provider.
*/ */
export function markOAuthBindPopup( export function markOAuthPopup(
storage: OAuthModeStorage | null | undefined, storage: OAuthModeStorage | null | undefined,
provider: string, provider: string,
state: string state: string,
intent: 'bind' | 'verify'
): boolean { ): boolean {
if (!storage || !provider || !state) return false if (!storage || !provider || !state) return false
try { try {
const key = `${OAUTH_BIND_FLOW_KEY_PREFIX}${provider}` const key = `${OAUTH_POPUP_FLOW_KEY_PREFIX}${provider}`
storage.setItem(key, state) const marker = JSON.stringify({ state, intent })
return storage.getItem(key) === state storage.setItem(key, marker)
return storage.getItem(key) === marker
} catch { } catch {
return false return false
} }
...@@ -109,12 +93,24 @@ export function resolveOAuthCallbackMode( ...@@ -109,12 +93,24 @@ export function resolveOAuthCallbackMode(
): OAuthCallbackMode { ): OAuthCallbackMode {
if (!opener || opener.closed || !storage || !state) return 'login' if (!opener || opener.closed || !storage || !state) return 'login'
let markedState: string | null = null
try { try {
markedState = storage.getItem(`${OAUTH_BIND_FLOW_KEY_PREFIX}${provider}`) const value = storage.getItem(`${OAUTH_POPUP_FLOW_KEY_PREFIX}${provider}`)
if (!value) return 'login'
const marker: unknown = JSON.parse(value)
if (
!marker ||
typeof marker !== 'object' ||
!('state' in marker) ||
!('intent' in marker)
) {
return 'login'
}
if (marker.state !== state) return 'login'
if (marker.intent === 'bind' || marker.intent === 'verify') {
return marker.intent
}
} catch { } catch {
return 'login' return 'login'
} }
return 'login'
return markedState === state ? 'bind' : 'login'
} }
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AuthOperationError } from '@/lib/secure-verification'
import {
OAUTH_POPUP_CALLBACK_MESSAGE,
OAUTH_POPUP_RESULT_MESSAGE,
} from '../constants'
import { watchOAuthPopupClosed } from './oauth-bind-window'
import { getOAuthSessionStorage, markOAuthPopup } from './oauth-callback-mode'
export interface OAuthPopupCallback {
provider: string
state: string
code?: string
error?: string
errorDescription?: string
}
export interface OAuthPopupExchange {
callback: OAuthPopupCallback
signal: AbortSignal
finish: (result: { success: boolean; message?: string }) => void
}
interface OAuthPopupOptions {
provider: string
intent: 'bind' | 'verify'
signal: AbortSignal
prepare: (signal: AbortSignal) => Promise<{ state: string; url: string }>
}
// Window transport only: account binding and security verification each submit
// their own authenticated callback request after receiving the exchange.
export function openOAuthPopup(
options: OAuthPopupOptions
): Promise<OAuthPopupExchange> {
if (options.signal.aborted) return Promise.reject(options.signal.reason)
const popup = window.open('', '_blank')
if (!popup) {
return Promise.reject(new AuthOperationError('OAuth pop-up was blocked'))
}
const controller = new AbortController()
return new Promise((resolve, reject) => {
let state = ''
let received = false
let finished = false
let stopCloseWatcher: () => void = () => undefined
const cleanup = () => {
window.removeEventListener('message', onMessage)
options.signal.removeEventListener('abort', onAbort)
stopCloseWatcher()
clearTimeout(deadline)
}
const fail = (error: unknown) => {
if (finished) return
finished = true
const failure = AuthOperationError.from(error)
controller.abort(failure)
cleanup()
if (!popup.closed) popup.close()
reject(failure)
}
const onAbort = () => fail(options.signal.reason)
const finish: OAuthPopupExchange['finish'] = (result) => {
if (finished) return
finished = true
cleanup()
if (!popup.closed) {
popup.postMessage(
{
type: OAUTH_POPUP_RESULT_MESSAGE,
intent: options.intent,
provider: options.provider,
state,
...result,
},
window.location.origin
)
popup.close()
}
}
const onMessage = (event: MessageEvent<unknown>) => {
if (
received ||
finished ||
!state ||
event.origin !== window.location.origin ||
event.source !== popup
) {
return
}
const message = event.data as
| (Partial<OAuthPopupCallback> & { type?: string; intent?: string })
| null
if (
!message ||
message.type !== OAUTH_POPUP_CALLBACK_MESSAGE ||
message.intent !== options.intent ||
message.provider !== options.provider ||
message.state !== state
) {
return
}
if (
typeof message.code !== 'string' &&
typeof message.error !== 'string'
) {
return
}
received = true
resolve({
callback: {
provider: options.provider,
state,
code: message.code,
error: message.error,
errorDescription:
typeof message.errorDescription === 'string'
? message.errorDescription
: undefined,
},
signal: controller.signal,
finish,
})
}
window.addEventListener('message', onMessage)
options.signal.addEventListener('abort', onAbort, { once: true })
stopCloseWatcher = watchOAuthPopupClosed(popup, () =>
fail(
new AuthOperationError(
'OAuth authorization was cancelled.',
'AUTH_CANCELLED'
)
)
)
const deadline = setTimeout(
() =>
fail(
new AuthOperationError(
'OAuth authorization timed out. Please try again.'
)
),
10 * 60_000
)
void options
.prepare(controller.signal)
.then((authorization) => {
if (finished || popup.closed) return
state = authorization.state
if (
!markOAuthPopup(
getOAuthSessionStorage(popup),
options.provider,
state,
options.intent
)
) {
throw new AuthOperationError('OAuth popup storage is unavailable.')
}
popup.location.replace(authorization.url)
})
.catch(fail)
})
}
...@@ -17,10 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,10 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { authRequestOptions, authResult } from '@/lib/secure-verification'
import type { import type {
SecurityProof, SecurityProof,
SecurityProofScope, VerificationOperation,
} from '../secure-verification/types' } from '../secure-verification/types'
import type { ApiResponse, PasskeyOptionsPayload, PasskeyStatus } from './types' import type { ApiResponse, PasskeyOptionsPayload, PasskeyStatus } from './types'
...@@ -33,39 +34,50 @@ export async function getPasskeyStatus(): Promise<ApiResponse<PasskeyStatus>> { ...@@ -33,39 +34,50 @@ export async function getPasskeyStatus(): Promise<ApiResponse<PasskeyStatus>> {
return res.data return res.data
} }
export async function beginPasskeyRegistration( export function beginPasskeyRegistration(
proofToken?: string proofToken: string,
): Promise<ApiResponse<PasskeyOptionsPayload>> { signal?: AbortSignal
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>( ): Promise<PasskeyOptionsPayload> {
'/api/user/passkey/register/begin', return authResult(
undefined, api.post('/api/user/passkey/register/begin', undefined, {
{ headers: proofHeaders(proofToken) } ...authRequestOptions,
headers: proofHeaders(proofToken),
signal,
})
) )
return res.data
} }
export async function finishPasskeyRegistration( export function finishPasskeyRegistration(
flowToken: string, flowToken: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
proofToken?: string signal?: AbortSignal
): Promise<ApiResponse> { ): Promise<unknown> {
const res = await api.post<ApiResponse>( return authResult(
'/api/user/passkey/register/finish', api.post(
{ '/api/user/passkey/register/finish',
flow_token: flowToken, { flow_token: flowToken, credential: payload },
credential: payload, {
}, ...authRequestOptions,
{ headers: proofHeaders(proofToken), acceptAuthRotation: true } acceptAuthRotation: true,
singleUseAuthorization: true,
signal,
}
)
) )
return res.data
} }
export async function deletePasskey(proofToken?: string): Promise<ApiResponse> { export function deletePasskey(
const res = await api.delete<ApiResponse>('/api/user/passkey', { proofToken: string,
headers: proofHeaders(proofToken), signal?: AbortSignal
acceptAuthRotation: true, ): Promise<unknown> {
}) return authResult(
return res.data api.delete('/api/user/passkey', {
...authRequestOptions,
headers: proofHeaders(proofToken),
acceptAuthRotation: true,
signal,
})
)
} }
export async function beginPasskeyLogin(): Promise< export async function beginPasskeyLogin(): Promise<
...@@ -89,23 +101,32 @@ export async function finishPasskeyLogin( ...@@ -89,23 +101,32 @@ export async function finishPasskeyLogin(
return res.data return res.data
} }
export async function beginPasskeyVerification( export function beginPasskeyVerification(
scope: SecurityProofScope operation: VerificationOperation,
): Promise<ApiResponse<PasskeyOptionsPayload>> { signal?: AbortSignal
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>( ): Promise<PasskeyOptionsPayload> {
'/api/user/passkey/verify/begin', return authResult(
{ scope } api.post(
'/api/user/passkey/verify/begin',
{
scope: operation.scope,
...(operation.context ? { context: operation.context } : {}),
},
{ ...authRequestOptions, signal }
)
) )
return res.data
} }
export async function finishPasskeyVerification( export function finishPasskeyVerification(
flowToken: string, flowToken: string,
payload: Record<string, unknown> payload: Record<string, unknown>,
): Promise<ApiResponse<SecurityProof>> { signal?: AbortSignal
const res = await api.post<ApiResponse<SecurityProof>>( ): Promise<SecurityProof> {
'/api/user/passkey/verify/finish', return authResult(
{ flow_token: flowToken, credential: payload } api.post(
'/api/user/passkey/verify/finish',
{ flow_token: flowToken, credential: payload },
{ ...authRequestOptions, singleUseAuthorization: true, signal }
)
) )
return res.data
} }
...@@ -16,16 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,16 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import i18next from 'i18next' import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { import {
buildRegistrationResult, buildRegistrationResult,
createCredential, createCredential,
isPasskeySupported as detectPasskeySupport, isPasskeySupported,
prepareCredentialCreationOptions, prepareCredentialCreationOptions,
} from '@/lib/passkey' } from '@/lib/passkey'
import { AuthOperationError } from '@/lib/secure-verification'
import { import {
beginPasskeyRegistration, beginPasskeyRegistration,
...@@ -35,168 +34,156 @@ import { ...@@ -35,168 +34,156 @@ import {
} from '../api' } from '../api'
import type { PasskeyStatus } from '../types' import type { PasskeyStatus } from '../types'
interface UsePasskeyManagementOptions { export function usePasskeyManagement() {
onStatusChange?: (status: PasskeyStatus | null) => void
}
export function usePasskeyManagement(
options: UsePasskeyManagementOptions = {}
) {
const { onStatusChange } = options
const [status, setStatus] = useState<PasskeyStatus | null>(null) const [status, setStatus] = useState<PasskeyStatus | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [registering, setRegistering] = useState(false) const [registering, setRegistering] = useState(false)
const [removing, setRemoving] = useState(false) const [removing, setRemoving] = useState(false)
const [supported, setSupported] = useState(false) const [supported, setSupported] = useState(false)
const operation = useRef<AbortController | null>(null)
const mounted = useRef(true)
const fetchStatus = useCallback(async () => { const fetchStatus = useCallback(async () => {
setLoading(true)
try { try {
setLoading(true) const response = await getPasskeyStatus()
const res = await getPasskeyStatus() if (!response.success || !response.data) {
if (res.success) { throw new AuthOperationError(
setStatus(res.data ?? null) response.message || 'Failed to load Passkey status'
onStatusChange?.(res.data ?? null) )
} else {
setStatus(null)
toast.error(res.message || i18next.t('Failed to load Passkey status'))
} }
if (!mounted.current) return
setStatus(response.data)
setStatusError(null)
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console if (mounted.current) {
console.error('[Passkey] Failed to fetch status', error) setStatusError(AuthOperationError.from(error).message)
toast.error(i18next.t('Failed to load Passkey status')) }
setStatus(null)
} finally { } finally {
setLoading(false) if (mounted.current) setLoading(false)
} }
}, [onStatusChange]) }, [])
useEffect(() => { useEffect(() => {
fetchStatus() mounted.current = true
void fetchStatus()
void isPasskeySupported().then((value) => {
if (mounted.current) setSupported(value)
})
return () => {
mounted.current = false
operation.current?.abort()
}
}, [fetchStatus]) }, [fetchStatus])
useEffect(() => {
detectPasskeySupport()
.then(setSupported)
.catch(() => setSupported(false))
}, [])
const register = useCallback( const register = useCallback(
async (proofToken?: string) => { async (proofToken: string) => {
if (!supported) { if (!supported || !navigator.credentials) {
toast.error(i18next.t('This device does not support Passkey')) throw new AuthOperationError('This device does not support Passkey')
return false
} }
if (!navigator?.credentials) { if (operation.current) {
toast.error(i18next.t('Passkey is not supported in this environment')) throw new AuthOperationError(
return false 'A security operation is already in progress.'
)
} }
const controller = new AbortController()
operation.current = controller
setRegistering(true) setRegistering(true)
try { try {
const beginResponse = await beginPasskeyRegistration(proofToken) const begin = await beginPasskeyRegistration(
if (!beginResponse.success) { proofToken,
toast.error( controller.signal
beginResponse.message ||
i18next.t('Failed to start Passkey registration')
)
return false
}
const publicKey = prepareCredentialCreationOptions(
beginResponse.data?.options ?? beginResponse.data
) )
const flowToken = beginResponse.data?.flow_token if (!begin.flow_token) {
if (!flowToken) { throw new AuthOperationError(
toast.error(i18next.t('Registration flow expired. Please try again.')) 'Registration flow expired. Please try again.'
return false )
} }
const credential = (await createCredential( const credential = (await createCredential(
publicKey prepareCredentialCreationOptions(begin.options ?? begin),
controller.signal
)) as PublicKeyCredential | null )) as PublicKeyCredential | null
controller.signal.throwIfAborted()
if (!credential) { if (!credential) {
toast.error(i18next.t('Passkey registration was cancelled')) throw new AuthOperationError(
return false 'Passkey registration was cancelled',
'AUTH_CANCELLED'
)
} }
const attestation = buildRegistrationResult(credential) const attestation = buildRegistrationResult(credential)
if (!attestation) { if (!attestation) {
toast.error(i18next.t('Invalid Passkey registration response')) throw new AuthOperationError('Invalid Passkey registration response')
return false
} }
await finishPasskeyRegistration(
const finishResponse = await finishPasskeyRegistration( begin.flow_token,
flowToken,
attestation, attestation,
proofToken controller.signal
) )
if (!finishResponse.success) { controller.signal.throwIfAborted()
toast.error(
finishResponse.message || i18next.t('Failed to register Passkey')
)
return false
}
toast.success(i18next.t('Passkey registered successfully'))
await fetchStatus() await fetchStatus()
return true } catch (error) {
} catch (error: unknown) { if (mounted.current && !controller.signal.aborted) await fetchStatus()
if (error instanceof DOMException && error.name === 'NotAllowedError') { if (
toast.info(i18next.t('Passkey registration was cancelled')) controller.signal.aborted ||
return false (error instanceof DOMException && error.name === 'NotAllowedError')
) {
throw new AuthOperationError(
'Passkey registration was cancelled',
'AUTH_CANCELLED',
{ cause: error }
)
} }
// eslint-disable-next-line no-console throw AuthOperationError.from(error, 'Failed to register Passkey')
console.error('[Passkey] Registration error', error)
toast.error(
error instanceof Error
? error.message
: i18next.t('Failed to register Passkey')
)
return false
} finally { } finally {
setRegistering(false) if (operation.current === controller) operation.current = null
if (mounted.current) setRegistering(false)
} }
}, },
[supported, fetchStatus] [fetchStatus, supported]
) )
const remove = useCallback( const remove = useCallback(
async (proofToken?: string) => { async (proofToken: string) => {
if (operation.current) {
throw new AuthOperationError(
'A security operation is already in progress.'
)
}
const controller = new AbortController()
operation.current = controller
setRemoving(true) setRemoving(true)
try { try {
const res = await deletePasskey(proofToken) await deletePasskey(proofToken, controller.signal)
if (!res.success) { controller.signal.throwIfAborted()
toast.error(res.message || i18next.t('Failed to remove Passkey'))
return false
}
toast.success(i18next.t('Passkey removed successfully'))
await fetchStatus() await fetchStatus()
return true
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console if (mounted.current && !controller.signal.aborted) await fetchStatus()
console.error('[Passkey] Removal error', error) if (controller.signal.aborted) {
toast.error(i18next.t('Failed to remove Passkey')) throw new AuthOperationError(
return false 'Operation cancelled',
'AUTH_CANCELLED',
{ cause: error }
)
}
throw AuthOperationError.from(error, 'Failed to remove Passkey')
} finally { } finally {
setRemoving(false) if (operation.current === controller) operation.current = null
if (mounted.current) setRemoving(false)
} }
}, },
[fetchStatus] [fetchStatus]
) )
const enabled = useMemo(() => Boolean(status?.enabled), [status])
const lastUsed = useMemo(() => status?.last_used_at ?? null, [status])
return { return {
status, status,
statusError,
loading, loading,
registering, registering,
removing, removing,
supported, supported,
enabled, enabled: Boolean(status?.enabled),
lastUsed, lastUsed: status?.last_used_at ?? null,
fetchStatus, fetchStatus,
register, register,
remove, remove,
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { AuthOperationError, authResult } from '@/lib/secure-verification'
import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
import { createOAuthFlow } from '../../api'
import { checkVerificationMethods, verify } from '../api'
const originalAdapter = api.defaults.adapter
const originalLocation = window.location.href
const sessionBundle = {
access_token: 'access-token',
token_type: 'Bearer' as const,
access_expires_at: Math.floor(Date.now() / 1000) + 600,
user: { id: 42, username: 'user', role: 1 },
session: {
sid: 'session',
current: true,
login_method: 'password',
ip: '127.0.0.1',
user_agent: 'test',
created_at: 1,
last_active_at: 1,
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
}
function mockRefreshResponse(bundle: AuthBundle, onRequest?: () => void) {
const requests = vi.fn()
vi.stubGlobal(
'XMLHttpRequest',
class {
status = 200
statusText = 'OK'
readyState = 4
responseText = JSON.stringify({ success: true, data: bundle })
onloadend: (() => void) | null = null
url = ''
open(_method: string, url: string) {
this.url = url
}
setRequestHeader() {}
getAllResponseHeaders() {
return 'content-type: application/json'
}
abort() {}
send() {
requests(this.url)
onRequest?.()
queueMicrotask(() => this.onloadend?.())
}
}
)
return requests
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
api.defaults.adapter = originalAdapter
useAuthStore.getState().auth.reset('idle')
window.history.replaceState(null, '', originalLocation)
})
it.each(['proof', 'flow'] as const)(
'never replays a %s request after a 401 response',
async (kind) => {
window.history.replaceState(null, '', '/sign-in')
useAuthStore.getState().auth.setBundle(sessionBundle)
const refresh = mockRefreshResponse(sessionBundle)
const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => {
throw new AxiosError(
'Unauthorized',
'ERR_BAD_REQUEST',
config,
undefined,
{
status: 401,
statusText: 'Unauthorized',
config,
headers: {},
data: { code: 'AUTH_SESSION_REVOKED' },
}
)
})
api.defaults.adapter = adapter
await expect(
api.post(
'/protected',
{},
{
skipErrorHandler: true,
...(kind === 'proof'
? { headers: { 'X-Security-Proof': 'one-use-proof' } }
: { singleUseAuthorization: true }),
}
)
).rejects.toThrow('Unauthorized')
expect(adapter).toHaveBeenCalledTimes(1)
expect(refresh).not.toHaveBeenCalled()
}
)
it('refreshes an expiring login token before submitting a one-time proof', async () => {
useAuthStore.getState().auth.setBundle({
...sessionBundle,
access_expires_at: Math.floor(Date.now() / 1000) + 10,
})
const order: string[] = []
mockRefreshResponse({ ...sessionBundle, access_token: 'fresh-access' }, () =>
order.push('refresh')
)
const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => {
order.push('action')
return {
status: 200,
statusText: 'OK',
config,
headers: {},
data: { success: true },
}
})
api.defaults.adapter = adapter
await api.post(
'/protected',
{},
{ headers: { 'X-Security-Proof': 'one-use-proof' } }
)
expect(order).toEqual(['refresh', 'action'])
expect(adapter).toHaveBeenCalledTimes(1)
expect(adapter.mock.calls[0]?.[0].headers.Authorization).toBe(
'Bearer fresh-access'
)
})
it('binds a channel verification to the requested channel context', async () => {
const proof = {
proof_token: 'channel-proof',
method: '2fa',
scope: 'channel.key.read',
expires_at: Math.floor(Date.now() / 1000) + 60,
}
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: { success: true, data: proof },
})
await expect(
verify(
{ method: '2fa', code: '123456' },
{ scope: 'channel.key.read', context: { channel_id: 123 } },
false,
new AbortController().signal
)
).resolves.toEqual(proof)
expect(post).toHaveBeenCalledWith(
'/api/verify',
{
method: '2fa',
code: '123456',
scope: 'channel.key.read',
context: { channel_id: 123 },
},
expect.anything()
)
})
it('passes the operation context to Passkey begin and completes with only its flow and assertion', async () => {
vi.stubGlobal('navigator', {
credentials: {
get: vi.fn().mockResolvedValue({
id: 'credential',
rawId: new Uint8Array([1, 2, 3]).buffer,
type: 'public-key',
response: {
clientDataJSON: new Uint8Array([1]).buffer,
authenticatorData: new Uint8Array([2]).buffer,
signature: new Uint8Array([3]).buffer,
userHandle: null,
},
getClientExtensionResults: () => ({}),
}),
},
})
const proof = {
proof_token: 'passkey-proof',
scope: 'channel.key.read',
method: 'passkey',
expires_at: Math.floor(Date.now() / 1000) + 60,
}
const post = vi
.spyOn(api, 'post')
.mockResolvedValueOnce({
data: {
success: true,
data: {
flow_token: 'flow',
options: { publicKey: { challenge: 'AQID', allowCredentials: [] } },
},
},
})
.mockResolvedValueOnce({ data: { success: true, data: proof } })
await expect(
verify(
{ method: 'passkey' },
{ scope: 'channel.key.read', context: { channel_id: 123 } },
false,
new AbortController().signal
)
).resolves.toEqual(proof)
expect(post).toHaveBeenNthCalledWith(
1,
'/api/user/passkey/verify/begin',
{
scope: 'channel.key.read',
context: { channel_id: 123 },
},
expect.anything()
)
expect(post.mock.calls[1]?.[1]).toEqual({
flow_token: 'flow',
credential: expect.anything(),
})
})
it('passes an enrollment operation through OAuth state creation', async () => {
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: { success: true, data: { flow_token: 'oauth-flow' } },
})
await expect(
createOAuthFlow('github', 'verify', {
scope: 'passkey.register',
context: {},
})
).resolves.toBe('oauth-flow')
expect(post).toHaveBeenCalledWith(
'/api/oauth/state',
expect.objectContaining({
provider: 'github',
intent: 'verify',
scope: 'passkey.register',
context: {},
}),
expect.anything()
)
})
it.each([
[
'SECURITY_PROOF_CONSUMED',
'This verification has already been used. Please verify again.',
],
[
'SECURITY_PROOF_CONTEXT_MISMATCH',
"Verification does not match this action's details. Please verify again.",
],
])('provides a re-verification message for %s', async (code, message) => {
await expect(
authResult(
Promise.resolve({
data: { success: false, code, message: 'untranslated backend text' },
})
)
).rejects.toMatchObject({ code, message })
})
it('reports a failed method query instead of treating the account as unenrolled', async () => {
vi.spyOn(api, 'get').mockRejectedValue(
new Error('Unable to load verification methods')
)
await expect(checkVerificationMethods('passkey.register')).rejects.toThrow(
'Unable to load verification methods'
)
})
it('displays a generic internal error even if the server includes database details', async () => {
await expect(
authResult(
Promise.resolve({
data: {
success: false,
code: 'AUTH_INTERNAL_ERROR',
message: 'SELECT private_table at private-db-host',
},
})
)
).rejects.toThrow('Please try again later.')
const failure = AuthOperationError.from({
isAxiosError: true,
response: {
status: 500,
data: { message: 'SELECT private_table at private-db-host' },
},
})
expect(failure.message).toBe('Please try again later.')
})
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { SecureVerificationDialog } from '../components/secure-verification-dialog'
import { useSecureVerification } from '../hooks/use-secure-verification'
import type {
RequestVerificationOptions,
SecurityProof,
VerificationRequirements,
} from '../types'
const passwordRequirements: VerificationRequirements = {
scope: 'passkey.register',
methods: [{ method: 'password', available: true }],
oauth_providers: [],
password_encryption_enabled: false,
}
function Harness(props: {
onResult: (proof: SecurityProof | null) => void
operation?: RequestVerificationOptions
}) {
const verification = useSecureVerification()
return (
<>
<button
type='button'
onClick={async () =>
props.onResult(
await verification.requestVerification(
props.operation ?? {
scope: 'passkey.register',
}
)
)
}
>
Protected action
</button>
<SecureVerificationDialog {...verification.dialogProps} />
</>
)
}
function pendingResponse<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((finish) => {
resolve = finish
})
return { promise, resolve }
}
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('keeps the requested channel context fixed while verification is open', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
scope: 'channel.key.read',
methods: [{ method: '2fa', available: true }],
oauth_providers: [],
password_encryption_enabled: false,
},
},
})
const post = vi.spyOn(api, 'post').mockResolvedValue({
data: {
success: true,
data: {
proof_token: 'channel-proof',
scope: 'channel.key.read',
method: '2fa',
expires_at: Math.floor(Date.now() / 1000) + 60,
},
},
})
const operation: RequestVerificationOptions = {
scope: 'channel.key.read',
context: { channel_id: 123 },
}
const user = userEvent.setup()
const result = vi.fn()
render(<Harness operation={operation} onResult={result} />)
await user.click(screen.getByText('Protected action'))
await user.type(
await screen.findByLabelText('Authenticator code or backup code'),
'123456'
)
operation.context.channel_id = 456
await user.click(screen.getByRole('button', { name: 'Verify' }))
await waitFor(() => expect(result).toHaveBeenCalledTimes(1))
expect(post).toHaveBeenCalledWith(
'/api/verify',
{
scope: 'channel.key.read',
context: { channel_id: 123 },
method: '2fa',
code: '123456',
},
expect.anything()
)
})
it('shows query errors and reloads requirements only when the user retries', async () => {
const requests = vi
.spyOn(api, 'get')
.mockRejectedValueOnce(new Error('Unable to load methods'))
.mockResolvedValue({ data: { success: true, data: passwordRequirements } })
const result = vi.fn()
const user = userEvent.setup()
render(<Harness onResult={result} />)
await user.click(screen.getByText('Protected action'))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Unable to load methods'
)
expect(
screen.queryByText('No verification method is available for this action.')
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Retry' }))
expect(
await screen.findByLabelText('Password', { selector: 'input' })
).toBeVisible()
expect(requests).toHaveBeenCalledTimes(2)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(result).toHaveBeenCalledWith(null))
})
it('returns a proof only after successful verification and clears a rejected password', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: passwordRequirements },
})
const proof: SecurityProof = {
proof_token: 'proof',
method: 'password',
scope: 'passkey.register',
expires_at: Math.floor(Date.now() / 1000) + 300,
}
const posts = vi
.spyOn(api, 'post')
.mockResolvedValueOnce({
data: { success: false, message: 'Wrong password' },
})
.mockResolvedValueOnce({ data: { success: true, data: proof } })
const result = vi.fn()
const user = userEvent.setup()
render(<Harness onResult={result} />)
await user.click(screen.getByText('Protected action'))
await user.type(
await screen.findByLabelText('Password', { selector: 'input' }),
'incorrect'
)
await user.click(screen.getByRole('button', { name: 'Verify' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong password')
expect(screen.getByLabelText('Password', { selector: 'input' })).toHaveValue(
''
)
expect(result).not.toHaveBeenCalled()
await user.type(
screen.getByLabelText('Password', { selector: 'input' }),
'correct'
)
await user.keyboard('{Enter}')
await waitFor(() => expect(result).toHaveBeenCalledWith(proof))
expect(posts).toHaveBeenLastCalledWith(
'/api/verify',
{ method: 'password', scope: 'passkey.register', password: 'correct' },
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('discards a late proof after cancellation and prevents a duplicate submission', async () => {
vi.spyOn(api, 'get').mockResolvedValue({
data: { success: true, data: passwordRequirements },
})
const reply = pendingResponse<{
data: { success: boolean; data: SecurityProof }
}>()
const posts = vi.spyOn(api, 'post').mockReturnValue(reply.promise)
const result = vi.fn()
const user = userEvent.setup()
render(<Harness onResult={result} />)
await user.click(screen.getByText('Protected action'))
await user.type(
await screen.findByLabelText('Password', { selector: 'input' }),
'password'
)
const submit = screen.getByRole('button', { name: 'Verify' })
fireEvent.click(submit)
fireEvent.click(submit)
await waitFor(() => expect(posts).toHaveBeenCalledTimes(1))
expect(submit).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(result).toHaveBeenCalledExactlyOnceWith(null))
await act(async () => {
reply.resolve({
data: {
success: true,
data: {
proof_token: 'late-proof',
method: 'password',
scope: 'passkey.register',
expires_at: Math.floor(Date.now() / 1000) + 300,
},
},
})
await reply.promise
})
expect(result).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('does not reopen after a cancelled method query finishes', async () => {
const reply = pendingResponse<{
data: { success: boolean; data: VerificationRequirements }
}>()
vi.spyOn(api, 'get').mockReturnValue(reply.promise)
const result = vi.fn()
const user = userEvent.setup()
render(<Harness onResult={result} />)
await user.click(screen.getByText('Protected action'))
expect(screen.getByRole('status')).toHaveTextContent(
'Loading verification methods...'
)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await act(async () => {
reply.resolve({ data: { success: true, data: passwordRequirements } })
await reply.promise
})
expect(result).toHaveBeenCalledExactlyOnceWith(null)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('keeps an enrolled Passkey unavailable when this browser cannot use it', async () => {
vi.stubGlobal('PublicKeyCredential', undefined)
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
...passwordRequirements,
methods: [{ method: 'passkey', available: true }],
},
},
})
const user = userEvent.setup()
render(<Harness onResult={vi.fn()} />)
await user.click(screen.getByText('Protected action'))
expect(
await screen.findByText(
'This device does not support Passkey verification.'
)
).toBeVisible()
expect(screen.getByRole('button', { name: 'Verify' })).toBeDisabled()
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument()
})
...@@ -16,12 +16,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,12 +16,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
export type VerificationMethod = '2fa' | 'passkey' export type VerificationMethod = '2fa' | 'passkey' | 'password' | 'oauth'
export type SecurityProofScope = export type SecurityProofScope =
| 'channel.key.read' | 'channel.key.read'
| 'passkey.register' | 'passkey.register'
| 'passkey.delete' | 'passkey.delete'
| '2fa.setup'
export type VerificationOperation =
| { scope: 'channel.key.read'; context: { channel_id: number } }
| {
scope: Exclude<SecurityProofScope, 'channel.key.read'>
context?: Record<string, never>
}
export interface SecurityProof { export interface SecurityProof {
proof_token: string proof_token: string
...@@ -30,31 +37,32 @@ export interface SecurityProof { ...@@ -30,31 +37,32 @@ export interface SecurityProof {
scope: SecurityProofScope scope: SecurityProofScope
} }
export interface VerificationMethods { export interface VerificationRequirements {
has2FA: boolean scope: SecurityProofScope
hasPasskey: boolean methods: { method: VerificationMethod; available: boolean; reason?: string }[]
passkeySupported: boolean oauth_providers: { slug: string; name: string }[]
} password_encryption_enabled: boolean
export interface SecureVerificationState {
method: VerificationMethod | null
scope?: SecurityProofScope
loading: boolean
code: string
title?: string
description?: string
} }
export interface UseSecureVerificationOptions { export type VerificationInput =
onSuccess?: (result: unknown, method: VerificationMethod) => void | { method: '2fa'; code: string }
onError?: (error: unknown) => void | { method: 'password'; password: string }
successMessage?: string | { method: 'passkey' }
autoReset?: boolean | { method: 'oauth'; provider: string }
}
export interface StartVerificationOptions { export type RequestVerificationOptions = VerificationOperation & {
scope: SecurityProofScope
preferredMethod?: VerificationMethod
title?: string title?: string
description?: string description?: string
} }
export type SecureVerificationState =
| { phase: 'idle' }
| { phase: 'loading'; request: RequestVerificationOptions }
| { phase: 'error'; request: RequestVerificationOptions; error: string }
| {
phase: 'ready' | 'verifying'
request: RequestVerificationOptions
requirements: VerificationRequirements
input: VerificationInput | null
error?: string
}
...@@ -305,13 +305,15 @@ export async function deleteDisabledChannels(): Promise<{ ...@@ -305,13 +305,15 @@ export async function deleteDisabledChannels(): Promise<{
*/ */
export async function getChannelKey( export async function getChannelKey(
id: number, id: number,
proofToken?: string proofToken: string,
signal?: AbortSignal
): Promise<{ success: boolean; message?: string; data?: { key: string } }> { ): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
const res = await api.post( const res = await api.post(
`/api/channel/${id}/key`, `/api/channel/${id}/key`,
undefined, undefined,
channelActionConfig({ channelActionConfig({
headers: proofToken ? { 'X-Security-Proof': proofToken } : undefined, headers: { 'X-Security-Proof': proofToken },
signal,
}) })
) )
return res.data return res.data
......
...@@ -107,10 +107,7 @@ import { ...@@ -107,10 +107,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { import { SecureVerificationDialog } from '@/features/auth/secure-verification'
SecureVerificationDialog,
useSecureVerification,
} from '@/features/auth/secure-verification'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock' import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
import { import {
...@@ -131,7 +128,6 @@ import { ...@@ -131,7 +128,6 @@ import {
fetchModels, fetchModels,
getAllModels, getAllModels,
getChannel, getChannel,
getChannelKey,
getGroups, getGroups,
getPrefillGroups, getPrefillGroups,
getTaskPluginOptions, getTaskPluginOptions,
...@@ -152,6 +148,7 @@ import { ...@@ -152,6 +148,7 @@ import {
MODEL_FETCHABLE_TYPES, MODEL_FETCHABLE_TYPES,
OPENAI_FIELD_PASSTHROUGH_TYPES, OPENAI_FIELD_PASSTHROUGH_TYPES,
} from '../../constants' } from '../../constants'
import { useChannelKeyDisclosure } from '../../hooks/use-channel-key-disclosure'
import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form' import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form'
import { import {
CHANNEL_FORM_DEFAULT_VALUES, CHANNEL_FORM_DEFAULT_VALUES,
...@@ -630,8 +627,6 @@ export function ChannelMutateDrawer({ ...@@ -630,8 +627,6 @@ export function ChannelMutateDrawer({
) )
const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false) const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
const [channelKey, setChannelKey] = useState<string | null>(null)
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] = const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] =
useState(false) useState(false)
const initialModelsRef = useRef<string[]>([]) const initialModelsRef = useRef<string[]>([])
...@@ -695,25 +690,8 @@ export function ChannelMutateDrawer({ ...@@ -695,25 +690,8 @@ export function ChannelMutateDrawer({
const { copyToClipboard } = useCopyToClipboard() const { copyToClipboard } = useCopyToClipboard()
const { const { channelKey, isChannelKeyLoading, handleRevealKey, verification } =
open: verificationOpen, useChannelKeyDisclosure(open, channelId)
methods: verificationMethods,
state: verificationState,
executeVerification,
withVerification,
cancel: cancelVerification,
setCode: setVerificationCode,
switchMethod: switchVerificationMethod,
} = useSecureVerification()
useEffect(() => {
if (!open) {
setChannelKey(null)
setIsChannelKeyLoading(false)
} else if (channelId) {
setChannelKey(null)
}
}, [open, channelId])
// Check if this is a multi-key channel // Check if this is a multi-key channel
const isMultiKeyChannel = const isMultiKeyChannel =
...@@ -1372,49 +1350,6 @@ export function ChannelMutateDrawer({ ...@@ -1372,49 +1350,6 @@ export function ChannelMutateDrawer({
} }
} }
const fetchChannelKey = useCallback(
async (proofToken?: string) => {
if (!channelId) {
throw new Error('Channel is not selected')
}
setIsChannelKeyLoading(true)
try {
const res = await getChannelKey(channelId, proofToken)
if (!res.success) {
throw new Error(res.message || t('Failed to fetch channel key'))
}
const keyValue = res.data?.key ?? ''
setChannelKey(keyValue)
toast.success(t('Channel key unlocked'))
return res
} finally {
setIsChannelKeyLoading(false)
}
},
[channelId, t]
)
const handleRevealKey = useCallback(async () => {
if (!channelId) return
try {
await withVerification(fetchChannelKey, {
scope: 'channel.key.read',
preferredMethod: 'passkey',
title: t('Verify to view channel key'),
description: t(
'Use Passkey or 2FA to confirm your identity before revealing this channel key.'
),
})
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
}
}, [channelId, withVerification, fetchChannelKey, t])
const handleRefreshCodexCredential = useCallback(async () => { const handleRefreshCodexCredential = useCallback(async () => {
if (!channelId) return if (!channelId) return
setIsCodexCredentialRefreshing(true) setIsCodexCredentialRefreshing(true)
...@@ -3132,11 +3067,11 @@ export function ChannelMutateDrawer({ ...@@ -3132,11 +3067,11 @@ export function ChannelMutateDrawer({
onClick={handleRevealKey} onClick={handleRevealKey}
disabled={ disabled={
isChannelKeyLoading || isChannelKeyLoading ||
verificationState.loading verification.isActive
} }
> >
{isChannelKeyLoading || {isChannelKeyLoading ||
verificationState.loading ? ( verification.isActive ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> <Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : ( ) : (
<Eye className='mr-2 h-4 w-4' /> <Eye className='mr-2 h-4 w-4' />
...@@ -4931,22 +4866,7 @@ export function ChannelMutateDrawer({ ...@@ -4931,22 +4866,7 @@ export function ChannelMutateDrawer({
existingModelsOverride={currentModelsArray} existingModelsOverride={currentModelsArray}
/> />
<SecureVerificationDialog <SecureVerificationDialog {...verification.dialogProps} />
open={verificationOpen}
onOpenChange={(open) => {
if (!open) {
cancelVerification()
}
}}
methods={verificationMethods}
state={verificationState}
onVerify={async (method, code) => {
await executeVerification(method, code)
}}
onCancel={cancelVerification}
onCodeChange={setVerificationCode}
onMethodChange={switchVerificationMethod}
/>
{/* Missing Models Confirmation Dialog */} {/* Missing Models Confirmation Dialog */}
<MissingModelsConfirmationDialog <MissingModelsConfirmationDialog
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { api } from '@/lib/api'
import { useChannelKeyDisclosure } from '../use-channel-key-disclosure'
function Harness(props: { open: boolean; channelId: number }) {
const disclosure = useChannelKeyDisclosure(props.open, props.channelId)
return (
<>
<button type='button' onClick={disclosure.handleRevealKey}>
Reveal
</button>
<output aria-label='Channel key'>
{disclosure.channelKey ?? 'Hidden'}
</output>
<SecureVerificationDialog {...disclosure.verification.dialogProps} />
</>
)
}
function deferredResponse<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((finish) => {
resolve = finish
})
return { promise, resolve }
}
function channelVerification() {
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
scope: 'channel.key.read',
methods: [{ method: '2fa', available: true }],
oauth_providers: [],
password_encryption_enabled: false,
},
},
})
return {
data: {
success: true,
data: {
proof_token: 'channel-proof',
method: '2fa',
scope: 'channel.key.read',
expires_at: Math.floor(Date.now() / 1000) + 60,
},
},
}
}
afterEach(() => vi.restoreAllMocks())
it.each(['switch', 'close'] as const)(
'discards a pending channel key response after %s',
async (change) => {
const proof = channelVerification()
const keyReply = deferredResponse<{
data: { success: boolean; data: { key: string } }
}>()
const post = vi.spyOn(api, 'post').mockImplementation((url) => {
if (url === '/api/verify') return Promise.resolve(proof)
if (url === '/api/channel/123/key') return keyReply.promise
throw new Error(`Unexpected POST ${url}`)
})
const user = userEvent.setup()
const view = render(<Harness open channelId={123} />)
await user.click(screen.getByRole('button', { name: 'Reveal' }))
await user.type(
await screen.findByLabelText('Authenticator code or backup code'),
'123456'
)
await user.click(screen.getByRole('button', { name: 'Verify' }))
await waitFor(() =>
expect(post).toHaveBeenCalledWith(
'/api/channel/123/key',
undefined,
expect.anything()
)
)
view.rerender(
<Harness
open={change !== 'close'}
channelId={change === 'switch' ? 456 : 123}
/>
)
await act(async () => {
keyReply.resolve({
data: { success: true, data: { key: 'CHANNEL_A_SECRET' } },
})
await keyReply.promise
})
expect(screen.getByLabelText('Channel key')).toHaveTextContent('Hidden')
expect(screen.queryByText('CHANNEL_A_SECRET')).not.toBeInTheDocument()
}
)
it('cancels a pending verification when the selected channel changes', async () => {
channelVerification()
const reply = deferredResponse<{
data: { success: boolean; data: Record<string, unknown> }
}>()
const post = vi.spyOn(api, 'post').mockReturnValue(reply.promise)
const user = userEvent.setup()
const view = render(<Harness open channelId={123} />)
await user.click(screen.getByRole('button', { name: 'Reveal' }))
await user.type(
await screen.findByLabelText('Authenticator code or backup code'),
'123456'
)
await user.click(screen.getByRole('button', { name: 'Verify' }))
view.rerender(<Harness open channelId={456} />)
await act(async () => {
reply.resolve({
data: {
success: true,
data: {
proof_token: 'late-proof',
scope: 'channel.key.read',
method: '2fa',
expires_at: Math.floor(Date.now() / 1000) + 60,
},
},
})
await reply.promise
})
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(post.mock.calls.map(([url]) => url)).toEqual(['/api/verify'])
})
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useSecureVerification } from '@/features/auth/secure-verification'
import { AuthOperationError } from '@/lib/secure-verification'
import { getChannelKey } from '../api'
export function useChannelKeyDisclosure(
open: boolean,
channelId: number | null
) {
const { t } = useTranslation()
const verification = useSecureVerification()
const cancelVerification = verification.cancel
const requestVerification = verification.requestVerification
const [disclosedKey, setDisclosedKey] = useState<{
channelId: number
key: string
} | null>(null)
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
const operation = useRef<AbortController | null>(null)
useEffect(() => {
setDisclosedKey(null)
setIsChannelKeyLoading(false)
return () => {
operation.current?.abort()
operation.current = null
cancelVerification()
}
}, [open, channelId, cancelVerification])
const handleRevealKey = useCallback(async () => {
if (!channelId || !open || operation.current) return
const current = new AbortController()
operation.current = current
try {
const proof = await requestVerification({
scope: 'channel.key.read',
context: { channel_id: channelId },
title: t('Verify to view channel key'),
description: t(
'Use Passkey or 2FA to confirm your identity before revealing this channel key.'
),
})
if (!proof || operation.current !== current) return
setIsChannelKeyLoading(true)
const res = await getChannelKey(
channelId,
proof.proof_token,
current.signal
)
if (operation.current !== current) return
if (!res.success) {
throw new Error(res.message || t('Failed to fetch channel key'))
}
setDisclosedKey({ channelId, key: res.data?.key ?? '' })
toast.success(t('Channel key unlocked'))
} catch (error) {
if (operation.current === current && !current.signal.aborted) {
toast.error(t(AuthOperationError.from(error).message))
}
} finally {
if (operation.current === current) {
operation.current = null
setIsChannelKeyLoading(false)
}
}
}, [channelId, open, requestVerification, t])
const channelKey =
open && disclosedKey?.channelId === channelId ? disclosedKey.key : null
return { channelKey, isChannelKeyLoading, handleRevealKey, verification }
}
...@@ -179,15 +179,6 @@ export interface TwoFAStatus { ...@@ -179,15 +179,6 @@ export interface TwoFAStatus {
backup_codes_remaining: number backup_codes_remaining: number
} }
/**
* Two-Factor Authentication Setup Data
*/
export interface TwoFASetupData {
secret: string
qr_code_data: string
backup_codes: string[]
}
// ============================================================================ // ============================================================================
// Checkin Type Definitions // Checkin Type Definitions
// ============================================================================ // ============================================================================
......
...@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import type { ApiResponse } from '@/features/profile/types' import type { ApiResponse, TwoFAStatus } from '@/features/profile/types'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { authRequestOptions, authResult } from '@/lib/secure-verification'
export interface AccessTokenStatus { export interface AccessTokenStatus {
exists: boolean exists: boolean
...@@ -51,3 +52,47 @@ export async function revokeAccessToken(): Promise<void> { ...@@ -51,3 +52,47 @@ export async function revokeAccessToken(): Promise<void> {
throw new Error(response.data.message || 'Failed to revoke token') throw new Error(response.data.message || 'Failed to revoke token')
} }
} }
export interface TwoFASetupData {
secret: string
qr_code_data: string
backup_codes: string[]
flow_token: string
expires_at: number
}
export function get2FAStatus(): Promise<TwoFAStatus> {
return authResult(api.get('/api/user/2fa/status', authRequestOptions))
}
export function setup2FA(
proofToken: string,
signal: AbortSignal
): Promise<TwoFASetupData> {
return authResult(
api.post('/api/user/2fa/setup', undefined, {
...authRequestOptions,
signal,
headers: { 'X-Security-Proof': proofToken },
})
)
}
export function enable2FA(
code: string,
flowToken: string,
signal: AbortSignal
): Promise<unknown> {
return authResult(
api.post(
'/api/user/2fa/enable',
{ code, flow_token: flowToken },
{
...authRequestOptions,
signal,
acceptAuthRotation: true,
singleUseAuthorization: true,
}
)
)
}
...@@ -18,9 +18,8 @@ For commercial licensing, please contact support@quantumnous.com ...@@ -18,9 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { useState, useEffect, useCallback } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CopyButton } from '@/components/copy-button' import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
...@@ -28,8 +27,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert' ...@@ -28,8 +27,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import type { TwoFASetupData } from '@/features/profile/types'
import { setup2FA, enable2FA } from '@/lib/api' import type { TwoFASetupData } from '../../api'
// ============================================================================ // ============================================================================
// Two-FA Setup Dialog Component // Two-FA Setup Dialog Component
...@@ -37,102 +36,29 @@ import { setup2FA, enable2FA } from '@/lib/api' ...@@ -37,102 +36,29 @@ import { setup2FA, enable2FA } from '@/lib/api'
interface TwoFASetupDialogProps { interface TwoFASetupDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void setupData: TwoFASetupData | null
onSuccess: () => void loading: boolean
initializing: boolean
error?: string
onCancel: () => void
onEnable: (code: string) => Promise<void>
} }
export function TwoFASetupDialog({ export function TwoFASetupDialog(props: TwoFASetupDialogProps) {
open,
onOpenChange,
onSuccess,
}: TwoFASetupDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [initializing, setInitializing] = useState(false)
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
const [setupData, setSetupData] = useState<TwoFASetupData | null>(null)
const [code, setCode] = useState('') const [code, setCode] = useState('')
const stepLabels = [ const stepLabels = [
t('Scan QR Code'), t('Scan QR Code'),
t('Save Backup Codes'), t('Save Backup Codes'),
t('Verify Setup'), t('Verify Setup'),
] ]
const handleSetup = useCallback(async () => {
try {
setInitializing(true)
const response = await setup2FA()
if (response.success && response.data) {
setSetupData(response.data)
setStep(0)
} else {
toast.error(response.message || t('Failed to setup 2FA'))
onOpenChange(false)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Setup 2FA error:', error)
toast.error(t('Failed to setup 2FA'))
onOpenChange(false)
} finally {
setInitializing(false)
}
}, [onOpenChange, t])
const handleEnable = async () => {
if (!code) {
toast.error(t('Please enter the verification code'))
return
}
try {
setLoading(true)
const response = await enable2FA(code)
if (response.success) {
toast.success(t('Two-factor authentication enabled successfully!'))
onOpenChange(false)
onSuccess()
// Reset
setStep(0)
setCode('')
setSetupData(null)
} else {
toast.error(response.message || t('Failed to enable 2FA'))
}
} catch {
toast.error(t('Failed to enable 2FA'))
} finally {
setLoading(false)
}
}
const handleOpenChange = (open: boolean) => {
if (!loading && !initializing) {
if (open && !setupData) {
handleSetup()
}
if (!open) {
setStep(0)
setCode('')
setSetupData(null)
}
onOpenChange(open)
}
}
// Initialize when dialog opens
useEffect(() => {
if (open && !setupData && !initializing) {
handleSetup()
}
}, [open, setupData, initializing, handleSetup])
return ( return (
<Dialog <Dialog
open={open} open={props.open}
onOpenChange={handleOpenChange} onOpenChange={(open) => {
if (!open) props.onCancel()
}}
title={t('Setup Two-Factor Authentication')} title={t('Setup Two-Factor Authentication')}
description={ description={
<> <>
...@@ -151,7 +77,7 @@ export function TwoFASetupDialog({ ...@@ -151,7 +77,7 @@ export function TwoFASetupDialog({
<Button <Button
variant='outline' variant='outline'
onClick={() => setStep(step - 1)} onClick={() => setStep(step - 1)}
disabled={initializing || loading} disabled={props.initializing || props.loading}
> >
{t('Back')} {t('Back')}
</Button> </Button>
...@@ -159,24 +85,31 @@ export function TwoFASetupDialog({ ...@@ -159,24 +85,31 @@ export function TwoFASetupDialog({
{step < 2 ? ( {step < 2 ? (
<Button <Button
onClick={() => setStep(step + 1)} onClick={() => setStep(step + 1)}
disabled={initializing || !setupData} disabled={props.initializing || !props.setupData}
> >
{t('Next')} {t('Next')}
</Button> </Button>
) : ( ) : (
<Button <Button
onClick={handleEnable} onClick={() => void props.onEnable(code)}
disabled={initializing || loading || !code} disabled={props.initializing || props.loading || !code}
> >
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />} {props.loading && (
{loading ? t('Enabling...') : t('Enable 2FA')} <Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{props.loading ? t('Enabling...') : t('Enable 2FA')}
</Button> </Button>
)} )}
</> </>
} }
> >
<div className='space-y-4 py-4'> <div className='space-y-4 py-4'>
{initializing && ( {props.error && (
<p role='alert' className='text-destructive text-sm'>
{t(props.error)}
</p>
)}
{props.initializing && (
<div className='flex flex-col items-center justify-center gap-3 py-8'> <div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' /> <div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'> <div className='text-muted-foreground text-sm'>
...@@ -184,14 +117,14 @@ export function TwoFASetupDialog({ ...@@ -184,14 +117,14 @@ export function TwoFASetupDialog({
</div> </div>
</div> </div>
)} )}
{!initializing && !setupData && ( {!props.initializing && !props.setupData && (
<div className='flex justify-center py-8'> <div className='flex justify-center py-8'>
<div className='text-muted-foreground'> <div className='text-muted-foreground'>
{t('Failed to load setup data')} {t('Failed to load setup data')}
</div> </div>
</div> </div>
)} )}
{!initializing && setupData && ( {!props.initializing && props.setupData && (
<> <>
{/* Step 0: QR Code */} {/* Step 0: QR Code */}
{step === 0 && ( {step === 0 && (
...@@ -202,7 +135,7 @@ export function TwoFASetupDialog({ ...@@ -202,7 +135,7 @@ export function TwoFASetupDialog({
)} )}
</p> </p>
<div className='flex justify-center rounded-lg bg-white p-4'> <div className='flex justify-center rounded-lg bg-white p-4'>
<QRCodeSVG value={setupData.qr_code_data} size={200} /> <QRCodeSVG value={props.setupData.qr_code_data} size={200} />
</div> </div>
<div className='bg-muted rounded-lg p-3'> <div className='bg-muted rounded-lg p-3'>
<div className='flex items-center justify-between'> <div className='flex items-center justify-between'>
...@@ -211,11 +144,11 @@ export function TwoFASetupDialog({ ...@@ -211,11 +144,11 @@ export function TwoFASetupDialog({
{t('Or enter this key manually:')} {t('Or enter this key manually:')}
</p> </p>
<code className='font-mono text-sm'> <code className='font-mono text-sm'>
{setupData.secret} {props.setupData.secret}
</code> </code>
</div> </div>
<CopyButton <CopyButton
value={setupData.secret} value={props.setupData.secret}
variant='ghost' variant='ghost'
tooltip={t('Copy secret key')} tooltip={t('Copy secret key')}
aria-label={t('Copy secret key')} aria-label={t('Copy secret key')}
...@@ -237,7 +170,7 @@ export function TwoFASetupDialog({ ...@@ -237,7 +170,7 @@ export function TwoFASetupDialog({
</Alert> </Alert>
<div className='rounded-lg border p-4'> <div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'> <div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code) => ( {props.setupData.backup_codes.map((code) => (
<div <div
key={code} key={code}
className='bg-muted rounded-md p-2 text-center font-mono text-sm' className='bg-muted rounded-md p-2 text-center font-mono text-sm'
...@@ -248,7 +181,7 @@ export function TwoFASetupDialog({ ...@@ -248,7 +181,7 @@ export function TwoFASetupDialog({
</div> </div>
</div> </div>
<CopyButton <CopyButton
value={setupData.backup_codes.join('\n')} value={props.setupData.backup_codes.join('\n')}
variant='outline' variant='outline'
size='default' size='default'
className='w-full' className='w-full'
...@@ -272,7 +205,7 @@ export function TwoFASetupDialog({ ...@@ -272,7 +205,7 @@ export function TwoFASetupDialog({
onChange={(e) => setCode(e.target.value)} onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter 6-digit code')} placeholder={t('Enter 6-digit code')}
maxLength={6} maxLength={6}
disabled={loading} disabled={props.loading}
/> />
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t('Enter the 6-digit code from your authenticator app')} {t('Enter the 6-digit code from your authenticator app')}
......
...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { AlertTriangle, KeyRound, Loader2, ShieldAlert } from 'lucide-react' import { AlertTriangle, KeyRound, Loader2, ShieldAlert } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
...@@ -47,10 +47,9 @@ import { usePasskeyManagement } from '@/features/auth/passkey' ...@@ -47,10 +47,9 @@ import { usePasskeyManagement } from '@/features/auth/passkey'
import { import {
SecureVerificationDialog, SecureVerificationDialog,
useSecureVerification, useSecureVerification,
type VerificationMethod,
type VerificationMethods,
} from '@/features/auth/secure-verification' } from '@/features/auth/secure-verification'
import dayjs from '@/lib/dayjs' import dayjs from '@/lib/dayjs'
import { AuthOperationError } from '@/lib/secure-verification'
interface PasskeyCardProps { interface PasskeyCardProps {
loading: boolean loading: boolean
...@@ -59,11 +58,10 @@ interface PasskeyCardProps { ...@@ -59,11 +58,10 @@ interface PasskeyCardProps {
export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false)
const [restrictedMethod, setRestrictedMethod] =
useState<VerificationMethod | null>(null)
const { const {
status, status,
statusError,
fetchStatus,
loading, loading,
registering, registering,
removing, removing,
...@@ -74,121 +72,38 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { ...@@ -74,121 +72,38 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
remove, remove,
} = usePasskeyManagement() } = usePasskeyManagement()
const { const verification = useSecureVerification()
open: verificationOpen,
setOpen: setVerificationOpen,
methods: verificationMethods,
state: verificationState,
startVerification,
executeVerification,
cancel: cancelVerification,
setCode,
switchMethod,
fetchVerificationMethods,
} = useSecureVerification({
onSuccess: () => {
setRestrictedMethod(null)
},
})
const dialogMethods = useMemo<VerificationMethods>(() => {
if (!restrictedMethod) return verificationMethods
return {
...verificationMethods,
has2FA: restrictedMethod === '2fa' && verificationMethods.has2FA,
hasPasskey:
restrictedMethod === 'passkey' && verificationMethods.hasPasskey,
}
}, [restrictedMethod, verificationMethods])
const handleRegister = useCallback(async () => { const handleRegister = useCallback(async () => {
if (!supported) { if (registering || removing || verification.isActive) return
toast.info(t('This device does not support Passkey')) const proof = await verification.requestVerification({
return
}
const methods = await fetchVerificationMethods()
if (!methods.has2FA) {
// Without 2FA enabled, register directly. The browser-level Passkey prompt
// is itself a strong proof of presence, so no extra verification is needed.
await register()
return
}
setRestrictedMethod('2fa')
await startVerification(register, {
scope: 'passkey.register', scope: 'passkey.register',
preferredMethod: '2fa',
title: t('Security verification'),
description: t(
'Confirm your identity with Two-factor Authentication before registering a Passkey.'
),
}) })
}, [fetchVerificationMethods, register, startVerification, supported, t]) if (!proof) return
try {
const handleRemove = useCallback(async () => { await register(proof.proof_token)
const methods = await fetchVerificationMethods() toast.success(t('Passkey registered successfully'))
let required: VerificationMethod | null = null } catch (error) {
if (methods.has2FA) { const failure = AuthOperationError.from(error)
required = '2fa' if (failure.code !== 'AUTH_CANCELLED') toast.error(t(failure.message))
} else if (methods.hasPasskey) {
required = 'passkey'
}
if (!required) {
toast.error(
t(
'Please enable Two-factor Authentication or Passkey before proceeding'
)
)
return
}
if (required === 'passkey' && !methods.passkeySupported) {
toast.info(t('This device does not support Passkey'))
return
} }
}, [register, registering, removing, t, verification])
const handleRemove = useCallback(async () => {
if (registering || removing || verification.isActive) return
setConfirmOpen(false) setConfirmOpen(false)
setRestrictedMethod(required) const proof = await verification.requestVerification({
await startVerification(remove, {
scope: 'passkey.delete', scope: 'passkey.delete',
preferredMethod: required,
title: t('Security verification'),
description: t(
'Confirm your identity before removing this Passkey from your account.'
),
}) })
}, [fetchVerificationMethods, remove, startVerification, t]) if (!proof) return
try {
const handleVerificationCancel = useCallback(() => { await remove(proof.proof_token)
setRestrictedMethod(null) toast.success(t('Passkey removed successfully'))
cancelVerification() } catch (error) {
}, [cancelVerification]) const failure = AuthOperationError.from(error)
if (failure.code !== 'AUTH_CANCELLED') toast.error(t(failure.message))
const handleVerificationOpenChange = useCallback( }
(next: boolean) => { }, [registering, remove, removing, t, verification])
if (!next) {
setRestrictedMethod(null)
}
setVerificationOpen(next)
},
[setVerificationOpen]
)
// Adapt the hook's `Promise<unknown>` return into the dialog's
// `void | Promise<void>` signature without losing error propagation
// semantics (errors are surfaced via toast inside the hook).
const handleDialogVerify = useCallback(
async (method: VerificationMethod, code?: string) => {
try {
await executeVerification(method, code)
} catch {
// Errors are already surfaced by useSecureVerification via toast.
}
},
[executeVerification]
)
if (pageLoading || loading) { if (pageLoading || loading) {
return ( return (
...@@ -204,6 +119,20 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { ...@@ -204,6 +119,20 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
) )
} }
if (statusError) {
return (
<Card>
<CardHeader>
<CardTitle>{t('Passkey Login')}</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
<p role='alert'>{t(statusError)}</p>
<Button onClick={() => void fetchStatus()}>{t('Retry')}</Button>
</CardContent>
</Card>
)
}
const formattedLastUsed = const formattedLastUsed =
lastUsed && !Number.isNaN(Date.parse(lastUsed)) lastUsed && !Number.isNaN(Date.parse(lastUsed))
? dayjs(lastUsed).fromNow() ? dayjs(lastUsed).fromNow()
...@@ -276,7 +205,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { ...@@ -276,7 +205,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
<Button <Button
className='w-full sm:w-auto xl:w-full 2xl:w-auto' className='w-full sm:w-auto xl:w-full 2xl:w-auto'
onClick={handleRegister} onClick={handleRegister}
disabled={!supported || registering} disabled={!supported || registering || verification.isActive}
> >
{registering && ( {registering && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' /> <Loader2 className='mr-2 h-4 w-4 animate-spin' />
...@@ -355,16 +284,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) { ...@@ -355,16 +284,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
</CardContent> </CardContent>
</Card> </Card>
<SecureVerificationDialog <SecureVerificationDialog {...verification.dialogProps} />
open={verificationOpen}
onOpenChange={handleVerificationOpenChange}
methods={dialogMethods}
state={verificationState}
onVerify={handleDialogVerify}
onCancel={handleVerificationCancel}
onCodeChange={setCode}
onMethodChange={switchMethod}
/>
</> </>
) )
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment