feat(core): sync framework security hardening and accessibility improvements

- add util.Go with panic recovery for background goroutines
- add util.EscapeLike and explicit ESCAPE clause for SQL LIKE queries
- add DummyCheckPassword and subtle.ConstantTimeCompare against timing attacks
- enforce session ID rotation upon login/oauth callback to prevent session fixation
- add sliding window login failure rate limiting and oauth state rate limiting
- fix redis client capture race in pubsub listeners and wait on stop channel
- adjust global --primary to oklch(51.1% 0.262 276.966) for WCAG AA contrast
- fix semantic heading levels and missing aria-labels across UI components
- document security, concurrency, and a11y standards in AGENTS.md
This commit is contained in:
ryan
2026-08-27 23:01:28 +08:00
parent b66cf3ae9c
commit ae3b792e16
63 changed files with 570 additions and 176 deletions
+51 -4
View File
@@ -6,14 +6,17 @@ package user
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/infra/persistence"
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
"github.com/Rain-kl/Wavelet/internal/infra/task"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
@@ -49,6 +52,48 @@ type updateProfileInput struct {
Location string
}
const (
loginFailLimitKeyFormat = "login:fail:%s"
loginFailLimitMax = 20
loginFailLimitWindow = 10 * time.Minute
)
func loginFailLimitKey(ip string) string {
return fmt.Sprintf(loginFailLimitKeyFormat, strings.TrimSpace(ip))
}
func loginAttemptsBlocked(ctx context.Context, ip string) bool {
if db.Redis == nil {
return false
}
n, err := db.Redis.Get(ctx, db.PrefixedKey(loginFailLimitKey(ip))).Int()
if err != nil {
return false
}
return n >= loginFailLimitMax
}
func recordFailedLogin(ctx context.Context, ip string) {
if db.Redis == nil {
return
}
key := db.PrefixedKey(loginFailLimitKey(ip))
n, err := db.Redis.Incr(ctx, key).Result()
if err != nil {
return
}
if n == 1 {
_ = db.Redis.Expire(ctx, key, loginFailLimitWindow).Err()
}
}
func clearFailedLogins(ctx context.Context, ip string) {
if db.Redis == nil {
return
}
_ = db.Redis.Del(ctx, db.PrefixedKey(loginFailLimitKey(ip))).Err()
}
func isPasswordLoginEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyPasswordLoginEnabled)
if err != nil {
@@ -60,7 +105,7 @@ func isPasswordLoginEnabled(ctx context.Context) bool {
func isPasswordRegisterEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyPasswordRegisterEnabled)
if err != nil {
return true
return false
}
return enabled
}
@@ -68,7 +113,7 @@ func isPasswordRegisterEnabled(ctx context.Context) bool {
func isRegistrationEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
if err != nil {
return true
return false
}
return enabled
}
@@ -161,7 +206,9 @@ func verifyEmailCode(ctx context.Context, email, scene, code string) bool {
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
return false
}
if storedCode != code {
sumGot := sha256.Sum256([]byte(strings.TrimSpace(code)))
sumWant := sha256.Sum256([]byte(strings.TrimSpace(storedCode)))
if subtle.ConstantTimeCompare(sumGot[:], sumWant[:]) != 1 {
return false
}
_ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err()