summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.ja.md2
-rw-r--r--README.md2
-rw-r--r--cmd/server/main.go7
-rw-r--r--config/config.go30
-rw-r--r--handlers/auth.go39
-rw-r--r--logging/logging.go13
-rw-r--r--middleware/ratelimit.go17
-rw-r--r--types/types.go19
-rw-r--r--utils/validation.go7
9 files changed, 78 insertions, 58 deletions
diff --git a/README.ja.md b/README.ja.md
index 43c0bba..cede5d4 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -20,7 +20,7 @@
- **柔軟なデータベースサポート**: PostgreSQL, MySQL, SQLiteに対応。
- **プラグイン可能なフロントエンド**: 複数のフロントエンドモード(JS駆動またはPHP)をサポート。
- **セキュリティ強化**:
- - **ブルートフォース攻撃からの保護**: 設定可能な回数ログインに失敗したユーザーアカウントをロックします。
+ - **ブルートフォース攻撃からの保護**: 設定可能な回数ログインに失敗したユーザーアカウントをロックします。この保護は、Web UIからのログインと `/api/auth/token` エンドポイントの両方に適用されます。
- **ユーザー作成レート制限**: 同一IPアドレスから短時間に多くのユーザーが作成されるのを防ぎます。
- **設定可能なパスワードポリシー**: パスワードの強度要件を強制します。詳細は下記を参照。
- **セキュアなセッションクッキー**: セッション管理に `HttpOnly` のセキュアなクッキーを使用します。
diff --git a/README.md b/README.md
index 0de63fa..c00c26e 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ This project aims to solve that by acting as a central authentication service. B
- **Flexible Database Support**: Works with PostgreSQL, MySQL, and SQLite.
- **Pluggable Frontend**: Supports multiple frontend modes (JS-driven or PHP).
- **Security Hardening**:
- - **Brute-force Protection**: Locks user accounts after a configurable number of failed login attempts.
+ - **Brute-force Protection**: Locks user accounts after a configurable number of failed login attempts. This protection applies to both the web UI login and the `/api/auth/token` endpoint.
- **User Creation Rate Limiting**: Prevents the same IP from creating too many users in a short period.
- **Configurable Password Policies**: Enforce password strength requirements. See details below.
- **Secure Session Cookies**: Uses `HttpOnly` secure cookies for session management.
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 461278c..c97dbdd 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -139,7 +139,12 @@ func main() {
// --- Main Application Proxy ---
proxyHandler := handlers.NewProxy(targetURL)
- // --- CSRF Protection (for Web UI) ---
+ // --- Secret Key Validation ---
+ sessionSecret := os.Getenv("SESSION_SECRET")
+ if sessionSecret == "" {
+ logging.AppLog.Warn("SESSION_SECRET environment variable not set. Using default key. THIS IS NOT SAFE FOR PRODUCTION.")
+ sessionSecret = "default-secret-key-for-dev"
+ }
csrfSecret := os.Getenv("CSRF_SECRET_KEY")
if csrfSecret == "" {
logging.AppLog.Error("CSRF_SECRET_KEY environment variable not set")
diff --git a/config/config.go b/config/config.go
index 2de3c36..fdddca0 100644
--- a/config/config.go
+++ b/config/config.go
@@ -29,12 +29,23 @@ func getEnvAsBool(key string, fallback bool) bool {
return fallback
}
+// getEnvAsInt reads an environment variable as an integer or returns a fallback value.
+func getEnvAsInt(key string, fallback int) int {
+ if value, ok := os.LookupEnv(key); ok {
+ if i, err := strconv.Atoi(value); err == nil {
+ return i
+ }
+ }
+ return fallback
+}
+
// Init loads path configurations from environment variables.
func Init() {
// Load .env file. Errors are ignored, so variables from the environment can still be used.
_ = godotenv.Load()
Paths = types.PathConfig{
+ // Paths
Login: getEnv("AUTH_PATH_LOGIN", "/login"),
Register: getEnv("AUTH_PATH_REGISTER", "/register"),
Logout: getEnv("AUTH_PATH_LOGOUT", "/logout"),
@@ -43,10 +54,25 @@ func Init() {
Admin: getEnv("AUTH_PATH_ADMIN", "/admin"),
AdminUsersAPI: getEnv("AUTH_PATH_ADMIN_USERS_API", "/api/admin/users"),
Assets: getEnv("AUTH_ASSETS_PATH", "/assets"),
- ProtectFrontend: getEnvAsBool("PROTECT_FRONTEND", false),
- ProtectAPI: getEnvAsBool("PROTECT_API", false),
FrontPath: getEnv("FRONT_PATH", "/"),
APIPath: getEnv("API_PATH", "/api/"),
+
+ // Booleans
+ ProtectFrontend: getEnvAsBool("PROTECT_FRONTEND", false),
+ ProtectAPI: getEnvAsBool("PROTECT_API", false),
RegisterEnabled: getEnvAsBool("REGISTER", true),
+
+ // Security Policies
+ MaxLoginAttempts: getEnvAsInt("MAX_LOGIN_ATTEMPTS", 5),
+ LockoutDurationMinutes: getEnvAsInt("LOCKOUT_DURATION_MINUTES", 10),
+ RateLimitMaxRequests: getEnvAsInt("USER_CREATION_RATE_LIMIT_MAX_REQUESTS", 5),
+ RateLimitWindowSeconds: getEnvAsInt("USER_CREATION_RATE_LIMIT_WINDOW_SECONDS", 3600),
+ PasswordPolicy: getEnv("PASSWORD_POLICY", "standard"),
+
+ // Token Durations
+ TokenDurationHours: getEnvAsInt("TOKEN_DURATION_HOURS", 24),
+ AccessTokenDurationMinutes: getEnvAsInt("ACCESS_TOKEN_DURATION_MINUTES", 15),
+ RefreshTokenDurationDays: getEnvAsInt("REFRESH_TOKEN_DURATION_DAYS", 7),
}
}
+
diff --git a/handlers/auth.go b/handlers/auth.go
index 7a01d2d..4b75e0b 100644
--- a/handlers/auth.go
+++ b/handlers/auth.go
@@ -10,8 +10,6 @@ import (
"errors"
"html/template"
"net/http"
- "os"
- "strconv"
"time"
"github.com/gorilla/csrf"
@@ -35,8 +33,7 @@ func authenticateUser(username, password, ip string) (*types.User, error) {
// Step 1: Check if the account is locked.
if !user.IsActive {
- lockoutMinutes, _ := strconv.Atoi(os.Getenv("LOCKOUT_DURATION_MINUTES"))
- lockoutDuration := time.Duration(lockoutMinutes) * time.Minute
+ lockoutDuration := time.Duration(config.Paths.LockoutDurationMinutes) * time.Minute
// Check if the lockout period has expired.
if time.Since(user.UpdatedAt) < lockoutDuration {
@@ -65,7 +62,7 @@ func authenticateUser(username, password, ip string) (*types.User, error) {
// Re-fetch user to get updated failed_logins count
updatedUser, _ := models.GetUserByID(user.ID)
- maxAttempts, _ := strconv.Atoi(os.Getenv("MAX_LOGIN_ATTEMPTS"))
+ maxAttempts := config.Paths.MaxLoginAttempts
// Lock the account if max attempts are exceeded
if maxAttempts > 0 && updatedUser.FailedLogins >= maxAttempts {
@@ -134,17 +131,15 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
errMsg = "Account is temporarily locked."
} else if err.Error() == "internal server error" {
errMsg = "An internal error occurred. Please try again."
+ } else if err.Error() == "account has been unlocked, please try again" {
+ errMsg = "Account has been unlocked. Please try logging in again."
}
renderLoginWithError(w, r, errMsg)
return
}
// --- Create Session Cookie ---
- tokenDurationHours, err := strconv.Atoi(os.Getenv("TOKEN_DURATION_HOURS"))
- if err != nil || tokenDurationHours <= 0 {
- tokenDurationHours = 24 // Default to 24 hours
- }
- duration := time.Duration(tokenDurationHours) * time.Hour
+ duration := time.Duration(config.Paths.TokenDurationHours) * time.Hour
token, err := models.CreateAuthToken(user.ID, duration)
if err != nil {
@@ -323,11 +318,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
}
// 4. Issue a new access token
- accessTokenDurationMinutes, _ := strconv.Atoi(os.Getenv("ACCESS_TOKEN_DURATION_MINUTES"))
- if accessTokenDurationMinutes <= 0 {
- accessTokenDurationMinutes = 15
- }
- newAccessToken, err := utils.GenerateJWT(user, time.Duration(accessTokenDurationMinutes)*time.Minute)
+ newAccessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute)
if err != nil {
logging.AppLog.Error("Failed to generate new access token", "error", err, "username", user.Username, "ip", ip)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -342,11 +333,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
}
// 6. Issue a new refresh token
- refreshTokenDurationDays, _ := strconv.Atoi(os.Getenv("REFRESH_TOKEN_DURATION_DAYS"))
- if refreshTokenDurationDays <= 0 {
- refreshTokenDurationDays = 7
- }
- newRefreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(refreshTokenDurationDays)*24*time.Hour)
+ newRefreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour)
if err != nil {
logging.AppLog.Error("Failed to create new refresh token", "error", err, "username", user.Username, "ip", ip)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -393,11 +380,7 @@ func TokenHandler(w http.ResponseWriter, r *http.Request) {
// --- Issue Tokens ---
// Access Token
- accessTokenDurationMinutes, _ := strconv.Atoi(os.Getenv("ACCESS_TOKEN_DURATION_MINUTES"))
- if accessTokenDurationMinutes <= 0 {
- accessTokenDurationMinutes = 15
- }
- accessToken, err := utils.GenerateJWT(user, time.Duration(accessTokenDurationMinutes)*time.Minute)
+ accessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute)
if err != nil {
logging.AppLog.Error("Failed to generate access token", "error", err, "username", user.Username, "ip", ip)
http.Error(w, "Failed to generate access token", http.StatusInternalServerError)
@@ -405,11 +388,7 @@ func TokenHandler(w http.ResponseWriter, r *http.Request) {
}
// Refresh Token
- refreshTokenDurationDays, _ := strconv.Atoi(os.Getenv("REFRESH_TOKEN_DURATION_DAYS"))
- if refreshTokenDurationDays <= 0 {
- refreshTokenDurationDays = 7
- }
- refreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(refreshTokenDurationDays)*24*time.Hour)
+ refreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour)
if err != nil {
logging.AppLog.Error("Failed to create refresh token", "error", err, "username", user.Username, "ip", ip)
http.Error(w, "Failed to create refresh token", http.StatusInternalServerError)
diff --git a/logging/logging.go b/logging/logging.go
index 9e6f8bf..83b6624 100644
--- a/logging/logging.go
+++ b/logging/logging.go
@@ -20,9 +20,18 @@ func InitLoggers() {
appWriter := os.Stdout
securityWriter := os.Stdout
- logLevel := slog.LevelInfo
- if strings.ToUpper(os.Getenv("LOG_LEVEL")) == "DEBUG" {
+ var logLevel slog.Level
+ switch strings.ToUpper(os.Getenv("LOG_LEVEL")) {
+ case "DEBUG":
logLevel = slog.LevelDebug
+ case "INFO":
+ logLevel = slog.LevelInfo
+ case "WARN":
+ logLevel = slog.LevelWarn
+ case "ERROR":
+ logLevel = slog.LevelError
+ default:
+ logLevel = slog.LevelInfo // Default to INFO
}
AppLog = slog.New(slog.NewTextHandler(appWriter, &slog.HandlerOptions{
diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go
index 944f863..7ccdf17 100644
--- a/middleware/ratelimit.go
+++ b/middleware/ratelimit.go
@@ -1,29 +1,18 @@
package middleware
import (
+ "auth-proxy/config"
"auth-proxy/types"
"net"
"net/http"
- "os"
- "strconv"
"time"
)
func NewRateLimiter() *types.RateLimiter {
- maxRequests, err := strconv.Atoi(os.Getenv("USER_CREATION_RATE_LIMIT_MAX_REQUESTS"))
- if err != nil || maxRequests <= 0 {
- maxRequests = 5 // Default value
- }
-
- windowSeconds, err := strconv.Atoi(os.Getenv("USER_CREATION_RATE_LIMIT_WINDOW_SECONDS"))
- if err != nil || windowSeconds <= 0 {
- windowSeconds = 3600 // Default value (1 hour)
- }
-
return &types.RateLimiter{
Requests: make(map[string][]int64),
- Max: maxRequests,
- Window: time.Duration(windowSeconds) * time.Second,
+ Max: config.Paths.RateLimitMaxRequests,
+ Window: time.Duration(config.Paths.RateLimitWindowSeconds) * time.Second,
}
}
diff --git a/types/types.go b/types/types.go
index b94d89a..79059d4 100644
--- a/types/types.go
+++ b/types/types.go
@@ -8,6 +8,7 @@ import (
// PathConfig holds the configurable paths for the application routes and assets.
type PathConfig struct {
+ // Paths
Login string
Register string
Logout string
@@ -16,11 +17,25 @@ type PathConfig struct {
Admin string
AdminUsersAPI string
Assets string
- ProtectFrontend bool
- ProtectAPI bool
FrontPath string
APIPath string
+
+ // Booleans
+ ProtectFrontend bool
+ ProtectAPI bool
RegisterEnabled bool
+
+ // Security Policies
+ MaxLoginAttempts int
+ LockoutDurationMinutes int
+ RateLimitMaxRequests int
+ RateLimitWindowSeconds int
+ PasswordPolicy string
+
+ // Token Durations
+ TokenDurationHours int
+ AccessTokenDurationMinutes int
+ RefreshTokenDurationDays int
}
// ContextKey is a custom type for context keys to avoid collisions.
diff --git a/utils/validation.go b/utils/validation.go
index e4ef884..745d278 100644
--- a/utils/validation.go
+++ b/utils/validation.go
@@ -1,17 +1,14 @@
package utils
import (
+ "auth-proxy/config"
"fmt"
- "os"
"unicode"
)
// ValidatePassword checks a password against the policy defined in the PASSWORD_POLICY environment variable.
func ValidatePassword(password string) (bool, string) {
- policy := os.Getenv("PASSWORD_POLICY")
- if policy == "" {
- policy = "none" // Default to no policy if not set
- }
+ policy := config.Paths.PasswordPolicy
switch policy {
case "none":