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
### 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/`):
- `bun install` for dependency installation
- `bun run dev` for development server
......
......@@ -4,6 +4,8 @@ import (
"bytes"
"encoding/json"
"io"
"github.com/gin-gonic/gin/binding"
)
func Unmarshal(data []byte, v any) error {
......@@ -18,6 +20,18 @@ func DecodeJson(reader io.Reader, v any) error {
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) {
return json.Marshal(v)
}
......
......@@ -2,8 +2,11 @@ package common
import (
"encoding/json"
"strings"
"testing"
"github.com/go-playground/validator/v10"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
......@@ -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) {
func GetChannelKey(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
common.ApiErrorMsg(c, "渠道ID格式错误")
return
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
writeSecurityOperationError(c, err)
return
}
if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在"))
common.ApiErrorMsg(c, "渠道不存在")
return
}
......@@ -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 通用的渠道校验函数
func validateChannel(channel *model.Channel, isAdd bool) error {
if channel == nil {
......
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
......@@ -13,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
......@@ -20,13 +22,16 @@ import (
const oauthAuthFlowTTL = 10 * time.Minute
type oauthStateRequest struct {
Provider string `json:"provider"`
Intent string `json:"intent"`
Aff string `json:"aff,omitempty"`
Provider string `json:"provider"`
Intent string `json:"intent"`
Aff string `json:"aff,omitempty"`
Scope string `json:"scope,omitempty"`
Context json.RawMessage `json:"context,omitempty"`
}
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
......@@ -45,15 +50,17 @@ func GenerateOAuthCode(c *gin.Context) {
request.Intent = strings.TrimSpace(request.Intent)
request.Aff = strings.TrimSpace(request.Aff)
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 ||
(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)
return
}
userID := 0
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)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"})
......@@ -61,10 +68,18 @@ func GenerateOAuthCode(c *gin.Context) {
}
userID = identity.UserID
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 {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
expiresAt := time.Now().Add(oauthAuthFlowTTL)
......@@ -78,7 +93,7 @@ func GenerateOAuthCode(c *gin.Context) {
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
......@@ -122,8 +137,8 @@ func HandleOAuth(c *gin.Context) {
Provider: providerName,
Intent: pendingFlow.Intent,
}
// 2. Bind flows are bound to the live dashboard Session that created them.
if pendingFlow.Intent == model.AuthFlowIntentBind {
// Bind and verification callbacks must use the dashboard session that started them.
if pendingFlow.Intent == model.AuthFlowIntentBind || pendingFlow.Intent == model.AuthFlowIntentVerify {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
c.JSON(http.StatusForbidden, gin.H{
......@@ -162,11 +177,6 @@ func HandleOAuth(c *gin.Context) {
})
return
}
if pendingFlow.Intent == model.AuthFlowIntentBind {
handleOAuthBind(c, provider, pendingFlow, state)
return
}
// 5. Exchange code for token
code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
......@@ -187,10 +197,37 @@ func HandleOAuth(c *gin.Context) {
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
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode)
......@@ -207,7 +244,7 @@ func HandleOAuth(c *gin.Context) {
case *OAuthEmailAlreadyTakenError:
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
default:
common.ApiError(c, err)
writeSecurityOperationError(c, err)
}
return
}
......@@ -223,22 +260,7 @@ func HandleOAuth(c *gin.Context) {
}
// handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model.AuthFlow, flowToken string) {
// 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
}
func handleOAuthBind(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, flow *model.AuthFlow) {
// Check if this OAuth account is already bound (check both new ID and legacy ID)
if provider.IsUserIDTaken(oauthUser.ProviderUserID) {
common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName()))
......@@ -252,25 +274,15 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model
}
}
if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
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
userId := flow.UserId
var err error
// Handle binding based on provider type
if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok {
// Custom provider: use user_oauth_bindings table
err = model.UpdateUserOAuthBinding(userId, genericProvider.GetProviderId(), oauthUser.ProviderUserID)
if err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
} else {
......@@ -278,7 +290,7 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model
// role/status/group 一并写回,覆盖并发发生的封禁、降权或分组变更。
err = model.UpdateUserBindColumn(userId, provider.ProviderUserIDColumn(), oauthUser.ProviderUserID)
if err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
}
......@@ -461,6 +473,6 @@ func handleOAuthError(c *gin.Context, err error) {
case *oauth.TrustLevelError:
common.ApiErrorI18n(c, i18n.MsgOAuthTrustLevelLow)
default:
common.ApiError(c, err)
writeSecurityOperationError(c, err)
}
}
package controller
import (
"fmt"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"net/http"
"net/http/httptest"
"strings"
......@@ -13,10 +15,8 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type passkeyTestBody struct {
......@@ -43,88 +43,30 @@ func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) {
assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength)
}
func TestPasskeyRegisterFinishRejectsMissingOrWrongProofWithoutConsumingFlow(t *testing.T) {
previousDB := model.DB
previousType := common.MainDatabaseType()
previousRedis := common.RedisEnabled
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{})
func TestPasskeyRegisterFinishRejectsUnapprovedFlowWithoutConsumingIt(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
system_setting.GetPasskeySettings().UserVerification = "required"
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TwoFA{}, &model.AuthFlow{}))
model.DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.SessionSecret = "passkey-register-proof-test-secret"
*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()
}
payload, err := common.Marshal(map[string]any{"scope": service.VerificationScopePasskeyRegister})
require.NoError(t, err)
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: identity.UserID, SessionId: identity.SessionID,
Payload: string(payload), ExpiresAt: time.Now().Add(time.Minute),
})
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)
tests := []struct {
name string
proof string
expectedCode string
}{
{name: "missing proof", expectedCode: "SECURITY_PROOF_REQUIRED"},
{name: "wrong scope proof", proof: wrongScopeProof, expectedCode: "SECURITY_PROOF_SCOPE_MISMATCH"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
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)
})
}
body, err := common.Marshal(passkeyFinishRequest{
FlowToken: token, Credential: securityPasskeyResponse(t, key, "test-challenge", true, 0),
})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/passkey/register/finish", string(body), "", identity, PasskeyRegisterFinish)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.False(t, result.Success)
assert.Equal(t, "AUTH_FLOW_INVALID", result.Code)
_, err = model.GetPasskeyByUserID(identity.UserID)
assert.ErrorIs(t, err, model.ErrPasskeyNotFound)
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposePasskeyRegister})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
}
......@@ -2,87 +2,97 @@ package controller
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
)
const (
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) {
func GetVerificationMethods(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
return
}
var request UniversalVerifyRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
if request.Method != secureVerificationMethod2FA {
common.ApiError(c, errors.New("Passkey 验证必须使用 Passkey verify 流程"))
return
}
if !isAllowedSecurityProofScope(request.Scope) {
common.ApiError(c, errors.New("不支持的安全验证范围"))
requirements, err := service.GetVerificationRequirements(identity, c.Query("scope"))
if err != nil {
writeSecurityOperationError(c, err)
return
}
if strings.TrimSpace(request.Code) == "" {
common.ApiError(c, errors.New("验证码不能为空"))
common.ApiSuccess(c, requirements)
}
// 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
}
twoFA, err := model.GetTwoFAByUserId(identity.UserID)
if err != nil {
common.ApiError(c, err)
default:
writeAuthSessionError(c, err)
return
}
if twoFA == nil || !twoFA.IsEnabled {
common.ApiError(c, errors.New("用户未启用2FA"))
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
}
func UniversalVerify(c *gin.Context) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
return
}
if !validateTwoFactorAuth(twoFA, request.Code) {
common.ApiError(c, errors.New("验证失败,请检查验证码"))
var request service.VerificationInput
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorMsg(c, "参数错误")
return
}
proofToken, expiresAt, err := service.IssueSecurityProof(identity, request.Method, []string{request.Scope})
proof, err := service.VerifySecurityInput(identity, request)
if err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]interface{}{"method": "2fa"})
c.JSON(http.StatusOK, gin.H{
"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
}
recordUserSecurityAudit(c, identity.UserID, "user.security_verify", map[string]interface{}{"method": proof.Method, "scope": proof.Scope})
common.ApiSuccess(c, proof)
}
......@@ -156,7 +156,10 @@ func TestTryUserAuthCredentialClassification(t *testing.T) {
}
accessToken, _, err := service.IssueAccessToken(identity)
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)
externalToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "external-issuer",
......
......@@ -2,6 +2,7 @@ package middleware
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
......@@ -27,6 +28,12 @@ func SetUpLogger(server *gin.Engine) {
if tag == "" {
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",
param.TimeStamp.Format("2006/01/02 - 15:04:05"),
tag,
......@@ -35,7 +42,7 @@ func SetUpLogger(server *gin.Engine) {
param.Latency,
param.ClientIP,
param.Method,
param.Path,
path,
)
}))
}
......@@ -3,8 +3,11 @@ package middleware
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
......@@ -13,7 +16,17 @@ import (
// operations validate their narrower proof scopes in their controller.
func SecureVerificationRequired() gin.HandlerFunc {
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
}
c.Set("secure_verified", true)
......@@ -23,31 +36,43 @@ func SecureVerificationRequired() gin.HandlerFunc {
// RequireSecurityProof validates a proof against the authenticated dashboard
// 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)
if !ok {
securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效")
return false
return nil
}
raw := strings.TrimSpace(c.GetHeader("X-Security-Proof"))
if raw == "" {
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 {
case errors.Is(err, service.ErrAuthTokenExpired):
securityProofError(c, "SECURITY_PROOF_EXPIRED", "安全验证已过期")
case errors.Is(err, service.ErrProofScope):
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):
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", "安全验证状态无效")
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) {
......
......@@ -24,6 +24,9 @@ const (
AuthFlowPurposeTelegramAssertion = "telegram_assertion"
AuthFlowIntentLogin = "login"
AuthFlowIntentBind = "bind"
AuthFlowIntentVerify = "verify"
AuthFlowPurposeTwoFASetup = "2fa_setup"
AuthFlowPurposeSecurityProof = "security_proof"
AuthFlowTokenBytes = 32
AuthFlowDefaultCleanupRetention = 24 * time.Hour
)
......@@ -72,6 +75,47 @@ type AuthFlowMatch struct {
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 {
query = query.Where("token_hash = ? AND purpose = ?", authFlowTokenHash(token), match.Purpose)
if match.Provider != "" {
......@@ -94,6 +138,10 @@ func authFlowTokenHash(token string) string {
}
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()) {
return "", nil, ErrAuthFlowInvalid
}
......@@ -112,7 +160,7 @@ func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) {
Payload: input.Payload,
ExpiresAt: input.ExpiresAt,
}
if err := DB.Create(flow).Error; err != nil {
if err := tx.Create(flow).Error; err != nil {
return "", nil, err
}
return token, flow, nil
......@@ -192,30 +240,31 @@ func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx
}
var consumed AuthFlow
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 errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAuthFlowInvalid
}
return err
}
if consumed.ConsumedAt != nil {
if result.RowsAffected != 1 && consumed.ConsumedAt != nil {
return ErrAuthFlowConsumed
}
now := time.Now()
if !consumed.ExpiresAt.After(now) {
if !consumed.ExpiresAt.After(time.Now()) {
return ErrAuthFlowExpired
}
result := tx.Model(&AuthFlow{}).
Where("id = ? AND consumed_at IS NULL AND expires_at > ?", consumed.Id, now).
Update("consumed_at", now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrAuthFlowConsumed
return ErrAuthFlowInvalid
}
consumed.ConsumedAt = &now
if action != nil {
if err := action(tx, &consumed); err != nil {
return err
......
......@@ -201,10 +201,26 @@ func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) e
// UpsertPasskeyCredentialWithAuthVersion is reserved for enrollment changes;
// assertion sign-count updates must use UpdatePasskeyAssertionState.
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 {
return fmt.Errorf("Passkey 保存失败,请重试")
}
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 {
return err
}
......@@ -216,10 +232,23 @@ func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) 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 {
return fmt.Errorf("删除失败,请重试")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
}
var credential PasskeyCredential
if err := lockForUpdate(tx).Where("user_id = ?", userID).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
......
......@@ -62,33 +62,6 @@ func IsTwoFAEnabled(userId int) (bool, error) {
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 {
if t.Id == 0 {
return errors.New("2FA记录ID不能为空")
......@@ -100,27 +73,6 @@ func (t *TwoFA) updateUsageState() 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 重置失败尝试次数
func (t *TwoFA) ResetFailedAttempts() error {
t.FailedAttempts = 0
......@@ -184,18 +136,6 @@ func (t *TwoFA) IsLocked() bool {
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 {
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
......@@ -298,41 +238,6 @@ func DisableTwoFAWithAuthVersion(userId int) error {
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并更新使用记录
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 err := t.IncrementFailedAttempts(); err != nil {
common.SysLog("更新2FA失败次数失败: " + err.Error())
return false, err
}
return false, nil
}
......@@ -356,7 +261,7 @@ func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
t.LastUsedAt = &now
if err := t.updateUsageState(); err != nil {
common.SysLog("更新2FA使用记录失败: " + err.Error())
return false, err
}
return true, nil
......@@ -378,7 +283,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
if !valid {
// 增加失败次数
if err := t.IncrementFailedAttempts(); err != nil {
common.SysLog("更新2FA失败次数失败: " + err.Error())
return false, err
}
return false, nil
}
......@@ -390,7 +295,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
t.LastUsedAt = &now
if err := t.updateUsageState(); err != nil {
common.SysLog("更新2FA使用记录失败: " + err.Error())
return false, err
}
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 (
"github.com/QuantumNous/new-api/common"
"github.com/go-redis/redis/v8"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
......@@ -165,7 +166,9 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
user := User{Id: 123, Username: "backup-code-user", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).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
results := make(chan bool, attempts)
......@@ -203,13 +206,17 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
func TestPendingTwoFASetupAPIsRejectEnabledFactor(t *testing.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)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}
require.NoError(t, DB.Create(&twoFA).Error)
require.Error(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{"ABCD-1234"}))
require.Error(t, twoFA.DeletePendingTwoFASetup())
session := UserSession{SID: "enabled-factor-session", UserID: user.Id, Version: 1, UserAuthVersion: 1, Status: UserSessionStatusActive, ExpiresAt: time.Now().Add(time.Hour).Unix()}
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
require.NoError(t, DB.First(&stored, twoFA.Id).Error)
......@@ -231,12 +238,17 @@ func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) {
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}
require.NoError(t, DB.Create(&twoFA).Error)
require.NoError(t, twoFA.EnableWithAuthVersion())
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(&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"}
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)
assert.ErrorIs(t, twoFA.EnableWithAuthVersion(), ErrTwoFAAlreadyEnabled)
assert.ErrorIs(t, EnableTwoFAEnrollment(identity, token, code), ErrTwoFASetupInvalid)
assertUserAuthVersion(t, user.Id, 2)
require.NoError(t, ReplaceBackupCodesWithAuthVersion(user.Id, []string{"ABCD-1234"}))
assertUserAuthVersion(t, user.Id, 3)
......
......@@ -51,8 +51,6 @@ func (p *DiscordProvider) ExchangeToken(ctx context.Context, code string, c *gin
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
}
logger.LogDebug(ctx, "[OAuth-Discord] ExchangeToken: code=%s...", code[:min(len(code), 10)])
settings := system_setting.GetDiscordSettings()
redirectUri := fmt.Sprintf("%s/oauth/discord", system_setting.ServerAddress)
values := url.Values{}
......
......@@ -92,8 +92,6 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c
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)
values := url.Values{}
values.Set("grant_type", "authorization_code")
......@@ -150,7 +148,6 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c
}
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
var tokenResponse struct {
......@@ -236,7 +233,6 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke
}
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)
userId := gjson.Get(bodyStr, p.config.UserIdField).String()
......
......@@ -50,8 +50,6 @@ func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string, c *gin.
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
}
logger.LogDebug(ctx, "[OAuth-GitHub] ExchangeToken: code=%s...", code[:min(len(code), 10)])
values := map[string]string{
"client_id": common.GitHubClientId,
"client_secret": common.GitHubClientSecret,
......
......@@ -47,8 +47,6 @@ func (p *LinuxDOProvider) ExchangeToken(ctx context.Context, code string, c *gin
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
tokenEndpoint := common.GetEnvOrDefaultString("LINUX_DO_TOKEN_ENDPOINT", "https://connect.linux.do/oauth2/token")
credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret
......
......@@ -53,8 +53,6 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co
return nil, NewOAuthError(i18n.MsgOAuthInvalidCode, nil)
}
logger.LogDebug(ctx, "[OAuth-OIDC] ExchangeToken: code=%s...", code[:min(len(code), 10)])
settings := system_setting.GetOIDCSettings()
redirectUri := fmt.Sprintf("%s/oauth/oidc", system_setting.ServerAddress)
values := url.Values{}
......
......@@ -65,7 +65,8 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/waffo-pancake/webhook/:env", anonymousRequestBodyLimit, controller.WaffoPancakeWebhook)
// 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")
{
......@@ -98,10 +99,10 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.POST("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.DELETE("/token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RevokeAccessToken)
selfRoute.GET("/passkey", controller.PasskeyStatus)
selfRoute.POST("/passkey/register/begin", middleware.DisableCache(), controller.PasskeyRegisterBegin)
selfRoute.POST("/passkey/register/finish", middleware.DisableCache(), controller.PasskeyRegisterFinish)
selfRoute.POST("/passkey/verify/begin", middleware.DisableCache(), controller.PasskeyVerifyBegin)
selfRoute.POST("/passkey/verify/finish", middleware.DisableCache(), controller.PasskeyVerifyFinish)
selfRoute.POST("/passkey/register/begin", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterBegin)
selfRoute.POST("/passkey/register/finish", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterFinish)
selfRoute.POST("/passkey/verify/begin", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyVerifyBegin)
selfRoute.POST("/passkey/verify/finish", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyVerifyFinish)
selfRoute.DELETE("/passkey", middleware.DisableCache(), controller.PasskeyDelete)
selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
......@@ -121,8 +122,8 @@ func SetApiRouter(router *gin.Engine) {
// 2FA routes
selfRoute.GET("/2fa/status", controller.Get2FAStatus)
selfRoute.POST("/2fa/setup", middleware.DisableCache(), controller.Setup2FA)
selfRoute.POST("/2fa/enable", middleware.DisableCache(), controller.Enable2FA)
selfRoute.POST("/2fa/setup", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.Setup2FA)
selfRoute.POST("/2fa/enable", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.Enable2FA)
selfRoute.POST("/2fa/disable", middleware.DisableCache(), controller.Disable2FA)
selfRoute.POST("/2fa/backup_codes", middleware.DisableCache(), controller.RegenerateBackupCodes)
......
......@@ -10,13 +10,14 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
const (
AccessTokenTTL = 15 * time.Minute
SecurityProofTTL = 5 * time.Minute
SecurityProofTTL = time.Minute
LoginSessionTTL = 30 * 24 * time.Hour
RefreshReplayWindow = 30 * time.Second
accessTokenUse = "access"
......@@ -30,16 +31,13 @@ var (
ErrAuthTokenExpired = errors.New("authentication token has expired")
ErrProofScope = errors.New("security proof scope 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.
// Role, status and group are deliberately loaded from the user cache instead of JWT claims.
type AuthIdentity struct {
UserID int
SessionID string
UserAuthVersion int64
SessionVersion int64
}
type AuthIdentity = model.AuthSessionIdentity
type authClaims struct {
TokenUse string `json:"token_use"`
......@@ -48,6 +46,7 @@ type authClaims struct {
SessionVersion int64 `json:"sv"`
Method string `json:"method,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ContextHash string `json:"context_hash,omitempty"`
jwt.RegisteredClaims
}
......@@ -129,20 +128,28 @@ func ParseDashboardAccessToken(raw string) (identity AuthIdentity, internal bool
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)
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
}
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{
TokenUse: securityProofTokenUse,
SessionID: identity.SessionID,
UserAuthVersion: identity.UserAuthVersion,
SessionVersion: identity.SessionVersion,
Method: method,
Scopes: append([]string(nil), scopes...),
Scopes: []string{binding.Scope},
ContextHash: binding.ContextHash,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: authTokenIssuer,
Subject: strconv.Itoa(identity.UserID),
......@@ -150,45 +157,32 @@ func IssueSecurityProof(identity AuthIdentity, method string, scopes []string) (
ExpiresAt: jwt.NewNumericDate(expiresAt),
NotBefore: jwt.NewNumericDate(now.Add(-5 * time.Second)),
IssuedAt: jwt.NewNumericDate(now),
ID: uuid.NewString(),
ID: proofID,
},
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(authSigningKey(securityProofTokenUse))
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))
if err != nil {
return "", err
return nil, err
}
userID, err := strconv.Atoi(claims.Subject)
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
for _, method := range allowedMethods {
if hmac.Equal([]byte(claims.Method), []byte(method)) {
methodAllowed = true
break
}
if len(claims.Scopes) != 1 || claims.Scopes[0] != binding.Scope {
return nil, ErrProofScope
}
if !methodAllowed {
return "", ErrProofMethod
if claims.ContextHash == "" {
return nil, ErrAuthTokenInvalid
}
if requiredScope != "" {
found := false
for _, scope := range claims.Scopes {
if hmac.Equal([]byte(scope), []byte(requiredScope)) {
found = true
break
}
}
if !found {
return "", ErrProofScope
}
if !hmac.Equal([]byte(claims.ContextHash), []byte(binding.ContextHash)) {
return nil, ErrProofContext
}
return claims.Method, nil
return claims, nil
}
func parseAuthClaims(raw, expectedUse string, key []byte) (*authClaims, error) {
......
......@@ -19,6 +19,7 @@ func useTestSessionSecret(t *testing.T) {
}
func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t)
identity := AuthIdentity{UserID: 42, SessionID: "session-1", UserAuthVersion: 3, SessionVersion: 2}
......@@ -30,7 +31,9 @@ func TestAccessTokenRoundTripAndPurposeIsolation(t *testing.T) {
require.NoError(t, err)
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)
_, err = ParseAccessToken(proof)
assert.ErrorIs(t, err, ErrAuthTokenInvalid)
......@@ -57,6 +60,7 @@ func TestAccessTokenRejectsTampering(t *testing.T) {
}
func TestDashboardAccessTokenClassification(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t)
identity, internal, err := ParseDashboardAccessToken("opaque.key.with-dots")
......@@ -87,9 +91,11 @@ func TestDashboardAccessTokenClassification(t *testing.T) {
require.NoError(t, err)
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{
UserID: 42, SessionID: "session-1", UserAuthVersion: 1, SessionVersion: 1,
}, "2fa", []string{"channel.key.read"})
}, "2fa", binding)
require.NoError(t, err)
_, internal, err = ParseDashboardAccessToken(proof)
assert.True(t, internal)
......@@ -117,24 +123,40 @@ func TestDashboardAccessTokenClassification(t *testing.T) {
assert.ErrorIs(t, err, ErrAuthTokenExpired)
}
func TestSecurityProofBindsIdentityMethodAndScope(t *testing.T) {
func TestSecurityProofBindsIdentityAndOperation(t *testing.T) {
setupAuthSessionTestDB(t)
useTestSessionSecret(t)
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)
method, err := VerifySecurityProof(proof, identity, "channel.key.read", []string{"2fa", "passkey"})
claims, err := verifySecurityProof(proof, identity, binding)
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)
_, err = VerifySecurityProof(proof, identity, "channel.key.read", []string{"passkey"})
assert.ErrorIs(t, err, ErrProofMethod)
otherChannel, err := BindVerificationOperation(VerificationOperation{Scope: "channel.key.read", Context: []byte(`{"channel_id":456}`)})
require.NoError(t, err)
_, err = verifySecurityProof(proof, identity, otherChannel)
assert.ErrorIs(t, err, ErrProofContext)
otherSession := identity
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))
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 (
"github.com/QuantumNous/new-api/model"
webauthn "github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm"
)
var errSessionNotFound = errors.New("Passkey 会话不存在或已过期")
const passkeyFlowTTL = 5 * time.Minute
type flowPayload struct {
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 {
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 {
return "", 0, err
}
expiresAt := time.Now().Add(passkeyFlowTTL)
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: purpose,
UserId: userID,
SessionId: sessionID,
UserId: security.UserID,
SessionId: security.SessionID,
Payload: string(payload),
ExpiresAt: expiresAt,
})
......@@ -41,21 +53,30 @@ func CreateSessionDataFlow(purpose string, userID int, sessionID, scope string,
return token, expiresAt.Unix(), nil
}
func PopSessionDataFlow(token, purpose string, userID int, sessionID string) (*webauthn.SessionData, string, error) {
flow, err := model.ConsumeAuthFlow(token, model.AuthFlowMatch{
func PopSessionDataFlow(token, purpose string, identity model.AuthSessionIdentity) (*webauthn.SessionData, *FlowSecurity, error) {
var payload flowPayload
_, err := model.ConsumeAuthFlowWithAction(token, model.AuthFlowMatch{
Purpose: purpose,
UserId: userID,
SessionId: sessionID,
UserId: identity.UserID,
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 errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowExpired) || errors.Is(err, model.ErrAuthFlowConsumed) {
return nil, "", errSessionNotFound
}
return nil, "", err
}
var payload flowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
return nil, "", err
return nil, 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 @@
| 图表 | @visactor/vchart、@visactor/react-vchart |
| 工具 | qrcode.react、oxfmt、oxlint、vitest(可选) |
优先选用成熟、维护良好的开源库;仅在现有库无法满足或需特殊适配时自行实现,并评估可维护性与通用性
优先复用项目已有组件与能力(见 [3.3 组件](#33-组件));项目内无合适实现时,再评估已安装依赖及成熟、维护良好的开源库。仅在复用、组合或合理扩展仍无法满足需求时自行实现,并说明具体能力缺口
---
......@@ -81,6 +81,26 @@
### 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 须有明确类型(接口或类型别名)。
- **Props 使用**:组件 props 非必要不要解构,直接使用 `props.xxx` 访问属性,保持代码清晰(详见 [3.2 代码风格与类型](#32-代码风格与类型))。
- 单文件超过约 200 行时考虑拆分子组件或将逻辑抽到自定义 Hooks;类型定义可与组件同文件或放在同模块的 `types` 中。
......@@ -182,6 +202,7 @@
- 提交信息清晰、符合项目约定,描述变更内容与原因,中英文统一即可。
- 变更需经过代码审查,符合本文档规范,并关注质量、性能与安全。
- UI 变更完成前必须检查新增组件、基础组件导入及手写交互是否绕过已有业务封装;发现重复实现应在本次变更范围内改为复用。保留的新实现需在变更说明中说明能力缺口,typecheck、lint 和测试通过不能替代此项检查。
- 重大功能或规范变更时更新相关文档与 `AGENTS.md`
---
......@@ -193,3 +214,4 @@
- **2026-01-29**:重组文档结构,合并重复内容,明确主次与交叉引用。
- **2026-01-31**:在 3.2 中补充「类型检查」要求:改动 TS/TSX 后须执行 typecheck 并修复至无错。
- **2026-06-21**:在 3.2 中补充「Lint 检查」要求:完成代码改动前须修复所涉及文件的所有 lint error。
- **2026-09-06**:明确组件复用的强制检索流程、业务封装优先级、新增条件、常用入口及审查要求。
......@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import axios from 'axios'
import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api'
import { AuthOperationError } from '@/lib/secure-verification'
import { useAuthStore } from '@/stores/auth-store'
import {
......@@ -27,6 +28,7 @@ import {
} from './lib/password-encryption'
import { getAffiliateCode } from './lib/storage'
import type { TelegramAuthorization } from './lib/telegram-login'
import type { VerificationOperation } from './secure-verification/types'
import type {
LoginPayload,
LoginResponse,
......@@ -166,13 +168,26 @@ export async function githubOAuthStart(clientId: string, state: string) {
// Get OAuth state for CSRF protection
export async function createOAuthFlow(
provider: string,
intent: 'login' | 'bind'
intent: 'login' | 'bind' | 'verify',
operation?: VerificationOperation,
signal?: AbortSignal
): Promise<string> {
const aff = intent === 'login' ? getAffiliateCode() : ''
const res = await api.post(
'/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 (typeof res.data.data === 'string') return res.data.data
......@@ -180,7 +195,10 @@ export async function createOAuthFlow(
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
......
......@@ -25,7 +25,7 @@ import { AuthLayout } from '../auth-layout'
type OAuthCallbackScreenProps = {
provider: string
mode: 'login' | 'bind'
mode: 'login' | 'bind' | 'verify'
}
type ProviderMeta = {
......@@ -72,23 +72,30 @@ export function OAuthCallbackScreen({
}, [provider])
const providerLabel = t(label)
const isBindMode = mode === 'bind'
const headline = isBindMode
? t('Binding your {{provider}} account', { provider: providerLabel })
: t('Signing you in with {{provider}}', { provider: providerLabel })
const description = isBindMode
? t('Hang tight while we securely link this account to your profile.')
: t('Hang tight while we finish connecting your account.')
const secondaryNote = isBindMode
? t(
'You can close this tab once the binding completes or a success message appears in the original window.'
)
: t(
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds."
)
let headline = t('Signing you in with {{provider}}', {
provider: providerLabel,
})
let description = t('Hang tight while we finish connecting your account.')
let secondaryNote = t(
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds."
)
if (mode === 'bind') {
headline = t('Binding your {{provider}} account', {
provider: providerLabel,
})
description = t(
'Hang tight while we securely link this account to your profile.'
)
}
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 (
<AuthLayout>
......
......@@ -75,6 +75,6 @@ export const PASSWORD_RESET_COUNTDOWN = 30 // seconds
// OAuth Constants
// ============================================================================
export const OAUTH_BIND_CALLBACK_MESSAGE = 'oauth:binding:callback'
export const OAUTH_BIND_RESULT_MESSAGE = 'oauth:binding:result'
export const OAUTH_POPUP_CALLBACK_MESSAGE = 'oauth:popup:callback'
export const OAUTH_POPUP_RESULT_MESSAGE = 'oauth:popup:result'
export const TELEGRAM_BIND_RESULT_MESSAGE = 'telegram:binding:result'
......@@ -20,7 +20,7 @@ import { describe, expect, test } from 'vitest'
import {
getOAuthSessionStorage,
markOAuthBindPopup,
markOAuthPopup,
resolveOAuthCallbackMode,
type OAuthModeStorage,
} from '../oauth-callback-mode'
......@@ -39,7 +39,7 @@ const bindState = 'bind-state'
describe('resolveOAuthCallbackMode', () => {
test('matching provider and state mark is treated as a bind flow', () => {
const storage = fakeStorage()
expect(markOAuthBindPopup(storage, 'oidc', bindState)).toBe(true)
expect(markOAuthPopup(storage, 'oidc', bindState, 'bind')).toBe(true)
expect(
resolveOAuthCallbackMode('oidc', bindState, {
......@@ -49,6 +49,25 @@ describe('resolveOAuthCallbackMode', () => {
).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
// 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
......@@ -66,7 +85,7 @@ describe('resolveOAuthCallbackMode', () => {
test('bind marker for another provider does not hijack this callback', () => {
const storage = fakeStorage()
markOAuthBindPopup(storage, 'github', bindState)
markOAuthPopup(storage, 'github', bindState, 'bind')
expect(
resolveOAuthCallbackMode('oidc', bindState, {
......@@ -78,7 +97,7 @@ describe('resolveOAuthCallbackMode', () => {
test('stale bind marker does not hijack a later callback', () => {
const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', 'previous-state')
markOAuthPopup(storage, 'oidc', 'previous-state', 'bind')
expect(
resolveOAuthCallbackMode('oidc', bindState, {
......@@ -90,7 +109,7 @@ describe('resolveOAuthCallbackMode', () => {
test('bind marker without an opener falls back to login', () => {
const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', bindState)
markOAuthPopup(storage, 'oidc', bindState, 'bind')
expect(
resolveOAuthCallbackMode('oidc', bindState, {
......@@ -102,7 +121,7 @@ describe('resolveOAuthCallbackMode', () => {
test('closed opener falls back to login', () => {
const storage = fakeStorage()
markOAuthBindPopup(storage, 'oidc', bindState)
markOAuthPopup(storage, 'oidc', bindState, 'bind')
expect(
resolveOAuthCallbackMode('oidc', bindState, {
......@@ -157,16 +176,17 @@ describe('OAuth bind popup storage', () => {
},
}
expect(markOAuthBindPopup(null, 'oidc', bindState)).toBe(false)
expect(markOAuthBindPopup(storage, 'oidc', bindState)).toBe(false)
expect(markOAuthPopup(null, 'oidc', bindState, 'bind')).toBe(false)
expect(markOAuthPopup(storage, 'oidc', bindState, 'bind')).toBe(false)
expect(
markOAuthBindPopup(
markOAuthPopup(
{
getItem: () => null,
setItem: () => undefined,
},
'oidc',
bindState
bindState,
'bind'
)
).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/>.
For commercial licensing, please contact support@quantumnous.com
*/
/**
* 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:'
const OAUTH_POPUP_FLOW_KEY_PREFIX = 'oauth_popup_flow:'
/** Minimal shape of `sessionStorage`, kept structural so tests can fake it. */
export interface OAuthModeStorage {
......@@ -58,7 +40,7 @@ export interface OAuthCallbackModeContext {
storage: OAuthModeStorage | null | undefined
}
export type OAuthCallbackMode = 'login' | 'bind'
export type OAuthCallbackMode = 'login' | 'bind' | 'verify'
/**
* Access `sessionStorage` without letting browser privacy settings crash the
......@@ -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.
*/
export function markOAuthBindPopup(
export function markOAuthPopup(
storage: OAuthModeStorage | null | undefined,
provider: string,
state: string
state: string,
intent: 'bind' | 'verify'
): boolean {
if (!storage || !provider || !state) return false
try {
const key = `${OAUTH_BIND_FLOW_KEY_PREFIX}${provider}`
storage.setItem(key, state)
return storage.getItem(key) === state
const key = `${OAUTH_POPUP_FLOW_KEY_PREFIX}${provider}`
const marker = JSON.stringify({ state, intent })
storage.setItem(key, marker)
return storage.getItem(key) === marker
} catch {
return false
}
......@@ -109,12 +93,24 @@ export function resolveOAuthCallbackMode(
): OAuthCallbackMode {
if (!opener || opener.closed || !storage || !state) return 'login'
let markedState: string | null = null
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 {
return 'login'
}
return markedState === state ? 'bind' : 'login'
return '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/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import { authRequestOptions, authResult } from '@/lib/secure-verification'
import type {
SecurityProof,
SecurityProofScope,
VerificationOperation,
} from '../secure-verification/types'
import type { ApiResponse, PasskeyOptionsPayload, PasskeyStatus } from './types'
......@@ -33,39 +34,50 @@ export async function getPasskeyStatus(): Promise<ApiResponse<PasskeyStatus>> {
return res.data
}
export async function beginPasskeyRegistration(
proofToken?: string
): Promise<ApiResponse<PasskeyOptionsPayload>> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/register/begin',
undefined,
{ headers: proofHeaders(proofToken) }
export function beginPasskeyRegistration(
proofToken: string,
signal?: AbortSignal
): Promise<PasskeyOptionsPayload> {
return authResult(
api.post('/api/user/passkey/register/begin', undefined, {
...authRequestOptions,
headers: proofHeaders(proofToken),
signal,
})
)
return res.data
}
export async function finishPasskeyRegistration(
export function finishPasskeyRegistration(
flowToken: string,
payload: Record<string, unknown>,
proofToken?: string
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/register/finish',
{
flow_token: flowToken,
credential: payload,
},
{ headers: proofHeaders(proofToken), acceptAuthRotation: true }
signal?: AbortSignal
): Promise<unknown> {
return authResult(
api.post(
'/api/user/passkey/register/finish',
{ flow_token: flowToken, credential: payload },
{
...authRequestOptions,
acceptAuthRotation: true,
singleUseAuthorization: true,
signal,
}
)
)
return res.data
}
export async function deletePasskey(proofToken?: string): Promise<ApiResponse> {
const res = await api.delete<ApiResponse>('/api/user/passkey', {
headers: proofHeaders(proofToken),
acceptAuthRotation: true,
})
return res.data
export function deletePasskey(
proofToken: string,
signal?: AbortSignal
): Promise<unknown> {
return authResult(
api.delete('/api/user/passkey', {
...authRequestOptions,
headers: proofHeaders(proofToken),
acceptAuthRotation: true,
signal,
})
)
}
export async function beginPasskeyLogin(): Promise<
......@@ -89,23 +101,32 @@ export async function finishPasskeyLogin(
return res.data
}
export async function beginPasskeyVerification(
scope: SecurityProofScope
): Promise<ApiResponse<PasskeyOptionsPayload>> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/verify/begin',
{ scope }
export function beginPasskeyVerification(
operation: VerificationOperation,
signal?: AbortSignal
): Promise<PasskeyOptionsPayload> {
return authResult(
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,
payload: Record<string, unknown>
): Promise<ApiResponse<SecurityProof>> {
const res = await api.post<ApiResponse<SecurityProof>>(
'/api/user/passkey/verify/finish',
{ flow_token: flowToken, credential: payload }
payload: Record<string, unknown>,
signal?: AbortSignal
): Promise<SecurityProof> {
return authResult(
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/>.
For commercial licensing, please contact support@quantumnous.com
*/
import i18next from 'i18next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
buildRegistrationResult,
createCredential,
isPasskeySupported as detectPasskeySupport,
isPasskeySupported,
prepareCredentialCreationOptions,
} from '@/lib/passkey'
import { AuthOperationError } from '@/lib/secure-verification'
import {
beginPasskeyRegistration,
......@@ -35,168 +34,156 @@ import {
} from '../api'
import type { PasskeyStatus } from '../types'
interface UsePasskeyManagementOptions {
onStatusChange?: (status: PasskeyStatus | null) => void
}
export function usePasskeyManagement(
options: UsePasskeyManagementOptions = {}
) {
const { onStatusChange } = options
export function usePasskeyManagement() {
const [status, setStatus] = useState<PasskeyStatus | null>(null)
const [statusError, setStatusError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [registering, setRegistering] = useState(false)
const [removing, setRemoving] = useState(false)
const [supported, setSupported] = useState(false)
const operation = useRef<AbortController | null>(null)
const mounted = useRef(true)
const fetchStatus = useCallback(async () => {
setLoading(true)
try {
setLoading(true)
const res = await getPasskeyStatus()
if (res.success) {
setStatus(res.data ?? null)
onStatusChange?.(res.data ?? null)
} else {
setStatus(null)
toast.error(res.message || i18next.t('Failed to load Passkey status'))
const response = await getPasskeyStatus()
if (!response.success || !response.data) {
throw new AuthOperationError(
response.message || 'Failed to load Passkey status'
)
}
if (!mounted.current) return
setStatus(response.data)
setStatusError(null)
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Passkey] Failed to fetch status', error)
toast.error(i18next.t('Failed to load Passkey status'))
setStatus(null)
if (mounted.current) {
setStatusError(AuthOperationError.from(error).message)
}
} finally {
setLoading(false)
if (mounted.current) setLoading(false)
}
}, [onStatusChange])
}, [])
useEffect(() => {
fetchStatus()
mounted.current = true
void fetchStatus()
void isPasskeySupported().then((value) => {
if (mounted.current) setSupported(value)
})
return () => {
mounted.current = false
operation.current?.abort()
}
}, [fetchStatus])
useEffect(() => {
detectPasskeySupport()
.then(setSupported)
.catch(() => setSupported(false))
}, [])
const register = useCallback(
async (proofToken?: string) => {
if (!supported) {
toast.error(i18next.t('This device does not support Passkey'))
return false
async (proofToken: string) => {
if (!supported || !navigator.credentials) {
throw new AuthOperationError('This device does not support Passkey')
}
if (!navigator?.credentials) {
toast.error(i18next.t('Passkey is not supported in this environment'))
return false
if (operation.current) {
throw new AuthOperationError(
'A security operation is already in progress.'
)
}
const controller = new AbortController()
operation.current = controller
setRegistering(true)
try {
const beginResponse = await beginPasskeyRegistration(proofToken)
if (!beginResponse.success) {
toast.error(
beginResponse.message ||
i18next.t('Failed to start Passkey registration')
)
return false
}
const publicKey = prepareCredentialCreationOptions(
beginResponse.data?.options ?? beginResponse.data
const begin = await beginPasskeyRegistration(
proofToken,
controller.signal
)
const flowToken = beginResponse.data?.flow_token
if (!flowToken) {
toast.error(i18next.t('Registration flow expired. Please try again.'))
return false
if (!begin.flow_token) {
throw new AuthOperationError(
'Registration flow expired. Please try again.'
)
}
const credential = (await createCredential(
publicKey
prepareCredentialCreationOptions(begin.options ?? begin),
controller.signal
)) as PublicKeyCredential | null
controller.signal.throwIfAborted()
if (!credential) {
toast.error(i18next.t('Passkey registration was cancelled'))
return false
throw new AuthOperationError(
'Passkey registration was cancelled',
'AUTH_CANCELLED'
)
}
const attestation = buildRegistrationResult(credential)
if (!attestation) {
toast.error(i18next.t('Invalid Passkey registration response'))
return false
throw new AuthOperationError('Invalid Passkey registration response')
}
const finishResponse = await finishPasskeyRegistration(
flowToken,
await finishPasskeyRegistration(
begin.flow_token,
attestation,
proofToken
controller.signal
)
if (!finishResponse.success) {
toast.error(
finishResponse.message || i18next.t('Failed to register Passkey')
)
return false
}
toast.success(i18next.t('Passkey registered successfully'))
controller.signal.throwIfAborted()
await fetchStatus()
return true
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(i18next.t('Passkey registration was cancelled'))
return false
} catch (error) {
if (mounted.current && !controller.signal.aborted) await fetchStatus()
if (
controller.signal.aborted ||
(error instanceof DOMException && error.name === 'NotAllowedError')
) {
throw new AuthOperationError(
'Passkey registration was cancelled',
'AUTH_CANCELLED',
{ cause: error }
)
}
// eslint-disable-next-line no-console
console.error('[Passkey] Registration error', error)
toast.error(
error instanceof Error
? error.message
: i18next.t('Failed to register Passkey')
)
return false
throw AuthOperationError.from(error, 'Failed to register Passkey')
} finally {
setRegistering(false)
if (operation.current === controller) operation.current = null
if (mounted.current) setRegistering(false)
}
},
[supported, fetchStatus]
[fetchStatus, supported]
)
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)
try {
const res = await deletePasskey(proofToken)
if (!res.success) {
toast.error(res.message || i18next.t('Failed to remove Passkey'))
return false
}
toast.success(i18next.t('Passkey removed successfully'))
await deletePasskey(proofToken, controller.signal)
controller.signal.throwIfAborted()
await fetchStatus()
return true
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Passkey] Removal error', error)
toast.error(i18next.t('Failed to remove Passkey'))
return false
if (mounted.current && !controller.signal.aborted) await fetchStatus()
if (controller.signal.aborted) {
throw new AuthOperationError(
'Operation cancelled',
'AUTH_CANCELLED',
{ cause: error }
)
}
throw AuthOperationError.from(error, 'Failed to remove Passkey')
} finally {
setRemoving(false)
if (operation.current === controller) operation.current = null
if (mounted.current) setRemoving(false)
}
},
[fetchStatus]
)
const enabled = useMemo(() => Boolean(status?.enabled), [status])
const lastUsed = useMemo(() => status?.last_used_at ?? null, [status])
return {
status,
statusError,
loading,
registering,
removing,
supported,
enabled,
lastUsed,
enabled: Boolean(status?.enabled),
lastUsed: status?.last_used_at ?? null,
fetchStatus,
register,
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/>.
For commercial licensing, please contact support@quantumnous.com
*/
export type VerificationMethod = '2fa' | 'passkey'
export type VerificationMethod = '2fa' | 'passkey' | 'password' | 'oauth'
export type SecurityProofScope =
| 'channel.key.read'
| 'passkey.register'
| '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 {
proof_token: string
......@@ -30,31 +37,32 @@ export interface SecurityProof {
scope: SecurityProofScope
}
export interface VerificationMethods {
has2FA: boolean
hasPasskey: boolean
passkeySupported: boolean
}
export interface SecureVerificationState {
method: VerificationMethod | null
scope?: SecurityProofScope
loading: boolean
code: string
title?: string
description?: string
export interface VerificationRequirements {
scope: SecurityProofScope
methods: { method: VerificationMethod; available: boolean; reason?: string }[]
oauth_providers: { slug: string; name: string }[]
password_encryption_enabled: boolean
}
export interface UseSecureVerificationOptions {
onSuccess?: (result: unknown, method: VerificationMethod) => void
onError?: (error: unknown) => void
successMessage?: string
autoReset?: boolean
}
export type VerificationInput =
| { method: '2fa'; code: string }
| { method: 'password'; password: string }
| { method: 'passkey' }
| { method: 'oauth'; provider: string }
export interface StartVerificationOptions {
scope: SecurityProofScope
preferredMethod?: VerificationMethod
export type RequestVerificationOptions = VerificationOperation & {
title?: 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<{
*/
export async function getChannelKey(
id: number,
proofToken?: string
proofToken: string,
signal?: AbortSignal
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
const res = await api.post(
`/api/channel/${id}/key`,
undefined,
channelActionConfig({
headers: proofToken ? { 'X-Security-Proof': proofToken } : undefined,
headers: { 'X-Security-Proof': proofToken },
signal,
})
)
return res.data
......
......@@ -107,10 +107,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
SecureVerificationDialog,
useSecureVerification,
} from '@/features/auth/secure-verification'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
import {
......@@ -131,7 +128,6 @@ import {
fetchModels,
getAllModels,
getChannel,
getChannelKey,
getGroups,
getPrefillGroups,
getTaskPluginOptions,
......@@ -152,6 +148,7 @@ import {
MODEL_FETCHABLE_TYPES,
OPENAI_FIELD_PASSTHROUGH_TYPES,
} from '../../constants'
import { useChannelKeyDisclosure } from '../../hooks/use-channel-key-disclosure'
import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form'
import {
CHANNEL_FORM_DEFAULT_VALUES,
......@@ -630,8 +627,6 @@ export function ChannelMutateDrawer({
)
const canRevealChannelKey = currentUser?.role === ROLE.SUPER_ADMIN
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
const [channelKey, setChannelKey] = useState<string | null>(null)
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] =
useState(false)
const initialModelsRef = useRef<string[]>([])
......@@ -695,25 +690,8 @@ export function ChannelMutateDrawer({
const { copyToClipboard } = useCopyToClipboard()
const {
open: verificationOpen,
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])
const { channelKey, isChannelKeyLoading, handleRevealKey, verification } =
useChannelKeyDisclosure(open, channelId)
// Check if this is a multi-key channel
const isMultiKeyChannel =
......@@ -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 () => {
if (!channelId) return
setIsCodexCredentialRefreshing(true)
......@@ -3132,11 +3067,11 @@ export function ChannelMutateDrawer({
onClick={handleRevealKey}
disabled={
isChannelKeyLoading ||
verificationState.loading
verification.isActive
}
>
{isChannelKeyLoading ||
verificationState.loading ? (
verification.isActive ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<Eye className='mr-2 h-4 w-4' />
......@@ -4931,22 +4866,7 @@ export function ChannelMutateDrawer({
existingModelsOverride={currentModelsArray}
/>
<SecureVerificationDialog
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}
/>
<SecureVerificationDialog {...verification.dialogProps} />
{/* Missing Models Confirmation Dialog */}
<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 {
backup_codes_remaining: number
}
/**
* Two-Factor Authentication Setup Data
*/
export interface TwoFASetupData {
secret: string
qr_code_data: string
backup_codes: string[]
}
// ============================================================================
// Checkin Type Definitions
// ============================================================================
......
......@@ -16,8 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 { authRequestOptions, authResult } from '@/lib/secure-verification'
export interface AccessTokenStatus {
exists: boolean
......@@ -51,3 +52,47 @@ export async function revokeAccessToken(): Promise<void> {
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
*/
import { Loader2 } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { useState, useEffect, useCallback } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
......@@ -28,8 +27,8 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
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
......@@ -37,102 +36,29 @@ import { setup2FA, enable2FA } from '@/lib/api'
interface TwoFASetupDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
setupData: TwoFASetupData | null
loading: boolean
initializing: boolean
error?: string
onCancel: () => void
onEnable: (code: string) => Promise<void>
}
export function TwoFASetupDialog({
open,
onOpenChange,
onSuccess,
}: TwoFASetupDialogProps) {
export function TwoFASetupDialog(props: TwoFASetupDialogProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [initializing, setInitializing] = useState(false)
const [step, setStep] = useState(0)
const [setupData, setSetupData] = useState<TwoFASetupData | null>(null)
const [code, setCode] = useState('')
const stepLabels = [
t('Scan QR Code'),
t('Save Backup Codes'),
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 (
<Dialog
open={open}
onOpenChange={handleOpenChange}
open={props.open}
onOpenChange={(open) => {
if (!open) props.onCancel()
}}
title={t('Setup Two-Factor Authentication')}
description={
<>
......@@ -151,7 +77,7 @@ export function TwoFASetupDialog({
<Button
variant='outline'
onClick={() => setStep(step - 1)}
disabled={initializing || loading}
disabled={props.initializing || props.loading}
>
{t('Back')}
</Button>
......@@ -159,24 +85,31 @@ export function TwoFASetupDialog({
{step < 2 ? (
<Button
onClick={() => setStep(step + 1)}
disabled={initializing || !setupData}
disabled={props.initializing || !props.setupData}
>
{t('Next')}
</Button>
) : (
<Button
onClick={handleEnable}
disabled={initializing || loading || !code}
onClick={() => void props.onEnable(code)}
disabled={props.initializing || props.loading || !code}
>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Enabling...') : t('Enable 2FA')}
{props.loading && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{props.loading ? t('Enabling...') : t('Enable 2FA')}
</Button>
)}
</>
}
>
<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='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'>
......@@ -184,14 +117,14 @@ export function TwoFASetupDialog({
</div>
</div>
)}
{!initializing && !setupData && (
{!props.initializing && !props.setupData && (
<div className='flex justify-center py-8'>
<div className='text-muted-foreground'>
{t('Failed to load setup data')}
</div>
</div>
)}
{!initializing && setupData && (
{!props.initializing && props.setupData && (
<>
{/* Step 0: QR Code */}
{step === 0 && (
......@@ -202,7 +135,7 @@ export function TwoFASetupDialog({
)}
</p>
<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 className='bg-muted rounded-lg p-3'>
<div className='flex items-center justify-between'>
......@@ -211,11 +144,11 @@ export function TwoFASetupDialog({
{t('Or enter this key manually:')}
</p>
<code className='font-mono text-sm'>
{setupData.secret}
{props.setupData.secret}
</code>
</div>
<CopyButton
value={setupData.secret}
value={props.setupData.secret}
variant='ghost'
tooltip={t('Copy secret key')}
aria-label={t('Copy secret key')}
......@@ -237,7 +170,7 @@ export function TwoFASetupDialog({
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code) => (
{props.setupData.backup_codes.map((code) => (
<div
key={code}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
......@@ -248,7 +181,7 @@ export function TwoFASetupDialog({
</div>
</div>
<CopyButton
value={setupData.backup_codes.join('\n')}
value={props.setupData.backup_codes.join('\n')}
variant='outline'
size='default'
className='w-full'
......@@ -272,7 +205,7 @@ export function TwoFASetupDialog({
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter 6-digit code')}
maxLength={6}
disabled={loading}
disabled={props.loading}
/>
<p className='text-muted-foreground text-xs'>
{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/>.
For commercial licensing, please contact support@quantumnous.com
*/
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 { toast } from 'sonner'
......@@ -47,10 +47,9 @@ import { usePasskeyManagement } from '@/features/auth/passkey'
import {
SecureVerificationDialog,
useSecureVerification,
type VerificationMethod,
type VerificationMethods,
} from '@/features/auth/secure-verification'
import dayjs from '@/lib/dayjs'
import { AuthOperationError } from '@/lib/secure-verification'
interface PasskeyCardProps {
loading: boolean
......@@ -59,11 +58,10 @@ interface PasskeyCardProps {
export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
const { t } = useTranslation()
const [confirmOpen, setConfirmOpen] = useState(false)
const [restrictedMethod, setRestrictedMethod] =
useState<VerificationMethod | null>(null)
const {
status,
statusError,
fetchStatus,
loading,
registering,
removing,
......@@ -74,121 +72,38 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
remove,
} = usePasskeyManagement()
const {
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 verification = useSecureVerification()
const handleRegister = useCallback(async () => {
if (!supported) {
toast.info(t('This device does not support Passkey'))
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, {
if (registering || removing || verification.isActive) return
const proof = await verification.requestVerification({
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])
const handleRemove = useCallback(async () => {
const methods = await fetchVerificationMethods()
let required: VerificationMethod | null = null
if (methods.has2FA) {
required = '2fa'
} 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
if (!proof) return
try {
await register(proof.proof_token)
toast.success(t('Passkey registered successfully'))
} catch (error) {
const failure = AuthOperationError.from(error)
if (failure.code !== 'AUTH_CANCELLED') toast.error(t(failure.message))
}
}, [register, registering, removing, t, verification])
const handleRemove = useCallback(async () => {
if (registering || removing || verification.isActive) return
setConfirmOpen(false)
setRestrictedMethod(required)
await startVerification(remove, {
const proof = await verification.requestVerification({
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])
const handleVerificationCancel = useCallback(() => {
setRestrictedMethod(null)
cancelVerification()
}, [cancelVerification])
const handleVerificationOpenChange = useCallback(
(next: boolean) => {
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 (!proof) return
try {
await remove(proof.proof_token)
toast.success(t('Passkey removed successfully'))
} catch (error) {
const failure = AuthOperationError.from(error)
if (failure.code !== 'AUTH_CANCELLED') toast.error(t(failure.message))
}
}, [registering, remove, removing, t, verification])
if (pageLoading || loading) {
return (
......@@ -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 =
lastUsed && !Number.isNaN(Date.parse(lastUsed))
? dayjs(lastUsed).fromNow()
......@@ -276,7 +205,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
<Button
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
onClick={handleRegister}
disabled={!supported || registering}
disabled={!supported || registering || verification.isActive}
>
{registering && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
......@@ -355,16 +284,7 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
</CardContent>
</Card>
<SecureVerificationDialog
open={verificationOpen}
onOpenChange={handleVerificationOpenChange}
methods={dialogMethods}
state={verificationState}
onVerify={handleDialogVerify}
onCancel={handleVerificationCancel}
onCodeChange={setCode}
onMethodChange={switchMethod}
/>
<SecureVerificationDialog {...verification.dialogProps} />
</>
)
}
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