diff options
| -rw-r--r-- | README.ja.md | 2 | ||||
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | cmd/server/main.go | 7 | ||||
| -rw-r--r-- | config/config.go | 30 | ||||
| -rw-r--r-- | handlers/auth.go | 39 | ||||
| -rw-r--r-- | logging/logging.go | 13 | ||||
| -rw-r--r-- | middleware/ratelimit.go | 17 | ||||
| -rw-r--r-- | types/types.go | 19 | ||||
| -rw-r--r-- | utils/validation.go | 7 |
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` のセキュアなクッキーを使用します。 @@ -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": |
