mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +08:00
[优化] POW 系统接口方案
This commit is contained in:
@@ -20,6 +20,12 @@ sidebar: false
|
|||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
|
- 新增密码登录人机验证(基于 Proof-of-Work 和无感浏览器检测的 Cap 验证码防护)
|
||||||
|
- 新增后端 PoW 校验服务,实现 FNV-1a/XORShift PRNG 难题生成、验证及 JWT 难题校验算法
|
||||||
|
- 新增线程安全的内存 TTL 核销缓存,支持高并发与 Single-use 难题令牌防重放
|
||||||
|
- 新增 Gin 拦截中间件与登录路由 `X-Cap-Token` 自动校验,支持从 HTTP 请求头验证并放行
|
||||||
|
- 前端登录页集成 cap-widget 组件,按需加载 CDN 脚本,实现静默 PoW 求解与令牌提交
|
||||||
|
- 管理后台系统设置页“登录与注册开关”中新增“启用登录人机验证”开关,支持热更新全局防护状态
|
||||||
- 新增 Agent 交互式安装向导,支持选择本地安装和 Docker 运行模式;未传参数时自动进入交互菜单
|
- 新增 Agent 交互式安装向导,支持选择本地安装和 Docker 运行模式;未传参数时自动进入交互菜单
|
||||||
- 新增 Docker 运行模式的智能环境检查,检测到未安装 Docker 时支持一键在线安装,中国大陆环境支持多镜像源自动测速优选与加速器配置
|
- 新增 Docker 运行模式的智能环境检查,检测到未安装 Docker 时支持一键在线安装,中国大陆环境支持多镜像源自动测速优选与加速器配置
|
||||||
- 新增 Agent 交互式卸载向导,支持选择本地卸载和 Docker 容器卸载模式;未传参数时自动进入交互菜单
|
- 新增 Agent 交互式卸载向导,支持选择本地卸载和 Docker 容器卸载模式;未传参数时自动进入交互菜单
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ var OptionMapRWMutex sync.RWMutex
|
|||||||
var ItemsPerPage = 10
|
var ItemsPerPage = 10
|
||||||
|
|
||||||
var PasswordLoginEnabled = true
|
var PasswordLoginEnabled = true
|
||||||
|
var CapLoginEnabled = true
|
||||||
var PasswordRegisterEnabled = false
|
var PasswordRegisterEnabled = false
|
||||||
var EmailVerificationEnabled = false
|
var EmailVerificationEnabled = false
|
||||||
var GitHubOAuthEnabled = false
|
var GitHubOAuthEnabled = false
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rain-kl/openflare/openflare-server/controller/bind"
|
||||||
|
"github.com/rain-kl/openflare/openflare-server/service"
|
||||||
|
"github.com/rain-kl/openflare/openflare-server/utils/cap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCapChallenge generates a new CAPTCHA challenge
|
||||||
|
func GetCapChallenge(c *gin.Context) {
|
||||||
|
scope := c.Query("scope")
|
||||||
|
resp, err := service.CapManager.Generate(scope)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemCapChallenge validates CAPTCHA solutions and yields a one-time redeem token
|
||||||
|
func RedeemCapChallenge(c *gin.Context) {
|
||||||
|
scope := c.Query("scope")
|
||||||
|
|
||||||
|
var req cap.RedeemRequest
|
||||||
|
if !bind.JSON(c, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := service.CapManager.Redeem(c.Request.Context(), req.Token, req.Solutions, scope)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ func GetStatus(c *gin.Context) {
|
|||||||
"wechat_login": common.WeChatAuthEnabled,
|
"wechat_login": common.WeChatAuthEnabled,
|
||||||
"server_address": common.ServerAddress,
|
"server_address": common.ServerAddress,
|
||||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||||
|
"cap_login_enabled": common.CapLoginEnabled,
|
||||||
"auth_sources": authSources,
|
"auth_sources": authSources,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize options
|
// Initialize options
|
||||||
model.InitOptionMap()
|
model.InitOptionMap()
|
||||||
|
service.InitCap()
|
||||||
middleware.InitJWTMiddleware()
|
middleware.InitJWTMiddleware()
|
||||||
geoip.InitGeoIP(common.GeoIPProvider)
|
geoip.InitGeoIP(common.GeoIPProvider)
|
||||||
backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background())
|
backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background())
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/rain-kl/openflare/openflare-server/common"
|
||||||
|
"github.com/rain-kl/openflare/openflare-server/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CapAuth wraps the core Cap middleware with OpenFlare's dynamic CapLoginEnabled configuration switch
|
||||||
|
func CapAuth(scope string) gin.HandlerFunc {
|
||||||
|
return service.CapManager.VerifyMiddleware(scope, func() bool {
|
||||||
|
return common.CapLoginEnabled
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ func InitOptionMap() {
|
|||||||
common.OptionMapRWMutex.Lock()
|
common.OptionMapRWMutex.Lock()
|
||||||
common.OptionMap = make(map[string]string)
|
common.OptionMap = make(map[string]string)
|
||||||
common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled)
|
common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled)
|
||||||
|
common.OptionMap["CapLoginEnabled"] = strconv.FormatBool(common.CapLoginEnabled)
|
||||||
common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
|
common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled)
|
||||||
common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
|
common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled)
|
||||||
common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
|
common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled)
|
||||||
@@ -170,6 +171,8 @@ func updateOptionMap(key string, value string) {
|
|||||||
common.PasswordRegisterEnabled = boolValue
|
common.PasswordRegisterEnabled = boolValue
|
||||||
case "PasswordLoginEnabled":
|
case "PasswordLoginEnabled":
|
||||||
common.PasswordLoginEnabled = boolValue
|
common.PasswordLoginEnabled = boolValue
|
||||||
|
case "CapLoginEnabled":
|
||||||
|
common.CapLoginEnabled = boolValue
|
||||||
case "EmailVerificationEnabled":
|
case "EmailVerificationEnabled":
|
||||||
common.EmailVerificationEnabled = boolValue
|
common.EmailVerificationEnabled = boolValue
|
||||||
case "GitHubOAuthEnabled":
|
case "GitHubOAuthEnabled":
|
||||||
|
|||||||
@@ -31,10 +31,16 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
externalAccountRoute.POST("/:id/delete", controller.DeleteExternalAccount)
|
externalAccountRoute.POST("/:id/delete", controller.DeleteExternalAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
capRoute := apiRouter.Group("/cap")
|
||||||
|
{
|
||||||
|
capRoute.POST("/challenge", middleware.CriticalRateLimit(), controller.GetCapChallenge)
|
||||||
|
capRoute.POST("/redeem", middleware.CriticalRateLimit(), controller.RedeemCapChallenge)
|
||||||
|
}
|
||||||
|
|
||||||
userRoute := apiRouter.Group("/user")
|
userRoute := apiRouter.Group("/user")
|
||||||
{
|
{
|
||||||
userRoute.POST("/register", middleware.CriticalRateLimit(), controller.Register)
|
userRoute.POST("/register", middleware.CriticalRateLimit(), controller.Register)
|
||||||
userRoute.POST("/login", middleware.CriticalRateLimit(), controller.Login)
|
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.CapAuth("login"), controller.Login)
|
||||||
userRoute.GET("/logout", controller.Logout)
|
userRoute.GET("/logout", controller.Logout)
|
||||||
|
|
||||||
selfRoute := userRoute.Group("/")
|
selfRoute := userRoute.Group("/")
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
"github.com/rain-kl/openflare/openflare-server/common"
|
||||||
|
"github.com/rain-kl/openflare/openflare-server/utils/cap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisCapStore wraps the shared Redis client to implement the cap.Store interface
|
||||||
|
type RedisCapStore struct{}
|
||||||
|
|
||||||
|
func (s *RedisCapStore) Get(ctx context.Context, key string) (string, bool, error) {
|
||||||
|
val, err := common.RDB.Get(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
if err == redis.Nil {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return val, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisCapStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error {
|
||||||
|
return common.RDB.Set(ctx, key, val, ttl).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RedisCapStore) Delete(ctx context.Context, key string) error {
|
||||||
|
return common.RDB.Del(ctx, key).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapManager is the global CAPTCHA manager instance
|
||||||
|
var CapManager *cap.Manager
|
||||||
|
|
||||||
|
// InitCap initializes the global CAPTCHA manager
|
||||||
|
func InitCap() {
|
||||||
|
var store cap.Store
|
||||||
|
if common.RedisEnabled {
|
||||||
|
store = &RedisCapStore{}
|
||||||
|
slog.Info("CAPTCHA service initialized with Redis store")
|
||||||
|
} else {
|
||||||
|
store = cap.NewMemoryStore(1 * time.Minute)
|
||||||
|
slog.Info("CAPTCHA service initialized with Memory store")
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := common.JWTSecret
|
||||||
|
if secret == "" {
|
||||||
|
secret = common.SessionSecret
|
||||||
|
}
|
||||||
|
secretBytes := []byte(secret)
|
||||||
|
if len(secretBytes) < 16 {
|
||||||
|
// CAPTCHA JWT verification requires a key of at least 16 bytes
|
||||||
|
padding := make([]byte, 16-len(secretBytes))
|
||||||
|
secretBytes = append(secretBytes, padding...)
|
||||||
|
}
|
||||||
|
|
||||||
|
CapManager = cap.NewManager(cap.Config{
|
||||||
|
Secret: secretBytes,
|
||||||
|
ChallengeCount: 50,
|
||||||
|
ChallengeSize: 32,
|
||||||
|
ChallengeDifficulty: 4,
|
||||||
|
ChallengeTTL: 10 * time.Minute,
|
||||||
|
TokenTTL: 20 * time.Minute,
|
||||||
|
}, store)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package cap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const jwtHeaderB64 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
||||||
|
|
||||||
|
// ChallengeConfig holds parameters for the PoW challenge
|
||||||
|
type ChallengeConfig struct {
|
||||||
|
Count int // Number of puzzles (c)
|
||||||
|
Size int // Salt length (s)
|
||||||
|
Difficulty int // Difficulty prefix length (d)
|
||||||
|
ExpiresMs time.Duration // Challenge TTL
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChallengeResponse is returned to the client
|
||||||
|
type ChallengeResponse struct {
|
||||||
|
Challenge struct {
|
||||||
|
C int `json:"c"`
|
||||||
|
S int `json:"s"`
|
||||||
|
D int `json:"d"`
|
||||||
|
} `json:"challenge"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Expires int64 `json:"expires"` // ms timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChallengePayload represents the signed JWT payload
|
||||||
|
type ChallengePayload struct {
|
||||||
|
Nonce string `json:"n"`
|
||||||
|
Count int `json:"c"`
|
||||||
|
Size int `json:"s"`
|
||||||
|
Difficulty int `json:"d"`
|
||||||
|
Expires int64 `json:"exp"` // ms timestamp
|
||||||
|
IssuedAt int64 `json:"iat"` // ms timestamp
|
||||||
|
Scope string `json:"sk,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemRequest payload sent by client
|
||||||
|
type RedeemRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Solutions []int `json:"solutions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedeemResponse returned to client after verification
|
||||||
|
type RedeemResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Expires int64 `json:"expires,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64urlEncode(data []byte) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func b64urlDecode(str string) ([]byte, error) {
|
||||||
|
return base64.RawURLEncoding.DecodeString(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(byteLen int) string {
|
||||||
|
bytes := make([]byte, byteLen)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jwtSign(payload []byte, secret []byte) string {
|
||||||
|
body := b64urlEncode(payload)
|
||||||
|
sigInput := jwtHeaderB64 + "." + body
|
||||||
|
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(sigInput))
|
||||||
|
sig := mac.Sum(nil)
|
||||||
|
|
||||||
|
return sigInput + "." + b64urlEncode(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jwtVerify(token string, secret []byte) ([]byte, error) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, errors.New("invalid token format")
|
||||||
|
}
|
||||||
|
if parts[0] != jwtHeaderB64 {
|
||||||
|
return nil, errors.New("invalid header")
|
||||||
|
}
|
||||||
|
|
||||||
|
sigInput := parts[0] + "." + parts[1]
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write([]byte(sigInput))
|
||||||
|
expectedSig := mac.Sum(nil)
|
||||||
|
|
||||||
|
actualSig, err := b64urlDecode(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hmac.Equal(expectedSig, actualSig) {
|
||||||
|
return nil, errors.New("signature mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := b64urlDecode(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jwtSigHex(token string) string {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sigBytes, err := b64urlDecode(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(sigBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateChallenge produces a new challenge and signed token
|
||||||
|
func GenerateChallenge(secret []byte, conf ChallengeConfig, scope string) (*ChallengeResponse, error) {
|
||||||
|
if conf.Count <= 0 {
|
||||||
|
conf.Count = 50
|
||||||
|
}
|
||||||
|
if conf.Size <= 0 {
|
||||||
|
conf.Size = 32
|
||||||
|
}
|
||||||
|
if conf.Difficulty <= 0 {
|
||||||
|
conf.Difficulty = 4
|
||||||
|
}
|
||||||
|
if conf.ExpiresMs <= 0 {
|
||||||
|
conf.ExpiresMs = 10 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixNano() / int64(time.Millisecond)
|
||||||
|
expires := now + int64(conf.ExpiresMs/time.Millisecond)
|
||||||
|
|
||||||
|
payload := ChallengePayload{
|
||||||
|
Nonce: randomHex(25),
|
||||||
|
Count: conf.Count,
|
||||||
|
Size: conf.Size,
|
||||||
|
Difficulty: conf.Difficulty,
|
||||||
|
Expires: expires,
|
||||||
|
IssuedAt: now,
|
||||||
|
Scope: scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwtSign(payloadBytes, secret)
|
||||||
|
|
||||||
|
resp := &ChallengeResponse{
|
||||||
|
Token: token,
|
||||||
|
Expires: expires,
|
||||||
|
}
|
||||||
|
resp.Challenge.C = conf.Count
|
||||||
|
resp.Challenge.S = conf.Size
|
||||||
|
resp.Challenge.D = conf.Difficulty
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyChallengeSolutions verifies client submitted solutions
|
||||||
|
func VerifyChallengeSolutions(token string, solutions []int, secret []byte, expectedScope string) (*ChallengePayload, error) {
|
||||||
|
payloadBytes, err := jwtVerify(token, secret)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("invalid_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload ChallengePayload
|
||||||
|
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
|
||||||
|
return nil, errors.New("invalid_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedScope != "" && payload.Scope != expectedScope {
|
||||||
|
return nil, errors.New("scope_mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixNano() / int64(time.Millisecond)
|
||||||
|
if payload.Expires < now {
|
||||||
|
return nil, errors.New("expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(solutions) != payload.Count {
|
||||||
|
return nil, errors.New("invalid_solutions")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenFnv := fnv1a(token)
|
||||||
|
for i := 0; i < payload.Count; i++ {
|
||||||
|
idxStr := strconv.Itoa(i + 1)
|
||||||
|
saltSeed := fnv1aResume(tokenFnv, idxStr)
|
||||||
|
targetSeed := fnv1aResume(saltSeed, "d")
|
||||||
|
salt := prngFromHash(saltSeed, payload.Size)
|
||||||
|
target := prngFromHash(targetSeed, payload.Difficulty)
|
||||||
|
|
||||||
|
hashInput := salt + strconv.Itoa(solutions[i])
|
||||||
|
hashBytes := sha256.Sum256([]byte(hashInput))
|
||||||
|
hashHex := hex.EncodeToString(hashBytes[:])
|
||||||
|
|
||||||
|
if !strings.HasPrefix(hashHex, target) {
|
||||||
|
return nil, errors.New("invalid_solution")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &payload, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package cap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCapFullFlow(t *testing.T) {
|
||||||
|
secret := []byte("a-very-long-secret-key-at-least-16-bytes")
|
||||||
|
store := NewMemoryStore(1 * time.Minute)
|
||||||
|
|
||||||
|
manager := NewManager(Config{
|
||||||
|
Secret: secret,
|
||||||
|
ChallengeCount: 3, // small count for fast test
|
||||||
|
ChallengeSize: 32,
|
||||||
|
ChallengeDifficulty: 3, // small difficulty for fast test
|
||||||
|
ChallengeTTL: 5 * time.Second,
|
||||||
|
TokenTTL: 10 * time.Second,
|
||||||
|
}, store)
|
||||||
|
|
||||||
|
scope := "test-scope"
|
||||||
|
resp, err := manager.Generate(scope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Generate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Challenge.C != 3 {
|
||||||
|
t.Errorf("Expected count 3, got %d", resp.Challenge.C)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solve the challenge (acting as client)
|
||||||
|
solutions := make([]int, resp.Challenge.C)
|
||||||
|
tokenFnv := fnv1a(resp.Token)
|
||||||
|
for i := 0; i < resp.Challenge.C; i++ {
|
||||||
|
idxStr := strconv.Itoa(i + 1)
|
||||||
|
saltSeed := fnv1aResume(tokenFnv, idxStr)
|
||||||
|
targetSeed := fnv1aResume(saltSeed, "d")
|
||||||
|
salt := prngFromHash(saltSeed, resp.Challenge.S)
|
||||||
|
target := prngFromHash(targetSeed, resp.Challenge.D)
|
||||||
|
|
||||||
|
// Brute force the PoW solution
|
||||||
|
var found bool
|
||||||
|
for nonce := 0; nonce < 1000000; nonce++ {
|
||||||
|
hashInput := salt + strconv.Itoa(nonce)
|
||||||
|
hashBytes := sha256.Sum256([]byte(hashInput))
|
||||||
|
hashHex := hex.EncodeToString(hashBytes[:])
|
||||||
|
if strings.HasPrefix(hashHex, target) {
|
||||||
|
solutions[i] = nonce
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("Failed to solve puzzle %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redeem
|
||||||
|
ctx := context.Background()
|
||||||
|
redeemResp, err := manager.Redeem(ctx, resp.Token, solutions, scope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Redeem failed: %v", err)
|
||||||
|
}
|
||||||
|
if !redeemResp.Success {
|
||||||
|
t.Fatalf("Redeem returned success=false: %s", redeemResp.Error)
|
||||||
|
}
|
||||||
|
if redeemResp.Token == "" {
|
||||||
|
t.Fatalf("Expected token, got empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the token
|
||||||
|
valid, err := manager.VerifyToken(ctx, redeemResp.Token, scope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyToken failed: %v", err)
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
t.Fatalf("Expected redeem token to be valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token is one-time use
|
||||||
|
validAgain, err := manager.VerifyToken(ctx, redeemResp.Token, scope)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("VerifyToken second call failed: %v", err)
|
||||||
|
}
|
||||||
|
if validAgain {
|
||||||
|
t.Fatalf("Expected redeem token to be single-use (invalidated after verification)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package cap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds settings for the CAPTCHA manager
|
||||||
|
type Config struct {
|
||||||
|
Secret []byte // HMAC signing key
|
||||||
|
ChallengeCount int // Number of PoW puzzles
|
||||||
|
ChallengeSize int // Size of the salt string
|
||||||
|
ChallengeDifficulty int // Length of difficulty target prefix
|
||||||
|
ChallengeTTL time.Duration // Lifespan of the challenge JWT
|
||||||
|
TokenTTL time.Duration // Lifespan of the redeem token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager orchestrates challenge generation and solution validation
|
||||||
|
type Manager struct {
|
||||||
|
conf Config
|
||||||
|
store Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new CAPTCHA Manager
|
||||||
|
func NewManager(conf Config, store Store) *Manager {
|
||||||
|
if conf.ChallengeCount <= 0 {
|
||||||
|
conf.ChallengeCount = 50
|
||||||
|
}
|
||||||
|
if conf.ChallengeSize <= 0 {
|
||||||
|
conf.ChallengeSize = 32
|
||||||
|
}
|
||||||
|
if conf.ChallengeDifficulty <= 0 {
|
||||||
|
conf.ChallengeDifficulty = 4
|
||||||
|
}
|
||||||
|
if conf.ChallengeTTL <= 0 {
|
||||||
|
conf.ChallengeTTL = 10 * time.Minute
|
||||||
|
}
|
||||||
|
if conf.TokenTTL <= 0 {
|
||||||
|
conf.TokenTTL = 20 * time.Minute
|
||||||
|
}
|
||||||
|
return &Manager{
|
||||||
|
conf: conf,
|
||||||
|
store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate creates a challenge response
|
||||||
|
func (m *Manager) Generate(scope string) (*ChallengeResponse, error) {
|
||||||
|
c := ChallengeConfig{
|
||||||
|
Count: m.conf.ChallengeCount,
|
||||||
|
Size: m.conf.ChallengeSize,
|
||||||
|
Difficulty: m.conf.ChallengeDifficulty,
|
||||||
|
ExpiresMs: m.conf.ChallengeTTL,
|
||||||
|
}
|
||||||
|
return GenerateChallenge(m.conf.Secret, c, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redeem verifies PoW solutions and returns a one-time redeem token
|
||||||
|
func (m *Manager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*RedeemResponse, error) {
|
||||||
|
sigHex := jwtSigHex(token)
|
||||||
|
if sigHex == "" {
|
||||||
|
return &RedeemResponse{Success: false, Error: "invalid_token"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay prevention: check if this JWT signature has already been used
|
||||||
|
nonceKey := "cap:nonce:" + sigHex
|
||||||
|
_, exists, err := m.store.Get(ctx, nonceKey)
|
||||||
|
if err != nil {
|
||||||
|
return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return &RedeemResponse{Success: false, Error: "already_redeemed"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := VerifyChallengeSolutions(token, solutions, m.conf.Secret, scope)
|
||||||
|
if err != nil {
|
||||||
|
return &RedeemResponse{Success: false, Error: err.Error()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verification succeeded. Consume the nonce.
|
||||||
|
now := time.Now().UnixNano() / int64(time.Millisecond)
|
||||||
|
ttlMs := time.Duration(payload.Expires-now) * time.Millisecond
|
||||||
|
if ttlMs < time.Second {
|
||||||
|
ttlMs = time.Second
|
||||||
|
}
|
||||||
|
if err := m.store.Set(ctx, nonceKey, "1", ttlMs); err != nil {
|
||||||
|
return &RedeemResponse{Success: false, Error: "nonce_store_error"}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a redeem token formatted as "id:verToken"
|
||||||
|
id := randomHex(8)
|
||||||
|
verToken := randomHex(15)
|
||||||
|
verHashBytes := sha256.Sum256([]byte(verToken))
|
||||||
|
verHashHex := hex.EncodeToString(verHashBytes[:])
|
||||||
|
|
||||||
|
tokenKey := "cap:token:" + id + ":" + verHashHex
|
||||||
|
tokenExpires := time.Now().Add(m.conf.TokenTTL)
|
||||||
|
|
||||||
|
// Value stored is "expiresNano|scope"
|
||||||
|
storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope
|
||||||
|
|
||||||
|
if err := m.store.Set(ctx, tokenKey, storeVal, m.conf.TokenTTL); err != nil {
|
||||||
|
return &RedeemResponse{Success: false, Error: "token_store_error"}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RedeemResponse{
|
||||||
|
Success: true,
|
||||||
|
Token: id + ":" + verToken,
|
||||||
|
Expires: tokenExpires.UnixNano() / int64(time.Millisecond),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyToken validates and consumes the redeem token (single-use)
|
||||||
|
func (m *Manager) VerifyToken(ctx context.Context, token string, expectedScope string) (bool, error) {
|
||||||
|
if token == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ":")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
id := parts[0]
|
||||||
|
verToken := parts[1]
|
||||||
|
|
||||||
|
verHashBytes := sha256.Sum256([]byte(verToken))
|
||||||
|
verHashHex := hex.EncodeToString(verHashBytes[:])
|
||||||
|
|
||||||
|
tokenKey := "cap:token:" + id + ":" + verHashHex
|
||||||
|
|
||||||
|
val, exists, err := m.store.Get(ctx, tokenKey)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-use: consume/delete the token immediately
|
||||||
|
_ = m.store.Delete(ctx, tokenKey)
|
||||||
|
|
||||||
|
valParts := strings.Split(val, "|")
|
||||||
|
if len(valParts) != 2 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expNano, err := strconv.ParseInt(valParts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
tokenScope := valParts[1]
|
||||||
|
|
||||||
|
if expectedScope != "" && tokenScope != expectedScope {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().UnixNano() > expNano {
|
||||||
|
return false, nil // Expired
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package cap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
|
||||||
|
// enabledFunc is an optional callback allowing dynamic check of whether captcha protection is turned on.
|
||||||
|
func (m *Manager) VerifyMiddleware(scope string, enabledFunc func() bool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if enabledFunc != nil && !enabledFunc() {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := c.GetHeader("X-Cap-Token")
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "验证码验证失败,缺少验证码凭证",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := m.VerifyToken(c.Request.Context(), token, scope)
|
||||||
|
if err != nil || !valid {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "验证码校验失败或已过期,请重试",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package cap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fnv1a returns the 32-bit FNV-1a hash of a string
|
||||||
|
func fnv1a(str string) uint32 {
|
||||||
|
var hash uint32 = 2166136261
|
||||||
|
for i := 0; i < len(str); i++ {
|
||||||
|
hash ^= uint32(str[i])
|
||||||
|
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// fnv1aResume resumes FNV-1a hashing from a given state
|
||||||
|
func fnv1aResume(state uint32, str string) uint32 {
|
||||||
|
h := state
|
||||||
|
for i := 0; i < len(str); i++ {
|
||||||
|
h ^= uint32(str[i])
|
||||||
|
h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// prng generates a hex string of specified length using a seed
|
||||||
|
func prng(seed string, length int) string {
|
||||||
|
return prngFromHash(fnv1a(seed), length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prngFromHash generates a hex string of specified length using an initial hash state
|
||||||
|
func prngFromHash(initialHash uint32, length int) string {
|
||||||
|
state := initialHash
|
||||||
|
var result strings.Builder
|
||||||
|
for result.Len() < length {
|
||||||
|
state ^= state << 13
|
||||||
|
state ^= state >> 17
|
||||||
|
state ^= state << 5
|
||||||
|
hexStr := fmt.Sprintf("%08x", state)
|
||||||
|
result.WriteString(hexStr)
|
||||||
|
}
|
||||||
|
return result.String()[:length]
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package cap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store defines the storage interface for challenge nonces and verification tokens
|
||||||
|
type Store interface {
|
||||||
|
Get(ctx context.Context, key string) (string, bool, error)
|
||||||
|
Set(ctx context.Context, key string, val string, ttl time.Duration) error
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryItem struct {
|
||||||
|
value string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryStore is a thread-safe in-memory implementation of Store
|
||||||
|
type MemoryStore struct {
|
||||||
|
items map[string]memoryItem
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemoryStore creates and initializes a new MemoryStore
|
||||||
|
func NewMemoryStore(cleanupInterval time.Duration) *MemoryStore {
|
||||||
|
store := &MemoryStore{
|
||||||
|
items: make(map[string]memoryItem),
|
||||||
|
}
|
||||||
|
if cleanupInterval > 0 {
|
||||||
|
go store.startCleanupLoop(cleanupInterval)
|
||||||
|
}
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) Get(ctx context.Context, key string) (string, bool, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
item, found := s.items[key]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(item.expiresAt) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.items, key)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.value, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.items[key] = memoryItem{
|
||||||
|
value: val,
|
||||||
|
expiresAt: time.Now().Add(ttl),
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) Delete(ctx context.Context, key string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.items, key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) startCleanupLoop(interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
for range ticker.C {
|
||||||
|
s.cleanupExpired()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MemoryStore) cleanupExpired() {
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for k, v := range s.items {
|
||||||
|
if now.After(v.expiresAt) {
|
||||||
|
delete(s.items, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
import { apiRequest } from '@/lib/api/client';
|
import {apiRequest} from '@/lib/api/client';
|
||||||
import {
|
import {clearStoredOpenFlareToken, setStoredOpenFlareToken,} from '@/lib/api/auth-token';
|
||||||
clearStoredOpenFlareToken,
|
import type {AuthUser, LoginPayload, PasswordResetRequestPayload, RegisterPayload,} from '@/types/auth';
|
||||||
setStoredOpenFlareToken,
|
|
||||||
} from '@/lib/api/auth-token';
|
|
||||||
import type {
|
|
||||||
AuthUser,
|
|
||||||
LoginPayload,
|
|
||||||
PasswordResetRequestPayload,
|
|
||||||
RegisterPayload,
|
|
||||||
} from '@/types/auth';
|
|
||||||
|
|
||||||
export function getCurrentUser() {
|
export function getCurrentUser() {
|
||||||
return apiRequest<AuthUser>('/user/self');
|
return apiRequest<AuthUser>('/user/self');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(payload: LoginPayload) {
|
export function login(payload: LoginPayload) {
|
||||||
|
const { cap_token, ...body } = payload;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (cap_token) {
|
||||||
|
headers['X-Cap-Token'] = cap_token;
|
||||||
|
}
|
||||||
return apiRequest<AuthUser>('/user/login', {
|
return apiRequest<AuthUser>('/user/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
}).then((user) => {
|
}).then((user) => {
|
||||||
if (user.token) {
|
if (user.token) {
|
||||||
setStoredOpenFlareToken(user.token);
|
setStoredOpenFlareToken(user.token);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {zodResolver} from '@hookform/resolvers/zod';
|
|||||||
import {useMutation, useQuery} from '@tanstack/react-query';
|
import {useMutation, useQuery} from '@tanstack/react-query';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter, useSearchParams} from 'next/navigation';
|
import {useRouter, useSearchParams} from 'next/navigation';
|
||||||
import {useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import {useForm} from 'react-hook-form';
|
import {useForm} from 'react-hook-form';
|
||||||
import {z} from 'zod';
|
import {z} from 'zod';
|
||||||
|
|
||||||
@@ -16,6 +16,22 @@ import {getPublicStatus} from '@/features/auth/api/public';
|
|||||||
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
|
import {AuthButton, AuthFormField, AuthInput, SecondaryButton,} from '@/features/auth/components/auth-form-primitives';
|
||||||
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
|
import {PublicAuthGuard} from '@/features/auth/components/public-auth-guard';
|
||||||
|
|
||||||
|
declare module 'react' {
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-namespace */
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
'cap-widget': React.DetailedHTMLProps<
|
||||||
|
React.HTMLAttributes<HTMLElement> & {
|
||||||
|
ref?: React.RefObject<unknown> | React.Ref<unknown>;
|
||||||
|
onsolve?: (e: CustomEvent<{ token: string }>) => void;
|
||||||
|
'data-cap-api-endpoint'?: string;
|
||||||
|
},
|
||||||
|
HTMLElement
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const TEXT = {
|
const TEXT = {
|
||||||
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
||||||
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
|
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
|
||||||
@@ -43,6 +59,8 @@ export function LoginForm() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { setUser } = useAuth();
|
const { setUser } = useAuth();
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [capToken, setCapToken] = useState('');
|
||||||
|
const capWidgetRef = useRef<HTMLElement & { reset?: () => void }>(null);
|
||||||
const redirect = searchParams?.get('redirect') || '/';
|
const redirect = searchParams?.get('redirect') || '/';
|
||||||
|
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
@@ -58,6 +76,19 @@ export function LoginForm() {
|
|||||||
queryFn: getPublicStatus,
|
queryFn: getPublicStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (statusQuery.data?.cap_login_enabled) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||||
|
script.type = 'module';
|
||||||
|
script.async = true;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(script);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [statusQuery.data?.cap_login_enabled]);
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: login,
|
mutationFn: login,
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
@@ -66,6 +97,10 @@ export function LoginForm() {
|
|||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
setErrorMessage(error.message || TEXT.loginFailed);
|
setErrorMessage(error.message || TEXT.loginFailed);
|
||||||
|
setCapToken('');
|
||||||
|
if (capWidgetRef.current && typeof capWidgetRef.current.reset === 'function') {
|
||||||
|
capWidgetRef.current.reset();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +116,10 @@ export function LoginForm() {
|
|||||||
|
|
||||||
const handleSubmit = form.handleSubmit((values) => {
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
loginMutation.mutate(values);
|
loginMutation.mutate({
|
||||||
|
...values,
|
||||||
|
cap_token: capToken || undefined,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOAuthLogin = (sourceName: string) => {
|
const handleOAuthLogin = (sourceName: string) => {
|
||||||
@@ -118,12 +156,27 @@ export function LoginForm() {
|
|||||||
) : null}
|
) : null}
|
||||||
</AuthFormField>
|
</AuthFormField>
|
||||||
|
|
||||||
|
{statusQuery.data?.cap_login_enabled ? (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<cap-widget
|
||||||
|
ref={capWidgetRef}
|
||||||
|
data-cap-api-endpoint="/api/cap/"
|
||||||
|
onsolve={(e: CustomEvent<{ token: string }>) => {
|
||||||
|
setCapToken(e.detail.token);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<InlineMessage tone="danger" message={errorMessage} />
|
<InlineMessage tone="danger" message={errorMessage} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AuthButton type="submit" disabled={loginMutation.isPending}>
|
<AuthButton
|
||||||
|
type="submit"
|
||||||
|
disabled={loginMutation.isPending || (statusQuery.data?.cap_login_enabled && !capToken)}
|
||||||
|
>
|
||||||
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
|
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import {EmptyState} from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import {ErrorState} from '@/components/feedback/error-state';
|
||||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import {LoadingState} from '@/components/feedback/loading-state';
|
||||||
import { AppModal } from '@/components/ui/app-modal';
|
import {AppModal} from '@/components/ui/app-modal';
|
||||||
import { useAuth } from '@/components/providers/auth-provider';
|
import {useAuth} from '@/components/providers/auth-provider';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import {PageHeader} from '@/components/layout/page-header';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import {AppCard} from '@/components/ui/app-card';
|
||||||
import { StatusBadge } from '@/components/ui/status-badge';
|
import {StatusBadge} from '@/components/ui/status-badge';
|
||||||
import {
|
import {getOAuthAuthorizeUrl, sendEmailVerification,} from '@/features/auth/api/auth';
|
||||||
getOAuthAuthorizeUrl,
|
import {getPublicStatus} from '@/features/auth/api/public';
|
||||||
sendEmailVerification,
|
|
||||||
} from '@/features/auth/api/auth';
|
|
||||||
import { getPublicStatus } from '@/features/auth/api/public';
|
|
||||||
import {
|
import {
|
||||||
bindEmail,
|
bindEmail,
|
||||||
cleanupDatabaseObservability,
|
cleanupDatabaseObservability,
|
||||||
@@ -29,12 +26,12 @@ import {
|
|||||||
getSettingsProfile,
|
getSettingsProfile,
|
||||||
lookupGeoIP,
|
lookupGeoIP,
|
||||||
rotateBootstrapToken,
|
rotateBootstrapToken,
|
||||||
|
syncUptimeKuma,
|
||||||
updateOptions,
|
updateOptions,
|
||||||
updateSelf,
|
updateSelf,
|
||||||
syncUptimeKuma,
|
|
||||||
} from '@/features/settings/api/settings';
|
} from '@/features/settings/api/settings';
|
||||||
import { AuthSourceModal } from '@/features/settings/components/auth-source-modal';
|
import {AuthSourceModal} from '@/features/settings/components/auth-source-modal';
|
||||||
import { UptimeKumaSiteSelectModal } from './uptimekuma-modal';
|
import {UptimeKumaSiteSelectModal} from './uptimekuma-modal';
|
||||||
import type {
|
import type {
|
||||||
BootstrapTokenPayload,
|
BootstrapTokenPayload,
|
||||||
DatabaseCleanupResult,
|
DatabaseCleanupResult,
|
||||||
@@ -54,7 +51,7 @@ import {
|
|||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
ToggleField,
|
ToggleField,
|
||||||
} from '@/features/shared/components/resource-primitives';
|
} from '@/features/shared/components/resource-primitives';
|
||||||
import { formatDateTime } from '@/lib/utils/date';
|
import {formatDateTime} from '@/lib/utils/date';
|
||||||
|
|
||||||
const settingsQueryKey = ['settings', 'options'] as const;
|
const settingsQueryKey = ['settings', 'options'] as const;
|
||||||
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
|
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
|
||||||
@@ -68,6 +65,7 @@ const installerScriptUrl =
|
|||||||
const defaultSystemFields = {
|
const defaultSystemFields = {
|
||||||
ServerAddress: '',
|
ServerAddress: '',
|
||||||
PasswordLoginEnabled: true,
|
PasswordLoginEnabled: true,
|
||||||
|
CapLoginEnabled: true,
|
||||||
PasswordRegisterEnabled: true,
|
PasswordRegisterEnabled: true,
|
||||||
EmailVerificationEnabled: false,
|
EmailVerificationEnabled: false,
|
||||||
GitHubOAuthEnabled: false,
|
GitHubOAuthEnabled: false,
|
||||||
@@ -358,6 +356,7 @@ export function SettingsPage() {
|
|||||||
setSystemFields({
|
setSystemFields({
|
||||||
ServerAddress: resolvedServerAddress,
|
ServerAddress: resolvedServerAddress,
|
||||||
PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true),
|
PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true),
|
||||||
|
CapLoginEnabled: toBoolean(optionMap.CapLoginEnabled, true),
|
||||||
PasswordRegisterEnabled: toBoolean(
|
PasswordRegisterEnabled: toBoolean(
|
||||||
optionMap.PasswordRegisterEnabled,
|
optionMap.PasswordRegisterEnabled,
|
||||||
true,
|
true,
|
||||||
@@ -1841,6 +1840,15 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
disabled={busyKey === 'toggle-PasswordLoginEnabled'}
|
disabled={busyKey === 'toggle-PasswordLoginEnabled'}
|
||||||
/>
|
/>
|
||||||
|
<ToggleField
|
||||||
|
label="启用登录人机验证"
|
||||||
|
description="开启后,密码登录前必须完成 Proof-of-Work 人机验证,防止暴力破解。"
|
||||||
|
checked={systemFields.CapLoginEnabled}
|
||||||
|
onChange={(checked) =>
|
||||||
|
handleToggleOption('CapLoginEnabled', checked)
|
||||||
|
}
|
||||||
|
disabled={busyKey === 'toggle-CapLoginEnabled'}
|
||||||
|
/>
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label="允许密码注册"
|
label="允许密码注册"
|
||||||
description="关闭后新用户不能通过密码方式注册。"
|
description="关闭后新用户不能通过密码方式注册。"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface AuthUser {
|
|||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
cap_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterPayload {
|
export interface RegisterPayload {
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ export interface PublicStatus {
|
|||||||
wechat_login: boolean;
|
wechat_login: boolean;
|
||||||
server_address: string;
|
server_address: string;
|
||||||
password_register_enabled: boolean;
|
password_register_enabled: boolean;
|
||||||
|
cap_login_enabled?: boolean;
|
||||||
auth_sources: PublicAuthSource[];
|
auth_sources: PublicAuthSource[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user