mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +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 运行模式;未传参数时自动进入交互菜单
|
||||
- 新增 Docker 运行模式的智能环境检查,检测到未安装 Docker 时支持一键在线安装,中国大陆环境支持多镜像源自动测速优选与加速器配置
|
||||
- 新增 Agent 交互式卸载向导,支持选择本地卸载和 Docker 容器卸载模式;未传参数时自动进入交互菜单
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
"server_address": common.ServerAddress,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"cap_login_enabled": common.CapLoginEnabled,
|
||||
"auth_sources": authSources,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ func main() {
|
||||
|
||||
// Initialize options
|
||||
model.InitOptionMap()
|
||||
service.InitCap()
|
||||
middleware.InitJWTMiddleware()
|
||||
geoip.InitGeoIP(common.GeoIPProvider)
|
||||
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.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":
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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 {
|
||||
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="关闭后新用户不能通过密码方式注册。"
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user