feat: support secure passkey login (#561)

Add WebAuthn passkey enrollment and login for issue #536, with trusted origin configuration, user verification, single-use challenges, and documented deployment and recovery behavior.
This commit is contained in:
sagit
2026-09-30 19:23:10 +08:00
committed by GitHub
parent c952d2fb3a
commit 861d61239a
19 changed files with 1150 additions and 5 deletions
+22
View File
@@ -71,6 +71,28 @@ docker compose up -d
3) 如果你想继续使用 SQLite,保留 `DB_TYPE=sqlite`(或不设置 `DB_TYPE`)即可。
#### 通行证密钥登录(可选)
通行证密钥登录不依赖 Cloudflare Turnstile,但必须在验证码可用时**预先绑定**。原密码登录保持可用;未绑定密钥的用户无法借此绕过验证码。
1. 在面板后端进程的环境中设置可信的、浏览器实际访问的公开源。例如在 Compose 的 `.env` 中添加:
```dotenv
FLVX_WEBAUTHN_ORIGIN=https://panel.example.com
```
该值必须是完整的 HTTPS origin(协议、域名和可选端口),不能带路径、查询参数或尾部 `/`。仅本机开发允许 `http://localhost:3000` 或 `http://127.0.0.1:3000`。在反向代理后部署时填浏览器地址栏中的**前端**公开源,而不是容器内部地址、后端监听地址或代理转发头。API 可以位于另一地址;WebAuthn 验证的是发起操作的前端页面 origin。Compose 模板会将变量传给后端容器;自行部署时需传给 `paneld` 进程。
2. 使环境变量对后端生效(Compose 部署可运行 `docker compose up -d backend`)。登录页出现“使用通行证密钥登录”按钮时表示浏览器支持该功能且配置有效。
3. 用户先照常用密码登录,在“个人”页面的“通行证密钥”卡片中输入**当前密码**、可选名称,然后选择“绑定通行证密钥”并完成设备解锁。建议保留至少一种可用的密码恢复途径。绑定或删除密钥均需当前密码;只有该账号能列出和删除自己的密钥。
4. 以后在登录页输入用户名,选择“使用通行证密钥登录”,通过设备解锁即可进入面板。账号被停用时密钥登录也会被拒绝。需要撤销设备时,在个人页面输入当前密码并删除对应密钥。
**安全配置与恢复:**未设置或设置错误时,通行证密钥接口拒绝新的绑定和登录,登录页与个人页不显示对应入口;密码登录不受影响。不要把不可信的请求 `Host` / `X-Forwarded-Host` 当作可信 origin。域名变化会改变 RP ID,旧域名下绑定的密钥不能用于新域名;迁移时需保留原域名或可用的密码登录途径,再在新域名重新绑定。仅更改同一域名的端口也需同步更新此 origin。正在进行的密钥挑战只保存在后端内存中,重启后需重新开始;多副本部署需要共享会话方案,未实现前请保持单个后端实例。
数据库级备份会保留密钥记录。当前面板的 JSON 导出不包含通行证密钥,使用 JSON 导入迁移后,用户需要通过密码登录并重新绑定。
#### 从 SQLite 迁移到 PostgreSQL
如果你是通过 `panel_install.sh` 安装面板,推荐直接使用脚本菜单一键迁移:
+1
View File
@@ -13,6 +13,7 @@ services:
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
+1
View File
@@ -13,6 +13,7 @@ services:
DB_PATH: /app/data/gost.db
DATABASE_URL: ${DATABASE_URL:-}
JWT_SECRET: ${JWT_SECRET}
FLVX_WEBAUTHN_ORIGIN: ${FLVX_WEBAUTHN_ORIGIN:-}
SERVER_ADDR: :6365
TZ: Asia/Shanghai
FLUX_VERSION: ${FLUX_VERSION:-dev}
+8 -1
View File
@@ -3,11 +3,13 @@ module go-backend
go 1.25.0
require (
github.com/fxamacker/cbor/v2 v2.9.0
github.com/glebarez/sqlite v1.11.0
github.com/go-webauthn/webauthn v0.14.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.9.2
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.42.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -15,14 +17,19 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
+20 -2
View File
@@ -3,10 +3,20 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
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/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -27,6 +37,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -38,8 +50,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
@@ -49,6 +65,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
@@ -42,6 +42,8 @@ type Handler struct {
peerResourceMu sync.Mutex
captchaMu sync.Mutex
captchaTokens map[string]int64
passkeyMu sync.Mutex
passkeyPending map[string]passkeyCeremony
jobsMu sync.Mutex
jobsCancel context.CancelFunc
@@ -116,6 +118,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
healthCheck: nil,
nftablesManager: runtimenft.NewManager(nil),
captchaTokens: make(map[string]int64),
passkeyPending: make(map[string]passkeyCeremony),
pendingUpgradeRedeploy: make(map[int64]struct{}),
nodeOnlineRedeployAt: make(map[int64]time.Time),
nodeOnlineRedeployQueued: make(map[int64]struct{}),
@@ -157,6 +160,13 @@ func (h *Handler) GetUserAuthState(userID int64) (*auth.UserAuthState, error) {
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/user/login", h.login)
mux.HandleFunc("/api/v1/user/passkey/status", h.passkeyStatus)
mux.HandleFunc("/api/v1/user/passkey/login/begin", h.passkeyLoginBegin)
mux.HandleFunc("/api/v1/user/passkey/login/finish", h.passkeyLoginFinish)
mux.HandleFunc("/api/v1/user/passkey/register/begin", h.passkeyRegisterBegin)
mux.HandleFunc("/api/v1/user/passkey/register/finish", h.passkeyRegisterFinish)
mux.HandleFunc("/api/v1/user/passkey/list", h.passkeyList)
mux.HandleFunc("/api/v1/user/passkey/delete", h.passkeyDelete)
mux.HandleFunc("/api/v1/user/list", h.userList)
mux.HandleFunc("/api/v1/user/create", h.userCreate)
mux.HandleFunc("/api/v1/user/update", h.userUpdate)
+426
View File
@@ -0,0 +1,426 @@
package handler
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
"go-backend/internal/store/model"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const passkeyTTL = 2 * time.Minute
type passkeyCeremony struct {
userID int64
kind string
origin string
session webauthn.SessionData
}
type passkeyUser struct {
id int64
name string
credentials []webauthn.Credential
}
func (u passkeyUser) WebAuthnID() []byte {
id := make([]byte, 8)
binary.BigEndian.PutUint64(id, uint64(u.id))
return id
}
func (u passkeyUser) WebAuthnName() string { return u.name }
func (u passkeyUser) WebAuthnDisplayName() string { return u.name }
func (u passkeyUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
func passkeyConfig() (*webauthn.WebAuthn, string) {
raw := strings.TrimSpace(os.Getenv("FLVX_WEBAUTHN_ORIGIN"))
u, err := url.Parse(raw)
if err != nil || u == nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return nil, ""
}
host := u.Hostname()
if host == "" || strings.ContainsAny(host, " /\\") || (u.Scheme != "https" && !(u.Scheme == "http" && (host == "localhost" || host == "127.0.0.1"))) {
return nil, ""
}
origin := u.Scheme + "://" + u.Host
wa, err := webauthn.New(&webauthn.Config{
RPID: host,
RPDisplayName: "FLVX",
RPOrigins: []string{origin},
AuthenticatorSelection: protocol.AuthenticatorSelection{UserVerification: protocol.VerificationRequired, ResidentKey: protocol.ResidentKeyRequirementPreferred},
Timeouts: webauthn.TimeoutsConfig{
Login: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: passkeyTTL},
},
})
if err != nil {
return nil, ""
}
return wa, origin
}
func (h *Handler) passkeyStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, _ := passkeyConfig()
response.WriteJSON(w, response.OK(map[string]bool{"enabled": wa != nil}))
}
func (h *Handler) putPasskeyCeremony(c passkeyCeremony) (string, bool) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", false
}
id := base64.RawURLEncoding.EncodeToString(buf)
h.passkeyMu.Lock()
defer h.passkeyMu.Unlock()
for k, v := range h.passkeyPending {
if time.Now().After(v.session.Expires) {
delete(h.passkeyPending, k)
}
}
if len(h.passkeyPending) >= 1000 {
return "", false
}
h.passkeyPending[id] = c
return id, true
}
func (h *Handler) takePasskeyCeremony(id, kind, origin string, userID int64) (webauthn.SessionData, bool) {
h.passkeyMu.Lock()
c, ok := h.passkeyPending[id]
delete(h.passkeyPending, id)
h.passkeyMu.Unlock()
if !ok || c.kind != kind || c.origin != origin || c.userID != userID || time.Now().After(c.session.Expires) {
return webauthn.SessionData{}, false
}
return c.session, true
}
func (h *Handler) loadPasskeyUser(userID int64) (passkeyUser, error) {
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
return passkeyUser{}, errInvalidPasskey
}
rows, err := h.repo.ListPasskeys(userID)
if err != nil {
return passkeyUser{}, err
}
u := passkeyUser{id: user.ID, name: user.User}
for _, row := range rows {
var credential webauthn.Credential
if err := json.Unmarshal([]byte(row.CredentialJSON), &credential); err != nil {
return passkeyUser{}, err
}
u.credentials = append(u.credentials, credential)
}
return u, nil
}
var errInvalidPasskey = &passkeyError{}
type passkeyError struct{}
func (*passkeyError) Error() string { return "invalid passkey user" }
func passkeyBody(r *http.Request, out interface{}) bool {
return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64*1024)).Decode(out) == nil
}
func (h *Handler) passkeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req struct {
Password string `json:"password"`
}
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
u, err := h.loadPasskeyUser(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法读取通行证密钥"))
return
}
options, session, err := wa.BeginRegistration(u)
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: userID, kind: "register", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
}
type passkeyFinishRequest struct {
SessionID string `json:"sessionId"`
Credential json.RawMessage `json:"credential"`
Name string `json:"name"`
}
func (h *Handler) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req passkeyFinishRequest
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
session, ok := h.takePasskeyCeremony(req.SessionID, "register", origin, userID)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
u, err := h.loadPasskeyUser(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
credential, err := wa.FinishRegistration(u, session, credentialRequest(r, req.Credential))
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
credentialJSON, err := json.Marshal(credential)
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
return
}
name := strings.TrimSpace(req.Name)
if len([]rune(name)) > 100 {
name = string([]rune(name)[:100])
}
if name == "" {
name = "Passkey"
}
err = h.repo.CreatePasskey(&model.Passkey{ID: base64.RawURLEncoding.EncodeToString(credential.ID), UserID: userID, Name: name, CredentialJSON: string(credentialJSON), CreatedAt: time.Now().UnixMilli()})
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥保存失败"))
return
}
response.WriteJSON(w, response.OK(nil))
}
func credentialRequest(original *http.Request, body []byte) *http.Request {
r := original.Clone(original.Context())
r.Body = http.NoBody
if len(body) > 0 {
r.Body = ioNopCloser{bytes.NewReader(body)}
}
return r
}
type ioNopCloser struct{ *bytes.Reader }
func (ioNopCloser) Close() error { return nil }
func (h *Handler) passkeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
var req struct {
Username string `json:"username"`
}
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByUsername(strings.TrimSpace(req.Username))
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录"))
return
}
u, err := h.loadPasskeyUser(user.ID)
if err != nil || len(u.credentials) == 0 {
response.WriteJSON(w, response.ErrDefault("无法使用通行证密钥登录"))
return
}
options, session, err := wa.BeginLogin(u, webauthn.WithUserVerification(protocol.VerificationRequired))
if err != nil {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: user.ID, kind: "login", origin: origin, session: *session})
if !ok {
response.WriteJSON(w, response.ErrDefault("无法创建通行证密钥挑战"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"sessionId": id, "options": options}))
}
func (h *Handler) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
wa, origin := passkeyConfig()
if wa == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥未配置"))
return
}
var req passkeyFinishRequest
if !passkeyBody(r, &req) {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
// Login sessions carry the user ID; the caller cannot choose a different account at finish.
h.passkeyMu.Lock()
pending := h.passkeyPending[req.SessionID]
h.passkeyMu.Unlock()
session, ok := h.takePasskeyCeremony(req.SessionID, "login", origin, pending.userID)
if !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥挑战已过期"))
return
}
u, err := h.loadPasskeyUser(pending.userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
credential, err := wa.FinishLogin(u, session, credentialRequest(r, req.Credential))
if err != nil || credential.Authenticator.CloneWarning {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
credentialID := base64.RawURLEncoding.EncodeToString(credential.ID)
stored, err := h.repo.GetPasskey(u.id, credentialID)
if err != nil || stored == nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
updated, err := json.Marshal(credential)
if err != nil {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
ok, err = h.repo.UpdatePasskeyCredential(u.id, credentialID, stored.CredentialJSON, string(updated), time.Now().UnixMilli())
if err != nil || !ok {
response.WriteJSON(w, response.ErrDefault("通行证密钥验证失败"))
return
}
user, err := h.repo.GetUserByID(u.id)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
token, err := auth.GenerateTokenAt(user.ID, user.User, user.RoleID, h.jwtSecret, time.Now())
if err != nil {
response.WriteJSON(w, response.ErrDefault("登录失败"))
return
}
response.WriteJSON(w, response.OK(map[string]interface{}{"token": token, "name": user.User, "role_id": user.RoleID, "requirePasswordChange": user.User == "admin_user"}))
}
func (h *Handler) passkeyList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
rows, err := h.repo.ListPasskeys(userID)
if err != nil {
response.WriteJSON(w, response.ErrDefault("获取通行证密钥失败"))
return
}
out := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
out = append(out, map[string]interface{}{"id": row.ID, "name": row.Name, "createdAt": row.CreatedAt, "lastUsedAt": row.LastUsedAt})
}
response.WriteJSON(w, response.OK(out))
}
func (h *Handler) passkeyDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
userID, err := userIDFromRequest(r)
if err != nil {
response.WriteJSON(w, response.Err(401, "未登录或token已过期"))
return
}
var req struct {
ID string `json:"id"`
Password string `json:"password"`
}
if !passkeyBody(r, &req) || req.ID == "" {
response.WriteJSON(w, response.ErrDefault("请求参数错误"))
return
}
user, err := h.repo.GetUserByID(userID)
if err != nil || user == nil || user.Status != 1 {
response.WriteJSON(w, response.ErrDefault("账号不可用"))
return
}
if ok, _ := security.VerifyPassword(user.Pwd, req.Password); !ok {
response.WriteJSON(w, response.ErrDefault("当前密码错误"))
return
}
deleted, err := h.repo.DeletePasskey(userID, req.ID)
if err != nil || !deleted {
response.WriteJSON(w, response.ErrDefault("通行证密钥不存在"))
return
}
response.WriteJSON(w, response.OK(nil))
}
@@ -0,0 +1,29 @@
package handler
import (
"testing"
"time"
"github.com/go-webauthn/webauthn/webauthn"
)
func TestPasskeyChallengeExpiresAndIsSingleUse(t *testing.T) {
h := &Handler{passkeyPending: make(map[string]passkeyCeremony)}
id, ok := h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(time.Minute)}})
if !ok {
t.Fatal("could not create challenge")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); !ok {
t.Fatal("valid challenge was rejected")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok {
t.Fatal("challenge was reusable")
}
id, ok = h.putPasskeyCeremony(passkeyCeremony{userID: 7, kind: "login", origin: "https://panel.example.test", session: webauthn.SessionData{Expires: time.Now().Add(-time.Second)}})
if !ok {
t.Fatal("could not create expired challenge")
}
if _, ok := h.takePasskeyCeremony(id, "login", "https://panel.example.test", 7); ok {
t.Fatal("expired challenge was accepted")
}
}
@@ -116,6 +116,8 @@ func shouldSkip(path string) bool {
return false
case path == "/api/v1/user/login":
return true
case path == "/api/v1/user/passkey/status" || path == "/api/v1/user/passkey/login/begin" || path == "/api/v1/user/passkey/login/finish":
return true
case path == "/api/v1/public/config/get":
return true
case path == "/api/v1/federation/connect":
@@ -0,0 +1,13 @@
package model
// Passkey stores a WebAuthn credential for exactly one panel user.
type Passkey struct {
ID string `gorm:"primaryKey;type:varchar(512)"`
UserID int64 `gorm:"column:user_id;not null;index"`
Name string `gorm:"type:varchar(100);not null"`
CredentialJSON string `gorm:"column:credential_json;type:text;not null"`
CreatedAt int64 `gorm:"column:created_at;not null"`
LastUsedAt int64 `gorm:"column:last_used_at;not null;default:0"`
}
func (Passkey) TableName() string { return "passkey" }
@@ -288,6 +288,7 @@ func autoMigrateAll(db *gorm.DB) error {
models := []interface{}{
&model.User{},
&model.Passkey{},
&model.UserQuota{},
&model.Forward{},
&model.ForwardPort{},
@@ -156,6 +156,9 @@ func (r *Repository) DeleteUserCascade(userID int64) error {
return errors.New("repository not initialized")
}
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Passkey{}).Error; err != nil {
return err
}
forwardIDs := tx.Model(&model.Forward{}).Select("id").Where("user_id = ?", userID)
if err := tx.Where("forward_id IN (?)", forwardIDs).Delete(&model.ForwardPort{}).Error; err != nil {
return err
@@ -0,0 +1,40 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
"gorm.io/gorm"
)
func (r *Repository) ListPasskeys(userID int64) ([]model.Passkey, error) {
var keys []model.Passkey
err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&keys).Error
return keys, err
}
func (r *Repository) GetPasskey(userID int64, id string) (*model.Passkey, error) {
var key model.Passkey
err := r.db.Where("user_id = ? AND id = ?", userID, id).Take(&key).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return &key, err
}
func (r *Repository) CreatePasskey(key *model.Passkey) error {
return r.db.Create(key).Error
}
func (r *Repository) DeletePasskey(userID int64, id string) (bool, error) {
result := r.db.Where("user_id = ? AND id = ?", userID, id).Delete(&model.Passkey{})
return result.RowsAffected == 1, result.Error
}
// Compare-and-swap prevents a concurrent assertion from reusing an old sign counter.
func (r *Repository) UpdatePasskeyCredential(userID int64, id, oldJSON, newJSON string, now int64) (bool, error) {
result := r.db.Model(&model.Passkey{}).
Where("user_id = ? AND id = ? AND credential_json = ?", userID, id, oldJSON).
Updates(map[string]interface{}{"credential_json": newJSON, "last_used_at": now})
return result.RowsAffected == 1, result.Error
}
@@ -0,0 +1,201 @@
package contract_test
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/fxamacker/cbor/v2"
"go-backend/internal/auth"
"go-backend/internal/http/response"
)
const passkeyTestOrigin = "https://panel.example.test"
func passkeyPost(t *testing.T, router http.Handler, path, token string, body interface{}) response.R {
t.Helper()
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var out response.R
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return out
}
func passkeyData(t *testing.T, out response.R) map[string]interface{} {
t.Helper()
if out.Code != 0 {
t.Fatalf("unexpected response: code=%d msg=%s", out.Code, out.Msg)
}
return out.Data.(map[string]interface{})
}
func passkeyChallenge(t *testing.T, data map[string]interface{}) string {
t.Helper()
options := data["options"].(map[string]interface{})
return options["publicKey"].(map[string]interface{})["challenge"].(string)
}
func TestPasskeyConfigurationFailsClosedAndPasswordLoginStillWorks(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", "https://panel.example.test/path")
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9301, "passkey-disabled", "test-password")
status := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/status", "", map[string]string{}))
if status["enabled"] != false {
t.Fatalf("invalid origin enabled passkeys: %v", status)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-disabled"}); result.Code == 0 {
t.Fatal("login begin succeeded with invalid origin")
}
if result := passkeyPost(t, router, "/api/v1/user/login", "", map[string]string{"username": "passkey-disabled", "password": "test-password"}); result.Code != 0 {
t.Fatalf("password login regressed: %s", result.Msg)
}
}
func TestPasskeyRegistrationLoginAndOwnership(t *testing.T) {
t.Setenv("FLVX_WEBAUTHN_ORIGIN", passkeyTestOrigin)
router, r := setupContractRouter(t, "passkey-test-secret")
seedLegacyUser(t, r, 9302, "passkey-alice", "alice-password")
seedLegacyUser(t, r, 9303, "passkey-bob", "bob-password")
aliceToken, _ := auth.GenerateToken(9302, "passkey-alice", 1, "passkey-test-secret")
bobToken, _ := auth.GenerateToken(9303, "passkey-bob", 1, "passkey-test-secret")
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/begin", "", map[string]string{"password": "alice-password"}); result.Code == 0 {
t.Fatal("unauthenticated registration was allowed")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "wrong"}); result.Code == 0 {
t.Fatal("registration did not require password re-verification")
}
begin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", bobToken, map[string]interface{}{"sessionId": begin["sessionId"], "credential": map[string]string{}}); result.Code == 0 {
t.Fatal("another user completed Alice's registration")
}
// The failed cross-user attempt consumes the challenge.
begin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/register/begin", aliceToken, map[string]string{"password": "alice-password"}))
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
credentialID := make([]byte, 32)
if _, err := rand.Read(credentialID); err != nil {
t.Fatal(err)
}
registerResponse := makeRegistrationResponse(t, privateKey, credentialID, passkeyChallenge(t, begin), passkeyTestOrigin)
finishBody := map[string]interface{}{"sessionId": begin["sessionId"], "credential": registerResponse, "name": "Laptop"}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, finishBody); result.Code != 0 {
t.Fatalf("register: %s", result.Msg)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/register/finish", aliceToken, finishBody); result.Code == 0 {
t.Fatal("registration challenge was reusable")
}
keyID := base64.RawURLEncoding.EncodeToString(credentialID)
if items := passkeyPost(t, router, "/api/v1/user/passkey/list", bobToken, map[string]string{}).Data.([]interface{}); len(items) != 0 {
t.Fatal("Alice's credential appeared in Bob's list")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", bobToken, map[string]string{"id": keyID, "password": "bob-password"}); result.Code == 0 {
t.Fatal("Bob deleted Alice's credential")
}
loginBegin := passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
bad := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), "https://wrong.example.test", "panel.example.test", true, 1)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": bad}); result.Code == 0 {
t.Fatal("wrong origin was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
assertion := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1)
loginBody := map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": assertion}
loginResult := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody)
if passkeyData(t, loginResult)["token"] == "" {
t.Fatal("passkey login returned no JWT")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", loginBody); result.Code == 0 {
t.Fatal("login challenge was reusable")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
badRP := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "wrong.example.test", true, 2)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": badRP}); result.Code == 0 {
t.Fatal("wrong RP ID was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
noUV := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", false, 2)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": noUV}); result.Code == 0 {
t.Fatal("assertion without user verification was accepted")
}
loginBegin = passkeyData(t, passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}))
reusedCounter := makeAssertionResponse(t, privateKey, credentialID, passkeyChallenge(t, loginBegin), passkeyTestOrigin, "panel.example.test", true, 1)
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/finish", "", map[string]interface{}{"sessionId": loginBegin["sessionId"], "credential": reusedCounter}); result.Code == 0 {
t.Fatal("reused nonzero signature counter was accepted")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "wrong"}); result.Code == 0 {
t.Fatal("delete did not require password re-verification")
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/delete", aliceToken, map[string]string{"id": keyID, "password": "alice-password"}); result.Code != 0 {
t.Fatalf("delete: %s", result.Msg)
}
if result := passkeyPost(t, router, "/api/v1/user/passkey/login/begin", "", map[string]string{"username": "passkey-alice"}); result.Code == 0 {
t.Fatal("deleted credential could still start login")
}
}
func makeRegistrationResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin string) map[string]interface{} {
t.Helper()
pub := privateKey.PublicKey
cose, err := cbor.Marshal(map[int]interface{}{1: 2, 3: -7, -1: 1, -2: pub.X.FillBytes(make([]byte, 32)), -3: pub.Y.FillBytes(make([]byte, 32))})
if err != nil {
t.Fatal(err)
}
rpHash := sha256.Sum256([]byte("panel.example.test"))
authData := append([]byte{}, rpHash[:]...)
authData = append(authData, 0x45, 0, 0, 0, 0)
authData = append(authData, make([]byte, 16)...)
length := make([]byte, 2)
binary.BigEndian.PutUint16(length, uint16(len(id)))
authData = append(authData, length...)
authData = append(authData, id...)
authData = append(authData, cose...)
attestation, err := cbor.Marshal(map[string]interface{}{"fmt": "none", "authData": authData, "attStmt": map[string]interface{}{}})
if err != nil {
t.Fatal(err)
}
client, _ := json.Marshal(map[string]interface{}{"type": "webauthn.create", "challenge": challenge, "origin": origin})
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"attestationObject": base64.RawURLEncoding.EncodeToString(attestation), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client)}}
}
func makeAssertionResponse(t *testing.T, privateKey *ecdsa.PrivateKey, id []byte, challenge, origin, rpID string, verified bool, count uint32) map[string]interface{} {
t.Helper()
rpHash := sha256.Sum256([]byte(rpID))
authData := append([]byte{}, rpHash[:]...)
flags := byte(0x01)
if verified {
flags |= 0x04
}
authData = append(authData, flags, 0, 0, 0, 0)
binary.BigEndian.PutUint32(authData[33:37], count)
client, _ := json.Marshal(map[string]interface{}{"type": "webauthn.get", "challenge": challenge, "origin": origin})
clientHash := sha256.Sum256(client)
signed := append(append([]byte{}, authData...), clientHash[:]...)
hash := sha256.Sum256(signed)
signature, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:])
if err != nil {
t.Fatal(err)
}
return map[string]interface{}{"id": base64.RawURLEncoding.EncodeToString(id), "rawId": base64.RawURLEncoding.EncodeToString(id), "type": "public-key", "response": map[string]interface{}{"authenticatorData": base64.RawURLEncoding.EncodeToString(authData), "clientDataJSON": base64.RawURLEncoding.EncodeToString(client), "signature": base64.RawURLEncoding.EncodeToString(signature), "userHandle": nil}}
}
+38
View File
@@ -70,6 +70,44 @@ export interface LoginResponse {
export const login = (data: LoginData) =>
Network.post<LoginResponse>("/user/login", data);
export interface PasskeyOptions {
sessionId: string;
options: { publicKey: Record<string, unknown> };
}
export interface PasskeyItem {
id: string;
name: string;
createdAt: number;
lastUsedAt: number;
}
export const getPasskeyStatus = () =>
Network.post<{ enabled: boolean }>("/user/passkey/status");
export const beginPasskeyLogin = (username: string) =>
Network.post<PasskeyOptions>("/user/passkey/login/begin", { username });
export const finishPasskeyLogin = (sessionId: string, credential: unknown) =>
Network.post<LoginResponse>("/user/passkey/login/finish", {
sessionId,
credential,
});
export const beginPasskeyRegistration = (password: string) =>
Network.post<PasskeyOptions>("/user/passkey/register/begin", { password });
export const finishPasskeyRegistration = (
sessionId: string,
credential: unknown,
name: string,
) =>
Network.post("/user/passkey/register/finish", {
sessionId,
credential,
name,
});
export const listPasskeys = () =>
Network.post<PasskeyItem[]>("/user/passkey/list");
export const deletePasskey = (id: string, password: string) =>
Network.post("/user/passkey/delete", { id, password });
// 用户CRUD操作 - 全部使用POST请求
export const createUser = (data: UserMutationPayload) =>
Network.post("/user/create", data);
@@ -0,0 +1,168 @@
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input";
import {
beginPasskeyRegistration,
deletePasskey,
finishPasskeyRegistration,
getPasskeyStatus,
listPasskeys,
type PasskeyItem,
} from "@/api";
import { createPasskey } from "@/utils/passkey";
export function PasskeyManager() {
const [enabled, setEnabled] = useState(false);
const [items, setItems] = useState<PasskeyItem[]>([]);
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const refresh = () =>
listPasskeys()
.then((res) => {
if (res.code === 0) setItems(res.data || []);
})
.catch(() => setItems([]));
useEffect(() => {
if (
!window.isSecureContext ||
typeof window.PublicKeyCredential === "undefined"
)
return;
getPasskeyStatus()
.then((res) => {
if (res.code === 0 && res.data.enabled) {
setEnabled(true);
void refresh();
}
})
.catch(() => setEnabled(false));
}, []);
if (!enabled) return null;
const register = async () => {
if (!password) {
toast.error("请输入当前密码");
return;
}
setBusy(true);
try {
const begin = await beginPasskeyRegistration(password);
if (begin.code !== 0) {
toast.error(begin.msg || "无法绑定通行证密钥");
return;
}
const credential = await createPasskey(begin.data.options);
const finish = await finishPasskeyRegistration(
begin.data.sessionId,
credential,
name.trim(),
);
if (finish.code !== 0) {
toast.error(finish.msg || "绑定失败");
return;
}
toast.success("通行证密钥已绑定");
setPassword("");
setName("");
await refresh();
} catch {
toast.error("绑定已取消或失败");
} finally {
setBusy(false);
}
};
const remove = async (id: string) => {
if (!password) {
toast.error("请输入当前密码以删除密钥");
return;
}
setBusy(true);
try {
const result = await deletePasskey(id, password);
if (result.code !== 0) {
toast.error(result.msg || "删除失败");
return;
}
toast.success("通行证密钥已删除");
setPassword("");
await refresh();
} catch {
toast.error("删除失败");
} finally {
setBusy(false);
}
};
return (
<Card>
<CardBody className="p-4 space-y-4">
<div>
<h3 className="text-base font-medium">通行证密钥</h3>
<p className="text-sm text-default-500">
绑定后可使用设备解锁登录,无需 Cloudflare
验证。绑定和删除均需输入当前密码。
</p>
</div>
<Input
label="当前密码"
type="password"
value={password}
variant="bordered"
onChange={(e) => setPassword(e.target.value)}
/>
<Input
label="密钥名称(可选)"
value={name}
variant="bordered"
onChange={(e) => setName(e.target.value)}
/>
<Button disabled={busy} onPress={register}>
绑定通行证密钥
</Button>
<div className="space-y-2">
{items.map((item) => (
<div
key={item.id}
className="flex items-center justify-between gap-3 rounded-lg border border-default-200 p-3"
>
<div>
<div className="text-sm font-medium">{item.name}</div>
<div className="text-xs text-default-500">
创建于 {new Date(item.createdAt).toLocaleDateString()}{" "}
{item.lastUsedAt
? ` · 最近使用 ${new Date(item.lastUsedAt).toLocaleDateString()}`
: ""}
</div>
</div>
<Button
color="danger"
disabled={busy}
size="sm"
variant="light"
onPress={() => void remove(item.id)}
>
删除
</Button>
</div>
))}
</div>
</CardBody>
</Card>
);
}
+68 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Turnstile } from "@marsidev/react-turnstile";
@@ -10,8 +10,17 @@ import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer";
import { BrandLogo } from "@/components/brand-logo";
import { login, LoginData, checkCaptcha, getPublicConfigByName } from "@/api";
import {
login,
LoginData,
checkCaptcha,
getPublicConfigByName,
getPasskeyStatus,
beginPasskeyLogin,
finishPasskeyLogin,
} from "@/api";
import { writeLoginSession } from "@/utils/session";
import { getPasskey } from "@/utils/passkey";
import { useWebViewMode } from "@/hooks/useWebViewMode";
interface LoginForm {
@@ -30,9 +39,56 @@ export default function IndexPage() {
const [errors, setErrors] = useState<Partial<LoginForm>>({});
const [showCaptcha, setShowCaptcha] = useState(false);
const [siteKey, setSiteKey] = useState("");
const [passkeyEnabled, setPasskeyEnabled] = useState(false);
const navigate = useNavigate();
const isWebView = useWebViewMode();
useEffect(() => {
if (
window.isSecureContext &&
typeof window.PublicKeyCredential !== "undefined"
) {
getPasskeyStatus()
.then((res) => setPasskeyEnabled(res.code === 0 && res.data.enabled))
.catch(() => setPasskeyEnabled(false));
}
}, []);
const handlePasskeyLogin = async () => {
if (!form.username.trim()) {
toast.error("请输入用户名");
return;
}
setLoading(true);
try {
const begin = await beginPasskeyLogin(form.username.trim());
if (begin.code !== 0) {
toast.error(begin.msg || "无法使用通行证密钥登录");
return;
}
const credential = await getPasskey(begin.data.options);
const result = await finishPasskeyLogin(begin.data.sessionId, credential);
if (result.code !== 0) {
toast.error(result.msg || "通行证密钥登录失败");
return;
}
writeLoginSession(result.data);
toast.success("登录成功");
navigate(
result.data.requirePasswordChange ? "/change-password" : "/dashboard",
);
} catch {
toast.error("通行证密钥登录已取消或失败");
} finally {
setLoading(false);
}
};
// 验证表单
const validateForm = (): boolean => {
const newErrors: Partial<LoginForm> = {};
@@ -221,6 +277,16 @@ export default function IndexPage() {
: "Signing in..."
: "Sign In"}
</Button>
{passkeyEnabled && (
<Button
className="h-12 rounded-xl"
disabled={loading}
variant="bordered"
onPress={handlePasskeyLogin}
>
使用通行证密钥登录
</Button>
)}
</div>
</CardBody>
</Card>
+2
View File
@@ -19,6 +19,7 @@ import { VersionFooter } from "@/components/version-footer";
import { updatePassword } from "@/api";
import { safeLogout } from "@/utils/logout";
import { getAdminFlag, getSessionName } from "@/utils/session";
import { PasskeyManager } from "@/components/passkey-manager";
interface PasswordForm {
newUsername: string;
currentPassword: string;
@@ -241,6 +242,7 @@ export default function ProfilePage() {
</Card>
{/* 功能网格 */}
<PasskeyManager />
<Card>
<CardBody className="p-4">
<div className="grid grid-cols-3 gap-3">
+97
View File
@@ -0,0 +1,97 @@
// Convert the WebAuthn JSON wire format to the browser's ArrayBuffer format.
function decode(value: string): ArrayBuffer {
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(padded), (character) =>
character.charCodeAt(0),
);
return bytes.buffer;
}
function encode(value: ArrayBuffer): string {
const bytes = new Uint8Array(value);
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export async function createPasskey(options: {
publicKey: Record<string, unknown>;
}) {
const source = options.publicKey as unknown as {
challenge: string;
user: { id: string };
excludeCredentials?: Array<{ id: string }>;
};
const publicKey: PublicKeyCredentialCreationOptions = {
...(options.publicKey as unknown as PublicKeyCredentialCreationOptions),
challenge: decode(source.challenge),
user: {
...(source.user as unknown as PublicKeyCredentialUserEntity),
id: decode(source.user.id),
},
excludeCredentials: source.excludeCredentials?.map((item) => ({
...(item as unknown as PublicKeyCredentialDescriptor),
id: decode(item.id),
})),
};
const credential = (await navigator.credentials.create({
publicKey,
})) as PublicKeyCredential | null;
if (!credential) throw new Error("未创建通行证密钥");
const result = credential.response as AuthenticatorAttestationResponse;
return {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
attestationObject: encode(result.attestationObject),
clientDataJSON: encode(result.clientDataJSON),
transports: result.getTransports?.() || [],
},
};
}
export async function getPasskey(options: {
publicKey: Record<string, unknown>;
}) {
const source = options.publicKey as unknown as {
challenge: string;
allowCredentials?: Array<{ id: string }>;
};
const publicKey: PublicKeyCredentialRequestOptions = {
...(options.publicKey as unknown as PublicKeyCredentialRequestOptions),
challenge: decode(source.challenge),
allowCredentials: source.allowCredentials?.map((item) => ({
...(item as unknown as PublicKeyCredentialDescriptor),
id: decode(item.id),
})),
};
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null;
if (!credential) throw new Error("未选择通行证密钥");
const result = credential.response as AuthenticatorAssertionResponse;
return {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
authenticatorData: encode(result.authenticatorData),
clientDataJSON: encode(result.clientDataJSON),
signature: encode(result.signature),
userHandle: result.userHandle ? encode(result.userHandle) : null,
},
};
}