diff --git a/README.md b/README.md index 90c39d8..af8d5ec 100644 --- a/README.md +++ b/README.md @@ -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` 安装面板,推荐直接使用脚本菜单一键迁移: diff --git a/docker-compose-v4.yml b/docker-compose-v4.yml index c35b65c..25fbcfa 100644 --- a/docker-compose-v4.yml +++ b/docker-compose-v4.yml @@ -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} diff --git a/docker-compose-v6.yml b/docker-compose-v6.yml index 07cf2f7..ff04019 100644 --- a/docker-compose-v6.yml +++ b/docker-compose-v6.yml @@ -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} diff --git a/go-backend/go.mod b/go-backend/go.mod index d44fdb0..35f8637 100644 --- a/go-backend/go.mod +++ b/go-backend/go.mod @@ -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 diff --git a/go-backend/go.sum b/go-backend/go.sum index 655654d..76ce5ae 100644 --- a/go-backend/go.sum +++ b/go-backend/go.sum @@ -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= diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 4cfcfe9..98ef165 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -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) diff --git a/go-backend/internal/http/handler/passkey.go b/go-backend/internal/http/handler/passkey.go new file mode 100644 index 0000000..1d12ddf --- /dev/null +++ b/go-backend/internal/http/handler/passkey.go @@ -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)) +} diff --git a/go-backend/internal/http/handler/passkey_test.go b/go-backend/internal/http/handler/passkey_test.go new file mode 100644 index 0000000..8d27cdd --- /dev/null +++ b/go-backend/internal/http/handler/passkey_test.go @@ -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") + } +} diff --git a/go-backend/internal/http/middleware/auth.go b/go-backend/internal/http/middleware/auth.go index 4ab1a50..36e411c 100644 --- a/go-backend/internal/http/middleware/auth.go +++ b/go-backend/internal/http/middleware/auth.go @@ -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": diff --git a/go-backend/internal/store/model/passkey.go b/go-backend/internal/store/model/passkey.go new file mode 100644 index 0000000..ab31d96 --- /dev/null +++ b/go-backend/internal/store/model/passkey.go @@ -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" } diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index ef86b29..17b9d5f 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -288,6 +288,7 @@ func autoMigrateAll(db *gorm.DB) error { models := []interface{}{ &model.User{}, + &model.Passkey{}, &model.UserQuota{}, &model.Forward{}, &model.ForwardPort{}, diff --git a/go-backend/internal/store/repo/repository_mutations.go b/go-backend/internal/store/repo/repository_mutations.go index 34e0bdb..9755108 100644 --- a/go-backend/internal/store/repo/repository_mutations.go +++ b/go-backend/internal/store/repo/repository_mutations.go @@ -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 diff --git a/go-backend/internal/store/repo/repository_passkey.go b/go-backend/internal/store/repo/repository_passkey.go new file mode 100644 index 0000000..f475dd2 --- /dev/null +++ b/go-backend/internal/store/repo/repository_passkey.go @@ -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 +} diff --git a/go-backend/tests/contract/passkey_contract_test.go b/go-backend/tests/contract/passkey_contract_test.go new file mode 100644 index 0000000..a73a936 --- /dev/null +++ b/go-backend/tests/contract/passkey_contract_test.go @@ -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}} +} diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index c97ddc4..8abd148 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -70,6 +70,44 @@ export interface LoginResponse { export const login = (data: LoginData) => Network.post("/user/login", data); +export interface PasskeyOptions { + sessionId: string; + options: { publicKey: Record }; +} + +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("/user/passkey/login/begin", { username }); +export const finishPasskeyLogin = (sessionId: string, credential: unknown) => + Network.post("/user/passkey/login/finish", { + sessionId, + credential, + }); +export const beginPasskeyRegistration = (password: string) => + Network.post("/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("/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); diff --git a/vite-frontend/src/components/passkey-manager.tsx b/vite-frontend/src/components/passkey-manager.tsx new file mode 100644 index 0000000..b93564b --- /dev/null +++ b/vite-frontend/src/components/passkey-manager.tsx @@ -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([]); + 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 ( + + +
+

通行证密钥

+

+ 绑定后可使用设备解锁登录,无需 Cloudflare + 验证。绑定和删除均需输入当前密码。 +

+
+ setPassword(e.target.value)} + /> + setName(e.target.value)} + /> + +
+ {items.map((item) => ( +
+
+
{item.name}
+
+ 创建于 {new Date(item.createdAt).toLocaleDateString()}{" "} + {item.lastUsedAt + ? ` · 最近使用 ${new Date(item.lastUsedAt).toLocaleDateString()}` + : ""} +
+
+ +
+ ))} +
+
+
+ ); +} diff --git a/vite-frontend/src/pages/index.tsx b/vite-frontend/src/pages/index.tsx index a4d5644..250ec0e 100644 --- a/vite-frontend/src/pages/index.tsx +++ b/vite-frontend/src/pages/index.tsx @@ -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>({}); 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 = {}; @@ -221,6 +277,16 @@ export default function IndexPage() { : "Signing in..." : "Sign In"} + {passkeyEnabled && ( + + )} diff --git a/vite-frontend/src/pages/profile.tsx b/vite-frontend/src/pages/profile.tsx index aa69c48..02b9ed4 100644 --- a/vite-frontend/src/pages/profile.tsx +++ b/vite-frontend/src/pages/profile.tsx @@ -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() { {/* 功能网格 */} +
diff --git a/vite-frontend/src/utils/passkey.ts b/vite-frontend/src/utils/passkey.ts new file mode 100644 index 0000000..059460c --- /dev/null +++ b/vite-frontend/src/utils/passkey.ts @@ -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; +}) { + 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; +}) { + 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, + }, + }; +}