summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/server/main.go277
-rw-r--r--handlers/auth.go407
-rw-r--r--handlers/auth_login.go165
-rw-r--r--handlers/auth_logout.go41
-rw-r--r--handlers/auth_register.go92
-rw-r--r--handlers/auth_token.go138
-rw-r--r--router/router.go228
7 files changed, 676 insertions, 672 deletions
diff --git a/cmd/server/main.go b/cmd/server/main.go
index c97dbdd..3ac9bfc 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -6,16 +6,12 @@ import (
"auth-proxy/handlers"
"auth-proxy/logging"
"auth-proxy/middleware"
- "auth-proxy/models"
- "auth-proxy/types"
+ "auth-proxy/router"
"auth-proxy/worker"
"net/http"
"os"
- "strings"
- "time"
"github.com/joho/godotenv"
- "github.com/gorilla/csrf"
)
func main() {
@@ -39,175 +35,29 @@ func main() {
defer worker.Close(pool)
handlers.SetWorkerPool(pool)
- // Get target URL for proxy
- targetURL := os.Getenv("TARGET_URL")
- if targetURL == "" {
- logging.AppLog.Error("TARGET_URL environment variable not set")
- os.Exit(1)
- }
-
- // --- Create routers for Web and API ---
- webMux := http.NewServeMux()
- apiMux := http.NewServeMux()
-
- // --- Static file serving (Web) ---
- assetsPath := strings.TrimRight(config.Paths.Assets, "/") + "/"
- assetsFS := http.FileServer(http.Dir("templates"))
- webMux.Handle(assetsPath, http.StripPrefix(assetsPath, assetsFS))
-
- // --- Rate Limiter ---
- rateLimiter := middleware.NewRateLimiter()
-
- // Helper to switch handler based on HTTP method
- methodSwitch := func(get, post http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- post.ServeHTTP(w, r)
- } else {
- get.ServeHTTP(w, r)
- }
- })
- }
-
- // --- Web UI Routes (CSRF-protected) ---
- if frontendType := os.Getenv("FRONTEND_TYPE"); frontendType == "php" {
- // PHP frontend routes
- loginPageHandler := handlers.NewPhpProxyHandler("login.php")
- registerPageHandler := handlers.NewPhpProxyHandler("register.php")
- accountPageHandler := handlers.NewPhpProxyHandler("account.php")
- accountPasswordPageHandler := handlers.NewPhpProxyHandler("account_password.php")
-
- loginHandler := methodSwitch(loginPageHandler, http.HandlerFunc(handlers.LoginHandler))
- webMux.Handle(config.Paths.Login, loginHandler)
-
- if config.Paths.RegisterEnabled {
- registerPostHandler := middleware.RateLimitMiddleware(rateLimiter)(http.HandlerFunc(handlers.RegisterHandler))
- registerHandler := methodSwitch(registerPageHandler, registerPostHandler)
- webMux.Handle(config.Paths.Register, registerHandler)
- }
-
- accountPasswordPostHandler := http.HandlerFunc(handlers.ChangePasswordHandler)
- accountPasswordHandler := methodSwitch(accountPasswordPageHandler, accountPasswordPostHandler)
- webMux.Handle(config.Paths.AccountPassword, middleware.SessionAuth(accountPasswordHandler))
- webMux.Handle(config.Paths.Account, middleware.SessionAuth(accountPageHandler))
- webMux.Handle(config.Paths.Admin, middleware.SessionAuth(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler))))
- } else {
- // Go template frontend routes
- webMux.HandleFunc(config.Paths.Login, handlers.LoginHandler)
- if config.Paths.RegisterEnabled {
- webMux.Handle(config.Paths.Register, middleware.RateLimitMiddleware(rateLimiter)(http.HandlerFunc(handlers.RegisterHandler)))
- }
- webMux.Handle(config.Paths.Account, middleware.SessionAuth(http.HandlerFunc(handlers.AccountPageHandler)))
- webMux.Handle(config.Paths.Admin, middleware.SessionAuth(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler))))
- webMux.Handle(config.Paths.AccountPassword, middleware.SessionAuth(http.HandlerFunc(handlers.ChangePasswordHandler)))
- }
- webMux.HandleFunc(config.Paths.Logout, handlers.LogoutHandler)
-
- // --- API Routes (No CSRF) ---
- apiMux.HandleFunc("/api/auth/token", handlers.TokenHandler)
- apiMux.HandleFunc("/auth/refresh", handlers.RefreshTokenHandler)
-
- // Admin API endpoints
- adminCreateUserAPI := http.HandlerFunc(handlers.CreateUserHandler)
- getUsersHandler := http.HandlerFunc(handlers.GetUsersHandler)
- adminUsersAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodPost {
- adminCreateUserAPI.ServeHTTP(w, r)
- } else {
- getUsersHandler.ServeHTTP(w, r)
- }
- })
- adminUpdateRoleAPI := http.HandlerFunc(handlers.UpdateUserRoleHandler)
- adminDeleteUserAPI := http.HandlerFunc(handlers.DeleteUserHandler)
- adminSetStatusAPI := http.HandlerFunc(handlers.SetUserActiveStatusHandler)
-
- apiMux.Handle(config.Paths.AdminUsersAPI, middleware.BearerAuth(middleware.AdminMiddleware(adminUsersAPI)))
- apiMux.Handle(config.Paths.AdminUsersAPI+"/", middleware.BearerAuth(middleware.AdminMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- path := r.URL.Path
- switch {
- case strings.HasSuffix(path, "/role") && r.Method == http.MethodPost:
- adminUpdateRoleAPI.ServeHTTP(w, r)
- case strings.HasSuffix(path, "/status") && r.Method == http.MethodPost:
- adminSetStatusAPI.ServeHTTP(w, r)
- case r.Method == http.MethodDelete:
- adminDeleteUserAPI.ServeHTTP(w, r)
- default:
- http.NotFound(w, r)
- }
- }))))
-
- // --- Main Application Proxy ---
- proxyHandler := handlers.NewProxy(targetURL)
-
// --- 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")
os.Exit(1)
}
- csrfOptions := []csrf.Option{
- csrf.Secure(os.Getenv("ENV") == "production"),
- csrf.Path("/"),
- }
- sameSiteMode := csrf.SameSiteLaxMode
- switch strings.ToLower(os.Getenv("CSRF_SAME_SITE")) {
- case "strict":
- sameSiteMode = csrf.SameSiteStrictMode
- case "none":
- sameSiteMode = csrf.SameSiteNoneMode
- }
- csrfOptions = append(csrfOptions, csrf.SameSite(sameSiteMode))
- trustedOrigins := os.Getenv("CSRF_TRUSTED_ORIGINS")
- if trustedOrigins != "" {
- origins := strings.Split(trustedOrigins, ",")
- csrfOptions = append(csrfOptions, csrf.TrustedOrigins(origins))
- }
- csrfMiddleware := csrf.Protect([]byte(csrfSecret), csrfOptions...)
- csrfProtectedWebMux := middleware.HandleOptions(csrfMiddleware(webMux))
-
- // --- Final Handler ---
- finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-
- // Handle API paths
- if strings.HasPrefix(r.URL.Path, config.Paths.APIPath) {
- // Check if a specific handler exists in the API mux
- _, pattern := apiMux.Handler(r)
- // If a specific pattern is found, let the API mux handle it internally.
- if pattern != "" {
- apiMux.ServeHTTP(w, r)
- return
- }
-
- // If no specific handler is found, it's a request to be proxied.
- // Protect it with Bearer/Cookie auth if PROTECT_API is enabled.
- if config.Paths.ProtectAPI {
- middleware.BearerAuth(proxyHandler).ServeHTTP(w, r)
- } else {
- proxyHandler.ServeHTTP(w, r)
- }
- return
- }
+ // Get target URL for proxy
+ targetURL := os.Getenv("TARGET_URL")
+ if targetURL == "" {
+ logging.AppLog.Error("TARGET_URL environment variable not set")
+ os.Exit(1)
+ }
- // Check if the path is a web UI path and apply CSRF protection
- _, webPattern := webMux.Handler(r)
- if webPattern != "" {
- csrfProtectedWebMux.ServeHTTP(w, r)
- return
- }
+ // Create the main handler
+ mainRouter := router.NewRouter()
- // Fallback to proxy for all other paths
- if config.Paths.ProtectFrontend {
- middleware.SessionAuth(proxyHandler).ServeHTTP(w, r)
- } else {
- proxyHandler.ServeHTTP(w, r)
- }
- })
+ // Start periodic background tasks
+ go router.StartBackgroundTasks(pool, middleware.NewRateLimiter())
// Get server port
port := os.Getenv("LISTEN_PORT")
@@ -219,111 +69,8 @@ func main() {
// --- Start Server ---
logging.AppLog.Info("Server starting on " + listenAddr)
- // Start periodic background tasks
- go startBackgroundTasks(pool, rateLimiter)
-
- if err := http.ListenAndServe(listenAddr, finalHandler); err != nil {
+ if err := http.ListenAndServe(listenAddr, mainRouter); err != nil {
logging.AppLog.Error("Server failed to start", "error", err)
os.Exit(1)
}
}
-
-
-
- // startBackgroundTasks launches periodic jobs in the background.
-
- func startBackgroundTasks(pool *types.WorkerPool, limiter *types.RateLimiter) {
-
- // Ticker for expired token cleanup (every hour)
-
- tokenTicker := time.NewTicker(1 * time.Hour)
-
- defer tokenTicker.Stop()
-
-
-
- // Ticker for rate limiter cleanup (every 10 minutes)
-
- rateLimiterTicker := time.NewTicker(24 * time.Hour)
-
- defer rateLimiterTicker.Stop()
-
-
-
- logging.AppLog.Info("Background task scheduler started")
-
-
-
- // Run initial cleanup tasks at startup
-
- submitTokenCleanup(pool)
-
- submitRateLimiterCleanup(pool, limiter)
-
-
-
- // Run tasks on their respective tickers
-
- for {
-
- select {
-
- case <-tokenTicker.C:
-
- submitTokenCleanup(pool)
-
- case <-rateLimiterTicker.C:
-
- submitRateLimiterCleanup(pool, limiter)
-
- }
-
- }
-
- }
-
-
-
- // submitTokenCleanup submits the expired token deletion task to the worker pool.
-
- func submitTokenCleanup(pool *types.WorkerPool) {
-
- worker.Submit(pool, func() {
-
- logging.AppLog.Info("Running background task: deleting expired tokens")
-
- rowsAffected, err := models.DeleteExpiredTokens()
-
- if err != nil {
-
- logging.AppLog.Error("Failed to delete expired tokens", "error", err)
-
- } else {
-
- if rowsAffected > 0 {
-
- logging.AppLog.Info("Finished deleting expired tokens", "deleted_count", rowsAffected)
-
- }
-
- }
-
- })
-
- }
-
-
-
- // submitRateLimiterCleanup submits the rate limiter cleanup task to the worker pool.
-
- func submitRateLimiterCleanup(pool *types.WorkerPool, limiter *types.RateLimiter) {
-
- worker.Submit(pool, func() {
-
- logging.AppLog.Info("Running background task: cleaning up rate limiter")
-
- middleware.Cleanup(limiter)
-
- })
-
- }
diff --git a/handlers/auth.go b/handlers/auth.go
deleted file mode 100644
index 4b75e0b..0000000
--- a/handlers/auth.go
+++ /dev/null
@@ -1,407 +0,0 @@
-package handlers
-
-import (
- "auth-proxy/config"
- "auth-proxy/logging"
- "auth-proxy/models"
- "auth-proxy/types"
- "auth-proxy/utils"
- "encoding/json"
- "errors"
- "html/template"
- "net/http"
- "time"
-
- "github.com/gorilla/csrf"
-)
-
-var templates = template.Must(template.ParseGlob("templates/*.html"))
-
-// authenticateUser handles the core logic for authenticating a user, including brute-force protection.
-// It returns the authenticated user or an error if authentication fails.
-func authenticateUser(username, password, ip string) (*types.User, error) {
- user, err := models.GetUserByUsername(username)
- if err != nil {
- logging.AppLog.Error("Database error during authentication", "error", err, "username", username, "ip", ip)
- return nil, errors.New("internal server error")
- }
-
- if user == nil {
- logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "user not found")
- return nil, errors.New("invalid username or password")
- }
-
- // Step 1: Check if the account is locked.
- if !user.IsActive {
- lockoutDuration := time.Duration(config.Paths.LockoutDurationMinutes) * time.Minute
-
- // Check if the lockout period has expired.
- if time.Since(user.UpdatedAt) < lockoutDuration {
- logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "account locked")
- return nil, errors.New("account is temporarily locked")
- } else {
- // Lockout has expired. Reactivate the account but do not log the user in on this attempt.
- if err := models.SetUserActiveStatus(user.ID, true); err != nil {
- logging.AppLog.Error("Failed to reactivate user", "error", err, "user_id", user.ID, "ip", ip)
- return nil, errors.New("internal server error")
- }
- if err := models.RecordLoginSuccess(user.ID); err != nil {
- logging.AppLog.Error("Failed to reset failed login attempts on reactivation", "error", err, "user_id", user.ID, "ip", ip)
- }
- logging.SecurityLog.Info("ACCOUNT UNLOCKED", "username", username, "ip", ip)
- return nil, errors.New("account has been unlocked, please try again")
- }
- }
-
- // Step 2: If the account is active, validate credentials.
- if !models.CheckPasswordHash(password, user.PasswordHash) {
- // Record the failure
- if err := models.RecordLoginFailure(user.ID); err != nil {
- logging.AppLog.Error("Failed to record login failure", "error", err, "user_id", user.ID, "ip", ip)
- }
-
- // Re-fetch user to get updated failed_logins count
- updatedUser, _ := models.GetUserByID(user.ID)
- maxAttempts := config.Paths.MaxLoginAttempts
-
- // Lock the account if max attempts are exceeded
- if maxAttempts > 0 && updatedUser.FailedLogins >= maxAttempts {
- if err := models.SetUserActiveStatus(user.ID, false); err != nil {
- logging.AppLog.Error("Failed to lock account", "error", err, "user_id", user.ID, "ip", ip)
- }
- logging.SecurityLog.Warn("ACCOUNT LOCKED", "username", username, "ip", ip)
- }
-
- logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "invalid credentials")
- return nil, errors.New("invalid username or password")
- }
-
- // Step 3: Login successful.
- if err := models.RecordLoginSuccess(user.ID); err != nil {
- logging.AppLog.Error("Failed to record login success", "error", err, "user_id", user.ID, "ip", ip)
- }
- logging.SecurityLog.Info("AUTH SUCCESS", "username", user.Username, "ip", ip)
-
- return user, nil
-}
-
-func renderLoginWithError(w http.ResponseWriter, r *http.Request, message string) {
- data := map[string]interface{}{
- "Paths": config.Paths,
- "Error": message,
- "RegisterEnabled": config.Paths.RegisterEnabled,
- "CSRFToken": csrf.Token(r),
- }
- w.WriteHeader(http.StatusUnauthorized) // Set status to indicate failure
- if err := templates.ExecuteTemplate(w, "login.html", data); err != nil {
- logging.AppLog.Error("Failed to execute login template with error", "error", err)
- }
-}
-
-// LoginHandler handles displaying the login form and processing login requests.
-func LoginHandler(w http.ResponseWriter, r *http.Request) {
- ip := logging.GetClientIP(r)
-
- if r.Method == http.MethodGet {
- data := map[string]interface{}{
- "Paths": config.Paths,
- "RegisterEnabled": config.Paths.RegisterEnabled,
- "CSRFToken": csrf.Token(r),
- }
- if err := templates.ExecuteTemplate(w, "login.html", data); err != nil {
- logging.AppLog.Error("Failed to execute login template", "error", err, "ip", ip)
- }
- return
- }
-
- if r.Method == http.MethodPost {
- if err := r.ParseForm(); err != nil {
- logging.AppLog.Error("Failed to parse login form", "error", err, "ip", ip)
- renderLoginWithError(w, r, "An internal error occurred. Please try again.")
- return
- }
- username := r.FormValue("username")
- password := r.FormValue("password")
-
- user, err := authenticateUser(username, password, ip)
- if err != nil {
- // Map authentication error to user-facing message
- errMsg := "Invalid username or password."
- if err.Error() == "account is temporarily locked" {
- 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 ---
- duration := time.Duration(config.Paths.TokenDurationHours) * time.Hour
-
- token, err := models.CreateAuthToken(user.ID, duration)
- if err != nil {
- logging.AppLog.Error("Failed to create auth token", "error", err, "username", user.Username, "ip", ip)
- renderLoginWithError(w, r, "Could not create session.")
- return
- }
-
- http.SetCookie(w, &http.Cookie{
- Name: "auth_token",
- Value: token.Token,
- Expires: token.ExpiresAt,
- Path: "/",
- HttpOnly: true,
- Secure: r.TLS != nil, // Use Secure flag if on HTTPS
- SameSite: http.SameSiteLaxMode,
- })
-
- // Redirect to the root path, which is the proxied application.
- http.Redirect(w, r, "/", http.StatusFound)
- return
- }
-
- w.WriteHeader(http.StatusMethodNotAllowed)
-}
-
-// LogoutHandler invalidates the user's token by expiring the cookie.
-func LogoutHandler(w http.ResponseWriter, r *http.Request) {
- ip := logging.GetClientIP(r)
- cookie, err := r.Cookie("auth_token")
- if err != nil {
- // No cookie, nothing to do.
- http.Redirect(w, r, config.Paths.Login, http.StatusFound)
- return
- }
-
- err = models.DeleteAuthToken(cookie.Value)
- if err != nil {
- // Log the error but still proceed with logging the user out client-side.
- logging.AppLog.Error("Failed to delete token on logout", "error", err, "ip", ip)
- }
-
- // Expire the cookie by setting its max age to a negative number.
- http.SetCookie(w, &http.Cookie{
- Name: "auth_token",
- Value: "",
- Path: "/",
- Expires: time.Unix(0, 0),
- MaxAge: -1,
- HttpOnly: true,
- Secure: r.TLS != nil,
- SameSite: http.SameSiteLaxMode,
- })
-
- logging.SecurityLog.Info("LOGOUT SUCCESS", "ip", ip)
- http.Redirect(w, r, config.Paths.Login, http.StatusFound)
-}
-
-func renderRegisterWithError(w http.ResponseWriter, r *http.Request, message string) {
- data := map[string]interface{}{
- "Paths": config.Paths,
- "Error": message,
- "CSRFToken": csrf.Token(r),
- }
- w.WriteHeader(http.StatusBadRequest) // Set status to indicate failure
- if err := templates.ExecuteTemplate(w, "register.html", data); err != nil {
- logging.AppLog.Error("Failed to execute register template with error", "error", err)
- }
-}
-
-// RegisterHandler handles displaying and processing the user registration form.
-func RegisterHandler(w http.ResponseWriter, r *http.Request) {
- if !config.Paths.RegisterEnabled {
- http.Error(w, "User registration is disabled.", http.StatusForbidden)
- return
- }
- ip := logging.GetClientIP(r)
- data := map[string]interface{}{
- "Paths": config.Paths,
- "CSRFToken": csrf.Token(r),
- }
-
- if r.Method == http.MethodGet {
- if err := templates.ExecuteTemplate(w, "register.html", data); err != nil {
- logging.AppLog.Error("Failed to execute register template", "error", err, "ip", ip)
- }
- return
- }
-
- if r.Method == http.MethodPost {
- if err := r.ParseForm(); err != nil {
- logging.AppLog.Error("Failed to parse registration form", "error", err, "ip", ip)
- renderRegisterWithError(w, r, "An internal error occurred. Please try again.")
- return
- }
- username := r.FormValue("username")
- password := r.FormValue("password")
- confirmPassword := r.FormValue("confirm_password")
-
- // Validate password policy
- if ok, message := utils.ValidatePassword(password); !ok {
- renderRegisterWithError(w, r, message)
- return
- }
-
- if password != confirmPassword {
- logging.AppLog.Warn("User registration failed", "reason", "passwords do not match", "ip", ip)
- renderRegisterWithError(w, r, "Passwords do not match")
- return
- }
-
- existingUser, err := models.GetUserByUsername(username)
- if err != nil {
- logging.AppLog.Error("Error checking username during registration", "error", err, "ip", ip)
- renderRegisterWithError(w, r, "An internal error occurred while checking the username.")
- return
- }
- if existingUser != nil {
- logging.AppLog.Warn("User registration failed", "reason", "username is already taken", "username", username, "ip", ip)
- renderRegisterWithError(w, r, "Username is already taken")
- return
- }
-
- user, err := models.CreateUser(username, password, "user")
- if err != nil {
- logging.AppLog.Error("Failed to create user", "error", err, "username", username, "ip", ip)
- renderRegisterWithError(w, r, "Failed to create user due to an internal error.")
- return
- }
- logging.SecurityLog.Info("USER CREATED", "username", user.Username, "ip", ip)
-
- // Redirect to the login page so the user can sign in.
- http.Redirect(w, r, config.Paths.Login, http.StatusFound)
- return
- }
-
- w.WriteHeader(http.StatusMethodNotAllowed)
-}
-
-// RefreshTokenHandler handles exchanging a valid refresh token for a new access token.
-func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
- ip := logging.GetClientIP(r)
-
- // 1. Parse request body
- var reqBody struct {
- RefreshToken string `json:"refresh_token"`
- }
- if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
- http.Error(w, "Invalid request body", http.StatusBadRequest)
- return
- }
- if reqBody.RefreshToken == "" {
- http.Error(w, "Missing refresh_token", http.StatusBadRequest)
- return
- }
-
- // 2. Validate the refresh token in DB
- refreshToken, err := models.GetRefreshTokenByToken(reqBody.RefreshToken)
- if err != nil {
- logging.AppLog.Error("Error getting refresh token from DB", "error", err, "ip", ip)
- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
- return
- }
- if refreshToken == nil || refreshToken.IsRevoked || time.Now().After(refreshToken.ExpiresAt) {
- logging.SecurityLog.Warn("REFRESH TOKEN FAIL", "reason", "token not found, revoked, or expired", "ip", ip)
- http.Error(w, "Unauthorized: Invalid refresh token", http.StatusUnauthorized)
- return
- }
-
- // 3. Get user info
- user, err := models.GetUserByID(refreshToken.UserID)
- if err != nil || user == nil {
- logging.AppLog.Error("Failed to get user from refresh token", "error", err, "user_id", refreshToken.UserID, "ip", ip)
- http.Error(w, "Unauthorized: User not found", http.StatusUnauthorized)
- return
- }
-
- // 4. Issue a new access token
- 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)
- return
- }
-
- // --- Optional: Token Rotation ---
- // 5. Delete the old refresh token
- if err := models.DeleteRefreshTokenByToken(refreshToken.Token); err != nil {
- logging.AppLog.Error("Failed to delete old refresh token", "error", err, "ip", ip)
- // Continue, but log the error
- }
-
- // 6. Issue a new refresh token
- 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)
- return
- }
- // --- End Token Rotation ---
-
- // 7. Respond with new tokens
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- if err := json.NewEncoder(w).Encode(map[string]string{
- "access_token": newAccessToken,
- "refresh_token": newRefreshToken.Token,
- }); err != nil {
- logging.AppLog.Error("Failed to encode token response", "error", err, "ip", ip)
- }
-}
-
-// TokenHandler handles issuing JWT tokens for API clients.
-func TokenHandler(w http.ResponseWriter, r *http.Request) {
- ip := logging.GetClientIP(r)
-
- if r.Method != http.MethodPost {
- http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
- return
- }
-
- var creds struct {
- Username string `json:"username"`
- Password string `json:"password"`
- }
- if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
- http.Error(w, "Invalid request body", http.StatusBadRequest)
- return
- }
-
- // Use the shared authentication logic
- user, err := authenticateUser(creds.Username, creds.Password, ip)
- if err != nil {
- // Return a generic error to the API client
- http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized)
- return
- }
-
- // --- Issue Tokens ---
- // Access Token
- 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)
- return
- }
-
- // Refresh Token
- 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)
- return
- }
- // --- End Issue Tokens ---
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- if err := json.NewEncoder(w).Encode(map[string]string{
- "access_token": accessToken,
- "refresh_token": refreshToken.Token,
- }); err != nil {
- logging.AppLog.Error("Failed to encode token response", "error", err, "ip", ip)
- }
-}
diff --git a/handlers/auth_login.go b/handlers/auth_login.go
new file mode 100644
index 0000000..855fa8a
--- /dev/null
+++ b/handlers/auth_login.go
@@ -0,0 +1,165 @@
+package handlers
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/logging"
+ "auth-proxy/models"
+ "auth-proxy/types"
+ "errors"
+ "html/template"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/csrf"
+)
+
+var templates = template.Must(template.ParseGlob("templates/*.html"))
+
+// authenticateUser handles the core logic for authenticating a user, including brute-force protection.
+// It returns the authenticated user or an error if authentication fails.
+func authenticateUser(username, password, ip string) (*types.User, error) {
+ user, err := models.GetUserByUsername(username)
+ if err != nil {
+ logging.AppLog.Error("Database error during authentication", "error", err, "username", username, "ip", ip)
+ return nil, errors.New("internal server error")
+ }
+
+ if user == nil {
+ logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "user not found")
+ return nil, errors.New("invalid username or password")
+ }
+
+ // Step 1: Check if the account is locked.
+ if !user.IsActive {
+ lockoutDuration := time.Duration(config.Paths.LockoutDurationMinutes) * time.Minute
+
+ // Check if the lockout period has expired.
+ if time.Since(user.UpdatedAt) < lockoutDuration {
+ logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "account locked")
+ return nil, errors.New("account is temporarily locked")
+ } else {
+ // Lockout has expired. Reactivate the account but do not log the user in on this attempt.
+ if err := models.SetUserActiveStatus(user.ID, true); err != nil {
+ logging.AppLog.Error("Failed to reactivate user", "error", err, "user_id", user.ID, "ip", ip)
+ return nil, errors.New("internal server error")
+ }
+ if err := models.RecordLoginSuccess(user.ID); err != nil {
+ logging.AppLog.Error("Failed to reset failed login attempts on reactivation", "error", err, "user_id", user.ID, "ip", ip)
+ }
+ logging.SecurityLog.Info("ACCOUNT UNLOCKED", "username", username, "ip", ip)
+ return nil, errors.New("account has been unlocked, please try again")
+ }
+ }
+
+ // Step 2: If the account is active, validate credentials.
+ if !models.CheckPasswordHash(password, user.PasswordHash) {
+ // Record the failure
+ if err := models.RecordLoginFailure(user.ID); err != nil {
+ logging.AppLog.Error("Failed to record login failure", "error", err, "user_id", user.ID, "ip", ip)
+ }
+
+ // Re-fetch user to get updated failed_logins count
+ updatedUser, _ := models.GetUserByID(user.ID)
+ maxAttempts := config.Paths.MaxLoginAttempts
+
+ // Lock the account if max attempts are exceeded
+ if maxAttempts > 0 && updatedUser.FailedLogins >= maxAttempts {
+ if err := models.SetUserActiveStatus(user.ID, false); err != nil {
+ logging.AppLog.Error("Failed to lock account", "error", err, "user_id", user.ID, "ip", ip)
+ }
+ logging.SecurityLog.Warn("ACCOUNT LOCKED", "username", username, "ip", ip)
+ }
+
+ logging.SecurityLog.Warn("AUTH FAIL", "username", username, "ip", ip, "reason", "invalid credentials")
+ return nil, errors.New("invalid username or password")
+ }
+
+ // Step 3: Login successful.
+ if err := models.RecordLoginSuccess(user.ID); err != nil {
+ logging.AppLog.Error("Failed to record login success", "error", err, "user_id", user.ID, "ip", ip)
+ }
+ logging.SecurityLog.Info("AUTH SUCCESS", "username", user.Username, "ip", ip)
+
+ return user, nil
+}
+
+func renderLoginWithError(w http.ResponseWriter, r *http.Request, message string) {
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ "Error": message,
+ "RegisterEnabled": config.Paths.RegisterEnabled,
+ "CSRFToken": csrf.Token(r),
+ }
+ w.WriteHeader(http.StatusUnauthorized) // Set status to indicate failure
+ if err := templates.ExecuteTemplate(w, "login.html", data); err != nil {
+ logging.AppLog.Error("Failed to execute login template with error", "error", err)
+ }
+}
+
+// LoginHandler handles displaying the login form and processing login requests.
+func LoginHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+
+ if r.Method == http.MethodGet {
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ "RegisterEnabled": config.Paths.RegisterEnabled,
+ "CSRFToken": csrf.Token(r),
+ }
+ if err := templates.ExecuteTemplate(w, "login.html", data); err != nil {
+ logging.AppLog.Error("Failed to execute login template", "error", err, "ip", ip)
+ }
+ return
+ }
+
+ if r.Method == http.MethodPost {
+ if err := r.ParseForm(); err != nil {
+ logging.AppLog.Error("Failed to parse login form", "error", err, "ip", ip)
+ renderLoginWithError(w, r, "An internal error occurred. Please try again.")
+ return
+ }
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ user, err := authenticateUser(username, password, ip)
+ if err != nil {
+ // Map authentication error to user-facing message
+ errMsg := "Invalid username or password."
+ if err.Error() == "account is temporarily locked" {
+ 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 ---
+ duration := time.Duration(config.Paths.TokenDurationHours) * time.Hour
+
+ token, err := models.CreateAuthToken(user.ID, duration)
+ if err != nil {
+ logging.AppLog.Error("Failed to create auth token", "error", err, "username", user.Username, "ip", ip)
+ renderLoginWithError(w, r, "Could not create session.")
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "auth_token",
+ Value: token.Token,
+ Expires: token.ExpiresAt,
+ Path: "/",
+ HttpOnly: true,
+ Secure: r.TLS != nil, // Use Secure flag if on HTTPS
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ // Redirect to the root path, which is the proxied application.
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+
+ w.WriteHeader(http.StatusMethodNotAllowed)
+}
diff --git a/handlers/auth_logout.go b/handlers/auth_logout.go
new file mode 100644
index 0000000..d008a81
--- /dev/null
+++ b/handlers/auth_logout.go
@@ -0,0 +1,41 @@
+package handlers
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/logging"
+ "auth-proxy/models"
+ "net/http"
+ "time"
+)
+
+// LogoutHandler invalidates the user's token by expiring the cookie.
+func LogoutHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ cookie, err := r.Cookie("auth_token")
+ if err != nil {
+ // No cookie, nothing to do.
+ http.Redirect(w, r, config.Paths.Login, http.StatusFound)
+ return
+ }
+
+ err = models.DeleteAuthToken(cookie.Value)
+ if err != nil {
+ // Log the error but still proceed with logging the user out client-side.
+ logging.AppLog.Error("Failed to delete token on logout", "error", err, "ip", ip)
+ }
+
+ // Expire the cookie by setting its max age to a negative number.
+ http.SetCookie(w, &http.Cookie{
+ Name: "auth_token",
+ Value: "",
+ Path: "/",
+ Expires: time.Unix(0, 0),
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: r.TLS != nil,
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ logging.SecurityLog.Info("LOGOUT SUCCESS", "ip", ip)
+ http.Redirect(w, r, config.Paths.Login, http.StatusFound)
+}
diff --git a/handlers/auth_register.go b/handlers/auth_register.go
new file mode 100644
index 0000000..9b93494
--- /dev/null
+++ b/handlers/auth_register.go
@@ -0,0 +1,92 @@
+package handlers
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/logging"
+ "auth-proxy/models"
+ "auth-proxy/utils"
+ "net/http"
+
+ "github.com/gorilla/csrf"
+)
+
+func renderRegisterWithError(w http.ResponseWriter, r *http.Request, message string) {
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ "Error": message,
+ "CSRFToken": csrf.Token(r),
+ }
+ w.WriteHeader(http.StatusBadRequest) // Set status to indicate failure
+ if err := templates.ExecuteTemplate(w, "register.html", data); err != nil {
+ logging.AppLog.Error("Failed to execute register template with error", "error", err)
+ }
+}
+
+// RegisterHandler handles displaying and processing the user registration form.
+func RegisterHandler(w http.ResponseWriter, r *http.Request) {
+ if !config.Paths.RegisterEnabled {
+ http.Error(w, "User registration is disabled.", http.StatusForbidden)
+ return
+ }
+ ip := logging.GetClientIP(r)
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ "CSRFToken": csrf.Token(r),
+ }
+
+ if r.Method == http.MethodGet {
+ if err := templates.ExecuteTemplate(w, "register.html", data); err != nil {
+ logging.AppLog.Error("Failed to execute register template", "error", err, "ip", ip)
+ }
+ return
+ }
+
+ if r.Method == http.MethodPost {
+ if err := r.ParseForm(); err != nil {
+ logging.AppLog.Error("Failed to parse registration form", "error", err, "ip", ip)
+ renderRegisterWithError(w, r, "An internal error occurred. Please try again.")
+ return
+ }
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+ confirmPassword := r.FormValue("confirm_password")
+
+ // Validate password policy
+ if ok, message := utils.ValidatePassword(password); !ok {
+ renderRegisterWithError(w, r, message)
+ return
+ }
+
+ if password != confirmPassword {
+ logging.AppLog.Warn("User registration failed", "reason", "passwords do not match", "ip", ip)
+ renderRegisterWithError(w, r, "Passwords do not match")
+ return
+ }
+
+ existingUser, err := models.GetUserByUsername(username)
+ if err != nil {
+ logging.AppLog.Error("Error checking username during registration", "error", err, "ip", ip)
+ renderRegisterWithError(w, r, "An internal error occurred while checking the username.")
+ return
+ }
+ if existingUser != nil {
+ logging.AppLog.Warn("User registration failed", "reason", "username is already taken", "username", username, "ip", ip)
+ renderRegisterWithError(w, r, "Username is already taken")
+ return
+ }
+
+ user, err := models.CreateUser(username, password, "user")
+ if err != nil {
+ logging.AppLog.Error("Failed to create user", "error", err, "username", username, "ip", ip)
+ renderRegisterWithError(w, r, "Failed to create user due to an internal error.")
+ return
+ }
+ logging.SecurityLog.Info("USER CREATED", "username", user.Username, "ip", ip)
+
+ // Redirect to the login page so the user can sign in.
+ http.Redirect(w, r, config.Paths.Login, http.StatusFound)
+ return
+ }
+
+ w.WriteHeader(http.StatusMethodNotAllowed)
+}
diff --git a/handlers/auth_token.go b/handlers/auth_token.go
new file mode 100644
index 0000000..7ea7ef6
--- /dev/null
+++ b/handlers/auth_token.go
@@ -0,0 +1,138 @@
+package handlers
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/logging"
+ "auth-proxy/models"
+ "auth-proxy/utils"
+ "encoding/json"
+ "net/http"
+ "time"
+)
+
+// RefreshTokenHandler handles exchanging a valid refresh token for a new access token.
+func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+
+ // 1. Parse request body
+ var reqBody struct {
+ RefreshToken string `json:"refresh_token"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+ if reqBody.RefreshToken == "" {
+ http.Error(w, "Missing refresh_token", http.StatusBadRequest)
+ return
+ }
+
+ // 2. Validate the refresh token in DB
+ refreshToken, err := models.GetRefreshTokenByToken(reqBody.RefreshToken)
+ if err != nil {
+ logging.AppLog.Error("Error getting refresh token from DB", "error", err, "ip", ip)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+ if refreshToken == nil || refreshToken.IsRevoked || time.Now().After(refreshToken.ExpiresAt) {
+ logging.SecurityLog.Warn("REFRESH TOKEN FAIL", "reason", "token not found, revoked, or expired", "ip", ip)
+ http.Error(w, "Unauthorized: Invalid refresh token", http.StatusUnauthorized)
+ return
+ }
+
+ // 3. Get user info
+ user, err := models.GetUserByID(refreshToken.UserID)
+ if err != nil || user == nil {
+ logging.AppLog.Error("Failed to get user from refresh token", "error", err, "user_id", refreshToken.UserID, "ip", ip)
+ http.Error(w, "Unauthorized: User not found", http.StatusUnauthorized)
+ return
+ }
+
+ // 4. Issue a new access token
+ 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)
+ return
+ }
+
+ // --- Optional: Token Rotation ---
+ // 5. Delete the old refresh token
+ if err := models.DeleteRefreshTokenByToken(refreshToken.Token); err != nil {
+ logging.AppLog.Error("Failed to delete old refresh token", "error", err, "ip", ip)
+ // Continue, but log the error
+ }
+
+ // 6. Issue a new refresh token
+ 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)
+ return
+ }
+ // --- End Token Rotation ---
+
+ // 7. Respond with new tokens
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ if err := json.NewEncoder(w).Encode(map[string]string{
+ "access_token": newAccessToken,
+ "refresh_token": newRefreshToken.Token,
+ }); err != nil {
+ logging.AppLog.Error("Failed to encode token response", "error", err, "ip", ip)
+ }
+}
+
+// TokenHandler handles issuing JWT tokens for API clients.
+func TokenHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var creds struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ // Use the shared authentication logic
+ user, err := authenticateUser(creds.Username, creds.Password, ip)
+ if err != nil {
+ // Return a generic error to the API client
+ http.Error(w, "Unauthorized: "+err.Error(), http.StatusUnauthorized)
+ return
+ }
+
+ // --- Issue Tokens ---
+ // Access Token
+ 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)
+ return
+ }
+
+ // Refresh Token
+ 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)
+ return
+ }
+ // --- End Issue Tokens ---
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ if err := json.NewEncoder(w).Encode(map[string]string{
+ "access_token": accessToken,
+ "refresh_token": refreshToken.Token,
+ }); err != nil {
+ logging.AppLog.Error("Failed to encode token response", "error", err, "ip", ip)
+ }
+}
diff --git a/router/router.go b/router/router.go
new file mode 100644
index 0000000..78b422d
--- /dev/null
+++ b/router/router.go
@@ -0,0 +1,228 @@
+package router
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/handlers"
+ "auth-proxy/logging"
+ "auth-proxy/middleware"
+ "auth-proxy/models"
+ "auth-proxy/types"
+ "auth-proxy/worker"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/gorilla/csrf"
+)
+
+// NewRouter creates and configures the main application router.
+func NewRouter() http.Handler {
+ targetURL := os.Getenv("TARGET_URL")
+
+ // --- Create routers for Web and API ---
+ webMux := http.NewServeMux()
+ apiMux := http.NewServeMux()
+
+ // --- Static file serving (Web) ---
+ assetsPath := strings.TrimRight(config.Paths.Assets, "/") + "/"
+ assetsFS := http.FileServer(http.Dir("templates"))
+ webMux.Handle(assetsPath, http.StripPrefix(assetsPath, assetsFS))
+
+ // --- Rate Limiter ---
+ rateLimiter := middleware.NewRateLimiter()
+
+ // Helper to switch handler based on HTTP method
+ methodSwitch := func(get, post http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ post.ServeHTTP(w, r)
+ } else {
+ get.ServeHTTP(w, r)
+ }
+ })
+ }
+
+ // --- Web UI Routes (CSRF-protected) ---
+ if os.Getenv("FRONTEND_TYPE") == "php" {
+ // PHP frontend routes
+ loginPageHandler := handlers.NewPhpProxyHandler("login.php")
+ registerPageHandler := handlers.NewPhpProxyHandler("register.php")
+ accountPageHandler := handlers.NewPhpProxyHandler("account.php")
+ accountPasswordPageHandler := handlers.NewPhpProxyHandler("account_password.php")
+
+ loginHandler := methodSwitch(loginPageHandler, http.HandlerFunc(handlers.LoginHandler))
+ webMux.Handle(config.Paths.Login, loginHandler)
+
+ if config.Paths.RegisterEnabled {
+ registerPostHandler := middleware.RateLimitMiddleware(rateLimiter)(http.HandlerFunc(handlers.RegisterHandler))
+ registerHandler := methodSwitch(registerPageHandler, registerPostHandler)
+ webMux.Handle(config.Paths.Register, registerHandler)
+ }
+
+ accountPasswordPostHandler := http.HandlerFunc(handlers.ChangePasswordHandler)
+ accountPasswordHandler := methodSwitch(accountPasswordPageHandler, accountPasswordPostHandler)
+ webMux.Handle(config.Paths.AccountPassword, middleware.SessionAuth(accountPasswordHandler))
+ webMux.Handle(config.Paths.Account, middleware.SessionAuth(accountPageHandler))
+ webMux.Handle(config.Paths.Admin, middleware.SessionAuth(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler))))
+ } else {
+ // Go template frontend routes
+ webMux.HandleFunc(config.Paths.Login, handlers.LoginHandler)
+ if config.Paths.RegisterEnabled {
+ webMux.Handle(config.Paths.Register, middleware.RateLimitMiddleware(rateLimiter)(http.HandlerFunc(handlers.RegisterHandler)))
+ }
+ webMux.Handle(config.Paths.Account, middleware.SessionAuth(http.HandlerFunc(handlers.AccountPageHandler)))
+ webMux.Handle(config.Paths.Admin, middleware.SessionAuth(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler))))
+ webMux.Handle(config.Paths.AccountPassword, middleware.SessionAuth(http.HandlerFunc(handlers.ChangePasswordHandler)))
+ }
+ webMux.HandleFunc(config.Paths.Logout, handlers.LogoutHandler)
+
+ // --- API Routes (No CSRF) ---
+ apiMux.HandleFunc("/api/auth/token", handlers.TokenHandler)
+ apiMux.HandleFunc("/auth/refresh", handlers.RefreshTokenHandler)
+
+ // Admin API endpoints
+ adminCreateUserAPI := http.HandlerFunc(handlers.CreateUserHandler)
+ getUsersHandler := http.HandlerFunc(handlers.GetUsersHandler)
+ adminUsersAPI := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ adminCreateUserAPI.ServeHTTP(w, r)
+ } else {
+ getUsersHandler.ServeHTTP(w, r)
+ }
+ })
+ adminUpdateRoleAPI := http.HandlerFunc(handlers.UpdateUserRoleHandler)
+ adminDeleteUserAPI := http.HandlerFunc(handlers.DeleteUserHandler)
+ adminSetStatusAPI := http.HandlerFunc(handlers.SetUserActiveStatusHandler)
+
+ apiMux.Handle(config.Paths.AdminUsersAPI, middleware.BearerAuth(middleware.AdminMiddleware(adminUsersAPI)))
+ apiMux.Handle(config.Paths.AdminUsersAPI+"/", middleware.BearerAuth(middleware.AdminMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := r.URL.Path
+ switch {
+ case strings.HasSuffix(path, "/role") && r.Method == http.MethodPost:
+ adminUpdateRoleAPI.ServeHTTP(w, r)
+ case strings.HasSuffix(path, "/status") && r.Method == http.MethodPost:
+ adminSetStatusAPI.ServeHTTP(w, r)
+ case r.Method == http.MethodDelete:
+ adminDeleteUserAPI.ServeHTTP(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+ }))))
+
+ // --- Main Application Proxy ---
+ proxyHandler := handlers.NewProxy(targetURL)
+
+ // --- CSRF Protection (for Web UI) ---
+ csrfSecret := os.Getenv("CSRF_SECRET_KEY")
+ csrfOptions := []csrf.Option{
+ csrf.Secure(os.Getenv("ENV") == "production"),
+ csrf.Path("/"),
+ }
+ sameSiteMode := csrf.SameSiteLaxMode
+ switch strings.ToLower(os.Getenv("CSRF_SAME_SITE")) {
+ case "strict":
+ sameSiteMode = csrf.SameSiteStrictMode
+ case "none":
+ sameSiteMode = csrf.SameSiteNoneMode
+ }
+ csrfOptions = append(csrfOptions, csrf.SameSite(sameSiteMode))
+ trustedOrigins := os.Getenv("CSRF_TRUSTED_ORIGINS")
+ if trustedOrigins != "" {
+ origins := strings.Split(trustedOrigins, ",")
+ csrfOptions = append(csrfOptions, csrf.TrustedOrigins(origins))
+ }
+ csrfMiddleware := csrf.Protect([]byte(csrfSecret), csrfOptions...)
+ csrfProtectedWebMux := middleware.HandleOptions(csrfMiddleware(webMux))
+
+ // --- Final Handler ---
+ finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Handle API paths
+ if strings.HasPrefix(r.URL.Path, config.Paths.APIPath) {
+ // Check if a specific handler exists in the API mux
+ _, pattern := apiMux.Handler(r)
+
+ // If a specific pattern is found, let the API mux handle it internally.
+ if pattern != "" {
+ apiMux.ServeHTTP(w, r)
+ return
+ }
+
+ // If no specific handler is found, it's a request to be proxied.
+ // Protect it with Bearer/Cookie auth if PROTECT_API is enabled.
+ if config.Paths.ProtectAPI {
+ middleware.BearerAuth(proxyHandler).ServeHTTP(w, r)
+ } else {
+ proxyHandler.ServeHTTP(w, r)
+ }
+ return
+ }
+
+ // Check if the path is a web UI path and apply CSRF protection
+ _, webPattern := webMux.Handler(r)
+ if webPattern != "" {
+ csrfProtectedWebMux.ServeHTTP(w, r)
+ return
+ }
+
+ // Fallback to proxy for all other paths
+ if config.Paths.ProtectFrontend {
+ middleware.SessionAuth(proxyHandler).ServeHTTP(w, r)
+ } else {
+ proxyHandler.ServeHTTP(w, r)
+ }
+ })
+
+ return finalHandler
+}
+
+// StartBackgroundTasks launches periodic jobs for cleanup.
+func StartBackgroundTasks(pool *types.WorkerPool, limiter *types.RateLimiter) {
+ // Ticker for expired token cleanup (every hour)
+ tokenTicker := time.NewTicker(1 * time.Hour)
+ defer tokenTicker.Stop()
+
+ // Ticker for rate limiter cleanup (every 24 hours)
+ rateLimiterTicker := time.NewTicker(24 * time.Hour)
+ defer rateLimiterTicker.Stop()
+
+ logging.AppLog.Info("Background task scheduler started")
+
+ // Run initial cleanup tasks at startup
+ submitTokenCleanup(pool)
+ submitRateLimiterCleanup(pool, limiter)
+
+ // Run tasks on their respective tickers
+ for {
+ select {
+ case <-tokenTicker.C:
+ submitTokenCleanup(pool)
+ case <-rateLimiterTicker.C:
+ submitRateLimiterCleanup(pool, limiter)
+ }
+ }
+}
+
+// submitTokenCleanup submits the expired token deletion task to the worker pool.
+func submitTokenCleanup(pool *types.WorkerPool) {
+ worker.Submit(pool, func() {
+ logging.AppLog.Info("Running background task: deleting expired tokens")
+ rowsAffected, err := models.DeleteExpiredTokens()
+ if err != nil {
+ logging.AppLog.Error("Failed to delete expired tokens", "error", err)
+ } else {
+ if rowsAffected > 0 {
+ logging.AppLog.Info("Finished deleting expired tokens", "deleted_count", rowsAffected)
+ }
+ }
+ })
+}
+
+// submitRateLimiterCleanup submits the rate limiter cleanup task to the worker pool.
+func submitRateLimiterCleanup(pool *types.WorkerPool, limiter *types.RateLimiter) {
+ worker.Submit(pool, func() {
+ logging.AppLog.Info("Running background task: cleaning up rate limiter")
+ middleware.Cleanup(limiter)
+ })
+}
+