Files
panel/internal/service/user_passkey.go
T
耗子andClaude Opus 4.8 443516a2cf refactor!: 迁移至 samber/do 依赖注入与三层架构
依赖注入:
- 移除 google/wire(含 wire.go/wire_gen.go 与全部 ProviderSet),
  改用 samber/do v2 单注入器 + 双入口惰性构建
- 贡献模型替代命令式注册:路由 routes:、命令 commands:、
  任务 jobs: 前缀经 internal/registry 收集与校验
- 构造函数统一 func NewXxx(i do.Injector) (T, error)

三层架构:
- 补全用例层:每个 biz.XxxRepo 配 XxxUsecase,
  service/command/job 表现层只依赖用例,不再直接引用仓储

路由与文档:
- route/http.go 按域拆为声明式 Endpoint 贡献,
  Endpoint 承载登录白名单与端点限流语义
- 调试模式下提供 OpenAPI 3.1 文档:/openapi.json 与 /docs(Scalar),
  从 validate 标签生成

目录与依赖:
- internal/http/{middleware,request,rule} 拍平至 internal/*
- CLI 命令拆至 internal/command
- 日志轮转 timberjack 换为 libtnb/logrotate
- 升级 validator/cron/sessions/gormstore/sqlite/securecookie
- 数据库迁移保持 gormigrate 不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:54:22 +08:00

383 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"crypto/x509"
"encoding/gob"
"encoding/json"
"encoding/pem"
"net/http"
"os"
"path/filepath"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/sessions"
"github.com/samber/do/v2"
"github.com/spf13/cast"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/request"
"github.com/acepanel/panel/v3/pkg/config"
"github.com/acepanel/panel/v3/pkg/passkey"
)
type UserPasskeyService struct {
t *gotext.Locale
conf *config.Config
session *sessions.Manager
userPasskeyRepo *biz.UserPasskeyUsecase
userRepo *biz.UserUsecase
}
func NewUserPasskeyService(i do.Injector) (*UserPasskeyService, error) {
// 注册 webauthn.SessionData 类型,否则 gob 无法序列化
gob.Register(webauthn.SessionData{})
return &UserPasskeyService{
t: do.MustInvoke[*gotext.Locale](i),
conf: do.MustInvoke[*config.Config](i),
session: do.MustInvoke[*sessions.Manager](i),
userPasskeyRepo: do.MustInvoke[*biz.UserPasskeyUsecase](i),
userRepo: do.MustInvoke[*biz.UserUsecase](i),
}, nil
}
// Enabled 检查是否有任何已注册的通行密钥
func (s *UserPasskeyService) Enabled(w http.ResponseWriter, r *http.Request) {
has, err := s.userPasskeyRepo.HasAny()
if err != nil {
Success(w, false)
return
}
Success(w, has)
}
// Supported 检查面板是否满足通行密钥条件
func (s *UserPasskeyService) Supported(w http.ResponseWriter, r *http.Request) {
// 面板自身开启 TLS 且证书可信,或者反代已做 TLS 终止
if s.isCertTrusted() {
Success(w, true)
return
}
// 反代场景:面板本身可能是 HTTP,但客户端通过 HTTPS 访问
if r.Header.Get("X-Forwarded-Proto") == "https" {
Success(w, true)
return
}
Success(w, false)
}
// BeginRegister 开始注册通行密钥
func (s *UserPasskeyService) BeginRegister(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.GetSession(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
userID := cast.ToUint(r.Context().Value("user_id"))
if userID == 0 {
ErrorSystem(w)
return
}
u, err := s.userRepo.Get(userID)
if err != nil {
ErrorSystem(w)
return
}
passkeys, err := s.userPasskeyRepo.List(userID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
wa, err := passkey.NewWebAuthn(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
wUser := &passkey.User{Inner: u, Passkeys: passkeys}
// 排除已有凭据
var excludeCredentials []protocol.CredentialDescriptor
for _, cred := range wUser.WebAuthnCredentials() {
excludeCredentials = append(excludeCredentials, cred.Descriptor())
}
creation, sessionData, err := wa.BeginRegistration(
wUser,
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.VerificationRequired,
}),
webauthn.WithExclusions(excludeCredentials),
)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
sess.Put("webauthn_register", *sessionData)
Success(w, creation)
}
// FinishRegister 完成注册通行密钥
func (s *UserPasskeyService) FinishRegister(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.GetSession(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
userID := cast.ToUint(r.Context().Value("user_id"))
if userID == 0 {
ErrorSystem(w)
return
}
sessionData, ok := sess.Get("webauthn_register").(webauthn.SessionData)
if !ok {
Error(w, http.StatusBadRequest, s.t.Get("invalid session, please try again"))
return
}
sess.Forget("webauthn_register")
u, err := s.userRepo.Get(userID)
if err != nil {
ErrorSystem(w)
return
}
passkeys, err := s.userPasskeyRepo.List(userID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
wa, err := passkey.NewWebAuthn(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
wUser := &passkey.User{Inner: u, Passkeys: passkeys}
credential, err := wa.FinishRegistration(wUser, sessionData, r)
if err != nil {
Error(w, http.StatusBadRequest, s.t.Get("passkey registration failed: %v", err))
return
}
// 从 query 参数提取 name(FinishRegistration 已消费 body)
name := r.URL.Query().Get("name")
if name == "" {
name = s.t.Get("Passkey")
}
// 序列化 transports
transports, err := json.Marshal(credential.Transport)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
pk := &biz.UserPasskey{
UserID: userID,
Name: name,
CredentialID: credential.ID,
PublicKey: credential.PublicKey,
AAGUID: credential.Authenticator.AAGUID,
SignCount: credential.Authenticator.SignCount,
Transports: string(transports),
BackupEligible: credential.Flags.BackupEligible,
BackupState: credential.Flags.BackupState,
}
if err = s.userPasskeyRepo.Create(pk); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, pk)
}
// BeginLogin 开始通行密钥登录
func (s *UserPasskeyService) BeginLogin(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.GetSession(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
wa, err := passkey.NewWebAuthn(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
assertion, sessionData, err := wa.BeginDiscoverableLogin(
webauthn.WithUserVerification(protocol.VerificationRequired),
)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
sess.Put("webauthn_login", *sessionData)
Success(w, assertion)
}
// FinishLogin 完成通行密钥登录
func (s *UserPasskeyService) FinishLogin(w http.ResponseWriter, r *http.Request) {
sess, err := s.session.GetSession(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
sessionData, ok := sess.Get("webauthn_login").(webauthn.SessionData)
if !ok {
Error(w, http.StatusBadRequest, s.t.Get("invalid session, please try again"))
return
}
sess.Forget("webauthn_login")
wa, err := passkey.NewWebAuthn(r)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
// discoverable login handler:根据 userHandle 找到用户
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
userID, err := passkey.ParseUserID(userHandle)
if err != nil {
return nil, err
}
u, err := s.userRepo.Get(userID)
if err != nil {
return nil, err
}
pks, err := s.userPasskeyRepo.List(userID)
if err != nil {
return nil, err
}
return &passkey.User{Inner: u, Passkeys: pks}, nil
}
returnedUser, credential, err := wa.FinishPasskeyLogin(handler, sessionData, r)
if err != nil {
Error(w, http.StatusUnauthorized, s.t.Get("passkey login failed: %v", err))
return
}
// 更新 sign count 和最后使用时间
_ = s.userPasskeyRepo.UpdateSignCount(credential.ID, credential.Authenticator.SignCount)
_ = s.userPasskeyRepo.UpdateLastUsed(credential.ID)
wUser := returnedUser.(*passkey.User)
// 重新生成会话 ID
if err = sess.Regenerate(true); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 设置登录状态
sess.Put("user_id", wUser.Inner.ID)
sess.Put("refresh_at", time.Now().Unix())
// 通行密钥登录已经过设备验证,无需 safe_login
sess.Forget("safe_login")
sess.Forget("safe_client")
Success(w, nil)
}
// List 列出指定用户的通行密钥
func (s *UserPasskeyService) List(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.UserPasskeyList](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 未指定 user_id 时默认查当前用户
userID := req.UserID
if userID == 0 {
userID = cast.ToUint(r.Context().Value("user_id"))
}
if userID == 0 {
ErrorSystem(w)
return
}
passkeys, err := s.userPasskeyRepo.List(userID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, chix.M{
"items": passkeys,
})
}
// Delete 删除指定通行密钥
func (s *UserPasskeyService) Delete(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.UserPasskeyDelete](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
// 未指定 user_id 时默认为当前用户
userID := req.UserID
if userID == 0 {
userID = cast.ToUint(r.Context().Value("user_id"))
}
if userID == 0 {
ErrorSystem(w)
return
}
if err = s.userPasskeyRepo.Delete(userID, req.ID); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
// isCertTrusted 检查面板证书是否由 CA 签发(非自签名)
// 用证书自身公钥验签自身签名,成功说明是自签名证书
func (s *UserPasskeyService) isCertTrusted() bool {
if !s.conf.HTTP.IsHTTPS() {
return false
}
certPath := filepath.Join(app.Root, "panel/storage/cert.pem")
certPEM, err := os.ReadFile(certPath)
if err != nil {
return false
}
block, _ := pem.Decode(certPEM)
if block == nil {
return false
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false
}
// 用自身公钥验证自身签名:成功 = 自签名,失败 = CA 签发
return cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature) != nil
}