Passkey: 账户设置绑定 + 免密登录(WebAuthn)
- 引入 go-webauthn, Passkey 表存凭据, challenge 会话内存存储(带过期) - API: /webauthn/register|login begin/complete, /webauthn/passkeys 列表/删除 - 配置 OT_WEBAUTHN_RP_ID/RP_ORIGIN/RP_NAME;登录成功发 JWT+refresh cookie - 前端 lib/webauthn(编解码+凭据序列化+安全上下文检测), 账户设置绑定区, 登录页免密按钮 - 需 HTTPS 或 localhost(安全上下文) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,11 @@ OT_PROXY_HEALTH_FAIL_THRESHOLD=2
|
|||||||
# 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制)
|
# 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制)
|
||||||
OT_RATELIMIT_USER_RPS=20
|
OT_RATELIMIT_USER_RPS=20
|
||||||
|
|
||||||
|
# Passkey(WebAuthn):RPID 为域名,RPOrigin 为前端来源(需 HTTPS 或 localhost)
|
||||||
|
OT_WEBAUTHN_RP_ID=localhost
|
||||||
|
OT_WEBAUTHN_RP_ORIGIN=http://localhost:5173
|
||||||
|
OT_WEBAUTHN_RP_NAME=openteam
|
||||||
|
|
||||||
# 初始管理员(仅首次创建生效)
|
# 初始管理员(仅首次创建生效)
|
||||||
OT_ADMIN_USERNAME=admin
|
OT_ADMIN_USERNAME=admin
|
||||||
OT_ADMIN_EMAIL=admin@localhost
|
OT_ADMIN_EMAIL=admin@localhost
|
||||||
|
|||||||
+9
-2
@@ -5,6 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
@@ -19,16 +20,19 @@ require (
|
|||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.2.6 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||||
@@ -42,6 +46,7 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
@@ -51,8 +56,10 @@ require (
|
|||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
|||||||
+20
-4
@@ -15,6 +15,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
@@ -33,8 +35,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||||
|
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||||
|
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
@@ -43,11 +49,15 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||||
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -81,6 +91,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
@@ -118,10 +130,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/openteam/server/internal/api/middleware"
|
"github.com/openteam/server/internal/api/middleware"
|
||||||
"github.com/openteam/server/internal/app"
|
"github.com/openteam/server/internal/app"
|
||||||
|
"github.com/openteam/server/internal/passkey"
|
||||||
"github.com/openteam/server/internal/pkg/resp"
|
"github.com/openteam/server/internal/pkg/resp"
|
||||||
"github.com/openteam/server/internal/proxy"
|
"github.com/openteam/server/internal/proxy"
|
||||||
"github.com/openteam/server/internal/store"
|
"github.com/openteam/server/internal/store"
|
||||||
@@ -17,11 +18,14 @@ import (
|
|||||||
|
|
||||||
// Handler 聚合所有管理 API。
|
// Handler 聚合所有管理 API。
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
a *app.App
|
a *app.App
|
||||||
gw *proxy.Gateway
|
gw *proxy.Gateway
|
||||||
|
passkeys *passkey.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(a *app.App, gw *proxy.Gateway) *Handler { return &Handler{a: a, gw: gw} }
|
func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler {
|
||||||
|
return &Handler{a: a, gw: gw, passkeys: pk}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 认证
|
// 认证
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/openteam/server/internal/pkg/resp"
|
||||||
|
"github.com/openteam/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasskeyRegisterBegin POST /api/v1/webauthn/register/begin — 生成注册选项。
|
||||||
|
func (h *Handler) PasskeyRegisterBegin(c *gin.Context) {
|
||||||
|
u := sessionUser(c)
|
||||||
|
creation, err := h.passkeys.BeginRegistration(u)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin registration: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"creation": creation, "challenge": creation.Response.Challenge})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyRegisterComplete POST /api/v1/webauthn/register/complete — 校验并保存凭据。
|
||||||
|
func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
|
||||||
|
u := sessionUser(c)
|
||||||
|
var req struct {
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, []byte(req.Name)); err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "passkey 注册失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyLoginBegin POST /api/v1/webauthn/login/begin — 生成断言选项。
|
||||||
|
// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。
|
||||||
|
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
if req.Username != "" {
|
||||||
|
var u store.User
|
||||||
|
err := h.a.DB.Where("username = ? OR email = ?", req.Username, req.Username).First(&u).Error
|
||||||
|
if err != nil || u.Status != store.UserStatusActive {
|
||||||
|
resp.Fail(c, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertion, err := h.passkeys.BeginLogin(&u)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertion, err := h.passkeys.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyLoginComplete POST /api/v1/webauthn/login/complete — 校验断言并发放令牌。
|
||||||
|
func (h *Handler) PasskeyLoginComplete(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var u *store.User
|
||||||
|
if req.UserID > 0 {
|
||||||
|
var su store.User
|
||||||
|
if err := h.a.DB.First(&su, req.UserID).Error; err != nil {
|
||||||
|
resp.Fail(c, http.StatusUnauthorized, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u = &su
|
||||||
|
if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil {
|
||||||
|
resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Status != store.UserStatusActive {
|
||||||
|
resp.Fail(c, http.StatusForbidden, "user account disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh")
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.setRefreshCookie(c, refresh)
|
||||||
|
resp.OK(c, gin.H{
|
||||||
|
"access_token": access,
|
||||||
|
"expires_in": int(h.a.JWT.AccessTTL().Seconds()),
|
||||||
|
"user": h.publicUser(u),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyList GET /api/v1/webauthn/passkeys — 当前用户的 passkey 列表。
|
||||||
|
func (h *Handler) PasskeyList(c *gin.Context) {
|
||||||
|
u := sessionUser(c)
|
||||||
|
pks, err := h.passkeys.List(u.ID)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to load passkeys")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(pks))
|
||||||
|
for _, pk := range pks {
|
||||||
|
out = append(out, gin.H{"id": pk.ID, "name": pk.Name, "created_at": pk.CreatedAt})
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"items": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyDelete DELETE /api/v1/webauthn/passkeys/:id — 解除绑定。
|
||||||
|
func (h *Handler) PasskeyDelete(c *gin.Context) {
|
||||||
|
u := sessionUser(c)
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
resp.Fail(c, http.StatusBadRequest, "invalid passkey id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.passkeys.Delete(u.ID, id); err != nil {
|
||||||
|
resp.Fail(c, http.StatusNotFound, "passkey not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
|||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||||
|
|
||||||
h := NewHandler(a, gw)
|
h := NewHandler(a, gw, a.Passkeys)
|
||||||
|
|
||||||
// --- 代理端点(对外)---
|
// --- 代理端点(对外)---
|
||||||
proxyGroup := r.Group("/v1")
|
proxyGroup := r.Group("/v1")
|
||||||
@@ -67,6 +67,16 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
|||||||
auth.GET("/me", middleware.SessionAuth(a), h.Me)
|
auth.GET("/me", middleware.SessionAuth(a), h.Me)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
webauthn := api.Group("/webauthn")
|
||||||
|
{
|
||||||
|
webauthn.POST("/register/begin", middleware.SessionAuth(a), h.PasskeyRegisterBegin)
|
||||||
|
webauthn.POST("/register/complete", middleware.SessionAuth(a), h.PasskeyRegisterComplete)
|
||||||
|
webauthn.POST("/login/begin", h.PasskeyLoginBegin)
|
||||||
|
webauthn.POST("/login/complete", h.PasskeyLoginComplete)
|
||||||
|
webauthn.GET("/passkeys", middleware.SessionAuth(a), h.PasskeyList)
|
||||||
|
webauthn.DELETE("/passkeys/:id", middleware.SessionAuth(a), h.PasskeyDelete)
|
||||||
|
}
|
||||||
|
|
||||||
user := api.Group("", middleware.SessionAuth(a))
|
user := api.Group("", middleware.SessionAuth(a))
|
||||||
{
|
{
|
||||||
user.GET("/user/profile", h.UserProfile)
|
user.GET("/user/profile", h.UserProfile)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/openteam/server/internal/channel"
|
"github.com/openteam/server/internal/channel"
|
||||||
"github.com/openteam/server/internal/config"
|
"github.com/openteam/server/internal/config"
|
||||||
|
"github.com/openteam/server/internal/passkey"
|
||||||
"github.com/openteam/server/internal/pkg/crypto"
|
"github.com/openteam/server/internal/pkg/crypto"
|
||||||
"github.com/openteam/server/internal/pkg/jwt"
|
"github.com/openteam/server/internal/pkg/jwt"
|
||||||
"github.com/openteam/server/internal/pkg/ratelimit"
|
"github.com/openteam/server/internal/pkg/ratelimit"
|
||||||
@@ -25,6 +26,7 @@ type App struct {
|
|||||||
Usage *usage.Recorder
|
Usage *usage.Recorder
|
||||||
Health *channel.HealthMonitor
|
Health *channel.HealthMonitor
|
||||||
Limit *ratelimit.Limiter
|
Limit *ratelimit.Limiter
|
||||||
|
Passkeys *passkey.Service
|
||||||
startedAt time.Time
|
startedAt time.Time
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -57,6 +59,13 @@ func New(cfg *config.Config) (*App, error) {
|
|||||||
FailThreshold: cfg.Proxy.HealthFailThreshold,
|
FailThreshold: cfg.Proxy.HealthFailThreshold,
|
||||||
})
|
})
|
||||||
a.Health.Start(a.ctx)
|
a.Health.Start(a.ctx)
|
||||||
|
|
||||||
|
a.Passkeys, err = passkey.New(db, passkey.Config{
|
||||||
|
RPID: cfg.WebAuthn.RPID, Origin: cfg.WebAuthn.RPOrigin, Name: cfg.WebAuthn.RPName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,17 @@ type Config struct {
|
|||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
Proxy ProxyConfig
|
Proxy ProxyConfig
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
|
WebAuthn WebAuthnConfig
|
||||||
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
|
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebAuthnConfig Passkey(WebAuthn)配置。
|
||||||
|
type WebAuthnConfig struct {
|
||||||
|
RPID string // Relying Party ID(域名,如 localhost)
|
||||||
|
RPOrigin string // 前端来源,如 http://localhost:5173
|
||||||
|
RPName string // 展示名
|
||||||
|
}
|
||||||
|
|
||||||
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
|
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
|
||||||
type RateLimitConfig struct {
|
type RateLimitConfig struct {
|
||||||
UserRPS int // 用户级每秒请求数上限(0=不限制)
|
UserRPS int // 用户级每秒请求数上限(0=不限制)
|
||||||
@@ -124,6 +132,10 @@ func Load() (*Config, error) {
|
|||||||
|
|
||||||
v.SetDefault("ratelimit.user_rps", 20)
|
v.SetDefault("ratelimit.user_rps", 20)
|
||||||
|
|
||||||
|
v.SetDefault("webauthn.rp_id", "localhost")
|
||||||
|
v.SetDefault("webauthn.rp_origin", "http://localhost:5173")
|
||||||
|
v.SetDefault("webauthn.rp_name", "openteam")
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Env: v.GetString("env"),
|
Env: v.GetString("env"),
|
||||||
Port: v.GetInt("port"),
|
Port: v.GetInt("port"),
|
||||||
@@ -160,6 +172,11 @@ func Load() (*Config, error) {
|
|||||||
RateLimit: RateLimitConfig{
|
RateLimit: RateLimitConfig{
|
||||||
UserRPS: v.GetInt("ratelimit.user_rps"),
|
UserRPS: v.GetInt("ratelimit.user_rps"),
|
||||||
},
|
},
|
||||||
|
WebAuthn: WebAuthnConfig{
|
||||||
|
RPID: v.GetString("webauthn.rp_id"),
|
||||||
|
RPOrigin: v.GetString("webauthn.rp_origin"),
|
||||||
|
RPName: v.GetString("webauthn.rp_name"),
|
||||||
|
},
|
||||||
Master: v.GetString("master_key"),
|
Master: v.GetString("master_key"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Package passkey 封装 WebAuthn(passkey)注册与登录。
|
||||||
|
// 依赖 go-webauthn 库;RPID/RPOrigin 由配置提供。
|
||||||
|
package passkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/openteam/server/internal/store"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
RPID string
|
||||||
|
Origin string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service WebAuthn 服务:凭据存储 + challenge 会话(内存)。
|
||||||
|
type Service struct {
|
||||||
|
wa *webauthn.WebAuthn
|
||||||
|
db *gorm.DB
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]webauthn.SessionData // keyed by challenge
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *gorm.DB, cfg Config) (*Service, error) {
|
||||||
|
wa, err := webauthn.New(&webauthn.Config{
|
||||||
|
RPDisplayName: cfg.Name,
|
||||||
|
RPID: cfg.RPID,
|
||||||
|
RPOrigins: []string{cfg.Origin},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Service{wa: wa, db: db, sessions: map[string]webauthn.SessionData{}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webUser 实现 go-webauthn 的 User 接口。
|
||||||
|
type webUser struct {
|
||||||
|
id uint64
|
||||||
|
name string
|
||||||
|
displayName string
|
||||||
|
credentials []webauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *webUser) WebAuthnID() []byte { return []byte(strconv.FormatUint(u.id, 10)) }
|
||||||
|
func (u *webUser) WebAuthnName() string { return u.name }
|
||||||
|
func (u *webUser) WebAuthnDisplayName() string { return u.displayName }
|
||||||
|
func (u *webUser) WebAuthnIcon() string { return "" }
|
||||||
|
func (u *webUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||||
|
|
||||||
|
func (s *Service) loadWebUser(u *store.User) (*webUser, error) {
|
||||||
|
var pks []store.Passkey
|
||||||
|
s.db.Where("user_id = ?", u.ID).Find(&pks)
|
||||||
|
creds := make([]webauthn.Credential, 0, len(pks))
|
||||||
|
for _, pk := range pks {
|
||||||
|
var c webauthn.Credential
|
||||||
|
if err := json.Unmarshal(pk.Credential, &c); err == nil {
|
||||||
|
creds = append(creds, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &webUser{id: u.ID, name: u.Username, displayName: u.Username, credentials: creds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 注册
|
||||||
|
|
||||||
|
// BeginRegistration 生成注册选项并暂存 challenge。
|
||||||
|
func (s *Service) BeginRegistration(u *store.User) (*protocol.CredentialCreation, error) {
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
creation, session, err := s.wa.BeginRegistration(wu)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.storeSession(session)
|
||||||
|
return creation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishRegistration 校验浏览器返回的凭据并落库。
|
||||||
|
func (s *Service) FinishRegistration(u *store.User, challenge string, body, name []byte) error {
|
||||||
|
session, ok := s.takeSession(challenge)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
cred, err := s.wa.FinishRegistration(wu, session, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(cred)
|
||||||
|
nm := string(name)
|
||||||
|
if nm == "" {
|
||||||
|
nm = "passkey"
|
||||||
|
}
|
||||||
|
return s.db.Create(&store.Passkey{
|
||||||
|
UserID: u.ID, Name: nm, CredentialID: cred.ID, Credential: raw,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 登录
|
||||||
|
|
||||||
|
// BeginLogin 已知用户(按用户名)发起断言。
|
||||||
|
func (s *Service) BeginLogin(u *store.User) (*protocol.CredentialAssertion, error) {
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assertion, session, err := s.wa.BeginLogin(wu)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.storeSession(session)
|
||||||
|
return assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginDiscoverableLogin 无用户名(使用平台/漫游器上的可发现凭据)。
|
||||||
|
func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error) {
|
||||||
|
assertion, session, err := s.wa.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.storeSession(session)
|
||||||
|
return assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishLogin 校验断言并更新签名计数。
|
||||||
|
func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error {
|
||||||
|
session, ok := s.takeSession(challenge)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
cred, err := s.wa.FinishLogin(wu, session, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.updateCredential(u.ID, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishDiscoverableLogin 通过凭据定位用户并校验断言。
|
||||||
|
func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store.User, error) {
|
||||||
|
session, ok := s.takeSession(challenge)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||||
|
var pk store.Passkey
|
||||||
|
if err := s.db.Where("credential_id = ?", rawID).First(&pk).Error; err != nil {
|
||||||
|
return nil, errors.New("credential not found")
|
||||||
|
}
|
||||||
|
var u store.User
|
||||||
|
if err := s.db.First(&u, pk.UserID).Error; err != nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
return s.loadWebUser(&u)
|
||||||
|
}
|
||||||
|
user, cred, err := s.wa.FinishPasskeyLogin(handler, session, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wu := user.(*webUser)
|
||||||
|
_ = s.updateCredential(wu.id, cred)
|
||||||
|
var st store.User
|
||||||
|
if err := s.db.First(&st, wu.id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 管理
|
||||||
|
|
||||||
|
// List 列出用户的 passkey。
|
||||||
|
func (s *Service) List(userID uint64) ([]store.Passkey, error) {
|
||||||
|
var pks []store.Passkey
|
||||||
|
err := s.db.Where("user_id = ?", userID).Order("id DESC").Find(&pks).Error
|
||||||
|
return pks, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除用户的 passkey。
|
||||||
|
func (s *Service) Delete(userID, id uint64) error {
|
||||||
|
res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&store.Passkey{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) updateCredential(userID uint64, cred *webauthn.Credential) error {
|
||||||
|
raw, _ := json.Marshal(cred)
|
||||||
|
return s.db.Model(&store.Passkey{}).
|
||||||
|
Where("user_id = ? AND credential_id = ?", userID, cred.ID).
|
||||||
|
Update("credential", raw).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// challenge 会话
|
||||||
|
|
||||||
|
func (s *Service) storeSession(session *webauthn.SessionData) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessions[session.Challenge] = *session
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) takeSession(challenge string) (webauthn.SessionData, bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
sess, ok := s.sessions[challenge]
|
||||||
|
if ok {
|
||||||
|
delete(s.sessions, challenge)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ok && time.Now().After(sess.Expires) {
|
||||||
|
return webauthn.SessionData{}, false
|
||||||
|
}
|
||||||
|
return sess, ok
|
||||||
|
}
|
||||||
@@ -200,6 +200,16 @@ type BalanceLog struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Passkey WebAuthn 凭据(passkey 绑定/登录)
|
||||||
|
type Passkey struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||||
|
Name string `gorm:"size:64" json:"name"`
|
||||||
|
CredentialID []byte `gorm:"size:255;not null" json:"-"` // credential.ID
|
||||||
|
Credential []byte `gorm:"type:blob;not null" json:"-"` // json.Marshal(webauthn.Credential)
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// SystemConfig 系统配置(PLANNING §6.9)
|
// SystemConfig 系统配置(PLANNING §6.9)
|
||||||
type SystemConfig struct {
|
type SystemConfig struct {
|
||||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
@@ -218,6 +228,7 @@ func AllModels() []any {
|
|||||||
&UsageDaily{},
|
&UsageDaily{},
|
||||||
&RechargeOrder{},
|
&RechargeOrder{},
|
||||||
&BalanceLog{},
|
&BalanceLog{},
|
||||||
|
&Passkey{},
|
||||||
&SystemConfig{},
|
&SystemConfig{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// WebAuthn(passkey)浏览器侧工具:选项编解码 + 凭据序列化。
|
||||||
|
// go-webauthn 返回的 challenge/id 为 base64url 字符串,浏览器需要 ArrayBuffer;
|
||||||
|
// 回调的 credential 需要把 ArrayBuffer 字段转回 base64url。
|
||||||
|
|
||||||
|
export function bufToB64url(buf: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buf)
|
||||||
|
let s = ''
|
||||||
|
for (const b of bytes) s += String.fromCharCode(b)
|
||||||
|
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64urlToBuf(s: string): ArrayBuffer {
|
||||||
|
const t = s.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const pad = t.length % 4 === 0 ? '' : '='.repeat(4 - (t.length % 4))
|
||||||
|
const bin = atob(t + pad)
|
||||||
|
const bytes = new Uint8Array(bin.length)
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
||||||
|
return bytes.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialId {
|
||||||
|
type?: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareIds(list?: CredentialId[]): { type?: string; id: ArrayBuffer }[] | undefined {
|
||||||
|
if (!list) return undefined
|
||||||
|
return list.map((c) => ({ ...c, id: b64urlToBuf(c.id) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerPasskey 调用 navigator.credentials.create,返回可提交后端的 JSON。
|
||||||
|
export async function registerPasskey(options: Record<string, any>): Promise<Record<string, any>> {
|
||||||
|
const pk: Record<string, any> = { ...options.publicKey }
|
||||||
|
pk.challenge = b64urlToBuf(pk.challenge)
|
||||||
|
if (pk.user?.id) pk.user = { ...pk.user, id: b64urlToBuf(pk.user.id) }
|
||||||
|
pk.excludeCredentials = prepareIds(pk.excludeCredentials)
|
||||||
|
|
||||||
|
const cred = (await navigator.credentials.create({ publicKey: pk as PublicKeyCredentialCreationOptions })) as PublicKeyCredential
|
||||||
|
const resp = cred.response as AuthenticatorAttestationResponse
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufToB64url(resp.clientDataJSON),
|
||||||
|
attestationObject: bufToB64url(resp.attestationObject),
|
||||||
|
transports: (resp as unknown as { getTransports?: () => string[] }).getTransports?.() ?? [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginPasskey 调用 navigator.credentials.get,返回可提交后端的 JSON。
|
||||||
|
export async function loginPasskey(options: Record<string, any>): Promise<Record<string, any>> {
|
||||||
|
const pk: Record<string, any> = { ...options.publicKey }
|
||||||
|
pk.challenge = b64urlToBuf(pk.challenge)
|
||||||
|
pk.allowCredentials = prepareIds(pk.allowCredentials)
|
||||||
|
|
||||||
|
const cred = (await navigator.credentials.get({ publicKey: pk as PublicKeyCredentialRequestOptions })) as PublicKeyCredential
|
||||||
|
const resp = cred.response as AuthenticatorAssertionResponse
|
||||||
|
return {
|
||||||
|
id: cred.id,
|
||||||
|
rawId: bufToB64url(cred.rawId),
|
||||||
|
type: cred.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufToB64url(resp.clientDataJSON),
|
||||||
|
authenticatorData: bufToB64url(resp.authenticatorData),
|
||||||
|
signature: bufToB64url(resp.signature),
|
||||||
|
userHandle: resp.userHandle ? bufToB64url(resp.userHandle) : null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isWebAuthnSupported 是否处于安全上下文(passkey 需要 HTTPS 或 localhost)。
|
||||||
|
export function isWebAuthnSupported(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && !!navigator.credentials && window.isSecureContext
|
||||||
|
}
|
||||||
@@ -29,7 +29,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
},
|
},
|
||||||
async login(username: string, password: string) {
|
async login(username: string, password: string) {
|
||||||
const { data } = await http.post('/auth/login', { username, password })
|
const { data } = await http.post('/auth/login', { username, password })
|
||||||
const d = data.data as { access_token: string; user: User }
|
this.setSession(data.data)
|
||||||
|
},
|
||||||
|
setSession(d: { access_token: string; user: User }) {
|
||||||
this.accessToken = d.access_token
|
this.accessToken = d.access_token
|
||||||
this.user = d.user
|
this.user = d.user
|
||||||
localStorage.setItem('ot_access', d.access_token)
|
localStorage.setItem('ot_access', d.access_token)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { PhFingerprint } from '@phosphor-icons/vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
|
import { loginPasskey, isWebAuthnSupported } from '@/lib/webauthn'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||||
@@ -16,6 +18,7 @@ const toast = useToastStore()
|
|||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const pkLoading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -33,6 +36,34 @@ async function submit() {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function passkeyLogin() {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
toast.err('当前环境不支持 Passkey(需 HTTPS 或 localhost)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pkLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const { data } = await http.post('/webauthn/login/begin', {
|
||||||
|
username: username.value || undefined,
|
||||||
|
})
|
||||||
|
const credential = await loginPasskey(data.data.assertion)
|
||||||
|
const resp = await http.post('/webauthn/login/complete', {
|
||||||
|
challenge: data.data.challenge,
|
||||||
|
credential,
|
||||||
|
user_id: data.data.user_id ?? 0,
|
||||||
|
})
|
||||||
|
auth.setSession(resp.data.data)
|
||||||
|
toast.ok('登录成功')
|
||||||
|
const redirect = (route.query.redirect as string) || '/console/dashboard'
|
||||||
|
router.push(redirect)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = errMsg(e)
|
||||||
|
} finally {
|
||||||
|
pkLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -52,6 +83,15 @@ async function submit() {
|
|||||||
<Input v-model="password" label="密码" type="password" autocomplete="current-password" />
|
<Input v-model="password" label="密码" type="password" autocomplete="current-password" />
|
||||||
<p v-if="error" class="text-xs text-err">{{ error }}</p>
|
<p v-if="error" class="text-xs text-err">{{ error }}</p>
|
||||||
<Button class="w-full" :loading="loading" type="submit">登录</Button>
|
<Button class="w-full" :loading="loading" type="submit">登录</Button>
|
||||||
|
<div class="flex items-center gap-3 py-1">
|
||||||
|
<div class="h-px flex-1 bg-edge" />
|
||||||
|
<span class="text-xs text-muted">或</span>
|
||||||
|
<div class="h-px flex-1 bg-edge" />
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" class="w-full" :loading="pkLoading" type="button" @click="passkeyLogin">
|
||||||
|
<PhFingerprint :size="15" />
|
||||||
|
使用 Passkey 登录
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-5 text-center text-sm text-muted">
|
<p class="mt-5 text-center text-sm text-muted">
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { PhFingerprint } from '@phosphor-icons/vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { fmtMoney, fmtTime } from '@/lib/format'
|
import { fmtMoney, fmtTime } from '@/lib/format'
|
||||||
|
import { registerPasskey, isWebAuthnSupported } from '@/lib/webauthn'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
@@ -11,6 +13,54 @@ import Input from '@/components/ui/Input.vue'
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const passkeys = ref<{ id: number; name: string; created_at: string }[]>([])
|
||||||
|
const binding = ref(false)
|
||||||
|
|
||||||
|
async function loadPasskeys() {
|
||||||
|
try {
|
||||||
|
const { data } = await http.get('/webauthn/passkeys')
|
||||||
|
passkeys.value = data.data.items
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindPasskey() {
|
||||||
|
if (!isWebAuthnSupported()) {
|
||||||
|
toast.err('当前环境不支持 Passkey(需 HTTPS 或 localhost)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await http.post('/webauthn/register/begin')
|
||||||
|
const credential = await registerPasskey(data.data.creation)
|
||||||
|
await http.post('/webauthn/register/complete', {
|
||||||
|
challenge: data.data.challenge,
|
||||||
|
name: 'passkey',
|
||||||
|
credential,
|
||||||
|
})
|
||||||
|
toast.ok('Passkey 已绑定')
|
||||||
|
await loadPasskeys()
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
} finally {
|
||||||
|
binding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePasskey(id: number) {
|
||||||
|
if (!confirm('解除该 Passkey?解除后需重新绑定才能免密登录。')) return
|
||||||
|
try {
|
||||||
|
await http.delete(`/webauthn/passkeys/${id}`)
|
||||||
|
toast.ok('已解除')
|
||||||
|
await loadPasskeys()
|
||||||
|
} catch (e) {
|
||||||
|
toast.err(errMsg(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPasskeys)
|
||||||
|
|
||||||
const oldPwd = ref('')
|
const oldPwd = ref('')
|
||||||
const newPwd = ref('')
|
const newPwd = ref('')
|
||||||
const confirmPwd = ref('')
|
const confirmPwd = ref('')
|
||||||
@@ -80,5 +130,26 @@ async function changePassword() {
|
|||||||
<Button :loading="saving" @click="changePassword">更新密码</Button>
|
<Button :loading="saving" @click="changePassword">更新密码</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card p-5">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold">Passkey 登录</h2>
|
||||||
|
<Button size="sm" :loading="binding" @click="bindPasskey">
|
||||||
|
<PhFingerprint :size="14" />
|
||||||
|
绑定 Passkey
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="mb-3 text-xs text-muted">用生物识别或系统 PIN 免密登录。需要 HTTPS 或 localhost 环境。</p>
|
||||||
|
<ul v-if="passkeys.length" class="divide-y divide-edge">
|
||||||
|
<li v-for="pk in passkeys" :key="pk.id" class="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-ink">{{ pk.name }}</p>
|
||||||
|
<p class="font-mono text-xs text-muted">{{ fmtTime(pk.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="text-xs text-muted transition hover:text-err" @click="removePasskey(pk.id)">解除</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="text-xs text-muted">尚未绑定 Passkey</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user