mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend
Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.
Text is now translated where its reader is:
- Web console APIs return the English source text as message_key with
message_params, and the web console translates it in all seven languages.
message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
the backend (en, zh-CN, zh-TW) in the language saved on the account, then
the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
messages that were Chinese before, word for word. Gateways that use another
new-api as their upstream match that text, for example to disable a channel
whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
params) and rendered in the viewer's language. Task fail reasons and refund
reasons written by the gateway are English keys. Rows written earlier are
shown as before.
- Server logs are English.
Also:
- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
non-English string literal, on a web console message without translations
and on a locale key without a constant.
* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it
Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.
common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.
* docs: list LOG_LANGUAGE in the docker compose example
* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE
Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.
DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.
Review fixes:
- Channel test logs store the token name "Model test"; the console shows
it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
translates. The HTTP status and the upstream detail go to the server log
when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.
Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
translated message key.
- Remove guards and helpers that no caller needs.
* fix(i18n): address review on language lookup, notices and error responses
- Relay requests no longer load the user a second time to find the
language: the settings TokenAuth put into the request are used as they
are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
messages again, with the keys and status codes used before this PR.
Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
senders. Running the rendered text through Sprintf appended
%!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
641 lines
17 KiB
Go
641 lines
17 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/middleware"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/service"
|
|
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
|
|
"github.com/QuantumNous/new-api/setting/system_setting"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
|
|
)
|
|
|
|
type passkeyFinishRequest struct {
|
|
FlowToken string `json:"flow_token"`
|
|
Credential json.RawMessage `json:"credential"`
|
|
}
|
|
|
|
type passkeyVerifyBeginRequest struct {
|
|
RPID string `json:"rp_id"`
|
|
Scope string `json:"scope"`
|
|
Context json.RawMessage `json:"context,omitempty"`
|
|
}
|
|
|
|
func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) {
|
|
var request passkeyFinishRequest
|
|
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
|
|
return nil, err
|
|
}
|
|
if request.FlowToken == "" || len(request.Credential) == 0 {
|
|
return nil, errors.New("incomplete Passkey flow parameters")
|
|
}
|
|
return &request, nil
|
|
}
|
|
|
|
func PasskeyRegisterBegin(c *gin.Context) {
|
|
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
|
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
|
return
|
|
}
|
|
|
|
user, err := getAuthenticatedUser(c)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
authorization := middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister})
|
|
if authorization == nil {
|
|
return
|
|
}
|
|
|
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
|
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
|
credential = nil
|
|
}
|
|
|
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
|
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}))
|
|
}
|
|
|
|
creation, sessionData, err := wa.BeginRegistration(waUser, options...)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
identity, ok := middleware.GetStepUpIdentity(c)
|
|
if !ok {
|
|
common.ApiErrorT(c, "The current sign-in method does not support security verification")
|
|
return
|
|
}
|
|
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
|
|
model.AuthFlowPurposePasskeyRegister,
|
|
passkeysvc.FlowSecurity{AuthSessionIdentity: identity, Scope: authorization.Scope, ContextHash: authorization.ContextHash, Authorization: authorization},
|
|
sessionData,
|
|
)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": gin.H{
|
|
"options": creation,
|
|
"flow_token": flowToken,
|
|
"expires_at": expiresAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
func PasskeyRegisterFinish(c *gin.Context) {
|
|
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
|
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
|
return
|
|
}
|
|
|
|
user, err := getAuthenticatedUser(c)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
request, err := parsePasskeyFinishRequest(c)
|
|
if err != nil {
|
|
common.ApiErrorT(c, "Invalid Passkey verification request")
|
|
return
|
|
}
|
|
parsedCredential, err := protocol.ParseCredentialCreationResponseBytes(request.Credential)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
credentialRecord, err := model.GetPasskeyByUserID(user.Id)
|
|
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
|
credentialRecord = nil
|
|
}
|
|
|
|
identity, ok := middleware.GetStepUpIdentity(c)
|
|
if !ok {
|
|
common.ApiErrorT(c, "The current sign-in method does not support security verification")
|
|
return
|
|
}
|
|
sessionData, security, err := passkeysvc.PopSessionDataFlow(
|
|
request.FlowToken,
|
|
model.AuthFlowPurposePasskeyRegister,
|
|
identity,
|
|
)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
|
|
if sessionData.UserVerification != protocol.VerificationRequired {
|
|
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
|
return
|
|
}
|
|
if err := service.ValidateFlowAuthorization(identity, service.VerificationOperation{Scope: service.VerificationScopePasskeyRegister}, security.Authorization); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
|
|
credential, err := wa.CreateCredential(waUser, *sessionData, parsedCredential)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
|
|
if passkeyCredential == nil {
|
|
common.ApiErrorT(c, "Unable to create the Passkey credential")
|
|
return
|
|
}
|
|
|
|
passkeyCredential.RPID = &sessionData.RelyingPartyID
|
|
if err := model.RegisterPasskeyForSession(identity, passkeyCredential); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_registered")
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
recordUserSecurityAudit(c, user.Id, "user.passkey_register", nil)
|
|
common.ApiSuccessT(c, "Passkey registered successfully", authRotationData(bundle))
|
|
}
|
|
|
|
func PasskeyDelete(c *gin.Context) {
|
|
user, err := getAuthenticatedUser(c)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopePasskeyDelete}) == nil {
|
|
return
|
|
}
|
|
|
|
identity, ok := middleware.GetStepUpIdentity(c)
|
|
if !ok {
|
|
common.ApiErrorT(c, "The current sign-in method does not support security verification")
|
|
return
|
|
}
|
|
if err := model.DeletePasskeyForSession(identity); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_deleted")
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
recordUserSecurityAudit(c, user.Id, "user.passkey_delete", nil)
|
|
common.ApiSuccessT(c, "Passkey removed successfully", authRotationData(bundle))
|
|
}
|
|
|
|
func PasskeyStatus(c *gin.Context) {
|
|
user, err := getAuthenticatedUser(c)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": gin.H{
|
|
"enabled": false,
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
data := gin.H{
|
|
"enabled": true,
|
|
"last_used_at": credential.LastUsedAt,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": data,
|
|
})
|
|
}
|
|
|
|
func PasskeyLoginBegin(c *gin.Context) {
|
|
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
|
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
|
return
|
|
}
|
|
|
|
var request struct {
|
|
RPID string `json:"rp_id"`
|
|
}
|
|
if c.Request.Body != nil && c.Request.ContentLength != 0 {
|
|
if common.DecodeJson(c.Request.Body, &request) != nil {
|
|
common.ApiErrorT(c, "Invalid Passkey verification request")
|
|
return
|
|
}
|
|
}
|
|
wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, "")
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
assertion, sessionData, err := wa.BeginDiscoverableLogin(webauthnlib.WithUserVerification(protocol.VerificationRequired))
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
|
|
model.AuthFlowPurposePasskeyLogin,
|
|
passkeysvc.FlowSecurity{},
|
|
sessionData,
|
|
)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": gin.H{
|
|
"options": assertion,
|
|
"rp_ids": rpIDs,
|
|
"flow_token": flowToken,
|
|
"expires_at": expiresAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
func PasskeyLoginFinish(c *gin.Context) {
|
|
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
|
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
|
return
|
|
}
|
|
|
|
request, err := parsePasskeyFinishRequest(c)
|
|
if err != nil {
|
|
common.ApiErrorT(c, "Invalid Passkey verification request")
|
|
return
|
|
}
|
|
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
sessionData, _, err := passkeysvc.PopSessionDataFlow(
|
|
request.FlowToken,
|
|
model.AuthFlowPurposePasskeyLogin,
|
|
service.AuthIdentity{},
|
|
)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
|
|
if sessionData.UserVerification != protocol.VerificationRequired {
|
|
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
|
return
|
|
}
|
|
|
|
handler := func(rawID, userHandle []byte) (webauthnlib.User, error) {
|
|
// 首先通过凭证ID查找用户
|
|
credential, err := model.GetPasskeyByCredentialID(rawID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("passkey credential not found: %w", err)
|
|
}
|
|
|
|
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
|
|
return nil, service.ErrVerificationFailed
|
|
}
|
|
|
|
// 通过凭证获取用户
|
|
user := &model.User{Id: credential.UserID}
|
|
if err := user.FillUserById(); err != nil {
|
|
return nil, fmt.Errorf("failed to load the passkey user: %w", err)
|
|
}
|
|
|
|
if user.Status != common.UserStatusEnabled {
|
|
return nil, model.ErrUserSessionInactive
|
|
}
|
|
|
|
if len(userHandle) > 0 {
|
|
userID, parseErr := strconv.Atoi(string(userHandle))
|
|
if parseErr != nil {
|
|
// 记录异常但继续验证,因为某些客户端可能使用非数字格式
|
|
common.SysLog(common.LogText("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle)))
|
|
} else if userID != user.Id {
|
|
return nil, errors.New("user handle does not match the credential")
|
|
}
|
|
}
|
|
|
|
return passkeysvc.NewWebAuthnUser(user, credential), nil
|
|
}
|
|
|
|
waUser, credential, err := wa.ValidatePasskeyLogin(handler, *sessionData, parsedCredential)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
|
|
if !ok {
|
|
common.ApiErrorT(c, "Invalid Passkey login state")
|
|
return
|
|
}
|
|
|
|
modelUser := userWrapper.ModelUser()
|
|
if modelUser == nil {
|
|
common.ApiErrorT(c, "Invalid Passkey login state")
|
|
return
|
|
}
|
|
|
|
if modelUser.Status != common.UserStatusEnabled {
|
|
common.ApiErrorT(c, "This user account is disabled.")
|
|
return
|
|
}
|
|
|
|
if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now(), sessionData.RelyingPartyID); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
c.Set("login_verification_method", service.VerificationMethodPasskey)
|
|
setupLoginAtAuthVersion(modelUser, modelUser.AuthVersion, c)
|
|
}
|
|
|
|
func AdminResetPasskey(c *gin.Context) {
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil {
|
|
common.ApiErrorT(c, "Invalid user ID")
|
|
return
|
|
}
|
|
|
|
user := &model.User{Id: id}
|
|
if err := user.FillUserById(); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
myRole := c.GetInt("role")
|
|
if !canManageTargetRole(myRole, user.Role) {
|
|
common.ApiErrorMsg(c, "no permission")
|
|
return
|
|
}
|
|
authorization := requireAdminUserProof(c, service.VerificationScopeAdminUserPasskeyReset, service.AdminUserContext{UserID: user.Id})
|
|
if authorization == nil {
|
|
return
|
|
}
|
|
|
|
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
|
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
|
common.ApiErrorT(c, "No Passkey is registered.")
|
|
return
|
|
}
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if _, err := model.RevokeAllUserSessions(user.Id, "admin_passkey_reset"); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
recordManageAuditFor(c, user.Id, "user.reset_passkey", map[string]any{
|
|
"username": user.Username,
|
|
"id": user.Id,
|
|
"verification_method": authorization.Method,
|
|
})
|
|
common.ApiSuccessT(c, "Passkey reset successfully", nil)
|
|
}
|
|
|
|
func PasskeyVerifyBegin(c *gin.Context) {
|
|
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
|
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
|
return
|
|
}
|
|
|
|
user, err := getAuthenticatedUser(c)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
var request passkeyVerifyBeginRequest
|
|
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
|
|
common.ApiErrorT(c, "Invalid Passkey verification request")
|
|
return
|
|
}
|
|
binding, err := service.BindVerificationOperation(service.VerificationOperation{Scope: request.Scope, Context: request.Context})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
identity, ok := middleware.GetStepUpIdentity(c)
|
|
if !ok {
|
|
writeSecurityOperationError(c, service.ErrAuthTokenInvalid)
|
|
return
|
|
}
|
|
if _, err := service.RequireVerificationMethod(identity, request.Scope, service.VerificationMethodPasskey); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
|
if err != nil {
|
|
common.ApiErrorT(c, "No Passkey is registered.")
|
|
return
|
|
}
|
|
|
|
credentialRPID := ""
|
|
if credential.RPID != nil {
|
|
credentialRPID = *credential.RPID
|
|
}
|
|
wa, rpIDs, err := passkeysvc.BuildLoginWebAuthn(c.Request, request.RPID, credentialRPID)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
|
assertion, sessionData, err := wa.BeginLogin(waUser, webauthnlib.WithUserVerification(protocol.VerificationRequired))
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
|
|
model.AuthFlowPurposePasskeyStepUp,
|
|
passkeysvc.FlowSecurity{AuthSessionIdentity: identity, Scope: binding.Scope, ContextHash: binding.ContextHash},
|
|
sessionData,
|
|
)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "",
|
|
"data": gin.H{
|
|
"options": assertion,
|
|
"rp_ids": rpIDs,
|
|
"flow_token": flowToken,
|
|
"expires_at": expiresAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
func PasskeyVerifyFinish(c *gin.Context) {
|
|
if !system_setting.PasskeySettingsSnapshot().Enabled {
|
|
common.ApiErrorT(c, "Passkey authentication is disabled.")
|
|
return
|
|
}
|
|
|
|
user, err := getAuthenticatedUser(c)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
request, err := parsePasskeyFinishRequest(c)
|
|
if err != nil {
|
|
common.ApiErrorT(c, "Invalid Passkey verification request")
|
|
return
|
|
}
|
|
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
|
if err != nil {
|
|
common.ApiErrorT(c, "No Passkey is registered.")
|
|
return
|
|
}
|
|
|
|
identity, ok := middleware.GetStepUpIdentity(c)
|
|
if !ok {
|
|
common.ApiErrorT(c, "The current sign-in method does not support security verification")
|
|
return
|
|
}
|
|
sessionData, security, err := passkeysvc.PopSessionDataFlow(
|
|
request.FlowToken,
|
|
model.AuthFlowPurposePasskeyStepUp,
|
|
identity,
|
|
)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
wa, err := passkeysvc.BuildWebAuthnForRPID(c.Request, sessionData.RelyingPartyID)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
c.Set("passkey_rp_id", sessionData.RelyingPartyID)
|
|
if sessionData.UserVerification != protocol.VerificationRequired {
|
|
writeSecurityOperationError(c, model.ErrAuthFlowInvalid)
|
|
return
|
|
}
|
|
|
|
if credential.RPID != nil && *credential.RPID != "" && *credential.RPID != sessionData.RelyingPartyID {
|
|
writeSecurityOperationError(c, service.ErrVerificationFailed)
|
|
return
|
|
}
|
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
|
validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now(), sessionData.RelyingPartyID); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
proof, err := service.CompleteSecurityVerification(identity, service.VerificationBinding{Scope: security.Scope, ContextHash: security.ContextHash}, service.VerificationMethodPasskey)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
|
|
recordUserSecurityAudit(c, user.Id, "user.security_verify", map[string]any{"method": proof.Method, "scope": proof.Scope})
|
|
common.ApiSuccess(c, proof)
|
|
}
|
|
|
|
func getAuthenticatedUser(c *gin.Context) (*model.User, error) {
|
|
id := c.GetInt("id")
|
|
if id == 0 {
|
|
return nil, service.ErrAuthTokenInvalid
|
|
}
|
|
user, err := model.GetUserById(id, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user.Status != common.UserStatusEnabled {
|
|
return nil, model.ErrUserSessionInactive
|
|
}
|
|
return user, nil
|
|
}
|