diff options
| -rw-r--r-- | cmd/server/main.go | 43 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | go.sum | 4 | ||||
| -rw-r--r-- | handlers/admin.go | 8 | ||||
| -rw-r--r-- | handlers/auth.go | 36 | ||||
| -rw-r--r-- | handlers/php_proxy.go | 4 | ||||
| -rw-r--r-- | handlers/user.go | 10 | ||||
| -rw-r--r-- | templates/account_password.html | 1 | ||||
| -rw-r--r-- | templates/admin.html | 1 | ||||
| -rw-r--r-- | templates/js/admin.js | 3 | ||||
| -rw-r--r-- | templates/login.html | 1 | ||||
| -rw-r--r-- | templates/php/account_password.php | 1 | ||||
| -rw-r--r-- | templates/php/login.php | 1 | ||||
| -rw-r--r-- | templates/php/register.php | 1 | ||||
| -rw-r--r-- | templates/register.html | 1 |
15 files changed, 85 insertions, 32 deletions
diff --git a/cmd/server/main.go b/cmd/server/main.go index 196164e..b60c1e0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -15,6 +15,7 @@ import ( "time" "github.com/joho/godotenv" + "github.com/gorilla/csrf" ) func main() { @@ -113,6 +114,7 @@ func main() { // --- Protected API Endpoints (for Admin UI, protected by SessionAuth) --- 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) @@ -175,24 +177,41 @@ func main() { } listenAddr := ":" + port - // --- Start Server --- - logging.AppLog.Info("Server starting on " + listenAddr) - - // Start periodic background tasks - - go startBackgroundTasks(pool, rateLimiter) - - + // --- CSRF Protection --- + csrfSecret := os.Getenv("CSRF_SECRET_KEY") + if csrfSecret == "" { + logging.AppLog.Error("CSRF_SECRET_KEY environment variable not set") + os.Exit(1) + } + // In production, you should set csrf.Secure(true). + // You can use an environment variable to control this setting. + csrfOptions := []csrf.Option{ + csrf.Secure(true), // Set to true in production + csrf.Path("/"), + } - if err := http.ListenAndServe(listenAddr, finalHandler); err != nil { + trustedOrigins := os.Getenv("CSRF_TRUSTED_ORIGINS") + if trustedOrigins != "" { + origins := strings.Split(trustedOrigins, ",") + csrfOptions = append(csrfOptions, csrf.TrustedOrigins(origins)) + } - logging.AppLog.Error("Server failed to start", "error", err) + csrfMiddleware := csrf.Protect( + []byte(csrfSecret), + csrfOptions..., + ) - os.Exit(1) + // --- Start Server --- + logging.AppLog.Info("Server starting on " + listenAddr) - } + // Start periodic background tasks + go startBackgroundTasks(pool, rateLimiter) + if err := http.ListenAndServe(listenAddr, csrfMiddleware(finalHandler)); err != nil { + logging.AppLog.Error("Server failed to start", "error", err) + os.Exit(1) } +} @@ -12,5 +12,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/gorilla/csrf v1.7.3 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect github.com/tomasen/fcgi_client v0.0.0-20180423082037-2bb3d819fd19 // indirect ) @@ -2,6 +2,10 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= diff --git a/handlers/admin.go b/handlers/admin.go index 8f302c0..5614e0e 100644 --- a/handlers/admin.go +++ b/handlers/admin.go @@ -6,12 +6,15 @@ import ( "auth-proxy/models" "auth-proxy/types" "auth-proxy/worker" + "auth-proxy/utils" "encoding/json" "fmt" "net/http" "strconv" "sync" "time" + + "github.com/gorilla/csrf" ) var workerPool *types.WorkerPool @@ -32,8 +35,9 @@ func AdminPageHandler(w http.ResponseWriter, r *http.Request) { } data := map[string]interface{}{ - "Paths": config.Paths, - "Users": users, + "Paths": config.Paths, + "Users": users, + "CSRFToken": csrf.Token(r), } if err := templates.ExecuteTemplate(w, "admin.html", data); err != nil { logging.AppLog.Error("Failed to execute admin template", "error", err) diff --git a/handlers/auth.go b/handlers/auth.go index fbd1349..0eb4851 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -11,15 +11,18 @@ import ( "os" "strconv" "time" + + "github.com/gorilla/csrf" ) var templates = template.Must(template.ParseGlob("templates/*.html")) -func renderLoginWithError(w http.ResponseWriter, message string) { +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 { @@ -35,6 +38,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { 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) @@ -45,7 +49,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { 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, "An internal error occurred. Please try again.") + renderLoginWithError(w, r, "An internal error occurred. Please try again.") return } username := r.FormValue("username") @@ -70,13 +74,13 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { lockoutDuration := time.Duration(lockoutMinutes) * time.Minute if time.Since(user.UpdatedAt) < lockoutDuration { logging.SecurityLog.Warn("LOGIN FAIL", "username", username, "ip", ip, "reason", "account locked") - renderLoginWithError(w, "Account is temporarily locked.") + renderLoginWithError(w, r, "Account is temporarily locked.") return } else { // If lockout expired, re-activate account and reset attempts if err := models.SetUserActiveStatus(user.ID, true); err != nil { logging.AppLog.Error("Failed to reactivate user", "error", err, "user_id", user.ID, "ip", ip) - renderLoginWithError(w, "An internal error occurred. Please try again.") + renderLoginWithError(w, r, "An internal error occurred. Please try again.") return } if err := models.RecordLoginSuccess(user.ID); err != nil { @@ -104,7 +108,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { } } logging.SecurityLog.Warn("LOGIN FAIL", "username", username, "ip", ip, "reason", "invalid credentials") - renderLoginWithError(w, "Invalid username or password.") + renderLoginWithError(w, r, "Invalid username or password.") return } @@ -181,10 +185,11 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, config.Paths.Login, http.StatusFound) } -func renderRegisterWithError(w http.ResponseWriter, message string) { +func renderRegisterWithError(w http.ResponseWriter, r *http.Request, message string) { data := map[string]interface{}{ - "Paths": config.Paths, - "Error": message, + "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 { @@ -200,7 +205,8 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) { } ip := logging.GetClientIP(r) data := map[string]interface{}{ - "Paths": config.Paths, + "Paths": config.Paths, + "CSRFToken": csrf.Token(r), } if r.Method == http.MethodGet { @@ -213,7 +219,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) { 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, "An internal error occurred. Please try again.") + renderRegisterWithError(w, r, "An internal error occurred. Please try again.") return } username := r.FormValue("username") @@ -222,32 +228,32 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) { // Validate password policy if ok, message := utils.ValidatePassword(password); !ok { - renderRegisterWithError(w, message) + renderRegisterWithError(w, r, message) return } if password != confirmPassword { logging.AppLog.Warn("User registration failed", "reason", "passwords do not match", "ip", ip) - renderRegisterWithError(w, "Passwords do not match") + 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, "An internal error occurred while checking the username.") + 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, "Username is already taken") + 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, "Failed to create user due to an internal error.") + renderRegisterWithError(w, r, "Failed to create user due to an internal error.") return } logging.SecurityLog.Info("USER CREATED", "username", user.Username, "ip", ip) diff --git a/handlers/php_proxy.go b/handlers/php_proxy.go index d30f363..24a38dc 100644 --- a/handlers/php_proxy.go +++ b/handlers/php_proxy.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" + "github.com/gorilla/csrf" fcgiclient "github.com/tomasen/fcgi_client" ) @@ -78,6 +79,9 @@ func NewPhpProxyHandler(scriptName string) http.Handler { env["AUTH_PATH_ADMIN_USERS_API"] = config.Paths.AdminUsersAPI env["AUTH_ASSETS_PATH"] = config.Paths.Assets + // Pass CSRF token to PHP + env["HTTP_X_CSRF_TOKEN"] = csrf.Token(r) + // Asynchronously log the proxying action to avoid blocking the request. worker.Submit(workerPool, func() { diff --git a/handlers/user.go b/handlers/user.go index d55f93d..7999755 100644 --- a/handlers/user.go +++ b/handlers/user.go @@ -8,6 +8,8 @@ import ( "auth-proxy/utils" "encoding/json" "net/http" + + "github.com/gorilla/csrf" ) // AccountPageHandler serves the account page, passing user data to the template. @@ -20,8 +22,9 @@ func AccountPageHandler(w http.ResponseWriter, r *http.Request) { } data := map[string]interface{}{ - "Paths": config.Paths, - "User": user, + "Paths": config.Paths, + "User": user, + "CSRFToken": csrf.Token(r), } if err := templates.ExecuteTemplate(w, "account.html", data); err != nil { logging.AppLog.Error("Failed to execute account template", "error", err, "user_id", user.ID) @@ -33,7 +36,8 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { // For GET request, just serve the page. if r.Method == http.MethodGet { data := map[string]interface{}{ - "Paths": config.Paths, + "Paths": config.Paths, + "CSRFToken": csrf.Token(r), } if err := templates.ExecuteTemplate(w, "account_password.html", data); err != nil { logging.AppLog.Error("Failed to execute account_password template", "error", err) diff --git a/templates/account_password.html b/templates/account_password.html index ba40761..68fb95f 100644 --- a/templates/account_password.html +++ b/templates/account_password.html @@ -11,6 +11,7 @@ <div class="container content-container"> <h2>Change Password</h2> <form id="change-password-form" action="{{.Paths.AccountPassword}}" method="post"> + <input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}"> <div class="form-group"> <label for="current_password">Current Password</label> <input type="password" id="current_password" name="current_password" required> diff --git a/templates/admin.html b/templates/admin.html index ac4ba94..c2f9488 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -3,6 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="csrf-token" content="{{.CSRFToken}}"> <title>Admin Dashboard</title> <link rel="stylesheet" href="{{.Paths.Assets}}/css/style.css"> <link rel="stylesheet" href="{{.Paths.Assets}}/css/admin.css"> diff --git a/templates/js/admin.js b/templates/js/admin.js index 4dff572..0b26e2f 100644 --- a/templates/js/admin.js +++ b/templates/js/admin.js @@ -7,8 +7,11 @@ * @returns {Promise<Response>} */ async function fetchApi(url, options = {}) { + const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + options.headers = { ...options.headers, + 'X-CSRF-Token': csrfToken }; // Set content type for methods that have a body. diff --git a/templates/login.html b/templates/login.html index 12f27eb..b2f847c 100644 --- a/templates/login.html +++ b/templates/login.html @@ -10,6 +10,7 @@ <div class="container form-container"> <h2>Login Required</h2> <form id="login-form" action="{{.Paths.Login}}" method="post"> + <input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}"> <div class="form-group"> <label for="username">Username</label> <input type="text" id="username" name="username" required> diff --git a/templates/php/account_password.php b/templates/php/account_password.php index b287c90..8a1871d 100644 --- a/templates/php/account_password.php +++ b/templates/php/account_password.php @@ -18,6 +18,7 @@ <div class="container content-container"> <h2>Change Password</h2> <form id="change-password-form" action="<?php echo htmlspecialchars($paths['AccountPassword']); ?>" method="post"> + <input type="hidden" name="gorilla.csrf.Token" value="<?php echo htmlspecialchars($_SERVER['HTTP_X_CSRF_TOKEN']); ?>"> <div class="form-group"> <label for="current_password">Current Password</label> <input type="password" id="current_password" name="current_password" required> diff --git a/templates/php/login.php b/templates/php/login.php index f3e5113..c4644ef 100644 --- a/templates/php/login.php +++ b/templates/php/login.php @@ -17,6 +17,7 @@ <div class="container form-container"> <h2>Login Required</h2> <form id="login-form" action="<?php echo htmlspecialchars($paths['Login']); ?>" method="post"> + <input type="hidden" name="gorilla.csrf.Token" value="<?php echo htmlspecialchars($_SERVER['HTTP_X_CSRF_TOKEN']); ?>"> <div class="form-group"> <label for="username">Username</label> <input type="text" id="username" name="username" required> diff --git a/templates/php/register.php b/templates/php/register.php index 956467a..31edf25 100644 --- a/templates/php/register.php +++ b/templates/php/register.php @@ -17,6 +17,7 @@ <div class="container form-container"> <h2>Create Account</h2> <form id="register-form" action="<?php echo htmlspecialchars($paths['Register']); ?>" method="post"> + <input type="hidden" name="gorilla.csrf.Token" value="<?php echo htmlspecialchars($_SERVER['HTTP_X_CSRF_TOKEN']); ?>"> <div class="form-group"> <label for="username">Username</label> <input type="text" id="username" name="username" required> diff --git a/templates/register.html b/templates/register.html index d98ef4d..7db7972 100644 --- a/templates/register.html +++ b/templates/register.html @@ -10,6 +10,7 @@ <div class="container form-container"> <h2>Create Account</h2> <form id="register-form" action="{{.Paths.Register}}" method="post"> + <input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}"> <div class="form-group"> <label for="username">Username</label> <input type="text" id="username" name="username" required> |
