[优化] POW 系统接口方案

This commit is contained in:
ryan
2026-06-06 12:13:55 +08:00
parent 4be7c19798
commit 6bf5023af1
20 changed files with 896 additions and 36 deletions
+6
View File
@@ -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 容器卸载模式;未传参数时自动进入交互菜单
+1
View File
@@ -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
+45
View File
@@ -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)
}
+1
View File
@@ -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,
})
}
+1
View File
@@ -74,6 +74,7 @@ func main() {
// Initialize options
model.InitOptionMap()
service.InitCap()
middleware.InitJWTMiddleware()
geoip.InitGeoIP(common.GeoIPProvider)
backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background())
+14
View File
@@ -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
})
}
+3
View File
@@ -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":
+7 -1
View File
@@ -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("/")
+68
View File
@@ -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)
}
+221
View File
@@ -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
}
+93
View File
@@ -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)")
}
}
+165
View File
@@ -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
}
+40
View File
@@ -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()
}
}
+45
View File
@@ -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]
}
+90
View File
@@ -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)
}
}
}
+10 -12
View File
@@ -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<AuthUser>('/user/self');
}
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', {
method: 'POST',
body: JSON.stringify(payload),
headers,
body: JSON.stringify(body),
}).then((user) => {
if (user.token) {
setStoredOpenFlareToken(user.token);
@@ -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<HTMLElement> & {
ref?: React.RefObject<unknown> | React.Ref<unknown>;
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<HTMLElement & { reset?: () => void }>(null);
const redirect = searchParams?.get('redirect') || '/';
const form = useForm<LoginFormValues>({
@@ -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}
</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 ? (
<InlineMessage tone="danger" message={errorMessage} />
) : null}
<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}
</AuthButton>
</div>
@@ -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'}
/>
<ToggleField
label="启用登录人机验证"
description="开启后,密码登录前必须完成 Proof-of-Work 人机验证,防止暴力破解。"
checked={systemFields.CapLoginEnabled}
onChange={(checked) =>
handleToggleOption('CapLoginEnabled', checked)
}
disabled={busyKey === 'toggle-CapLoginEnabled'}
/>
<ToggleField
label="允许密码注册"
description="关闭后新用户不能通过密码方式注册。"
+1
View File
@@ -13,6 +13,7 @@ export interface AuthUser {
export interface LoginPayload {
username: string;
password: string;
cap_token?: string;
}
export interface RegisterPayload {
@@ -20,5 +20,6 @@ export interface PublicStatus {
wechat_login: boolean;
server_address: string;
password_register_enabled: boolean;
cap_login_enabled?: boolean;
auth_sources: PublicAuthSource[];
}