diff options
| -rw-r--r-- | .dockerignore | 8 | ||||
| -rw-r--r-- | .env.example | 25 | ||||
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | Dockerfile | 22 | ||||
| -rw-r--r-- | README.md | 52 | ||||
| -rw-r--r-- | cmd/admin-cli/main.go | 2 | ||||
| -rw-r--r-- | database/database.go | 420 | ||||
| -rw-r--r-- | docker-compose.yml | 65 | ||||
| -rw-r--r-- | docker/demo-backend/index.html | 86 | ||||
| -rw-r--r-- | docker/pocketbase/Dockerfile | 16 | ||||
| -rw-r--r-- | handlers/admin.go | 19 | ||||
| -rw-r--r-- | handlers/auth_login.go | 11 | ||||
| -rw-r--r-- | handlers/auth_token.go | 8 | ||||
| -rw-r--r-- | handlers/php_proxy.go | 2 | ||||
| -rw-r--r-- | handlers/user.go | 22 | ||||
| -rw-r--r-- | middleware/auth.go | 15 | ||||
| -rw-r--r-- | middleware/xsrf.go | 2 | ||||
| -rw-r--r-- | models/token.go | 145 | ||||
| -rw-r--r-- | models/user.go | 306 | ||||
| -rw-r--r-- | pb_migrations/1730000000_bootstrap.js | 86 | ||||
| -rw-r--r-- | templates/admin.html | 8 | ||||
| -rw-r--r-- | types/types.go | 63 | ||||
| -rw-r--r-- | utils/jwt.go | 41 |
23 files changed, 875 insertions, 557 deletions
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d4dfcb4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.codex +auth.db +admin-cli +auth-proxy-server +pb_data +tmp +node_modules diff --git a/.env.example b/.env.example index d53c39e..743541b 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,12 @@ # --- Server Settings --- # The port for the proxy server to listen on LISTEN_PORT=8080 +AUTH_PROXY_PUBLISH_PORT=8080 +POCKETBASE_PUBLISH_PORT=8090 # --- Required --- # The URL of the backend service you want to protect -TARGET_URL=http://localhost:8081 +TARGET_URL=http://backend:80 # A long, random string for securing session cookies and JWTs # For production, generate a new key using: openssl rand -base64 45 @@ -14,24 +16,21 @@ SESSION_SECRET= # Generate with: openssl rand -base64 32 XSRF_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= - -# Option 2: SQLite (Default if DATABASE_URL is not set) -DATABASE_PATH=./auth.db +# --- PocketBase --- +POCKETBASE_URL=http://pocketbase:8090 +POCKETBASE_COLLECTION=proxy_users +POCKETBASE_IDENTITY_FIELD=username +POCKETBASE_SUPERUSER_EMAIL=admin@example.com +POCKETBASE_SUPERUSER_PASSWORD=change-me-now +POCKETBASE_EMAIL_DOMAIN=pb-auth.local # --- Frontend & Proxy --- # Frontend mode: "js" (default) or "php" FRONTEND_TYPE=js # If true, protects the frontend path with session authentication -PROTECT_FRONTEND=false +PROTECT_FRONTEND=true # If true, protects the API path with bearer token authentication -PROTECT_API=false +PROTECT_API=true # Base path for frontend routes to be protected FRONT_PATH=/ # Base path for API routes to be protected @@ -7,3 +7,11 @@ auth.db # .env file .env + +# Local PocketBase / Docker data +pb_data/ +tmp/ + +# Local tooling +.codex/ +.codex diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a5da5d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/auth-proxy-server ./cmd/server + +FROM alpine:3.22 + +RUN apk add --no-cache ca-certificates + +WORKDIR /app + +COPY --from=builder /out/auth-proxy-server /app/auth-proxy-server +COPY templates /app/templates + +EXPOSE 8080 + +CMD ["/app/auth-proxy-server"] @@ -53,9 +53,23 @@ ```bash git clone https://github.com/haturatu/auth-proxy.git cd auth-proxy - chmod +x run-demo.sh - bash run-demo.sh + cp .env.example .env + docker compose up --build ``` + + Local demo endpoints: + + - Proxy login: `http://localhost:8080/login` + - PocketBase admin API: `http://localhost:8090/_/` + - Seeded proxy admin login: `admin` / `password123` + - `docker compose` reads variables from the repo-root `.env` + + PocketBase collection requirements: + + - Use an auth collection. + - Enable password authentication. + - Ensure the auth collection has a unique `username` identity field if you keep the default `POCKETBASE_IDENTITY_FIELD=username`. + - Add custom fields `role` (text), `is_active` (bool), `failed_logins` (number), and `last_login_at` (date). ## Why I Built This @@ -68,7 +82,7 @@ - **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. + - **PocketBase Backend**: User records, admin operations, and credential checks are delegated to a PocketBase auth collection. - **Pluggable Frontend**: Supports multiple frontend modes (JS-driven or PHP). - **Security Hardening**: - **Brute-force Protection**: Locks user accounts after a configurable number of failed login attempts. This protection applies to both the web UI login and the `/api/auth/token` endpoint. @@ -107,8 +121,8 @@ This flow is designed for users interacting with the application through a web browser. 1. A user submits their username and password via the login page. - 2. The server validates the credentials against the database. - 3. On success, a cryptographically secure, random token is generated and stored in the database, associated with the user. + 2. The server validates the credentials against PocketBase. + 3. On success, the proxy issues its own signed session JWT. 4. This token is sent to the user's browser in a secure, `HttpOnly` cookie named `auth_token`, establishing a session. #### 2. API (JWT-Based) @@ -119,13 +133,13 @@ 2. The server validates the credentials. 3. On success, it generates and returns two tokens: * A short-lived **JWT Access Token** containing user claims (ID, role) and an expiration time. This token is signed to prevent tampering. - * A long-lived **Refresh Token** that is stored in the database and can be used to obtain a new access token. + * A long-lived **Refresh Token** issued by the proxy and used to mint a new access token. ### Session Management #### Web UI Sessions - For subsequent requests from a browser, the `auth_token` cookie is automatically sent to the server. A middleware validates this token by looking it up in the database. If the token is valid and has not expired, the request is authenticated and allowed to proceed. When a user logs out, the session token is deleted from the database, effectively invalidating the session. + For subsequent requests from a browser, the `auth_token` cookie is automatically sent to the server. A middleware validates the JWT signature and expiration, then loads the current user from PocketBase before allowing the request. #### API Sessions (Stateless with JWT) @@ -144,7 +158,7 @@ - Go 1.21 or later - (Optional) A running PHP-FPM service if you intend to use the PHP frontend. - - (Optional) PostgreSQL or MySQL database server. + - A running PocketBase instance with an auth collection for proxy users. ### Installation @@ -187,16 +201,18 @@ # Generate with: openssl rand -base64 32 XSRF_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 + # --- PocketBase --- + # PocketBase base URL + POCKETBASE_URL=http://127.0.0.1:8090 + # Auth collection used for proxy users + POCKETBASE_COLLECTION=proxy_users + # Username is the expected login field by default + POCKETBASE_IDENTITY_FIELD=username + # Superuser credentials used by the proxy for admin/user management + POCKETBASE_SUPERUSER_EMAIL=admin@example.com + POCKETBASE_SUPERUSER_PASSWORD=change-me + # Optional domain used to synthesize internal email addresses when usernames are not emails + POCKETBASE_EMAIL_DOMAIN=pb-auth.local # --- Frontend & Proxy --- # Frontend mode: "js" (default) or "php" diff --git a/cmd/admin-cli/main.go b/cmd/admin-cli/main.go index d7d4114..d480a4f 100644 --- a/cmd/admin-cli/main.go +++ b/cmd/admin-cli/main.go @@ -61,4 +61,4 @@ func main() { } fmt.Printf("Admin user '%s' created successfully.\n", *username) -}
\ No newline at end of file +} diff --git a/database/database.go b/database/database.go index 0e7a4c9..3b7e7c3 100644 --- a/database/database.go +++ b/database/database.go @@ -2,270 +2,256 @@ package database import ( "auth-proxy/logging" - "database/sql" + "bytes" + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" "fmt" + "io" + "net/http" "net/url" "os" "strings" - - _ "github.com/go-sql-driver/mysql" - _ "github.com/lib/pq" - _ "github.com/mattn/go-sqlite3" + "sync" + "time" ) -var ( - DB *sql.DB - dbType string -) +type Client struct { + baseURL string + collection string + identityField string + emailDomain string + superEmail string + superPassword string + httpClient *http.Client + + mu sync.Mutex + superToken string +} + +type apiListResponse[T any] struct { + Items []T `json:"items"` +} + +type userAuthResponse[T any] struct { + Token string `json:"token"` + Record T `json:"record"` +} + +var PB *Client -// 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() + baseURL := strings.TrimRight(os.Getenv("POCKETBASE_URL"), "/") + if baseURL == "" { + logging.AppLog.Error("POCKETBASE_URL environment variable not set") + os.Exit(1) } - 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) + superEmail := os.Getenv("POCKETBASE_SUPERUSER_EMAIL") + superPassword := os.Getenv("POCKETBASE_SUPERUSER_PASSWORD") + if superEmail == "" || superPassword == "" { + logging.AppLog.Error("PocketBase superuser credentials are required", "missing_email", superEmail == "", "missing_password", superPassword == "") os.Exit(1) } - driver := u.Scheme - logging.AppLog.Info("Attempting to connect to database", "driver", driver) + collection := os.Getenv("POCKETBASE_COLLECTION") + if collection == "" { + collection = "proxy_users" + } - switch driver { - case "postgres", "postgresql": - dbType = "postgres" - initPostgreSQL(databaseURL) - case "mysql": - dbType = "mysql" + identityField := os.Getenv("POCKETBASE_IDENTITY_FIELD") + if identityField == "" { + identityField = "username" + } - // 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) + emailDomain := os.Getenv("POCKETBASE_EMAIL_DOMAIN") + if emailDomain == "" { + emailDomain = "pb-auth.local" + } + + PB = &Client{ + baseURL: baseURL, + collection: collection, + identityField: identityField, + emailDomain: emailDomain, + superEmail: superEmail, + superPassword: superPassword, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, } + + logging.AppLog.Info("PocketBase client initialized", "url", baseURL, "collection", collection, "identity_field", identityField) } -// initSQLite initializes a SQLite database connection. -func initSQLite() { - databasePath := os.Getenv("DATABASE_PATH") - if databasePath == "" { - databasePath = "./auth.db" +func CloseDB() { + if PB != nil { + logging.AppLog.Info("PocketBase client closed") } +} - 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) +func (c *Client) Collection() string { + return c.collection +} + +func (c *Client) IdentityField() string { + return c.identityField +} + +func (c *Client) SyntheticEmail(username string) string { + if strings.Contains(username, "@") { + return username } - if err = DB.Ping(); err != nil { - logging.AppLog.Error("Failed to connect to sqlite database", "error", err) - os.Exit(1) + + sum := sha1.Sum([]byte(username)) + return fmt.Sprintf("%s@%s", hex.EncodeToString(sum[:8]), c.emailDomain) +} + +func (c *Client) SuperuserAuth(ctx context.Context) (string, error) { + c.mu.Lock() + token := c.superToken + c.mu.Unlock() + + if token != "" { + return token, nil } - logging.AppLog.Info("Database connection established", "type", "sqlite", "path", databasePath) + + return c.refreshSuperuserToken(ctx) } -// initPostgreSQL initializes a PostgreSQL database connection. -func initPostgreSQL(dataSourceName string) { - var err error - DB, err = sql.Open("postgres", dataSourceName) +func (c *Client) refreshSuperuserToken(ctx context.Context) (string, error) { + var resp userAuthResponse[map[string]any] + err := c.doJSON(ctx, http.MethodPost, "/api/collections/_superusers/auth-with-password", "", map[string]string{ + "identity": c.superEmail, + "password": c.superPassword, + }, nil, &resp) 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) + return "", err } - logging.AppLog.Info("Database connection established", "type", "postgres") + + c.mu.Lock() + c.superToken = resp.Token + c.mu.Unlock() + + return resp.Token, nil } -// initMySQL initializes a MySQL database connection. -func initMySQL(dataSourceName string) { - var err error - DB, err = sql.Open("mysql", dataSourceName) +func (c *Client) DoWithSuperuser(ctx context.Context, method, path string, query url.Values, body any, out any) error { + token, err := c.SuperuserAuth(ctx) if err != nil { - logging.AppLog.Error("Failed to open mysql database", "error", err) - os.Exit(1) + return err } - if err = DB.Ping(); err != nil { - logging.AppLog.Error("Failed to connect to mysql database", "error", err) - os.Exit(1) + + err = c.doJSON(ctx, method, path, token, body, query, out) + if err == nil { + return nil } - logging.AppLog.Info("Database connection established", "type", "mysql") + + if !IsUnauthorized(err) { + return err + } + + token, refreshErr := c.refreshSuperuserToken(ctx) + if refreshErr != nil { + return refreshErr + } + + return c.doJSON(ctx, method, path, token, body, query, out) } -// 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 - );` +func (c *Client) Do(ctx context.Context, method, path, bearerToken string, body any, out any) error { + return c.doJSON(ctx, method, path, bearerToken, body, nil, out) +} + +type APIError struct { + StatusCode int + Message string + Details map[string]any +} + +func (e *APIError) Error() string { + if e.Message != "" { + return e.Message } + return fmt.Sprintf("pocketbase request failed with status %d", e.StatusCode) +} - if _, err := DB.Exec(createTableSQL); err != nil { - logging.AppLog.Error("Failed to create users table", "error", err, "db_type", dbType) - os.Exit(1) +func IsUnauthorized(err error) bool { + apiErr, ok := err.(*APIError) + return ok && apiErr.StatusCode == http.StatusUnauthorized +} + +func (c *Client) doJSON(ctx context.Context, method, path, bearerToken string, body any, query url.Values, out any) error { + if c == nil { + return fmt.Errorf("pocketbase client is not initialized") + } + + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return err + } + reader = bytes.NewReader(payload) } - 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 UNIQUE, - "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 UNIQUE 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 UNIQUE, - token VARCHAR(255) NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - );` + + reqURL := c.baseURL + path + if len(query) > 0 { + reqURL += "?" + query.Encode() } - if _, err := DB.Exec(createTokenTableSQL); err != nil { - logging.AppLog.Error("Failed to create auth_tokens table", "error", err, "db_type", dbType) - os.Exit(1) + req, err := http.NewRequestWithContext(ctx, method, reqURL, reader) + if err != nil { + return err } - logging.AppLog.Info("Auth_tokens table verified successfully") - - // Now, create the refresh_tokens table - var createRefreshTokenTableSQL string - switch dbType { - case "sqlite3": - createRefreshTokenTableSQL = `CREATE TABLE IF NOT EXISTS refresh_tokens ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "user_id" INTEGER NOT NULL UNIQUE, - "token" TEXT NOT NULL UNIQUE, - "expires_at" DATETIME NOT NULL, - "is_revoked" BOOLEAN NOT NULL DEFAULT FALSE, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - );` - case "postgres": - createRefreshTokenTableSQL = `CREATE TABLE IF NOT EXISTS refresh_tokens ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - token VARCHAR(255) NOT NULL UNIQUE, - expires_at TIMESTAMP WITH TIME ZONE NOT NULL, - is_revoked BOOLEAN NOT NULL DEFAULT FALSE - );` - case "mysql": - createRefreshTokenTableSQL = `CREATE TABLE IF NOT EXISTS refresh_tokens ( - id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL UNIQUE, - token VARCHAR(255) NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - is_revoked BOOLEAN NOT NULL DEFAULT FALSE, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - );` + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if bearerToken != "" { + req.Header.Set("Authorization", bearerToken) } - if _, err := DB.Exec(createRefreshTokenTableSQL); err != nil { - logging.AppLog.Error("Failed to create refresh_tokens table", "error", err, "db_type", dbType) - os.Exit(1) + resp, err := c.httpClient.Do(req) + if err != nil { + return err } - logging.AppLog.Info("Refresh_tokens table verified successfully") + defer resp.Body.Close() //nolint:errcheck -} + if resp.StatusCode >= 400 { + return decodeAPIError(resp) + } -// 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() + if out == nil { + io.Copy(io.Discard, resp.Body) //nolint:errcheck + return nil } - // sqlite3 and mysql both use '?' - return query + + return json.NewDecoder(resp.Body).Decode(out) } -// CloseDB closes the database connection. -func CloseDB() { - if DB != nil { - if err := DB.Close(); err != nil { - logging.AppLog.Error("Failed to close database connection", "error", err) +func decodeAPIError(resp *http.Response) error { + payload := struct { + Message string `json:"message"` + Data map[string]any `json:"data"` + }{} + + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return &APIError{ + StatusCode: resp.StatusCode, + Message: resp.Status, } - logging.AppLog.Info("Database connection closed") } + + return &APIError{ + StatusCode: resp.StatusCode, + Message: payload.Message, + Details: payload.Data, + } +} + +func QuoteFilterValue(value string) string { + replacer := strings.NewReplacer("\\", "\\\\", "\"", "\\\"") + return "\"" + replacer.Replace(value) + "\"" } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..178f568 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +services: + pocketbase: + build: + context: . + dockerfile: docker/pocketbase/Dockerfile + container_name: pb-auth-proxy-pocketbase + ports: + - "${POCKETBASE_PUBLISH_PORT:-8090}:8090" + volumes: + - pocketbase_data:/pb/pb_data + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/health"] + interval: 5s + timeout: 3s + retries: 20 + + backend: + image: nginx:1.27-alpine + container_name: pb-auth-proxy-backend + volumes: + - ./docker/demo-backend/index.html:/usr/share/nginx/html/index.html:ro + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"] + interval: 5s + timeout: 3s + retries: 20 + + auth-proxy: + build: + context: . + dockerfile: Dockerfile + container_name: pb-auth-proxy-app + depends_on: + pocketbase: + condition: service_healthy + backend: + condition: service_healthy + environment: + LISTEN_PORT: ${LISTEN_PORT:-8080} + TARGET_URL: ${TARGET_URL:-http://backend:80} + SESSION_SECRET: ${SESSION_SECRET:-local-dev-session-secret-change-me} + XSRF_SECRET_KEY: ${XSRF_SECRET_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} + POCKETBASE_URL: ${POCKETBASE_URL:-http://pocketbase:8090} + POCKETBASE_COLLECTION: ${POCKETBASE_COLLECTION:-proxy_users} + POCKETBASE_IDENTITY_FIELD: ${POCKETBASE_IDENTITY_FIELD:-username} + POCKETBASE_SUPERUSER_EMAIL: ${POCKETBASE_SUPERUSER_EMAIL:-admin@example.com} + POCKETBASE_SUPERUSER_PASSWORD: ${POCKETBASE_SUPERUSER_PASSWORD:-change-me-now} + POCKETBASE_EMAIL_DOMAIN: ${POCKETBASE_EMAIL_DOMAIN:-pb-auth.local} + FRONTEND_TYPE: ${FRONTEND_TYPE:-js} + PROTECT_FRONTEND: ${PROTECT_FRONTEND:-true} + PROTECT_API: ${PROTECT_API:-true} + REGISTER: ${REGISTER:-true} + PASSWORD_POLICY: ${PASSWORD_POLICY:-standard} + MAX_LOGIN_ATTEMPTS: ${MAX_LOGIN_ATTEMPTS:-5} + LOCKOUT_DURATION_MINUTES: ${LOCKOUT_DURATION_MINUTES:-10} + USER_CREATION_RATE_LIMIT_MAX_REQUESTS: ${USER_CREATION_RATE_LIMIT_MAX_REQUESTS:-5} + USER_CREATION_RATE_LIMIT_WINDOW_SECONDS: ${USER_CREATION_RATE_LIMIT_WINDOW_SECONDS:-3600} + TOKEN_DURATION_HOURS: ${TOKEN_DURATION_HOURS:-24} + ACCESS_TOKEN_DURATION_MINUTES: ${ACCESS_TOKEN_DURATION_MINUTES:-15} + REFRESH_TOKEN_DURATION_DAYS: ${REFRESH_TOKEN_DURATION_DAYS:-7} + ports: + - "${AUTH_PROXY_PUBLISH_PORT:-8080}:8080" + +volumes: + pocketbase_data: diff --git a/docker/demo-backend/index.html b/docker/demo-backend/index.html new file mode 100644 index 0000000..f7cc702 --- /dev/null +++ b/docker/demo-backend/index.html @@ -0,0 +1,86 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Protected Backend</title> + <style> + :root { + color-scheme: light; + --bg: #f4efe6; + --card: #fffaf2; + --ink: #1e1b18; + --accent: #c95f27; + --line: #d9c7ae; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + min-height: 100vh; + font-family: Georgia, "Times New Roman", serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(201, 95, 39, 0.18), transparent 28rem), + linear-gradient(180deg, #f8f2e8 0%, var(--bg) 100%); + display: grid; + place-items: center; + padding: 24px; + } + + main { + width: min(760px, 100%); + background: var(--card); + border: 1px solid var(--line); + border-radius: 20px; + padding: 32px; + box-shadow: 0 20px 60px rgba(79, 53, 24, 0.12); + } + + h1 { + margin: 0 0 16px; + font-size: clamp(2rem, 4vw, 3.5rem); + line-height: 0.95; + } + + p { + font-size: 1.1rem; + line-height: 1.7; + } + + code { + background: rgba(201, 95, 39, 0.09); + padding: 0.15em 0.4em; + border-radius: 6px; + } + + .badge { + display: inline-block; + margin-bottom: 14px; + padding: 6px 10px; + border-radius: 999px; + background: #2f5d50; + color: #f7f3eb; + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + </style> +</head> +<body> + <main> + <div class="badge">Protected Target</div> + <h1>Backend reached through the auth proxy</h1> + <p> + If you can see this page through <code>http://localhost:8080/</code>, the reverse proxy, + PocketBase-backed auth flow, and demo backend are all wired correctly. + </p> + <p> + Default login for the seeded proxy admin user: <code>admin</code> / <code>password123</code>. + </p> + </main> +</body> +</html> diff --git a/docker/pocketbase/Dockerfile b/docker/pocketbase/Dockerfile new file mode 100644 index 0000000..86fdf77 --- /dev/null +++ b/docker/pocketbase/Dockerfile @@ -0,0 +1,16 @@ +FROM alpine:3.22 + +ARG PB_VERSION=0.36.9 + +RUN apk add --no-cache ca-certificates unzip wget + +WORKDIR /pb + +ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pocketbase.zip +RUN unzip /tmp/pocketbase.zip -d /pb && rm /tmp/pocketbase.zip && chmod +x /pb/pocketbase + +COPY pb_migrations /pb/pb_migrations + +EXPOSE 8090 + +CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data"] diff --git a/handlers/admin.go b/handlers/admin.go index 8413ac9..a568ac5 100644 --- a/handlers/admin.go +++ b/handlers/admin.go @@ -11,7 +11,6 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "sync" "time" ) @@ -43,8 +42,6 @@ func AdminPageHandler(w http.ResponseWriter, r *http.Request) { } } - - // GetUsersHandler returns a list of all users with enriched information. // It uses a worker pool to calculate extra information for each user in parallel. func GetUsersHandler(w http.ResponseWriter, r *http.Request) { @@ -71,8 +68,8 @@ func GetUsersHandler(w http.ResponseWriter, r *http.Request) { time.Sleep(5 * time.Millisecond) timeSinceLogin := "N/A" - if userCopy.LastLoginAt.Valid { - timeSinceLogin = fmt.Sprintf("%.0fm ago", time.Since(userCopy.LastLoginAt.Time).Minutes()) + if userCopy.LastLoginAt != nil { + timeSinceLogin = fmt.Sprintf("%.0fm ago", time.Since(*userCopy.LastLoginAt).Minutes()) } enrichedUsers[index] = types.EnrichedUser{ @@ -103,8 +100,8 @@ func UpdateUserRoleHandler(w http.ResponseWriter, r *http.Request) { } 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 { + id := r.URL.Path[len(config.Paths.AdminUsersAPI+"/") : len(r.URL.Path)-len("/role")] + if id == "" { logging.AppLog.Warn("Invalid user ID in request", "path", r.URL.Path, "ip", ip) http.Error(w, "Invalid user ID", http.StatusBadRequest) return @@ -148,8 +145,8 @@ func DeleteUserHandler(w http.ResponseWriter, r *http.Request) { } adminUsername := adminUser.Username - id, err := strconv.ParseInt(r.URL.Path[len(config.Paths.AdminUsersAPI+"/"):], 10, 64) - if err != nil { + id := r.URL.Path[len(config.Paths.AdminUsersAPI+"/"):] + if id == "" { logging.AppLog.Warn("Invalid user ID in request", "path", r.URL.Path, "ip", ip) http.Error(w, "Invalid user ID", http.StatusBadRequest) return @@ -177,8 +174,8 @@ func SetUserActiveStatusHandler(w http.ResponseWriter, r *http.Request) { } 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 { + id := r.URL.Path[len(config.Paths.AdminUsersAPI+"/") : len(r.URL.Path)-len("/status")] + if id == "" { logging.AppLog.Warn("Invalid user ID in request", "path", r.URL.Path, "ip", ip) http.Error(w, "Invalid user ID", http.StatusBadRequest) return diff --git a/handlers/auth_login.go b/handlers/auth_login.go index 33a3482..b0a288a 100644 --- a/handlers/auth_login.go +++ b/handlers/auth_login.go @@ -50,8 +50,8 @@ func authenticateUser(username, password, ip string) (*types.User, error) { } } - // Step 2: If the account is active, validate credentials. - if !models.CheckPasswordHash(password, user.PasswordHash) { + // Step 2: If the account is active, validate credentials against PocketBase. + if _, err := models.AuthenticateWithPassword(username, password); err != nil { // Record the failure if err := models.RecordLoginFailure(user.ID); err != nil { logging.AppLog.Error("Failed to record login failure", "error", err, "user_id", user.ID, "ip", ip) @@ -62,7 +62,7 @@ func authenticateUser(username, password, ip string) (*types.User, error) { maxAttempts := config.Paths.MaxLoginAttempts // Lock the account if max attempts are exceeded - if maxAttempts > 0 && updatedUser.FailedLogins >= maxAttempts { + if updatedUser != nil && maxAttempts > 0 && updatedUser.FailedLogins >= maxAttempts { if err := models.SetUserActiveStatus(user.ID, false); err != nil { logging.AppLog.Error("Failed to lock account", "error", err, "user_id", user.ID, "ip", ip) } @@ -77,6 +77,9 @@ func authenticateUser(username, password, ip string) (*types.User, error) { if err := models.RecordLoginSuccess(user.ID); err != nil { logging.AppLog.Error("Failed to record login success", "error", err, "user_id", user.ID, "ip", ip) } + if refreshedUser, refreshErr := models.GetUserByID(user.ID); refreshErr == nil && refreshedUser != nil { + user = refreshedUser + } logging.SecurityLog.Info("AUTH SUCCESS", "username", user.Username, "ip", ip) return user, nil @@ -148,7 +151,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { // --- Create Session Cookie --- duration := time.Duration(config.Paths.TokenDurationHours) * time.Hour - token, err := models.CreateAuthToken(user.ID, duration) + token, err := models.CreateAuthToken(user, duration) if err != nil { logging.AppLog.Error("Failed to create auth token", "error", err, "username", user.Username, "ip", ip) renderLoginWithError(w, r, "Could not create session.") diff --git a/handlers/auth_token.go b/handlers/auth_token.go index 7ea7ef6..a35a23b 100644 --- a/handlers/auth_token.go +++ b/handlers/auth_token.go @@ -49,7 +49,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { } // 4. Issue a new access token - newAccessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute) + newAccessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute, "access") if err != nil { logging.AppLog.Error("Failed to generate new access token", "error", err, "username", user.Username, "ip", ip) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -64,7 +64,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { } // 6. Issue a new refresh token - newRefreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour) + newRefreshToken, err := models.CreateRefreshToken(user, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour) if err != nil { logging.AppLog.Error("Failed to create new refresh token", "error", err, "username", user.Username, "ip", ip) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -111,7 +111,7 @@ func TokenHandler(w http.ResponseWriter, r *http.Request) { // --- Issue Tokens --- // Access Token - accessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute) + accessToken, err := utils.GenerateJWT(user, time.Duration(config.Paths.AccessTokenDurationMinutes)*time.Minute, "access") if err != nil { logging.AppLog.Error("Failed to generate access token", "error", err, "username", user.Username, "ip", ip) http.Error(w, "Failed to generate access token", http.StatusInternalServerError) @@ -119,7 +119,7 @@ func TokenHandler(w http.ResponseWriter, r *http.Request) { } // Refresh Token - refreshToken, err := models.CreateRefreshToken(user.ID, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour) + refreshToken, err := models.CreateRefreshToken(user, time.Duration(config.Paths.RefreshTokenDurationDays)*24*time.Hour) if err != nil { logging.AppLog.Error("Failed to create refresh token", "error", err, "username", user.Username, "ip", ip) http.Error(w, "Failed to create refresh token", http.StatusInternalServerError) diff --git a/handlers/php_proxy.go b/handlers/php_proxy.go index 3c6c597..1a394f7 100644 --- a/handlers/php_proxy.go +++ b/handlers/php_proxy.go @@ -143,4 +143,4 @@ func NewPhpProxyHandler(scriptName string) http.Handler { logging.AppLog.Error("Failed to copy response body from PHP-FPM", "error", err) } }) -}
\ No newline at end of file +} diff --git a/handlers/user.go b/handlers/user.go index 65ebe83..ad68f6f 100644 --- a/handlers/user.go +++ b/handlers/user.go @@ -83,16 +83,7 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { return } - dbUser, err := models.GetUserByID(user.ID) - if err != nil || dbUser == nil { - w.WriteHeader(http.StatusInternalServerError) - if err := json.NewEncoder(w).Encode(map[string]string{"error": "Could not retrieve user data."}); err != nil { - logging.AppLog.Error("Failed to encode json error response", "error", err) - } - return - } - - if !models.CheckPasswordHash(currentPassword, dbUser.PasswordHash) { + if _, err := models.AuthenticateWithPassword(user.Username, currentPassword); err != nil { w.WriteHeader(http.StatusUnauthorized) if err := json.NewEncoder(w).Encode(map[string]string{"error": "Current password is incorrect."}); err != nil { logging.AppLog.Error("Failed to encode json error response", "error", err) @@ -100,16 +91,7 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { return } - newPasswordHash, err := models.HashPassword(newPassword) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - if err := json.NewEncoder(w).Encode(map[string]string{"error": "Failed to process new password."}); err != nil { - logging.AppLog.Error("Failed to encode json error response", "error", err) - } - return - } - - if err := models.UpdateUserPassword(dbUser.ID, newPasswordHash); err != nil { + if err := models.ChangeUserPassword(user.ID, currentPassword, newPassword); err != nil { w.WriteHeader(http.StatusInternalServerError) if err := json.NewEncoder(w).Encode(map[string]string{"error": "Failed to update password."}); err != nil { logging.AppLog.Error("Failed to encode json error response", "error", err) diff --git a/middleware/auth.go b/middleware/auth.go index 3d6484f..45236aa 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -7,7 +7,6 @@ import ( "auth-proxy/types" "auth-proxy/utils" "context" - "database/sql" "net/http" "strings" ) @@ -32,8 +31,8 @@ func SessionAuth(next http.Handler) http.Handler { } tokenStr := cookie.Value - user, err := models.GetUserByToken(tokenStr) // This still relies on the session token in the DB - if err != nil && err != sql.ErrNoRows { + user, err := models.GetUserByToken(tokenStr) + if err != nil { logging.AppLog.Error("Error validating session token", "error", err, "path", r.URL.Path, "ip", ip) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return @@ -85,9 +84,15 @@ func BearerAuth(next http.Handler) http.Handler { return } + if payload.TokenType != "access" { + logging.SecurityLog.Warn("API ACCESS DENIED", "reason", "unexpected token type", "token_type", payload.TokenType, "path", r.URL.Path, "ip", ip) + http.Error(w, "Unauthorized: Invalid token type", http.StatusUnauthorized) + return + } + user, err := models.GetUserByID(payload.UserID) if err != nil || user == nil { - if err == sql.ErrNoRows || user == nil { + if user == nil { logging.SecurityLog.Error("API ACCESS DENIED", "reason", "user from token not found", "user_id", payload.UserID, "path", r.URL.Path, "ip", ip) http.Error(w, "Unauthorized: User not found", http.StatusUnauthorized) } else { @@ -166,4 +171,4 @@ func HandleOptions(next http.Handler) http.Handler { } next.ServeHTTP(w, r) }) -}
\ No newline at end of file +} diff --git a/middleware/xsrf.go b/middleware/xsrf.go index 5b9a0b8..fbac7da 100644 --- a/middleware/xsrf.go +++ b/middleware/xsrf.go @@ -7,7 +7,7 @@ import ( "os" "time" - "github.com/haturatu/xsrftoken" + "golang.org/x/net/xsrftoken" ) const ( diff --git a/models/token.go b/models/token.go index de2880a..d0ded38 100644 --- a/models/token.go +++ b/models/token.go @@ -1,154 +1,75 @@ package models import ( - "auth-proxy/database" "auth-proxy/types" - "crypto/rand" - "database/sql" - "encoding/hex" + "auth-proxy/utils" "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, deleting any existing ones. -func CreateAuthToken(userID int64, duration time.Duration) (*types.AuthToken, error) { - tokenString, err := GenerateSecureToken(32) +func CreateAuthToken(user *types.User, duration time.Duration) (*types.AuthToken, error) { + tokenString, err := utils.GenerateJWT(user, duration, "session") if err != nil { return nil, err } - expiresAt := time.Now().Add(duration) - - tx, err := database.DB.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() //nolint:errcheck - - // Delete existing tokens for the user - deleteQuery := database.Rebind("DELETE FROM auth_tokens WHERE user_id = ?") - if _, err := tx.Exec(deleteQuery, userID); err != nil { - return nil, err - } - - // Insert the new token - insertQuery := database.Rebind("INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)") - if _, err := tx.Exec(insertQuery, userID, tokenString, expiresAt); err != nil { - return nil, err - } - - if err := tx.Commit(); err != nil { - return nil, err - } - return &types.AuthToken{ - UserID: userID, + UserID: user.ID, Token: tokenString, - ExpiresAt: expiresAt, + ExpiresAt: time.Now().Add(duration), }, 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) (*types.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 := &types.User{} - err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.IsActive, &user.FailedLogins, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) + payload, err := utils.ValidateJWT(tokenString) if err != nil { - if err == sql.ErrNoRows { - return nil, nil // Token not found or expired, not a server error - } - return nil, err + return nil, nil + } + if payload.TokenType != "session" { + return nil, nil } - return user, nil + return GetUserByID(payload.UserID) } -// 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 +func DeleteAuthToken(string) error { + return nil } -// 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() + return 0, nil } -// CreateRefreshToken creates a new refresh token for a user. -func CreateRefreshToken(userID int64, duration time.Duration) (*types.RefreshToken, error) { - tokenString, err := GenerateSecureToken(32) - if err != nil { - return nil, err - } - - expiresAt := time.Now().Add(duration) - - tx, err := database.DB.Begin() +func CreateRefreshToken(user *types.User, duration time.Duration) (*types.RefreshToken, error) { + tokenString, err := utils.GenerateJWT(user, duration, "refresh") if err != nil { return nil, err } - defer tx.Rollback() //nolint:errcheck - - // Delete existing, non-revoked refresh tokens for the user - deleteQuery := database.Rebind("DELETE FROM refresh_tokens WHERE user_id = ?") - if _, err := tx.Exec(deleteQuery, userID); err != nil { - return nil, err - } - - // Insert the new refresh token - insertQuery := database.Rebind("INSERT INTO refresh_tokens (user_id, token, expires_at) VALUES (?, ?, ?)") - if _, err := tx.Exec(insertQuery, userID, tokenString, expiresAt); err != nil { - return nil, err - } - - if err := tx.Commit(); err != nil { - return nil, err - } return &types.RefreshToken{ - UserID: userID, + UserID: user.ID, Token: tokenString, - ExpiresAt: expiresAt, + ExpiresAt: time.Now().Add(duration), + IsRevoked: false, }, nil } -// GetRefreshTokenByToken retrieves a refresh token by its token string. func GetRefreshTokenByToken(tokenString string) (*types.RefreshToken, error) { - query := database.Rebind("SELECT id, user_id, token, expires_at, is_revoked FROM refresh_tokens WHERE token = ?") - row := database.DB.QueryRow(query, tokenString) - - token := &types.RefreshToken{} - err := row.Scan(&token.ID, &token.UserID, &token.Token, &token.ExpiresAt, &token.IsRevoked) + payload, err := utils.ValidateJWT(tokenString) if err != nil { - if err == sql.ErrNoRows { - return nil, nil // Not found - } - return nil, err + return nil, nil } - return token, nil + if payload.TokenType != "refresh" { + return nil, nil + } + + return &types.RefreshToken{ + UserID: payload.UserID, + Token: tokenString, + ExpiresAt: time.Unix(payload.Exp, 0), + IsRevoked: false, + }, nil } -// DeleteRefreshTokenByToken deletes a refresh token from the database by its token string. -func DeleteRefreshTokenByToken(tokenString string) error { - query := database.Rebind("DELETE FROM refresh_tokens WHERE token = ?") - _, err := database.DB.Exec(query, tokenString) - return err +func DeleteRefreshTokenByToken(string) error { + return nil } diff --git a/models/user.go b/models/user.go index f2b4e22..de440cc 100644 --- a/models/user.go +++ b/models/user.go @@ -3,132 +3,282 @@ package models import ( "auth-proxy/database" "auth-proxy/types" - "database/sql" - - "golang.org/x/crypto/bcrypt" + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" ) -// 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 +type pbUserRecord struct { + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + IsActive *bool `json:"is_active"` + FailedLogins any `json:"failed_logins"` + LastLoginAt string `json:"last_login_at"` + Created string `json:"created"` + Updated string `json:"updated"` } -// CheckPasswordHash compares a password with a hash -func CheckPasswordHash(password, hash string) bool { - err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) - return err == nil +func parsePBTime(raw string) (*time.Time, error) { + if raw == "" { + return nil, nil + } + + formats := []string{time.RFC3339Nano, "2006-01-02 15:04:05.000Z", "2006-01-02 15:04:05.000"} + for _, format := range formats { + if ts, err := time.Parse(format, raw); err == nil { + return &ts, nil + } + } + + return nil, fmt.Errorf("unsupported time format: %s", raw) } -// CreateUser creates a new user in the database -func CreateUser(username, password, role string) (*types.User, error) { - passwordHash, err := HashPassword(password) +func parsePBTimeValue(raw string) (time.Time, error) { + ts, err := parsePBTime(raw) if err != nil { - return nil, err + return time.Time{}, err } + if ts == nil { + return time.Time{}, nil + } + return *ts, nil +} + +func parseFailedLogins(value any) int { + switch v := value.(type) { + case float64: + return int(v) + case int: + return v + case int64: + return int(v) + case string: + i, _ := strconv.Atoi(v) + return i + default: + return 0 + } +} + +func normalizeRole(role string) string { + if role == "admin" { + return "admin" + } + return "user" +} - query := database.Rebind("INSERT INTO users(username, password_hash, role) VALUES(?, ?, ?)") - stmt, err := database.DB.Prepare(query) +func normalizeUser(record pbUserRecord) (*types.User, error) { + lastLoginAt, err := parsePBTime(record.LastLoginAt) if err != nil { return nil, err } - defer stmt.Close() //nolint:errcheck + createdAt, err := parsePBTimeValue(record.Created) + if err != nil { + return nil, err + } + updatedAt, err := parsePBTimeValue(record.Updated) + if err != nil { + return nil, err + } + + isActive := true + if record.IsActive != nil { + isActive = *record.IsActive + } - _, err = stmt.Exec(username, passwordHash, role) + return &types.User{ + ID: record.ID, + Username: record.Username, + Role: normalizeRole(record.Role), + IsActive: isActive, + FailedLogins: parseFailedLogins(record.FailedLogins), + LastLoginAt: lastLoginAt, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil +} + +func CreateUser(username, password, role string) (*types.User, error) { + record := pbUserRecord{} + err := database.PB.DoWithSuperuser(context.Background(), http.MethodPost, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records", nil, map[string]any{ + "username": username, + "email": database.PB.SyntheticEmail(username), + "password": password, + "passwordConfirm": password, + "role": normalizeRole(role), + "is_active": true, + "failed_logins": 0, + "last_login_at": "", + "emailVisibility": false, + "verified": true, + }, &record) if err != nil { return nil, err } - return GetUserByUsername(username) // Return the full user object + return normalizeUser(record) } -// 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" +func AuthenticateWithPassword(username, password string) (*types.User, error) { + resp := struct { + Token string `json:"token"` + Record pbUserRecord `json:"record"` + }{} -// scanUser scans a row into a User struct -func scanUser(row *sql.Row) (*types.User, error) { - user := &types.User{} - err := row.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Role, &user.IsActive, &user.FailedLogins, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) + err := database.PB.Do(context.Background(), http.MethodPost, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/auth-with-password", "", map[string]any{ + "identity": username, + "password": password, + "identityField": database.PB.IdentityField(), + }, &resp) if err != nil { - if err == sql.ErrNoRows { - return nil, nil // User not found - } return nil, err } - return user, nil + + return normalizeUser(resp.Record) } -// GetUserByUsername retrieves a user by their username func GetUserByUsername(username string) (*types.User, error) { - query := database.Rebind(baseSelectQuery + " WHERE username = ?") - row := database.DB.QueryRow(query, username) - return scanUser(row) + query := url.Values{} + query.Set("filter", fmt.Sprintf("%s=%s", database.PB.IdentityField(), database.QuoteFilterValue(username))) + query.Set("perPage", "1") + + resp := apiListResponse[pbUserRecord]{} + err := database.PB.DoWithSuperuser(context.Background(), http.MethodGet, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records", query, nil, &resp) + if err != nil { + return nil, err + } + if len(resp.Items) == 0 { + return nil, nil + } + + return normalizeUser(resp.Items[0]) } -// GetUserByID retrieves a user by their ID -func GetUserByID(id int64) (*types.User, error) { - query := database.Rebind(baseSelectQuery + " WHERE id = ?") - row := database.DB.QueryRow(query, id) - return scanUser(row) +func GetUserByID(id string) (*types.User, error) { + record := pbUserRecord{} + err := database.PB.DoWithSuperuser(context.Background(), http.MethodGet, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records/"+url.PathEscape(id), nil, nil, &record) + if err != nil { + apiErr, ok := err.(*database.APIError) + if ok && apiErr.StatusCode == http.StatusNotFound { + return nil, nil + } + return nil, err + } + + return normalizeUser(record) } -// GetAllUsers retrieves all users from the database func GetAllUsers() ([]types.User, error) { - rows, err := database.DB.Query(baseSelectQuery) + query := url.Values{} + query.Set("perPage", "500") + query.Set("sort", "username") + + resp := apiListResponse[pbUserRecord]{} + err := database.PB.DoWithSuperuser(context.Background(), http.MethodGet, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records", query, nil, &resp) if err != nil { return nil, err } - defer rows.Close() //nolint:errcheck - var users []types.User - for rows.Next() { - var user types.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 := make([]types.User, 0, len(resp.Items)) + for _, item := range resp.Items { + user, convErr := normalizeUser(item) + if convErr != nil { + return nil, convErr } - users = append(users, user) + 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 +func UpdateUserRole(id, role string) error { + return patchUser(id, map[string]any{ + "role": normalizeRole(role), + }) +} + +func DeleteUser(id string) error { + return database.PB.DoWithSuperuser(context.Background(), http.MethodDelete, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records/"+url.PathEscape(id), nil, nil, nil) } -// 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 +func ChangeUserPassword(id, currentPassword, newPassword string) error { + return patchUser(id, map[string]any{ + "oldPassword": currentPassword, + "password": newPassword, + "passwordConfirm": newPassword, + }) } -// 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 +func RecordLoginSuccess(id string) error { + return patchUser(id, map[string]any{ + "failed_logins": 0, + "last_login_at": time.Now().UTC().Format(time.RFC3339), + "is_active": true, + }) } -// 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 +func RecordLoginFailure(id string) error { + user, err := GetUserByID(id) + if err != nil { + return err + } + if user == nil { + return nil + } + + return patchUser(id, map[string]any{ + "failed_logins": user.FailedLogins + 1, + }) } -// 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 +func SetUserActiveStatus(id string, isActive bool) error { + return patchUser(id, map[string]any{ + "is_active": isActive, + }) } -// 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 +func patchUser(id string, body map[string]any) error { + return database.PB.DoWithSuperuser(context.Background(), http.MethodPatch, "/api/collections/"+url.PathEscape(database.PB.Collection())+"/records/"+url.PathEscape(id), nil, body, nil) +} + +type apiListResponse[T any] struct { + Items []T `json:"items"` +} + +func IsUnauthorized(err error) bool { + return database.IsUnauthorized(err) +} + +func IsValidationConflict(err error) bool { + apiErr, ok := err.(*database.APIError) + if !ok { + return false + } + return apiErr.StatusCode == http.StatusBadRequest || apiErr.StatusCode == http.StatusConflict +} + +func ValidationMessage(err error) string { + apiErr, ok := err.(*database.APIError) + if !ok || len(apiErr.Details) == 0 { + return "" + } + + for _, key := range []string{"username", "password", "email", "role"} { + raw, exists := apiErr.Details[key] + if !exists { + continue + } + if detail, ok := raw.(map[string]any); ok { + if msg, ok := detail["message"].(string); ok && strings.TrimSpace(msg) != "" { + return msg + } + } + } + + return "" } diff --git a/pb_migrations/1730000000_bootstrap.js b/pb_migrations/1730000000_bootstrap.js new file mode 100644 index 0000000..3a4bfca --- /dev/null +++ b/pb_migrations/1730000000_bootstrap.js @@ -0,0 +1,86 @@ +migrate((app) => { + let superusers = app.findCollectionByNameOrId("_superusers") + + let superuser = new Record(superusers) + superuser.set("email", "admin@example.com") + superuser.set("password", "change-me-now") + app.save(superuser) + + let users = new Collection({ + type: "auth", + name: "proxy_users", + listRule: null, + viewRule: null, + createRule: "", + updateRule: "@request.auth.id = id", + deleteRule: null, + fields: [ + { + name: "username", + type: "text", + required: true, + presentable: true, + min: 3, + max: 64, + pattern: "^[a-zA-Z0-9_.-]+$", + }, + { + name: "role", + type: "select", + required: true, + maxSelect: 1, + values: ["user", "admin"], + }, + { + name: "is_active", + type: "bool", + }, + { + name: "failed_logins", + type: "number", + onlyInt: true, + min: 0, + }, + { + name: "last_login_at", + type: "date", + }, + ], + indexes: [ + "CREATE UNIQUE INDEX idx_proxy_users_username ON proxy_users (username)", + ], + passwordAuth: { + enabled: true, + identityFields: ["username", "email"], + }, + }) + app.save(users) + + let usersCollection = app.findCollectionByNameOrId("proxy_users") + let admin = new Record(usersCollection) + admin.set("username", "admin") + admin.set("email", "proxy-admin@example.com") + admin.set("password", "password123") + admin.set("passwordConfirm", "password123") + admin.set("verified", true) + admin.set("emailVisibility", false) + admin.set("role", "admin") + admin.set("is_active", true) + admin.set("failed_logins", 0) + app.save(admin) +}, (app) => { + try { + let adminRecord = app.findAuthRecordByEmail("proxy_users", "proxy-admin@example.com") + app.delete(adminRecord) + } catch (_) {} + + try { + let usersCollection = app.findCollectionByNameOrId("proxy_users") + app.delete(usersCollection) + } catch (_) {} + + try { + let superuser = app.findAuthRecordByEmail("_superusers", "admin@example.com") + app.delete(superuser) + } catch (_) {} +}) diff --git a/templates/admin.html b/templates/admin.html index 6c08132..0ce6452 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -38,11 +38,11 @@ </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>{{if .LastLoginAt}}{{.LastLoginAt.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> + <button onclick="updateRole({{printf "%q" .ID}})">Save Role</button> + <button onclick="toggleActive({{printf "%q" .ID}}, {{not .IsActive}})">{{if .IsActive}}Deactivate{{else}}Activate{{end}}</button> + <button class="delete" onclick="deleteUser({{printf "%q" .ID}})">Delete</button> </td> </tr> {{end}} diff --git a/types/types.go b/types/types.go index 44cb60a..a975f0e 100644 --- a/types/types.go +++ b/types/types.go @@ -1,14 +1,11 @@ package types import ( - "database/sql" "sync" "time" ) -// PathConfig holds the configurable paths for the application routes and assets. type PathConfig struct { - // Paths Login string Register string Logout string @@ -20,68 +17,56 @@ type PathConfig struct { FrontPath string APIPath string - // Booleans ProtectFrontend bool ProtectAPI bool RegisterEnabled bool - // Security Policies - MaxLoginAttempts int - LockoutDurationMinutes int - RateLimitMaxRequests int - RateLimitWindowSeconds int - PasswordPolicy string - - // Token Durations - TokenDurationHours int + MaxLoginAttempts int + LockoutDurationMinutes int + RateLimitMaxRequests int + RateLimitWindowSeconds int + PasswordPolicy string + TokenDurationHours int AccessTokenDurationMinutes int - RefreshTokenDurationDays int + RefreshTokenDurationDays int } -// 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") -// 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"` + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + IsActive bool `json:"is_active"` + FailedLogins int `json:"failed_logins"` + LastLoginAt *time.Time `json:"last_login_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// AuthToken represents a token in the database type AuthToken struct { - ID int64 - UserID int64 + UserID string Token string ExpiresAt time.Time } -// RefreshToken represents a refresh token in the database type RefreshToken struct { - ID int64 - UserID int64 + UserID string Token string ExpiresAt time.Time IsRevoked bool } -// JWTPayload defines the claims for the access token. type JWTPayload struct { - UserID int64 `json:"user_id"` - Role string `json:"role"` - Exp int64 `json:"exp"` + UserID string `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + TokenType string `json:"token_type"` + Exp int64 `json:"exp"` } -// RateLimiter represents a user-specific rate limiter. type RateLimiter struct { Requests map[string][]int64 Mutex sync.Mutex @@ -89,7 +74,6 @@ type RateLimiter struct { Window time.Duration } -// WorkerPool manages a pool of goroutines to execute tasks concurrently. type WorkerPool struct { Workers int TaskQueue chan func() @@ -98,8 +82,7 @@ type WorkerPool struct { Active int64 } -// EnrichedUser is a wrapper for User with additional computed fields for admin views. type EnrichedUser struct { *User TimeSinceLogin string `json:"time_since_login"` -}
\ No newline at end of file +} diff --git a/utils/jwt.go b/utils/jwt.go index 9eb974c..c994ff1 100644 --- a/utils/jwt.go +++ b/utils/jwt.go @@ -12,22 +12,18 @@ import ( "time" ) -// base64Encode URL-encodes a string func base64Encode(src []byte) string { return strings.TrimRight(base64.URLEncoding.EncodeToString(src), "=") } -// base64Decode URL-decodes a string func base64Decode(src string) ([]byte, error) { - // Add padding back if necessary if l := len(src) % 4; l > 0 { src += strings.Repeat("=", 4-l) } return base64.URLEncoding.DecodeString(src) } -// GenerateJWT creates a new JWT access token. -func GenerateJWT(user *types.User, duration time.Duration) (string, error) { +func GenerateJWT(user *types.User, duration time.Duration, tokenType string) (string, error) { header := map[string]string{ "alg": "HS256", "typ": "JWT", @@ -36,58 +32,49 @@ func GenerateJWT(user *types.User, duration time.Duration) (string, error) { if err != nil { return "", err } - headerEnc := base64Encode(headerJSON) payload := types.JWTPayload{ - UserID: user.ID, - Role: user.Role, - Exp: time.Now().Add(duration).Unix(), + UserID: user.ID, + Username: user.Username, + Role: user.Role, + TokenType: tokenType, + Exp: time.Now().Add(duration).Unix(), } payloadJSON, err := json.Marshal(payload) if err != nil { return "", err } - payloadEnc := base64Encode(payloadJSON) + headerEnc := base64Encode(headerJSON) + payloadEnc := base64Encode(payloadJSON) signatureInput := fmt.Sprintf("%s.%s", headerEnc, payloadEnc) - // Sign with HMAC-SHA256 h := hmac.New(sha256.New, []byte(os.Getenv("SESSION_SECRET"))) h.Write([]byte(signatureInput)) - signature := h.Sum(nil) - signatureEnc := base64Encode(signature) + signatureEnc := base64Encode(h.Sum(nil)) return fmt.Sprintf("%s.%s.%s", headerEnc, payloadEnc, signatureEnc), nil } -// ValidateJWT parses and validates a JWT token. func ValidateJWT(tokenString string) (*types.JWTPayload, error) { parts := strings.Split(tokenString, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid token format") } - headerEnc := parts[0] - payloadEnc := parts[1] - signatureEnc := parts[2] - - // Check signature - signatureInput := fmt.Sprintf("%s.%s", headerEnc, payloadEnc) + signatureInput := parts[0] + "." + parts[1] h := hmac.New(sha256.New, []byte(os.Getenv("SESSION_SECRET"))) h.Write([]byte(signatureInput)) - expectedSignature := h.Sum(nil) - suppliedSignature, err := base64Decode(signatureEnc) + suppliedSignature, err := base64Decode(parts[2]) if err != nil { return nil, fmt.Errorf("invalid signature encoding: %w", err) } - - if !hmac.Equal(suppliedSignature, expectedSignature) { + if !hmac.Equal(suppliedSignature, h.Sum(nil)) { return nil, fmt.Errorf("invalid signature") } - // Decode payload - payloadJSON, err := base64Decode(payloadEnc) + payloadJSON, err := base64Decode(parts[1]) if err != nil { return nil, fmt.Errorf("invalid payload encoding: %w", err) } @@ -96,8 +83,6 @@ func ValidateJWT(tokenString string) (*types.JWTPayload, error) { if err := json.Unmarshal(payloadJSON, &payload); err != nil { return nil, fmt.Errorf("failed to unmarshal payload: %w", err) } - - // Check expiration if time.Now().Unix() > payload.Exp { return nil, fmt.Errorf("token has expired") } |
