diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 1b81fa29..97418537 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -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 运行模式;未传参数时自动进入交互菜单 - 新增 Docker 运行模式的智能环境检查,检测到未安装 Docker 时支持一键在线安装,中国大陆环境支持多镜像源自动测速优选与加速器配置 - 新增 Agent 交互式卸载向导,支持选择本地卸载和 Docker 容器卸载模式;未传参数时自动进入交互菜单 diff --git a/openflare-server/common/constants.go b/openflare-server/common/constants.go index 589db47b..e0261148 100644 --- a/openflare-server/common/constants.go +++ b/openflare-server/common/constants.go @@ -27,6 +27,7 @@ var OptionMapRWMutex sync.RWMutex var ItemsPerPage = 10 var PasswordLoginEnabled = true +var CapLoginEnabled = true var PasswordRegisterEnabled = false var EmailVerificationEnabled = false var GitHubOAuthEnabled = false diff --git a/openflare-server/controller/cap.go b/openflare-server/controller/cap.go new file mode 100644 index 00000000..ba120ca0 --- /dev/null +++ b/openflare-server/controller/cap.go @@ -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) +} diff --git a/openflare-server/controller/misc.go b/openflare-server/controller/misc.go index 2a549c6c..023a9ac8 100644 --- a/openflare-server/controller/misc.go +++ b/openflare-server/controller/misc.go @@ -39,6 +39,7 @@ func GetStatus(c *gin.Context) { "wechat_login": common.WeChatAuthEnabled, "server_address": common.ServerAddress, "password_register_enabled": common.PasswordRegisterEnabled, + "cap_login_enabled": common.CapLoginEnabled, "auth_sources": authSources, }) } diff --git a/openflare-server/main.go b/openflare-server/main.go index 607d48ed..d1876785 100644 --- a/openflare-server/main.go +++ b/openflare-server/main.go @@ -74,6 +74,7 @@ func main() { // Initialize options model.InitOptionMap() + service.InitCap() middleware.InitJWTMiddleware() geoip.InitGeoIP(common.GeoIPProvider) backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background()) diff --git a/openflare-server/middleware/cap.go b/openflare-server/middleware/cap.go new file mode 100644 index 00000000..e7e430ef --- /dev/null +++ b/openflare-server/middleware/cap.go @@ -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 + }) +} diff --git a/openflare-server/model/option.go b/openflare-server/model/option.go index c9fc4471..59e60bb0 100644 --- a/openflare-server/model/option.go +++ b/openflare-server/model/option.go @@ -27,6 +27,7 @@ func InitOptionMap() { common.OptionMapRWMutex.Lock() common.OptionMap = make(map[string]string) common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled) + common.OptionMap["CapLoginEnabled"] = strconv.FormatBool(common.CapLoginEnabled) common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled) common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) @@ -170,6 +171,8 @@ func updateOptionMap(key string, value string) { common.PasswordRegisterEnabled = boolValue case "PasswordLoginEnabled": common.PasswordLoginEnabled = boolValue + case "CapLoginEnabled": + common.CapLoginEnabled = boolValue case "EmailVerificationEnabled": common.EmailVerificationEnabled = boolValue case "GitHubOAuthEnabled": diff --git a/openflare-server/router/api-router.go b/openflare-server/router/api-router.go index b94da173..e19a9357 100644 --- a/openflare-server/router/api-router.go +++ b/openflare-server/router/api-router.go @@ -31,10 +31,16 @@ func SetApiRouter(router *gin.Engine) { 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.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) selfRoute := userRoute.Group("/") diff --git a/openflare-server/service/cap.go b/openflare-server/service/cap.go new file mode 100644 index 00000000..ea8a3c66 --- /dev/null +++ b/openflare-server/service/cap.go @@ -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) +} diff --git a/openflare-server/utils/cap/cap.go b/openflare-server/utils/cap/cap.go new file mode 100644 index 00000000..a261b0c6 --- /dev/null +++ b/openflare-server/utils/cap/cap.go @@ -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 +} diff --git a/openflare-server/utils/cap/cap_test.go b/openflare-server/utils/cap/cap_test.go new file mode 100644 index 00000000..c8cee314 --- /dev/null +++ b/openflare-server/utils/cap/cap_test.go @@ -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)") + } +} diff --git a/openflare-server/utils/cap/manager.go b/openflare-server/utils/cap/manager.go new file mode 100644 index 00000000..af3e5621 --- /dev/null +++ b/openflare-server/utils/cap/manager.go @@ -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 +} diff --git a/openflare-server/utils/cap/middleware.go b/openflare-server/utils/cap/middleware.go new file mode 100644 index 00000000..6544c137 --- /dev/null +++ b/openflare-server/utils/cap/middleware.go @@ -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() + } +} diff --git a/openflare-server/utils/cap/prng.go b/openflare-server/utils/cap/prng.go new file mode 100644 index 00000000..f85bbd4e --- /dev/null +++ b/openflare-server/utils/cap/prng.go @@ -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] +} diff --git a/openflare-server/utils/cap/store.go b/openflare-server/utils/cap/store.go new file mode 100644 index 00000000..f77f42dc --- /dev/null +++ b/openflare-server/utils/cap/store.go @@ -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) + } + } +} diff --git a/openflare-server/web/features/auth/api/auth.ts b/openflare-server/web/features/auth/api/auth.ts index 648aae58..4cf6858b 100644 --- a/openflare-server/web/features/auth/api/auth.ts +++ b/openflare-server/web/features/auth/api/auth.ts @@ -1,23 +1,21 @@ -import { apiRequest } from '@/lib/api/client'; -import { - clearStoredOpenFlareToken, - setStoredOpenFlareToken, -} from '@/lib/api/auth-token'; -import type { - AuthUser, - LoginPayload, - PasswordResetRequestPayload, - RegisterPayload, -} from '@/types/auth'; +import {apiRequest} from '@/lib/api/client'; +import {clearStoredOpenFlareToken, setStoredOpenFlareToken,} from '@/lib/api/auth-token'; +import type {AuthUser, LoginPayload, PasswordResetRequestPayload, RegisterPayload,} from '@/types/auth'; export function getCurrentUser() { return apiRequest('/user/self'); } export function login(payload: LoginPayload) { + const { cap_token, ...body } = payload; + const headers: Record = {}; + if (cap_token) { + headers['X-Cap-Token'] = cap_token; + } return apiRequest('/user/login', { method: 'POST', - body: JSON.stringify(payload), + headers, + body: JSON.stringify(body), }).then((user) => { if (user.token) { setStoredOpenFlareToken(user.token); diff --git a/openflare-server/web/features/auth/components/login-form.tsx b/openflare-server/web/features/auth/components/login-form.tsx index 5618f0ee..936def77 100644 --- a/openflare-server/web/features/auth/components/login-form.tsx +++ b/openflare-server/web/features/auth/components/login-form.tsx @@ -4,7 +4,7 @@ import {zodResolver} from '@hookform/resolvers/zod'; import {useMutation, useQuery} from '@tanstack/react-query'; import Link from 'next/link'; import {useRouter, useSearchParams} from 'next/navigation'; -import {useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import {useForm} from 'react-hook-form'; 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 {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 & { + ref?: React.RefObject | React.Ref; + onsolve?: (e: CustomEvent<{ token: string }>) => void; + 'data-cap-api-endpoint'?: string; + }, + HTMLElement + >; + } + } +} + const TEXT = { usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d', passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801', @@ -43,6 +59,8 @@ export function LoginForm() { const searchParams = useSearchParams(); const { setUser } = useAuth(); const [errorMessage, setErrorMessage] = useState(''); + const [capToken, setCapToken] = useState(''); + const capWidgetRef = useRef void }>(null); const redirect = searchParams?.get('redirect') || '/'; const form = useForm({ @@ -58,6 +76,19 @@ export function LoginForm() { 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({ mutationFn: login, onSuccess: (user) => { @@ -66,6 +97,10 @@ export function LoginForm() { }, onError: (error: Error) => { 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) => { setErrorMessage(''); - loginMutation.mutate(values); + loginMutation.mutate({ + ...values, + cap_token: capToken || undefined, + }); }); const handleOAuthLogin = (sourceName: string) => { @@ -118,12 +156,27 @@ export function LoginForm() { ) : null} + {statusQuery.data?.cap_login_enabled ? ( +
+ ) => { + setCapToken(e.detail.token); + }} + /> +
+ ) : null} + {errorMessage ? ( ) : null}
- + {loginMutation.isPending ? TEXT.loginPending : TEXT.login}
diff --git a/openflare-server/web/features/settings/components/settings-page.tsx b/openflare-server/web/features/settings/components/settings-page.tsx index 2a594e9f..13ae5cf8 100644 --- a/openflare-server/web/features/settings/components/settings-page.tsx +++ b/openflare-server/web/features/settings/components/settings-page.tsx @@ -1,22 +1,19 @@ 'use client'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {useEffect, useMemo, useState} from 'react'; -import { EmptyState } from '@/components/feedback/empty-state'; -import { ErrorState } from '@/components/feedback/error-state'; -import { InlineMessage } from '@/components/feedback/inline-message'; -import { LoadingState } from '@/components/feedback/loading-state'; -import { AppModal } from '@/components/ui/app-modal'; -import { useAuth } from '@/components/providers/auth-provider'; -import { PageHeader } from '@/components/layout/page-header'; -import { AppCard } from '@/components/ui/app-card'; -import { StatusBadge } from '@/components/ui/status-badge'; -import { - getOAuthAuthorizeUrl, - sendEmailVerification, -} from '@/features/auth/api/auth'; -import { getPublicStatus } from '@/features/auth/api/public'; +import {EmptyState} from '@/components/feedback/empty-state'; +import {ErrorState} from '@/components/feedback/error-state'; +import {InlineMessage} from '@/components/feedback/inline-message'; +import {LoadingState} from '@/components/feedback/loading-state'; +import {AppModal} from '@/components/ui/app-modal'; +import {useAuth} from '@/components/providers/auth-provider'; +import {PageHeader} from '@/components/layout/page-header'; +import {AppCard} from '@/components/ui/app-card'; +import {StatusBadge} from '@/components/ui/status-badge'; +import {getOAuthAuthorizeUrl, sendEmailVerification,} from '@/features/auth/api/auth'; +import {getPublicStatus} from '@/features/auth/api/public'; import { bindEmail, cleanupDatabaseObservability, @@ -29,12 +26,12 @@ import { getSettingsProfile, lookupGeoIP, rotateBootstrapToken, + syncUptimeKuma, updateOptions, updateSelf, - syncUptimeKuma, } from '@/features/settings/api/settings'; -import { AuthSourceModal } from '@/features/settings/components/auth-source-modal'; -import { UptimeKumaSiteSelectModal } from './uptimekuma-modal'; +import {AuthSourceModal} from '@/features/settings/components/auth-source-modal'; +import {UptimeKumaSiteSelectModal} from './uptimekuma-modal'; import type { BootstrapTokenPayload, DatabaseCleanupResult, @@ -54,7 +51,7 @@ import { SecondaryButton, ToggleField, } 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 authSourcesQueryKey = ['settings', 'auth-sources'] as const; @@ -68,6 +65,7 @@ const installerScriptUrl = const defaultSystemFields = { ServerAddress: '', PasswordLoginEnabled: true, + CapLoginEnabled: true, PasswordRegisterEnabled: true, EmailVerificationEnabled: false, GitHubOAuthEnabled: false, @@ -358,6 +356,7 @@ export function SettingsPage() { setSystemFields({ ServerAddress: resolvedServerAddress, PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true), + CapLoginEnabled: toBoolean(optionMap.CapLoginEnabled, true), PasswordRegisterEnabled: toBoolean( optionMap.PasswordRegisterEnabled, true, @@ -1841,6 +1840,15 @@ export function SettingsPage() { } disabled={busyKey === 'toggle-PasswordLoginEnabled'} /> + + handleToggleOption('CapLoginEnabled', checked) + } + disabled={busyKey === 'toggle-CapLoginEnabled'} + />