summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci.yml60
-rw-r--r--.github/workflows/release.yml36
-rw-r--r--.gitignore9
-rw-r--r--README.md200
-rw-r--r--cmd/server/main.go109
-rw-r--r--config/config.go31
-rw-r--r--database/database.go231
-rw-r--r--go.mod13
-rw-r--r--go.sum14
-rw-r--r--handlers/admin.go155
-rw-r--r--handlers/auth.go221
-rw-r--r--handlers/proxy.go38
-rw-r--r--handlers/user.go100
-rw-r--r--logging/logging.go53
-rw-r--r--middleware/auth.go93
-rw-r--r--middleware/ratelimit.go74
-rw-r--r--models/token.go90
-rw-r--r--models/user.go147
-rw-r--r--templates/account.html23
-rw-r--r--templates/account_password.html35
-rw-r--r--templates/admin.html60
-rw-r--r--templates/css/account.css5
-rw-r--r--templates/css/admin.css57
-rw-r--r--templates/css/style.css128
-rw-r--r--templates/js/admin.js63
-rw-r--r--templates/js/auth.js48
-rw-r--r--templates/login.html34
-rw-r--r--templates/register.html38
-rw-r--r--types/types.go19
-rw-r--r--utils/validation.go103
30 files changed, 2287 insertions, 0 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c6be79c
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,60 @@
+name: CI & Lint
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ cache-dependency-path: '**/go.sum'
+
+ - name: Run build script
+ run: bash build.sh
+
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ cache-dependency-path: '**/go.sum'
+
+ - name: Check formatting
+ run: |
+ gofmt_output=$(gofmt -l .)
+ if [ -n "$gofmt_output" ]; then
+ echo "The following files are not formatted correctly:"
+ echo "$gofmt_output"
+ exit 1
+ fi
+ echo "All files are correctly formatted."
+
+ - name: Cache golangci-lint
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/golangci-lint
+ key: ${{ runner.os }}-golangci-lint-${{ hashFiles('**/go.sum') }}
+ restore-keys: |
+ ${{ runner.os }}-golangci-lint-
+
+ - name: Run golangci-lint
+ uses: golangci/golangci-lint-action@v6
+ with:
+ version: latest
+ skip-cache: true \ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..7e1dac7
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,36 @@
+name: Create Release
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ release:
+ name: Release
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Required for semantic-version
+
+ - name: Determine next version
+ id: version
+ uses: paulhatch/semantic-version@v5.4.0
+ with:
+ # The action will automatically bump the patch version
+ # on commits to main.
+ tag_prefix: "v"
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v2.0.6
+ with:
+ tag_name: ${{ steps.version.outputs.version_tag }}
+ name: "Release ${{ steps.version.outputs.version }}"
+ generate_release_notes: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9f3fa75
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+# Binaries
+auth-proxy-server
+admin-cli
+
+# DB file
+auth.db
+
+# .env file
+.env
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3fbd76d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,200 @@
+# Auth Proxy
+
+This is a simple authenticating reverse proxy written in Go. It provides a flexible authentication layer that can be placed in front of any backend web service.
+
+It handles user registration, login, and session management, and proxies authenticated requests to the configured backend. It also includes an administration dashboard for user management.
+
+## Features
+
+- **Authentication**: Provides login, logout, and registration pages.
+- **Reverse Proxy**: Proxies authenticated users to a backend service.
+- **Admin Dashboard**: A simple UI to manage users (update roles, activate/deactivate, delete).
+- **Flexible Database Support**: Works with PostgreSQL, MySQL, and SQLite.
+- **Security Hardening**:
+ - **Brute-force Protection**: Locks user accounts after a configurable number of failed login attempts.
+ - **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.
+- **Highly Configurable**: Nearly all aspects, including database connections, security policies, and internal URL paths, can be configured via an `.env` file.
+- **Structured Logging**: Detailed application and security logs using `slog`.
+- **CLI for Admin Creation**: A command-line tool to easily create initial admin users.
+
+### Password Policies
+
+The password strength requirements can be configured by setting the `PASSWORD_POLICY` variable in the `.env` file. The available levels are:
+
+- **`none`**: No requirements. Any password is allowed.
+- **`standard`** (Default): A minimum length of 6 characters is required.
+- **`enhanced`**: A minimum length of 8 characters is required. The password must contain at least one of each of the following: an uppercase letter, a lowercase letter, a number, and a symbol (e.g., `!@#$%^&*`).
+- **`strict`**: A minimum length of 12 characters is required. The password must meet the same complexity requirements as `enhanced` (uppercase, lowercase, number, and symbol).
+
+## Getting Started
+
+### Prerequisites
+
+- Go 1.21 or later
+- (Optional) PostgreSQL or MySQL database server.
+
+### Installation
+
+1. **Clone the repository:**
+ ```sh
+ git clone <repository-url>
+ cd auth-proxy
+ ```
+
+2. **Install dependencies:**
+ ```sh
+ go mod tidy
+ ```
+
+3. **Build the binaries:**
+ ```sh
+ go build -o auth-proxy-server ./cmd/server && go build -o admin-cli ./cmd/admin-cli
+ ```
+
+### Configuration
+
+Configuration is managed via a `.env` file in the root of the project. Create a file named `.env` and add the necessary variables.
+
+**Example `.env` file:**
+
+```dotenv
+# --- Required ---
+# The URL of the backend service you want to protect
+TARGET_URL=http://localhost:8081
+
+# A long, random string for securing session cookies
+# For production, generate a new key using: openssl rand -base64 45
+SESSION_SECRET=my-super-secret-key
+
+# --- Database ---
+# Use ONE of the following options:
+
+# Option 1: PostgreSQL or MySQL (Recommended)
+# Example: postgres://user:password@host:port/dbname?sslmode=disable
+# Example: mysql://user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=true
+DATABASE_URL=mysql://test:test@127.0.0.1:3306/auth
+
+# Option 2: SQLite (Default if DATABASE_URL is not set)
+# DATABASE_PATH=./auth.db
+
+# --- Security Policies ---
+
+# Brute-force protection settings
+MAX_LOGIN_ATTEMPTS=5
+LOCKOUT_DURATION_MINUTES=10
+
+# User creation rate limit settings
+USER_CREATION_RATE_LIMIT_MAX_REQUESTS=5
+USER_CREATION_RATE_LIMIT_WINDOW_SECONDS=3600
+
+# Password policy (none, standard, enhanced, strict)
+PASSWORD_POLICY=standard
+
+# --- Optional: Path Overrides ---
+# Uncomment and change these to avoid URL conflicts with your backend application.
+# AUTH_PATH_LOGIN=/login
+# AUTH_PATH_REGISTER=/register
+# AUTH_PATH_LOGOUT=/logout
+# AUTH_PATH_ACCOUNT=/account
+# AUTH_PATH_ACCOUNT_PASSWORD=/account/password
+# AUTH_PATH_ADMIN=/admin
+# AUTH_PATH_ADMIN_USERS_API=/api/admin/users
+# AUTH_ASSETS_PATH=/auth-proxy-assets
+```
+
+### Database Setup Examples
+
+If you are using PostgreSQL or MySQL, you will need to create a database and a user for the proxy.
+
+#### PostgreSQL
+
+Run these commands in `psql`:
+
+```sql
+-- Create a dedicated database
+CREATE DATABASE auth_proxy;
+
+-- Create a dedicated user
+CREATE USER auth_user WITH PASSWORD 'your_strong_password';
+
+-- Grant privileges on the database to the user
+GRANT ALL PRIVILEGES ON DATABASE auth_proxy TO auth_user;
+```
+
+Your `DATABASE_URL` in the `.env` file would then be:
+`DATABASE_URL=postgres://auth_user:your_strong_password@localhost:5432/auth_proxy?sslmode=disable`
+
+#### MySQL / MariaDB
+
+Run these commands in the MySQL client:
+
+```sql
+-- Create a dedicated database
+CREATE DATABASE auth_proxy CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- Create a dedicated user
+CREATE USER 'auth_user'@'localhost' IDENTIFIED BY 'your_strong_password';
+
+-- Grant privileges on the database to the user
+GRANT ALL PRIVILEGES ON auth_proxy.* TO 'auth_user'@'localhost';
+
+-- Apply the changes
+FLUSH PRIVILEGES;
+```
+
+Your `DATABASE_URL` in the `.env` file would then be:
+`DATABASE_URL=mysql://auth_user:your_strong_password@tcp(127.0.0.1:3306)/auth_proxy?parseTime=true`
+
+## Usage
+
+1. **Create an Admin User**:
+
+ Before starting the server, create your first admin user using the `admin-cli` tool. Make sure your `.env` file is configured correctly, as the CLI uses it to connect to the database.
+
+ The `--username` and `--password` flags are required.
+
+ *Successful command:*
+ ```sh
+ ./admin-cli --username <your-admin-username> --password <your-strong-password>
+ ```
+
+ *Example of missing flags:*
+ ```sh
+ $ ./admin-cli
+ time=2025-10-12T04:28:09.014+09:00 level=ERROR msg="Both --username and --password flags are required"
+ ```
+
+2. **Run the Proxy Server**:
+
+ ```sh
+ ./auth-proxy-server
+ ```
+
+ The server will start on port `:8080` by default.
+
+3. **Access the Application**:
+
+ - Open your browser and navigate to `http://localhost:8080`.
+ - You will be redirected to the login page.
+ - Log in with the admin credentials you created.
+ - Once authenticated, you will be seamlessly proxied to the `TARGET_URL`.
+ - You can access the admin dashboard at `http://localhost:8080/admin` (or your configured path).
+
+## Configuration Details
+
+| Environment Variable | Description | Default Value |
+| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
+| `TARGET_URL` | **(Required)** The URL of the backend service to proxy to. | - |
+| `SESSION_SECRET` | **(Required)** A long, random secret key for encrypting session cookies. | `default-secret-key-for-dev` |
+| `DATABASE_URL` | The connection string for PostgreSQL or MySQL. If set, this overrides `DATABASE_PATH`. | - |
+| `DATABASE_PATH` | The file path for the SQLite database. Used only if `DATABASE_URL` is not set. | `./auth.db` |
+| `MAX_LOGIN_ATTEMPTS` | Number of failed login attempts before an account is locked. | `5` |
+| `LOCKOUT_DURATION_MINUTES` | Duration in minutes for how long an account remains locked. | `10` |
+| `USER_CREATION_RATE_LIMIT_MAX_REQUESTS` | Max number of user registrations allowed from a single IP within the time window. | `5` |
+| `USER_CREATION_RATE_LIMIT_WINDOW_SECONDS` | The time window in seconds for the user creation rate limit. | `3600` (1 hour) |
+| `PASSWORD_POLICY` | Sets the password strength requirements. See the "Password Policies" section for details. | `standard` |
+| `AUTH_PATH_*` | A set of variables to customize the internal URLs for login, admin, etc., to prevent conflicts with the backend. See `.env` example. | Various, e.g., `/login`|
+| `AUTH_ASSETS_PATH` | The URL path for serving internal static assets (CSS, JS). | `/auth-proxy-assets` |
+
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..a12b278
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,109 @@
+package main
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/database"
+ "auth-proxy/handlers"
+ "auth-proxy/logging"
+ "auth-proxy/middleware"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/joho/godotenv"
+)
+
+func main() {
+ // Initialize loggers
+ logging.InitLoggers()
+
+ // Load .env file
+ if err := godotenv.Load(); err != nil {
+ logging.AppLog.Info("No .env file found, using environment variables")
+ }
+
+ // Initialize path configurations
+ config.Init()
+
+ // Initialize database
+ database.InitDB()
+ defer database.CloseDB()
+
+ // 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 router and define routes ---
+ mux := http.NewServeMux()
+
+ // --- Static file serving ---
+ // The assets path must end with a slash for http.StripPrefix to work correctly.
+ assetsPath := config.Paths.Assets + "/"
+ assetsFS := http.FileServer(http.Dir("templates"))
+ mux.Handle(assetsPath, http.StripPrefix(assetsPath, assetsFS))
+
+ // --- Public Auth Routes ---
+ rateLimiter := middleware.NewRateLimiter()
+ // Auth API endpoints
+ mux.HandleFunc(config.Paths.Login, handlers.LoginHandler) // GET serves HTML, POST handles login
+ mux.Handle(config.Paths.Register, rateLimiter.RateLimitMiddleware(http.HandlerFunc(handlers.RegisterHandler))) // GET serves HTML, POST handles registration
+ mux.HandleFunc(config.Paths.Logout, handlers.LogoutHandler)
+
+ // Auth HTML pages
+ mux.Handle(config.Paths.Account, middleware.AuthMiddleware(http.HandlerFunc(handlers.AccountPageHandler)))
+ mux.Handle(config.Paths.Admin, middleware.AuthMiddleware(middleware.AdminMiddleware(http.HandlerFunc(handlers.AdminPageHandler))))
+ mux.Handle(config.Paths.AccountPassword, middleware.AuthMiddleware(http.HandlerFunc(handlers.ChangePasswordHandler))) // GET serves HTML, POST handles password change
+
+ // --- Protected API Endpoints ---
+ adminUsersAPI := http.HandlerFunc(handlers.GetUsersHandler)
+ adminUpdateRoleAPI := http.HandlerFunc(handlers.UpdateUserRoleHandler)
+ adminDeleteUserAPI := http.HandlerFunc(handlers.DeleteUserHandler)
+ adminSetStatusAPI := http.HandlerFunc(handlers.SetUserActiveStatusHandler)
+
+ mux.Handle(config.Paths.AdminUsersAPI, middleware.AuthMiddleware(middleware.AdminMiddleware(adminUsersAPI)))
+ mux.Handle(config.Paths.AdminUsersAPI+"/", middleware.AuthMiddleware(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 ---
+ // All other requests are sent to the proxy. The final handler below will wrap them
+ // with the authentication middleware.
+ proxyHandler := handlers.NewProxy(targetURL)
+ authedProxy := middleware.AuthMiddleware(proxyHandler)
+
+ // finalHandler wraps the mux. If a route is not explicitly defined in the mux,
+ // it falls through to the authenticated proxy. This prevents the login page
+ // from being caught by the authentication middleware, fixing the redirect loop.
+ finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Check if a handler is registered for the path in the mux.
+ // The returned pattern will be empty if no handler matches.
+ _, pattern := mux.Handler(r)
+ if pattern != "" {
+ // A specific route was matched (e.g., /login, /admin, /assets/). Serve it.
+ mux.ServeHTTP(w, r)
+ } else {
+ // No specific route was matched. Use the authenticated proxy.
+ authedProxy.ServeHTTP(w, r)
+ }
+ })
+
+ // --- Start Server ---
+ logging.AppLog.Info("Server starting on :8081")
+ if err := http.ListenAndServe(":8081", finalHandler); err != nil {
+ logging.AppLog.Error("Server failed to start", "error", err)
+ os.Exit(1)
+ }
+}
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..35cd2dc
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,31 @@
+package config
+
+import (
+ "auth-proxy/types"
+ "os"
+)
+
+// Paths holds the global configuration values.
+var Paths types.PathConfig
+
+// getEnv reads an environment variable or returns a fallback value.
+func getEnv(key, fallback string) string {
+ if value, ok := os.LookupEnv(key); ok {
+ return value
+ }
+ return fallback
+}
+
+// Init loads path configurations from environment variables.
+func Init() {
+ Paths = types.PathConfig{
+ Login: getEnv("AUTH_PATH_LOGIN", "/login"),
+ Register: getEnv("AUTH_PATH_REGISTER", "/register"),
+ Logout: getEnv("AUTH_PATH_LOGOUT", "/logout"),
+ Account: getEnv("AUTH_PATH_ACCOUNT", "/account"),
+ AccountPassword: getEnv("AUTH_PATH_ACCOUNT_PASSWORD", "/account/password"),
+ Admin: getEnv("AUTH_PATH_ADMIN", "/admin"),
+ AdminUsersAPI: getEnv("AUTH_PATH_ADMIN_USERS_API", "/api/admin/users"),
+ Assets: getEnv("AUTH_ASSETS_PATH", "/auth-proxy-assets"),
+ }
+}
diff --git a/database/database.go b/database/database.go
new file mode 100644
index 0000000..04ebf3e
--- /dev/null
+++ b/database/database.go
@@ -0,0 +1,231 @@
+package database
+
+import (
+ "auth-proxy/logging"
+ "database/sql"
+ "fmt"
+ "net/url"
+ "os"
+ "strings"
+
+ _ "github.com/go-sql-driver/mysql"
+ _ "github.com/lib/pq"
+ _ "github.com/mattn/go-sqlite3"
+)
+
+var (
+ DB *sql.DB
+ dbType string
+)
+
+// InitDB determines the database type from environment variables and initializes the connection.
+func InitDB() {
+ databaseURL := os.Getenv("DATABASE_URL")
+ if databaseURL != "" {
+ initFromURL(databaseURL)
+ } else {
+ dbType = "sqlite3"
+ initSQLite()
+ }
+ createTables()
+}
+
+// initFromURL initializes the database based on the DATABASE_URL.
+func initFromURL(databaseURL string) {
+ u, err := url.Parse(databaseURL)
+ if err != nil {
+ logging.AppLog.Error("Invalid DATABASE_URL", "error", err)
+ os.Exit(1)
+ }
+
+ driver := u.Scheme
+ logging.AppLog.Info("Attempting to connect to database", "driver", driver)
+
+ switch driver {
+ case "postgres", "postgresql":
+ dbType = "postgres"
+ initPostgreSQL(databaseURL)
+ case "mysql":
+ dbType = "mysql"
+
+ // The mysql driver requires parseTime=true to scan DATE/DATETIME into time.Time
+ q := u.Query()
+ if q.Get("parseTime") == "" {
+ q.Set("parseTime", "true")
+ }
+ u.RawQuery = q.Encode()
+
+ // The mysql driver DSN format is user:password@tcp(host:port)/dbname
+ var dsn string
+ if u.User != nil {
+ dsn = fmt.Sprintf("%s@tcp(%s)%s", u.User.String(), u.Host, u.Path)
+ } else {
+ dsn = fmt.Sprintf("tcp(%s)%s", u.Host, u.Path)
+ }
+ if u.RawQuery != "" {
+ dsn = fmt.Sprintf("%s?%s", dsn, u.RawQuery)
+ }
+ initMySQL(dsn)
+ default:
+ logging.AppLog.Error("Unsupported database driver in DATABASE_URL", "driver", driver)
+ os.Exit(1)
+ }
+}
+
+// initSQLite initializes a SQLite database connection.
+func initSQLite() {
+ databasePath := os.Getenv("DATABASE_PATH")
+ if databasePath == "" {
+ databasePath = "./auth.db"
+ }
+
+ var err error
+ DB, err = sql.Open("sqlite3", databasePath)
+ if err != nil {
+ logging.AppLog.Error("Failed to open sqlite database", "error", err)
+ os.Exit(1)
+ }
+ if err = DB.Ping(); err != nil {
+ logging.AppLog.Error("Failed to connect to sqlite database", "error", err)
+ os.Exit(1)
+ }
+ logging.AppLog.Info("Database connection established", "type", "sqlite", "path", databasePath)
+}
+
+// initPostgreSQL initializes a PostgreSQL database connection.
+func initPostgreSQL(dataSourceName string) {
+ var err error
+ DB, err = sql.Open("postgres", dataSourceName)
+ if err != nil {
+ logging.AppLog.Error("Failed to open postgres database", "error", err)
+ os.Exit(1)
+ }
+ if err = DB.Ping(); err != nil {
+ logging.AppLog.Error("Failed to connect to postgres database", "error", err)
+ os.Exit(1)
+ }
+ logging.AppLog.Info("Database connection established", "type", "postgres")
+}
+
+// initMySQL initializes a MySQL database connection.
+func initMySQL(dataSourceName string) {
+ var err error
+ DB, err = sql.Open("mysql", dataSourceName)
+ if err != nil {
+ logging.AppLog.Error("Failed to open mysql database", "error", err)
+ os.Exit(1)
+ }
+ if err = DB.Ping(); err != nil {
+ logging.AppLog.Error("Failed to connect to mysql database", "error", err)
+ os.Exit(1)
+ }
+ logging.AppLog.Info("Database connection established", "type", "mysql")
+}
+
+// createTables creates the necessary tables if they don't exist, using syntax appropriate for the current dbType.
+func createTables() {
+ var createTableSQL string
+ switch dbType {
+ case "sqlite3":
+ createTableSQL = `CREATE TABLE IF NOT EXISTS users (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "username" TEXT NOT NULL UNIQUE,
+ "password_hash" TEXT NOT NULL,
+ "role" TEXT NOT NULL,
+ "is_active" BOOLEAN NOT NULL DEFAULT TRUE,
+ "failed_logins" INTEGER NOT NULL DEFAULT 0,
+ "last_login_at" DATETIME,
+ "created_at" DATETIME DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" DATETIME DEFAULT CURRENT_TIMESTAMP
+ );`
+ case "postgres":
+ createTableSQL = `CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(255) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ role VARCHAR(50) NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ failed_logins INT NOT NULL DEFAULT 0,
+ last_login_at TIMESTAMP WITH TIME ZONE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+ );`
+ case "mysql":
+ createTableSQL = `CREATE TABLE IF NOT EXISTS users (
+ id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(255) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NOT NULL,
+ role VARCHAR(50) NOT NULL,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ failed_logins INT NOT NULL DEFAULT 0,
+ last_login_at DATETIME,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ );`
+ }
+
+ if _, err := DB.Exec(createTableSQL); err != nil {
+ logging.AppLog.Error("Failed to create users table", "error", err, "db_type", dbType)
+ os.Exit(1)
+ }
+ logging.AppLog.Info("Users table verified successfully")
+
+ // Now, create the auth_tokens table
+ var createTokenTableSQL string
+ switch dbType {
+ case "sqlite3":
+ createTokenTableSQL = `CREATE TABLE IF NOT EXISTS auth_tokens (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "user_id" INTEGER NOT NULL,
+ "token" TEXT NOT NULL UNIQUE,
+ "expires_at" DATETIME NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ );`
+ case "postgres":
+ createTokenTableSQL = `CREATE TABLE IF NOT EXISTS auth_tokens (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token VARCHAR(255) NOT NULL UNIQUE,
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL
+ );`
+ case "mysql":
+ createTokenTableSQL = `CREATE TABLE IF NOT EXISTS auth_tokens (
+ id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ token VARCHAR(255) NOT NULL UNIQUE,
+ expires_at DATETIME NOT NULL,
+ FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
+ );`
+ }
+
+ if _, err := DB.Exec(createTokenTableSQL); err != nil {
+ logging.AppLog.Error("Failed to create auth_tokens table", "error", err, "db_type", dbType)
+ os.Exit(1)
+ }
+ logging.AppLog.Info("Auth_tokens table verified successfully")
+}
+
+// Rebind converts a query with '_?_' placeholders to the database-specific format.
+func Rebind(query string) string {
+ if dbType == "postgres" {
+ parts := strings.Split(query, "?")
+ var result strings.Builder
+ for i, part := range parts {
+ if i > 0 {
+ result.WriteString(fmt.Sprintf("$%d", i))
+ }
+ result.WriteString(part)
+ }
+ return result.String()
+ }
+ // sqlite3 and mysql both use '?'
+ return query
+}
+
+// CloseDB closes the database connection.
+func CloseDB() {
+ if DB != nil {
+ DB.Close()
+ logging.AppLog.Info("Database connection closed")
+ }
+} \ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..ab70c4b
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,13 @@
+module auth-proxy
+
+go 1.25.2
+
+require (
+ filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/go-sql-driver/mysql v1.9.3 // indirect
+ github.com/gorilla/securecookie v1.1.2 // indirect
+ github.com/joho/godotenv v1.5.1 // indirect
+ github.com/lib/pq v1.10.9 // indirect
+ github.com/mattn/go-sqlite3 v1.14.32 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..885ddd3
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,14 @@
+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/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=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
diff --git a/handlers/admin.go b/handlers/admin.go
new file mode 100644
index 0000000..d9fb310
--- /dev/null
+++ b/handlers/admin.go
@@ -0,0 +1,155 @@
+package handlers
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/logging"
+ "auth-proxy/models"
+ "auth-proxy/types"
+ "encoding/json"
+ "net/http"
+ "strconv"
+)
+
+// AdminPageHandler serves the admin dashboard page.
+func AdminPageHandler(w http.ResponseWriter, r *http.Request) {
+ // Fetch all users to render on the page
+ users, err := models.GetAllUsers()
+ if err != nil {
+ logging.AppLog.Error("Failed to retrieve users for admin page", "error", err)
+ http.Error(w, "Could not load user data.", http.StatusInternalServerError)
+ return
+ }
+
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ "Users": users,
+ }
+ templates.ExecuteTemplate(w, "admin.html", data)
+}
+
+// GetUsersHandler returns a list of all users.
+func GetUsersHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ users, err := models.GetAllUsers()
+ if err != nil {
+ logging.AppLog.Error("Failed to retrieve users", "error", err, "ip", ip)
+ http.Error(w, "Failed to retrieve users", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(users)
+}
+
+// UpdateUserRoleHandler updates a user's role.
+func UpdateUserRoleHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ // Get admin user from context
+ adminUser, ok := r.Context().Value(types.SessionDataContextKey).(*models.User)
+ if !ok || adminUser == nil {
+ logging.AppLog.Error("Failed to get user from context for admin action", "ip", ip)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ adminUsername := adminUser.Username
+
+ id, err := strconv.ParseInt(r.URL.Path[len(config.Paths.AdminUsersAPI+"/"):len(r.URL.Path)-len("/role")], 10, 64)
+ if err != nil {
+ logging.AppLog.Warn("Invalid user ID in request", "path", r.URL.Path, "ip", ip)
+ http.Error(w, "Invalid user ID", http.StatusBadRequest)
+ return
+ }
+
+ var req struct {
+ Role string `json:"role"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ logging.AppLog.Warn("Invalid request body for role update", "error", err, "ip", ip)
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.Role != "admin" && req.Role != "user" {
+ logging.AppLog.Warn("Invalid role provided for update", "role", req.Role, "ip", ip)
+ http.Error(w, "Invalid role", http.StatusBadRequest)
+ return
+ }
+
+ if err := models.UpdateUserRole(id, req.Role); err != nil {
+ logging.AppLog.Error("Failed to update user role in database", "error", err, "target_user_id", id, "ip", ip)
+ http.Error(w, "Failed to update role", http.StatusInternalServerError)
+ return
+ }
+
+ logging.SecurityLog.Info("ADMIN ACTION", "action", "update_role", "performed_by", adminUsername, "target_user_id", id, "new_role", req.Role, "ip", ip)
+ w.WriteHeader(http.StatusOK)
+}
+
+// DeleteUserHandler deletes a user.
+func DeleteUserHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ // Get admin user from context
+ adminUser, ok := r.Context().Value(types.SessionDataContextKey).(*models.User)
+ if !ok || adminUser == nil {
+ logging.AppLog.Error("Failed to get user from context for admin action", "ip", ip)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ adminUsername := adminUser.Username
+
+ id, err := strconv.ParseInt(r.URL.Path[len(config.Paths.AdminUsersAPI+"/"):], 10, 64)
+ if err != nil {
+ logging.AppLog.Warn("Invalid user ID in request", "path", r.URL.Path, "ip", ip)
+ http.Error(w, "Invalid user ID", http.StatusBadRequest)
+ return
+ }
+
+ if err := models.DeleteUser(id); err != nil {
+ logging.AppLog.Error("Failed to delete user from database", "error", err, "target_user_id", id, "ip", ip)
+ http.Error(w, "Failed to delete user", http.StatusInternalServerError)
+ return
+ }
+
+ logging.SecurityLog.Info("ADMIN ACTION", "action", "delete_user", "performed_by", adminUsername, "target_user_id", id, "ip", ip)
+ w.WriteHeader(http.StatusOK)
+}
+
+// SetUserActiveStatusHandler updates a user's active status.
+func SetUserActiveStatusHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ // Get admin user from context
+ adminUser, ok := r.Context().Value(types.SessionDataContextKey).(*models.User)
+ if !ok || adminUser == nil {
+ logging.AppLog.Error("Failed to get user from context for admin action", "ip", ip)
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ adminUsername := adminUser.Username
+
+ id, err := strconv.ParseInt(r.URL.Path[len(config.Paths.AdminUsersAPI+"/"):len(r.URL.Path)-len("/status")], 10, 64)
+ if err != nil {
+ logging.AppLog.Warn("Invalid user ID in request", "path", r.URL.Path, "ip", ip)
+ http.Error(w, "Invalid user ID", http.StatusBadRequest)
+ return
+ }
+
+ var req struct {
+ IsActive bool `json:"is_active"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ logging.AppLog.Warn("Invalid request body for status update", "error", err, "ip", ip)
+ http.Error(w, "Invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if err := models.SetUserActiveStatus(id, req.IsActive); err != nil {
+ logging.AppLog.Error("Failed to update user status in database", "error", err, "target_user_id", id, "ip", ip)
+ http.Error(w, "Failed to update status", http.StatusInternalServerError)
+ return
+ }
+
+ logging.SecurityLog.Info("ADMIN ACTION", "action", "set_active_status", "performed_by", adminUsername, "target_user_id", id, "new_status", req.IsActive, "ip", ip)
+ w.WriteHeader(http.StatusOK)
+} \ No newline at end of file
diff --git a/handlers/auth.go b/handlers/auth.go
new file mode 100644
index 0000000..28c7fa7
--- /dev/null
+++ b/handlers/auth.go
@@ -0,0 +1,221 @@
+package handlers
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/logging"
+ "auth-proxy/models"
+ "auth-proxy/utils"
+ "encoding/json"
+ "html/template"
+ "net/http"
+ "os"
+ "strconv"
+ "time"
+)
+
+var templates = template.Must(template.ParseGlob("templates/*.html"))
+
+func renderLoginWithError(w http.ResponseWriter, message string) {
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ "Error": message,
+ }
+ w.WriteHeader(http.StatusUnauthorized) // Set status to indicate failure
+ templates.ExecuteTemplate(w, "login.html", data)
+}
+
+// LoginHandler handles displaying the login form and processing login requests.
+func LoginHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ }
+
+ if r.Method == http.MethodGet {
+ templates.ExecuteTemplate(w, "login.html", data)
+ return
+ }
+
+ if r.Method == http.MethodPost {
+ r.ParseForm()
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ user, err := models.GetUserByUsername(username)
+ if err != nil {
+ logging.AppLog.Error("Database error during login", "error", err, "username", username, "ip", ip)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "An internal error occurred."})
+ return
+ }
+
+ // Check for lockout and user status first
+ if user != nil {
+ lockoutMinutes, _ := strconv.Atoi(os.Getenv("LOCKOUT_DURATION_MINUTES"))
+
+ if !user.IsActive {
+ 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.")
+ return
+ } else {
+ // If lockout expired, re-activate account and reset attempts
+ models.SetUserActiveStatus(user.ID, true)
+ models.RecordLoginSuccess(user.ID) // Resets failed_logins
+ }
+ }
+ }
+
+ // Validate user credentials
+ if user == nil || !models.CheckPasswordHash(password, user.PasswordHash) {
+ if user != nil {
+ models.RecordLoginFailure(user.ID)
+ // Re-fetch user to get updated failed_logins count
+ updatedUser, _ := models.GetUserByID(user.ID)
+ maxAttempts, _ := strconv.Atoi(os.Getenv("MAX_LOGIN_ATTEMPTS"))
+ if updatedUser.FailedLogins >= maxAttempts {
+ models.SetUserActiveStatus(user.ID, false)
+ logging.SecurityLog.Warn("ACCOUNT LOCKED", "username", username, "ip", ip)
+ }
+ }
+ logging.SecurityLog.Warn("LOGIN FAIL", "username", username, "ip", ip, "reason", "invalid credentials")
+ renderLoginWithError(w, "Invalid username or password.")
+ return
+ }
+
+ // Login successful
+ models.RecordLoginSuccess(user.ID)
+ logging.SecurityLog.Info("LOGIN SUCCESS", "username", user.Username, "ip", ip)
+
+ 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
+
+ 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)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "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, message string) {
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ "Error": message,
+ }
+ w.WriteHeader(http.StatusBadRequest) // Set status to indicate failure
+ templates.ExecuteTemplate(w, "register.html", data)
+}
+
+// RegisterHandler handles displaying and processing the user registration form.
+func RegisterHandler(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ }
+
+ if r.Method == http.MethodGet {
+ templates.ExecuteTemplate(w, "register.html", data)
+ return
+ }
+
+ if r.Method == http.MethodPost {
+ r.ParseForm()
+ 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, message)
+ return
+ }
+
+ if password != confirmPassword {
+ logging.AppLog.Warn("User registration failed", "reason", "passwords do not match", "ip", ip)
+ renderRegisterWithError(w, "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.")
+ 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")
+ 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.")
+ 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/proxy.go b/handlers/proxy.go
new file mode 100644
index 0000000..bc31059
--- /dev/null
+++ b/handlers/proxy.go
@@ -0,0 +1,38 @@
+package handlers
+
+import (
+ "auth-proxy/logging"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+)
+
+// NewProxy creates a new reverse proxy handler using httputil.ReverseProxy.
+func NewProxy(targetURL string) http.Handler {
+ target, err := url.Parse(targetURL)
+ if err != nil {
+ panic("Invalid target URL: " + err.Error())
+ }
+
+ proxy := httputil.NewSingleHostReverseProxy(target)
+
+ // The default director is mostly fine, but we want to log and ensure the Host header is correct.
+ director := proxy.Director
+ proxy.Director = func(req *http.Request) {
+ director(req) // Run the default director
+ req.Host = target.Host // Set the Host header to the target's host
+
+ // Log the proxying action
+ ip := logging.GetClientIP(req)
+ logging.AppLog.Info("Proxying request", "url", req.URL.String(), "ip", ip)
+ }
+
+ // Custom error handler for logging
+ proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
+ ip := logging.GetClientIP(r)
+ logging.AppLog.Error("Proxy error", "error", err, "ip", ip)
+ http.Error(w, "Proxy Error", http.StatusBadGateway)
+ }
+
+ return proxy
+} \ No newline at end of file
diff --git a/handlers/user.go b/handlers/user.go
new file mode 100644
index 0000000..196405b
--- /dev/null
+++ b/handlers/user.go
@@ -0,0 +1,100 @@
+package handlers
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/models"
+ "auth-proxy/types"
+ "auth-proxy/utils"
+ "encoding/json"
+ "net/http"
+)
+
+// AccountPageHandler serves the account page, passing user data to the template.
+func AccountPageHandler(w http.ResponseWriter, r *http.Request) {
+ user, ok := r.Context().Value(types.SessionDataContextKey).(*models.User)
+ if !ok || user == nil {
+ // This should ideally not happen if AuthMiddleware is working correctly.
+ http.Error(w, "User not authenticated", http.StatusUnauthorized)
+ return
+ }
+
+ data := map[string]interface{}{
+ "Paths": config.Paths,
+ "User": user,
+ }
+ templates.ExecuteTemplate(w, "account.html", data)
+}
+
+// ChangePasswordHandler handles both displaying the page (GET) and processing the update (POST).
+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,
+ }
+ templates.ExecuteTemplate(w, "account_password.html", data)
+ return
+ }
+
+ // POST request logic (API call)
+ if r.Method == http.MethodPost {
+ // This is a protected route, so AuthMiddleware should have put the user in the context.
+ user, ok := r.Context().Value(types.SessionDataContextKey).(*models.User)
+ if !ok || user == nil {
+ http.Error(w, "User not authenticated", http.StatusUnauthorized)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ r.ParseForm()
+ currentPassword := r.FormValue("current_password")
+ newPassword := r.FormValue("new_password")
+ confirmNewPassword := r.FormValue("confirm_new_password")
+
+ if ok, message := utils.ValidatePassword(newPassword); !ok {
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": message})
+ return
+ }
+
+ if newPassword != confirmNewPassword {
+ w.WriteHeader(http.StatusBadRequest)
+ json.NewEncoder(w).Encode(map[string]string{"error": "New passwords do not match."})
+ return
+ }
+
+ dbUser, err := models.GetUserByID(user.ID)
+ if err != nil || dbUser == nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "Could not retrieve user data."})
+ return
+ }
+
+ if !models.CheckPasswordHash(currentPassword, dbUser.PasswordHash) {
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(map[string]string{"error": "Current password is incorrect."})
+ return
+ }
+
+ newPasswordHash, err := models.HashPassword(newPassword)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "Failed to process new password."})
+ return
+ }
+
+ if err := models.UpdateUserPassword(dbUser.ID, newPasswordHash); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "Failed to update password."})
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{"message": "Password updated successfully."})
+ return
+ }
+
+ // Handle other methods
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+} \ No newline at end of file
diff --git a/logging/logging.go b/logging/logging.go
new file mode 100644
index 0000000..8a2d0ee
--- /dev/null
+++ b/logging/logging.go
@@ -0,0 +1,53 @@
+package logging
+
+import (
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+)
+
+var (
+ AppLog *slog.Logger
+ SecurityLog *slog.Logger
+)
+
+// InitLoggers initializes the application and security loggers.
+func InitLoggers() {
+ // For now, both loggers write to stdout.
+ // This could be configured to write to different files (e.g., app.log, security.log)
+ appWriter := os.Stdout
+ securityWriter := os.Stdout
+
+ AppLog = slog.New(slog.NewTextHandler(appWriter, &slog.HandlerOptions{
+ Level: slog.LevelInfo,
+ AddSource: false, // Include source file and line number for app logs
+ }))
+
+ SecurityLog = slog.New(slog.NewTextHandler(securityWriter, &slog.HandlerOptions{
+ Level: slog.LevelInfo,
+ // The default TextHandler format is `time=... level=... msg=... key=value`.
+ // This is structured and easily parsable, so we'll stick with it.
+ }))
+}
+
+// GetClientIP extracts the client IP address from the request.
+// It prioritizes the X-Forwarded-For header to work behind a reverse proxy.
+func GetClientIP(r *http.Request) string {
+ // Check the X-Forwarded-For header first.
+ forwardedFor := r.Header.Get("X-Forwarded-For")
+ if forwardedFor != "" {
+ // The header can contain a comma-separated list of IPs. The client IP is the first one.
+ ips := strings.Split(forwardedFor, ",")
+ return strings.TrimSpace(ips[0])
+ }
+
+ // If the header is not present, fall back to RemoteAddr.
+ ip, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ // If parsing fails (e.g., no port), return the raw RemoteAddr.
+ return r.RemoteAddr
+ }
+ return ip
+}
diff --git a/middleware/auth.go b/middleware/auth.go
new file mode 100644
index 0000000..08ad088
--- /dev/null
+++ b/middleware/auth.go
@@ -0,0 +1,93 @@
+package middleware
+
+import (
+ "auth-proxy/config"
+ "auth-proxy/logging"
+ "auth-proxy/models"
+ "auth-proxy/types"
+ "context"
+ "database/sql"
+ "net/http"
+ "strings"
+)
+
+// AuthMiddleware checks for a valid Bearer token, suitable for API endpoints.
+// If the token is valid, it adds the user info to the context and proceeds.
+// If not, it returns a 401 Unauthorized error.
+func AuthMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ cookie, err := r.Cookie("auth_token")
+
+ // If the cookie is not found
+ if err != nil {
+ if err == http.ErrNoCookie {
+ // If it's an API request, return 401.
+ if strings.HasPrefix(r.URL.Path, "/api/") {
+ logging.AppLog.Info("API access denied (no token)", "path", r.URL.Path, "ip", ip)
+ http.Error(w, "Unauthorized: A valid token is required", http.StatusUnauthorized)
+ return
+ }
+ // Otherwise, it's a page navigation, so redirect to login.
+ logging.AppLog.Info("Redirecting to login (no token)", "path", r.URL.Path, "ip", ip)
+ http.Redirect(w, r, config.Paths.Login, http.StatusFound)
+ return
+ }
+ // For any other error retrieving the cookie
+ logging.AppLog.Error("Error reading auth cookie", "error", err, "path", r.URL.Path, "ip", ip)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ tokenStr := cookie.Value
+ user, err := models.GetUserByToken(tokenStr)
+ // Check for database errors first
+ if err != nil && err != sql.ErrNoRows {
+ logging.AppLog.Error("Error validating token", "error", err, "path", r.URL.Path, "ip", ip)
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ if user == nil {
+ // Token was provided but is invalid or expired. Clear the bad cookie.
+ http.SetCookie(w, &http.Cookie{
+ Name: "auth_token",
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ })
+ logging.SecurityLog.Warn("ACCESS DENIED", "reason", "invalid or expired token", "path", r.URL.Path, "ip", ip)
+ // Redirect to login, as the user needs to re-authenticate.
+ http.Redirect(w, r, config.Paths.Login, http.StatusFound)
+ return
+ }
+
+ // User is authenticated, proceed
+ ctx := context.WithValue(r.Context(), types.SessionDataContextKey, user)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// AdminMiddleware checks if the user has the 'admin' role.
+// This middleware should be used after AuthMiddleware.
+func AdminMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ip := logging.GetClientIP(r)
+ // Get user from context
+ user, ok := r.Context().Value(types.SessionDataContextKey).(*models.User)
+ if !ok || user == nil {
+ logging.SecurityLog.Warn("ADMIN ACCESS DENIED", "reason", "no user data in context", "path", r.URL.Path, "ip", ip)
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ if user.Role != "admin" {
+ logging.SecurityLog.Warn("ADMIN ACCESS DENIED", "reason", "insufficient permissions", "user", user.Username, "role", user.Role, "path", r.URL.Path, "ip", ip)
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ logging.SecurityLog.Info("ADMIN ACCESS GRANTED", "user", user.Username, "path", r.URL.Path, "ip", ip)
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go
new file mode 100644
index 0000000..065a0a2
--- /dev/null
+++ b/middleware/ratelimit.go
@@ -0,0 +1,74 @@
+package middleware
+
+import (
+ "net"
+ "net/http"
+ "os"
+ "strconv"
+ "sync"
+ "time"
+)
+
+type RateLimiter struct {
+ requests map[string][]int64
+ mutex sync.Mutex
+ max int
+ window time.Duration
+}
+
+func NewRateLimiter() *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 &RateLimiter{
+ requests: make(map[string][]int64),
+ max: maxRequests,
+ window: time.Duration(windowSeconds) * time.Second,
+ }
+}
+
+func (l *RateLimiter) RateLimitMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ l.mutex.Lock()
+ defer l.mutex.Unlock()
+
+ ip, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+ return
+ }
+
+ now := time.Now().Unix()
+ if _, ok := l.requests[ip]; !ok {
+ l.requests[ip] = []int64{}
+ }
+
+ // Filter out old timestamps
+ var recentRequests []int64
+ for _, ts := range l.requests[ip] {
+ if now-ts < int64(l.window.Seconds()) {
+ recentRequests = append(recentRequests, ts)
+ }
+ }
+ l.requests[ip] = recentRequests
+
+ if len(l.requests[ip]) >= l.max {
+ http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
+ return
+ }
+
+ // Only add a timestamp for POST requests (actual registration attempts)
+ if r.Method == http.MethodPost {
+ l.requests[ip] = append(l.requests[ip], now)
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/models/token.go b/models/token.go
new file mode 100644
index 0000000..831491a
--- /dev/null
+++ b/models/token.go
@@ -0,0 +1,90 @@
+package models
+
+import (
+ "auth-proxy/database"
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "time"
+)
+
+// AuthToken represents a token in the database
+type AuthToken struct {
+ ID int64
+ UserID int64
+ Token string
+ ExpiresAt time.Time
+}
+
+// GenerateSecureToken creates a random, secure token.
+func GenerateSecureToken(length int) (string, error) {
+ bytes := make([]byte, length)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
+
+// CreateAuthToken creates a new token for a user.
+func CreateAuthToken(userID int64, duration time.Duration) (*AuthToken, error) {
+ tokenString, err := GenerateSecureToken(32)
+ if err != nil {
+ return nil, err
+ }
+
+ expiresAt := time.Now().Add(duration)
+
+ query := database.Rebind("INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)")
+ stmt, err := database.DB.Prepare(query)
+ if err != nil {
+ return nil, err
+ }
+ defer stmt.Close()
+
+ _, err = stmt.Exec(userID, tokenString, expiresAt)
+ if err != nil {
+ return nil, err
+ }
+
+ return &AuthToken{
+ UserID: userID,
+ Token: tokenString,
+ ExpiresAt: expiresAt,
+ }, nil
+}
+
+// GetUserByToken retrieves a user associated with a given token.
+// It returns the user if the token is valid and not expired.
+func GetUserByToken(tokenString string) (*User, error) {
+ query := database.Rebind("SELECT u.id, u.username, u.password_hash, u.role, u.is_active, u.failed_logins, u.last_login_at, u.created_at, u.updated_at FROM users u JOIN auth_tokens t ON u.id = t.user_id WHERE t.token = ? AND t.expires_at > ?")
+
+ row := database.DB.QueryRow(query, tokenString, time.Now())
+
+ user := &User{}
+ err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.IsActive, &user.FailedLogins, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, nil // Token not found or expired, not a server error
+ }
+ return nil, err
+ }
+
+ return user, nil
+}
+
+// DeleteAuthToken deletes a token from the database.
+func DeleteAuthToken(tokenString string) error {
+ query := database.Rebind("DELETE FROM auth_tokens WHERE token = ?")
+ _, err := database.DB.Exec(query, tokenString)
+ return err
+}
+
+// DeleteExpiredTokens cleans up expired tokens from the database.
+func DeleteExpiredTokens() (int64, error) {
+ query := database.Rebind("DELETE FROM auth_tokens WHERE expires_at < ?")
+ res, err := database.DB.Exec(query, time.Now())
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+} \ No newline at end of file
diff --git a/models/user.go b/models/user.go
new file mode 100644
index 0000000..c5ecdd7
--- /dev/null
+++ b/models/user.go
@@ -0,0 +1,147 @@
+package models
+
+import (
+ "auth-proxy/database"
+ "database/sql"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+// User represents a user in the database
+type User struct {
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+ PasswordHash string `json:"-"`
+ Role string `json:"role"`
+ IsActive bool `json:"is_active"`
+ FailedLogins int `json:"failed_logins"`
+ LastLoginAt sql.NullTime `json:"last_login_at"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// HashPassword generates a bcrypt hash of the password
+func HashPassword(password string) (string, error) {
+ bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
+ return string(bytes), err
+}
+
+// CheckPasswordHash compares a password with a hash
+func CheckPasswordHash(password, hash string) bool {
+ err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+ return err == nil
+}
+
+// CreateUser creates a new user in the database
+func CreateUser(username, password, role string) (*User, error) {
+ passwordHash, err := HashPassword(password)
+ if err != nil {
+ return nil, err
+ }
+
+ query := database.Rebind("INSERT INTO users(username, password_hash, role) VALUES(?, ?, ?)")
+ stmt, err := database.DB.Prepare(query)
+ if err != nil {
+ return nil, err
+ }
+ defer stmt.Close()
+
+ _, err = stmt.Exec(username, passwordHash, role)
+ if err != nil {
+ return nil, err
+ }
+
+ return GetUserByUsername(username) // Return the full user object
+}
+
+// baseSelectQuery is the common set of columns for user queries
+const baseSelectQuery = "SELECT id, username, password_hash, role, is_active, failed_logins, last_login_at, created_at, updated_at FROM users"
+
+// scanUser scans a row into a User struct
+func scanUser(row *sql.Row) (*User, error) {
+ user := &User{}
+ err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.IsActive, &user.FailedLogins, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return nil, nil // User not found
+ }
+ return nil, err
+ }
+ return user, nil
+}
+
+// GetUserByUsername retrieves a user by their username
+func GetUserByUsername(username string) (*User, error) {
+ query := database.Rebind(baseSelectQuery + " WHERE username = ?")
+ row := database.DB.QueryRow(query, username)
+ return scanUser(row)
+}
+
+// GetUserByID retrieves a user by their ID
+func GetUserByID(id int64) (*User, error) {
+ query := database.Rebind(baseSelectQuery + " WHERE id = ?")
+ row := database.DB.QueryRow(query, id)
+ return scanUser(row)
+}
+
+// GetAllUsers retrieves all users from the database
+func GetAllUsers() ([]User, error) {
+ rows, err := database.DB.Query(baseSelectQuery)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var users []User
+ for rows.Next() {
+ var user User
+ if err := rows.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.IsActive, &user.FailedLogins, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt); err != nil {
+ return nil, err
+ }
+ users = append(users, user)
+ }
+ return users, nil
+}
+
+// UpdateUserRole updates a user's role
+func UpdateUserRole(id int64, role string) error {
+ query := database.Rebind("UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
+ _, err := database.DB.Exec(query, role, id)
+ return err
+}
+
+// DeleteUser deletes a user from the database
+func DeleteUser(id int64) error {
+ query := database.Rebind("DELETE FROM users WHERE id = ?")
+ _, err := database.DB.Exec(query, id)
+ return err
+}
+
+// UpdateUserPassword updates a user's password hash
+func UpdateUserPassword(id int64, passwordHash string) error {
+ query := database.Rebind("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
+ _, err := database.DB.Exec(query, passwordHash, id)
+ return err
+}
+
+// RecordLoginSuccess resets failed login attempts and updates last login time
+func RecordLoginSuccess(id int64) error {
+ query := database.Rebind("UPDATE users SET failed_logins = 0, last_login_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
+ _, err := database.DB.Exec(query, id)
+ return err
+}
+
+// RecordLoginFailure increments the failed login counter
+func RecordLoginFailure(id int64) error {
+ query := database.Rebind("UPDATE users SET failed_logins = failed_logins + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
+ _, err := database.DB.Exec(query, id)
+ return err
+}
+
+// SetUserActiveStatus updates the is_active flag for a user
+func SetUserActiveStatus(id int64, isActive bool) error {
+ query := database.Rebind("UPDATE users SET is_active = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
+ _, err := database.DB.Exec(query, isActive, id)
+ return err
+} \ No newline at end of file
diff --git a/templates/account.html b/templates/account.html
new file mode 100644
index 0000000..0a1a353
--- /dev/null
+++ b/templates/account.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>My Account</title>
+ <link rel="stylesheet" href="{{.Paths.Assets}}/css/style.css">
+ <link rel="stylesheet" href="{{.Paths.Assets}}/css/account.css">
+</head>
+<body>
+ <div class="container content-container">
+ <h2>My Account</h2>
+ <p>Welcome, {{.User.Username}}!</p>
+ <ul>
+ <li><a href="{{.Paths.AccountPassword}}">Change Password</a></li>
+ </ul>
+ <div class="logout-link">
+ <a href="{{.Paths.Logout}}">Logout</a>
+ </div>
+ </div>
+ <script src="{{.Paths.Assets}}/js/auth.js"></script>
+</body>
+</html>
diff --git a/templates/account_password.html b/templates/account_password.html
new file mode 100644
index 0000000..ba40761
--- /dev/null
+++ b/templates/account_password.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Change Password</title>
+ <link rel="stylesheet" href="{{.Paths.Assets}}/css/style.css">
+ <link rel="stylesheet" href="{{.Paths.Assets}}/css/account.css">
+</head>
+<body>
+ <div class="container content-container">
+ <h2>Change Password</h2>
+ <form id="change-password-form" action="{{.Paths.AccountPassword}}" method="post">
+ <div class="form-group">
+ <label for="current_password">Current Password</label>
+ <input type="password" id="current_password" name="current_password" required>
+ </div>
+ <div class="form-group">
+ <label for="new_password">New Password</label>
+ <input type="password" id="new_password" name="new_password" required>
+ </div>
+ <div class="form-group">
+ <label for="confirm_new_password">Confirm New Password</label>
+ <input type="password" id="confirm_new_password" name="confirm_new_password" required>
+ </div>
+ <button type="submit">Update Password</button>
+ </form>
+ <p id="message-placeholder" class="message"></p>
+ <div class="back-link">
+ <a href="{{.Paths.Account}}">Back to Account</a>
+ </div>
+ </div>
+ <script src="{{.Paths.Assets}}/js/auth.js"></script>
+</body>
+</html>
diff --git a/templates/admin.html b/templates/admin.html
new file mode 100644
index 0000000..ac4ba94
--- /dev/null
+++ b/templates/admin.html
@@ -0,0 +1,60 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Admin Dashboard</title>
+ <link rel="stylesheet" href="{{.Paths.Assets}}/css/style.css">
+ <link rel="stylesheet" href="{{.Paths.Assets}}/css/admin.css">
+</head>
+<body class="admin-page">
+ <div class="container admin-container">
+ <h1>User Management</h1>
+ <a href="{{.Paths.Account}}">My Account</a> | <a href="{{.Paths.Logout}}">Logout</a>
+ <div class="table-container">
+ <table>
+ <thead>
+ <tr>
+ <th>ID</th>
+ <th>Username</th>
+ <th>Role</th>
+ <th>Is Active</th>
+ <th>Failed Logins</th>
+ <th>Last Login</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody id="user-list">
+ {{range .Users}}
+ <tr>
+ <td>{{.ID}}</td>
+ <td>{{.Username}}</td>
+ <td>
+ <select id="role-{{.ID}}">
+ <option value="user" {{if eq .Role "user"}}selected{{end}}>user</option>
+ <option value="admin" {{if eq .Role "admin"}}selected{{end}}>admin</option>
+ </select>
+ </td>
+ <td>{{if .IsActive}}Yes{{else}}No{{end}}</td>
+ <td>{{.FailedLogins}}</td>
+ <td>{{if .LastLoginAt.Valid}}{{.LastLoginAt.Time.Format "2006-01-02 15:04"}}{{else}}Never{{end}}</td>
+ <td>
+ <button onclick="updateRole({{.ID}})">Save Role</button>
+ <button onclick="toggleActive({{.ID}}, {{not .IsActive}})">{{if .IsActive}}Deactivate{{else}}Activate{{end}}</button>
+ <button class="delete" onclick="deleteUser({{.ID}})">Delete</button>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ <script>
+ const API_BASE_PATH = "{{.Paths.AdminUsersAPI}}";
+ const LOGIN_PATH = "{{.Paths.Login}}";
+ </script>
+ <script src="{{.Paths.Assets}}/js/auth.js"></script>
+ <script src="{{.Paths.Assets}}/js/admin.js"></script>
+</body>
+</html>
diff --git a/templates/css/account.css b/templates/css/account.css
new file mode 100644
index 0000000..f9617ec
--- /dev/null
+++ b/templates/css/account.css
@@ -0,0 +1,5 @@
+/* Styles for account pages */
+.account-container {
+ width: 100%;
+ max-width: 450px;
+}
diff --git a/templates/css/admin.css b/templates/css/admin.css
new file mode 100644
index 0000000..446b083
--- /dev/null
+++ b/templates/css/admin.css
@@ -0,0 +1,57 @@
+/* Admin page specific container */
+.admin-container {
+ max-width: 95%;
+ width: 1200px;
+ margin: 2rem auto;
+}
+
+/* Responsive wrapper for the table */
+.table-container {
+ overflow-x: auto;
+ width: 100%;
+ margin-top: 1.5rem;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+}
+
+/* Admin Table */
+table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 800px; /* Prevent table from becoming too cramped */
+}
+
+th, td {
+ padding: 0.75rem 1rem;
+ text-align: left;
+ border-bottom: 1px solid #ddd;
+ white-space: nowrap; /* Prevent content from wrapping */
+}
+
+tr:last-child td {
+ border-bottom: none;
+}
+
+th {
+ background-color: #f7f7f7;
+ font-weight: 600;
+}
+
+td button {
+ margin-right: 0.5rem;
+ padding: 0.25rem 0.5rem;
+ width: auto; /* Override common button width */
+ font-size: 0.875rem;
+}
+
+td select {
+ padding: 0.25rem 0.5rem;
+}
+
+button.delete {
+ background-color: #dc3545;
+ border-color: #dc3545;
+}
+button.delete:hover {
+ background-color: #c82333;
+} \ No newline at end of file
diff --git a/templates/css/style.css b/templates/css/style.css
new file mode 100644
index 0000000..dfe1139
--- /dev/null
+++ b/templates/css/style.css
@@ -0,0 +1,128 @@
+/* General Body Styles */
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ background-color: #f0f2f5;
+ margin: 0;
+ padding: 1rem;
+ box-sizing: border-box;
+}
+
+/* General Container for forms and content */
+.container {
+ background-color: #fff;
+ padding: 2rem;
+ border-radius: 8px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ width: 100%;
+ box-sizing: border-box;
+}
+
+/* Centered page content (login, register) */
+.form-container {
+ max-width: 420px;
+}
+
+/* Wider content (account page) */
+.content-container {
+ max-width: 500px;
+}
+
+
+/* Headings */
+h1, h2 {
+ text-align: center;
+ margin-top: 0;
+ margin-bottom: 1.5rem;
+ color: #333;
+}
+
+/* Forms */
+.form-group {
+ margin-bottom: 1rem;
+}
+
+label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+ color: #555;
+}
+
+input[type="text"],
+input[type="password"],
+input[type="email"] {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ box-sizing: border-box;
+ font-size: 1rem;
+}
+
+/* Buttons */
+button {
+ width: 100%;
+ padding: 0.75rem;
+ border: none;
+ border-radius: 4px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ color: #fff;
+ background-color: #007bff;
+ transition: background-color 0.2s;
+}
+
+button:hover {
+ background-color: #0056b3;
+}
+
+button.btn-success {
+ background-color: #28a745;
+}
+button.btn-success:hover {
+ background-color: #218838;
+}
+
+/* Links */
+.form-link {
+ text-align: center;
+ margin-top: 1rem;
+}
+
+a {
+ text-decoration: none;
+ color: #007bff;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+/* Messages */
+.error, .message.error {
+ color: #dc3545;
+ text-align: center;
+ margin-top: 1rem;
+ min-height: 1em;
+}
+
+.message.success {
+ color: #28a745;
+ text-align: center;
+ margin-top: 1rem;
+}
+
+/* Lists */
+ul {
+ list-style: none;
+ padding: 0;
+}
+
+li {
+ margin-bottom: 1rem;
+} \ No newline at end of file
diff --git a/templates/js/admin.js b/templates/js/admin.js
new file mode 100644
index 0000000..4dff572
--- /dev/null
+++ b/templates/js/admin.js
@@ -0,0 +1,63 @@
+/**
+ * A wrapper for the fetch API that standardizes API calls.
+ * It automatically sets the Content-Type for POST requests
+ * and handles non-OK responses.
+ * @param {string} url The URL to fetch.
+ * @param {object} options The options for the fetch call.
+ * @returns {Promise<Response>}
+ */
+async function fetchApi(url, options = {}) {
+ options.headers = {
+ ...options.headers,
+ };
+
+ // Set content type for methods that have a body.
+ if (options.method === 'POST' || options.method === 'PUT') {
+ options.headers['Content-Type'] = 'application/json';
+ }
+
+ const response = await fetch(url, options);
+
+ // Redirect to login if unauthorized.
+ if (response.status === 401) {
+ window.location.href = LOGIN_PATH;
+ // Throw an error to stop further processing
+ throw new Error('Unauthorized');
+ }
+
+ return response;
+}
+
+function updateRole(userId) {
+ const role = document.getElementById(`role-${userId}`).value;
+ fetchApi(`${API_BASE_PATH}/${userId}/role`, {
+ method: 'POST',
+ body: JSON.stringify({ role })
+ }).then(handleResponse);
+}
+
+function deleteUser(userId) {
+ if (confirm('Are you sure you want to permanently delete this user?')) {
+ fetchApi(`${API_BASE_PATH}/${userId}`, {
+ method: 'DELETE'
+ }).then(handleResponse);
+ }
+}
+
+function toggleActive(userId, isActive) {
+ const action = isActive ? 'activate' : 'deactivate';
+ if (confirm(`Are you sure you want to ${action} this user?`)) {
+ fetchApi(`${API_BASE_PATH}/${userId}/status`, {
+ method: 'POST',
+ body: JSON.stringify({ is_active: isActive })
+ }).then(handleResponse);
+ }
+}
+
+function handleResponse(response) {
+ if (response.ok) {
+ location.reload();
+ } else {
+ alert('Operation failed.');
+ }
+}
diff --git a/templates/js/auth.js b/templates/js/auth.js
new file mode 100644
index 0000000..3ca7c4e
--- /dev/null
+++ b/templates/js/auth.js
@@ -0,0 +1,48 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const changePasswordForm = document.getElementById('change-password-form');
+ if (changePasswordForm) {
+ changePasswordForm.addEventListener('submit', handleChangePassword);
+ }
+});
+
+async function handleChangePassword(event) {
+ event.preventDefault();
+ const form = event.target;
+ const formData = new FormData(form);
+ const messagePlaceholder = document.getElementById('message-placeholder');
+ messagePlaceholder.textContent = '';
+
+ const newPassword = formData.get('new_password');
+ const confirmNewPassword = formData.get('confirm_new_password');
+
+ if (newPassword !== confirmNewPassword) {
+ messagePlaceholder.className = 'error';
+ messagePlaceholder.textContent = 'New passwords do not match.';
+ return;
+ }
+
+ try {
+ const response = await fetch(form.action, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: new URLSearchParams(formData)
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ messagePlaceholder.className = 'error';
+ messagePlaceholder.textContent = data.error || `Error: ${response.statusText}`;
+ } else {
+ messagePlaceholder.className = 'success';
+ messagePlaceholder.textContent = data.message || 'Password updated successfully.';
+ }
+
+ } catch (error) {
+ console.error('Password change failed:', error);
+ messagePlaceholder.className = 'error';
+ messagePlaceholder.textContent = 'An unexpected error occurred.';
+ }
+} \ No newline at end of file
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..8875cb1
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Login</title>
+ <link rel="stylesheet" href="{{.Paths.Assets}}/css/style.css">
+</head>
+<body>
+ <div class="container form-container">
+ <h2>Login Required</h2>
+ <form id="login-form" action="{{.Paths.Login}}" method="post">
+ <div class="form-group">
+ <label for="username">Username</label>
+ <input type="text" id="username" name="username" required>
+ </div>
+ <div class="form-group">
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" required>
+ </div>
+ <button type="submit">Login</button>
+ </form>
+ <p class="error">
+ {{if .Error}}
+ {{.Error}}
+ {{end}}
+ </p>
+ <div style="text-align: center; margin-top: 1rem;">
+ <a href="{{.Paths.Register}}">Don't have an account? Register</a>
+ </div>
+ </div>
+ <script src="{{.Paths.Assets}}/js/auth.js"></script>
+</body>
+</html>
diff --git a/templates/register.html b/templates/register.html
new file mode 100644
index 0000000..d98ef4d
--- /dev/null
+++ b/templates/register.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Register</title>
+ <link rel="stylesheet" href="{{.Paths.Assets}}/css/style.css">
+</head>
+<body>
+ <div class="container form-container">
+ <h2>Create Account</h2>
+ <form id="register-form" action="{{.Paths.Register}}" method="post">
+ <div class="form-group">
+ <label for="username">Username</label>
+ <input type="text" id="username" name="username" required>
+ </div>
+ <div class="form-group">
+ <label for="password">Password</label>
+ <input type="password" id="password" name="password" required>
+ </div>
+ <div class="form-group">
+ <label for="confirm_password">Confirm Password</label>
+ <input type="password" id="confirm_password" name="confirm_password" required>
+ </div>
+ <button type="submit" class="btn-success">Register</button>
+ </form>
+ <p class="error">
+ {{if .Error}}
+ {{.Error}}
+ {{end}}
+ </p>
+ <div class="form-link">
+ <a href="{{.Paths.Login}}">Already have an account? Login</a>
+ </div>
+ </div>
+ <script src="{{.Paths.Assets}}/js/auth.js"></script>
+</body>
+</html>
diff --git a/types/types.go b/types/types.go
new file mode 100644
index 0000000..3d8ae69
--- /dev/null
+++ b/types/types.go
@@ -0,0 +1,19 @@
+package types
+
+// PathConfig holds the configurable paths for the application routes and assets.
+type PathConfig struct {
+ Login string
+ Register string
+ Logout string
+ Account string
+ AccountPassword string
+ Admin string
+ AdminUsersAPI string
+ Assets string
+}
+
+// ContextKey is a custom type for context keys to avoid collisions.
+type ContextKey string
+
+// SessionDataContextKey is the key for storing session data in the request context.
+const SessionDataContextKey = ContextKey("sessionData")
diff --git a/utils/validation.go b/utils/validation.go
new file mode 100644
index 0000000..e4ef884
--- /dev/null
+++ b/utils/validation.go
@@ -0,0 +1,103 @@
+package utils
+
+import (
+ "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
+ }
+
+ switch policy {
+ case "none":
+ return true, ""
+ case "standard":
+ return validateStandard(password)
+ case "enhanced":
+ return validateEnhanced(password)
+ case "strict":
+ return validateStrict(password)
+ default:
+ // If an unknown policy is set, default to a safe option (standard).
+ fmt.Printf("Warning: Unknown PASSWORD_POLICY '%s', falling back to 'standard'\n", policy)
+ return validateStandard(password)
+ }
+}
+
+// validateStandard checks for a minimum length of 6 characters.
+func validateStandard(password string) (bool, string) {
+ if len(password) < 6 {
+ return false, "Password must be at least 6 characters long."
+ }
+ return true, ""
+}
+
+// validateEnhanced checks for a minimum length of 8 characters and complexity.
+func validateEnhanced(password string) (bool, string) {
+ if len(password) < 8 {
+ return false, "Password must be at least 8 characters long."
+ }
+
+ var (
+ hasUpper bool
+ hasLower bool
+ hasNumber bool
+ hasSymbol bool
+ )
+
+ for _, char := range password {
+ switch {
+ case unicode.IsUpper(char):
+ hasUpper = true
+ case unicode.IsLower(char):
+ hasLower = true
+ case unicode.IsNumber(char):
+ hasNumber = true
+ case unicode.IsPunct(char) || unicode.IsSymbol(char):
+ hasSymbol = true
+ }
+ }
+
+ if !hasUpper || !hasLower || !hasNumber || !hasSymbol {
+ return false, "Password must contain at least one uppercase letter, one lowercase letter, one number, and one symbol."
+ }
+ return true, ""
+}
+
+// validateStrict checks for a minimum length of 12 characters and complexity.
+func validateStrict(password string) (bool, string) {
+ if len(password) < 12 {
+ return false, "Password must be at least 12 characters long."
+ }
+
+ // Using the same complexity requirements as 'enhanced' but with a longer length.
+ var (
+ hasUpper bool
+ hasLower bool
+ hasNumber bool
+ hasSymbol bool
+ )
+
+ for _, char := range password {
+ switch {
+ case unicode.IsUpper(char):
+ hasUpper = true
+ case unicode.IsLower(char):
+ hasLower = true
+ case unicode.IsNumber(char):
+ hasNumber = true
+ case unicode.IsPunct(char) || unicode.IsSymbol(char):
+ hasSymbol = true
+ }
+ }
+
+ if !hasUpper || !hasLower || !hasNumber || !hasSymbol {
+ return false, "Password must be at least one uppercase letter, one lowercase letter, one number, and one symbol."
+ }
+ return true, ""
+}