5 Commits

Author SHA1 Message Date
ae41b96fa4 updated docs for new feature 2026-06-10 01:10:23 +02:00
fabe5319ae Added UI for mfa and other profile settings 2026-06-10 01:00:45 +02:00
e2926df62c updated dependencies 2026-06-10 00:38:16 +02:00
ea8ea45c4c started with 2fa support 2026-06-09 22:50:29 +02:00
5485fd135d new version 2026-06-09 14:45:04 +02:00
17 changed files with 1897 additions and 415 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ appdata
*.exe *.exe
*.cmd *.cmd
.run .run
*.out

388
README.md
View File

@@ -1,65 +1,111 @@
# MiauInv - Inventory and Project Management System # MiauInv
MiauInv is a secure, light-weight inventory, stock, and project allocation tracking system written in Go. It utilizes HTMX for a dynamic, reactive single-page experience over traditional server-rendered HTML blocks, backed by an encrypted JWT and dual-token refresh architecture alongside an embedded SQLite instance. MiauInv is a lightweight inventory, stock, and project allocation management system written in Go. It provides a server-rendered dashboard with HTMX-style page composition, vanilla JavaScript for API interactions, SQLite for persistence, JWT-based sessions, refresh-token rotation, account settings, and optional TOTP-based two-factor authentication.
## Table of Contents The project is designed for self-hosted/private deployments. It is not a full enterprise asset-management platform, but it already covers the main workflows needed for tracking items, locations, stock distribution, and project allocations.
* [Technical Specifications](#technical-specifications) ## Contents
* [Architecture Overview](#architecture-overview)
* [Detailed Documentation](#detailed-documentation)
* [Configuration](#configuration)
* [Configuration File (config.yaml)](#configuration-file-configyaml)
* [Environment Variables](#environment-variables)
* [Route and Endpoint Matrix](#route-and-endpoint-matrix)
* [Frontend Web Routes (HTML Views)](#frontend-web-routes-html-views)
* [Backend API Endpoints (JSON Serialization)](#backend-api-endpoints-json-serialization)
* [Setup and Deployment Tutorial](#setup-and-deployment-tutorial)
* [Prerequisites](#prerequisites)
* [Option 1: Native Local Deployment](#option-1-native-local-deployment)
* [Option 2: Docker Deployment (Recommended)](#option-2-docker-deployment-recommended)
* [Reverse Proxy Integration with Caddy](#reverse-proxy-integration-with-caddy)
* [Images](#images)
## Technical Specifications - [Features](#features)
- [Current Status](#current-status)
- [Technical Stack](#technical-stack)
- [Architecture](#architecture)
- [Documentation](#documentation)
- [Configuration](#configuration)
- [Routes and API Endpoints](#routes-and-api-endpoints)
- [Setup](#setup)
- [Docker Deployment](#docker-deployment)
- [Reverse Proxy Deployment](#reverse-proxy-deployment)
- [Security Notes](#security-notes)
- [Screenshots](#screenshots)
* **Backend Language:** Go (Golang 1.22+) ## Features
* **Frontend Interactivity:** HTMX (v2.0.4) & Vanilla JavaScript (API Client integration)
* **Database Engine:** SQLite (via standard driver dependencies)
* **Security & Session Layer:** JSON Web Tokens (JWT) with HTTP-Only/Secure dual cookie strategy (Access and Refresh Token Rotation)
* **Styling Architecture:** Modern dark-theme customized native CSS variables (`--bg`, `--card`, `--accent`, `--border`)
* **Transport Security:** Compulsory HTTPS / Native TLS Listener implementation
--- ### Inventory and allocation
## Architecture Overview - Item management with name, category, description, and total quantity.
- Location management for physical or logical storage places.
- Stock mapping between items and locations.
- Project management for allocating items to projects.
- Association tracking between projects and item quantities.
- Dashboard statistics for items, locations, and projects.
MiauInv splits responsibility cleanly across modularized architecture packages: ### Account and authentication
* **`main.go`**: Entrypoint initializing components and connecting layers. - User registration, if enabled in the configuration.
* **`server/`**: Configures variables, spins up TLS mechanisms, and exposes route endpoints. - Password hashing with bcrypt.
* **`auth/`**: Custom HTTP Middleware interceptors validating JWT signatures and parsing sub-claims. - Signed JWT access tokens.
* **`handlers/`**: Core API Controller actions processing CRUD functions on database entities. - Database-backed refresh tokens with rotation.
* **`storage/`**: Direct abstraction queries interacting with the underlying SQLite database schema. - HTTP-only secure cookies for access and refresh tokens.
* **`frontend/`**: Serving standard static assets and injecting structural data into components. - Account settings page at `/profile/settings`.
- Username change with password confirmation.
- Password change with old-password verification and session refresh.
- Avatar placeholder in the account settings UI for a later avatar implementation.
--- ### Two-factor authentication
--- - Optional TOTP 2FA using authenticator apps.
- QR-code based setup from the account settings page.
- Manual setup key fallback if QR scanning is not available.
- Two-step login flow for accounts with 2FA enabled.
- Recovery codes generated when 2FA is enabled.
- Recovery codes are stored only as hashes.
- Recovery codes can be downloaded as a text file after generation.
- Recovery codes can be regenerated from account settings.
- Recovery codes are one-time use.
## Detailed Documentation ## Current Status
For deep dives into specific subsystems, database layouts, and security mechanisms, please refer to the dedicated documentation files: MiauInv is an active private project. The current version supports core inventory workflows and account-level security settings. Some areas are intentionally still basic:
* **[Database Schema & Integrity](docs/DATABASE.md):** Comprehensive breakdown of the SQLite table structures, fields, and foreign key relations. - Avatar support is currently only represented by a placeholder in the UI.
* **[Authentication Architecture](docs/AUTHENTICATION.md):** Detailed explanation of the dual-token rotation flow, JWT lifecycle, and frontend loop protection. - There is no dedicated admin panel yet.
- There is no rate limiting yet for login, 2FA, or recovery-code attempts.
- Automated test coverage is still incomplete and should be expanded around authentication, 2FA, recovery codes, and inventory handlers.
- The application currently uses native TLS. If deployed behind a reverse proxy, the proxy must connect to the backend over HTTPS or the backend TLS behavior must be adjusted intentionally.
## Technical Stack
| Area | Technology |
| --- | --- |
| Backend | Go 1.26 |
| Routing | Go standard library `net/http` |
| Database | SQLite via `github.com/glebarez/go-sqlite` |
| Authentication | JWT via `github.com/golang-jwt/jwt/v5` |
| Password hashing | bcrypt via `golang.org/x/crypto/bcrypt` |
| 2FA | TOTP via `github.com/pquerna/otp/totp` |
| Frontend | Server-rendered HTML, HTMX-style structure, vanilla JavaScript |
| Styling | Custom CSS with dark theme variables |
| Deployment | Docker / Docker Compose |
## Architecture
The codebase is split into small packages with mostly direct responsibilities:
| Path | Responsibility |
| --- | --- |
| `main.go` | Application entrypoint. Initializes configuration, database, and server startup. |
| `server/` | HTTP route registration, TLS listener, and server-level configuration. |
| `config/` | Runtime configuration file creation and loading. |
| `auth/` | JWT generation, JWT validation, middleware, role middleware, and password helpers. |
| `handlers/` | JSON API handlers for authentication, account settings, inventory, locations, projects, stock, and associations. |
| `storage/` | SQLite schema setup, migrations, and database access helpers. |
| `models/` | Shared data structures and constants. |
| `frontend/` | HTML template rendering and static asset serving. |
| `frontend/assets/js/` | Frontend API client, login flow, token refresh logic, account settings logic, and dashboard actions. |
| `frontend/htmx/` | HTML views and dashboard content templates. |
## Documentation
More detailed documentation is available in:
- [Authentication](docs/AUTHENTICATION.md)
- [Database](docs/DATABASE.md)
- [Testing](docs/TESTING.md)
## Configuration ## Configuration
The system uses a combination of a structural JSON configuration file and environment variables for system runtime flags. MiauInv reads `./appdata/config.yaml`. If the file does not exist, the application creates a default configuration on startup.
### Configuration File (`config.yaml`)
The application automatically creates or reads a configuration file named `config.json` in the working directory on startup.
```yaml ```yaml
port: "8080" port: "8080"
@@ -69,101 +115,132 @@ private_key_path: ./appdata/key.pem
allow_registration: true allow_registration: true
``` ```
### Environment Variables ### Configuration fields
For cryptographic functions, a mandatory environment variable must be exported before executing the binary: | Field | Description |
| --- | --- |
| `port` | HTTPS listen port. |
| `database_path` | SQLite database path. |
| `certificate_path` | TLS certificate path. |
| `private_key_path` | TLS private key path. |
| `allow_registration` | Enables or disables public registration. If false, `/register` renders a blocked-registration page and `/api/register` is not registered. |
| Variable | Type | Description | Minimum Requirement | ### Environment variables
| Variable | Required | Description |
| --- | --- | --- |
| `JWT_SECRET` | Yes | Symmetric signing secret for JWTs. Must be at least 32 characters. |
Generate a local development secret with:
```bash
openssl rand -base64 48
```
## Routes and API Endpoints
### Frontend routes
| Route | Method | Auth required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `JWT_SECRET` | String | Symmetric secret signature key used to sign access tokens | Minimum 32 characters | | `/` | `GET` | No | Landing page. |
| `/login` | `GET` | No | Login page. Supports password login and the second 2FA step. |
| `/register` | `GET` | No | Registration page or blocked-registration page, depending on configuration. |
| `/dashboard` | `GET` | Yes | Dashboard overview. |
| `/inventory` | `GET` | Yes | Stock and inventory overview. |
| `/items` | `GET` | Yes | Item management view. |
| `/locations` | `GET` | Yes | Location management view. |
| `/projects` | `GET` | Yes | Project management view. |
| `/profile/settings` | `GET` | Yes | Account settings, password changes, 2FA setup, 2FA disable, and recovery-code management. |
| `/profile/` | `GET` | No | Placeholder page for unfinished profile subpages. |
| `/assets/*` | `GET` | No | Static CSS/JS assets. Minified CSS/JS variants are generated on request. |
--- ### Authentication and account API
## Route and Endpoint Matrix | Endpoint | Method | Auth required | Description |
All communication with the application happens over defined HTTP transport interfaces. The routes are divided into User-Facing Views (HTML renders) and programmatic Data Hooks (JSON APIs).
### Frontend Web Routes (HTML Views)
| Route Path | HTTP Method | Auth Required | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `/` | `GET` | No | Root index landing view. | | `/api/register` | `POST` | No | Creates a user if registration is enabled. |
| `/login` | `GET` | No | Renders login page component. | | `/api/login` | `POST` | No | Validates username/password. Returns a full session if 2FA is disabled; otherwise returns a short-lived 2FA challenge token. |
| `/register` | `GET` | No | Registration layout or block alert based on authorization properties. | | `/api/login/2fa` | `POST` | No | Completes login using the 2FA challenge token plus either a TOTP code or a recovery code. |
| `/dashboard` | `GET` | **Yes** | Aggregated stats layout covering items, projects, and locations. | | `/api/refresh` | `POST` | No | Rotates a refresh token. Accepts the token from JSON or the `refresh_token` cookie. |
| `/inventory` | `GET` | **Yes** | General overview interface managing stock quantities. | | `/api/logout` | `POST` | Yes | Revokes refresh tokens for the current user and clears auth cookies. |
| `/items` | `GET` | **Yes** | Standard component interface targeting primary atomic assets. | | `/api/profile` | `GET` | Yes | Returns current user metadata, 2FA state, and unused recovery-code count. |
| `/locations` | `GET` | **Yes** | Physical or logical facility structures view. | | `/api/userinfo` | `GET` | Yes | Same user information handler as `/api/profile`. |
| `/projects` | `GET` | **Yes** | Overview interface listing active construction and logistics operations. | | `/api/account/username` | `POST` | Yes | Changes the current username after password confirmation. |
| `/profile/` | `GET` | **Yes** | Component context under development. | | `/api/account/password` | `POST` | Yes | Changes the current password, revokes old refresh tokens, and issues a new session. |
| `/assets/*` | `GET` | No | Serves global minified system design files (`.css`, `.js`). | | `/api/2fa/setup` | `POST` | Yes | Creates a pending TOTP secret and returns `secret`, `otpauth_url`, and a base64 PNG QR code. |
| `/api/2fa/enable` | `POST` | Yes | Enables 2FA after validating a TOTP code. Returns one-time recovery codes. |
| `/api/2fa/disable` | `POST` | Yes | Disables 2FA after password and TOTP confirmation. Revokes sessions and clears auth cookies. |
| `/api/2fa/recovery-codes/regenerate` | `POST` | Yes | Invalidates existing recovery codes and returns a new set after password and TOTP confirmation. |
### Backend API Endpoints (JSON Serialization) ### Inventory API
| Endpoint Path | Method | Auth | Query Parameters | Request/Response Behavior | | Endpoint | Method | Auth required | Query parameters | Description |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `/api/register` | `POST` | No | None | Creates a new user record. Requires plain JSON payload containing raw `username` and `password`. Returns status code `201 Created`. | | `/api/item` | `GET` | Yes | `id` optional | Returns all items with aggregate quantities, or one item by ID. |
| `/api/login` | `POST` | No | None | Performs credential authentication. Sets `access_token` and `refresh_token` as secure, HTTP-Only cookies, and returns user identity metadata. | | `/api/item` | `POST` | Yes | None | Creates an item. |
| `/api/refresh` | `POST` | No | None | Accepts JSON containing `refresh_token`. Invalidates previous token structures, rotates identities, and hands over a newly active pair. | | `/api/item` | `PUT` | Yes | `id` required | Updates item fields. |
| `/api/logout` | `POST` | **Yes** | None | Revokes active database-linked refresh session IDs for the user context and drops current browser state tracking. Returns status `204`. | | `/api/item` | `DELETE` | Yes | `id` required | Deletes an item if no dependent rows block it. |
| `/api/profile` | `GET` | **Yes** | `id` *(Optional)* | Returns `id`, `username`, and metadata of either the target parameter or active identity mapped from token signatures. | | `/api/location` | `GET` | Yes | `id`, `content` optional | Returns all locations, one location, or location contents with `content=true`. |
| `/api/item` | `GET` | **Yes** | `id` *(Optional)* | Empty parameters fetch all available items with aggregated `allocated` and `available` calculations. Providing `id` isolates a specific item. | | `/api/location` | `POST` | Yes | None | Creates a location. |
| `/api/item` | `POST` | **Yes** | None | Inserts a new tracked inventory item schema definition. | | `/api/location` | `PUT` | Yes | `id` required | Renames a location. |
| `/api/item` | `PUT` | **Yes** | `id` *(Required)* | Modifies values (`name`, `category`, `description`, `total_quantity`) of an active asset by primary key. | | `/api/location` | `DELETE` | Yes | `id` required | Deletes a location if no dependent rows block it. |
| `/api/item` | `DELETE` | **Yes** | `id` *(Required)* | Removes an entry. SQLite blocks cascade execution if foreign key assignments still exist in stock or project tracking. | | `/api/project` | `GET` | Yes | `id`, `details` optional | Returns all projects, one project, or project allocations with `details=true`. |
| `/api/location` | `GET` | **Yes** | `id`, `content` | Fetching with `id` and `content=true` extracts an array of items grouped at the facility (`item_id`, `name`, `quantity`). Without `content`, returns details or global catalogs. | | `/api/project` | `POST` | Yes | None | Creates a project. |
| `/api/location` | `POST` | **Yes** | None | Instantiates a singular distinct location boundary identifier. | | `/api/project` | `PUT` | Yes | `id` required | Updates a project. |
| `/api/location` | `PUT` | **Yes** | `id` *(Required)* | Renames a location while maintaining foreign keys. | | `/api/project` | `DELETE` | Yes | `id` required | Deletes a project if no dependent rows block it. |
| `/api/location` | `DELETE` | **Yes** | `id` *(Required)* | Destroys location configurations if currently cleared of active items. | | `/api/stock` | `GET` | Yes | `id` optional | Returns stock entries. |
| `/api/project` | `GET` | **Yes** | `id`, `details` | Providing `id` with `details=true` unrolls associated items allocated to that project context. | | `/api/stock` | `POST` | Yes | None | Creates a stock entry. |
| `/api/project` | `POST` | **Yes** | None | Inserts a tracking context entity for targeted hardware allocation. | | `/api/stock` | `PUT` | Yes | `id` required | Updates a stock entry. |
| `/api/project` | `PUT` | **Yes** | `id` *(Required)* | Updates a project metadata record definition. | | `/api/stock` | `DELETE` | Yes | `id` required | Deletes a stock entry. |
| `/api/project` | `DELETE` | **Yes** | `id` *(Required)* | Drops an empty project wrapper. | | `/api/association` | `GET` | Yes | `id`, `project_id` optional | Returns project-item associations. |
| `/api/stock` | `GET` | **Yes** | `id` *(Optional)* | Obtains exact relationship matrices between location nodes and items. | | `/api/association` | `POST` | Yes | None | Allocates item quantity to a project. |
| `/api/stock` | `POST` | **Yes** | None | Links allocations across specific site nodes. | | `/api/association` | `PUT` | Yes | `id` required | Updates a project allocation. |
| `/api/stock` | `PUT` | **Yes** | `id` *(Required)* | Modifies stock metrics directly on specified maps. | | `/api/association` | `DELETE` | Yes | `id` required | Removes a project allocation. |
| `/api/stock` | `DELETE` | **Yes** | `id` *(Required)* | Completely severs relationship entry allocations between nodes. |
| `/api/association` | `GET` | **Yes** | `id`, `project_id` | Dumps general configuration links, isolated optionally by operational `project_id`. |
| `/api/association` | `POST` | **Yes** | None | Allocates an inventory batch to a dedicated project infrastructure requirement. |
| `/api/association` | `PUT` | **Yes** | `id` *(Required)* | Alters active quantity indicators inside a specific deployment matrix. |
| `/api/association` | `DELETE` | **Yes** | `id` *(Required)* | Frees up allocations, returning tracking variables back to unassigned states. |
--- ## Setup
## Setup and Deployment Tutorial
### Prerequisites ### Prerequisites
Before deployment, you must generate SSL/TLS certificates since MiauInv enforces native transport encryption layer communication (or use a bought one). - Go 1.26.
- SQLite-compatible environment.
- OpenSSL or another way to generate TLS certificates for local development.
- A `JWT_SECRET` with at least 32 characters.
### Generate development TLS files
The server uses `http.ListenAndServeTLS`, so TLS files must exist before startup.
```bash ```bash
# Create directory for certs
mkdir -p appdata mkdir -p appdata
openssl req -x509 -newkey rsa:4096 \
# Generate self-signed certificate and private key -keyout appdata/key.pem \
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem -sha256 -days 365 -nodes -out appdata/cert.pem \
-days 365 \
-nodes \
-subj "/CN=localhost"
``` ```
### Option 1: Native Local Deployment ### Native local run
1. Make sure your Go environment path variable properties are populated (Go 1.22+).
2. Create your environmental token and execute the initialization routine task:
```bash ```bash
export JWT_SECRET="your_minimum_thirty_two_char_secret_key_here" export JWT_SECRET="replace-this-with-a-random-secret-of-at-least-32-chars"
go build -o miauinv main.go go mod tidy
go build -o miauinv .
./miauinv ./miauinv
``` ```
--- Then open:
### Option 2: Docker Deployment (Recommended) ```text
https://localhost:8080
```
MiauInv includes container definition orchestrations to package configurations cleanly, binding storage databases outside running containers into persistent volumes. ## Docker Deployment
#### 1. Create the Docker Compose Descriptor The repository contains a multi-stage Dockerfile. The final image is based on `scratch` and contains the compiled binary plus frontend assets.
Write the configuration definition mapping layer blocks directly inside a standard file named `docker-compose.yaml`: Example `docker-compose.yaml`:
```yaml ```yaml
services: services:
@@ -174,59 +251,31 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
- JWT_SECRET=SECURE_RANDOM_STRING # Must be at least 32 characters long - JWT_SECRET=replace-this-with-a-random-secret-of-at-least-32-chars
volumes:
- ./appdata:/appdata # To edit your configuration files
```
#### 2. Execution Commands
To bring up your background container image instance pipelines, execute the compose environment controls:
```bash
# Build and execute the container in background detached mode
docker-compose up --build -d
# Verify container operation statuses
docker-compose ps
# Monitor execution system logs
docker-compose logs -f
```
Once running successfully via Docker orchestration loops, navigate your web browser context safely to `https://localhost:8080` to interact with your MiauInv control panel workspace.
## Reverse Proxy Integration with Caddy
If you deploy MiauInv behind a global Caddy server, Caddy must act as an HTTPS reverse proxy. Since the MiauInv binary enforces native TLS transport, Caddy needs to be configured to establish a secure backend connection and bypass verification for self-signed backend certificates.
### 1. Docker Compose Network Configuration
Ensure your MiauInv container shares an external network with your Caddy container (e.g., a network named `proxy`). The container does not need to expose public ports since Caddy communicates with it internally over port `8080`.
```yaml
services:
miauinv:
image: git.miaurizius.de/miaurizius/miauinv:latest
container_name: MiauInv
restart: unless-stopped
networks:
- proxy
environment:
- JWT_SECRET=SECURE_RANDOM_STRING
volumes: volumes:
- ./appdata:/appdata - ./appdata:/appdata
networks:
proxy:
external: true
``` ```
### 2. Caddyfile Configuration Start the container:
Add the following block to your server's `Caddyfile`. The `https://` prefix forces Caddy to use TLS for the backend connection, and `tls_insecure_skip_verify` allows the proxy to accept the internal self-signed certificate generated during the prerequisites step. ```bash
docker compose up -d
```
View logs:
```bash
docker compose logs -f
```
## Reverse Proxy Deployment
MiauInv currently listens with native TLS. If it is deployed behind Caddy or another reverse proxy, the proxy must connect to the backend using HTTPS unless the server code is changed to listen without TLS.
Example Caddy configuration with a self-signed backend certificate:
```caddy ```caddy
inv.yourdomain.com { inv.example.com {
encode zstd gzip encode zstd gzip
reverse_proxy https://miauinv:8080 { reverse_proxy https://miauinv:8080 {
@@ -238,20 +287,27 @@ inv.yourdomain.com {
header { header {
X-Content-Type-Options nosniff X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin Referrer-Policy strict-origin-when-cross-origin
Strict-Transport-Security "max-age=31536000; includeSubDomains"
} }
} }
``` ```
### 3. Apply Configuration For Docker deployments, place Caddy and MiauInv on the same Docker network and reverse proxy to the service name.
Reload your Caddy instance to apply the reverse proxy routing rules: ## Security Notes
```bash - JWTs are signed, not encrypted. Do not put secrets into JWT claims.
docker compose exec -w /etc/caddy caddy caddy reload - `JWT_SECRET` must be random and private.
``` - Access tokens expire after 15 minutes.
- Refresh tokens expire after 7 days and are rotated on refresh.
- Refresh tokens and recovery codes are stored in the database as hashes.
- TOTP secrets are currently stored in the database because the server must validate codes. Protect the database file accordingly.
- Recovery codes are only shown when generated. Users should download or copy them immediately.
- 2FA disable and recovery-code regeneration require both the current password and a valid TOTP code.
- The project should add rate limiting before being exposed to untrusted public traffic.
- The project should add more automated tests around login, 2FA, recovery codes, and account settings before being considered production-ready.
## Screenshots
## Images
#### Dashboard #### Dashboard
<img src="docs/img/dashboard.png"> <img src="docs/img/dashboard.png">

View File

@@ -1,6 +1,7 @@
package auth package auth
import ( import (
"errors"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -12,6 +13,12 @@ type Claims struct {
jwt.RegisteredClaims jwt.RegisteredClaims
} }
type PurposeClaims struct {
UserID string `json:"user_id"`
Purpose string `json:"purpose"`
jwt.RegisteredClaims
}
func GenerateJWT(userID, role string, secret []byte) (string, error) { func GenerateJWT(userID, role string, secret []byte) (string, error) {
claims := Claims{ claims := Claims{
UserID: userID, UserID: userID,
@@ -25,8 +32,26 @@ func GenerateJWT(userID, role string, secret []byte) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret) return token.SignedString(secret)
} }
func GeneratePurposeJWT(userID, purpose string, secret []byte, ttl time.Duration) (string, error) {
claims := PurposeClaims{
UserID: userID,
Purpose: purpose,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secret)
}
func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) { func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return secret, nil return secret, nil
}) })
if err != nil { if err != nil {
@@ -35,7 +60,29 @@ func ValidateJWT(tokenStr string, secret []byte) (*Claims, error) {
claims, ok := token.Claims.(*Claims) claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid { if !ok || !token.Valid {
return nil, err return nil, errors.New("invalid token")
}
return claims, nil
}
func ValidatePurposeJWT(tokenStr, expectedPurpose string, secret []byte) (*PurposeClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &PurposeClaims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*PurposeClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
if claims.Purpose != expectedPurpose {
return nil, errors.New("invalid token purpose")
} }
return claims, nil return claims, nil

View File

@@ -1,5 +0,0 @@
sudo docker buildx build \
--platform linux/amd64,linux/arm64 \
-t git.miaurizius.de/miaurizius/miauinv:latest \
-t git.miaurizius.de/miaurizius/miauinv:v1.0.1 \
--push .

View File

@@ -1,51 +1,213 @@
# Authentication Architecture # Authentication Architecture
MiauInv implements a stateless JSON Web Token (JWT) architecture combined with a persistent database-backed Refresh Token mechanism to provide high security alongside seamless session retention. MiauInv uses signed JWT access tokens, database-backed refresh tokens, and optional TOTP-based two-factor authentication. The authentication flow is implemented in the `auth/`, `handlers/`, `storage/`, and `frontend/assets/js/` packages.
## Token Lifetime and Properties JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variable. They are not encrypted. Claims should therefore contain identity and authorization metadata only, not secrets.
| Token Type | Transport Vector | Storage Location | Lifetime | Purpose | ## Components
| --- | --- | --- | --- | --- |
| **Access Token** | HTTP-Only Cookie & Auth Header | Memory / Browser Cookies | 15 Minutes | Signed payload validating current session identity for immediate API interaction. |
| **Refresh Token** | Secure Cookie & JSON Payload | LocalStorage / Secure Cookies | 7 Days | Long-lived high-entropy string used to request a new token pair when the Access Token expires. |
--- | Component | Location | Responsibility |
| --- | --- | --- |
| JWT helpers | `auth/jwt.go` | Access-token and purpose-token generation/validation. |
| Middleware | `auth/middleware.go` | Extracts access tokens from bearer headers or cookies and injects claims into the request context. |
| Password helpers | `auth/password.go` | bcrypt hashing and verification. |
| Login/account handlers | `handlers/account.go` | Register, login, 2FA, refresh, logout, account settings, and user metadata. |
| Persistent session storage | `storage/storage.go` | Refresh tokens, 2FA state, TOTP secret, and recovery-code hashes. |
| Frontend auth logic | `frontend/assets/js/auth.js`, `frontend/assets/js/login.js`, `frontend/assets/js/api.js` | Login UI, token refresh, account settings, and 2FA UI interactions. |
## Token Rotation and Flow ## Token Types
The application coordinates token validation through cooperative interactions between Go authentication middlewares and the frontend runtime environment. | Token | Storage/Transport | Lifetime | Purpose |
| --- | --- | --- | --- |
| Access token | `access_token` HTTP-only secure cookie and JSON response body | 15 minutes | Authenticates normal API and page requests. |
| Refresh token | `refresh_token` HTTP-only secure cookie and JSON response body | 7 days | Rotates sessions after access-token expiry. Stored in the database only as a hash. |
| 2FA challenge token | JSON response from `/api/login` | 5 minutes | Allows `/api/login/2fa` to complete login after password verification. It is purpose-bound to `2fa_login`. |
### 1. Normal Authenticated Requests ## Normal Login Flow Without 2FA
During standard interaction loops, the Go server intercepts requests via auth middleware. It checks the incoming context for validity in the following order:
1. `Authorization: Bearer <token>` request header.
2. `access_token` cookie values.
If a valid, unexpired Access Token is recovered, the middleware parses the claims (ID, username, role) and injects them into the request context before execution routes fire. 1. Client sends `POST /api/login` with `username` and `password`.
2. Server loads the user by username.
3. Server verifies the bcrypt password hash.
4. If 2FA is disabled, the server issues:
- a signed access token,
- a high-entropy refresh token,
- HTTP-only secure cookies for both tokens,
- a JSON response containing the same token values and user metadata.
5. The refresh token is hashed before being stored in the `refresh_tokens` table.
### 2. Token Refresh Flow ## Login Flow With 2FA Enabled
When an Access Token expires mid-session, the following workflow occurs automatically:
1. The backend rejects an API call or routing intent with an HTTP state indicating token expiration.
2. The frontend execution scope identifies the expiration status and reads the `refresh_token` from storage assets.
3. The client submits a POST request containing the token payload to `/api/refresh`.
4. The backend verifies the signature, looks up the hash inside the `refresh_tokens` table, and verifies that `revoked == 0` and `expires_at > now`.
5. If the validation succeeds, a brand-new Access Token and a rotated Refresh Token pair are generated, saved to secure cookies/storage, and the user session continues without explicit re-authentication.
---
## Security Mitigations
### Loop Protection
To prevent broken, expired, or malformed credentials from triggering infinite network refresh loops (which degrade browser performance and strain backend lookup performance), the frontend utilizes an explicit safety lock.
1. Client sends `POST /api/login` with `username` and `password`.
2. Server verifies the password.
3. If `two_factor_enabled` is true, the server does not issue a full session.
4. Instead, the server returns:
```json
{
"requires_2fa": true,
"two_factor_token": "..."
}
``` ```
Token Expired -> Check 'is_refreshing' flag -> True -> Clear Auth & Force Login 5. The frontend shows the second login step.
-> False -> Set flag 'true' -> Send Request 6. Client sends `POST /api/login/2fa` with:
```json
{
"two_factor_token": "...",
"code": "123456"
}
``` ```
Before issuing an evaluation request to `/api/refresh`, the application checks a temporary session variable (`is_refreshing` within `sessionStorage`). If the flag is already set to `true`, the loop protection triggers a hard clearance routine via `clearAllAuth()`, drops all token storage records, and routes the user back to the primary login view safely. 7. The server validates the purpose token against the expected purpose `2fa_login`.
8. The server loads the user from the purpose-token claim.
9. The supplied code is checked as a TOTP code first.
10. If the TOTP check fails, the supplied value is normalized and checked as a recovery code.
11. If either check succeeds, the server issues the normal access/refresh token session.
12. If a recovery code was used, it is marked as used and cannot be used again.
### Database Revocation ## TOTP Setup Flow
Refresh sessions can be killed immediately from the server side. When a user requests `/api/logout`, the backend switches the corresponding row state within the `refresh_tokens` database container to `revoked = 1`. Any subsequent rotation requests relying on that token family are automatically dropped, protecting against stolen credential replay attacks.
The account settings page at `/profile/settings` exposes the UI for TOTP setup.
1. Authenticated user calls `POST /api/2fa/setup`.
2. Server creates a TOTP secret using issuer `MiauInv` and the current username as the account name.
3. Server stores the secret in `users.two_factor_secret`, but does not enable 2FA yet.
4. Server returns:
```json
{
"secret": "BASE32SECRET",
"otpauth_url": "otpauth://totp/...",
"qr_code": "data:image/png;base64,..."
}
```
5. The frontend displays the QR code and the manual setup key.
6. User scans the QR code or enters the secret manually into an authenticator app.
7. User submits a current TOTP code to `POST /api/2fa/enable`.
8. Server validates the code.
9. Server enables 2FA and generates recovery codes.
10. Recovery codes are returned once to the client for download/copying.
## Recovery Codes
Recovery codes are generated when 2FA is enabled and when the user regenerates them.
Properties:
- Generated with cryptographically secure randomness.
- Formatted as four groups of five hexadecimal characters.
- Normalized before hashing by removing spaces and hyphens and lowercasing the value.
- Stored only as hashes in `two_factor_recovery_codes`.
- Single-use only.
- Displayed only immediately after generation/regeneration.
- Downloaded client-side from the account settings page as `miauinv-recovery-codes.txt`.
Recovery-code login flow:
1. User enters a recovery code in the same field as the TOTP code during the second login step.
2. Server first attempts normal TOTP validation.
3. If TOTP validation fails, server hashes the normalized recovery code.
4. Server updates the matching unused row with `used_at = now`.
5. If exactly one row was updated, the recovery-code login succeeds.
## Recovery-Code Regeneration
`POST /api/2fa/recovery-codes/regenerate` requires:
```json
{
"password": "current-password",
"code": "123456"
}
```
The server verifies the current password and a current TOTP code. If both are valid, all previous recovery codes are deleted and a new set is inserted. The new plaintext codes are returned once.
## Disabling 2FA
`POST /api/2fa/disable` requires:
```json
{
"password": "current-password",
"code": "123456"
}
```
If the password and TOTP code are valid, the server:
1. Sets `two_factor_enabled = 0`.
2. Clears `two_factor_secret`.
3. Deletes recovery codes for the user.
4. Revokes refresh tokens for the user.
5. Clears authentication cookies.
The frontend redirects the user to `/login` after disabling 2FA because existing sessions are revoked.
## Refresh Token Rotation
`POST /api/refresh` accepts the refresh token either from JSON:
```json
{
"refresh_token": "..."
}
```
or from the `refresh_token` cookie.
The server:
1. Hashes the supplied token.
2. Looks it up in `refresh_tokens`.
3. Rejects revoked or expired tokens.
4. Marks the used refresh token as revoked.
5. Issues a new access token and refresh token.
6. Stores the new refresh token hash.
7. Updates the auth cookies.
## Account Settings Security
The account settings page currently supports:
- username changes,
- password changes,
- TOTP 2FA setup,
- TOTP 2FA disable,
- recovery-code download after generation,
- recovery-code regeneration.
Username changes require the current password.
Password changes require the current password, reject passwords longer than bcrypt's 72-byte effective limit, revoke existing refresh tokens, and issue a new session.
## Middleware Behavior
Protected routes use `AuthMiddleware`.
The middleware checks tokens in this order:
1. `Authorization: Bearer <token>` header.
2. `access_token` cookie.
If no token is found:
- `/api/*` routes receive `401 Unauthorized`.
- non-API routes redirect to `/login`.
If token validation fails:
- `/api/*` routes receive `401 Unauthorized`.
- non-API routes clear the access cookie and redirect to `/login`.
## Security Limitations and Follow-up Work
The current implementation is usable for private/self-hosted deployments, but these improvements should be prioritized before exposing it to untrusted public traffic:
- Add rate limiting for login, 2FA, refresh, and recovery-code attempts.
- Add audit logging for account security changes.
- Add optional session/device management UI.
- Consider encrypting TOTP secrets at rest if the deployment threat model includes database disclosure.
- Expand tests for all authentication and account settings handlers.

View File

@@ -1,97 +1,194 @@
# Database Documentation # Database Documentation
MiauInv utilizes an embedded SQLite database instance for persistent data storage. Foreign key constraints are strictly enforced at the database level. MiauInv uses SQLite for persistent storage. The schema is initialized in `storage.InitDB` and foreign-key enforcement is explicitly enabled with:
## Configuration
To ensure data integrity, every database connection initialization explicitly executes the following command before handling queries:
```sql ```sql
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
``` ```
--- The database stores users, refresh tokens, 2FA recovery codes, inventory items, locations, projects, stock mappings, and project allocations.
## Schema Architecture ## Entity Overview
### Entity-Relationship Summary ```text
[users] 1 ──── N [refresh_tokens]
[users] 1 ──── N [two_factor_recovery_codes]
The database consists of primary entity tables (`users`, `items`, `locations`, `projects`) and relational junction tables (`stock`, `project_items`, `refresh_tokens`) designed to track stock distribution and access sessions. [items] 1 ──── N [stock] N ──── 1 [locations]
[items] 1 ──── N [project_items] N ──── 1 [projects]
```
[users] <--- (1:N) ---> [refresh_tokens]
[items] <--- (1:N) ---> [stock] <--- (N:1) ---> [locations]
[items] <--- (1:N) ---> [project_items] <--- (N:1) ---> [projects]
``` ```
--- ## Tables
## Table Definitions ### `users`
### 1. users Stores account credentials, roles, and 2FA state.
Stores user credentials and operational roles within the system. | Column | Type | Constraints | Description |
| --- | --- | --- | --- |
| `id` | `TEXT` | Primary key | User UUID. |
| `username` | `TEXT` | Not null, unique | Lowercased account name. |
| `password` | `TEXT` | Not null | bcrypt password hash. |
| `role` | `TEXT` | Not null | User role, for example `user` or `admin`. |
| `two_factor_enabled` | `INTEGER` | Not null, default `0` | Boolean flag for TOTP 2FA state. |
| `two_factor_secret` | `TEXT` | Not null, default `''` | TOTP secret used to validate authenticator codes. Empty when 2FA is disabled. |
* **id (TEXT, PK):** Unique UUID Migration note: existing databases are migrated with `ALTER TABLE` statements for `two_factor_enabled` and `two_factor_secret` if those columns do not exist yet.
* **username (TEXT, Unique):** Unique account identifier.
* **password (TEXT):** Hashed user password.
* **role (TEXT):** Access control flag (e.g., admin, user).
### 2. refresh_tokens ### `refresh_tokens`
Tracks valid extended sessions linked to specific user accounts. Stores refresh-token sessions. Tokens are stored as hashes, not plaintext.
* **id (TEXT, PK):** Unique identifier. | Column | Type | Constraints | Description |
* **user_id (TEXT, FK):** References `users(id)`. | --- | --- | --- | --- |
* **token_hash (TEXT):** Cryptographic hash of the active refresh token. | `id` | `TEXT` | Primary key | Refresh-token row UUID. |
* **expires_at (INTEGER):** Unix timestamp indicating token expiration. | `user_id` | `TEXT` | Not null, foreign key to `users(id)` | Owning user. |
* **created_at (INTEGER):** Unix timestamp indicating session creation. | `token_hash` | `TEXT` | Not null | Hash of the refresh token. |
* **revoked (INTEGER):** Boolean flag (0 or 1) indicating if the session was manually invalidated. | `expires_at` | `INTEGER` | Not null | Unix timestamp for expiry. |
* **device_info (TEXT, Optional):** Client metadata for auditing. | `created_at` | `INTEGER` | Not null | Unix timestamp for creation. |
| `revoked` | `INTEGER` | Not null, default `0` | Boolean revocation flag. |
| `device_info` | `TEXT` | Optional | User-Agent string recorded when the session is created. |
### 3. items Refresh-token rotation revokes the used refresh token and inserts a new row for the next token.
Represents individual tracked assets. ### `two_factor_recovery_codes`
* **id (INTEGER, PK, Autoincrement):** Primary key. Stores recovery-code hashes for 2FA fallback login.
* **name (TEXT):** Asset designation.
* **category (TEXT, Optional):** Grouping classification.
* **description (TEXT, Optional):** Detailed asset context.
* **total_quantity (INTEGER):** Absolute global stock baseline counter.
### 4. locations | Column | Type | Constraints | Description |
| --- | --- | --- | --- |
| `id` | `TEXT` | Primary key | Recovery-code row UUID. |
| `user_id` | `TEXT` | Not null, foreign key to `users(id)` with `ON DELETE CASCADE` | Owning user. |
| `code_hash` | `TEXT` | Not null, unique with `user_id` | Hash of the normalized recovery code. |
| `created_at` | `INTEGER` | Not null | Unix timestamp for creation. |
| `used_at` | `INTEGER` | Nullable | Unix timestamp when the code was consumed. `NULL` means unused. |
Defines logical or physical facilities. Recovery codes are deleted and replaced when the user regenerates them. A recovery code is consumed with an atomic update that only matches unused codes.
* **id (INTEGER, PK, Autoincrement):** Primary key. ### `items`
* **name (TEXT, Unique):** Unique facility naming constraint.
### 5. projects Stores tracked inventory items.
Defines distinct tasks or allocation targets. | Column | Type | Constraints | Description |
| --- | --- | --- | --- |
| `id` | `INTEGER` | Primary key, autoincrement | Item ID. |
| `name` | `TEXT` | Not null | Item name. |
| `category` | `TEXT` | Optional | Category label. |
| `description` | `TEXT` | Optional | Item description. |
| `total_quantity` | `INTEGER` | Not null, default `0` | Global quantity baseline. |
* **id (INTEGER, PK, Autoincrement):** Primary key. ### `locations`
* **name (TEXT, Unique):** Unique operational tracking name.
* **description (TEXT, Optional):** Scope description.
### 6. stock Stores physical or logical storage locations.
Junction table mapping physical asset distributions across facilities. | Column | Type | Constraints | Description |
| --- | --- | --- | --- |
| `id` | `INTEGER` | Primary key, autoincrement | Location ID. |
| `name` | `TEXT` | Not null, unique | Location name. |
* **id (INTEGER, PK, Autoincrement):** Primary key. ### `projects`
* **item_id (INTEGER, FK):** References `items(id)`.
* **location_id (INTEGER, FK):** References `locations(id)`.
* **quantity (INTEGER):** Specific quantity present at this location node.
### 7. project_items Stores project contexts for item allocation.
Junction table tracking asset assignments dedicated to specific ongoing project environments. | Column | Type | Constraints | Description |
| --- | --- | --- | --- |
| `id` | `INTEGER` | Primary key, autoincrement | Project ID. |
| `name` | `TEXT` | Not null, unique | Project name. |
| `description` | `TEXT` | Optional | Project description. |
* **id (INTEGER, PK, Autoincrement):** Primary key. ### `stock`
* **item_id (INTEGER, FK):** References `items(id)`.
* **project_id (INTEGER, FK):** References `projects(id)`.
* **quantity (INTEGER):** Quantity allocated to this project context.
---
## Data Integrity Constraints Maps item quantities to locations.
* **Foreign Keys:** Because standard `ON DELETE` cascades are not defined explicitly in the schema rules, SQLite blocks parent deletion actions if dependent rows exist in `stock` or `project_items`. You must clear out stock allocations and project associations manually before deleting an item, location, or project. | Column | Type | Constraints | Description |
* **Uniqueness:** String uniqueness constraints protect against duplicate namespace registration on `users(username)`, `locations(name)`, and `projects(name)`. | --- | --- | --- | --- |
| `id` | `INTEGER` | Primary key, autoincrement | Stock row ID. |
| `item_id` | `INTEGER` | Not null, foreign key to `items(id)` | Item reference. |
| `location_id` | `INTEGER` | Not null, foreign key to `locations(id)` | Location reference. |
| `quantity` | `INTEGER` | Not null | Quantity at this location. |
### `project_items`
Maps item quantities to projects.
| Column | Type | Constraints | Description |
| --- | --- | --- | --- |
| `id` | `INTEGER` | Primary key, autoincrement | Association row ID. |
| `item_id` | `INTEGER` | Not null, foreign key to `items(id)` | Item reference. |
| `project_id` | `INTEGER` | Not null, foreign key to `projects(id)` | Project reference. |
| `quantity` | `INTEGER` | Not null | Quantity allocated to the project. |
## Data Integrity
### Foreign keys
Foreign keys are enabled per connection. Because most inventory foreign keys do not define explicit cascade behavior, SQLite blocks deletion of referenced items, locations, or projects while dependent rows exist.
`two_factor_recovery_codes.user_id` uses `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code rows.
### Uniqueness
The following values are unique:
- `users.username`
- `locations.name`
- `projects.name`
- `two_factor_recovery_codes(user_id, code_hash)`
### Current schema limitations
The current schema does not yet enforce all business rules at the database level. In particular:
- `items.total_quantity` has no explicit `CHECK(total_quantity >= 0)` constraint.
- `stock.quantity` has no explicit `CHECK(quantity >= 0)` constraint.
- `project_items.quantity` has no explicit `CHECK(quantity >= 0)` constraint.
- Duplicate stock rows for the same `(item_id, location_id)` pair are not prevented by a unique constraint.
- Duplicate project allocations for the same `(item_id, project_id)` pair are not prevented by a unique constraint.
These constraints should be added in a future migration once the desired application behavior is finalized.
## Important Queries and Behaviors
### User lookup
Users can be loaded by lowercased username or ID. Username updates store the new username lowercased.
### Password update
When the password is updated:
1. The new password is bcrypt-hashed.
2. The `users.password` field is updated.
3. Existing refresh tokens for that user are revoked.
4. A new session is issued.
### 2FA enable
When 2FA is enabled:
1. The TOTP secret must already exist from `/api/2fa/setup`.
2. The supplied TOTP code is validated.
3. Existing recovery codes are deleted.
4. New recovery-code hashes are inserted.
5. `users.two_factor_enabled` is set to `1`.
### 2FA disable
When 2FA is disabled:
1. `two_factor_enabled` is set to `0`.
2. `two_factor_secret` is cleared.
3. Recovery-code rows for the user are deleted.
4. Refresh tokens for the user are revoked.
### Recovery-code use
A recovery code is consumed with an update equivalent to:
```sql
UPDATE two_factor_recovery_codes
SET used_at = ?
WHERE user_id = ? AND code_hash = ? AND used_at IS NULL;
```
The login succeeds only if exactly one row is updated.

View File

@@ -39,6 +39,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById('items-table-body')) loadItems(); if (document.getElementById('items-table-body')) loadItems();
if (document.getElementById('locations-table-body')) loadLocations(); if (document.getElementById('locations-table-body')) loadLocations();
if (document.getElementById('projects-table-body')) loadProjects(); if (document.getElementById('projects-table-body')) loadProjects();
if (document.getElementById('account-settings-content')) loadAccountSettings();
loadProfile(); loadProfile();
}); });
@@ -482,3 +483,246 @@ async function loadProfile() {
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>'; username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
} }
} }
// ---- ACCOUNT SETTINGS ----
let latestRecoveryCodes = [];
function showAccountSettingsMessage(message, type = 'success') {
const box = document.getElementById('account-settings-message');
if (!box) return;
box.textContent = message;
box.className = `message ${type}`;
box.style.display = 'block';
}
function setTwoFactorPanels(enabled) {
const badge = document.getElementById('two-factor-badge');
const status = document.getElementById('two-factor-status');
const disabledPanel = document.getElementById('two-factor-disabled-panel');
const enabledPanel = document.getElementById('two-factor-enabled-panel');
if (!badge || !status || !disabledPanel || !enabledPanel) return;
if (enabled) {
badge.textContent = 'Enabled';
badge.classList.add('success');
status.textContent = '2FA is enabled for your account.';
disabledPanel.style.display = 'none';
enabledPanel.style.display = 'block';
} else {
badge.textContent = 'Disabled';
badge.classList.remove('success');
status.textContent = '2FA is disabled. Enable it to protect your account with an authenticator app.';
disabledPanel.style.display = 'block';
enabledPanel.style.display = 'none';
}
}
function renderRecoveryCodes(codes) {
latestRecoveryCodes = codes || [];
const panel = document.getElementById('recovery-codes-panel');
const list = document.getElementById('recovery-codes-list');
if (!panel || !list) return;
if (latestRecoveryCodes.length === 0) {
panel.style.display = 'none';
list.textContent = '';
return;
}
list.textContent = latestRecoveryCodes.join('\n');
panel.style.display = 'block';
}
async function loadAccountSettings() {
try {
const data = await apiRequest('/api/profile');
const usernameInput = document.getElementById('settings-username');
const avatarPreview = document.getElementById('settings-avatar-preview');
const remaining = document.getElementById('recovery-codes-remaining');
if (usernameInput) usernameInput.value = data.username || '';
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
if (remaining) remaining.innerText = data.recovery_codes_remaining || 0;
setTwoFactorPanels(!!data.two_factor_enabled);
renderRecoveryCodes([]);
} catch (err) {
showAccountSettingsMessage(err.message || 'Failed to load account settings.', 'error');
}
}
async function saveAccountUsername(event) {
event.preventDefault();
try {
const data = await apiRequest('/api/account/username', 'POST', {
username: document.getElementById('settings-username').value.trim(),
password: document.getElementById('settings-username-password').value
});
document.getElementById('settings-username-password').value = '';
showAccountSettingsMessage('Username updated.');
const username = document.getElementById('username');
const avatar = document.getElementById('avatar');
const avatarPreview = document.getElementById('settings-avatar-preview');
if (username) username.innerText = data.username;
if (avatar && data.username) avatar.innerText = data.username[0].toLocaleUpperCase();
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not update username.', 'error');
}
}
async function saveAccountPassword(event) {
event.preventDefault();
const currentPassword = document.getElementById('settings-current-password').value;
const newPassword = document.getElementById('settings-new-password').value;
const confirmPassword = document.getElementById('settings-confirm-password').value;
if (newPassword !== confirmPassword) {
showAccountSettingsMessage('New passwords do not match.', 'error');
return;
}
try {
const data = await apiRequest('/api/account/password', 'POST', {
current_password: currentPassword,
new_password: newPassword
});
if (data && data.access_token && data.refresh_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
document.getElementById('password-form').reset();
showAccountSettingsMessage('Password updated. Your session was refreshed.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not update password.', 'error');
}
}
async function startTwoFactorSetup() {
try {
const data = await apiRequest('/api/2fa/setup', 'POST');
const panel = document.getElementById('two-factor-setup-panel');
const qr = document.getElementById('two-factor-qr');
const secret = document.getElementById('two-factor-secret');
const otpauth = document.getElementById('two-factor-otpauth');
if (panel) panel.style.display = 'block';
if (qr) {
qr.src = data.qr_code;
qr.style.display = data.qr_code ? 'block' : 'none';
}
if (secret) secret.textContent = data.secret || '';
if (otpauth) {
otpauth.href = data.otpauth_url || '#';
otpauth.textContent = data.otpauth_url || 'No otpauth URL available';
}
showAccountSettingsMessage('Scan the QR code or enter the setup key manually, then confirm the 6-digit code.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not start 2FA setup.', 'error');
}
}
async function enableTwoFactor(event) {
event.preventDefault();
try {
const data = await apiRequest('/api/2fa/enable', 'POST', {
code: document.getElementById('two-factor-enable-code').value.trim()
});
document.getElementById('two-factor-enable-form').reset();
document.getElementById('two-factor-setup-panel').style.display = 'none';
setTwoFactorPanels(true);
renderRecoveryCodes(data.recovery_codes || []);
const remaining = document.getElementById('recovery-codes-remaining');
if (remaining) remaining.innerText = (data.recovery_codes || []).length;
showAccountSettingsMessage('2FA enabled. Download your recovery codes now.');
loadProfile();
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not enable 2FA.', 'error');
}
}
async function disableTwoFactor(event) {
event.preventDefault();
if (!confirm('Disable 2FA for your account?')) return;
try {
await apiRequest('/api/2fa/disable', 'POST', {
password: document.getElementById('two-factor-disable-password').value,
code: document.getElementById('two-factor-disable-code').value.trim()
});
document.getElementById('two-factor-disable-form').reset();
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
setTwoFactorPanels(false);
renderRecoveryCodes([]);
showAccountSettingsMessage('2FA disabled. Redirecting to login because sessions were revoked.');
setTimeout(() => {
window.location.href = '/login';
}, 1200);
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not disable 2FA.', 'error');
}
}
async function regenerateRecoveryCodes(event) {
event.preventDefault();
if (!confirm('Generate new recovery codes? Existing unused codes will stop working.')) return;
try {
const data = await apiRequest('/api/2fa/recovery-codes/regenerate', 'POST', {
password: document.getElementById('recovery-password').value,
code: document.getElementById('recovery-code').value.trim()
});
document.getElementById('recovery-regenerate-form').reset();
renderRecoveryCodes(data.recovery_codes || []);
const remaining = document.getElementById('recovery-codes-remaining');
if (remaining) remaining.innerText = (data.recovery_codes || []).length;
showAccountSettingsMessage('New recovery codes generated. Download them now.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not regenerate recovery codes.', 'error');
}
}
function downloadRecoveryCodes() {
if (!latestRecoveryCodes || latestRecoveryCodes.length === 0) {
showAccountSettingsMessage('No recovery codes available to download.', 'error');
return;
}
const text = [
'MiauInv recovery codes',
'Save these somewhere safe. Each code can be used once.',
'',
...latestRecoveryCodes,
''
].join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'miauinv-recovery-codes.txt';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}

View File

@@ -2,22 +2,63 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("login-form"); const form = document.getElementById("login-form");
const errorBox = document.getElementById("error"); const errorBox = document.getElementById("error");
const usernameInput = document.getElementById("username");
const passwordInput = document.getElementById("password");
const twoFactorInput = document.getElementById("two-factor-code");
const twoFactorGroup = document.getElementById("two-factor-group");
const submitButton = document.getElementById("login-submit");
let pendingTwoFactorToken = null;
if (!form) return; if (!form) return;
function showError(message) {
errorBox.textContent = message || "Login failed.";
errorBox.style.display = "block";
}
function storeTokens(data) {
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
}
function switchToTwoFactorMode(token) {
pendingTwoFactorToken = token;
usernameInput.disabled = true;
passwordInput.disabled = true;
twoFactorGroup.style.display = "block";
twoFactorInput.required = true;
twoFactorInput.focus();
submitButton.textContent = "Verify code";
}
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
errorBox.style.display = "none"; errorBox.style.display = "none";
submitButton.disabled = true;
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
try { try {
const response = await fetch("/api/login", { let response;
if (pendingTwoFactorToken) {
response = await fetch("/api/login/2fa", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }) body: JSON.stringify({
two_factor_token: pendingTwoFactorToken,
code: twoFactorInput.value.trim()
})
}); });
} else {
response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: usernameInput.value,
password: passwordInput.value
})
});
}
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
@@ -26,17 +67,17 @@ document.addEventListener("DOMContentLoaded", () => {
const data = await response.json(); const data = await response.json();
localStorage.setItem("access_token", data.access_token); if (data.requires_2fa) {
localStorage.setItem("refresh_token", data.refresh_token); switchToTwoFactorMode(data.two_factor_token);
return;
document.cookie = `access_token=${data.access_token}; path=/; max-age=900; SameSite=Lax; Secure`; }
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=604800; SameSite=Lax; Secure`;
storeTokens(data);
window.location.href = "/dashboard"; window.location.href = "/dashboard";
} catch (err) { } catch (err) {
errorBox.textContent = err.message || "Login failed."; showError(err.message);
errorBox.style.display = "block"; } finally {
submitButton.disabled = false;
} }
}); });
}); });

View File

@@ -33,6 +33,10 @@ var projects = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html", "frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/projects.html")) "frontend/htmx/contents/dash/projects.html"))
var accountSettings = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/account_settings.html"))
var home = template.Must(template.ParseFiles("frontend/htmx/home.html")) var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
func Home(w http.ResponseWriter, r *http.Request) { func Home(w http.ResponseWriter, r *http.Request) {
@@ -51,7 +55,6 @@ func Home(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
func Dashboard(w http.ResponseWriter, r *http.Request) { func Dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
@@ -142,6 +145,17 @@ func Projects(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
func AccountSettings(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := accountSettings.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Account Settings",
})
if err != nil {
return
}
}
var minifier *minify.M var minifier *minify.M
@@ -151,7 +165,6 @@ func init() {
minifier.AddFunc("text/css", css.Minify) minifier.AddFunc("text/css", css.Minify)
minifier.AddFunc("text/javascript", js.Minify) minifier.AddFunc("text/javascript", js.Minify)
} }
func Assets(w http.ResponseWriter, r *http.Request) { func Assets(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/assets/") path := strings.TrimPrefix(r.URL.Path, "/assets/")
fullPath := filepath.Join("frontend/assets", path) fullPath := filepath.Join("frontend/assets", path)

View File

@@ -0,0 +1,139 @@
{{ define "content" }}
<div class="page-header">
<h1>Account Settings</h1>
</div>
<div id="account-settings-content">
<div id="account-settings-message" class="message" style="display: none; margin-bottom: 1.5rem;"></div>
<div class="modal-split" style="align-items: start;">
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Profile</h2>
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your username. Avatar upload is planned for later.</p>
<form id="username-form" onsubmit="saveAccountUsername(event)">
<div class="form-group">
<label for="settings-username" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Username</label>
<input type="text" id="settings-username" placeholder="Username" required>
</div>
<div class="form-group">
<label for="settings-username-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Current password</label>
<input type="password" id="settings-username-password" placeholder="Confirm with current password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary">Save username</button>
</form>
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);">
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Avatar</h3>
<div style="display:flex; align-items:center; gap:1rem; color: var(--text-muted);">
<div id="settings-avatar-preview" class="avatar">M</div>
<div>
<div>Avatar upload is not implemented yet.</div>
<div style="font-size:0.85rem; margin-top:0.25rem;">This placeholder keeps the settings layout ready for it.</div>
</div>
</div>
</div>
</div>
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Password</h2>
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your password. You will receive a fresh session afterwards.</p>
<form id="password-form" onsubmit="saveAccountPassword(event)">
<div class="form-group">
<label for="settings-current-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Current password</label>
<input type="password" id="settings-current-password" placeholder="Current password" required autocomplete="current-password">
</div>
<div class="form-group">
<label for="settings-new-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">New password</label>
<input type="password" id="settings-new-password" placeholder="New password" required autocomplete="new-password">
</div>
<div class="form-group">
<label for="settings-confirm-password" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Confirm new password</label>
<input type="password" id="settings-confirm-password" placeholder="Confirm new password" required autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">Change password</button>
</form>
</div>
</div>
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem; margin-top: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;">
<div>
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Two-factor authentication</h2>
<p id="two-factor-status" style="color: var(--text-muted); margin-bottom: 1rem;">Loading 2FA status...</p>
</div>
<span id="two-factor-badge" class="badge">Unknown</span>
</div>
<div id="two-factor-disabled-panel" style="display: none;">
<p style="color: var(--text-muted); margin-bottom: 1rem;">Use an authenticator app. You can scan the QR code or enter the setup key manually.</p>
<button type="button" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;" onclick="startTwoFactorSetup()">Start 2FA setup</button>
<div id="two-factor-setup-panel" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border);">
<div class="modal-split" style="align-items: start;">
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Scan QR code</h3>
<img id="two-factor-qr" alt="2FA QR code" style="display: none; width: 220px; height: 220px; background: white; padding: 0.5rem; border-radius: 12px;">
</div>
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Manual setup</h3>
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">If you do not want to scan the QR code, enter this key manually in your authenticator app.</p>
<code id="two-factor-secret" style="display:block; word-break: break-all; background:#111827; border:1px solid var(--border); border-radius:10px; padding:0.85rem; color:var(--text);"></code>
<a id="two-factor-otpauth" href="#" style="display:block; color: var(--accent); margin-top:0.75rem; word-break: break-all;">Open otpauth URL</a>
</div>
</div>
<form id="two-factor-enable-form" onsubmit="enableTwoFactor(event)" style="margin-top: 1.5rem;">
<div class="form-group">
<label for="two-factor-enable-code" style="display:block; color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem;">Authenticator code</label>
<input type="text" id="two-factor-enable-code" inputmode="numeric" placeholder="123456" required autocomplete="one-time-code">
</div>
<button type="submit" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;">Enable 2FA</button>
</form>
</div>
</div>
<div id="two-factor-enabled-panel" style="display: none;">
<p style="color: var(--text-muted); margin-bottom: 1rem;">Recovery codes remaining: <strong id="recovery-codes-remaining">0</strong></p>
<div id="recovery-codes-panel" style="display: none; margin-bottom: 1.5rem; padding: 1rem; border: 1px solid var(--border); border-radius: 12px; background: #111827;">
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Recovery codes</h3>
<p style="color: var(--text-muted); margin-bottom: 1rem;">Save these now. They are shown only once.</p>
<pre id="recovery-codes-list" style="white-space: pre-wrap; word-break: break-word; color: var(--text); background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 10px; padding: 1rem; margin-bottom: 1rem;"></pre>
<button type="button" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;" onclick="downloadRecoveryCodes()">Download recovery codes</button>
</div>
<div class="modal-split" style="align-items: start;">
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Regenerate recovery codes</h3>
<p style="color: var(--text-muted); margin-bottom: 1rem;">This invalidates all existing recovery codes.</p>
<form id="recovery-regenerate-form" onsubmit="regenerateRecoveryCodes(event)">
<div class="form-group">
<input type="password" id="recovery-password" placeholder="Current password" required autocomplete="current-password">
</div>
<div class="form-group">
<input type="text" id="recovery-code" inputmode="numeric" placeholder="Authenticator code" required autocomplete="one-time-code">
</div>
<button type="submit" class="btn btn-secondary">Generate new recovery codes</button>
</form>
</div>
<div>
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Disable 2FA</h3>
<p style="color: var(--text-muted); margin-bottom: 1rem;">Disabling 2FA revokes your active refresh sessions.</p>
<form id="two-factor-disable-form" onsubmit="disableTwoFactor(event)">
<div class="form-group">
<input type="password" id="two-factor-disable-password" placeholder="Current password" required autocomplete="current-password">
</div>
<div class="form-group">
<input type="text" id="two-factor-disable-code" inputmode="numeric" placeholder="Authenticator code" required autocomplete="one-time-code">
</div>
<button type="submit" class="btn btn-secondary danger-btn">Disable 2FA</button>
</form>
</div>
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -24,12 +24,18 @@
<input type="text" id="username" placeholder="Username" autocomplete="username" required> <input type="text" id="username" placeholder="Username" autocomplete="username" required>
</div> </div>
<div class="form-group"> <div class="form-group" id="password-group">
<label for="password" class="sr-only">Password</label> <label for="password" class="sr-only">Password</label>
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required> <input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
</div> </div>
<button type="submit" class="btn btn-primary">Sign In</button> <div class="form-group" id="two-factor-group" style="display: none;">
<label for="two-factor-code" class="sr-only">2FA code</label>
<input type="text" id="two-factor-code" placeholder="Authenticator or recovery code" autocomplete="one-time-code" inputmode="text" pattern="[0-9A-Za-z\- ]*">
<p class="subtitle" style="margin-top: 0.75rem;">Enter your 6-digit authenticator code or one recovery code.</p>
</div>
<button type="submit" id="login-submit" class="btn btn-primary">Sign In</button>
</form> </form>
<div id="error" class="message error"></div> <div id="error" class="message error"></div>

20
go.mod
View File

@@ -5,20 +5,22 @@ go 1.26
require ( require (
github.com/glebarez/go-sqlite v1.22.0 github.com/glebarez/go-sqlite v1.22.0
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.5.0 github.com/google/uuid v1.6.0
github.com/pquerna/otp v1.5.0
github.com/tdewolff/minify/v2 v2.24.13 github.com/tdewolff/minify/v2 v2.24.13
golang.org/x/crypto v0.52.0 golang.org/x/crypto v0.53.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/boombuler/barcode v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect github.com/tdewolff/parse/v2 v2.8.13 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.46.0 // indirect
modernc.org/libc v1.37.6 // indirect modernc.org/libc v1.73.0 // indirect
modernc.org/mathutil v1.6.0 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.7.2 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.28.0 // indirect modernc.org/sqlite v1.52.0 // indirect
) )

34
go.sum
View File

@@ -1,3 +1,9 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
@@ -6,33 +12,61 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58= github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0= github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg= github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo= github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/libc v1.73.0 h1:Y/KmTxbIN5T3x+NFjYOzV/+Ha7wKClfIecmTCTuYlqQ=
modernc.org/libc v1.73.0/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=

View File

@@ -2,19 +2,23 @@ package handlers
import ( import (
"MiauInv/auth" "MiauInv/auth"
"MiauInv/config"
"MiauInv/models" "MiauInv/models"
"MiauInv/storage" "MiauInv/storage"
"MiauInv/util" utils "MiauInv/util"
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"image/png"
"log" "log"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"time" "time"
)
var cfg, _ = config.LoadConfig() "github.com/pquerna/otp/totp"
)
func APIRegister(w http.ResponseWriter, r *http.Request) { func APIRegister(w http.ResponseWriter, r *http.Request) {
var user models.User var user models.User
@@ -86,76 +90,407 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
return return
} }
accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret) if user.TwoFactorEnabled {
twoFactorToken, err := auth.GeneratePurposeJWT(user.ID, "2fa_login", secret, 5*time.Minute)
if err != nil { if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate token", http.StatusInternalServerError) http.Error(w, "Could not generate 2FA challenge", http.StatusInternalServerError)
return return
} }
refreshTokenPlain, err := utils.GenerateRefreshToken() writeJSON(w, http.StatusOK, map[string]interface{}{
if err != nil { "requires_2fa": true,
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error()) "two_factor_token": twoFactorToken,
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
return
}
refreshHash := utils.HashToken(refreshTokenPlain)
refreshID := utils.GenerateUUID()
refreshExpires := time.Now().Add(7 * 24 * time.Hour).Unix() // expiry: 7 days
deviceInfo := r.Header.Get("User-Agent")
if err := storage.AddRefreshToken(&models.RefreshToken{
ID: refreshID,
UserID: user.ID,
Token: refreshHash,
ExpiresAt: refreshExpires,
DeviceInfo: deviceInfo,
CreatedAt: time.Now().Unix(),
Revoked: false,
}); err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not save refresh token", http.StatusInternalServerError)
return
}
// Return access + refresh token (refresh in plain for client to store securely)
resp := map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshTokenPlain,
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"role": user.Role,
},
}
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: accessToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
}) })
http.SetCookie(w, &http.Cookie{ log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
Name: "refresh_token",
Value: refreshTokenPlain,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(resp)
if err != nil {
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Something went wrong", http.StatusInternalServerError)
return return
} }
issueLoginSession(w, r, user)
log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in") log.Println("POST [api/login] " + r.RemoteAddr + ": Successfully logged in")
} }
func APILoginTwoFactor(w http.ResponseWriter, r *http.Request) {
var req struct {
TwoFactorToken string `json:"two_factor_token"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
secret := []byte(os.Getenv("JWT_SECRET"))
claims, err := auth.ValidatePurposeJWT(req.TwoFactorToken, "2fa_login", secret)
if err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid or expired 2FA challenge", http.StatusUnauthorized)
return
}
user, err := storage.GetUserById(claims.UserID)
if err != nil || !user.TwoFactorEnabled || user.TwoFactorSecret == "" {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": 2FA not available for user")
http.Error(w, "Invalid 2FA state", http.StatusUnauthorized)
return
}
code := strings.TrimSpace(req.Code)
validTOTP := totp.Validate(code, user.TwoFactorSecret)
usedRecoveryCode := false
if !validTOTP {
recoveryCodeHash := utils.HashToken(normalizeRecoveryCode(code))
usedRecoveryCode, err = storage.UseUserRecoveryCode(user.ID, recoveryCodeHash)
if err != nil {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not validate recovery code", http.StatusInternalServerError)
return
}
}
if !validTOTP && !usedRecoveryCode {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Invalid 2FA or recovery code")
http.Error(w, "Invalid 2FA or recovery code", http.StatusUnauthorized)
return
}
issueLoginSession(w, r, user)
if usedRecoveryCode {
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with recovery code")
return
}
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with 2FA")
}
func AccountUpdateUsername(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
username := strings.TrimSpace(req.Username)
if username == "" || req.Password == "" {
http.Error(w, "Username and password required", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if err := storage.UpdateUserUsername(user.ID, username); err != nil {
log.Println("POST [api/account/username] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Username already exists or could not be saved", http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"username": strings.ToLower(username),
})
log.Println("POST [api/account/username] " + r.RemoteAddr + ": Updated username")
}
func AccountUpdatePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if req.CurrentPassword == "" || req.NewPassword == "" {
http.Error(w, "Current and new password required", http.StatusBadRequest)
return
}
if len(req.NewPassword) > 72 {
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
return
}
if req.CurrentPassword == req.NewPassword {
http.Error(w, "New password must be different", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.CurrentPassword, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
hashed, err := auth.HashPassword(req.NewPassword)
if err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not hash password", http.StatusInternalServerError)
return
}
if err := storage.UpdateUserPassword(user.ID, hashed); err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not update password", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/account/password] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
return
}
user.Password = hashed
issueLoginSession(w, r, user)
log.Println("POST [api/account/password] " + r.RemoteAddr + ": Updated password")
}
func TwoFactorSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if user.TwoFactorEnabled {
http.Error(w, "2FA is already enabled", http.StatusConflict)
return
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "MiauInv",
AccountName: user.Username,
SecretSize: 20,
})
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate 2FA secret", http.StatusInternalServerError)
return
}
if err := storage.SetUserTwoFactorSecret(user.ID, key.Secret()); err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save 2FA secret", http.StatusInternalServerError)
return
}
img, err := key.Image(220, 220)
if err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate QR code", http.StatusInternalServerError)
return
}
var qr bytes.Buffer
if err := png.Encode(&qr, img); err != nil {
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not encode QR code", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"secret": key.Secret(),
"otpauth_url": key.URL(),
"qr_code": "data:image/png;base64," + base64.StdEncoding.EncodeToString(qr.Bytes()),
})
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Created 2FA setup challenge")
}
func TwoFactorEnable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if user.TwoFactorSecret == "" {
http.Error(w, "2FA setup has not been started", http.StatusBadRequest)
return
}
if !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
recoveryCodes, recoveryCodeHashes, err := generateRecoveryCodes(10)
if err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate recovery codes", http.StatusInternalServerError)
return
}
if err := storage.EnableUserTwoFactorWithRecoveryCodes(user.ID, recoveryCodeHashes); err != nil {
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not enable 2FA", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"two_factor_enabled": true,
"recovery_codes": recoveryCodes,
})
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA and generated recovery codes")
}
func TwoFactorDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if user.TwoFactorEnabled && !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
if err := storage.DisableUserTwoFactor(user.ID); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not disable 2FA", http.StatusInternalServerError)
return
}
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not revoke sessions", http.StatusInternalServerError)
return
}
clearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]interface{}{"two_factor_enabled": false})
log.Println("POST [api/2fa/disable] " + r.RemoteAddr + ": Disabled 2FA")
}
func TwoFactorRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Password string `json:"password"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
user, err := storage.GetUserById(claims.UserID)
if err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "User not found", http.StatusNotFound)
return
}
if !user.TwoFactorEnabled || user.TwoFactorSecret == "" {
http.Error(w, "2FA is not enabled", http.StatusBadRequest)
return
}
if !auth.CheckPasswordHash(req.Password, user.Password) {
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
if !totp.Validate(strings.TrimSpace(req.Code), user.TwoFactorSecret) {
http.Error(w, "Invalid 2FA code", http.StatusUnauthorized)
return
}
recoveryCodes, recoveryCodeHashes, err := generateRecoveryCodes(10)
if err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate recovery codes", http.StatusInternalServerError)
return
}
if err := storage.ReplaceUserRecoveryCodes(user.ID, recoveryCodeHashes); err != nil {
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not save recovery codes", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"recovery_codes": recoveryCodes,
})
log.Println("POST [api/2fa/recovery-codes/regenerate] " + r.RemoteAddr + ": Regenerated recovery codes")
}
func Logout(w http.ResponseWriter, r *http.Request) { func Logout(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims) claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
err := storage.RevokeAllRefreshTokensForUser(claims.UserID) err := storage.RevokeAllRefreshTokensForUser(claims.UserID)
@@ -164,7 +499,8 @@ func Logout(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
w.WriteHeader(204) clearAuthCookies(w)
w.WriteHeader(http.StatusNoContent)
} }
func TestHandler(w http.ResponseWriter, r *http.Request) { func TestHandler(w http.ResponseWriter, r *http.Request) {
claims, _ := utils.IsLoggedIn(w, r) claims, _ := utils.IsLoggedIn(w, r)
@@ -185,9 +521,19 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error()) if r.Body != nil {
http.Error(w, "Invalid request", http.StatusBadRequest) _ = json.NewDecoder(r.Body).Decode(&req)
}
if req.RefreshToken == "" {
cookie, err := r.Cookie("refresh_token")
if err == nil {
req.RefreshToken = cookie.Value
}
}
if req.RefreshToken == "" {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Missing refresh token")
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
return return
} }
@@ -204,41 +550,14 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
log.Println(err) log.Println(err)
} }
newToken, _ := utils.GenerateRefreshToken()
newHash := utils.HashToken(newToken)
newExpires := time.Now().Add(7 * 24 * time.Hour).Unix() //7 days
newID := utils.GenerateUUID()
deviceInfo := r.Header.Get("User-Agent")
if err = storage.AddRefreshToken(&models.RefreshToken{
ID: newID,
UserID: tokenRow.UserID,
Token: newHash,
ExpiresAt: newExpires,
CreatedAt: time.Now().Unix(),
Revoked: false,
DeviceInfo: deviceInfo,
}); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate new refresh token", http.StatusInternalServerError)
return
}
user, err := storage.GetUserById(tokenRow.UserID) user, err := storage.GetUserById(tokenRow.UserID)
if err != nil { if err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error()) log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
accessToken, _ := auth.GenerateJWT(tokenRow.UserID, user.Role, []byte(os.Getenv("JWT_SECRET")))
if err = json.NewEncoder(w).Encode(map[string]string{ issueLoginSession(w, r, user)
"access_token": accessToken,
"refresh_token": newToken,
}); err != nil {
log.Println("POST [api/refresh] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token") log.Println("POST [api/refresh] " + r.RemoteAddr + ": Successfully refreshed token")
} }
func UserInfo(w http.ResponseWriter, r *http.Request) { func UserInfo(w http.ResponseWriter, r *http.Request) {
@@ -287,11 +606,21 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "User not found", http.StatusNotFound) http.Error(w, "User not found", http.StatusNotFound)
return return
} }
recoveryCodesRemaining := 0
if user.TwoFactorEnabled {
if count, err := storage.CountUnusedRecoveryCodes(user.ID); err == nil {
recoveryCodesRemaining = count
}
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]interface{}{ err = json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID, "id": user.ID,
"username": user.Username, "username": user.Username,
"avatar_url": "", "avatar_url": "",
"two_factor_enabled": user.TwoFactorEnabled,
"recovery_codes_remaining": recoveryCodesRemaining,
}) })
if err != nil { if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error()) log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
@@ -299,3 +628,133 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
} }
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")") log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
} }
func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) {
secret := []byte(os.Getenv("JWT_SECRET"))
if len(secret) == 0 {
log.Println("AUTH " + r.RemoteAddr + ": Server misconfiguration")
http.Error(w, "Server misconfiguration", http.StatusInternalServerError)
return
}
accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret)
if err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Could not generate token", http.StatusInternalServerError)
return
}
refreshTokenPlain, err := utils.GenerateRefreshToken()
if err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
return
}
refreshExpires := time.Now().Add(7 * 24 * time.Hour).Unix()
if err := storage.AddRefreshToken(&models.RefreshToken{
ID: utils.GenerateUUID(),
UserID: user.ID,
Token: utils.HashToken(refreshTokenPlain),
ExpiresAt: refreshExpires,
DeviceInfo: r.Header.Get("User-Agent"),
CreatedAt: time.Now().Unix(),
Revoked: false,
}); err != nil {
log.Println("AUTH " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "could not save refresh token", http.StatusInternalServerError)
return
}
setAuthCookies(w, accessToken, refreshTokenPlain)
writeJSON(w, http.StatusOK, map[string]interface{}{
"access_token": accessToken,
"refresh_token": refreshTokenPlain,
"user": map[string]interface{}{
"id": user.ID,
"username": user.Username,
"role": user.Role,
"two_factor_enabled": user.TwoFactorEnabled,
},
})
}
func generateRecoveryCodes(count int) ([]string, []string, error) {
codes := make([]string, 0, count)
hashes := make([]string, 0, count)
seen := make(map[string]struct{}, count)
for len(codes) < count {
code, err := generateRecoveryCode()
if err != nil {
return nil, nil, err
}
normalized := normalizeRecoveryCode(code)
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
codes = append(codes, code)
hashes = append(hashes, utils.HashToken(normalized))
}
return codes, hashes, nil
}
func generateRecoveryCode() (string, error) {
bytes := make([]byte, 10)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
raw := hex.EncodeToString(bytes)
return raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20], nil
}
func normalizeRecoveryCode(code string) string {
code = strings.TrimSpace(code)
code = strings.ReplaceAll(code, "-", "")
code = strings.ReplaceAll(code, " ", "")
return strings.ToLower(code)
}
func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) {
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: accessToken,
Path: "/",
MaxAge: 15 * 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refreshToken,
Path: "/",
MaxAge: 7 * 24 * 60 * 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func clearAuthCookies(w http.ResponseWriter) {
for _, name := range []string{"access_token", "refresh_token"} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Println("JSON response error: " + err.Error())
}
}

View File

@@ -5,4 +5,6 @@ type User struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Role string `json:"role"` Role string `json:"role"`
TwoFactorEnabled bool `json:"two_factor_enabled"`
TwoFactorSecret string `json:"-"`
} }

View File

@@ -71,6 +71,7 @@ func (this *Server) Run() {
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items))) mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations))) mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects))) mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
mux.Handle("/profile/settings", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.AccountSettings)))
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html")) mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
if this.AllowRegistration { if this.AllowRegistration {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html")) mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
@@ -82,10 +83,17 @@ func (this *Server) Run() {
// API // API
// //
mux.HandleFunc("/api/login", handlers.APILogin) mux.HandleFunc("/api/login", handlers.APILogin)
mux.HandleFunc("/api/login/2fa", handlers.APILoginTwoFactor)
mux.HandleFunc("/api/refresh", handlers.RefreshToken) mux.HandleFunc("/api/refresh", handlers.RefreshToken)
mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout))) mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout)))
mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo))) mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.HandleFunc("/api/userinfo", handlers.UserInfo) mux.Handle("/api/2fa/setup", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorSetup)))
mux.Handle("/api/2fa/enable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorEnable)))
mux.Handle("/api/2fa/disable", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable)))
mux.Handle("/api/2fa/recovery-codes/regenerate", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorRegenerateRecoveryCodes)))
mux.Handle("/api/userinfo", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.Handle("/api/account/username", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername)))
mux.Handle("/api/account/password", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword)))
if this.AllowRegistration { if this.AllowRegistration {
mux.HandleFunc("/api/register", handlers.APIRegister) mux.HandleFunc("/api/register", handlers.APIRegister)
} }

View File

@@ -2,10 +2,12 @@ package storage
import ( import (
"MiauInv/models" "MiauInv/models"
utils "MiauInv/util"
"database/sql" "database/sql"
"errors" "errors"
"log" "log"
"strings" "strings"
"time"
_ "github.com/glebarez/go-sqlite" _ "github.com/glebarez/go-sqlite"
) )
@@ -27,7 +29,9 @@ func InitDB(filepath string) error {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL, password TEXT NOT NULL,
role TEXT NOT NULL role TEXT NOT NULL,
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
two_factor_secret TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE TABLE IF NOT EXISTS refresh_tokens (
@@ -41,6 +45,16 @@ func InitDB(filepath string) error {
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
); );
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
code_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
used_at INTEGER DEFAULT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, code_hash)
);
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -84,7 +98,26 @@ func InitDB(filepath string) error {
log.Fatal(err) log.Fatal(err)
} }
if err := ensureUserTwoFactorColumns(); err != nil {
return err return err
}
return nil
}
func ensureUserTwoFactorColumns() error {
migrations := []string{
"ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN two_factor_secret TEXT NOT NULL DEFAULT ''",
}
for _, migration := range migrations {
_, err := DB.Exec(migration)
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return err
}
}
return nil
} }
// Users // Users
@@ -93,18 +126,161 @@ func AddUser(user *models.User) error {
return err return err
} }
func GetUserByUsername(username string) (models.User, error) { func GetUserByUsername(username string) (models.User, error) {
row := DB.QueryRow("SELECT * FROM users WHERE username = ?", strings.ToLower(username)) row := DB.QueryRow(`
var user models.User SELECT id, username, password, role, two_factor_enabled, two_factor_secret
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role) FROM users
return user, err WHERE username = ?
`, strings.ToLower(username))
return scanUser(row)
} }
func GetUserById(id string) (models.User, error) { func GetUserById(id string) (models.User, error) {
row := DB.QueryRow("SELECT * FROM users WHERE id = ?", id) row := DB.QueryRow(`
SELECT id, username, password, role, two_factor_enabled, two_factor_secret
FROM users
WHERE id = ?
`, id)
return scanUser(row)
}
func scanUser(row *sql.Row) (models.User, error) {
var user models.User var user models.User
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role) var twoFactorEnabled int
err := row.Scan(&user.ID, &user.Username, &user.Password, &user.Role, &twoFactorEnabled, &user.TwoFactorSecret)
user.TwoFactorEnabled = twoFactorEnabled == 1
return user, err return user, err
} }
func UpdateUserUsername(userID, username string) error {
res, err := DB.Exec("UPDATE users SET username = ? WHERE id = ?", strings.ToLower(username), userID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
func UpdateUserPassword(userID, passwordHash string) error {
res, err := DB.Exec("UPDATE users SET password = ? WHERE id = ?", passwordHash, userID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
func SetUserTwoFactorSecret(userID, secret string) error {
_, err := DB.Exec("UPDATE users SET two_factor_secret = ? WHERE id = ?", secret, userID)
return err
}
func EnableUserTwoFactorWithRecoveryCodes(userID string, recoveryCodeHashes []string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
now := time.Now().Unix()
for _, codeHash := range recoveryCodeHashes {
if _, err := tx.Exec(`
INSERT INTO two_factor_recovery_codes(id, user_id, code_hash, created_at)
VALUES (?, ?, ?, ?)
`, utils.GenerateUUID(), userID, codeHash, now); err != nil {
return err
}
}
if _, err := tx.Exec("UPDATE users SET two_factor_enabled = 1 WHERE id = ?", userID); err != nil {
return err
}
return tx.Commit()
}
func DisableUserTwoFactor(userID string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("UPDATE users SET two_factor_enabled = 0, two_factor_secret = '' WHERE id = ?", userID); err != nil {
return err
}
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
return tx.Commit()
}
func ReplaceUserRecoveryCodes(userID string, recoveryCodeHashes []string) error {
tx, err := DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM two_factor_recovery_codes WHERE user_id = ?", userID); err != nil {
return err
}
now := time.Now().Unix()
for _, codeHash := range recoveryCodeHashes {
if _, err := tx.Exec(`
INSERT INTO two_factor_recovery_codes(id, user_id, code_hash, created_at)
VALUES (?, ?, ?, ?)
`, utils.GenerateUUID(), userID, codeHash, now); err != nil {
return err
}
}
return tx.Commit()
}
func UseUserRecoveryCode(userID, codeHash string) (bool, error) {
res, err := DB.Exec(`
UPDATE two_factor_recovery_codes
SET used_at = ?
WHERE user_id = ? AND code_hash = ? AND used_at IS NULL
`, time.Now().Unix(), userID, codeHash)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n == 1, nil
}
func CountUnusedRecoveryCodes(userID string) (int, error) {
var count int
err := DB.QueryRow(`
SELECT COUNT(*)
FROM two_factor_recovery_codes
WHERE user_id = ? AND used_at IS NULL
`, userID).Scan(&count)
return count, err
}
// Refresh Tokens // Refresh Tokens
func AddRefreshToken(token *models.RefreshToken) error { func AddRefreshToken(token *models.RefreshToken) error {
_, err := DB.Exec("INSERT INTO refresh_tokens(id, user_id, token_hash, expires_at, created_at, revoked, device_info) VALUES (?, ?, ?, ?, ?, ?, ?)", _, err := DB.Exec("INSERT INTO refresh_tokens(id, user_id, token_hash, expires_at, created_at, revoked, device_info) VALUES (?, ?, ?, ?, ?, ?, ?)",