feat: passkey

This commit is contained in:
Seefs
2025-09-29 17:45:09 +08:00
parent a91f3e7556
commit dcf4336c75
30 changed files with 2924 additions and 110 deletions

View File

@@ -384,10 +384,11 @@ func GetChannel(c *gin.Context) {
return
}
// GetChannelKey 验证2FA后获取渠道密钥
// GetChannelKey 验证2FA或Passkey后获取渠道密钥
func GetChannelKey(c *gin.Context) {
type GetChannelKeyRequest struct {
Code string `json:"code" binding:"required"`
Code string `json:"code,omitempty"` // 2FA验证码或备用码
Method string `json:"method,omitempty"` // 验证方式: "2fa" 或 "passkey"
}
var req GetChannelKeyRequest
@@ -403,21 +404,108 @@ func GetChannelKey(c *gin.Context) {
return
}
// 获取2FA记录并验证
// 检查用户支持的验证方式
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
return
}
if twoFA == nil || !twoFA.IsEnabled {
common.ApiError(c, fmt.Errorf("用户未启用2FA无法查看密钥"))
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
has2FA := twoFA != nil && twoFA.IsEnabled
// 至少需要启用一种验证方式
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey无法查看密钥"))
return
}
// 统一的2FA验证逻辑
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
// 根据请求的验证方式进行验证
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("2FA验证码不能为空"))
return
}
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey验证已在前端完成这里只需要检查是否有有效的Passkey验证会话
// 由于Passkey验证是基于WebAuthn协议的验证过程已经在PasskeyVerifyFinish中完成
// 这里我们可以设置一个临时标记来验证Passkey验证是否成功
default:
// 自动选择验证方式如果提供了code则使用2FA否则需要用户明确指定
if req.Code != "" && has2FA {
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
} else {
common.ApiError(c, fmt.Errorf("请指定验证方式(method: '2fa' 或 'passkey')"))
return
}
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
return
}
if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在"))
return
}
// 记录操作日志
logMethod := req.Method
if logMethod == "" {
logMethod = "2fa"
}
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: %s)", channelId, logMethod))
// 统一的成功响应格式
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": map[string]interface{}{
"key": channel.Key,
},
})
}
// GetChannelKeyWithPasskey 使用Passkey验证查看渠道密钥
func GetChannelKeyWithPasskey(c *gin.Context) {
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
return
}
// 检查用户是否已绑定Passkey
passkey, err := model.GetPasskeyByUserID(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("用户未绑定Passkey无法使用此验证方式"))
return
}
if passkey == nil {
common.ApiError(c, fmt.Errorf("用户未绑定Passkey"))
return
}
@@ -434,12 +522,12 @@ func GetChannelKey(c *gin.Context) {
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: passkey)", channelId))
// 统一的成功响应格式
// 返回渠道密钥
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"message": "Passkey验证成功",
"data": map[string]interface{}{
"key": channel.Key,
},

View File

@@ -42,6 +42,8 @@ func GetStatus(c *gin.Context) {
common.OptionMapRWMutex.RLock()
defer common.OptionMapRWMutex.RUnlock()
passkeySetting := system_setting.GetPasskeySettings()
data := gin.H{
"version": common.Version,
"start_time": common.StartTime,
@@ -94,6 +96,13 @@ func GetStatus(c *gin.Context) {
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
"passkey_login": passkeySetting.Enabled,
"passkey_display_name": passkeySetting.RPDisplayName,
"passkey_rp_id": passkeySetting.RPID,
"passkey_origins": passkeySetting.Origins,
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
"passkey_user_verification": passkeySetting.UserVerification,
"passkey_attachment": passkeySetting.AttachmentPreference,
"setup": constant.Setup,
}

502
controller/passkey.go Normal file
View File

@@ -0,0 +1,502 @@
package controller
import (
"errors"
"fmt"
"net/http"
"strconv"
"time"
"one-api/common"
"one-api/model"
passkeysvc "one-api/service/passkey"
"one-api/setting/system_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
)
func PasskeyRegisterBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
common.ApiError(c, err)
return
}
if errors.Is(err, model.ErrPasskeyNotFound) {
credential = nil
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
var options []webauthnlib.RegistrationOption
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 {
common.ApiError(c, err)
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": creation,
},
})
}
func PasskeyRegisterFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
credentialRecord, err := model.GetPasskeyByUserID(user.Id)
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
common.ApiError(c, err)
return
}
if errors.Is(err, model.ErrPasskeyNotFound) {
credentialRecord = nil
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
if passkeyCredential == nil {
common.ApiErrorMsg(c, "无法创建 Passkey 凭证")
return
}
if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 注册成功",
})
}
func PasskeyDelete(c *gin.Context) {
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已解绑",
})
}
func PasskeyStatus(c *gin.Context) {
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
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 {
common.ApiError(c, err)
return
}
data := gin.H{
"enabled": true,
"last_used_at": credential.LastUsedAt,
"backup_eligible": credential.BackupEligible,
"backup_state": credential.BackupState,
}
if credential != nil {
data["credential_aaguid"] = fmt.Sprintf("%x", credential.AAGUID)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": data,
})
}
func PasskeyLoginBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
assertion, sessionData, err := wa.BeginDiscoverableLogin()
if err != nil {
common.ApiError(c, err)
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
},
})
}
func PasskeyLoginFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey)
if err != nil {
common.ApiError(c, err)
return
}
handler := func(rawID, userHandle []byte) (webauthnlib.User, error) {
// 首先通过凭证ID查找用户
credential, err := model.GetPasskeyByCredentialID(rawID)
if err != nil {
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
}
// 通过凭证获取用户
user := &model.User{Id: credential.UserID}
if err := user.FillUserById(); err != nil {
return nil, fmt.Errorf("用户信息获取失败: %w", err)
}
if user.Status != common.UserStatusEnabled {
return nil, errors.New("该用户已被禁用")
}
// 验证用户句柄(如果提供的话)
if len(userHandle) > 0 {
if userID, parseErr := strconv.Atoi(string(userHandle)); parseErr == nil {
if userID != user.Id {
return nil, errors.New("用户句柄与凭证不匹配")
}
}
// 如果解析失败不做严格验证因为某些情况下userHandle可能为空或格式不同
}
return passkeysvc.NewWebAuthnUser(user, credential), nil
}
waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
if !ok {
common.ApiErrorMsg(c, "Passkey 登录状态异常")
return
}
modelUser := userWrapper.ModelUser()
if modelUser == nil {
common.ApiErrorMsg(c, "Passkey 登录状态异常")
return
}
if modelUser.Status != common.UserStatusEnabled {
common.ApiErrorMsg(c, "该用户已被禁用")
return
}
// 更新凭证信息
updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential)
if updatedCredential == nil {
common.ApiErrorMsg(c, "Passkey 凭证更新失败")
return
}
now := time.Now()
updatedCredential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(updatedCredential); err != nil {
common.ApiError(c, err)
return
}
setupLogin(modelUser, c)
return
}
func AdminResetPasskey(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiErrorMsg(c, "无效的用户 ID")
return
}
user := &model.User{Id: id}
if err := user.FillUserById(); err != nil {
common.ApiError(c, err)
return
}
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
if errors.Is(err, model.ErrPasskeyNotFound) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
common.ApiError(c, err)
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已重置",
})
}
func PasskeyVerifyBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
assertion, sessionData, err := wa.BeginLogin(waUser)
if err != nil {
common.ApiError(c, err)
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
},
})
}
func PasskeyVerifyFinish(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
user, err := getSessionUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": err.Error(),
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
// 更新凭证的最后使用时间
now := time.Now()
credential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(credential); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
})
}
func getSessionUser(c *gin.Context) (*model.User, error) {
session := sessions.Default(c)
idRaw := session.Get("id")
if idRaw == nil {
return nil, errors.New("未登录")
}
id, ok := idRaw.(int)
if !ok {
return nil, errors.New("无效的会话信息")
}
user := &model.User{Id: id}
if err := user.FillUserById(); err != nil {
return nil, err
}
if user.Status != common.UserStatusEnabled {
return nil, errors.New("该用户已被禁用")
}
return user, nil
}