diff options
| -rw-r--r-- | .github/workflows/ci.yml | 60 | ||||
| -rw-r--r-- | .github/workflows/release.yml | 36 | ||||
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | README.md | 200 | ||||
| -rw-r--r-- | cmd/server/main.go | 109 | ||||
| -rw-r--r-- | config/config.go | 31 | ||||
| -rw-r--r-- | database/database.go | 231 | ||||
| -rw-r--r-- | go.mod | 13 | ||||
| -rw-r--r-- | go.sum | 14 | ||||
| -rw-r--r-- | handlers/admin.go | 155 | ||||
| -rw-r--r-- | handlers/auth.go | 221 | ||||
| -rw-r--r-- | handlers/proxy.go | 38 | ||||
| -rw-r--r-- | handlers/user.go | 100 | ||||
| -rw-r--r-- | logging/logging.go | 53 | ||||
| -rw-r--r-- | middleware/auth.go | 93 | ||||
| -rw-r--r-- | middleware/ratelimit.go | 74 | ||||
| -rw-r--r-- | models/token.go | 90 | ||||
| -rw-r--r-- | models/user.go | 147 | ||||
| -rw-r--r-- | templates/account.html | 23 | ||||
| -rw-r--r-- | templates/account_password.html | 35 | ||||
| -rw-r--r-- | templates/admin.html | 60 | ||||
| -rw-r--r-- | templates/css/account.css | 5 | ||||
| -rw-r--r-- | templates/css/admin.css | 57 | ||||
| -rw-r--r-- | templates/css/style.css | 128 | ||||
| -rw-r--r-- | templates/js/admin.js | 63 | ||||
| -rw-r--r-- | templates/js/auth.js | 48 | ||||
| -rw-r--r-- | templates/login.html | 34 | ||||
| -rw-r--r-- | templates/register.html | 38 | ||||
| -rw-r--r-- | types/types.go | 19 | ||||
| -rw-r--r-- | utils/validation.go | 103 |
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 @@ -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 +) @@ -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, "" +} |
