Commit 6f233399 by CaIon

feat(auth): unify login verification and secure account deletion

Treat TOTP and Passkey as alternative enrolled factors across login and
sensitive account operations. Gate every primary login transport before
issuing a session, require WebAuthn user verification, and consume login
challenges atomically with session creation.

Reuse the shared verification UI for login, 2FA management, and account
deletion. Require scoped, single-use deletion proof; recheck the session
inside the deletion transaction and revoke all sessions afterward.

Validation: controller/service/model/middleware tests; real SQLite 3.50.4,
MySQL 8.4.11, and PostgreSQL 16.15 security regressions; frontend tests,
TypeScript, targeted lint, formatting, and production build.

Deploy the frontend and all backend nodes together. No schema changes.
parent 521cebf5
......@@ -19,6 +19,7 @@ var auditContentTemplates = map[string]string{
"user.create": "Created user ${username} (role ${role})",
"user.update": "Updated user ${username} (ID: ${id})",
"user.delete": "Deleted user ${username} (ID: ${id})",
"user.account_delete": "Account deletion",
"user.manage": "Performed ${action} on user ${username} (ID: ${id})",
"user.quota_add": "Increased user quota by ${quota}",
"user.quota_subtract": "Decreased user quota by ${quota}",
......
......@@ -115,7 +115,7 @@ func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) {
previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}, &model.TwoFA{}, &model.PasskeyCredential{}))
model.DB = db
common.RedisEnabled = false
common.UserSessionActiveLimit = 1
......
package controller
import (
"encoding/json"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
)
func VerifyLogin(c *gin.Context) {
var request struct {
FlowToken string `json:"flow_token"`
Method string `json:"method"`
Code string `json:"code"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.Code == "" {
common.ApiErrorMsg(c, "参数错误")
return
}
if request.Method == "" {
request.Method = service.VerificationMethodTwoFA
}
if request.Method != service.VerificationMethodTwoFA {
writeSecurityOperationError(c, service.ErrProofMethod)
return
}
bundle, err := service.VerifyLoginCode(request.FlowToken, request.Code, c.ClientIP(), c.Request.UserAgent())
if err != nil {
writeSecurityOperationError(c, err)
return
}
completeVerifiedLoginResponse(c, bundle, service.VerificationMethodTwoFA)
}
func LoginPasskeyBegin(c *gin.Context) {
var request struct {
FlowToken string `json:"flow_token"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" {
common.ApiErrorMsg(c, "参数错误")
return
}
verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey)
if err != nil {
writeSecurityOperationError(c, err)
return
}
credential, err := model.GetPasskeyByUserID(verification.State.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
user := &model.User{Id: verification.State.UserID}
options, sessionData, err := wa.BeginLogin(passkeysvc.NewWebAuthnUser(user, credential), webauthnlib.WithUserVerification(protocol.VerificationRequired))
if err != nil {
writeSecurityOperationError(c, err)
return
}
token, expiresAt, err := passkeysvc.CreateSessionDataFlow(model.AuthFlowPurposeLoginPasskey, passkeysvc.FlowSecurity{
AuthSessionIdentity: model.AuthSessionIdentity{UserID: user.Id, UserAuthVersion: verification.State.AuthVersion},
LoginFlowID: verification.Flow.Id, LoginExpiresAt: verification.Flow.ExpiresAt.Unix(),
}, sessionData)
if err != nil {
writeSecurityOperationError(c, err)
return
}
common.ApiSuccess(c, gin.H{"flow_token": token, "expires_at": expiresAt, "options": options})
}
func LoginPasskeyFinish(c *gin.Context) {
var request struct {
FlowToken string `json:"flow_token"`
PasskeyFlowToken string `json:"passkey_flow_token"`
Credential json.RawMessage `json:"credential"`
}
if common.DecodeJson(c.Request.Body, &request) != nil || request.FlowToken == "" || request.PasskeyFlowToken == "" || len(request.Credential) == 0 {
common.ApiErrorMsg(c, "参数错误")
return
}
verification, err := service.RequireLoginVerification(request.FlowToken, service.VerificationMethodPasskey)
if err != nil {
writeSecurityOperationError(c, err)
return
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
if err != nil {
writeSecurityOperationError(c, err)
return
}
identity := model.AuthSessionIdentity{UserID: verification.State.UserID, UserAuthVersion: verification.State.AuthVersion}
sessionData, security, err := passkeysvc.PopSessionDataFlow(request.PasskeyFlowToken, model.AuthFlowPurposeLoginPasskey, identity)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if security.LoginFlowID != verification.Flow.Id || sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
credential, err := model.GetPasskeyByUserID(identity.UserID)
if err != nil {
writeSecurityOperationError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
writeSecurityOperationError(c, err)
return
}
validated, err := wa.ValidateLogin(passkeysvc.NewWebAuthnUser(&model.User{Id: identity.UserID}, credential), *sessionData, parsed)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if err := model.UpdatePasskeyAssertionState(identity.UserID, validated, time.Now()); err != nil {
writeSecurityOperationError(c, err)
return
}
bundle, err := service.CompleteLoginVerification(request.FlowToken, verification, service.VerificationMethodPasskey, c.ClientIP(), c.Request.UserAgent())
if err != nil {
writeSecurityOperationError(c, err)
return
}
completeVerifiedLoginResponse(c, bundle, service.VerificationMethodPasskey)
}
func completeVerifiedLoginResponse(c *gin.Context, bundle *service.AuthBundle, method string) {
identity, err := service.ParseAccessToken(bundle.AccessToken)
if err != nil {
writeAuthSessionError(c, err)
return
}
user, err := model.GetSelfUserById(identity.UserID)
if err != nil {
writeAuthSessionError(c, err)
return
}
c.Set("login_verification_method", method)
writeLoginResponse(c, user, bundle)
}
......@@ -495,7 +495,7 @@ func TestListModelsTokenLimitUsesResolvedCustomAutoGroups(t *testing.T) {
func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}, &model.UserSession{}))
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.AuditLog{}, &model.UserSession{}, &model.TwoFA{}, &model.PasskeyCredential{}))
hashedPassword, err := common.Password2Hash("CurrentPassword123")
require.NoError(t, err)
......@@ -511,11 +511,12 @@ func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
router := gin.New()
router.GET("/", func(c *gin.Context) {
setupLogin(&model.User{
Id: user.Id,
Username: user.Username,
Role: user.Role,
Status: user.Status,
Group: user.Group,
Id: user.Id,
AuthVersion: user.AuthVersion,
Username: user.Username,
Role: user.Role,
Status: user.Status,
Group: user.Group,
}, c)
})
......
......@@ -77,7 +77,9 @@ func PasskeyRegisterBegin(c *gin.Context) {
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
var options []webauthnlib.RegistrationOption
selection := wa.Config.AuthenticatorSelection
selection.UserVerification = protocol.VerificationRequired
options := []webauthnlib.RegistrationOption{webauthnlib.WithAuthenticatorSelection(selection)}
if credential != nil {
descriptor := credential.ToWebAuthnCredential().Descriptor()
options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor}))
......@@ -169,6 +171,10 @@ func PasskeyRegisterFinish(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}, security.Authorization); err != nil {
writeSecurityOperationError(c, err)
return
......@@ -289,7 +295,7 @@ func PasskeyLoginBegin(c *gin.Context) {
return
}
assertion, sessionData, err := wa.BeginDiscoverableLogin()
assertion, sessionData, err := wa.BeginDiscoverableLogin(webauthnlib.WithUserVerification(protocol.VerificationRequired))
if err != nil {
writeSecurityOperationError(c, err)
return
......@@ -351,6 +357,10 @@ func PasskeyLoginFinish(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
handler := func(rawID, userHandle []byte) (webauthnlib.User, error) {
// 首先通过凭证ID查找用户
......@@ -410,7 +420,8 @@ func PasskeyLoginFinish(c *gin.Context) {
return
}
setupLogin(modelUser, c)
c.Set("login_verification_method", service.VerificationMethodPasskey)
setupLoginAtAuthVersion(modelUser, modelUser.AuthVersion, c)
}
func AdminResetPasskey(c *gin.Context) {
......@@ -512,12 +523,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
var options []webauthnlib.LoginOption
switch request.Scope {
case service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, service.VerificationScopePasswordSet, service.VerificationScopePasswordChange:
options = append(options, webauthnlib.WithUserVerification(protocol.VerificationRequired))
}
assertion, sessionData, err := wa.BeginLogin(waUser, options...)
assertion, sessionData, err := wa.BeginLogin(waUser, webauthnlib.WithUserVerification(protocol.VerificationRequired))
if err != nil {
writeSecurityOperationError(c, err)
return
......@@ -599,6 +605,10 @@ func PasskeyVerifyFinish(c *gin.Context) {
writeSecurityOperationError(c, err)
return
}
if sessionData.UserVerification != protocol.VerificationRequired {
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential)
......
......@@ -201,9 +201,16 @@ func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
service.VerificationScopeAccountBind, service.VerificationScopeAccountUnbind, passwordScope} {
requirements, err := service.GetVerificationRequirements(identity, scope)
require.NoError(t, err)
require.Len(t, requirements.Methods, 1)
count := 1
if test.twoFA && test.passkey {
count = 2
}
require.Len(t, requirements.Methods, count)
assert.Equal(t, test.method, requirements.Methods[0].Method)
assert.Equal(t, test.available, requirements.Methods[0].Available)
if count == 2 {
assert.Equal(t, service.VerificationMethodOption{Method: "passkey", Available: true}, requirements.Methods[1])
}
if test.wechat {
input := service.VerificationInput{Scope: scope, Method: "session"}
switch scope {
......@@ -354,7 +361,7 @@ func authorizeSecurityEnrollment(t *testing.T, identity service.AuthIdentity) *m
// securityPasskeyResponse acts as a software authenticator at the browser boundary.
// The handlers still validate the real WebAuthn challenge, origin and signature.
func securityPasskeyResponse(t *testing.T, key *ecdsa.PrivateKey, challenge string, registration bool, counter uint32) json.RawMessage {
func securityPasskeyResponse(t *testing.T, key *ecdsa.PrivateKey, challenge string, registration bool, counter uint32, userVerified ...bool) json.RawMessage {
t.Helper()
ceremony := "webauthn.get"
if registration {
......@@ -367,7 +374,11 @@ func securityPasskeyResponse(t *testing.T, key *ecdsa.PrivateKey, challenge stri
authData := append([]byte{}, rpIDHash[:]...)
response := map[string]any{"clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData)}
if registration {
authData = append(authData, 0x45) // user present, user verified, attested credential
flags := byte(0x45) // user present, user verified, attested credential
if len(userVerified) > 0 && !userVerified[0] {
flags = 0x41
}
authData = append(authData, flags)
authData = append(authData, make([]byte, 4+16)...)
authData = binary.BigEndian.AppendUint16(authData, uint16(len(credentialID)))
authData = append(authData, credentialID[:]...)
......@@ -380,7 +391,11 @@ func securityPasskeyResponse(t *testing.T, key *ecdsa.PrivateKey, challenge stri
require.NoError(t, err)
response["attestationObject"] = base64.RawURLEncoding.EncodeToString(attestation)
} else {
authData = append(authData, 0x05) // user present and verified
flags := byte(0x05) // user present and verified
if len(userVerified) > 0 && !userVerified[0] {
flags = 0x01
}
authData = append(authData, flags)
authData = binary.BigEndian.AppendUint32(authData, counter)
clientHash := sha256.Sum256(clientData)
signedData := append(append([]byte{}, authData...), clientHash[:]...)
......@@ -433,8 +448,8 @@ func TestSecurityEnrollmentMethodPolicy(t *testing.T) {
}{
{name: "first factor uses password", password: true, method: "password", available: true},
{name: "existing passkey takes precedence", password: true, passkey: true, method: "passkey", available: true},
{name: "twofa takes precedence over passkey", password: true, passkey: true, twoFA: true, method: "2fa", available: true},
{name: "locked twofa does not fall back", password: true, passkey: true, twoFA: true, locked: true, method: "2fa"},
{name: "twofa and passkey are alternatives", password: true, passkey: true, twoFA: true, method: "2fa", available: true},
{name: "locked twofa permits passkey", password: true, passkey: true, twoFA: true, locked: true, method: "2fa"},
{name: "disabled passkey does not fall back", password: true, passkey: true, disabledPasskey: true, method: "passkey"},
{name: "disabled registration does not request a password", password: true, disabledPasskey: true, method: "password"},
{name: "passwordless account without providers is unavailable", method: "oauth"},
......@@ -458,9 +473,16 @@ func TestSecurityEnrollmentMethodPolicy(t *testing.T) {
system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey
requirements, err := service.GetVerificationRequirements(identity, "passkey.register")
require.NoError(t, err)
require.Len(t, requirements.Methods, 1)
count := 1
if test.twoFA && test.passkey {
count = 2
}
require.Len(t, requirements.Methods, count)
assert.Equal(t, test.method, requirements.Methods[0].Method)
assert.Equal(t, test.available, requirements.Methods[0].Available)
if count == 2 {
assert.Equal(t, service.VerificationMethodOption{Method: "passkey", Available: true}, requirements.Methods[1])
}
if test.passkey && !test.twoFA {
requirements, err = service.GetVerificationRequirements(identity, "2fa.setup")
require.NoError(t, err)
......@@ -1145,7 +1167,11 @@ func TestSecurityEnrollmentTwoFAFailureAccountingAndStorageErrors(t *testing.T)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.False(t, body.Success, endpoint.path)
assert.Equal(t, "参数错误", body.Message, endpoint.path)
if endpoint.path == "/api/user/login/2fa" {
assert.Equal(t, "参数错误", body.Message, endpoint.path)
} else {
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code, endpoint.path)
}
}
stored, err := model.GetTwoFAByUserId(user.Id)
require.NoError(t, err)
......
......@@ -20,10 +20,6 @@ type Verify2FARequest struct {
FlowToken string `json:"flow_token,omitempty"`
}
type twoFALoginFlowPayload struct {
AuthVersion int64 `json:"auth_version"`
}
func Setup2FA(c *gin.Context) {
authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup})
if authorization == nil {
......@@ -65,43 +61,12 @@ func Enable2FA(c *gin.Context) {
// Disable2FA 禁用2FA
func Disable2FA(c *gin.Context) {
var req Verify2FARequest
if err := common.DecodeJsonWithValidation(c.Request.Body, &req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
return
}
userId := c.GetInt("id")
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if twoFA == nil || !twoFA.IsEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户未启用2FA",
})
return
}
if err := service.VerifyTwoFactorCode(twoFA, req.Code); err != nil {
writeSecurityOperationError(c, err)
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeTwoFADisable}) == nil {
return
}
// 禁用2FA并原子推进用户鉴权版本
if err := model.DisableTwoFAWithAuthVersion(userId); err != nil {
identity, _ := middleware.GetSessionAuthIdentity(c)
userId := identity.UserID
if err := model.DisableTwoFAForSession(identity); err != nil {
writeSecurityOperationError(c, err)
return
}
......@@ -159,43 +124,11 @@ func Get2FAStatus(c *gin.Context) {
// RegenerateBackupCodes 重新生成备用码
func RegenerateBackupCodes(c *gin.Context) {
var req Verify2FARequest
if err := common.DecodeJsonWithValidation(c.Request.Body, &req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
return
}
userId := c.GetInt("id")
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
writeSecurityOperationError(c, err)
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeTwoFABackupCodes}) == nil {
return
}
if twoFA == nil || !twoFA.IsEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户未启用2FA",
})
return
}
// 验证TOTP验证码
cleanCode, err := common.ValidateNumericCode(req.Code)
if err != nil {
common.ApiErrorMsg(c, "验证码必须是6位数字")
return
}
if err := service.VerifyTwoFactorCode(twoFA, cleanCode); err != nil {
writeSecurityOperationError(c, err)
return
}
identity, _ := middleware.GetSessionAuthIdentity(c)
userId := identity.UserID
// 生成新的备用码
backupCodes, err := common.GenerateBackupCodes()
if err != nil {
......@@ -207,13 +140,8 @@ func RegenerateBackupCodes(c *gin.Context) {
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiErrorMsg(c, "当前认证方式不支持安全验证")
return
}
// 保存新的备用码并原子推进用户鉴权版本
if err := model.ReplaceBackupCodesWithAuthVersion(userId, backupCodes); err != nil {
if err := model.ReplaceBackupCodesForSession(identity, backupCodes); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "保存备用码失败",
......@@ -241,79 +169,7 @@ func RegenerateBackupCodes(c *gin.Context) {
// Verify2FALogin 登录时验证2FA
func Verify2FALogin(c *gin.Context) {
var req Verify2FARequest
if err := common.DecodeJsonWithValidation(c.Request.Body, &req); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "参数错误",
})
return
}
flow, err := model.GetAuthFlow(req.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFALogin})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
// 获取用户信息
user, err := model.GetUserById(flow.UserId, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户不存在",
})
return
}
if user.Status != common.UserStatusEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已被禁用",
})
return
}
var flowPayload twoFALoginFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &flowPayload); err != nil || flowPayload.AuthVersion <= 0 || flowPayload.AuthVersion != user.AuthVersion {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(user.Id)
if err != nil {
writeSecurityOperationError(c, err)
return
}
if twoFA == nil || !twoFA.IsEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户未启用2FA",
})
return
}
if err := service.VerifyTwoFactorCode(twoFA, req.Code); err != nil {
writeSecurityOperationError(c, err)
return
}
if _, err := model.ConsumeAuthFlow(req.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTwoFALogin,
UserId: user.Id,
}); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
setupLoginAtAuthVersion(user, flowPayload.AuthVersion, c)
VerifyLogin(c)
}
// Admin2FAStats 管理员获取2FA统计信息
......
......@@ -8,7 +8,6 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
......@@ -97,48 +96,14 @@ func Login(c *gin.Context) {
return
}
// 检查是否启用2FA
twoFAEnabled, err := model.IsTwoFAEnabled(user.Id)
if err != nil {
common.SysLog(fmt.Sprintf("Login failed to load 2FA status for user %d: %v", user.Id, err))
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
return
}
if twoFAEnabled {
expiresAt := time.Now().Add(5 * time.Minute)
payload, err := common.Marshal(twoFALoginFlowPayload{AuthVersion: user.AuthVersion})
if err != nil {
common.ApiError(c, err)
return
}
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTwoFALogin,
UserId: user.Id,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"message": i18n.T(c, i18n.MsgUserRequire2FA),
"success": true,
"data": map[string]interface{}{
"require_2fa": true,
"flow_token": flowToken,
"expires_at": expiresAt.Unix(),
},
})
return
}
setupLogin(&user, c)
}
// loginMethodFromContext 根据请求路径推导登录方式,用于登录审计日志。
func loginMethodFromContext(c *gin.Context) string {
if method := c.GetString("login_method"); method != "" {
return method
}
switch c.FullPath() {
case "/api/user/login":
return "password"
......@@ -169,15 +134,29 @@ func recordLoginAudit(user *model.User, c *gin.Context) {
UserAgent: c.Request.UserAgent(),
}
content := fmt.Sprintf("Logged in successfully via %s", method)
model.RecordLoginLog(user.Id, user.Role, user.Username, content, ip, "login", map[string]interface{}{
params := map[string]interface{}{
"method": method,
}, extra, c)
}
if verifiedMethod := c.GetString("login_verification_method"); verifiedMethod != "" {
params["verification_method"] = verifiedMethod
}
model.RecordLoginLog(user.Id, user.Role, user.Username, content, ip, "login", params, extra, c)
}
// setupLogin creates a server-controlled login Session and returns the shared
// authentication bundle used by every login method.
// setupLogin evaluates the shared login policy after primary authentication.
// Only a completed Passkey ceremony may go directly to session issuance.
func setupLogin(user *model.User, c *gin.Context) {
setupLoginAtAuthVersion(user, 0, c)
challenge, err := service.StartLoginVerification(user, loginMethodFromContext(c))
if err != nil {
writeSecurityOperationError(c, err)
return
}
if challenge != nil {
setAuthNoStore(c)
common.ApiSuccess(c, challenge)
return
}
setupLoginAtAuthVersion(user, user.AuthVersion, c)
}
func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin.Context) {
......@@ -211,6 +190,11 @@ func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin
writeAuthSessionError(c, err)
return
}
writeLoginResponse(c, currentUser, bundle)
}
func writeLoginResponse(c *gin.Context, user *model.User, bundle *service.AuthBundle) {
c.Set("login_method", bundle.Session.LoginMethod)
model.UpdateUserLastLoginAt(user.Id)
service.WriteRefreshCookie(c, bundle.RefreshToken)
setAuthNoStore(c)
......@@ -223,7 +207,7 @@ func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
"user": buildSelfUserData(currentUser),
"user": buildSelfUserData(user),
},
})
}
......@@ -956,24 +940,30 @@ func DeleteUser(c *gin.Context) {
}
func DeleteSelf(c *gin.Context) {
id := c.GetInt("id")
user, _ := model.GetUserById(id, false)
if user.Role == common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
setAuthNoStore(c)
succeeded := false
defer func() {
recordUserSecurityAudit(c, c.GetInt("id"), "user.account_delete", map[string]interface{}{"success": succeeded})
}()
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}) == nil {
return
}
err := model.DeleteUserById(id)
if err != nil {
common.ApiError(c, err)
identity, _ := middleware.GetSessionAuthIdentity(c)
if err := model.DeleteUserForSession(identity); err != nil {
if errors.Is(err, model.ErrCannotDeleteRootUser) {
common.ApiErrorI18n(c, i18n.MsgUserCannotDeleteRootUser)
return
}
writeSecurityOperationError(c, err)
return
}
succeeded = true
service.ClearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{},
})
return
}
func CreateUser(c *gin.Context) {
......
......@@ -12,6 +12,7 @@ var (
ErrAccountPasswordState = errors.New("Password settings have changed. Reload the page and try again.")
ErrLastLoginMethod = errors.New("Add another login method before unlinking this account.")
ErrAccountBindingChanged = errors.New("Account bindings have changed. Start this operation again.")
ErrCannotDeleteRootUser = errors.New("The root account cannot be deleted.")
)
// ChangeUserPassword rechecks the authorized session, password state and current
......
......@@ -17,6 +17,8 @@ import (
const (
AuthFlowPurposeOAuth = "oauth"
AuthFlowPurposeTwoFALogin = "2fa_login"
AuthFlowPurposeLoginVerification = "login_verification"
AuthFlowPurposeLoginPasskey = "login_passkey"
AuthFlowPurposePasskeyLogin = "passkey_login"
AuthFlowPurposePasskeyRegister = "passkey_register"
AuthFlowPurposePasskeyStepUp = "passkey_step_up"
......
package model
import (
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// UserVerificationState is an authoritative, credential-free projection for
// choosing an authentication method. Loading it never fetches credential secrets.
type UserVerificationState struct {
UserID int
Status int
Role int
AuthVersion int64
HasPassword bool
HasTwoFA bool
TwoFALocked bool
HasPasskey bool
}
func GetUserVerificationState(userID int) (*UserVerificationState, error) {
return getUserVerificationState(DB, userID, false)
}
func getUserVerificationState(tx *gorm.DB, userID int, forUpdate bool) (*UserVerificationState, error) {
if userID <= 0 {
return nil, ErrUserSessionInvalid
}
var state UserVerificationState
query := tx.Model(&User{}).Select(
"id AS user_id, status, role, auth_version, CASE WHEN password <> '' THEN 1 ELSE 0 END AS has_password, "+
"EXISTS (?) AS has_two_fa, EXISTS (?) AS two_fa_locked, EXISTS (?) AS has_passkey",
tx.Model(&TwoFA{}).Select("1").Where("user_id = ? AND is_enabled = ?", userID, true),
tx.Model(&TwoFA{}).Select("1").Where("user_id = ? AND is_enabled = ? AND locked_until > ?", userID, true, time.Now()),
tx.Model(&PasskeyCredential{}).Select("1").Where("user_id = ?", userID),
).Where("id = ?", userID)
if forUpdate {
query = lockForUpdate(query)
}
if err := query.Take(&state).Error; err != nil {
return nil, err
}
return &state, nil
}
// CreateUserSessionFromLoginFlow commits the one-time login authorization and
// the resulting session together. The user lock serializes credential changes
// and session issuance, including the per-user session limits.
func CreateUserSessionFromLoginFlow(token string, session *UserSession, validate func(*AuthFlow, *UserVerificationState) error) error {
if session == nil || validate == nil {
return ErrUserSessionInvalid
}
cacheDeadline := userSessionCacheDeadline()
_, err := ConsumeAuthFlowWithAction(token, AuthFlowMatch{
Purpose: AuthFlowPurposeLoginVerification, UserId: session.UserID,
}, func(tx *gorm.DB, flow *AuthFlow) error {
state, err := getUserVerificationState(tx, session.UserID, true)
if err != nil {
return err
}
if state.Status != common.UserStatusEnabled || state.AuthVersion != session.UserAuthVersion {
return ErrUserSessionInactive
}
if err := validate(flow, state); err != nil {
return err
}
now := time.Now().Unix()
var activeCount, issuanceCount int64
if err := tx.Model(&UserSession{}).Where("user_id = ? AND status = ? AND expires_at > ?", session.UserID, UserSessionStatusActive, now).Count(&activeCount).Error; err != nil {
return err
}
if activeCount >= int64(common.UserSessionActiveLimit) {
return ErrUserSessionLimit
}
if err := tx.Model(&UserSession{}).Where("user_id = ? AND created_at > ?", session.UserID, now-common.UserSessionIssuanceWindowSeconds).Count(&issuanceCount).Error; err != nil {
return err
}
if issuanceCount >= int64(common.UserSessionIssuanceLimit) {
return ErrUserSessionIssuanceLimit
}
return createUserSessionWithTx(tx, session)
})
if err != nil {
return err
}
return publishCreatedUserSession(session, cacheDeadline)
}
......@@ -155,7 +155,22 @@ func replaceBackupCodesWithTx(tx *gorm.DB, userId int, codes []string) error {
// ReplaceBackupCodesWithAuthVersion atomically replaces the factor's recovery
// credentials and advances the user's authentication version.
func ReplaceBackupCodesWithAuthVersion(userId int, codes []string) error {
return replaceBackupCodesWithAuthVersion(userId, codes, nil)
}
func ReplaceBackupCodesForSession(identity AuthSessionIdentity, codes []string) error {
return replaceBackupCodesWithAuthVersion(identity.UserID, codes, &identity)
}
func replaceBackupCodesWithAuthVersion(userId int, codes []string, identity *AuthSessionIdentity) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
} else if err := lockForUpdate(tx).Select("id").First(&User{}, userId).Error; err != nil {
return err
}
var enabled TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&enabled).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
......@@ -217,7 +232,22 @@ func GetUnusedBackupCodeCount(userId int) (int, error) {
// DisableTwoFAWithAuthVersion atomically removes the factor and invalidates
// every access token issued against the previous security configuration.
func DisableTwoFAWithAuthVersion(userId int) error {
return disableTwoFAWithAuthVersion(userId, nil)
}
func DisableTwoFAForSession(identity AuthSessionIdentity) error {
return disableTwoFAWithAuthVersion(identity.UserID, &identity)
}
func disableTwoFAWithAuthVersion(userId int, identity *AuthSessionIdentity) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
} else if err := lockForUpdate(tx).Select("id").First(&User{}, userId).Error; err != nil {
return err
}
var twoFA TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&twoFA).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
......
......@@ -954,11 +954,32 @@ func (user *User) ClearBinding(bindingType string) error {
}
func (user *User) Delete() error {
return user.delete(nil)
}
func DeleteUserForSession(identity AuthSessionIdentity) error {
user := User{Id: identity.UserID}
return user.delete(&identity)
}
func (user *User) delete(identity *AuthSessionIdentity) error {
if user.Id == 0 {
return errors.New("id 为空!")
}
var nextAuthVersion int64
if err := DB.Transaction(func(tx *gorm.DB) error {
if identity != nil {
if err := ValidateAuthSessionWithTx(tx, *identity); err != nil {
return err
}
var role int
if err := tx.Model(&User{}).Where("id = ?", user.Id).Select("role").Scan(&role).Error; err != nil {
return err
}
if role == common.RoleRootUser {
return ErrCannotDeleteRootUser
}
}
var err error
nextAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
......
......@@ -131,6 +131,14 @@ func userSessionCacheDeadline() time.Time {
}
func CreateUserSession(session *UserSession) error {
cacheDeadline := userSessionCacheDeadline()
if err := createUserSessionWithTx(DB, session); err != nil {
return err
}
return publishCreatedUserSession(session, cacheDeadline)
}
func createUserSessionWithTx(tx *gorm.DB, session *UserSession) error {
now := time.Now().Unix()
if session == nil || session.SID == "" || session.UserID <= 0 || session.UserAuthVersion <= 0 || session.RefreshHash == "" || session.ExpiresAt <= now {
return ErrUserSessionInvalid
......@@ -150,10 +158,10 @@ func CreateUserSession(session *UserSession) error {
if session.CreatedAt == 0 {
session.CreatedAt = now
}
cacheDeadline := userSessionCacheDeadline()
if err := DB.Create(session).Error; err != nil {
return err
}
return tx.Create(session).Error
}
func publishCreatedUserSession(session *UserSession, cacheDeadline time.Time) error {
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
return confirmUserSessionActiveSnapshot(session)
......
......@@ -78,6 +78,9 @@ func SetApiRouter(router *gin.Engine) {
userRoute.GET("/login/encryption-key", middleware.DisableCache(), controller.GetPasswordEncryptionKey)
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, middleware.TurnstileCheck(), controller.Login)
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.Verify2FALogin)
userRoute.POST("/login/verify", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.VerifyLogin)
userRoute.POST("/login/passkey/begin", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.LoginPasskeyBegin)
userRoute.POST("/login/passkey/finish", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.LoginPasskeyFinish)
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginBegin)
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), middleware.DisableCache(), anonymousRequestBodyLimit, controller.PasskeyLoginFinish)
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
......@@ -95,7 +98,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.GET("/self", controller.GetSelf)
selfRoute.GET("/models", controller.GetUserModels)
selfRoute.PUT("/self", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.UpdateSelf)
selfRoute.DELETE("/self", controller.DeleteSelf)
selfRoute.DELETE("/self", middleware.DisableCache(), controller.DeleteSelf)
selfRoute.GET("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.GET("/token/status", middleware.DisableCache(), controller.GetAccessTokenStatus)
selfRoute.POST("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
......
......@@ -86,15 +86,32 @@ func createLoginSession(userID int, expectedAuthVersion int64, loginMethod, ip,
if issuanceCount >= int64(common.UserSessionIssuanceLimit) {
return nil, model.ErrUserSessionIssuanceLimit
}
refreshSecret, err := common.GenerateRandomCharsKey(64)
session, refreshSecret, err := newLoginSession(userID, user.AuthVersion, loginMethod, ip, userAgent)
if err != nil {
return nil, err
}
if err := model.CreateUserSession(session); err != nil {
return nil, err
}
bundle, err := issueAuthBundle(session, session.SID+"."+refreshSecret, true)
if err != nil {
_, _ = model.RevokeUserSession(userID, session.SID, "token_issue_failed")
return nil, err
}
return bundle, nil
}
func newLoginSession(userID int, authVersion int64, loginMethod, ip, userAgent string) (*model.UserSession, string, error) {
refreshSecret, err := common.GenerateRandomCharsKey(64)
if err != nil {
return nil, "", err
}
now := time.Now().Unix()
session := &model.UserSession{
SID: uuid.NewString(),
UserID: userID,
Version: 1,
UserAuthVersion: user.AuthVersion,
UserAuthVersion: authVersion,
Status: model.UserSessionStatusActive,
RefreshHash: hashRefreshSecret(refreshSecret),
LoginMethod: strings.TrimSpace(loginMethod),
......@@ -107,15 +124,7 @@ func createLoginSession(userID int, expectedAuthVersion int64, loginMethod, ip,
if session.LoginMethod == "" {
session.LoginMethod = "unknown"
}
if err := model.CreateUserSession(session); err != nil {
return nil, err
}
bundle, err := issueAuthBundle(session, session.SID+"."+refreshSecret, true)
if err != nil {
_, _ = model.RevokeUserSession(userID, session.SID, "token_issue_failed")
return nil, err
}
return bundle, nil
return session, refreshSecret, nil
}
func ValidateLoginSession(identity AuthIdentity) (*model.UserSession, *model.UserBase, error) {
......
package service
import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
)
const LoginVerificationTTL = 5 * time.Minute
type LoginChallenge struct {
RequireVerification bool `json:"require_verification"`
FlowToken string `json:"flow_token"`
ExpiresAt int64 `json:"expires_at"`
Methods []VerificationMethodOption `json:"methods"`
}
type loginFlowPayload struct {
AuthVersion int64 `json:"auth_version"`
LoginMethod string `json:"login_method"`
}
// LoginVerification is server-owned state read from a primary-authenticated flow.
// It is never constructed from a user ID or an authentication claim in a request.
type LoginVerification struct {
Flow *model.AuthFlow
State *model.UserVerificationState
payload loginFlowPayload
}
func StartLoginVerification(user *model.User, loginMethod string) (*LoginChallenge, error) {
if user == nil || user.Id <= 0 || user.AuthVersion <= 0 || loginMethod == "" {
return nil, model.ErrAuthFlowInvalid
}
state, err := model.GetUserVerificationState(user.Id)
if err != nil {
return nil, err
}
if state.Status != common.UserStatusEnabled || state.AuthVersion != user.AuthVersion {
return nil, model.ErrUserSessionInactive
}
methods, err := securityVerificationPolicy(VerificationScopeLogin, *state)
if err != nil || len(methods) == 0 {
return nil, err
}
available := false
for _, method := range methods {
available = available || method.Available
}
if !available {
return nil, ErrVerificationUnavailable
}
payload, err := common.Marshal(loginFlowPayload{AuthVersion: state.AuthVersion, LoginMethod: loginMethod})
if err != nil {
return nil, err
}
expiresAt := time.Now().Add(LoginVerificationTTL)
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeLoginVerification, UserId: user.Id,
Payload: string(payload), ExpiresAt: expiresAt,
})
if err != nil {
return nil, err
}
return &LoginChallenge{RequireVerification: true, FlowToken: token, ExpiresAt: expiresAt.Unix(), Methods: methods}, nil
}
func RequireLoginVerification(token, method string) (*LoginVerification, error) {
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeLoginVerification})
if err != nil {
return nil, err
}
var payload loginFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil || payload.AuthVersion <= 0 || payload.LoginMethod == "" {
return nil, model.ErrAuthFlowInvalid
}
state, err := model.GetUserVerificationState(flow.UserId)
if err != nil {
return nil, err
}
if state.Status != common.UserStatusEnabled || state.AuthVersion != payload.AuthVersion {
return nil, model.ErrUserSessionInactive
}
if err := requireLoginVerificationMethod(state, method); err != nil {
return nil, err
}
return &LoginVerification{Flow: flow, State: state, payload: payload}, nil
}
func requireLoginVerificationMethod(state *model.UserVerificationState, method string) error {
methods, err := securityVerificationPolicy(VerificationScopeLogin, *state)
if err != nil {
return err
}
for _, option := range methods {
if option.Method != method {
continue
}
if !option.Available {
return ErrVerificationUnavailable
}
return nil
}
return ErrProofMethod
}
func VerifyLoginCode(token, code, ip, userAgent string) (*AuthBundle, error) {
verification, err := RequireLoginVerification(token, VerificationMethodTwoFA)
if err != nil {
return nil, err
}
twoFA, err := model.GetTwoFAByUserId(verification.State.UserID)
if err != nil {
return nil, err
}
if err := VerifyTwoFactorCode(twoFA, code); err != nil {
return nil, err
}
return CompleteLoginVerification(token, verification, VerificationMethodTwoFA, ip, userAgent)
}
// CompleteLoginVerification must only run after a concrete factor ceremony.
// Recheck the bound version and method while consuming the flow and creating the
// session atomically; a different request cannot reuse this authorization.
func CompleteLoginVerification(token string, verification *LoginVerification, method, ip, userAgent string) (*AuthBundle, error) {
if verification == nil || verification.Flow == nil || verification.State == nil {
return nil, model.ErrAuthFlowInvalid
}
session, refreshSecret, err := newLoginSession(verification.State.UserID, verification.payload.AuthVersion, verification.payload.LoginMethod, ip, userAgent)
if err != nil {
return nil, err
}
if err := model.CreateUserSessionFromLoginFlow(token, session, func(flow *model.AuthFlow, state *model.UserVerificationState) error {
var payload loginFlowPayload
if flow.Id != verification.Flow.Id || common.UnmarshalJsonStr(flow.Payload, &payload) != nil || payload != verification.payload {
return model.ErrAuthFlowInvalid
}
return requireLoginVerificationMethod(state, method)
}); err != nil {
return nil, err
}
bundle, err := issueAuthBundle(session, session.SID+"."+refreshSecret, true)
if err != nil {
_, _ = model.RevokeUserSession(session.UserID, session.SID, "token_issue_failed")
return nil, err
}
return bundle, nil
}
......@@ -20,16 +20,22 @@ type flowPayload struct {
type FlowSecurity struct {
model.AuthSessionIdentity
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
Scope string `json:"scope"`
ContextHash string `json:"context_hash"`
Authorization *model.AuthFlowAuthorization `json:"authorization,omitempty"`
LoginFlowID int64 `json:"login_flow_id,omitempty"`
LoginExpiresAt int64 `json:"login_expires_at,omitempty"`
}
func CreateSessionDataFlow(purpose string, security FlowSecurity, data *webauthn.SessionData) (string, int64, error) {
if data == nil {
return "", 0, errors.New("Passkey 会话数据不能为空")
}
if purpose != model.AuthFlowPurposePasskeyLogin && (security.UserID <= 0 || security.SessionID == "" || security.Scope == "" || security.ContextHash == "" || security.UserAuthVersion <= 0 || security.SessionVersion <= 0) {
if purpose == model.AuthFlowPurposeLoginPasskey {
if security.UserID <= 0 || security.UserAuthVersion <= 0 || security.LoginFlowID <= 0 || security.LoginExpiresAt <= time.Now().Unix() || security.SessionID != "" || security.SessionVersion != 0 {
return "", 0, model.ErrAuthFlowInvalid
}
} else 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) {
......@@ -40,6 +46,9 @@ func CreateSessionDataFlow(purpose string, security FlowSecurity, data *webauthn
return "", 0, err
}
expiresAt := time.Now().Add(passkeyFlowTTL)
if purpose == model.AuthFlowPurposeLoginPasskey && security.LoginExpiresAt < expiresAt.Unix() {
expiresAt = time.Unix(security.LoginExpiresAt, 0)
}
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: purpose,
UserId: security.UserID,
......@@ -67,6 +76,12 @@ func PopSessionDataFlow(token, purpose string, identity model.AuthSessionIdentit
return nil
}
security := payload.Security
if purpose == model.AuthFlowPurposeLoginPasskey {
if security.AuthSessionIdentity != identity || identity.UserID <= 0 || identity.UserAuthVersion <= 0 || identity.SessionID != "" || identity.SessionVersion != 0 || security.LoginFlowID <= 0 || security.LoginExpiresAt <= time.Now().Unix() {
return model.ErrAuthFlowInvalid
}
return nil
}
if security.AuthSessionIdentity != identity || security.Scope == "" || security.ContextHash == "" {
return model.ErrAuthFlowInvalid
}
......
......@@ -24,12 +24,16 @@ const (
VerificationScopePasskeyRegister = "passkey.register"
VerificationScopePasskeyDelete = "passkey.delete"
VerificationScopeTwoFASetup = "2fa.setup"
VerificationScopeTwoFADisable = "2fa.disable"
VerificationScopeTwoFABackupCodes = "2fa.backup_codes.regenerate"
VerificationScopeLogin = "auth.login"
VerificationScopeAccessTokenGenerate = "access_token.generate"
VerificationScopeAccessTokenRevoke = "access_token.revoke"
VerificationScopeAccountBind = "account.binding.bind"
VerificationScopeAccountUnbind = "account.binding.unbind"
VerificationScopePasswordSet = "account.password.set"
VerificationScopePasswordChange = "account.password.change"
VerificationScopeAccountDelete = "account.delete"
)
var (
......@@ -114,8 +118,9 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin
}
normalized = context
case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup,
VerificationScopeTwoFADisable, VerificationScopeTwoFABackupCodes,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
VerificationScopePasswordSet, VerificationScopePasswordChange:
VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete:
if len(fields) != 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
......@@ -162,51 +167,41 @@ type VerificationRequirements struct {
PasswordEncryptionEnabled bool `json:"password_encryption_enabled"`
}
type verificationAccountState struct {
HasPassword bool
HasTwoFA bool
TwoFALocked bool
HasPasskey bool
PasskeyEnabled bool
}
// securityVerificationPolicy is the only operation-to-method policy. Device
// support and disabled providers never turn an enrolled factor into an absent one.
func securityVerificationPolicy(scope string, state verificationAccountState) ([]VerificationMethodOption, error) {
func securityVerificationPolicy(scope string, state model.UserVerificationState) ([]VerificationMethodOption, error) {
var methods []string
if state.HasTwoFA {
methods = append(methods, VerificationMethodTwoFA)
}
if state.HasPasskey {
methods = append(methods, VerificationMethodPasskey)
}
switch scope {
case VerificationScopeChannelKeyRead:
if state.HasTwoFA {
methods = append(methods, VerificationMethodTwoFA)
}
if state.HasPasskey {
methods = append(methods, VerificationMethodPasskey)
}
case VerificationScopePasskeyDelete:
if state.HasTwoFA {
methods = []string{VerificationMethodTwoFA}
} else if state.HasPasskey {
methods = []string{VerificationMethodPasskey}
case VerificationScopeChannelKeyRead, VerificationScopePasskeyDelete, VerificationScopeLogin:
case VerificationScopeTwoFADisable, VerificationScopeTwoFABackupCodes:
if !state.HasTwoFA {
return nil, model.ErrTwoFANotEnabled
}
case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke,
VerificationScopeAccountBind, VerificationScopeAccountUnbind,
VerificationScopePasswordSet, VerificationScopePasswordChange:
VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete:
if scope == VerificationScopeAccountDelete && state.Role == common.RoleRootUser {
return nil, ErrVerificationForbidden
}
if scope == VerificationScopeTwoFASetup && state.HasTwoFA {
return nil, model.ErrTwoFAAlreadyEnabled
}
if (scope == VerificationScopePasswordSet && state.HasPassword) || (scope == VerificationScopePasswordChange && !state.HasPassword) {
return nil, ErrVerificationForbidden
}
switch {
case state.HasTwoFA:
methods = []string{VerificationMethodTwoFA}
case state.HasPasskey:
methods = []string{VerificationMethodPasskey}
case state.HasPassword:
methods = []string{VerificationMethodPassword}
default:
methods = []string{VerificationMethodOAuth}
if len(methods) == 0 {
if state.HasPassword {
methods = []string{VerificationMethodPassword}
} else {
methods = []string{VerificationMethodOAuth}
}
}
default:
return nil, ErrProofScope
......@@ -217,7 +212,7 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
if method == VerificationMethodTwoFA && state.TwoFALocked {
option.Available, option.Reason = false, ErrVerificationLocked.Error()
}
if !state.PasskeyEnabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) {
if !system_setting.GetPasskeySettings().Enabled && (method == VerificationMethodPasskey || scope == VerificationScopePasskeyRegister) {
option.Available, option.Reason = false, "Passkey authentication is disabled."
}
options = append(options, option)
......@@ -226,31 +221,20 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
}
func GetVerificationRequirements(identity AuthIdentity, scope string) (*VerificationRequirements, error) {
user, err := model.GetUserById(identity.UserID, true)
if scope == VerificationScopeLogin {
return nil, ErrProofScope
}
state, err := model.GetUserVerificationState(identity.UserID)
if err != nil {
return nil, err
}
if user.Status != common.UserStatusEnabled || user.AuthVersion != identity.UserAuthVersion {
if state.Status != common.UserStatusEnabled || state.AuthVersion != identity.UserAuthVersion {
return nil, ErrAuthTokenInvalid
}
if scope == VerificationScopeChannelKeyRead && user.Role != common.RoleRootUser {
if scope == VerificationScopeChannelKeyRead && state.Role != common.RoleRootUser {
return nil, ErrVerificationForbidden
}
twoFA, err := model.GetTwoFAByUserId(user.Id)
if err != nil {
return nil, err
}
_, err = model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
return nil, err
}
state := verificationAccountState{
HasPassword: user.Password != "", HasTwoFA: twoFA != nil && twoFA.IsEnabled,
TwoFALocked: twoFA != nil && twoFA.IsLocked(), HasPasskey: err == nil,
PasskeyEnabled: system_setting.GetPasskeySettings().Enabled,
}
methods, err := securityVerificationPolicy(scope, state)
methods, err := securityVerificationPolicy(scope, *state)
if err != nil {
return nil, err
}
......@@ -258,13 +242,17 @@ func GetVerificationRequirements(identity AuthIdentity, scope string) (*Verifica
for i := range methods {
if methods[i].Method == VerificationMethodPassword && !common.PasswordLoginEnabled {
switch scope {
case VerificationScopeAccountBind, VerificationScopeAccountUnbind, VerificationScopePasswordSet, VerificationScopePasswordChange:
case VerificationScopeAccountBind, VerificationScopeAccountUnbind, VerificationScopePasswordSet, VerificationScopePasswordChange, VerificationScopeAccountDelete:
methods[i].Available, methods[i].Reason = false, "Password authentication is disabled."
}
}
if methods[i].Method != VerificationMethodOAuth {
continue
}
user, err := model.GetUserById(identity.UserID, false)
if err != nil {
return nil, err
}
requirements.OAuthProviders, err = verificationOAuthProviders(user)
if err != nil {
return nil, err
......@@ -449,6 +437,11 @@ func VerifySecurityInput(identity AuthIdentity, input VerificationInput) (*Secur
return nil, ErrVerificationFailed
}
case VerificationMethodTwoFA:
if input.Scope == VerificationScopeTwoFABackupCodes {
if _, err := common.ValidateNumericCode(input.Code); err != nil {
return nil, ErrVerificationFailed
}
}
twoFA, err := model.GetTwoFAByUserId(identity.UserID)
if err != nil {
return nil, err
......
......@@ -18,64 +18,109 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useNavigate } from '@tanstack/react-router'
import i18n from 'i18next'
import { useCallback, useEffect, useRef } from 'react'
import {
getSavedLanguage,
sanitizeAuthRedirect,
} from '@/features/auth/lib/auth-redirect'
import { applyAuthBundle } from '@/lib/api'
import type { AuthBundle } from '@/stores/auth-store'
import { applyAuthBundle, isAuthBundle } from '@/lib/api'
import { AuthOperationError } from '@/lib/secure-verification'
import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
import { isLoginChallenge } from '../secure-verification/api'
/**
* Hook for handling authentication redirects and user data management
*/
export function useAuthRedirect() {
const navigate = useNavigate()
const sessionID = useAuthStore((state) => state.auth.session?.sid)
const mounted = useRef(true)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
/**
* Handle successful login
* @param userData - Optional user data from login response
* @param redirectTo - Redirect path after login
*/
const handleLoginSuccess = async (
bundle: AuthBundle,
redirectTo?: string
) => {
applyAuthBundle(bundle)
const savedLang = getSavedLanguage(bundle.user)
if (savedLang && savedLang !== i18n.language) {
await i18n.changeLanguage(savedLang)
}
const handleLoginSuccess = useCallback(
async (bundle: AuthBundle, redirectTo?: string) => {
if (
!mounted.current ||
useAuthStore.getState().auth.session?.sid !== sessionID
) {
return
}
applyAuthBundle(bundle)
const savedLang = getSavedLanguage(bundle.user)
if (savedLang && savedLang !== i18n.language) {
await i18n.changeLanguage(savedLang)
}
const targetPath =
sanitizeAuthRedirect(redirectTo, window.location.origin) ?? '/dashboard'
navigate({ href: targetPath, replace: true })
}
const targetPath =
sanitizeAuthRedirect(redirectTo, window.location.origin) ?? '/dashboard'
await navigate({ href: targetPath, replace: true })
},
[navigate, sessionID]
)
/**
* Redirect to 2FA page
* Every primary login transport returns the same bundle-or-challenge contract.
*/
const redirectTo2FA = () => {
navigate({ to: '/otp', replace: true })
}
const handleLoginResult = useCallback(
async (result: unknown, redirectTo?: string): Promise<boolean> => {
if (
!mounted.current ||
useAuthStore.getState().auth.session?.sid !== sessionID
) {
return false
}
if (isAuthBundle(result)) {
await handleLoginSuccess(result, redirectTo)
return true
}
if (!isLoginChallenge(result)) {
throw new AuthOperationError('Login failed')
}
if (result.expires_at * 1000 <= Date.now()) {
throw new AuthOperationError(
'Login flow expired. Please sign in again.'
)
}
useAuthStore.getState().auth.setPendingLoginVerification({
challenge: result,
redirectTo:
sanitizeAuthRedirect(redirectTo, window.location.origin) ?? undefined,
})
await navigate({ to: '/otp', replace: true })
return false
},
[handleLoginSuccess, navigate, sessionID]
)
/**
* Redirect to login page
*/
const redirectToLogin = () => {
navigate({ to: '/sign-in', replace: true })
}
const redirectToLogin = useCallback(() => {
void navigate({ to: '/sign-in', replace: true })
}, [navigate])
/**
* Redirect to register page
*/
const redirectToRegister = () => {
navigate({ to: '/sign-up', replace: true })
}
const redirectToRegister = useCallback(() => {
void navigate({ to: '/sign-up', replace: true })
}, [navigate])
return {
handleLoginSuccess,
redirectTo2FA,
handleLoginResult,
redirectToLogin,
redirectToRegister,
}
......
......@@ -87,6 +87,7 @@ export function useOAuthLogin(
try {
await resetSession()
const state = await createOAuthFlow('github', 'login')
rememberOAuthLoginRedirect(state, redirectTo)
const url = buildGitHubOAuthUrl(status.github_client_id, state)
window.open(url, '_self')
......@@ -108,6 +109,7 @@ export function useOAuthLogin(
try {
await resetSession()
const state = await createOAuthFlow('discord', 'login')
rememberOAuthLoginRedirect(state, redirectTo)
const url = buildDiscordOAuthUrl(status.discord_client_id, state)
window.open(url, '_self')
......@@ -125,6 +127,7 @@ export function useOAuthLogin(
try {
await resetSession()
const state = await createOAuthFlow('oidc', 'login')
rememberOAuthLoginRedirect(state, redirectTo)
const url = buildOIDCOAuthUrl(
status.oidc_authorization_endpoint,
......@@ -146,6 +149,7 @@ export function useOAuthLogin(
try {
await resetSession()
const state = await createOAuthFlow('linuxdo', 'login')
rememberOAuthLoginRedirect(state, redirectTo)
const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state)
window.open(url, '_self')
......@@ -188,6 +192,7 @@ export function useOAuthLogin(
try {
await resetSession()
const state = await createOAuthFlow(provider.slug, 'login')
rememberOAuthLoginRedirect(state, redirectTo)
const redirectUri = `${window.location.origin}/oauth/${provider.slug}`
const url = new URL(provider.authorization_endpoint)
......
/*
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 {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Outlet,
RouterProvider,
} from '@tanstack/react-router'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
import { useAuthRedirect } from '../../hooks/use-auth-redirect'
import type { LoginResult } from '../../secure-verification/types'
import { OtpForm } from '../components/otp-form'
const bundle: AuthBundle = {
access_token: 'completed-login',
token_type: 'Bearer',
access_expires_at: 9999999999,
user: { id: 42, username: 'verified-user', role: 1 },
session: {
sid: 'verified-session',
current: true,
login_method: 'password',
ip: '',
user_agent: '',
created_at: 1,
last_active_at: 1,
expires_at: 9999999999,
},
}
function PrimaryLoginHarness(props: { result: LoginResult }) {
const { handleLoginResult } = useAuthRedirect()
return (
<button
type='button'
onClick={() => void handleLoginResult(props.result, '/pricing?view=grid')}
>
Primary authentication succeeded
</button>
)
}
function renderLoginVerification(
hasChallenge = true,
primaryResult?: LoginResult
) {
useAuthStore.getState().auth.reset('complete')
if (hasChallenge) {
useAuthStore.getState().auth.setPendingLoginVerification({
challenge: {
require_verification: true,
flow_token: 'login-flow',
expires_at: Math.floor(Date.now() / 1000) + 300,
methods: [{ method: '2fa', available: true }],
},
redirectTo: '/pricing?view=grid',
})
}
const root = createRootRoute({ component: Outlet })
const routes = [
createRoute({
getParentRoute: () => root,
path: '/otp',
component: OtpForm,
}),
createRoute({
getParentRoute: () => root,
path: '/sign-in',
component: () => <div>Sign-in page</div>,
}),
createRoute({
getParentRoute: () => root,
path: '/pricing',
component: () => <div>Pricing destination</div>,
}),
createRoute({
getParentRoute: () => root,
path: '/primary-login',
component: () =>
primaryResult ? <PrimaryLoginHarness result={primaryResult} /> : null,
}),
]
const router = createRouter({
routeTree: root.addChildren(routes),
history: createMemoryHistory({
initialEntries: [primaryResult ? '/primary-login' : '/otp'],
}),
})
const view = render(<RouterProvider router={router} />)
return { router, ...view }
}
function pendingLoginResponse() {
let resolve!: (value: {
data: { success: boolean; data: AuthBundle }
}) => void
const promise = new Promise<{ data: { success: boolean; data: AuthBundle } }>(
(finish) => {
resolve = finish
}
)
return { promise, resolve }
}
beforeEach(() => {
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
useAuthStore.getState().auth.reset('idle')
})
it('routes a primary-authenticated challenge into shared verification without applying authentication', async () => {
const post = vi.spyOn(api, 'post')
const { router } = renderLoginVerification(false, {
require_verification: true,
flow_token: 'primary-challenge',
expires_at: Math.floor(Date.now() / 1000) + 300,
methods: [{ method: '2fa', available: true }],
})
const user = userEvent.setup()
await user.click(
await screen.findByRole('button', {
name: 'Primary authentication succeeded',
})
)
expect(
await screen.findByLabelText('Authenticator code or backup code')
).toBeVisible()
expect(router.state.location.pathname).toBe('/otp')
expect(useAuthStore.getState().auth.user).toBeNull()
expect(post).not.toHaveBeenCalled()
})
it('accepts a completed Passkey login without opening another verification dialog', async () => {
const post = vi.spyOn(api, 'post')
const { router } = renderLoginVerification(false, {
...bundle,
session: { ...bundle.session, login_method: 'passkey' },
})
const user = userEvent.setup()
await user.click(
await screen.findByRole('button', {
name: 'Primary authentication succeeded',
})
)
await waitFor(() =>
expect(router.state.location.href).toBe('/pricing?view=grid')
)
expect(useAuthStore.getState().auth.user?.id).toBe(42)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(post).not.toHaveBeenCalled()
})
it('writes authentication only after verification and preserves the original destination', async () => {
const pending = pendingLoginResponse()
const post = vi.spyOn(api, 'post').mockReturnValue(pending.promise)
const { router } = renderLoginVerification()
const user = userEvent.setup()
await user.type(
await screen.findByLabelText('Authenticator code or backup code'),
'123456'
)
expect(useAuthStore.getState().auth.pendingLoginVerification).toBeNull()
expect(useAuthStore.getState().auth.user).toBeNull()
await user.dblClick(screen.getByRole('button', { name: 'Verify' }))
expect(post).toHaveBeenCalledTimes(1)
expect(useAuthStore.getState().auth.user).toBeNull()
await act(async () => {
pending.resolve({ data: { success: true, data: bundle } })
await pending.promise
})
await waitFor(() =>
expect(router.state.location.href).toBe('/pricing?view=grid')
)
expect(useAuthStore.getState().auth.user?.id).toBe(42)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('cancels verification with Escape and returns to sign-in without authenticating', async () => {
const post = vi.spyOn(api, 'post')
const { router } = renderLoginVerification()
const user = userEvent.setup()
await screen.findByLabelText('Authenticator code or backup code')
await user.keyboard('{Escape}')
await waitFor(() => expect(router.state.location.pathname).toBe('/sign-in'))
expect(post).not.toHaveBeenCalled()
expect(useAuthStore.getState().auth.user).toBeNull()
expect(useAuthStore.getState().auth.pendingLoginVerification).toBeNull()
})
it.each(['unmount', 'account switch'] as const)(
'aborts an in-flight verification on %s and ignores its late result',
async (change) => {
const pending = pendingLoginResponse()
const post = vi.spyOn(api, 'post').mockReturnValue(pending.promise)
const { router } = renderLoginVerification()
const user = userEvent.setup()
await user.type(
await screen.findByLabelText('Authenticator code or backup code'),
'123456'
)
await user.click(screen.getByRole('button', { name: 'Verify' }))
await act(async () => {
if (change === 'unmount') {
await router.navigate({ to: '/sign-in' })
} else {
useAuthStore.getState().auth.setBundle({
...bundle,
user: { ...bundle.user, id: 99 },
session: { ...bundle.session, sid: 'other-session' },
})
}
})
expect(post.mock.calls[0][2]?.signal?.aborted).toBe(true)
await act(async () => {
pending.resolve({ data: { success: true, data: bundle } })
await pending.promise
})
expect(useAuthStore.getState().auth.user?.id).toBe(
change === 'account switch' ? 99 : undefined
)
expect(useAuthStore.getState().auth.pendingLoginVerification).toBeNull()
}
)
it('requires a new sign-in when the verification page has no in-memory challenge', async () => {
const post = vi.spyOn(api, 'post')
const { router } = renderLoginVerification(false)
await waitFor(() => expect(router.state.location.pathname).toBe('/sign-in'))
expect(post).not.toHaveBeenCalled()
})
it('rejects submission after the five-minute challenge deadline without sending a request', async () => {
const post = vi.spyOn(api, 'post')
renderLoginVerification()
const user = userEvent.setup()
await user.type(
await screen.findByLabelText('Authenticator code or backup code'),
'123456'
)
const later = Date.now() + 301000
vi.spyOn(Date, 'now').mockReturnValue(later)
await user.click(screen.getByRole('button', { name: 'Verify' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
'Login flow expired. Please sign in again.'
)
expect(post).not.toHaveBeenCalled()
expect(useAuthStore.getState().auth.user).toBeNull()
})
......@@ -16,224 +16,85 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { z } from 'zod'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from '@/components/ui/input-otp'
import { login2fa } from '@/features/auth/api'
import {
otpFormSchema,
OTP_LENGTH,
BACKUP_CODE_LENGTH,
} from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import {
isValidOTP,
isValidBackupCode,
formatBackupCode,
cleanBackupCode,
} from '@/features/auth/lib/validation'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import { AuthOperationError } from '@/lib/secure-verification'
import { useAuthStore } from '@/stores/auth-store'
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
import { useAuthRedirect } from '../../hooks/use-auth-redirect'
import {
SecureVerificationDialog,
useSecureVerification,
} from '../../secure-verification'
export function OtpForm({ className, ...props }: OtpFormProps) {
export function OtpForm() {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [useBackupCode, setUseBackupCode] = useState(false)
const pending2FAFlowToken = useAuthStore(
(state) => state.auth.pending2FAFlowToken
)
// Transfer the pending challenge from the navigation handoff into this page's
// lifetime. Reloading or leaving the page cannot resume it from browser storage.
const pending = useRef(
useAuthStore.getState().auth.pendingLoginVerification
).current
const initialSessionID = useRef(
useAuthStore.getState().auth.session?.sid
).current
const sessionID = useAuthStore((state) => state.auth.session?.sid)
const verification = useSecureVerification()
const { requestLoginVerification, cancel } = verification
const { handleLoginSuccess, redirectToLogin } = useAuthRedirect()
const completed = useRef(false)
const form = useForm<z.infer<typeof otpFormSchema>>({
resolver: zodResolver(otpFormSchema),
defaultValues: { otp: '' },
})
const otp = form.watch('otp')
async function onSubmit(data: z.infer<typeof otpFormSchema>) {
// Validate based on mode
if (useBackupCode) {
if (!isValidBackupCode(data.otp)) {
toast.error(t('Backup code must be in format XXXX-XXXX'))
return
}
} else {
if (!isValidOTP(data.otp)) {
toast.error(t('Verification code must be 6 digits'))
return
}
useEffect(() => {
if (completed.current) return
if (!pending) {
redirectToLogin()
return
}
setIsLoading(true)
try {
// Remove all hyphens from backup code before sending to backend
const code = useBackupCode ? cleanBackupCode(data.otp) : data.otp
if (!pending2FAFlowToken) {
toast.error(t('Login flow expired. Please sign in again.'))
if (sessionID !== initialSessionID) {
cancel()
return
}
if (useAuthStore.getState().auth.pendingLoginVerification === pending) {
useAuthStore.getState().auth.setPendingLoginVerification(null)
}
let active = true
void (async () => {
try {
const bundle = await requestLoginVerification(pending.challenge)
if (
!active ||
useAuthStore.getState().auth.session?.sid !== initialSessionID
) {
return
}
if (!bundle) {
redirectToLogin()
return
}
completed.current = true
await handleLoginSuccess(bundle, pending.redirectTo)
toast.success(t('Signed in'))
} catch (error) {
if (!active) return
toast.error(t(AuthOperationError.from(error).message))
redirectToLogin()
return
}
const res = await login2fa({
code,
flow_token: pending2FAFlowToken,
})
if (!res.success) {
if (getServerErrorMessageKey(res)) return
toast.error(res.message || t('Invalid code'))
return
}
if (!res.data) {
throw new Error(t('Login failed'))
}
await handleLoginSuccess(res.data)
toast.success(t('Signed in'))
} catch (error) {
// eslint-disable-next-line no-console
console.error('2FA verification error:', error)
if (getServerErrorMessageKey(error)) return
const errorMessage =
error instanceof Error ? error.message : t('Verification failed')
toast.error(errorMessage)
} finally {
setIsLoading(false)
})()
return () => {
active = false
cancel()
}
}
function handleToggleMode() {
setUseBackupCode(!useBackupCode)
form.setValue('otp', '')
}
function handleBackToLogin() {
redirectToLogin()
}
const isFormValid = useBackupCode
? otp.length >= BACKUP_CODE_LENGTH
: otp.length >= OTP_LENGTH
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
{...props}
>
<FormField
control={form.control}
name='otp'
render={({ field }) => (
<FormItem>
<FormLabel>
{useBackupCode ? t('Backup Code') : t('Verification Code')}
</FormLabel>
<FormControl>
{useBackupCode ? (
<Input
placeholder={t('Enter backup code (e.g., CAWD-OQDV)')}
{...field}
maxLength={BACKUP_CODE_LENGTH}
autoComplete='off'
className='font-mono uppercase'
onChange={(e) => {
const formatted = formatBackupCode(e.target.value)
field.onChange(formatted)
}}
/>
) : (
<InputOTP
maxLength={OTP_LENGTH}
{...field}
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)}
</FormControl>
<FormDescription className='text-muted-foreground text-xs'>
{useBackupCode
? t('Each backup code can only be used once.')
: t('Verification code updates every 30 seconds.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type='submit'
className='mt-2 w-full'
disabled={!isFormValid || isLoading}
>
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
{t('Verify and Sign In')}
</Button>
<div className='flex items-center justify-center gap-2 text-sm'>
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
onClick={handleToggleMode}
>
{useBackupCode ? t('Use authenticator code') : t('Use backup code')}
</Button>
<span className='text-muted-foreground'>·</span>
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
onClick={handleBackToLogin}
>
{t('Back to login')}
</Button>
</div>
</form>
</Form>
)
}, [
pending,
initialSessionID,
sessionID,
requestLoginVerification,
cancel,
handleLoginSuccess,
redirectToLogin,
t,
])
return <SecureVerificationDialog {...verification.dialogProps} />
}
......@@ -29,10 +29,10 @@ export function Otp() {
<div className='w-full space-y-8'>
<div className='space-y-3'>
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
{t('Two-factor Authentication')}
{t('Security verification')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Please enter the authentication code.')}
{t('Verify your identity to finish signing in.')}
</p>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Session expired?')}{' '}
......
......@@ -43,6 +43,30 @@ import type { SecurityProof } from '../types'
const originalAdapter = api.defaults.adapter
const originalLocation = window.location.href
it('allows a security key when the browser has WebAuthn but no platform authenticator', async () => {
vi.stubGlobal(
'PublicKeyCredential',
class {
static isUserVerifyingPlatformAuthenticatorAvailable() {
return Promise.resolve(false)
}
}
)
vi.spyOn(api, 'get').mockResolvedValue({
data: {
success: true,
data: {
scope: 'passkey.delete',
methods: [{ method: 'passkey', available: true }],
oauth_providers: [],
password_encryption_enabled: false,
},
},
})
const requirements = await checkVerificationMethods('passkey.delete')
expect(requirements.methods).toEqual([{ method: 'passkey', available: true }])
})
it.each([false, true])(
'retains the Telegram verification request after popup close and honors caller cancellation: %s',
async (cancel) => {
......
......@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import { api } from '@/lib/api'
import type { AuthBundle } from '@/stores/auth-store'
import { SecureVerificationDialog } from '../components/secure-verification-dialog'
import { useSecureVerification } from '../hooks/use-secure-verification'
......@@ -76,6 +77,84 @@ afterEach(() => {
vi.unstubAllGlobals()
})
function LoginHarness(props: {
onResult: (bundle: AuthBundle | null) => void
}) {
const verification = useSecureVerification()
return (
<>
<button
type='button'
onClick={async () =>
props.onResult(
await verification.requestLoginVerification({
require_verification: true,
flow_token: 'pending-login',
expires_at: Math.floor(Date.now() / 1000) + 300,
methods: [
{ method: '2fa', available: true },
{ method: 'passkey', available: true },
],
})
)
}
>
Continue sign-in
</button>
<SecureVerificationDialog {...verification.dialogProps} />
</>
)
}
it('lets a pending login switch from Passkey to 2FA without using authenticated verification endpoints', async () => {
vi.stubGlobal('PublicKeyCredential', class {})
const get = vi.spyOn(api, 'get')
const bundle: AuthBundle = {
access_token: 'verified-login',
token_type: 'Bearer',
access_expires_at: Math.floor(Date.now() / 1000) + 900,
user: { id: 42, username: 'user', role: 1 },
session: {
sid: 'new-session',
current: true,
login_method: 'password',
ip: '',
user_agent: '',
created_at: 1,
last_active_at: 1,
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
}
const post = vi
.spyOn(api, 'post')
.mockResolvedValue({ data: { success: true, data: bundle } })
const result = vi.fn()
const user = userEvent.setup()
render(<LoginHarness onResult={result} />)
await user.click(screen.getByRole('button', { name: 'Continue sign-in' }))
expect(await screen.findByRole('tab', { name: 'Passkey' })).toHaveAttribute(
'aria-selected',
'true'
)
expect(result).not.toHaveBeenCalled()
await user.click(screen.getByRole('tab', { name: 'Authenticator code' }))
await user.type(
screen.getByLabelText('Authenticator code or backup code'),
'123456'
)
await user.click(screen.getByRole('button', { name: 'Verify' }))
await waitFor(() => expect(result).toHaveBeenCalledExactlyOnceWith(bundle))
expect(post).toHaveBeenCalledExactlyOnceWith(
'/api/user/login/verify',
{ flow_token: 'pending-login', method: '2fa', code: '123456' },
expect.objectContaining({
skipAuthRefresh: true,
signal: expect.any(AbortSignal),
})
)
expect(get).not.toHaveBeenCalled()
})
it.each(['success', 'cancel', 'retry'] as const)(
'automatically obtains the first-enrollment session proof and handles %s',
async (outcome) => {
......
......@@ -16,7 +16,7 @@ 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 { api, isAuthBundle } from '@/lib/api'
import { buildOAuthAuthorizationUrl } from '@/lib/oauth'
import {
buildAssertionResult,
......@@ -28,6 +28,7 @@ import {
authRequestOptions,
authResult,
} from '@/lib/secure-verification'
import type { AuthBundle } from '@/stores/auth-store'
import { createOAuthAuthorization } from '../api'
import { openOAuthPopup } from '../lib/oauth-popup'
......@@ -39,6 +40,7 @@ import {
import type { SystemStatus } from '../types'
import type {
SecurityProof,
LoginChallenge,
SecurityProofScope,
VerificationInput,
VerificationOperation,
......@@ -60,6 +62,13 @@ export async function checkVerificationMethods(
),
isPasskeySupported(),
])
return withDeviceAvailability(requirements, passkeySupported)
}
function withDeviceAvailability(
requirements: VerificationRequirements,
passkeySupported: boolean
): VerificationRequirements {
return {
...requirements,
methods: requirements.methods.map((option) => {
......@@ -79,6 +88,107 @@ export async function checkVerificationMethods(
}
}
export function isLoginChallenge(value: unknown): value is LoginChallenge {
if (!value || typeof value !== 'object') return false
const challenge = value as Partial<LoginChallenge>
return (
challenge.require_verification === true &&
typeof challenge.flow_token === 'string' &&
challenge.flow_token.length > 0 &&
typeof challenge.expires_at === 'number' &&
Number.isFinite(challenge.expires_at) &&
Array.isArray(challenge.methods) &&
challenge.methods.length > 0 &&
challenge.methods.every(
(option) =>
option &&
(option.method === '2fa' || option.method === 'passkey') &&
typeof option.available === 'boolean'
)
)
}
export async function getLoginVerificationRequirements(
challenge: LoginChallenge,
signal: AbortSignal
): Promise<VerificationRequirements> {
if (challenge.expires_at * 1000 <= Date.now()) {
throw new AuthOperationError(
'Login flow expired. Please sign in again.',
'AUTH_FLOW_INVALID'
)
}
const supported = await isPasskeySupported()
signal.throwIfAborted()
return withDeviceAvailability(
{
scope: 'auth.login',
methods: challenge.methods,
oauth_providers: [],
password_encryption_enabled: false,
},
supported
)
}
export async function verifyLogin(
input: VerificationInput,
challenge: LoginChallenge,
signal: AbortSignal
): Promise<AuthBundle> {
if (challenge.expires_at * 1000 <= Date.now()) {
throw new AuthOperationError(
'Login flow expired. Please sign in again.',
'AUTH_FLOW_INVALID'
)
}
const options = { ...authRequestOptions, skipAuthRefresh: true, signal }
let result: unknown
if (input.method === '2fa') {
result = await authResult(
api.post(
'/api/user/login/verify',
{
flow_token: challenge.flow_token,
method: '2fa',
code: input.code.trim(),
},
options
)
)
} else if (input.method === 'passkey') {
const begin = await authResult<{ flow_token: string; options: unknown }>(
api.post(
'/api/user/login/passkey/begin',
{ flow_token: challenge.flow_token },
options
)
)
if (!begin.flow_token) {
throw new AuthOperationError('Verification flow expired')
}
const assertion = await requestPasskeyAssertion(begin.options, signal)
result = await authResult(
api.post(
'/api/user/login/passkey/finish',
{
flow_token: challenge.flow_token,
passkey_flow_token: begin.flow_token,
credential: assertion,
},
options
)
)
} else {
throw new AuthOperationError(
'This verification method is not allowed for this action.'
)
}
signal.throwIfAborted()
if (!isAuthBundle(result)) throw new AuthOperationError('Login failed')
return result
}
export async function verify(
input: VerificationInput,
operation: VerificationOperation,
......@@ -161,7 +271,18 @@ async function verifyPasskey(
if (!begin.flow_token) {
throw new AuthOperationError('Verification flow expired')
}
const publicKey = prepareCredentialRequestOptions(begin.options ?? begin)
const assertion = await requestPasskeyAssertion(
begin.options ?? begin,
signal
)
return finishPasskeyVerification(begin.flow_token, assertion, signal)
}
async function requestPasskeyAssertion(
options: unknown,
signal: AbortSignal
): Promise<Record<string, unknown>> {
const publicKey = prepareCredentialRequestOptions(options)
let credential: PublicKeyCredential | null
try {
credential = (await navigator.credentials.get({
......@@ -187,7 +308,7 @@ async function verifyPasskey(
if (!assertion) {
throw new AuthOperationError('Unable to build Passkey assertion')
}
return finishPasskeyVerification(begin.flow_token, assertion, signal)
return assertion
}
async function verifyOAuth(
......
......@@ -57,6 +57,9 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
state.phase === 'ready' || state.phase === 'verifying' ? state : null
const input = ready?.input
const verifying = state.phase === 'verifying'
const login = state.request.scope === 'auth.login'
const acceptsBackupCode =
state.request.scope !== '2fa.backup_codes.regenerate'
let canVerify = state.phase === 'ready' && Boolean(input)
if (input?.method === 'password') {
canVerify = canVerify && input.password.length > 0
......@@ -99,12 +102,15 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
title={
<>
<ShieldCheck className='size-5' />
{state.request.title ?? t('Security verification')}
{state.request.title ??
(login ? t('Complete sign-in') : t('Security verification'))}
</>
}
description={
state.request.description ??
t('Confirm your identity before accessing this sensitive action.')
(login
? t('Verify your identity to finish signing in.')
: t('Confirm your identity before accessing this sensitive action.'))
}
contentClassName='sm:max-w-md'
contentHeight='auto'
......@@ -191,12 +197,14 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
</TabsContent>
<TabsContent value='2fa' className='space-y-2'>
<Label htmlFor={inputId}>
{t('Authenticator code or backup code')}
{acceptsBackupCode
? t('Authenticator code or backup code')
: t('Authenticator code')}
</Label>
<Input
id={inputId}
autoComplete='one-time-code'
maxLength={9}
maxLength={acceptsBackupCode ? 9 : 6}
autoFocus
disabled={verifying}
value={input.method === '2fa' ? input.code : ''}
......@@ -208,9 +216,11 @@ export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
}
/>
<p className='text-muted-foreground text-sm'>
{t(
'Enter the 6-digit authenticator code or an unused backup code.'
)}
{acceptsBackupCode
? t(
'Enter the 6-digit authenticator code or an unused backup code.'
)
: t('Enter the 6-digit authenticator code.')}
</p>
</TabsContent>
<TabsContent value='passkey'>
......
......@@ -19,10 +19,19 @@ For commercial licensing, please contact support@quantumnous.com
import { useCallback, useEffect, useReducer, useRef } from 'react'
import { AuthOperationError } from '@/lib/secure-verification'
import type { AuthBundle } from '@/stores/auth-store'
import { checkVerificationMethods, verify } from '../api'
import {
checkVerificationMethods,
getLoginVerificationRequirements,
verify,
verifyLogin,
} from '../api'
import type {
RequestVerificationOptions,
RequestLoginVerificationOptions,
VerificationRequest,
LoginChallenge,
SecureVerificationState,
SecurityProof,
VerificationInput,
......@@ -31,7 +40,7 @@ import type {
type VerificationAction =
| { type: 'reset' }
| { type: 'loading'; request: RequestVerificationOptions }
| { type: 'loading'; request: VerificationRequest }
| { type: 'loaded'; requirements: VerificationRequirements }
| { type: 'input'; input: VerificationInput }
| { type: 'submit' }
......@@ -106,15 +115,28 @@ function verificationReducer(
}
}
interface PendingVerification {
request: RequestVerificationOptions
interface PendingVerificationBase {
controller: AbortController
resolve: (proof: SecurityProof | null) => void
reject: (error: unknown) => void
initialPassword?: string
submitting: boolean
}
type PendingVerification = PendingVerificationBase &
(
| {
kind: 'operation'
request: RequestVerificationOptions
resolve: (proof: SecurityProof | null) => void
initialPassword?: string
}
| {
kind: 'login'
request: RequestLoginVerificationOptions
resolve: (bundle: AuthBundle | null) => void
initialPassword?: never
}
)
export function useSecureVerification() {
const [state, dispatch] = useReducer(verificationReducer, { phase: 'idle' })
const pending = useRef<PendingVerification | null>(null)
......@@ -133,14 +155,21 @@ export function useSecureVerification() {
const loadRequirements = useCallback(async (current: PendingVerification) => {
dispatch({ type: 'loading', request: current.request })
try {
const requirements = await checkVerificationMethods(
current.request.scope,
current.controller.signal
)
const requirements =
current.kind === 'login'
? await getLoginVerificationRequirements(
current.request.challenge,
current.controller.signal
)
: await checkVerificationMethods(
current.request.scope,
current.controller.signal
)
if (pending.current !== current) return
const initialPassword = current.initialPassword
current.initialPassword = undefined
if (
current.kind === 'operation' &&
initialPassword !== undefined &&
requirements.methods.length === 1 &&
requirements.methods[0].method === 'password' &&
......@@ -166,6 +195,7 @@ export function useSecureVerification() {
return
}
if (
current.kind === 'operation' &&
requirements.methods.length === 1 &&
requirements.methods[0].method === 'session' &&
requirements.methods[0].available
......@@ -203,6 +233,7 @@ export function useSecureVerification() {
if (pending.current) return Promise.resolve(null)
return new Promise((resolve, reject) => {
const current: PendingVerification = {
kind: 'operation',
request: structuredClone(request),
resolve,
reject,
......@@ -217,6 +248,28 @@ export function useSecureVerification() {
[loadRequirements]
)
const requestLoginVerification = useCallback(
(challenge: LoginChallenge): Promise<AuthBundle | null> => {
if (pending.current) return Promise.resolve(null)
return new Promise((resolve, reject) => {
const current: PendingVerification = {
kind: 'login',
request: {
scope: 'auth.login',
challenge: structuredClone(challenge),
},
resolve,
reject,
controller: new AbortController(),
submitting: false,
}
pending.current = current
void loadRequirements(current)
})
},
[loadRequirements]
)
const executeVerification = useCallback(async () => {
const current = pending.current
if (
......@@ -231,16 +284,26 @@ export function useSecureVerification() {
const input = state.input
dispatch({ type: 'submit' })
try {
const proof = await verify(
input,
current.request,
state.requirements.password_encryption_enabled,
current.controller.signal
)
if (pending.current !== current) return
if (current.kind === 'login') {
const bundle = await verifyLogin(
input,
current.request.challenge,
current.controller.signal
)
if (pending.current !== current) return
current.resolve(bundle)
} else {
const proof = await verify(
input,
current.request,
state.requirements.password_encryption_enabled,
current.controller.signal
)
if (pending.current !== current) return
current.resolve(proof)
}
pending.current = null
dispatch({ type: 'reset' })
current.resolve(proof)
} catch (error) {
if (pending.current !== current) return
const failure = AuthOperationError.from(error)
......@@ -266,6 +329,7 @@ export function useSecureVerification() {
return {
requestVerification,
requestLoginVerification,
cancel,
isActive: state.phase !== 'idle',
dialogProps: {
......
......@@ -16,6 +16,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { AuthBundle } from '@/stores/auth-store'
export type VerificationMethod =
| '2fa'
| 'passkey'
......@@ -27,12 +29,15 @@ export type SecurityProofScope =
| 'passkey.register'
| 'passkey.delete'
| '2fa.setup'
| '2fa.disable'
| '2fa.backup_codes.regenerate'
| 'access_token.generate'
| 'access_token.revoke'
| 'account.binding.bind'
| 'account.binding.unbind'
| 'account.password.set'
| 'account.password.change'
| 'account.delete'
export type VerificationOperation =
| { scope: 'channel.key.read'; context: { channel_id: number } }
......@@ -57,7 +62,7 @@ export interface SecurityProof {
}
export interface VerificationRequirements {
scope: SecurityProofScope
scope: SecurityProofScope | 'auth.login'
methods: { method: VerificationMethod; available: boolean; reason?: string }[]
oauth_providers: { slug: string; name: string }[]
password_encryption_enabled: boolean
......@@ -75,13 +80,32 @@ export type RequestVerificationOptions = VerificationOperation & {
description?: string
}
export interface LoginChallenge {
require_verification: true
flow_token: string
expires_at: number
methods: VerificationRequirements['methods']
}
export interface RequestLoginVerificationOptions {
scope: 'auth.login'
challenge: LoginChallenge
title?: string
description?: string
}
export type VerificationRequest =
| RequestVerificationOptions
| RequestLoginVerificationOptions
export type LoginResult = AuthBundle | LoginChallenge
export type SecureVerificationState =
| { phase: 'idle' }
| { phase: 'loading'; request: RequestVerificationOptions }
| { phase: 'error'; request: RequestVerificationOptions; error: string }
| { phase: 'loading'; request: VerificationRequest }
| { phase: 'error'; request: VerificationRequest; error: string }
| {
phase: 'ready' | 'verifying'
request: RequestVerificationOptions
request: VerificationRequest
requirements: VerificationRequirements
input: VerificationInput | null
error?: string
......
......@@ -49,7 +49,6 @@ import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
import type { AuthFormProps } from '@/features/auth/types'
import { useStatus } from '@/hooks/use-status'
import { isAuthBundle } from '@/lib/api'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
......@@ -57,7 +56,6 @@ import {
} from '@/lib/passkey'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
export function UserAuthForm({
className,
......@@ -95,10 +93,7 @@ export function UserAuthForm({
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect()
const setPending2FAFlowToken = useAuthStore(
(state) => state.auth.setPending2FAFlowToken
)
const { handleLoginResult } = useAuthRedirect()
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
......@@ -179,20 +174,10 @@ export function UserAuthForm({
})
if (res.success) {
if (res.data && 'require_2fa' in res.data && res.data.require_2fa) {
if (!res.data.flow_token) {
throw new Error(t('Login flow expired. Please sign in again.'))
}
setPending2FAFlowToken(res.data.flow_token)
redirectTo2FA()
return
}
if (!isAuthBundle(res.data)) {
throw new Error(t('Login failed'))
form.setValue('password', '')
if (await handleLoginResult(res.data, redirectTo)) {
toast.success(t('Welcome back!'))
}
await handleLoginSuccess(res.data, redirectTo)
toast.success(t('Welcome back!'))
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) return
......@@ -228,10 +213,11 @@ export function UserAuthForm({
setIsWeChatSubmitting(true)
try {
const res = await wechatLoginByCode(wechatCode)
if (res?.success && isAuthBundle(res.data)) {
await handleLoginSuccess(res.data, redirectTo)
toast.success(t('Signed in via WeChat'))
if (res?.success) {
handleWeChatDialogChange(false)
if (await handleLoginResult(res.data, redirectTo)) {
toast.success(t('Signed in via WeChat'))
}
} else {
if (getServerErrorMessageKey(res)) return
toast.error(res?.message || loginFailedMessage)
......@@ -296,12 +282,9 @@ export function UserAuthForm({
throw new Error(finish.message || t('Failed to complete Passkey login'))
}
if (!isAuthBundle(finish.data)) {
throw new Error(t('Missing user data from Passkey login response'))
if (await handleLoginResult(finish.data, redirectTo)) {
toast.success(t('Signed in with Passkey'))
}
await handleLoginSuccess(finish.data, redirectTo)
toast.success(t('Signed in with Passkey'))
} catch (error: unknown) {
if (getServerErrorMessageKey(error)) return
if (error instanceof DOMException && error.name === 'NotAllowedError') {
......
......@@ -50,7 +50,6 @@ import {
saveAffiliateCode,
} from '@/features/auth/lib/storage'
import { useStatus } from '@/hooks/use-status'
import { isAuthBundle } from '@/lib/api'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
......@@ -76,7 +75,7 @@ export function SignUpForm({
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const { redirectToLogin, handleLoginSuccess } = useAuthRedirect()
const { redirectToLogin, handleLoginResult } = useAuthRedirect()
const {
isSending: isSendingCode,
secondsLeft,
......@@ -215,10 +214,11 @@ export function SignUpForm({
setIsWeChatSubmitting(true)
try {
const res = await wechatLoginByCode(wechatCode)
if (res?.success && isAuthBundle(res.data)) {
await handleLoginSuccess(res.data)
toast.success(t('Signed in via WeChat'))
if (res?.success) {
handleWeChatDialogChange(false)
if (await handleLoginResult(res.data)) {
toast.success(t('Signed in via WeChat'))
}
} else {
if (getServerErrorMessageKey(res)) return
toast.error(res?.message || t('Login failed'))
......
......@@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import type { AuthBundle } from '@/stores/auth-store'
import type { LoginResult } from './secure-verification/types'
// ============================================================================
// API Payloads
// ============================================================================
......@@ -66,13 +68,7 @@ export interface BindEmailPayload {
export interface LoginResponse {
success: boolean
message: string
data?:
| AuthBundle
| {
require_2fa?: boolean
flow_token?: string
expires_at?: number
}
data?: LoginResult
}
export interface Login2FAResponse {
......
......@@ -27,7 +27,6 @@ import type {
UserProfile,
UpdateUserRequest,
UpdateUserSettingsRequest,
DeleteAccountRequest,
CheckinStatusResponse,
CheckinResponse,
AccountSecurityResult,
......@@ -102,11 +101,19 @@ export async function updateUserLanguage(
/**
* Delete user account
*/
export async function deleteUserAccount(
data?: DeleteAccountRequest
): Promise<ApiResponse> {
const res = await api.delete('/api/user/self', { data })
return res.data
export function deleteUserAccount(
proof: string,
signal: AbortSignal
): Promise<AccountSecurityResult> {
return authResult(
api.delete('/api/user/self', {
...authRequestOptions,
headers: { 'X-Security-Proof': proof },
singleUseAuthorization: true,
signal,
}),
'Failed to delete account'
)
}
// ============================================================================
......
......@@ -165,13 +165,6 @@ export interface UpdateUserSettingsRequest {
}
/**
* Account deletion request
*/
export interface DeleteAccountRequest {
password?: string
}
/**
* Account binding item
*/
export interface BindingItem {
......
......@@ -17,23 +17,20 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useNavigate } from '@tanstack/react-router'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useEffect, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { ConfirmDialog } from '@/components/confirm-dialog'
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 { logout } from '@/features/auth/api'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { deleteUserAccount } from '@/features/profile/api'
import { clearAuthentication } from '@/lib/api'
// ============================================================================
// Delete Account Dialog Component
// ============================================================================
import { useAccountSecurity } from '../../hooks/use-account-security'
interface DeleteAccountDialogProps {
open: boolean
......@@ -41,119 +38,80 @@ interface DeleteAccountDialogProps {
username: string
}
export function DeleteAccountDialog({
open,
onOpenChange,
username,
}: DeleteAccountDialogProps) {
export function DeleteAccountDialog(props: DeleteAccountDialogProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const [loading, setLoading] = useState(false)
const confirmationId = useId()
const [confirmation, setConfirmation] = useState('')
const security = useAccountSecurity()
const cancel = security.cancel
const handleDelete = async () => {
if (confirmation !== username) {
toast.error(t('Username confirmation does not match'))
return
}
try {
setLoading(true)
const response = await deleteUserAccount()
if (response.success) {
toast.success(t('Account deleted successfully'))
// Logout and redirect
try {
await logout()
} catch {
// Ignore logout errors
}
useEffect(() => {
setConfirmation('')
if (!props.open) cancel()
}, [props.open, props.username, security.sessionKey, cancel])
clearAuthentication()
navigate({ to: '/sign-in' })
} else {
toast.error(response.message || t('Failed to delete account'))
}
} catch {
toast.error(t('Failed to delete account'))
} finally {
setLoading(false)
const handleOpenChange = (open: boolean) => {
if (!open) {
security.cancel()
setConfirmation('')
}
props.onOpenChange(open)
}
const handleOpenChange = (open: boolean) => {
if (!loading) {
onOpenChange(open)
if (!open) {
setConfirmation('')
}
}
const handleDelete = async () => {
if (confirmation !== props.username) return
const result = await security.run(async (signal) => {
const proof = await security.verify(
{ scope: 'account.delete', title: t('Delete Account') },
signal
)
return deleteUserAccount(proof, signal)
})
if (!result) return
toast.success(t('Account deleted successfully'))
props.onOpenChange(false)
clearAuthentication()
navigate({ to: '/sign-in' })
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('Delete Account')}
</>
}
description={t(
'This action cannot be undone. This will permanently delete your account and remove all your data from our servers.'
)}
contentClassName='sm:max-w-md'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
onClick={() => handleOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button
type='button'
variant='destructive'
onClick={handleDelete}
disabled={loading || confirmation !== username}
>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Deleting...') : t('Delete Account')}
</Button>
</>
}
>
<div className='my-6 space-y-4'>
<>
<ConfirmDialog
open={props.open && !security.showVerification}
onOpenChange={handleOpenChange}
title={t('Delete Account')}
desc={t(
'This action cannot be undone. This will permanently delete your account and remove all your data from our servers.'
)}
confirmText={security.pending ? t('Deleting...') : t('Delete Account')}
destructive
disabled={confirmation !== props.username || security.pending}
isLoading={security.pending}
handleConfirm={handleDelete}
>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertTriangle className='size-4' />
<AlertDescription>
{t('Warning: This action is permanent and irreversible!')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='confirmation'>
{t('Type')} <strong>{username}</strong> {t('to confirm')}
<Label htmlFor={confirmationId}>
{t('Type')} <strong>{props.username}</strong> {t('to confirm')}
</Label>
<Input
id='confirmation'
id={confirmationId}
type='text'
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
disabled={loading}
placeholder={username}
onChange={(event) => setConfirmation(event.target.value)}
disabled={security.pending}
placeholder={props.username}
autoComplete='off'
/>
</div>
</div>
</Dialog>
</ConfirmDialog>
<SecureVerificationDialog {...security.verificationDialogProps} />
</>
)
}
......@@ -16,22 +16,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { RefreshCw, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { CopyButton } from '@/components/copy-button'
import { Dialog } from '@/components/dialog'
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 { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { regenerate2FABackupCodes } from '@/lib/api'
// ============================================================================
// Two-FA Backup Codes Dialog Component
// ============================================================================
import { useAccountSecurity } from '../../hooks/use-account-security'
interface TwoFABackupDialogProps {
open: boolean
......@@ -39,153 +36,96 @@ interface TwoFABackupDialogProps {
onSuccess: () => void
}
export function TwoFABackupDialog({
open,
onOpenChange,
onSuccess,
}: TwoFABackupDialogProps) {
export function TwoFABackupDialog(props: TwoFABackupDialogProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [code, setCode] = useState('')
const [backupCodes, setBackupCodes] = useState<string[]>([])
const security = useAccountSecurity()
const cancel = security.cancel
const handleRegenerate = async () => {
if (!code) {
toast.error(t('Please enter your verification code'))
return
}
try {
setLoading(true)
const response = await regenerate2FABackupCodes(code)
if (response.success && response.data?.backup_codes) {
setBackupCodes(response.data.backup_codes)
toast.success(t('Backup codes regenerated successfully'))
} else {
toast.error(response.message || t('Failed to regenerate backup codes'))
}
} catch {
toast.error(t('Failed to regenerate backup codes'))
} finally {
setLoading(false)
}
}
const handleDone = () => {
handleOpenChange(false)
onSuccess()
}
useEffect(() => {
setBackupCodes([])
if (!props.open) cancel()
}, [props.open, security.sessionKey, cancel])
const handleOpenChange = (open: boolean) => {
if (!loading) {
if (!open) {
setCode('')
setBackupCodes([])
}
onOpenChange(open)
const changed = !open && backupCodes.length > 0
if (!open) {
security.cancel()
setBackupCodes([])
}
props.onOpenChange(open)
if (changed) props.onSuccess()
}
const handleRegenerate = async () => {
const result = await security.run(async (signal) => {
const proof = await security.verify(
{ scope: '2fa.backup_codes.regenerate' },
signal
)
return regenerate2FABackupCodes(proof, signal)
})
if (!result) return
setBackupCodes(result.backup_codes)
toast.success(t('Backup codes regenerated successfully'))
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<RefreshCw className='h-5 w-5' />
{t('Regenerate Backup Codes')}
</>
}
description={
backupCodes.length > 0
? t('Your new backup codes are ready')
: t('Generate new backup codes for account recovery')
}
contentClassName='sm:max-w-md'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
backupCodes.length === 0 ? (
<>
<Button
variant='outline'
onClick={() => handleOpenChange(false)}
disabled={loading}
<>
<ConfirmDialog
open={
props.open && backupCodes.length === 0 && !security.showVerification
}
onOpenChange={handleOpenChange}
title={t('Regenerate Backup Codes')}
desc={t(
'Generating new codes will invalidate all existing backup codes.'
)}
confirmText={t('Generate New Codes')}
isLoading={security.pending}
handleConfirm={handleRegenerate}
/>
<Dialog
open={props.open && backupCodes.length > 0}
onOpenChange={handleOpenChange}
title={t('Regenerate Backup Codes')}
description={t('Your new backup codes are ready')}
contentClassName='sm:max-w-md'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button onClick={() => handleOpenChange(false)}>{t('Done')}</Button>
}
>
<Alert>
<AlertDescription>
{t(
'Save these codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='grid grid-cols-2 gap-2 rounded-lg border p-4'>
{backupCodes.map((code) => (
<div
key={code}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{t('Cancel')}
</Button>
<Button onClick={handleRegenerate} disabled={loading || !code}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Generating...') : t('Generate New Codes')}
</Button>
</>
) : (
<Button onClick={handleDone}>{t('Done')}</Button>
)
}
>
<div className='space-y-4 py-4'>
{backupCodes.length === 0 ? (
<>
<Alert>
<AlertDescription>
{t(
'Generating new codes will invalidate all existing backup codes.'
)}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter authenticator code')}
maxLength={6}
disabled={loading}
/>
</div>
</>
) : (
<>
<Alert>
<AlertDescription>
{t(
'Save these codes in a safe place. Each code can only be used once.'
)}
</AlertDescription>
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code) => (
<div
key={code}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
</div>
))}
</div>
{code}
</div>
<CopyButton
value={backupCodes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</>
)}
</div>
</Dialog>
))}
</div>
<CopyButton
value={backupCodes.join('\n')}
variant='outline'
size='default'
className='w-full'
iconClassName='mr-2 size-4'
tooltip={t('Copy all backup codes')}
aria-label={t('Copy all backup codes')}
>
{t('Copy All Codes')}
</CopyButton>
</Dialog>
<SecureVerificationDialog {...security.verificationDialogProps} />
</>
)
}
......@@ -16,22 +16,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AlertTriangle, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useEffect, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { disable2FA } from '@/lib/api'
// ============================================================================
// Two-FA Disable Dialog Component
// ============================================================================
import { useAccountSecurity } from '../../hooks/use-account-security'
interface TwoFADisableDialogProps {
open: boolean
......@@ -39,133 +36,76 @@ interface TwoFADisableDialogProps {
onSuccess: () => void
}
export function TwoFADisableDialog({
open,
onOpenChange,
onSuccess,
}: TwoFADisableDialogProps) {
export function TwoFADisableDialog(props: TwoFADisableDialogProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [code, setCode] = useState('')
const confirmId = useId()
const [confirmed, setConfirmed] = useState(false)
const security = useAccountSecurity()
const cancel = security.cancel
const handleDisable = async () => {
if (!code) {
toast.error(t('Please enter your verification code or backup code'))
return
}
if (!confirmed) {
toast.error(t('Please confirm that you understand the consequences'))
return
}
try {
setLoading(true)
const response = await disable2FA(code)
if (response.success) {
toast.success(t('Two-factor authentication disabled'))
onOpenChange(false)
onSuccess()
// Reset
setCode('')
setConfirmed(false)
} else {
toast.error(response.message || t('Failed to disable 2FA'))
}
} catch {
toast.error(t('Failed to disable 2FA'))
} finally {
setLoading(false)
}
}
useEffect(() => {
setConfirmed(false)
if (!props.open) cancel()
}, [props.open, security.sessionKey, cancel])
const handleOpenChange = (open: boolean) => {
if (!loading) {
if (!open) {
setCode('')
setConfirmed(false)
}
onOpenChange(open)
if (!open) {
security.cancel()
setConfirmed(false)
}
props.onOpenChange(open)
}
const handleDisable = async () => {
if (!confirmed) return
const result = await security.run(async (signal) => {
const proof = await security.verify({ scope: '2fa.disable' }, signal)
return disable2FA(proof, signal)
})
if (!result) return
toast.success(t('Two-factor authentication disabled'))
props.onOpenChange(false)
props.onSuccess()
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('Disable Two-Factor Authentication')}
</>
}
description={t(
'This action will permanently remove 2FA protection from your account.'
)}
contentClassName='sm:max-w-md'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => handleOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button
variant='destructive'
onClick={handleDisable}
disabled={loading || !code || !confirmed}
>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Disabling...') : t('Disable 2FA')}
</Button>
</>
}
>
<div className='space-y-4 py-4'>
<>
<ConfirmDialog
open={props.open && !security.showVerification}
onOpenChange={handleOpenChange}
title={t('Disable Two-Factor Authentication')}
desc={t(
'This action will permanently remove 2FA protection from your account.'
)}
confirmText={t('Disable 2FA')}
destructive
disabled={!confirmed || security.pending}
isLoading={security.pending}
handleConfirm={handleDisable}
>
<Alert variant='destructive'>
<AlertTriangle className='h-4 w-4' />
<AlertTriangle className='size-4' />
<AlertDescription>
{t('Warning: Disabling 2FA will make your account less secure.')}
</AlertDescription>
</Alert>
<div className='space-y-2'>
<Label htmlFor='code'>{t('Verification Code')}</Label>
<Input
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={t('Enter code or backup code')}
disabled={loading}
/>
<p className='text-muted-foreground text-xs'>
{t('Enter your authenticator code or a backup code')}
</p>
</div>
<div className='flex items-start space-x-2'>
<div className='flex items-start gap-2'>
<Checkbox
id='confirm'
id={confirmId}
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked as boolean)}
disabled={security.pending}
onCheckedChange={(checked) => setConfirmed(checked === true)}
/>
<Label
htmlFor='confirm'
htmlFor={confirmId}
className='text-sm leading-tight font-normal'
>
{t(
'I understand that disabling 2FA will remove all protection and backup codes'
'I understand that disabling 2FA removes its authenticator and backup codes.'
)}
</Label>
</div>
</div>
</Dialog>
</ConfirmDialog>
<SecureVerificationDialog {...security.verificationDialogProps} />
</>
)
}
......@@ -415,6 +415,7 @@ const AUDIT_TEMPLATES: Record<string, string> = {
'user.create': 'Created user {{username}} (role {{role}})',
'user.update': 'Updated user {{username}} (ID: {{id}})',
'user.delete': 'Deleted user {{username}} (ID: {{id}})',
'user.account_delete': 'Account deletion',
'user.manage': 'Performed {{action}} on user {{username}} (ID: {{id}})',
'user.quota_add': 'Increased user quota by {{quota}}',
'user.quota_subtract': 'Decreased user quota by {{quota}}',
......
......@@ -154,6 +154,7 @@
"Account bindings have changed. Start this operation again.": "Account bindings have changed. Start this operation again.",
"Account created! Please sign in": "Account created! Please sign in",
"Account deleted successfully": "Account deleted successfully",
"Account deletion": "Account deletion",
"Account ID *": "Account ID *",
"Account Info": "Account Info",
"Account password change": "Account password change",
......@@ -1028,6 +1029,7 @@
"Compilation failed": "Compilation failed",
"Complete API documentation with multi-language SDK support": "Complete API documentation with multi-language SDK support",
"Complete Order": "Complete Order",
"Complete sign-in": "Complete sign-in",
"Complete these steps to finish the initial installation.": "Complete these steps to finish the initial installation.",
"Completed": "Completed",
"Completed security verification": "Completed security verification",
......@@ -1840,6 +1842,7 @@
"Enter system prompt (user prompt takes priority)": "Enter system prompt (user prompt takes priority)",
"Enter tag name (optional)": "Enter tag name (optional)",
"Enter the 6-digit authenticator code or an unused backup code.": "Enter the 6-digit authenticator code or an unused backup code.",
"Enter the 6-digit authenticator code.": "Enter the 6-digit authenticator code.",
"Enter the 6-digit code from your authenticator app": "Enter the 6-digit code from your authenticator app",
"Enter the 6-digit email verification code.": "Enter the 6-digit email verification code.",
"Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.": "Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.",
......@@ -2464,6 +2467,7 @@
"I have read and agree to the": "I have read and agree to the",
"I have read and understood the above compliance reminder": "I have read and understood the above compliance reminder",
"I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.",
"I understand that disabling 2FA removes its authenticator and backup codes.": "I understand that disabling 2FA removes its authenticator and backup codes.",
"I understand that disabling 2FA will remove all protection and backup codes": "I understand that disabling 2FA will remove all protection and backup codes",
"Icon": "Icon",
"Icon file must be 100 KB or smaller": "Icon file must be 100 KB or smaller",
......@@ -5545,6 +5549,7 @@
"Verify Setup": "Verify Setup",
"Verify to view channel key": "Verify to view channel key",
"Verify your database connection": "Verify your database connection",
"Verify your identity to finish signing in.": "Verify your identity to finish signing in.",
"Verifying credentials and pulling stores from your Pancake account...": "Verifying credentials and pulling stores from your Pancake account...",
"Verifying your {{provider}} account": "Verifying your {{provider}} account",
"Version": "Version",
......
......@@ -154,6 +154,7 @@
"Account bindings have changed. Start this operation again.": "Les comptes liés ont changé. Recommencez cette opération.",
"Account created! Please sign in": "Compte créé ! Veuillez vous connecter",
"Account deleted successfully": "Compte supprimé avec succès",
"Account deletion": "Suppression du compte",
"Account ID *": "ID de compte *",
"Account Info": "Informations du compte",
"Account password change": "Modification du mot de passe",
......@@ -1028,6 +1029,7 @@
"Compilation failed": "Échec de compilation",
"Complete API documentation with multi-language SDK support": "Documentation API complète avec support SDK multilingue",
"Complete Order": "Compléter la commande",
"Complete sign-in": "Finaliser la connexion",
"Complete these steps to finish the initial installation.": "Suivez ces étapes pour terminer l'installation initiale.",
"Completed": "Terminé",
"Completed security verification": "Vérification de sécurité terminée",
......@@ -1840,6 +1842,7 @@
"Enter system prompt (user prompt takes priority)": "Saisir l'invite système (l'invite utilisateur est prioritaire)",
"Enter tag name (optional)": "Saisir le nom du tag (facultatif)",
"Enter the 6-digit authenticator code or an unused backup code.": "Saisissez le code à 6 chiffres ou un code de secours inutilisé.",
"Enter the 6-digit authenticator code.": "Saisissez le code à 6 chiffres de votre application d’authentification.",
"Enter the 6-digit code from your authenticator app": "Saisir le code à 6 chiffres de votre application d'authentification",
"Enter the 6-digit email verification code.": "Saisissez le code à 6 chiffres reçu par e-mail.",
"Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.": "Saisir le mot de passe à usage unique basé sur le temps à 6 chiffres ou le code de secours à 8 caractères de votre application d'authentification.",
......@@ -2464,6 +2467,7 @@
"I have read and agree to the": "J'ai lu et j'accepte les",
"I have read and understood the above compliance reminder": "J’ai lu et compris le rappel de conformité ci-dessus",
"I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "J’ai lu et compris le rappel de conformité ci-dessus, je reconnais les risques juridiques associés et confirme assumer la responsabilité juridique liée au déploiement, à l’exploitation et à la facturation.",
"I understand that disabling 2FA removes its authenticator and backup codes.": "Je comprends que désactiver la 2FA supprimera l’authentificateur associé et ses codes de secours.",
"I understand that disabling 2FA will remove all protection and backup codes": "Je comprends que la désactivation de la 2FA supprimera toute protection et les codes de secours",
"Icon": "Icône",
"Icon file must be 100 KB or smaller": "Le fichier icône doit être de 100 Ko ou moins",
......@@ -5545,6 +5549,7 @@
"Verify Setup": "Vérifier la configuration",
"Verify to view channel key": "Vérifier pour afficher la clé du canal",
"Verify your database connection": "Vérifiez votre connexion à la base de données",
"Verify your identity to finish signing in.": "Vérifiez votre identité pour terminer la connexion.",
"Verifying credentials and pulling stores from your Pancake account...": "Vérification des identifiants et récupération des boutiques depuis votre compte Pancake...",
"Verifying your {{provider}} account": "Vérification de votre compte {{provider}}",
"Version": "Version",
......
......@@ -154,6 +154,7 @@
"Account bindings have changed. Start this operation again.": "アカウントの連携状態が変わりました。操作をやり直してください。",
"Account created! Please sign in": "アカウントが作成されました!ログインしてください",
"Account deleted successfully": "アカウントが正常に削除されました",
"Account deletion": "アカウント削除",
"Account ID *": "アカウントID *",
"Account Info": "アカウント情報",
"Account password change": "アカウントのパスワード変更",
......@@ -1028,6 +1029,7 @@
"Compilation failed": "コンパイル失敗",
"Complete API documentation with multi-language SDK support": "多言語SDKをサポートする完全なAPIドキュメント",
"Complete Order": "手動チャージ",
"Complete sign-in": "ログインを完了",
"Complete these steps to finish the initial installation.": "初期インストールを完了するには、これらの手順を完了してください。",
"Completed": "完了",
"Completed security verification": "セキュリティ確認を完了しました",
......@@ -1840,6 +1842,7 @@
"Enter system prompt (user prompt takes priority)": "システムプロンプトを入力 (ユーザープロンプトが優先されます)",
"Enter tag name (optional)": "タグ名を入力 (オプション)",
"Enter the 6-digit authenticator code or an unused backup code.": "認証アプリの6桁のコードか、未使用のバックアップコードを入力してください。",
"Enter the 6-digit authenticator code.": "認証アプリの6桁のコードを入力してください。",
"Enter the 6-digit code from your authenticator app": "認証アプリからの6桁のコードを入力",
"Enter the 6-digit email verification code.": "メールで届いた6桁の確認コードを入力してください。",
"Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.": "認証アプリからの6桁のワンタイムパスワードまたは8文字のバックアップコードを入力してください。",
......@@ -2464,6 +2467,7 @@
"I have read and agree to the": "私は以下を読み、同意します",
"I have read and understood the above compliance reminder": "上記のコンプライアンス注意事項を読み、理解しました",
"I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "上記のコンプライアンス注意事項を読み理解し、関連する法的リスクを認識したうえで、デプロイ、運用、課金行為に起因する法的責任を負うことを確認します。",
"I understand that disabling 2FA removes its authenticator and backup codes.": "2FAを無効にすると、認証アプリによる認証とバックアップコードが削除されることを理解しました。",
"I understand that disabling 2FA will remove all protection and backup codes": "2FA を無効にすると、すべての保護とバックアップコードが削除されることを理解しています",
"Icon": "アイコン",
"Icon file must be 100 KB or smaller": "アイコンファイルは100KB以下である必要があります",
......@@ -5545,6 +5549,7 @@
"Verify Setup": "設定を確認",
"Verify to view channel key": "認証してチャネルキーを表示",
"Verify your database connection": "データベース接続を確認",
"Verify your identity to finish signing in.": "本人確認を行い、ログインを完了してください。",
"Verifying credentials and pulling stores from your Pancake account...": "認証情報を検証し、Pancake アカウントからストアを取得しています...",
"Verifying your {{provider}} account": "{{provider}}アカウントを確認しています",
"Version": "バージョン",
......
......@@ -154,6 +154,7 @@
"Account bindings have changed. Start this operation again.": "Привязки аккаунта изменились. Начните операцию заново.",
"Account created! Please sign in": "Аккаунт создан! Пожалуйста, войдите в систему",
"Account deleted successfully": "Аккаунт успешно удалён",
"Account deletion": "Удаление аккаунта",
"Account ID *": "ID аккаунта *",
"Account Info": "Информация об аккаунте",
"Account password change": "Изменение пароля аккаунта",
......@@ -1028,6 +1029,7 @@
"Compilation failed": "Ошибка компиляции",
"Complete API documentation with multi-language SDK support": "Полная документация API с поддержкой SDK на нескольких языках",
"Complete Order": "Вывод заказа",
"Complete sign-in": "Завершение входа",
"Complete these steps to finish the initial installation.": "Выполните эти шаги, чтобы завершить начальную установку.",
"Completed": "Завершено",
"Completed security verification": "Проверка безопасности завершена",
......@@ -1840,6 +1842,7 @@
"Enter system prompt (user prompt takes priority)": "Введите системный промпт (пользовательский промпт имеет приоритет)",
"Enter tag name (optional)": "Введите имя тега (необязательно)",
"Enter the 6-digit authenticator code or an unused backup code.": "Введите 6-значный код аутентификатора или неиспользованный резервный код.",
"Enter the 6-digit authenticator code.": "Введите 6-значный код из приложения-аутентификатора.",
"Enter the 6-digit code from your authenticator app": "Введите 6-значный код из вашего приложения-аутентификатора",
"Enter the 6-digit email verification code.": "Введите 6-значный код из письма.",
"Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.": "Введите 6-значный одноразовый пароль на основе времени или 8-значный резервный код из вашего приложения-аутентификатора.",
......@@ -2464,6 +2467,7 @@
"I have read and agree to the": "Я прочитал и согласен с",
"I have read and understood the above compliance reminder": "Я прочитал и понял приведенное выше напоминание о соответствии",
"I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "Я прочитал и понял приведенное выше напоминание о соответствии, признаю связанные правовые риски и подтверждаю, что несу юридическую ответственность за развертывание, эксплуатацию и взимание платы.",
"I understand that disabling 2FA removes its authenticator and backup codes.": "Я понимаю, что отключение 2FA удалит привязку приложения-аутентификатора и резервные коды.",
"I understand that disabling 2FA will remove all protection and backup codes": "Я понимаю, что отключение 2FA удалит всю защиту и резервные коды",
"Icon": "Значок",
"Icon file must be 100 KB or smaller": "Файл иконки должен быть не более 100 КБ",
......@@ -5545,6 +5549,7 @@
"Verify Setup": "Проверить настройку",
"Verify to view channel key": "Подтвердить для просмотра ключа канала",
"Verify your database connection": "Проверьте подключение к базе данных",
"Verify your identity to finish signing in.": "Подтвердите свою личность, чтобы завершить вход.",
"Verifying credentials and pulling stores from your Pancake account...": "Проверяем учетные данные и загружаем магазины из вашего аккаунта Pancake...",
"Verifying your {{provider}} account": "Проверка аккаунта {{provider}}",
"Version": "Версия",
......
......@@ -154,6 +154,7 @@
"Account bindings have changed. Start this operation again.": "Liên kết tài khoản đã thay đổi. Vui lòng thực hiện lại.",
"Account created! Please sign in": "Tài khoản đã được tạo! Vui lòng đăng nhập",
"Account deleted successfully": "Tài khoản đã được xóa thành công",
"Account deletion": "Xóa tài khoản",
"Account ID *": "ID tài khoản *",
"Account Info": "Thông tin tài khoản",
"Account password change": "Thay đổi mật khẩu tài khoản",
......@@ -1028,6 +1029,7 @@
"Compilation failed": "Biên dịch thất bại",
"Complete API documentation with multi-language SDK support": "Tài liệu API đầy đủ với hỗ trợ SDK đa ngôn ngữ",
"Complete Order": "Hoàn thành đơn hàng",
"Complete sign-in": "Hoàn tất đăng nhập",
"Complete these steps to finish the initial installation.": "Hoàn thành các bước này để hoàn tất quá trình cài đặt ban đầu.",
"Completed": "Hoàn thành",
"Completed security verification": "Đã hoàn tất xác minh bảo mật",
......@@ -1840,6 +1842,7 @@
"Enter system prompt (user prompt takes priority)": "Nhập lời nhắc hệ thống (lời nhắc người dùng được ưu tiên)",
"Enter tag name (optional)": "Nhập tên thẻ (tùy chọn)",
"Enter the 6-digit authenticator code or an unused backup code.": "Nhập mã xác thực 6 chữ số hoặc mã dự phòng chưa sử dụng.",
"Enter the 6-digit authenticator code.": "Nhập mã 6 chữ số từ ứng dụng xác thực.",
"Enter the 6-digit code from your authenticator app": "Nhập mã 6 chữ số từ ứng dụng xác thực của bạn",
"Enter the 6-digit email verification code.": "Nhập mã xác minh email gồm 6 chữ số.",
"Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.": "Nhập Mật khẩu dùng một lần dựa trên thời gian gồm 6 chữ số hoặc mã dự phòng gồm 8 ký tự từ ứng dụng xác thực của bạn.",
......@@ -2464,6 +2467,7 @@
"I have read and agree to the": "Tôi đã đọc và đồng ý với",
"I have read and understood the above compliance reminder": "Tôi đã đọc và hiểu nhắc nhở tuân thủ ở trên",
"I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "Tôi đã đọc và hiểu nhắc nhở tuân thủ ở trên, thừa nhận các rủi ro pháp lý liên quan và xác nhận rằng tôi chịu trách nhiệm pháp lý phát sinh từ việc triển khai, vận hành và thu phí.",
"I understand that disabling 2FA removes its authenticator and backup codes.": "Tôi hiểu rằng tắt 2FA sẽ xóa phương thức xác thực bằng ứng dụng và các mã dự phòng.",
"I understand that disabling 2FA will remove all protection and backup codes": "Tôi hiểu rằng việc vô",
"Icon": "Biểu tượng",
"Icon file must be 100 KB or smaller": "Tệp biểu tượng phải có kích thước 100 KB hoặc nhỏ hơn",
......@@ -5545,6 +5549,7 @@
"Verify Setup": "Xác minh thiết lập",
"Verify to view channel key": "Xác minh để xem khóa kênh",
"Verify your database connection": "Xác minh kết nối cơ sở dữ liệu của bạn",
"Verify your identity to finish signing in.": "Xác minh danh tính để hoàn tất đăng nhập.",
"Verifying credentials and pulling stores from your Pancake account...": "Đang xác minh thông tin xác thực và lấy cửa hàng từ tài khoản Pancake của bạn...",
"Verifying your {{provider}} account": "Đang xác minh tài khoản {{provider}} của bạn",
"Version": "Phiên bản",
......
......@@ -154,6 +154,7 @@
"Account bindings have changed. Start this operation again.": "帳戶綁定已變更,請重新操作。",
"Account created! Please sign in": "用戶已建立!請登入",
"Account deleted successfully": "用戶刪除成功",
"Account deletion": "帳號註銷",
"Account ID *": "用戶 ID *",
"Account Info": "用戶資訊",
"Account password change": "帳戶密碼變更",
......@@ -1028,6 +1029,7 @@
"Compilation failed": "编译失败",
"Complete API documentation with multi-language SDK support": "完整的 API 文件,支援多語言 SDK",
"Complete Order": "補單",
"Complete sign-in": "完成登入",
"Complete these steps to finish the initial installation.": "完成這些步驟以完成初始安裝。",
"Completed": "已完成",
"Completed security verification": "已完成安全驗證",
......@@ -1840,6 +1842,7 @@
"Enter system prompt (user prompt takes priority)": "輸入系統提示詞(用戶提示詞優先)",
"Enter tag name (optional)": "輸入標籤名稱(可選)",
"Enter the 6-digit authenticator code or an unused backup code.": "輸入驗證器的 6 位驗證碼或未使用的備用碼。",
"Enter the 6-digit authenticator code.": "請輸入驗證器中的 6 位驗證碼。",
"Enter the 6-digit code from your authenticator app": "輸入來自身份驗證器套用的 6 位代碼",
"Enter the 6-digit email verification code.": "請輸入六位信箱驗證碼。",
"Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.": "輸入來自身份驗證器套用的 6 位基於時間的單次密碼或 8 位備份代碼。",
......@@ -2464,6 +2467,7 @@
"I have read and agree to the": "我已閱讀並同意",
"I have read and understood the above compliance reminder": "我已閱讀並理解上述合規提醒",
"I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "我已閱讀並理解上述合規提醒,確認相關法律風險,並確認承擔因部署、運營和收費行為產生的法律責任。",
"I understand that disabling 2FA removes its authenticator and backup codes.": "我了解,停用 2FA 會移除驗證器驗證方式及其備用碼。",
"I understand that disabling 2FA will remove all protection and backup codes": "我理解停用 2FA 將移除所有保護和備份代碼",
"Icon": "圖標",
"Icon file must be 100 KB or smaller": "圖標檔案必須小於等於 100 KB",
......@@ -5545,6 +5549,7 @@
"Verify Setup": "驗證設定",
"Verify to view channel key": "驗證後查看渠道金鑰",
"Verify your database connection": "驗證資料庫連接",
"Verify your identity to finish signing in.": "請驗證身分以完成登入。",
"Verifying credentials and pulling stores from your Pancake account...": "正在驗證憑證並從你的 Pancake 用戶拉取店鋪...",
"Verifying your {{provider}} account": "正在驗證 {{provider}} 帳號",
"Version": "版本",
......
......@@ -154,6 +154,7 @@
"Account bindings have changed. Start this operation again.": "账户绑定已变化,请重新操作。",
"Account created! Please sign in": "账户已创建!请登录",
"Account deleted successfully": "账户删除成功",
"Account deletion": "账号注销",
"Account ID *": "账户 ID *",
"Account Info": "账户信息",
"Account password change": "账户密码变更",
......@@ -1028,6 +1029,7 @@
"Compilation failed": "编译失败",
"Complete API documentation with multi-language SDK support": "完整的 API 文档,支持多语言 SDK",
"Complete Order": "补单",
"Complete sign-in": "完成登录",
"Complete these steps to finish the initial installation.": "完成这些步骤以完成初始安装。",
"Completed": "已完成",
"Completed security verification": "完成了安全验证",
......@@ -1840,6 +1842,7 @@
"Enter system prompt (user prompt takes priority)": "输入系统提示词(用户提示词优先)",
"Enter tag name (optional)": "输入标签名称(可选)",
"Enter the 6-digit authenticator code or an unused backup code.": "输入认证器的 6 位验证码或未使用的备用码。",
"Enter the 6-digit authenticator code.": "请输入身份验证器中的 6 位验证码。",
"Enter the 6-digit code from your authenticator app": "输入来自身份验证器应用的 6 位代码",
"Enter the 6-digit email verification code.": "请输入六位邮箱验证码。",
"Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.": "输入来自身份验证器应用的 6 位基于时间的单次密码或 8 位备份代码。",
......@@ -2464,6 +2467,7 @@
"I have read and agree to the": "我已阅读并同意",
"I have read and understood the above compliance reminder": "我已阅读并理解上述合规提醒",
"I have read and understood the above compliance reminder, acknowledge the related legal risks, and confirm that I bear legal responsibility arising from deployment, operation, and charging behavior.": "我已阅读并理解上述合规提醒,确认相关法律风险,并确认承担因部署、运营和收费行为产生的法律责任。",
"I understand that disabling 2FA removes its authenticator and backup codes.": "我了解,关闭 2FA 将移除身份验证器验证方式及其备用码。",
"I understand that disabling 2FA will remove all protection and backup codes": "我理解禁用 2FA 将移除所有保护和备份代码",
"Icon": "图标",
"Icon file must be 100 KB or smaller": "图标文件必须小于等于 100 KB",
......@@ -5545,6 +5549,7 @@
"Verify Setup": "验证设置",
"Verify to view channel key": "验证后查看渠道密钥",
"Verify your database connection": "验证数据库连接",
"Verify your identity to finish signing in.": "请验证身份以完成登录。",
"Verifying credentials and pulling stores from your Pancake account...": "正在验证凭证并从你的 Pancake 账户拉取店铺...",
"Verifying your {{provider}} account": "正在验证 {{provider}} 账号",
"Version": "版本",
......
......@@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
// Static translation keys that don't get picked up by the t('...') regex.
// These cover dynamic labels (e.g. constants, configs) that are passed into t at runtime.
export const STATIC_I18N_KEYS = [
'Account deletion',
// Header navigation
'Home',
'Console',
......
......@@ -17,6 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/http-client'
import { authRequestOptions, authResult } from '@/lib/secure-verification'
export {
applyAuthBundle,
......@@ -93,20 +94,40 @@ export async function getNotice(): Promise<{
// 2FA Management APIs
// ============================================================================
export async function disable2FA(code: string) {
const res = await api.post(
'/api/user/2fa/disable',
{ code },
{ acceptAuthRotation: true }
export function disable2FA(
proofToken: string,
signal?: AbortSignal
): Promise<{ notification_warning?: boolean }> {
return authResult(
api.post(
'/api/user/2fa/disable',
{},
{
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
acceptAuthRotation: true,
singleUseAuthorization: true,
signal,
}
)
)
return res.data
}
export async function regenerate2FABackupCodes(code: string) {
const res = await api.post(
'/api/user/2fa/backup_codes',
{ code },
{ acceptAuthRotation: true }
export function regenerate2FABackupCodes(
proofToken: string,
signal?: AbortSignal
): Promise<{ backup_codes: string[]; notification_warning?: boolean }> {
return authResult(
api.post(
'/api/user/2fa/backup_codes',
{},
{
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
acceptAuthRotation: true,
singleUseAuthorization: true,
signal,
}
)
)
return res.data
}
......@@ -293,7 +293,14 @@ describe('authentication session coordination', () => {
mutationFn: async () => undefined,
})
useAuthStore.getState().auth.setBundle(bundle)
useAuthStore.getState().auth.setPending2FAFlowToken('pending-flow')
useAuthStore.getState().auth.setPendingLoginVerification({
challenge: {
require_verification: true,
flow_token: 'pending-flow',
expires_at: 9999999999,
methods: [{ method: '2fa', available: true }],
},
})
clearAuthenticatedClientState(queryClient, false)
......@@ -302,7 +309,7 @@ describe('authentication session coordination', () => {
expect(useAuthStore.getState().auth.user).toBe(null)
expect(useAuthStore.getState().auth.accessToken).toBe(null)
expect(useAuthStore.getState().auth.session).toBe(null)
expect(useAuthStore.getState().auth.pending2FAFlowToken).toBe(null)
expect(useAuthStore.getState().auth.pendingLoginVerification).toBe(null)
expect(useAuthStore.getState().auth.bootstrapState).toBe('complete')
const nextBundle: AuthBundle = {
......
......@@ -240,34 +240,9 @@ export function buildAssertionResult(
* Check if current environment supports Passkey/WebAuthn.
*/
export async function isPasskeySupported(): Promise<boolean> {
if (typeof window === 'undefined') return false
const { PublicKeyCredential } = window
if (!PublicKeyCredential) return false
if (
typeof PublicKeyCredential.isConditionalMediationAvailable === 'function'
) {
try {
const available =
await PublicKeyCredential.isConditionalMediationAvailable()
if (available) return true
} catch {
// ignore
}
}
if (
typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable ===
'function'
) {
try {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch {
return false
}
}
return true
// A missing platform authenticator does not exclude USB/NFC security keys or
// cross-device Passkeys. Let the browser select an eligible authenticator.
return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential)
}
/**
......
......@@ -24,7 +24,7 @@ import {
} from '@tanstack/react-router'
import type { AxiosRequestConfig } from 'axios'
import i18next from 'i18next'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { OAuthCallbackScreen } from '@/features/auth/components/oauth-callback-screen'
......@@ -32,6 +32,7 @@ import {
OAUTH_POPUP_CALLBACK_MESSAGE,
OAUTH_POPUP_RESULT_MESSAGE,
} from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import { sanitizeAuthRedirect } from '@/features/auth/lib/auth-redirect'
import {
parseTelegramBindCallback,
......@@ -43,11 +44,13 @@ import {
consumeOAuthLoginRedirect,
resolveOAuthCallbackMode,
} from '@/features/auth/lib/oauth-callback-mode'
import { api, applyAuthBundle, isAuthBundle } from '@/lib/api'
import type { LoginResponse } from '@/features/auth/types'
import { api } from '@/lib/api'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
type OAuthRequestConfig = AxiosRequestConfig & {
skipBusinessError?: boolean
skipAuthRefresh?: boolean
}
interface OAuthPopupResult {
......@@ -61,6 +64,12 @@ interface OAuthPopupResult {
function OAuthCallback() {
const navigate = useNavigate()
const { handleLoginResult } = useAuthRedirect()
const loginExchange = useRef<{
key: string
request: Promise<{ data: LoginResponse }>
} | null>(null)
const completedLogin = useRef<string | null>(null)
const { provider } = useParams({ from: '/oauth/$provider' }) as {
provider: string
}
......@@ -192,6 +201,9 @@ function OAuthCallback() {
return
}
const loginKey = `${provider}:${state}:${code}`
if (completedLogin.current === loginKey) return
let active = true
void (async () => {
try {
const config: OAuthRequestConfig = {
......@@ -202,12 +214,26 @@ function OAuthCallback() {
error_description: search.error_description,
},
skipBusinessError: true,
skipAuthRefresh: true,
}
const response = await api.get(`/api/oauth/${provider}`, config)
if (response.data?.success && isAuthBundle(response.data?.data)) {
applyAuthBundle(response.data.data)
safeNavigate(search.redirect ?? consumeOAuthLoginRedirect(state))
toast.success(i18next.t('Signed in successfully!'))
if (loginExchange.current?.key !== loginKey) {
loginExchange.current = {
key: loginKey,
request: api.get<LoginResponse>(`/api/oauth/${provider}`, config),
}
}
const response = await loginExchange.current.request
if (!active) return
if (response.data?.success) {
completedLogin.current = loginKey
if (
await handleLoginResult(
response.data.data,
search.redirect ?? consumeOAuthLoginRedirect(state) ?? undefined
)
) {
toast.success(i18next.t('Signed in successfully!'))
}
return
}
const messageKey = getServerErrorMessageKey(response.data)
......@@ -217,6 +243,7 @@ function OAuthCallback() {
: response.data?.message || i18next.t('OAuth failed')
)
} catch (error: unknown) {
if (!active) return
const messageKey = getServerErrorMessageKey(error)
const responseMessage = (
error as { response?: { data?: { message?: string } } }
......@@ -232,10 +259,14 @@ function OAuthCallback() {
}
safeNavigate('/sign-in', '/sign-in')
})()
return () => {
active = false
}
}, [
callbackState,
mode,
navigate,
handleLoginResult,
provider,
search.code,
search.error,
......
......@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { create } from 'zustand'
import type { LoginChallenge } from '@/features/auth/secure-verification/types'
import type { AdminCapabilities } from '@/lib/admin-permissions'
export type UserPermissions = {
......@@ -77,17 +78,24 @@ export interface AuthBundle {
export type AuthBootstrapState = 'idle' | 'checking' | 'complete'
export interface PendingLoginVerification {
challenge: LoginChallenge
redirectTo?: string
}
interface AuthState {
auth: {
user: AuthUser | null
accessToken: string | null
accessExpiresAt: number | null
session: LoginSession | null
pending2FAFlowToken: string | null
pendingLoginVerification: PendingLoginVerification | null
bootstrapState: AuthBootstrapState
setBundle: (bundle: AuthBundle) => void
setUser: (user: AuthUser | null) => void
setPending2FAFlowToken: (flowToken: string | null) => void
setPendingLoginVerification: (
pending: PendingLoginVerification | null
) => void
setBootstrapState: (bootstrapState: AuthBootstrapState) => void
reset: (bootstrapState?: AuthBootstrapState) => void
}
......@@ -99,7 +107,7 @@ export const useAuthStore = create<AuthState>()((set) => ({
accessToken: null,
accessExpiresAt: null,
session: null,
pending2FAFlowToken: null,
pendingLoginVerification: null,
bootstrapState: 'idle',
setBundle: (bundle) =>
set((state) => ({
......@@ -110,19 +118,26 @@ export const useAuthStore = create<AuthState>()((set) => ({
accessToken: bundle.access_token,
accessExpiresAt: bundle.access_expires_at,
session: bundle.session,
pending2FAFlowToken: null,
pendingLoginVerification: null,
bootstrapState: 'complete',
},
})),
setUser: (user) =>
set((state) => ({
...state,
auth: { ...state.auth, user },
auth: {
...state.auth,
user,
pendingLoginVerification:
state.auth.user?.id === user?.id
? state.auth.pendingLoginVerification
: null,
},
})),
setPending2FAFlowToken: (pending2FAFlowToken) =>
setPendingLoginVerification: (pendingLoginVerification) =>
set((state) => ({
...state,
auth: { ...state.auth, pending2FAFlowToken },
auth: { ...state.auth, pendingLoginVerification },
})),
setBootstrapState: (bootstrapState) =>
set((state) => ({
......@@ -138,7 +153,7 @@ export const useAuthStore = create<AuthState>()((set) => ({
accessToken: null,
accessExpiresAt: null,
session: null,
pending2FAFlowToken: null,
pendingLoginVerification: null,
bootstrapState,
},
})),
......
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