Parcourir la source

perf: 登录逻辑添加人机验证和图片验证码验证

BaiLuoYan il y a 5 jours
Parent
commit
0f6d4b1cb8

+ 11 - 0
etc/perm-api-dev.yaml

@@ -21,3 +21,14 @@ Auth:
   RefreshSecret: "f3a234543b30bfbc2e14225743830b62"
   RefreshExpire: 604800
   ManagementKey: "c653e85ba6528542746eb46298db48db"
+
+# cap.js 人机验证配置(可选)
+# Enable: 1 启用 cap.js 验证,0 或不配置则使用图片验证码
+# EndpointURL: cap.js 服务地址(不含 key,如 https://cap.example.com)
+# Key: cap.js site key
+# Secret: cap.js site secret(服务端 siteverify 使用)
+Capjs:
+  Enable: 0
+  EndpointURL: ""
+  Key: ""
+  Secret: ""

+ 11 - 0
etc/perm-api-prod.yaml

@@ -21,3 +21,14 @@ Auth:
   RefreshSecret: "dfe03caffcfc73be4a941a862dc59ae4"
   RefreshExpire: 604800
   ManagementKey: "bec2b6b1df692610fb914ef2935bda88"
+
+# cap.js 人机验证配置(可选)
+# Enable: 1 启用 cap.js 验证,0 或不配置则使用图片验证码
+# EndpointURL: cap.js 服务地址(不含 key,如 https://cap.example.com)
+# Key: cap.js site key
+# Secret: cap.js site secret(服务端 siteverify 使用)
+Capjs:
+  Enable: 0
+  EndpointURL: ""
+  Key: ""
+  Secret: ""

+ 11 - 0
etc/perm-api-test.yaml

@@ -21,3 +21,14 @@ Auth:
   RefreshSecret: "cbf9b9cfc6516a50e737f580d8e51310"
   RefreshExpire: 604800
   ManagementKey: "1f63927367bd65aaca2bce0247e40b52"
+
+# cap.js 人机验证配置(可选)
+# Enable: 1 启用 cap.js 验证,0 或不配置则使用图片验证码
+# EndpointURL: cap.js 服务地址(不含 key,如 https://cap.example.com)
+# Key: cap.js site key
+# Secret: cap.js site secret(服务端 siteverify 使用)
+Capjs:
+  Enable: 0
+  EndpointURL: ""
+  Key: ""
+  Secret: ""

+ 3 - 0
go.mod

@@ -33,6 +33,7 @@ require (
 	github.com/go-openapi/jsonreference v0.20.2 // indirect
 	github.com/go-openapi/swag v0.23.0 // indirect
 	github.com/gogo/protobuf v1.3.2 // indirect
+	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
 	github.com/golang/protobuf v1.5.4 // indirect
 	github.com/google/gnostic-models v0.7.0 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
@@ -48,6 +49,7 @@ require (
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+	github.com/mojocn/base64Captcha v1.3.8 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/openzipkin/zipkin-go v0.4.3 // indirect
 	github.com/pelletier/go-toml/v2 v2.3.0 // indirect
@@ -82,6 +84,7 @@ require (
 	go.uber.org/zap v1.24.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
+	golang.org/x/image v0.23.0 // indirect
 	golang.org/x/net v0.50.0 // indirect
 	golang.org/x/oauth2 v0.34.0 // indirect
 	golang.org/x/sys v0.41.0 // indirect

+ 57 - 0
go.sum

@@ -79,6 +79,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
 github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
 github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
 github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -86,6 +88,7 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
 github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
 github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
 github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -158,6 +161,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
 github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg=
+github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -286,15 +291,33 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
 golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
+golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
 golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
 golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
@@ -302,6 +325,12 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
 golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -309,14 +338,38 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
 golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
 golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
 golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
 golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
@@ -325,6 +378,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
 golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 10 - 0
internal/config/config.go

@@ -11,6 +11,13 @@ type CacheRedisConf struct {
 	KeyPrefix string `json:",optional"`
 }
 
+type CapjsConf struct {
+	Enable      int64  `json:",optional"` // 1 启用,0 或未配置则禁用
+	EndpointURL string `json:",optional"` // cap.js 服务地址,如 https://cap.example.com
+	Key         string `json:",optional"` // cap.js site key
+	Secret      string `json:",optional"` // cap.js site secret(用于服务端 siteverify)
+}
+
 type Config struct {
 	rest.RestConf
 	RpcServerConf zrpc.RpcServerConf
@@ -29,5 +36,8 @@ type Config struct {
 		ManagementKey string
 	}
 
+	Capjs CapjsConf `json:",optional"`
+
 	BehindProxy bool `json:",optional"`
 }
+

+ 32 - 0
internal/handler/pub/adminLoginByCapHandler.go

@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package pub
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/pub"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+)
+
+func AdminLoginByCapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.AdminLoginByCapReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, response.ErrBadRequest(err.Error()))
+			return
+		}
+
+		l := pub.NewAdminLoginByCapLogic(r.Context(), svcCtx)
+		resp, err := l.AdminLoginByCap(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 24 - 0
internal/handler/pub/capEndpointHandler.go

@@ -0,0 +1,24 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package pub
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/pub"
+	"perms-system-server/internal/svc"
+)
+
+func CapEndpointHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		l := pub.NewCapEndpointLogic(r.Context(), svcCtx)
+		resp, err := l.CapEndpoint()
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 32 - 0
internal/handler/pub/captchaHandler.go

@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package pub
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/pub"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+)
+
+func CaptchaHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.CaptchaReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, response.ErrBadRequest(err.Error()))
+			return
+		}
+
+		l := pub.NewCaptchaLogic(r.Context(), svcCtx)
+		resp, err := l.Captcha(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 32 - 0
internal/handler/pub/loginByCapHandler.go

@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package pub
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/pub"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+)
+
+func LoginByCapHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.LoginByCapReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, response.ErrBadRequest(err.Error()))
+			return
+		}
+
+		l := pub.NewLoginByCapLogic(r.Context(), svcCtx)
+		resp, err := l.LoginByCap(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.OkJsonCtx(r.Context(), w, resp)
+		}
+	}
+}

+ 31 - 5
internal/handler/routes.go

@@ -1,5 +1,5 @@
 // Code generated by goctl. DO NOT EDIT.
-// goctl 1.10.0
+// goctl 1.10.1
 
 package handler
 
@@ -127,13 +127,13 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 				},
 				{
 					Method:  http.MethodPost,
-					Path:    "/fetchInitialCredentials",
-					Handler: product.FetchInitialCredentialsHandler(serverCtx),
+					Path:    "/detail",
+					Handler: product.ProductDetailHandler(serverCtx),
 				},
 				{
 					Method:  http.MethodPost,
-					Path:    "/detail",
-					Handler: product.ProductDetailHandler(serverCtx),
+					Path:    "/fetchInitialCredentials",
+					Handler: product.FetchInitialCredentialsHandler(serverCtx),
 				},
 				{
 					Method:  http.MethodPost,
@@ -150,6 +150,22 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 		rest.WithPrefix("/api/product"),
 	)
 
+	server.AddRoutes(
+		[]rest.Route{
+			{
+				Method:  http.MethodPost,
+				Path:    "/capjs/endpoint",
+				Handler: pub.CapEndpointHandler(serverCtx),
+			},
+			{
+				Method:  http.MethodPost,
+				Path:    "/captcha/get",
+				Handler: pub.CaptchaHandler(serverCtx),
+			},
+		},
+		rest.WithPrefix("/api"),
+	)
+
 	server.AddRoutes(
 		rest.WithMiddlewares(
 			[]rest.Middleware{serverCtx.AdminLoginRateLimit},
@@ -159,6 +175,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 					Path:    "/auth/adminLogin",
 					Handler: pub.AdminLoginHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/auth/adminLogin/cap",
+					Handler: pub.AdminLoginByCapHandler(serverCtx),
+				},
 			}...,
 		),
 		rest.WithPrefix("/api"),
@@ -173,6 +194,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 					Path:    "/auth/login",
 					Handler: pub.LoginHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/user/login/cap",
+					Handler: pub.LoginByCapHandler(serverCtx),
+				},
 			}...,
 		),
 		rest.WithPrefix("/api"),

+ 71 - 0
internal/logic/pub/adminLoginByCapLogic.go

@@ -0,0 +1,71 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package pub
+
+import (
+	"context"
+	"time"
+
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type AdminLoginByCapLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewAdminLoginByCapLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminLoginByCapLogic {
+	return &AdminLoginByCapLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *AdminLoginByCapLogic) AdminLoginByCap(req *types.AdminLoginByCapReq) (*types.LoginResp, error) {
+	if err := verifyCapToken(l.ctx, l.svcCtx.Config.Capjs, req.CapToken, l.Logger); err != nil {
+		return nil, err
+	}
+
+	clientIP := middleware.GetClientIP(l.ctx)
+	result, err := ValidateAdminLogin(l.ctx, l.svcCtx, req.Username, req.Password, req.ManagementKey, clientIP)
+	if err != nil {
+		if le, ok := err.(*LoginError); ok {
+			switch le.Code {
+			case 401:
+				return nil, response.ErrUnauthorized(le.Message)
+			case 429:
+				return nil, response.NewCodeError(429, le.Message)
+			case 503:
+				return nil, response.NewCodeError(503, le.Message)
+			}
+		}
+		return nil, err
+	}
+
+	ud := result.UserDetails
+	return &types.LoginResp{
+		AccessToken:  result.AccessToken,
+		RefreshToken: result.RefreshToken,
+		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
+		UserInfo: types.UserInfo{
+			UserId:             ud.UserId,
+			Username:           ud.Username,
+			Nickname:           ud.Nickname,
+			Avatar:             ud.Avatar,
+			Email:              ud.Email,
+			Phone:              ud.Phone,
+			IsSuperAdmin:       ud.IsSuperAdminRaw,
+			MustChangePassword: ud.MustChangePwdRaw,
+			MemberType:         ud.MemberType,
+			Perms:              ud.Perms,
+		},
+	}, nil
+}

+ 21 - 69
internal/logic/pub/adminLoginLogic.go

@@ -2,22 +2,14 @@ package pub
 
 import (
 	"context"
-	"crypto/subtle"
-	"errors"
-	"fmt"
 	"time"
 
-	"perms-system-server/internal/consts"
-	authHelper "perms-system-server/internal/logic/auth"
 	"perms-system-server/internal/middleware"
-	"perms-system-server/internal/model/user"
 	"perms-system-server/internal/response"
 	"perms-system-server/internal/svc"
 	"perms-system-server/internal/types"
 
-	"github.com/zeromicro/go-zero/core/limit"
 	"github.com/zeromicro/go-zero/core/logx"
-	"golang.org/x/crypto/bcrypt"
 )
 
 type AdminLoginLogic struct {
@@ -35,78 +27,38 @@ func NewAdminLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminL
 }
 
 // AdminLogin 管理后台登录。仅限超级管理员通过 managementKey + 用户名密码登录管理后台,返回 JWT 令牌对。
+// 当 cap.js 未启用时,需同时携带 captchaId/captchaCode 进行图片验证码校验。
 func (l *AdminLoginLogic) AdminLogin(req *types.AdminLoginReq) (resp *types.LoginResp, err error) {
-	if subtle.ConstantTimeCompare([]byte(req.ManagementKey), []byte(l.svcCtx.Config.Auth.ManagementKey)) != 1 {
-		return nil, response.ErrUnauthorized("managementKey无效")
-	}
-
-	// 限流 key 使用 "admin:<clientIP>:<username>" 双键维度:只有来自同一 IP + 同一 username 的
-	// 连续失败才会累加计数,远端任意 IP 无法通过只打 username 把任意超管账号永久锁死(见审计 H-1)。
-	// clientIP 由 AdminLoginRateLimit 中间件注入 context;解析失败时用 "unknown" 走共享桶兜底,
-	// 仍有 5min/10 次的总体上限,避免退化为"无限流"。
-	if l.svcCtx.UsernameLoginLimit != nil {
-		clientIP := middleware.GetClientIP(l.ctx)
-		if clientIP == "" {
-			clientIP = "unknown"
+	cfg := l.svcCtx.Config.Capjs
+	if cfg.Enable != 1 {
+		if req.CaptchaId == "" || req.CaptchaCode == "" {
+			return nil, response.ErrBadRequest("验证码不能为空")
 		}
-		key := fmt.Sprintf("admin:%s:%s", clientIP, req.Username)
-		code, _ := l.svcCtx.UsernameLoginLimit.Take(key)
-		if code == limit.OverQuota {
-			return nil, response.NewCodeError(429, "登录尝试过于频繁,请5分钟后再试")
+		if !VerifyCaptcha(req.CaptchaId, req.CaptchaCode) {
+			return nil, response.ErrBadRequest("验证码错误或已过期")
 		}
 	}
 
-	u, err := l.svcCtx.SysUserModel.FindOneByUsername(l.ctx, req.Username)
+	clientIP := middleware.GetClientIP(l.ctx)
+	result, err := ValidateAdminLogin(l.ctx, l.svcCtx, req.Username, req.Password, req.ManagementKey, clientIP)
 	if err != nil {
-		if errors.Is(err, user.ErrNotFound) {
-			bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(req.Password))
-			return nil, response.ErrUnauthorized("用户名或密码错误")
+		if le, ok := err.(*LoginError); ok {
+			switch le.Code {
+			case 401:
+				return nil, response.ErrUnauthorized(le.Message)
+			case 429:
+				return nil, response.NewCodeError(429, le.Message)
+			case 503:
+				return nil, response.NewCodeError(503, le.Message)
+			}
 		}
 		return nil, err
 	}
 
-	// 审计 L-N3:把 IsSuperAdmin 判断前置到真 bcrypt 之前,防止"存在但非超管"分支因跳过真 bcrypt
-	// 比"存在是超管且密码错"分支快一段时间(数十 ms),让攻击者借耗时差筛出"存在的超管账号"。
-	// 这里仍然要走一次 dummyBcryptHash,把时序抹平到与"真 bcrypt 失败 + 返回 ErrUnauthorized"同阶。
-	if u.IsSuperAdmin != consts.IsSuperAdminYes {
-		bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(req.Password))
-		return nil, response.ErrUnauthorized("用户名或密码错误")
-	}
-
-	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(req.Password)); err != nil {
-		return nil, response.ErrUnauthorized("用户名或密码错误")
-	}
-
-	if u.Status != consts.StatusEnabled {
-		return nil, response.ErrUnauthorized("用户名或密码错误")
-	}
-
-	ud, err := l.svcCtx.UserDetailsLoader.Load(l.ctx, u.Id, "")
-	if err != nil {
-		return nil, response.NewCodeError(503, "服务暂时不可用,请稍后重试")
-	}
-
-	accessToken, err := authHelper.GenerateAccessToken(
-		l.svcCtx.Config.Auth.AccessSecret,
-		l.svcCtx.Config.Auth.AccessExpire,
-		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.TokenVersion,
-	)
-	if err != nil {
-		return nil, err
-	}
-
-	refreshToken, err := authHelper.GenerateRefreshToken(
-		l.svcCtx.Config.Auth.RefreshSecret,
-		l.svcCtx.Config.Auth.RefreshExpire,
-		ud.UserId, ud.ProductCode, ud.TokenVersion,
-	)
-	if err != nil {
-		return nil, err
-	}
-
+	ud := result.UserDetails
 	return &types.LoginResp{
-		AccessToken:  accessToken,
-		RefreshToken: refreshToken,
+		AccessToken:  result.AccessToken,
+		RefreshToken: result.RefreshToken,
 		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
 		UserInfo: types.UserInfo{
 			UserId:             ud.UserId,

+ 39 - 0
internal/logic/pub/capEndpointLogic.go

@@ -0,0 +1,39 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package pub
+
+import (
+	"context"
+
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type CapEndpointLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewCapEndpointLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CapEndpointLogic {
+	return &CapEndpointLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+// CapEndpoint 返回 cap.js 服务的完整验证端点 URL。
+// 未配置或 Enable != 1 时返回空 Data,前端据此降级为图片验证码。
+func (l *CapEndpointLogic) CapEndpoint() (*types.CapEndpointResp, error) {
+	cfg := l.svcCtx.Config.Capjs
+	if cfg.Enable != 1 || cfg.EndpointURL == "" || cfg.Key == "" {
+		return &types.CapEndpointResp{Code: 0, Msg: "success", Data: ""}, nil
+	}
+	url := cfg.EndpointURL + "/" + cfg.Key + "/"
+	return &types.CapEndpointResp{Code: 0, Msg: "success", Data: url}, nil
+}
+

+ 54 - 0
internal/logic/pub/captchaLogic.go

@@ -0,0 +1,54 @@
+package pub
+
+import (
+	"context"
+
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/mojocn/base64Captcha"
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+// defaultCaptchaStore 全进程内共享的内存 store;多实例部署时验证码 ID 会在实例间不一致,
+// 属于已知局限——可后续替换为 Redis store。
+var defaultCaptchaStore = base64Captcha.DefaultMemStore
+
+type CaptchaLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewCaptchaLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CaptchaLogic {
+	return &CaptchaLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *CaptchaLogic) Captcha(req *types.CaptchaReq) (*types.CaptchaInfo, error) {
+	width := req.Width
+	height := req.Height
+	if width <= 0 {
+		width = 240
+	}
+	if height <= 0 {
+		height = 80
+	}
+
+	driver := base64Captcha.NewDriverDigit(height, width, 4, 0.7, 80)
+	c := base64Captcha.NewCaptcha(driver, defaultCaptchaStore)
+	id, b64, _, err := c.Generate()
+	if err != nil {
+		l.Errorf("captcha generate error: %v", err)
+		return nil, err
+	}
+	return &types.CaptchaInfo{Id: id, Base64Image: b64}, nil
+}
+
+// VerifyCaptcha 供 loginLogic 内部调用,校验图片验证码后立即消费(防重放)。
+func VerifyCaptcha(id, code string) bool {
+	return defaultCaptchaStore.Verify(id, code, true)
+}

+ 131 - 0
internal/logic/pub/loginByCapLogic.go

@@ -0,0 +1,131 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package pub
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"perms-system-server/internal/config"
+	"perms-system-server/internal/middleware"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type LoginByCapLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewLoginByCapLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginByCapLogic {
+	return &LoginByCapLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *LoginByCapLogic) LoginByCap(req *types.LoginByCapReq) (*types.LoginResp, error) {
+	if err := verifyCapToken(l.ctx, l.svcCtx.Config.Capjs, req.CapToken, l.Logger); err != nil {
+		return nil, err
+	}
+
+	clientIP := middleware.GetClientIP(l.ctx)
+	result, err := ValidateProductLogin(l.ctx, l.svcCtx, req.Username, req.Password, req.ProductCode, clientIP)
+	if err != nil {
+		if le, ok := err.(*LoginError); ok {
+			switch le.Code {
+			case 400:
+				return nil, response.ErrBadRequest(le.Message)
+			case 401:
+				return nil, response.ErrUnauthorized(le.Message)
+			case 403:
+				return nil, response.ErrForbidden(le.Message)
+			case 429:
+				return nil, response.NewCodeError(429, le.Message)
+			}
+		}
+		return nil, err
+	}
+
+	ud := result.UserDetails
+	return &types.LoginResp{
+		AccessToken:  result.AccessToken,
+		RefreshToken: result.RefreshToken,
+		Expires:      time.Now().Unix() + l.svcCtx.Config.Auth.AccessExpire,
+		UserInfo: types.UserInfo{
+			UserId:             ud.UserId,
+			Username:           ud.Username,
+			Nickname:           ud.Nickname,
+			Avatar:             ud.Avatar,
+			Email:              ud.Email,
+			Phone:              ud.Phone,
+			IsSuperAdmin:       ud.IsSuperAdminRaw,
+			MustChangePassword: ud.MustChangePwdRaw,
+			MemberType:         ud.MemberType,
+			Perms:              ud.Perms,
+		},
+	}, nil
+}
+
+// verifyCapToken 向 cap.js 服务端发送 siteverify 请求,校验人机验证令牌。
+// 供产品端和管理端的 cap.js 登录逻辑共用。
+func verifyCapToken(ctx context.Context, cfg config.CapjsConf, capToken string, logger logx.Logger) error {
+	if cfg.Enable != 1 || cfg.EndpointURL == "" {
+		return response.ErrBadRequest("当前未启用人机验证,请使用图片验证码登录")
+	}
+
+	if strings.TrimSpace(capToken) == "" {
+		return response.ErrBadRequest("人机验证不能为空")
+	}
+
+	body, _ := json.Marshal(map[string]string{
+		"secret":   cfg.Secret,
+		"response": capToken,
+	})
+
+	verifyURL := cfg.EndpointURL + "/" + cfg.Key + "/siteverify"
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, verifyURL, bytes.NewBuffer(body))
+	if err != nil {
+		logger.Errorf("verifyCapToken: create request failed: %v", err)
+		return response.NewCodeError(500, "人机验证校验失败")
+	}
+	req.Header.Set("Content-Type", "application/json")
+
+	client := &http.Client{Timeout: 10 * time.Second}
+	res, err := client.Do(req)
+	if err != nil {
+		logger.Errorf("verifyCapToken: send request failed: %v", err)
+		return response.NewCodeError(500, "人机验证校验失败")
+	}
+	defer res.Body.Close()
+
+	raw, err := io.ReadAll(res.Body)
+	if err != nil {
+		logger.Errorf("verifyCapToken: read response failed: %v", err)
+		return response.NewCodeError(500, "人机验证校验失败")
+	}
+
+	var result struct {
+		Success bool `json:"success"`
+	}
+	if err := json.Unmarshal(raw, &result); err != nil {
+		logger.Errorf("verifyCapToken: unmarshal failed: %v, body: %s", err, raw)
+		return response.NewCodeError(500, "人机验证校验失败")
+	}
+
+	if !result.Success {
+		return response.ErrBadRequest("人机验证失败,请重试")
+	}
+	return nil
+}

+ 12 - 0
internal/logic/pub/loginLogic.go

@@ -27,7 +27,19 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic
 }
 
 // Login 产品端登录。产品成员通过用户名密码 + productCode 登录指定产品,返回 JWT 令牌对及用户权限信息。
+// 当 cap.js 未启用时,需同时携带 captchaId/captchaCode 进行图片验证码校验。
 func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err error) {
+	// cap.js 未启用时强制校验图片验证码
+	cfg := l.svcCtx.Config.Capjs
+	if cfg.Enable != 1 {
+		if req.CaptchaId == "" || req.CaptchaCode == "" {
+			return nil, response.ErrBadRequest("验证码不能为空")
+		}
+		if !VerifyCaptcha(req.CaptchaId, req.CaptchaCode) {
+			return nil, response.ErrBadRequest("验证码错误或已过期")
+		}
+	}
+
 	clientIP := middleware.GetClientIP(l.ctx)
 	result, err := ValidateProductLogin(l.ctx, l.svcCtx, req.Username, req.Password, req.ProductCode, clientIP)
 	if err != nil {

+ 73 - 0
internal/logic/pub/loginService.go

@@ -2,6 +2,7 @@ package pub
 
 import (
 	"context"
+	"crypto/subtle"
 	"errors"
 	"fmt"
 
@@ -124,3 +125,75 @@ func ValidateProductLogin(ctx context.Context, svcCtx *svc.ServiceContext, usern
 		RefreshToken: refreshToken,
 	}, nil
 }
+
+// ValidateAdminLogin 管理后台登录核心认证:校验 managementKey、频率限制、用户名密码、超管身份,
+// 生成并返回令牌对。captcha / cap.js 校验由调用方在进入本函数前完成。
+func ValidateAdminLogin(ctx context.Context, svcCtx *svc.ServiceContext, username, password, managementKey, clientIP string) (*LoginResult, error) {
+	if subtle.ConstantTimeCompare([]byte(managementKey), []byte(svcCtx.Config.Auth.ManagementKey)) != 1 {
+		return nil, &LoginError{Code: 401, Message: "managementKey无效"}
+	}
+
+	if svcCtx.UsernameLoginLimit != nil {
+		if clientIP == "" {
+			clientIP = "unknown"
+		}
+		key := fmt.Sprintf("admin:%s:%s", clientIP, username)
+		code, _ := svcCtx.UsernameLoginLimit.Take(key)
+		if code == limit.OverQuota {
+			return nil, &LoginError{Code: 429, Message: "登录尝试过于频繁,请5分钟后再试"}
+		}
+	}
+
+	u, err := svcCtx.SysUserModel.FindOneByUsername(ctx, username)
+	if err != nil {
+		if errors.Is(err, user.ErrNotFound) {
+			bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
+			return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+		}
+		return nil, err
+	}
+
+	// 审计 L-N3:IsSuperAdmin 判断前置到真 bcrypt 之前,防止分支耗时差泄露账号存在性;
+	// 仍走一次 dummyBcryptHash 把时序抹平。
+	if u.IsSuperAdmin != consts.IsSuperAdminYes {
+		bcrypt.CompareHashAndPassword(dummyBcryptHash, []byte(password))
+		return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)); err != nil {
+		return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+	}
+
+	if u.Status != consts.StatusEnabled {
+		return nil, &LoginError{Code: 401, Message: "用户名或密码错误"}
+	}
+
+	ud, err := svcCtx.UserDetailsLoader.Load(ctx, u.Id, "")
+	if err != nil {
+		return nil, &LoginError{Code: 503, Message: "服务暂时不可用,请稍后重试"}
+	}
+
+	accessToken, err := authHelper.GenerateAccessToken(
+		svcCtx.Config.Auth.AccessSecret,
+		svcCtx.Config.Auth.AccessExpire,
+		ud.UserId, ud.Username, ud.ProductCode, ud.MemberType, ud.TokenVersion,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	refreshToken, err := authHelper.GenerateRefreshToken(
+		svcCtx.Config.Auth.RefreshSecret,
+		svcCtx.Config.Auth.RefreshExpire,
+		ud.UserId, ud.ProductCode, ud.TokenVersion,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return &LoginResult{
+		UserDetails:  ud,
+		AccessToken:  accessToken,
+		RefreshToken: refreshToken,
+	}, nil
+}

+ 25 - 0
internal/middleware/adminloginratelimitMiddleware.go

@@ -0,0 +1,25 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+type AdminLoginRateLimitMiddleware struct {
+	rl *RateLimitMiddleware
+}
+
+// NewAdminLoginRateLimitMiddleware 管理后台登录限流:每 IP 每分钟最多 20 次。
+func NewAdminLoginRateLimitMiddleware(rds *redis.Redis, keyPrefix string, behindProxy bool) *AdminLoginRateLimitMiddleware {
+	return &AdminLoginRateLimitMiddleware{
+		rl: NewRateLimitMiddleware(rds, 60, 20, keyPrefix+":rl:login:admin", behindProxy),
+	}
+}
+
+func (m *AdminLoginRateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
+	return m.rl.Handle(next)
+}

+ 25 - 0
internal/middleware/productloginratelimitMiddleware.go

@@ -0,0 +1,25 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+type ProductLoginRateLimitMiddleware struct {
+	rl *RateLimitMiddleware
+}
+
+// NewProductLoginRateLimitMiddleware 产品端登录限流:每 IP 每分钟最多 30 次。
+func NewProductLoginRateLimitMiddleware(rds *redis.Redis, keyPrefix string, behindProxy bool) *ProductLoginRateLimitMiddleware {
+	return &ProductLoginRateLimitMiddleware{
+		rl: NewRateLimitMiddleware(rds, 60, 30, keyPrefix+":rl:login:product", behindProxy),
+	}
+}
+
+func (m *ProductLoginRateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
+	return m.rl.Handle(next)
+}

+ 25 - 0
internal/middleware/refreshtokenratelimitMiddleware.go

@@ -0,0 +1,25 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+type RefreshTokenRateLimitMiddleware struct {
+	rl *RateLimitMiddleware
+}
+
+// NewRefreshTokenRateLimitMiddleware Token 刷新限流:每 IP 每分钟最多 30 次。
+func NewRefreshTokenRateLimitMiddleware(rds *redis.Redis, keyPrefix string, behindProxy bool) *RefreshTokenRateLimitMiddleware {
+	return &RefreshTokenRateLimitMiddleware{
+		rl: NewRateLimitMiddleware(rds, 60, 30, keyPrefix+":rl:refresh", behindProxy),
+	}
+}
+
+func (m *RefreshTokenRateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
+	return m.rl.Handle(next)
+}

+ 25 - 0
internal/middleware/syncratelimitMiddleware.go

@@ -0,0 +1,25 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+type SyncRateLimitMiddleware struct {
+	rl *RateLimitMiddleware
+}
+
+// NewSyncRateLimitMiddleware 权限同步限流:每 IP 每分钟最多 10 次。
+func NewSyncRateLimitMiddleware(rds *redis.Redis, keyPrefix string, behindProxy bool) *SyncRateLimitMiddleware {
+	return &SyncRateLimitMiddleware{
+		rl: NewRateLimitMiddleware(rds, 60, 10, keyPrefix+":rl:sync", behindProxy),
+	}
+}
+
+func (m *SyncRateLimitMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc {
+	return m.rl.Handle(next)
+}

+ 4 - 4
internal/svc/servicecontext.go

@@ -36,10 +36,10 @@ func NewServiceContext(c config.Config) *ServiceContext {
 	rds := redis.MustNewRedis(c.CacheRedis.Nodes[0].RedisConf)
 	models := model.NewModels(conn, c.CacheRedis.Nodes, c.CacheRedis.KeyPrefix)
 	udLoader := loaders.NewUserDetailsLoader(rds, c.CacheRedis.KeyPrefix, models)
-	productLoginRL := middleware.NewRateLimitMiddleware(rds, 60, 30, c.CacheRedis.KeyPrefix+":rl:login:product", c.BehindProxy)
-	adminLoginRL := middleware.NewRateLimitMiddleware(rds, 60, 20, c.CacheRedis.KeyPrefix+":rl:login:admin", c.BehindProxy)
-	syncRlMiddleware := middleware.NewRateLimitMiddleware(rds, 60, 10, c.CacheRedis.KeyPrefix+":rl:sync", c.BehindProxy)
-	refreshTokenRL := middleware.NewRateLimitMiddleware(rds, 60, 30, c.CacheRedis.KeyPrefix+":rl:refresh", c.BehindProxy)
+	productLoginRL := middleware.NewProductLoginRateLimitMiddleware(rds, c.CacheRedis.KeyPrefix, c.BehindProxy)
+	adminLoginRL := middleware.NewAdminLoginRateLimitMiddleware(rds, c.CacheRedis.KeyPrefix, c.BehindProxy)
+	syncRlMiddleware := middleware.NewSyncRateLimitMiddleware(rds, c.CacheRedis.KeyPrefix, c.BehindProxy)
+	refreshTokenRL := middleware.NewRefreshTokenRateLimitMiddleware(rds, c.CacheRedis.KeyPrefix, c.BehindProxy)
 	grpcLimiter := limit.NewPeriodLimit(60, 20, rds, c.CacheRedis.KeyPrefix+":rl:grpc:login")
 	// gRPC refreshToken 一般低频操作(分钟级),限紧一点可以同时防签名爆破与并发刷新被用作会话劫持的放大器。
 	grpcRefreshLimiter := limit.NewPeriodLimit(60, 30, rds, c.CacheRedis.KeyPrefix+":rl:grpc:refresh")

+ 3 - 0
internal/testutil/testutil.go

@@ -44,6 +44,9 @@ var testConfig = config.Config{
 		RefreshExpire: 604800,
 		ManagementKey: "test-management-key",
 	},
+	// 测试环境标记 cap.js 已启用,使 AdminLogin / Login 跳过图片验证码校验,
+	// 让业务逻辑测试无需预填充验证码即可通过。
+	Capjs: config.CapjsConf{Enable: 1},
 }
 
 func GetTestConfig() config.Config {

+ 50 - 19
internal/types/types.go

@@ -1,5 +1,5 @@
 // Code generated by goctl. DO NOT EDIT.
-// goctl 1.10.0
+// goctl 1.10.1
 
 package types
 
@@ -9,10 +9,19 @@ type AddMemberReq struct {
 	MemberType  string `json:"memberType"`
 }
 
+type AdminLoginByCapReq struct {
+	Username      string `json:"username"`
+	Password      string `json:"password"`
+	ManagementKey string `json:"managementKey"`
+	CapToken      string `json:"capToken"`
+}
+
 type AdminLoginReq struct {
 	Username      string `json:"username"`
 	Password      string `json:"password"`
 	ManagementKey string `json:"managementKey"`
+	CaptchaId     string `json:"captchaId,optional"`
+	CaptchaCode   string `json:"captchaCode,optional"`
 }
 
 type BindPermsReq struct {
@@ -25,6 +34,22 @@ type BindRolesReq struct {
 	RoleIds []int64 `json:"roleIds"`
 }
 
+type CapEndpointResp struct {
+	Code int    `json:"code"`
+	Msg  string `json:"msg"`
+	Data string `json:"data"`
+}
+
+type CaptchaInfo struct {
+	Id          string `json:"id"`
+	Base64Image string `json:"base64image"`
+}
+
+type CaptchaReq struct {
+	Width  int `json:"width,optional"`
+	Height int `json:"height,optional"`
+}
+
 type ChangePasswordReq struct {
 	OldPassword string `json:"oldPassword"`
 	NewPassword string `json:"newPassword"`
@@ -46,28 +71,14 @@ type CreateProductReq struct {
 }
 
 type CreateProductResp struct {
-	Id        int64  `json:"id"`
-	Code      string `json:"code"`
-	AppKey    string `json:"appKey"`
-	AdminUser string `json:"adminUser"`
-	// CredentialsTicket 一次性凭证票据。AppSecret 与初始 AdminPassword 不再随本响应明文返回,
-	// 改为由调用方用该 ticket 调一次 /api/product/fetchInitialCredentials 领取(5 分钟内有效,
-	// 一次性消费)。审计 M-4:避免密码/密钥经响应体落盘到上游日志/APM。
+	Id                   int64  `json:"id"`
+	Code                 string `json:"code"`
+	AppKey               string `json:"appKey"`
+	AdminUser            string `json:"adminUser"`
 	CredentialsTicket    string `json:"credentialsTicket"`
 	CredentialsExpiresAt int64  `json:"credentialsExpiresAt"`
 }
 
-type FetchInitialCredentialsReq struct {
-	Ticket string `json:"ticket"`
-}
-
-type FetchInitialCredentialsResp struct {
-	AppKey        string `json:"appKey"`
-	AppSecret     string `json:"appSecret"`
-	AdminUser     string `json:"adminUser"`
-	AdminPassword string `json:"adminPassword"`
-}
-
 type CreateRoleReq struct {
 	ProductCode string `json:"productCode"`
 	Name        string `json:"name"`
@@ -106,14 +117,34 @@ type DeptItem struct {
 	Children   []*DeptItem `json:"children"`
 }
 
+type FetchInitialCredentialsReq struct {
+	Ticket string `json:"ticket"`
+}
+
+type FetchInitialCredentialsResp struct {
+	AppKey        string `json:"appKey"`
+	AppSecret     string `json:"appSecret"`
+	AdminUser     string `json:"adminUser"`
+	AdminPassword string `json:"adminPassword"`
+}
+
 type IdResp struct {
 	Id int64 `json:"id"`
 }
 
+type LoginByCapReq struct {
+	Username    string `json:"username"`
+	Password    string `json:"password"`
+	ProductCode string `json:"productCode"`
+	CapToken    string `json:"capToken"`
+}
+
 type LoginReq struct {
 	Username    string `json:"username"`
 	Password    string `json:"password"`
 	ProductCode string `json:"productCode"`
+	CaptchaId   string `json:"captchaId,optional"`
+	CaptchaCode string `json:"captchaCode,optional"`
 }
 
 type LoginResp struct {

+ 56 - 7
perm.api

@@ -14,15 +14,44 @@ type PageResp {
 
 // ==================== Auth ====================
 type (
+	CaptchaReq {
+		Width  int `json:"width,optional"`
+		Height int `json:"height,optional"`
+	}
+	CaptchaInfo {
+		Id          string `json:"id"`
+		Base64Image string `json:"base64image"`
+	}
+	CapEndpointResp {
+		Code int    `json:"code"`
+		Msg  string `json:"msg"`
+		Data string `json:"data"`
+	}
 	LoginReq {
 		Username    string `json:"username"`
 		Password    string `json:"password"`
 		ProductCode string `json:"productCode"`
+		CaptchaId   string `json:"captchaId,optional"`
+		CaptchaCode string `json:"captchaCode,optional"`
+	}
+	LoginByCapReq {
+		Username    string `json:"username"`
+		Password    string `json:"password"`
+		ProductCode string `json:"productCode"`
+		CapToken    string `json:"capToken"`
 	}
 	AdminLoginReq {
 		Username      string `json:"username"`
 		Password      string `json:"password"`
 		ManagementKey string `json:"managementKey"`
+		CaptchaId     string `json:"captchaId,optional"`
+		CaptchaCode   string `json:"captchaCode,optional"`
+	}
+	AdminLoginByCapReq {
+		Username      string `json:"username"`
+		Password      string `json:"password"`
+		ManagementKey string `json:"managementKey"`
+		CapToken      string `json:"capToken"`
 	}
 	LoginResp {
 		AccessToken  string   `json:"accessToken"`
@@ -55,12 +84,12 @@ type (
 // ==================== Product ====================
 type (
 	CreateProductReq {
-		Code        string `json:"code"`
-		Name        string `json:"name"`
-		Remark      string `json:"remark,optional"`
+		Code   string `json:"code"`
+		Name   string `json:"name"`
+		Remark string `json:"remark,optional"`
 		// 审计 L-R10-1:新建 admin_<code> 用户时必须一并指定部门。若不带部门(DeptId=0),新账号
 		// 在 CheckAddMemberAccess / CreateUser 的 DeptPath 前缀校验下彻底瘫痪,除了改密码外做不了任何管理动作。
-		AdminDeptId int64  `json:"adminDeptId"`
+		AdminDeptId int64 `json:"adminDeptId"`
 	}
 	CreateProductResp {
 		Id        int64  `json:"id"`
@@ -70,7 +99,7 @@ type (
 		// CredentialsTicket 一次性凭证票据。AppSecret 与初始 AdminPassword 不再随本响应明文返回,
 		// 改为由调用方用该 ticket 调一次 /api/product/fetchInitialCredentials 领取(5 分钟内有效,
 		// 一次性消费)。审计 M-4:避免密码/密钥经响应体落盘到上游日志/APM。
-		CredentialsTicket string `json:"credentialsTicket"`
+		CredentialsTicket    string `json:"credentialsTicket"`
 		CredentialsExpiresAt int64  `json:"credentialsExpiresAt"`
 	}
 	FetchInitialCredentialsReq {
@@ -317,8 +346,21 @@ type IdResp {
 }
 
 // ==================== Routes ====================
-
 // -------- 公开接口(无需 JWT 鉴权) --------
+// 图片验证码与 cap.js 端点,无限流(自带内存 ID 防重)
+@server (
+	prefix: /api
+	group:  pub
+)
+service perm-api {
+	// Captcha 获取图片验证码(base64 编码)
+	@handler Captcha
+	post /captcha/get (CaptchaReq) returns (CaptchaInfo)
+
+	// CapEndpoint 返回 cap.js 服务端点 URL;未配置时返回空串,前端据此决定显示哪种验证方式
+	@handler CapEndpoint
+	post /capjs/endpoint returns (CapEndpointResp)
+}
 
 // 管理后台登录,需携带 managementKey 凭证,受 IP 维度限流保护
 @server (
@@ -330,6 +372,10 @@ service perm-api {
 	// AdminLogin 管理后台登录。仅限超级管理员通过 managementKey + 用户名密码登录管理后台,返回 JWT 令牌对
 	@handler AdminLogin
 	post /auth/adminLogin (AdminLoginReq) returns (LoginResp)
+
+	// AdminLoginByCap 使用 cap.js 人机验证令牌登录管理后台,验证通过后执行与 AdminLogin 相同的业务逻辑
+	@handler AdminLoginByCap
+	post /auth/adminLogin/cap (AdminLoginByCapReq) returns (LoginResp)
 }
 
 // 产品端登录,受 IP 维度限流保护
@@ -342,6 +388,10 @@ service perm-api {
 	// Login 产品端登录。产品成员通过用户名密码 + productCode 登录指定产品,返回 JWT 令牌对及用户权限信息
 	@handler Login
 	post /auth/login (LoginReq) returns (LoginResp)
+
+	// LoginByCap 使用 cap.js 人机验证令牌登录,验证通过后执行与 Login 相同的业务逻辑
+	@handler LoginByCap
+	post /user/login/cap (LoginByCapReq) returns (LoginResp)
 }
 
 // 令牌刷新,不需要鉴权中间件,自行验证 refreshToken 有效性;受 IP 维度限流保护,防止签名爆破/CPU 放大 DoS
@@ -369,7 +419,6 @@ service perm-api {
 }
 
 // -------- 需要 JWT 鉴权的接口 --------
-
 // 认证相关(修改密码、获取用户信息、注销)
 @server (
 	prefix:     /api