feat: 支持通行密钥登录 (#1432)

* feat: 支持通行密钥登录

* fix: 修复AI评论

* Update pkg/passkey/passkey.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: lint

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
耗子
2026-02-26 23:50:18 +08:00
committed by GitHub
parent 4873103904
commit 71e259ba32
23 changed files with 1068 additions and 70 deletions
+3 -1
View File
@@ -71,6 +71,8 @@ func initAce() (*app.Ace, error) {
middlewares := middleware.NewMiddlewares(config, manager, appRepo, userTokenRepo)
userRepo := data.NewUserRepo(locale, db, logger)
userService := service.NewUserService(locale, config, manager, userRepo)
userPasskeyRepo := data.NewUserPasskeyRepo(db)
userPasskeyService := service.NewUserPasskeyService(locale, config, manager, userPasskeyRepo, userRepo)
userTokenService := service.NewUserTokenService(locale, userTokenRepo)
databaseServerRepo := data.NewDatabaseServerRepo(locale, db, logger)
databaseUserRepo := data.NewDatabaseUserRepo(locale, db, logger, databaseServerRepo)
@@ -175,7 +177,7 @@ func initAce() (*app.Ace, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(apacheApp, codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseRedisService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, environmentDotnetService, cronService, processService, safeService, firewallService, firewallScanService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxNetworkService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, toolboxMigrationService, webHookService, templateService, websiteStatService, loader)
http := route.NewHttp(config, userService, userPasskeyService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseRedisService, databaseUserService, backupService, backupStorageService, certService, certDNSService, certAccountService, appService, environmentService, environmentGoService, environmentJavaService, environmentNodejsService, environmentPHPService, environmentPythonService, environmentDotnetService, cronService, processService, safeService, firewallService, firewallScanService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxNetworkService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, toolboxMigrationService, webHookService, templateService, websiteStatService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo, settingRepo)
ws := route.NewWs(wsService, toolboxMigrationService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)
+2 -1
View File
@@ -61,6 +61,7 @@ func initCli() (*app.Cli, error) {
taskRepo := data.NewTaskRepo(locale, db, logger, taskRunner)
appRepo := data.NewAppRepo(locale, config, db, logger, cacheRepo, taskRepo)
userRepo := data.NewUserRepo(locale, db, logger)
userPasskeyRepo := data.NewUserPasskeyRepo(db)
settingRepo := data.NewSettingRepo(locale, db, logger, config, taskRepo)
databaseServerRepo := data.NewDatabaseServerRepo(locale, db, logger)
databaseUserRepo := data.NewDatabaseUserRepo(locale, db, logger, databaseServerRepo)
@@ -69,7 +70,7 @@ func initCli() (*app.Cli, error) {
certAccountRepo := data.NewCertAccountRepo(locale, db, userRepo, logger)
websiteRepo := data.NewWebsiteRepo(locale, db, logger, cacheRepo, databaseRepo, databaseServerRepo, databaseUserRepo, certRepo, certAccountRepo, settingRepo)
backupRepo := data.NewBackupRepo(locale, config, db, logger, settingRepo, websiteRepo)
cliService := service.NewCliService(locale, config, db, appRepo, cacheRepo, userRepo, settingRepo, backupRepo, websiteRepo, databaseServerRepo, certRepo, certAccountRepo)
cliService := service.NewCliService(locale, config, db, appRepo, cacheRepo, userRepo, userPasskeyRepo, settingRepo, backupRepo, websiteRepo, databaseServerRepo, certRepo, certAccountRepo)
cli := route.NewCli(locale, cliService)
command := bootstrap.NewCli(locale, cli)
gormigrate := bootstrap.NewMigrate(db)
+7
View File
@@ -19,6 +19,7 @@ require (
github.com/go-chi/httplog/v3 v3.3.0
github.com/go-gormigrate/gormigrate/v2 v2.1.5
github.com/go-sql-driver/mysql v1.9.3
github.com/go-webauthn/webauthn v0.15.0
github.com/gomodule/redigo v1.9.3
github.com/google/wire v0.7.0
github.com/gookit/color v1.6.0
@@ -79,9 +80,14 @@ require (
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gookit/filter v1.2.3 // indirect
github.com/gookit/goutil v0.7.3 // indirect
@@ -106,6 +112,7 @@ require (
github.com/timtadh/lexmachine v0.2.3 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
+16
View File
@@ -89,6 +89,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -110,10 +112,18 @@ github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lG
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -130,6 +140,8 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -382,6 +394,8 @@ github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8=
github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
@@ -393,6 +407,8 @@ go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
+35
View File
@@ -0,0 +1,35 @@
package biz
import (
"time"
"gorm.io/gorm"
)
type UserPasskey struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
Name string `gorm:"not null;default:''" json:"name"`
CredentialID []byte `gorm:"uniqueIndex;not null" json:"-"`
PublicKey []byte `gorm:"not null" json:"-"`
AAGUID []byte `json:"-"`
SignCount uint32 `gorm:"not null;default:0" json:"-"`
Transports string `gorm:"not null;default:''" json:"transports"` // JSON: ["internal","hybrid"]
BackupState bool `gorm:"not null;default:false" json:"-"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
}
type UserPasskeyRepo interface {
List(userID uint) ([]*UserPasskey, error)
Create(passkey *UserPasskey) error
UpdateSignCount(credentialID []byte, signCount uint32) error
UpdateLastUsed(credentialID []byte) error
Delete(userID, id uint) error
GetByCredentialID(credentialID []byte) (*UserPasskey, *User, error)
HasPasskey(userID uint) (bool, error)
HasAny() (bool, error)
DeleteAllByUserID(userID uint) error
}
+1
View File
@@ -32,6 +32,7 @@ var ProviderSet = wire.NewSet(
NewTaskRepo,
NewTemplateRepo,
NewUserRepo,
NewUserPasskeyRepo,
NewUserTokenRepo,
NewWebHookRepo,
NewWebsiteRepo,
+76
View File
@@ -0,0 +1,76 @@
package data
import (
"time"
"gorm.io/gorm"
"github.com/acepanel/panel/v3/internal/biz"
)
type userPasskeyRepo struct {
db *gorm.DB
}
func NewUserPasskeyRepo(db *gorm.DB) biz.UserPasskeyRepo {
return &userPasskeyRepo{db: db}
}
func (r userPasskeyRepo) List(userID uint) ([]*biz.UserPasskey, error) {
var passkeys []*biz.UserPasskey
if err := r.db.Where("user_id = ?", userID).Order("id desc").Find(&passkeys).Error; err != nil {
return nil, err
}
return passkeys, nil
}
func (r userPasskeyRepo) Create(passkey *biz.UserPasskey) error {
return r.db.Create(passkey).Error
}
func (r userPasskeyRepo) UpdateSignCount(credentialID []byte, signCount uint32) error {
return r.db.Model(&biz.UserPasskey{}).Where("credential_id = ?", credentialID).Update("sign_count", signCount).Error
}
func (r userPasskeyRepo) UpdateLastUsed(credentialID []byte) error {
now := time.Now()
return r.db.Model(&biz.UserPasskey{}).Where("credential_id = ?", credentialID).Update("last_used_at", &now).Error
}
func (r userPasskeyRepo) Delete(userID, id uint) error {
return r.db.Where("user_id = ? AND id = ?", userID, id).Delete(&biz.UserPasskey{}).Error
}
func (r userPasskeyRepo) GetByCredentialID(credentialID []byte) (*biz.UserPasskey, *biz.User, error) {
passkey := new(biz.UserPasskey)
if err := r.db.Where("credential_id = ?", credentialID).First(passkey).Error; err != nil {
return nil, nil, err
}
user := new(biz.User)
if err := r.db.First(user, passkey.UserID).Error; err != nil {
return nil, nil, err
}
return passkey, user, nil
}
func (r userPasskeyRepo) HasPasskey(userID uint) (bool, error) {
var count int64
if err := r.db.Model(&biz.UserPasskey{}).Where("user_id = ?", userID).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r userPasskeyRepo) HasAny() (bool, error) {
var count int64
if err := r.db.Model(&biz.UserPasskey{}).Limit(1).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r userPasskeyRepo) DeleteAllByUserID(userID uint) error {
return r.db.Where("user_id = ?", userID).Delete(&biz.UserPasskey{}).Error
}
+2
View File
@@ -26,6 +26,8 @@ func MustLogin(t *gotext.Locale, conf *config.Config, session *sessions.Manager,
"/api/user/captcha",
"/api/user/login",
"/api/user/logout",
"/api/user/passkey/enabled",
"/api/user/passkey/login",
"/api/user/is_login",
"/api/user/is_2fa",
"/api/home/panel",
+10
View File
@@ -0,0 +1,10 @@
package request
type UserPasskeyList struct {
UserID uint `query:"user_id"`
}
type UserPasskeyDelete struct {
ID uint `uri:"id" validate:"required|min:1"`
UserID uint `query:"user_id"`
}
+9
View File
@@ -86,4 +86,13 @@ func init() {
return tx.Migrator().DropTable(&biz.ScanEvent{})
},
})
Migrations = append(Migrations, &gormigrate.Migration{
ID: "20260226-add-user-passkeys",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&biz.UserPasskey{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable(&biz.UserPasskey{})
},
})
}
+5
View File
@@ -85,6 +85,11 @@ func (route *Cli) Commands() []*cli.Command {
Usage: route.t.Get("Toggle two-factor authentication for a user"),
Action: route.cli.UserTwoFA,
},
{
Name: "passkey",
Usage: route.t.Get("Clear all passkeys for a user"),
Action: route.cli.UserPasskey,
},
},
},
{
+15
View File
@@ -23,6 +23,7 @@ import (
type Http struct {
conf *config.Config
user *service.UserService
userPasskey *service.UserPasskeyService
userToken *service.UserTokenService
home *service.HomeService
task *service.TaskService
@@ -77,6 +78,7 @@ type Http struct {
func NewHttp(
conf *config.Config,
user *service.UserService,
userPasskey *service.UserPasskeyService,
userToken *service.UserTokenService,
home *service.HomeService,
task *service.TaskService,
@@ -130,6 +132,7 @@ func NewHttp(
return &Http{
conf: conf,
user: user,
userPasskey: userPasskey,
userToken: userToken,
home: home,
task: task,
@@ -192,6 +195,12 @@ func (route *Http) Register(r *chi.Mux) {
r.Get("/is_login", route.user.IsLogin)
r.Get("/is_2fa", route.user.IsTwoFA)
r.Get("/info", route.user.Info)
// 通行密钥
r.Get("/passkey/enabled", route.userPasskey.Enabled)
r.Post("/passkey/register", route.userPasskey.BeginRegister)
r.Put("/passkey/register", route.userPasskey.FinishRegister)
r.With(middleware.Throttle(route.conf.HTTP.IPHeader, 5, time.Minute)).Post("/passkey/login", route.userPasskey.BeginLogin)
r.Put("/passkey/login", route.userPasskey.FinishLogin)
})
r.Route("/users", func(r chi.Router) {
@@ -212,6 +221,12 @@ func (route *Http) Register(r *chi.Mux) {
r.Delete("/{id}", route.userToken.Delete)
})
r.Route("/user_passkeys", func(r chi.Router) {
r.Get("/", route.userPasskey.List)
r.Get("/supported", route.userPasskey.Supported)
r.Delete("/{id}", route.userPasskey.Delete)
})
r.Route("/home", func(r chi.Router) {
r.Get("/panel", route.home.Panel)
r.Get("/apps", route.home.Apps)
+25 -1
View File
@@ -45,6 +45,7 @@ type CliService struct {
appRepo biz.AppRepo
cacheRepo biz.CacheRepo
userRepo biz.UserRepo
userPasskeyRepo biz.UserPasskeyRepo
settingRepo biz.SettingRepo
backupRepo biz.BackupRepo
websiteRepo biz.WebsiteRepo
@@ -54,7 +55,7 @@ type CliService struct {
hash hash.Hasher
}
func NewCliService(t *gotext.Locale, conf *config.Config, db *gorm.DB, appRepo biz.AppRepo, cache biz.CacheRepo, user biz.UserRepo, setting biz.SettingRepo, backup biz.BackupRepo, website biz.WebsiteRepo, databaseServer biz.DatabaseServerRepo, cert biz.CertRepo, certAccount biz.CertAccountRepo) *CliService {
func NewCliService(t *gotext.Locale, conf *config.Config, db *gorm.DB, appRepo biz.AppRepo, cache biz.CacheRepo, user biz.UserRepo, userPasskey biz.UserPasskeyRepo, setting biz.SettingRepo, backup biz.BackupRepo, website biz.WebsiteRepo, databaseServer biz.DatabaseServerRepo, cert biz.CertRepo, certAccount biz.CertAccountRepo) *CliService {
return &CliService{
hr: `+----------------------------------------------------`,
api: api.NewAPI(app.Version, app.Locale),
@@ -64,6 +65,7 @@ func NewCliService(t *gotext.Locale, conf *config.Config, db *gorm.DB, appRepo b
appRepo: appRepo,
cacheRepo: cache,
userRepo: user,
userPasskeyRepo: userPasskey,
settingRepo: setting,
backupRepo: backup,
websiteRepo: website,
@@ -332,6 +334,28 @@ func (s *CliService) UserTwoFA(ctx context.Context, cmd *cli.Command) error {
return nil
}
func (s *CliService) UserPasskey(ctx context.Context, cmd *cli.Command) error {
user := new(biz.User)
username := cmd.Args().Get(0)
if username == "" {
return errors.New(s.t.Get("Username cannot be empty"))
}
if err := s.db.Where("username", username).First(user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(s.t.Get("User not exists"))
}
return errors.New(s.t.Get("Failed to get user: %v", err))
}
if err := s.userPasskeyRepo.DeleteAllByUserID(user.ID); err != nil {
return errors.New(s.t.Get("Failed to clear passkeys: %v", err))
}
fmt.Println(s.t.Get("All passkeys cleared for user %s", username))
return nil
}
func (s *CliService) HTTPSOn(ctx context.Context, cmd *cli.Command) error {
conf, err := config.Load()
if err != nil {
+1
View File
@@ -43,6 +43,7 @@ var ProviderSet = wire.NewSet(
NewTaskService,
NewTemplateService,
NewUserService,
NewUserPasskeyService,
NewUserTokenService,
NewWebHookService,
NewWebsiteService,
+1
View File
@@ -175,6 +175,7 @@ func (s *UserService) Login(w http.ResponseWriter, r *http.Request) {
sess.Put("refresh_at", time.Now().Unix())
sess.Forget("key")
sess.Forget("login_fail_count")
Success(w, nil)
}
+379
View File
@@ -0,0 +1,379 @@
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/spf13/cast"
"github.com/acepanel/panel/v3/internal/app"
"github.com/acepanel/panel/v3/internal/biz"
"github.com/acepanel/panel/v3/internal/http/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.UserPasskeyRepo
userRepo biz.UserRepo
}
func NewUserPasskeyService(t *gotext.Locale, conf *config.Config, session *sessions.Manager, userPasskeyRepo biz.UserPasskeyRepo, userRepo biz.UserRepo) *UserPasskeyService {
// 注册 webauthn.SessionData 类型,否则 session 无法序列化
gob.Register(webauthn.SessionData{})
return &UserPasskeyService{
t: t,
conf: conf,
session: session,
userPasskeyRepo: userPasskeyRepo,
userRepo: userRepo,
}
}
// 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,
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 参数提取 nameFinishRegistration 已消费 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),
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.TLS {
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
}
+90
View File
@@ -0,0 +1,90 @@
package passkey
import (
"encoding/binary"
"encoding/json"
"fmt"
"net"
"net/http"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/acepanel/panel/v3/internal/biz"
)
// User 适配 webauthn.User 接口
type User struct {
Inner *biz.User
Passkeys []*biz.UserPasskey
}
func (u *User) WebAuthnID() []byte {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, uint64(u.Inner.ID))
return buf
}
func (u *User) WebAuthnName() string { return u.Inner.Username }
func (u *User) WebAuthnDisplayName() string { return u.Inner.Username }
func (u *User) WebAuthnCredentials() []webauthn.Credential {
creds := make([]webauthn.Credential, 0, len(u.Passkeys))
for _, p := range u.Passkeys {
var transports []protocol.AuthenticatorTransport
if p.Transports != "" {
var ts []string
if err := json.Unmarshal([]byte(p.Transports), &ts); err == nil {
for _, t := range ts {
transports = append(transports, protocol.AuthenticatorTransport(t))
}
}
}
creds = append(creds, webauthn.Credential{
ID: p.CredentialID,
PublicKey: p.PublicKey,
Transport: transports,
Flags: webauthn.CredentialFlags{
BackupState: p.BackupState,
},
Authenticator: webauthn.Authenticator{
AAGUID: p.AAGUID,
SignCount: p.SignCount,
},
})
}
return creds
}
// ParseUserID 从 WebAuthnID 字节还原 user ID
func ParseUserID(userHandle []byte) (uint, error) {
if len(userHandle) != 8 {
return 0, fmt.Errorf("invalid user handle")
}
return uint(binary.BigEndian.Uint64(userHandle)), nil
}
// NewWebAuthn 根据 HTTP 请求动态创建 WebAuthn 实例
func NewWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
host := r.Host
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host
}
scheme := "https"
if r.TLS == nil {
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
scheme = fwd
} else {
scheme = "http"
}
}
origin := fmt.Sprintf("%s://%s", scheme, host)
return webauthn.New(&webauthn.Config{
RPID: hostname,
RPDisplayName: "AcePanel",
RPOrigins: []string{origin},
})
}
+1
View File
@@ -28,6 +28,7 @@
"@alova/adapter-xhr": "^2.2.1",
"@eslint/eslintrc": "^3.3.1",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@simplewebauthn/browser": "^13.2.2",
"@vavt/copy2clipboard": "^1.0.3",
"@vueuse/core": "^14.0.0",
"@xterm/addon-attach": "^0.12.0",
+8
View File
@@ -17,6 +17,9 @@ importers:
'@fontsource-variable/jetbrains-mono':
specifier: ^5.2.8
version: 5.2.8
'@simplewebauthn/browser':
specifier: ^13.2.2
version: 13.2.2
'@vavt/copy2clipboard':
specifier: ^1.0.3
version: 1.0.3
@@ -910,6 +913,9 @@ packages:
'@rushstack/eslint-patch@1.16.1':
resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==}
'@simplewebauthn/browser@13.2.2':
resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==}
'@tsconfig/node24@24.0.4':
resolution: {integrity: sha512-2A933l5P5oCbv6qSxHs7ckKwobs8BDAe9SJ/Xr2Hy+nDlwmLE1GhFh/g/vXGRZWgxBg9nX/5piDtHR9Dkw/XuA==}
@@ -3889,6 +3895,8 @@ snapshots:
'@rushstack/eslint-patch@1.16.1': {}
'@simplewebauthn/browser@13.2.2': {}
'@tsconfig/node24@24.0.4': {}
'@types/esrecurse@4.3.1': {}
+12 -1
View File
@@ -59,5 +59,16 @@ export default {
tokenDelete: (id: number): any => http.Delete(`/user_tokens/${id}`),
// 更新用户Token
tokenUpdate: (id: number, ips: string[], expired_at: number): any =>
http.Put(`/user_tokens/${id}`, { ips, expired_at })
http.Put(`/user_tokens/${id}`, { ips, expired_at }),
// 通行密钥
passkeyEnabled: (): any => http.Get('/user/passkey/enabled'),
passkeySupported: (): any => http.Get('/user_passkeys/supported'),
passkeyBeginRegister: (name: string): any => http.Post('/user/passkey/register', { name }),
passkeyFinishRegister: (credential: any, name: string): any =>
http.Put(`/user/passkey/register?name=${encodeURIComponent(name)}`, credential),
passkeyBeginLogin: (): any => http.Post('/user/passkey/login'),
passkeyFinishLogin: (assertion: any): any => http.Put('/user/passkey/login', assertion),
passkeyList: (user_id: number): any => http.Get('/user_passkeys', { params: { user_id } }),
passkeyDelete: (id: number, user_id: number): any =>
http.Delete(`/user_passkeys/${id}`, { params: { user_id } })
}
+148 -65
View File
@@ -6,6 +6,7 @@ import { addDynamicRoutes } from '@/router'
import { useThemeStore, useUserStore } from '@/store'
import { getLocal, removeLocal, setLocal } from '@/utils'
import { rsaEncrypt } from '@/utils/encrypt'
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'
import { until } from '@vueuse/core'
import { useGettext } from 'vue3-gettext'
@@ -49,8 +50,74 @@ const showTwoFA = ref(false)
const captchaRequired = ref(false)
const captchaImage = ref('')
// 通行密钥相关状态
const passkeyAvailable = ref(false)
const passkeyAttempting = ref(false)
const passkeyFailed = ref(false)
const logo = computed(() => themeStore.logo || logoImg)
// 登录成功后的跳转
const handleLoginSuccess = async () => {
window.$notification?.success({ title: $gettext('Login successful!'), duration: 2500 })
await addDynamicRoutes()
useRequest(user.info()).onSuccess(({ data }) => {
userStore.set(data as any)
})
if (query.redirect) {
const path = query.redirect as string
Reflect.deleteProperty(query, 'redirect')
await router.push({ path, query })
} else {
await router.push('/')
}
}
// 通行密钥登录
const attemptPasskeyLogin = async () => {
passkeyAttempting.value = true
passkeyFailed.value = false
try {
// 获取登录挑战
const options = await user.passkeyBeginLogin()
// 调用浏览器 WebAuthn API
const assertion = await startAuthentication({ optionsJSON: options.publicKey })
// 完成登录
useRequest(user.passkeyFinishLogin(assertion))
.onSuccess(async () => {
await handleLoginSuccess()
})
.onError(() => {
passkeyFailed.value = true
passkeyAttempting.value = false
})
} catch {
// 用户取消或浏览器不支持
passkeyFailed.value = true
passkeyAttempting.value = false
}
}
// 检查通行密钥可用性
const checkPasskeyAvailable = async () => {
// 浏览器必须支持 WebAuthn 且处于安全上下文
if (!window.isSecureContext || !browserSupportsWebAuthn()) {
return
}
try {
const enabled = await user.passkeyEnabled()
if (enabled) {
passkeyAvailable.value = true
await nextTick()
await attemptPasskeyLogin()
}
} catch {
// 忽略
}
}
// 刷新验证码
const refreshCaptcha = () => {
useRequest(user.captcha())
@@ -96,24 +163,12 @@ async function handleLogin() {
)
)
.onSuccess(async () => {
window.$notification?.success({ title: $gettext('Login successful!'), duration: 2500 })
if (isRemember.value) {
setLocal('loginInfo', { username, password })
} else {
removeLocal('loginInfo')
}
await addDynamicRoutes()
useRequest(user.info()).onSuccess(({ data }) => {
userStore.set(data as any)
})
if (query.redirect) {
const path = query.redirect as string
Reflect.deleteProperty(query, 'redirect')
await router.push({ path, query })
} else {
await router.push('/')
}
await handleLoginSuccess()
})
.onError(() => {
// 登录失败后刷新验证码状态
@@ -156,6 +211,7 @@ watch(isLogin, async () => {
onMounted(() => {
refreshCaptcha()
checkPasskeyAvailable()
})
</script>
@@ -169,67 +225,94 @@ onMounted(() => {
<div px-28 py-32 rounded-lg bg-white min-w-380 card-shadow class="dark:bg-dark">
<h2 text-32 font-600 mb-28 text-center>{{ themeStore.name }}</h2>
<n-input
v-model:value="loginInfo.username"
:maxlength="32"
:placeholder="$gettext('Username')"
autofocus
class="text-15 h-48 items-center"
:on-blur="isTwoFA"
/>
<!-- 通行密钥正在尝试中 -->
<div v-if="passkeyAttempting" class="py-20 text-center">
<n-spin size="large" />
<p class="text-14 text-gray-500 mt-12">
{{ $gettext('Authenticating with passkey...') }}
</p>
</div>
<n-input
v-model:value="loginInfo.password"
:maxlength="32"
:placeholder="$gettext('Password')"
class="text-15 mt-20 h-48 items-center"
type="password"
show-password-on="click"
@keydown.enter="handleLogin"
/>
<!-- 密码登录表单 -->
<template v-else>
<!-- 通行密钥登录失败提示 -->
<n-alert v-if="passkeyFailed" type="warning" class="mb-20" :bordered="false">
{{ $gettext('Passkey login failed, please use username and password.') }}
</n-alert>
<n-input
v-if="showTwoFA"
v-model:value="loginInfo.pass_code"
:maxlength="6"
:placeholder="$gettext('2FA Code')"
class="text-15 mt-20 h-48 items-center"
type="text"
@keydown.enter="handleLogin"
/>
<n-flex v-if="captchaRequired" align="center" class="mt-20">
<n-input
v-model:value="loginInfo.captcha_code"
:maxlength="4"
:placeholder="$gettext('Captcha Code')"
v-model:value="loginInfo.username"
:maxlength="32"
:placeholder="$gettext('Username')"
autofocus
class="text-15 h-48 items-center"
style="flex: 1"
:on-blur="isTwoFA"
/>
<n-input
v-model:value="loginInfo.password"
:maxlength="32"
:placeholder="$gettext('Password')"
class="text-15 mt-20 h-48 items-center"
type="password"
show-password-on="click"
@keydown.enter="handleLogin"
/>
<n-input
v-if="showTwoFA"
v-model:value="loginInfo.pass_code"
:maxlength="6"
:placeholder="$gettext('2FA Code')"
:input-props="{ autocomplete: 'one-time-code' }"
class="text-15 mt-20 h-48 items-center"
type="text"
@keydown.enter="handleLogin"
/>
<n-image
:src="captchaImage"
preview-disabled
class="rounded h-48 cursor-pointer"
@click="refreshCaptcha"
/>
</n-flex>
<n-flex class="mt-20">
<n-checkbox v-model:checked="loginInfo.safe_login" :label="$gettext('Safe Login')" />
<n-checkbox v-model:checked="isRemember" :label="$gettext('Remember Me')" />
</n-flex>
<n-flex v-if="captchaRequired" align="center" class="mt-20">
<n-input
v-model:value="loginInfo.captcha_code"
:maxlength="4"
:placeholder="$gettext('Captcha Code')"
class="text-15 h-48 items-center"
style="flex: 1"
type="text"
@keydown.enter="handleLogin"
/>
<n-image
:src="captchaImage"
preview-disabled
class="rounded h-48 cursor-pointer"
@click="refreshCaptcha"
/>
</n-flex>
<n-button
:loading="!keyLoaded || logining"
:disabled="!keyLoaded || logining"
class="text-16 mt-24 h-48 w-full"
type="primary"
@click="handleLogin"
>
{{ $gettext('Login') }}
</n-button>
<n-flex class="mt-20">
<n-checkbox v-model:checked="loginInfo.safe_login" :label="$gettext('Safe Login')" />
<n-checkbox v-model:checked="isRemember" :label="$gettext('Remember Me')" />
</n-flex>
<n-button
:loading="!keyLoaded || logining"
:disabled="!keyLoaded || logining"
class="text-16 mt-24 h-48 w-full"
type="primary"
@click="handleLogin"
>
{{ $gettext('Login') }}
</n-button>
<!-- 手动触发通行密钥登录 -->
<n-button
v-if="passkeyAvailable"
quaternary
class="text-14 mt-12 w-full"
@click="attemptPasskeyLogin"
>
{{ $gettext('Login with Passkey') }}
</n-button>
</template>
</div>
</div>
</AppPage>
+203
View File
@@ -0,0 +1,203 @@
<script setup lang="ts">
import user from '@/api/panel/user'
import { useUserStore } from '@/store'
import { formatDateTime } from '@/utils'
import { startRegistration } from '@simplewebauthn/browser'
import { NAlert, NButton, NDataTable, NFlex, NInput, NPopconfirm } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const id = defineModel<number>('id', { type: Number, required: true })
const userStore = useUserStore()
const registerLoading = ref(false)
const passkeyName = ref('')
const passkeySupported = ref(false)
// 只有当前登录用户自己才能注册通行密钥
const isSelf = computed(() => String(id.value) === String(userStore.id))
const columns: any = [
{
title: $gettext('Name'),
key: 'name',
minWidth: 150,
resizable: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Creation Time'),
key: 'created_at',
minWidth: 200,
ellipsis: { tooltip: true },
render(row: any) {
return formatDateTime(row.created_at)
}
},
{
title: $gettext('Last Used'),
key: 'last_used_at',
minWidth: 200,
ellipsis: { tooltip: true },
render(row: any) {
return row.last_used_at ? formatDateTime(row.last_used_at) : $gettext('Never')
}
},
{
title: $gettext('Actions'),
key: 'actions',
width: 150,
hideInExcel: true,
render(row: any) {
return h(
NPopconfirm,
{
onPositiveClick: () => handleDelete(row.id)
},
{
default: () => {
return $gettext('Are you sure you want to delete this passkey?')
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error'
},
{
default: () => $gettext('Delete')
}
)
}
}
)
}
}
]
const loading = ref(false)
const data = ref<any[]>([])
const refresh = () => {
loading.value = true
useRequest(user.passkeyList(id.value))
.onSuccess(({ data: res }) => {
data.value = res.items || []
})
.onComplete(() => {
loading.value = false
})
}
const checkSupported = () => {
useRequest(user.passkeySupported()).onSuccess(({ data: res }) => {
passkeySupported.value = Boolean(res)
})
}
const handleDelete = (passkeyId: number) => {
useRequest(() => user.passkeyDelete(passkeyId, id.value)).onSuccess(() => {
window.$message.success($gettext('Deleted successfully'))
refresh()
})
}
const handleRegister = async () => {
const name = passkeyName.value.trim()
if (!name) {
window.$message.warning($gettext('Please enter a name for the passkey'))
return
}
registerLoading.value = true
try {
// 开始注册
const options = await user.passkeyBeginRegister(name)
// 调用浏览器 WebAuthn API
const credential = await startRegistration({ optionsJSON: options.publicKey })
// 完成注册
useRequest(user.passkeyFinishRegister(credential, name))
.onSuccess(() => {
window.$message.success($gettext('Passkey registered successfully'))
passkeyName.value = ''
refresh()
})
.onComplete(() => {
registerLoading.value = false
})
} catch (e: any) {
registerLoading.value = false
if (e.name === 'NotAllowedError') {
window.$message.warning($gettext('Registration was cancelled'))
} else {
window.$message.error($gettext('Passkey registration failed: %{msg}', { msg: e.message }))
}
}
}
watch(
() => show.value,
(val) => {
if (val) {
checkSupported()
refresh()
}
},
{ immediate: true }
)
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="$gettext('Passkeys')"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
@close="show = false"
>
<n-flex vertical>
<n-alert v-if="!passkeySupported" type="info" :bordered="false">
{{
$gettext(
'Passkeys are only available when using a bound domain with a trusted HTTPS certificate.'
)
}}
</n-alert>
<template v-else-if="isSelf">
<n-flex align="center">
<n-input
v-model:value="passkeyName"
:placeholder="$gettext('Passkey name')"
style="max-width: 300px"
@keydown.enter="handleRegister"
/>
<n-button
type="primary"
:loading="registerLoading"
:disabled="registerLoading"
@click="handleRegister"
>
{{ $gettext('Register New Passkey') }}
</n-button>
</n-flex>
</template>
<n-data-table
striped
:scroll-x="600"
:loading="loading"
:columns="columns"
:data="data"
:row-key="(row: any) => row.id"
/>
</n-flex>
</n-modal>
</template>
<style scoped lang="scss"></style>
+19 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import user from '@/api/panel/user'
import { formatDateTime } from '@/utils'
import PasskeyModal from '@/views/setting/PasskeyModal.vue'
import PasswordModal from '@/views/setting/PasswordModal.vue'
import TokenModal from '@/views/setting/TokenModal.vue'
import TwoFaModal from '@/views/setting/TwoFaModal.vue'
@@ -13,6 +14,7 @@ const currentID = ref(0)
const passwordModal = ref(false)
const twoFaModal = ref(false)
const tokenModal = ref(false)
const passkeyModal = ref(false)
const columns: any = [
{
@@ -84,7 +86,7 @@ const columns: any = [
{
title: $gettext('Actions'),
key: 'actions',
width: 380,
width: 500,
hideInExcel: true,
render(row: any) {
return [
@@ -102,6 +104,21 @@ const columns: any = [
default: () => $gettext('Access Tokens')
}
),
h(
NButton,
{
size: 'small',
type: 'primary',
style: 'margin-left: 15px;',
onClick: () => {
currentID.value = row.id
passkeyModal.value = true
}
},
{
default: () => $gettext('Passkeys')
}
),
h(
NButton,
{
@@ -207,6 +224,7 @@ onMounted(() => {
<password-modal v-model:id="currentID" v-model:show="passwordModal" />
<two-fa-modal v-model:id="currentID" v-model:show="twoFaModal" />
<token-modal v-model:id="currentID" v-model:show="tokenModal" />
<passkey-modal v-model:id="currentID" v-model:show="passkeyModal" />
</template>
<style scoped lang="scss"></style>