Skip to content

Commit 7e6bb8b

Browse files
committed
Add environment configuration and implement token cleanup service
1 parent 278092b commit 7e6bb8b

File tree

9 files changed

+345
-7
lines changed

9 files changed

+345
-7
lines changed

env.example

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Passwall Server Environment Variables
2+
# Copy this file to .env and fill in your actual values
3+
# NEVER commit .env to git!
4+
5+
# ===================================
6+
# SERVER CONFIGURATION
7+
# ===================================
8+
SERVER_PORT=3625
9+
SERVER_ENV=production
10+
SERVER_DOMAIN=https://your-domain.com
11+
SERVER_TIMEOUT=30
12+
13+
# CRITICAL: Generate a strong random secret for JWT tokens
14+
# Example: openssl rand -base64 64
15+
SERVER_SECRET=your-super-secret-jwt-key-here-use-openssl-rand-base64-64
16+
17+
# CRITICAL: Generate a strong passphrase for encryption
18+
# Example: openssl rand -base64 32
19+
SERVER_PASSPHRASE=your-encryption-passphrase-here
20+
21+
# Token expiration durations (examples: 15m, 1h, 24h, 7d)
22+
SERVER_ACCESS_TOKEN_EXPIRE_DURATION=15m
23+
SERVER_REFRESH_TOKEN_EXPIRE_DURATION=24h
24+
25+
# Generated password length for password manager
26+
SERVER_GENERATED_PASSWORD_LENGTH=16
27+
28+
# ===================================
29+
# DATABASE CONFIGURATION
30+
# ===================================
31+
DATABASE_NAME=passwall
32+
DATABASE_USERNAME=postgres
33+
DATABASE_PASSWORD=your-database-password
34+
DATABASE_HOST=localhost
35+
DATABASE_PORT=5432
36+
DATABASE_DBMS=postgres
37+
DATABASE_SSL_MODE=disable
38+
DATABASE_LOG_MODE=false
39+
40+
# ===================================
41+
# EMAIL CONFIGURATION
42+
# ===================================
43+
# SMTP Settings
44+
EMAIL_HOST=smtp.example.com
45+
EMAIL_PORT=587
46+
EMAIL_USERNAME=your-email@example.com
47+
EMAIL_PASSWORD=your-email-password
48+
EMAIL_FROM_EMAIL=no-reply@passwall.io
49+
EMAIL_FROM_NAME=Passwall
50+
EMAIL_API_KEY=your-email-api-key-if-needed
51+
52+
# ===================================
53+
# BACKUP CONFIGURATION
54+
# ===================================
55+
BACKUP_FOLDER=./store/backup
56+
BACKUP_ROTATION=7
57+
BACKUP_PERIOD=1440
58+
59+
# ===================================
60+
# SECURITY NOTES
61+
# ===================================
62+
# 1. Generate strong random secrets:
63+
# - JWT Secret: openssl rand -base64 64
64+
# - Passphrase: openssl rand -base64 32
65+
#
66+
# 2. Use environment variables in production
67+
#
68+
# 3. Enable SSL/TLS in production:
69+
# - DATABASE_SSL_MODE=require
70+
# - Use HTTPS domain
71+
#
72+
# 4. Change all default passwords
73+
#
74+
# 5. Revoke and regenerate email API keys if compromised

internal/cleanup/token_cleanup.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cleanup
2+
3+
import (
4+
"context"
5+
"time"
6+
7+
"github.com/passwall/passwall-server/internal/repository"
8+
"github.com/passwall/passwall-server/pkg/logger"
9+
)
10+
11+
// TokenCleanup handles periodic cleanup of expired tokens
12+
type TokenCleanup struct {
13+
tokenRepo repository.TokenRepository
14+
interval time.Duration
15+
stopChan chan struct{}
16+
}
17+
18+
// NewTokenCleanup creates a new token cleanup service
19+
func NewTokenCleanup(tokenRepo repository.TokenRepository, interval time.Duration) *TokenCleanup {
20+
return &TokenCleanup{
21+
tokenRepo: tokenRepo,
22+
interval: interval,
23+
stopChan: make(chan struct{}),
24+
}
25+
}
26+
27+
// Start begins the periodic cleanup process
28+
func (tc *TokenCleanup) Start(ctx context.Context) {
29+
logger.Infof("Token cleanup started with interval: %v", tc.interval)
30+
31+
// Run cleanup immediately on start
32+
tc.cleanup(ctx)
33+
34+
ticker := time.NewTicker(tc.interval)
35+
defer ticker.Stop()
36+
37+
for {
38+
select {
39+
case <-ticker.C:
40+
tc.cleanup(ctx)
41+
case <-tc.stopChan:
42+
logger.Infof("Token cleanup stopped")
43+
return
44+
case <-ctx.Done():
45+
logger.Infof("Token cleanup stopped due to context cancellation")
46+
return
47+
}
48+
}
49+
}
50+
51+
// Stop stops the cleanup process
52+
func (tc *TokenCleanup) Stop() {
53+
close(tc.stopChan)
54+
}
55+
56+
// cleanup performs the actual cleanup operation
57+
func (tc *TokenCleanup) cleanup(ctx context.Context) {
58+
logger.Infof("Running token cleanup...")
59+
60+
deletedCount, err := tc.tokenRepo.DeleteExpired(ctx)
61+
if err != nil {
62+
logger.Errorf("Failed to delete expired tokens: %v", err)
63+
return
64+
}
65+
66+
if deletedCount > 0 {
67+
logger.Infof("Successfully deleted %d expired tokens", deletedCount)
68+
} else {
69+
logger.Debugf("No expired tokens found")
70+
}
71+
}
72+

internal/core/app.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"github.com/gin-gonic/gin"
13+
"github.com/passwall/passwall-server/internal/cleanup"
1314
"github.com/passwall/passwall-server/internal/config"
1415
httpHandler "github.com/passwall/passwall-server/internal/handler/http"
1516
"github.com/passwall/passwall-server/internal/repository/gormrepo"
@@ -21,9 +22,12 @@ import (
2122

2223
// App represents the application
2324
type App struct {
24-
config *config.Config
25-
db database.Database
26-
server *http.Server
25+
config *config.Config
26+
db database.Database
27+
server *http.Server
28+
tokenCleanup *cleanup.TokenCleanup
29+
cleanupCtx context.Context
30+
cleanupStop context.CancelFunc
2731
}
2832

2933
// New creates a new application instance
@@ -130,6 +134,13 @@ func (a *App) Run() error {
130134
IdleTimeout: 60 * time.Second,
131135
}
132136

137+
// Initialize token cleanup service (runs every hour)
138+
a.tokenCleanup = cleanup.NewTokenCleanup(tokenRepo, 1*time.Hour)
139+
a.cleanupCtx, a.cleanupStop = context.WithCancel(context.Background())
140+
141+
// Start token cleanup in background
142+
go a.tokenCleanup.Start(a.cleanupCtx)
143+
133144
// Start server in a goroutine
134145
go func() {
135146
logger.Infof("Server starting on %s", addr)
@@ -153,6 +164,12 @@ func (a *App) waitForShutdown() error {
153164
logger.Infof("Shutting down server...")
154165
fmt.Println("\n⏳ Shutting down gracefully...")
155166

167+
// Stop token cleanup
168+
if a.cleanupStop != nil {
169+
a.cleanupStop()
170+
logger.Infof("Token cleanup stopped")
171+
}
172+
156173
// Give outstanding requests 5 seconds to complete
157174
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
158175
defer cancel()

internal/core/router.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package core
22

33
import (
44
"net/http"
5+
"time"
56

67
"github.com/gin-gonic/gin"
78
httpHandler "github.com/passwall/passwall-server/internal/handler/http"
@@ -37,12 +38,21 @@ func SetupRouter(
3738
c.JSON(http.StatusOK, gin.H{"status": "ok"})
3839
})
3940

41+
// Rate limiters for auth endpoints
42+
// SignIn/SignUp: 5 requests per minute per IP (prevents brute force)
43+
authRateLimiter := httpHandler.NewRateLimiter(12*time.Second, 5)
44+
// Refresh token: 10 requests per minute per IP
45+
refreshRateLimiter := httpHandler.NewRateLimiter(6*time.Second, 10)
46+
4047
// Auth routes (no auth middleware)
4148
authGroup := router.Group("/auth")
4249
{
43-
authGroup.POST("/signup", authHandler.SignUp)
44-
authGroup.POST("/signin", authHandler.SignIn)
45-
authGroup.POST("/refresh", authHandler.RefreshToken)
50+
// Rate-limited endpoints
51+
authGroup.POST("/signup", httpHandler.RateLimitMiddleware(authRateLimiter), authHandler.SignUp)
52+
authGroup.POST("/signin", httpHandler.RateLimitMiddleware(authRateLimiter), authHandler.SignIn)
53+
authGroup.POST("/refresh", httpHandler.RateLimitMiddleware(refreshRateLimiter), authHandler.RefreshToken)
54+
55+
// No rate limit on token check (it's already authenticated)
4656
authGroup.POST("/check", authHandler.CheckToken)
4757
}
4858

internal/handler/http/ratelimit.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package http
2+
3+
import (
4+
"net/http"
5+
"sync"
6+
"time"
7+
8+
"github.com/gin-gonic/gin"
9+
)
10+
11+
// RateLimiter implements a simple token bucket rate limiter
12+
type RateLimiter struct {
13+
visitors map[string]*Visitor
14+
mu sync.RWMutex
15+
rate time.Duration // Time between requests
16+
burst int // Maximum burst size
17+
}
18+
19+
// Visitor represents a client with rate limiting state
20+
type Visitor struct {
21+
lastSeen time.Time
22+
tokens int
23+
mu sync.Mutex
24+
}
25+
26+
// NewRateLimiter creates a new rate limiter
27+
// rate: minimum time between requests (e.g., 1 second)
28+
// burst: maximum number of requests in a burst
29+
func NewRateLimiter(rate time.Duration, burst int) *RateLimiter {
30+
rl := &RateLimiter{
31+
visitors: make(map[string]*Visitor),
32+
rate: rate,
33+
burst: burst,
34+
}
35+
36+
// Clean up old visitors every 5 minutes
37+
go rl.cleanupVisitors()
38+
39+
return rl
40+
}
41+
42+
// Allow checks if a request from the given IP is allowed
43+
func (rl *RateLimiter) Allow(ip string) bool {
44+
rl.mu.Lock()
45+
visitor, exists := rl.visitors[ip]
46+
if !exists {
47+
visitor = &Visitor{
48+
lastSeen: time.Now(),
49+
tokens: rl.burst,
50+
}
51+
rl.visitors[ip] = visitor
52+
}
53+
rl.mu.Unlock()
54+
55+
visitor.mu.Lock()
56+
defer visitor.mu.Unlock()
57+
58+
now := time.Now()
59+
timePassed := now.Sub(visitor.lastSeen)
60+
61+
// Refill tokens based on time passed
62+
tokensToAdd := int(timePassed / rl.rate)
63+
if tokensToAdd > 0 {
64+
visitor.tokens += tokensToAdd
65+
if visitor.tokens > rl.burst {
66+
visitor.tokens = rl.burst
67+
}
68+
visitor.lastSeen = now
69+
}
70+
71+
// Check if request is allowed
72+
if visitor.tokens > 0 {
73+
visitor.tokens--
74+
return true
75+
}
76+
77+
return false
78+
}
79+
80+
// cleanupVisitors removes visitors that haven't been seen in a while
81+
func (rl *RateLimiter) cleanupVisitors() {
82+
ticker := time.NewTicker(5 * time.Minute)
83+
defer ticker.Stop()
84+
85+
for range ticker.C {
86+
rl.mu.Lock()
87+
for ip, visitor := range rl.visitors {
88+
visitor.mu.Lock()
89+
if time.Since(visitor.lastSeen) > 10*time.Minute {
90+
delete(rl.visitors, ip)
91+
}
92+
visitor.mu.Unlock()
93+
}
94+
rl.mu.Unlock()
95+
}
96+
}
97+
98+
// RateLimitMiddleware creates a rate limiting middleware
99+
func RateLimitMiddleware(limiter *RateLimiter) gin.HandlerFunc {
100+
return func(c *gin.Context) {
101+
ip := c.ClientIP()
102+
103+
if !limiter.Allow(ip) {
104+
c.JSON(http.StatusTooManyRequests, gin.H{
105+
"error": "Rate limit exceeded",
106+
"message": "Too many requests. Please try again later.",
107+
})
108+
c.Abort()
109+
return
110+
}
111+
112+
c.Next()
113+
}
114+
}
115+

internal/repository/gormrepo/token.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/passwall/passwall-server/internal/domain"
99
"github.com/passwall/passwall-server/internal/repository"
10+
"github.com/passwall/passwall-server/pkg/hash"
1011
uuid "github.com/satori/go.uuid"
1112
"gorm.io/gorm"
1213
)
@@ -33,10 +34,14 @@ func (r *tokenRepository) GetByUUID(ctx context.Context, uuid string) (*domain.T
3334
}
3435

3536
func (r *tokenRepository) Create(ctx context.Context, userID int, tokenUUID uuid.UUID, token string, expiryTime time.Time) error {
37+
// SECURITY: Hash the token before storing in database
38+
// This prevents token theft if database is compromised
39+
hashedToken := hash.SHA256(token)
40+
3641
t := &domain.Token{
3742
UserID: userID,
3843
UUID: tokenUUID,
39-
Token: token,
44+
Token: hashedToken,
4045
ExpiryTime: expiryTime,
4146
}
4247
return r.db.WithContext(ctx).Create(t).Error
@@ -50,6 +55,11 @@ func (r *tokenRepository) DeleteByUUID(ctx context.Context, uuid string) error {
5055
return r.db.WithContext(ctx).Where("uuid = ?", uuid).Delete(&domain.Token{}).Error
5156
}
5257

58+
func (r *tokenRepository) DeleteExpired(ctx context.Context) (int64, error) {
59+
result := r.db.WithContext(ctx).Where("expiry_time < ?", time.Now()).Delete(&domain.Token{})
60+
return result.RowsAffected, result.Error
61+
}
62+
5363
func (r *tokenRepository) Migrate() error {
5464
return r.db.AutoMigrate(&domain.Token{})
5565
}

internal/repository/repository.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ type TokenRepository interface {
118118
Create(ctx context.Context, userID int, uuid uuid.UUID, token string, expiryTime time.Time) error
119119
Delete(ctx context.Context, userID int) error
120120
DeleteByUUID(ctx context.Context, uuid string) error
121+
DeleteExpired(ctx context.Context) (int64, error)
121122
Migrate() error
122123
}
123124

0 commit comments

Comments
 (0)