mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-03 08:49:37 +08:00
first commit
This commit is contained in:
60
server/internal/security/jwt.go
Normal file
60
server/internal/security/jwt.go
Normal file
@@ -0,0 +1,60 @@
|
||||
//go:build ignore
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type JWTManager struct {
|
||||
secret []byte
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
func NewJWTManager(secret string, duration time.Duration) *JWTManager {
|
||||
return &JWTManager{secret: []byte(secret), duration: duration}
|
||||
}
|
||||
|
||||
func (m *JWTManager) IssueToken(user *model.User) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: fmt.Sprintf("%d", user.ID),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.duration)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(m.secret)
|
||||
}
|
||||
|
||||
func (m *JWTManager) Parse(tokenValue string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, fmt.Errorf("unexpected signing method")
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
25
server/internal/security/jwt_test.go
Normal file
25
server/internal/security/jwt_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build ignore
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
func TestJWTManagerIssueAndParse(t *testing.T) {
|
||||
manager := NewJWTManager("test-secret", time.Hour)
|
||||
token, err := manager.IssueToken(&model.User{ID: 7, Username: "admin", Role: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken() error = %v", err)
|
||||
}
|
||||
claims, err := manager.Parse(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if claims.UserID != 7 || claims.Username != "admin" {
|
||||
t.Fatalf("unexpected claims: %+v", claims)
|
||||
}
|
||||
}
|
||||
54
server/internal/security/limiter.go
Normal file
54
server/internal/security/limiter.go
Normal file
@@ -0,0 +1,54 @@
|
||||
//go:build ignore
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type limiterEntry struct {
|
||||
Count int
|
||||
ResetAt time.Time
|
||||
}
|
||||
|
||||
type LoginLimiter struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
max int
|
||||
records map[string]limiterEntry
|
||||
}
|
||||
|
||||
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
|
||||
return &LoginLimiter{window: window, max: max, records: make(map[string]limiterEntry)}
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) Allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
entry, ok := l.records[key]
|
||||
if !ok || time.Now().After(entry.ResetAt) {
|
||||
delete(l.records, key)
|
||||
return true
|
||||
}
|
||||
return entry.Count < l.max
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) RegisterFailure(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
entry, ok := l.records[key]
|
||||
if !ok || now.After(entry.ResetAt) {
|
||||
l.records[key] = limiterEntry{Count: 1, ResetAt: now.Add(l.window)}
|
||||
return
|
||||
}
|
||||
entry.Count++
|
||||
l.records[key] = entry
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.records, key)
|
||||
}
|
||||
17
server/internal/security/password.go
Normal file
17
server/internal/security/password.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package security
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
const PasswordCost = 12
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), PasswordCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hashed), nil
|
||||
}
|
||||
|
||||
func ComparePassword(hashedPassword, plainPassword string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword))
|
||||
}
|
||||
16
server/internal/security/password_test.go
Normal file
16
server/internal/security/password_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package security
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashAndComparePassword(t *testing.T) {
|
||||
hash, err := HashPassword("super-secret-password")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword returned error: %v", err)
|
||||
}
|
||||
if hash == "super-secret-password" {
|
||||
t.Fatalf("expected hashed password to differ from plain text")
|
||||
}
|
||||
if err := ComparePassword(hash, "super-secret-password"); err != nil {
|
||||
t.Fatalf("ComparePassword returned error: %v", err)
|
||||
}
|
||||
}
|
||||
50
server/internal/security/rate_limiter.go
Normal file
50
server/internal/security/rate_limiter.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rateEntry struct {
|
||||
count int
|
||||
windowEnd time.Time
|
||||
}
|
||||
|
||||
type LoginRateLimiter struct {
|
||||
limit int
|
||||
window time.Duration
|
||||
mu sync.Mutex
|
||||
items map[string]rateEntry
|
||||
}
|
||||
|
||||
func NewLoginRateLimiter(limit int, window time.Duration) *LoginRateLimiter {
|
||||
return &LoginRateLimiter{
|
||||
limit: limit,
|
||||
window: window,
|
||||
items: make(map[string]rateEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LoginRateLimiter) Allow(key string) bool {
|
||||
now := time.Now().UTC()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
entry, ok := r.items[key]
|
||||
if !ok || now.After(entry.windowEnd) {
|
||||
r.items[key] = rateEntry{count: 0, windowEnd: now.Add(r.window)}
|
||||
entry = r.items[key]
|
||||
}
|
||||
if entry.count >= r.limit {
|
||||
return false
|
||||
}
|
||||
entry.count++
|
||||
r.items[key] = entry
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *LoginRateLimiter) Reset(key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.items, key)
|
||||
}
|
||||
14
server/internal/security/secret.go
Normal file
14
server/internal/security/secret.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
func GenerateSecret(bytesLength int) (string, error) {
|
||||
buffer := make([]byte, bytesLength)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||||
}
|
||||
93
server/internal/security/secret_store.go
Normal file
93
server/internal/security/secret_store.go
Normal file
@@ -0,0 +1,93 @@
|
||||
//go:build ignore
|
||||
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"backupx/server/internal/config"
|
||||
)
|
||||
|
||||
type PersistedSecrets struct {
|
||||
JWTSecret string `json:"jwtSecret"`
|
||||
EncryptionKey string `json:"encryptionKey"`
|
||||
}
|
||||
|
||||
func EnsureSecrets(cfg *config.Config) error {
|
||||
if cfg.Security.JWTSecret != "" && cfg.Security.EncryptionKey != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
storePath := filepath.Join(filepath.Dir(cfg.Database.Path), "backupx.secrets.json")
|
||||
current, err := loadSecrets(storePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current == nil {
|
||||
current = &PersistedSecrets{}
|
||||
}
|
||||
if current.JWTSecret == "" {
|
||||
current.JWTSecret, err = randomHex(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if current.EncryptionKey == "" {
|
||||
current.EncryptionKey, err = randomHex(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := saveSecrets(storePath, current); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Security.JWTSecret == "" {
|
||||
cfg.Security.JWTSecret = current.JWTSecret
|
||||
}
|
||||
if cfg.Security.EncryptionKey == "" {
|
||||
cfg.Security.EncryptionKey = current.EncryptionKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadSecrets(path string) (*PersistedSecrets, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read secrets: %w", err)
|
||||
}
|
||||
var secrets PersistedSecrets
|
||||
if err := json.Unmarshal(content, &secrets); err != nil {
|
||||
return nil, fmt.Errorf("decode secrets: %w", err)
|
||||
}
|
||||
return &secrets, nil
|
||||
}
|
||||
|
||||
func saveSecrets(path string, secrets *PersistedSecrets) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create secrets dir: %w", err)
|
||||
}
|
||||
content, err := json.MarshalIndent(secrets, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode secrets: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, content, 0o600); err != nil {
|
||||
return fmt.Errorf("write secrets: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomHex(size int) (string, error) {
|
||||
bytes := make([]byte, size)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", fmt.Errorf("generate random secret: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
57
server/internal/security/token.go
Normal file
57
server/internal/security/token.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type JWTManager struct {
|
||||
secret []byte
|
||||
expiry time.Duration
|
||||
}
|
||||
|
||||
func NewJWTManager(secret string, expiry time.Duration) *JWTManager {
|
||||
return &JWTManager{secret: []byte(secret), expiry: expiry}
|
||||
}
|
||||
|
||||
func (m *JWTManager) Generate(user *model.User) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
claims := Claims{
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(m.expiry)),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(m.secret)
|
||||
}
|
||||
|
||||
func (m *JWTManager) Parse(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, fmt.Errorf("unexpected signing method: %s", token.Method.Alg())
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token claims")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
30
server/internal/security/token_test.go
Normal file
30
server/internal/security/token_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
func TestJWTManagerGenerateAndParse(t *testing.T) {
|
||||
manager := NewJWTManager("test-secret", time.Hour)
|
||||
user := &model.User{ID: 7, Username: "admin", Role: "admin"}
|
||||
|
||||
token, err := manager.Generate(user)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate returned error: %v", err)
|
||||
}
|
||||
|
||||
claims, err := manager.Parse(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse returned error: %v", err)
|
||||
}
|
||||
|
||||
if claims.Subject != "7" {
|
||||
t.Fatalf("expected subject 7, got %s", claims.Subject)
|
||||
}
|
||||
if claims.Username != "admin" {
|
||||
t.Fatalf("expected username admin, got %s", claims.Username)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user