summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.dockerignore8
-rw-r--r--.env.example25
-rw-r--r--.gitignore8
-rw-r--r--Dockerfile22
-rw-r--r--README.md52
-rw-r--r--cmd/admin-cli/main.go2
-rw-r--r--database/database.go420
-rw-r--r--docker-compose.yml65
-rw-r--r--docker/demo-backend/index.html86
-rw-r--r--docker/pocketbase/Dockerfile16
-rw-r--r--handlers/admin.go19
-rw-r--r--handlers/auth_login.go11
-rw-r--r--handlers/auth_token.go8
-rw-r--r--handlers/php_proxy.go2
-rw-r--r--handlers/user.go22
-rw-r--r--middleware/auth.go15
-rw-r--r--middleware/xsrf.go2
-rw-r--r--models/token.go145
-rw-r--r--models/user.go306
-rw-r--r--pb_migrations/1730000000_bootstrap.js86
-rw-r--r--templates/admin.html8
-rw-r--r--types/types.go63
-rw-r--r--utils/jwt.go41
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
diff --git a/.gitignore b/.gitignore
index 1b4b73a..a0f6026 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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"]
diff --git a/README.md b/README.md
index 538debc..0fbc0a5 100644
--- a/README.md
+++ b/README.md
@@ -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")
}