mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-30 17:05:19 +08:00
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:
+3
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -32,6 +32,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewTaskRepo,
|
||||
NewTemplateRepo,
|
||||
NewUserRepo,
|
||||
NewUserPasskeyRepo,
|
||||
NewUserTokenRepo,
|
||||
NewWebHookRepo,
|
||||
NewWebsiteRepo,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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{})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -43,6 +43,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewTaskService,
|
||||
NewTemplateService,
|
||||
NewUserService,
|
||||
NewUserPasskeyService,
|
||||
NewUserTokenService,
|
||||
NewWebHookService,
|
||||
NewWebsiteService,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 参数提取 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),
|
||||
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
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+8
@@ -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': {}
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user