Compare commits
24 Commits
92e7ea4667
...
feature/5-
| Author | SHA1 | Date | |
|---|---|---|---|
|
58f098d4ca
|
|||
|
ae41b96fa4
|
|||
|
fabe5319ae
|
|||
|
e2926df62c
|
|||
|
ea8ea45c4c
|
|||
|
5485fd135d
|
|||
|
5558d42bdb
|
|||
|
b74df36bda
|
|||
|
918b9a6b74
|
|||
|
6d32ca13ca
|
|||
|
feffff0898
|
|||
|
5089f94a21
|
|||
|
f5f5da51c8
|
|||
|
089998d45b
|
|||
|
e2d51c6cdf
|
|||
|
0f8c7f57ac
|
|||
|
8d2be395b9
|
|||
|
339d5a709c
|
|||
|
405502cb20
|
|||
|
1a454dd201
|
|||
|
15cc33eb23
|
|||
|
f367188c08
|
|||
|
dcc6dc0b98
|
|||
|
d771ebba13
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@ appdata
|
|||||||
.idea
|
.idea
|
||||||
*.exe
|
*.exe
|
||||||
*.cmd
|
*.cmd
|
||||||
.run
|
.run
|
||||||
|
*.out
|
||||||
@@ -11,9 +11,15 @@ COPY . .
|
|||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://git.miaurizius.de/miaurizius/miauinv"
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||||
go build -ldflags "-s -w" -o MiauInv .
|
go build -ldflags "-s -w" -o MiauInv .
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|
||||||
COPY --from=builder /app/MiauInv /MiauInv
|
COPY --from=builder /app/MiauInv /MiauInv
|
||||||
|
|
||||||
|
COPY --from=builder /app/frontend /frontend
|
||||||
|
|
||||||
ENTRYPOINT ["/MiauInv"]
|
ENTRYPOINT ["/MiauInv"]
|
||||||
324
README.md
Normal file
324
README.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# MiauInv
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Inventory and allocation
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Account and authentication
|
||||||
|
|
||||||
|
- User registration, if enabled in the configuration.
|
||||||
|
- Password hashing with bcrypt.
|
||||||
|
- Signed JWT access tokens.
|
||||||
|
- Database-backed refresh tokens with rotation.
|
||||||
|
- HTTP-only secure cookies for access and refresh tokens.
|
||||||
|
- 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-code count warnings in account settings.
|
||||||
|
- Recovery codes are one-time use.
|
||||||
|
- The setup secret is only stored after the first valid authenticator code.
|
||||||
|
- Existing refresh sessions are revoked when 2FA is enabled or disabled.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
MiauInv is an active private project. The current version supports core inventory workflows and account-level security settings. Some areas are intentionally still basic:
|
||||||
|
|
||||||
|
- Avatar support is currently only represented by a placeholder in the UI.
|
||||||
|
- There is no dedicated admin panel yet.
|
||||||
|
- Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints.
|
||||||
|
- Automated testing is currently limited and will be expanded in future releases.
|
||||||
|
- 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)
|
||||||
|
- [Security](docs/SECURITY.md)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
MiauInv reads `./appdata/config.yaml`. If the file does not exist, the application creates a default configuration on startup.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
port: "8080"
|
||||||
|
database_path: ./appdata/database.db
|
||||||
|
certificate_path: ./appdata/cert.pem
|
||||||
|
private_key_path: ./appdata/key.pem
|
||||||
|
allow_registration: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration fields
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/` | `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
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/register` | `POST` | No | Creates a user if registration is enabled. |
|
||||||
|
| `/api/login` | `POST` | No | Validates username/password. Returns a full session if 2FA is disabled; otherwise returns a short-lived 2FA challenge token. |
|
||||||
|
| `/api/login/2fa` | `POST` | No | Completes login using the 2FA challenge token plus either a TOTP code or a recovery code. |
|
||||||
|
| `/api/refresh` | `POST` | No | Rotates a refresh token. Accepts the token from JSON or the `refresh_token` cookie. |
|
||||||
|
| `/api/logout` | `POST` | Yes | Revokes refresh tokens for the current user and clears auth cookies. |
|
||||||
|
| `/api/profile` | `GET` | Yes | Returns current user metadata, 2FA state, and unused recovery-code count. |
|
||||||
|
| `/api/userinfo` | `GET` | Yes | Same user information handler as `/api/profile`. |
|
||||||
|
| `/api/account/username` | `POST` | Yes | Changes the current username after password confirmation. |
|
||||||
|
| `/api/account/password` | `POST` | Yes | Changes the current password, revokes old refresh tokens, and issues a new session. |
|
||||||
|
| `/api/2fa/setup` | `POST` | Yes | Creates a pending TOTP secret and returns `secret`, `setup_token`, `otpauth_url`, and a base64 PNG QR code. |
|
||||||
|
| `/api/2fa/enable` | `POST` | Yes | Enables 2FA after validating the temporary setup token and a TOTP code. Replaces recovery codes and revokes old sessions. |
|
||||||
|
| `/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. |
|
||||||
|
|
||||||
|
### Inventory API
|
||||||
|
|
||||||
|
| Endpoint | Method | Auth required | Query parameters | Description |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `/api/item` | `GET` | Yes | `id` optional | Returns all items with aggregate quantities, or one item by ID. |
|
||||||
|
| `/api/item` | `POST` | Yes | None | Creates an item. |
|
||||||
|
| `/api/item` | `PUT` | Yes | `id` required | Updates item fields. |
|
||||||
|
| `/api/item` | `DELETE` | Yes | `id` required | Deletes an item if no dependent rows block it. |
|
||||||
|
| `/api/location` | `GET` | Yes | `id`, `content` optional | Returns all locations, one location, or location contents with `content=true`. |
|
||||||
|
| `/api/location` | `POST` | Yes | None | Creates a location. |
|
||||||
|
| `/api/location` | `PUT` | Yes | `id` required | Renames a location. |
|
||||||
|
| `/api/location` | `DELETE` | Yes | `id` required | Deletes a location if no dependent rows block it. |
|
||||||
|
| `/api/project` | `GET` | Yes | `id`, `details` optional | Returns all projects, one project, or project allocations with `details=true`. |
|
||||||
|
| `/api/project` | `POST` | Yes | None | Creates a project. |
|
||||||
|
| `/api/project` | `PUT` | Yes | `id` required | Updates a project. |
|
||||||
|
| `/api/project` | `DELETE` | Yes | `id` required | Deletes a project if no dependent rows block it. |
|
||||||
|
| `/api/stock` | `GET` | Yes | `id` optional | Returns stock entries. |
|
||||||
|
| `/api/stock` | `POST` | Yes | None | Creates a stock entry. |
|
||||||
|
| `/api/stock` | `PUT` | Yes | `id` required | Updates a stock entry. |
|
||||||
|
| `/api/stock` | `DELETE` | Yes | `id` required | Deletes a stock entry. |
|
||||||
|
| `/api/association` | `GET` | Yes | `id`, `project_id` optional | Returns project-item associations. |
|
||||||
|
| `/api/association` | `POST` | Yes | None | Allocates item quantity to a project. |
|
||||||
|
| `/api/association` | `PUT` | Yes | `id` required | Updates a project allocation. |
|
||||||
|
| `/api/association` | `DELETE` | Yes | `id` required | Removes a project allocation. |
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- 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
|
||||||
|
mkdir -p appdata
|
||||||
|
openssl req -x509 -newkey rsa:4096 \
|
||||||
|
-keyout appdata/key.pem \
|
||||||
|
-out appdata/cert.pem \
|
||||||
|
-days 365 \
|
||||||
|
-nodes \
|
||||||
|
-subj "/CN=localhost"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Native local run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JWT_SECRET="replace-this-with-a-random-secret-of-at-least-32-chars"
|
||||||
|
go mod tidy
|
||||||
|
go build -o miauinv .
|
||||||
|
./miauinv
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
The repository contains a multi-stage Dockerfile. The final image is based on `scratch` and contains the compiled binary plus frontend assets.
|
||||||
|
|
||||||
|
Example `docker-compose.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
miauinv:
|
||||||
|
image: git.miaurizius.de/miaurizius/miauinv:latest
|
||||||
|
container_name: MiauInv
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- JWT_SECRET=replace-this-with-a-random-secret-of-at-least-32-chars
|
||||||
|
volumes:
|
||||||
|
- ./appdata:/appdata
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the container:
|
||||||
|
|
||||||
|
```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
|
||||||
|
inv.example.com {
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
reverse_proxy https://miauinv:8080 {
|
||||||
|
transport http {
|
||||||
|
tls_insecure_skip_verify
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For Docker deployments, place Caddy and MiauInv on the same Docker network and reverse proxy to the service name.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- JWTs are signed, not encrypted. Normal access and purpose tokens must not contain secrets. The temporary 2FA setup token is a narrow exception because it carries the not-yet-enabled TOTP secret back to the authenticated browser until confirmation.
|
||||||
|
- `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. The UI warns when few unused codes remain.
|
||||||
|
- 2FA disable and recovery-code regeneration require both the current password and a valid TOTP code.
|
||||||
|
- Basic in-memory rate limiting is enabled for login, 2FA, refresh, registration, and sensitive account endpoints. Use persistent or distributed rate limiting for multi-instance deployments.
|
||||||
|
- Automated testing is currently limited. Authentication, 2FA, recovery codes, rate limiting, account settings, and inventory handlers should be covered before production use.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
#### Dashboard
|
||||||
|
<img src="docs/img/dashboard.png">
|
||||||
|
|
||||||
|
#### Inventory
|
||||||
|
<img src="docs/img/inventory.png">
|
||||||
|
|
||||||
|
#### Locations
|
||||||
|
<img src="docs/img/locations.png">
|
||||||
|
|
||||||
|
#### Projects
|
||||||
|
<img src="docs/img/projects.png">
|
||||||
407
ad.html
Normal file
407
ad.html
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MiauInv - Modern Inventory & Project Management</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b0f19;
|
||||||
|
--card: #111827;
|
||||||
|
--border: #1f2937;
|
||||||
|
--text: #f9fafb;
|
||||||
|
--text-muted: #9ca3af;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--success: #10b981;
|
||||||
|
--code-bg: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switch {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-switch:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem 4rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto 2.5rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.green {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
border-color: rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list {
|
||||||
|
list-style: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list li {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list li strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-section {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: left;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-section h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-section p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-num {
|
||||||
|
background: var(--border);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e5e7eb;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sprach-Sichtbarkeitsregeln */
|
||||||
|
[lang="de"] { display: none; }
|
||||||
|
html[data-lang="de"] [lang="de"] { display: block; }
|
||||||
|
html[data-lang="de"] [lang="en"] { display: none; }
|
||||||
|
|
||||||
|
html[data-lang="de"] span[lang="de"],
|
||||||
|
html[data-lang="de"] strong[lang="de"] {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
html[data-lang="en"] span[lang="en"],
|
||||||
|
html[data-lang="en"] strong[lang="en"] {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="lang-en">
|
||||||
|
|
||||||
|
<div class="navbar">
|
||||||
|
<button class="lang-switch" id="langBtn" onclick="toggleLanguage()">DE</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div class="brand">Miau<span>Inv</span></div>
|
||||||
|
<p class="tagline" lang="en">A secure, light-weight, and minimalistic management system for tracking your inventory stock and project allocations.</p>
|
||||||
|
<p class="tagline" lang="de">Ein sicheres, pfeilschnelles und minimalistisches System für deine Lagerbestände und Projekt-Zuweisungen.</p>
|
||||||
|
|
||||||
|
<div class="badge-container">
|
||||||
|
<span class="badge">Go Backend</span>
|
||||||
|
<span class="badge">HTMX Frontend</span>
|
||||||
|
<span class="badge">SQLite Inside</span>
|
||||||
|
<span class="badge green">Docker Ready</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2 lang="en">Why MiauInv?</h2>
|
||||||
|
<h2 lang="de">Warum MiauInv?</h2>
|
||||||
|
<ul class="features-list">
|
||||||
|
<li>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
<span>
|
||||||
|
<strong lang="en">Zero JS-Overhead:</strong><strong lang="de">Kein JS-Overhead:</strong>
|
||||||
|
<span lang="en"> Powered by HTMX for an ultra-reactive, dynamic SPA experience without heavy client-side bundles.</span>
|
||||||
|
<span lang="de"> Dank HTMX extrem reaktiv und dynamisch wie eine SPA, aber ohne tonnenweise schweres JavaScript.</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
<span>
|
||||||
|
<strong lang="en">Live Quantities:</strong><strong lang="de">Live-Verfügbarkeit:</strong>
|
||||||
|
<span lang="en"> See overall totals, allocated project metrics, and remaining available counts instantly.</span>
|
||||||
|
<span lang="de"> Siehst sofort den Gesamtbestand, was in Projekten verplant ist und was noch frei zur Verfügung steht.</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
<span>
|
||||||
|
<strong lang="en">Unified Dashboards:</strong><strong lang="de">Orte & Projekte im Blick:</strong>
|
||||||
|
<span lang="en"> Drill straight down into site boundaries or project lists to inspect stock components with one click.</span>
|
||||||
|
<span lang="de"> Klicke auf Lagerorte oder aktive Projekte im Dashboard, um direkt den aktuellen Inhalt einzusehen.</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 lang="en">Security & Tech Stack</h2>
|
||||||
|
<h2 lang="de">Sicherheit & Tech</h2>
|
||||||
|
<ul class="features-list">
|
||||||
|
<li>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
<span>
|
||||||
|
<strong lang="en">Dual-Token Security:</strong><strong lang="de">Dual-Token Security:</strong>
|
||||||
|
<span lang="en"> Uses robust cryptographically signed JWT cookies combined with seamless backend refresh token rotations.</span>
|
||||||
|
<span lang="de"> Zugriffsschutz über sichere, verschlüsselte JWT-Cookies inkl. Refresh-Token-Rotation.</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
<span>
|
||||||
|
<strong lang="en">Native TLS Listener:</strong><strong lang="de">Native TLS Listener:</strong>
|
||||||
|
<span lang="en"> Out-of-the-box enforced HTTPS transport layer security powered directly by Go's core networking.</span>
|
||||||
|
<span lang="de"> Enforcierte HTTPS-Verschlüsselung direkt aus dem Go-Core heraus.</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||||
|
<span>
|
||||||
|
<strong lang="en">Ultra Lightweight:</strong><strong lang="de">Leichtgewicht:</strong>
|
||||||
|
<span lang="en"> Runs on absolute minimal system footprint resource margins using an embedded optimized SQLite model.</span>
|
||||||
|
<span lang="de"> Minimaler Ressourcenverbrauch dank kompilierter Go-Binary und eingebetteter SQLite-Datenbank.</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="install-section">
|
||||||
|
<h2 lang="en">Install Your Server</h2>
|
||||||
|
<h2 lang="de">Server installieren</h2>
|
||||||
|
|
||||||
|
<p lang="en">Deploy MiauInv to your server infrastructure or local machine in less than a minute utilizing our streamlined container configurations.</p>
|
||||||
|
<p lang="de">MiauInv lässt sich dank vorkonfiguriertem Docker-Setup innerhalb weniger Sekunden auf deinem Server oder lokalen System starten.</p>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>
|
||||||
|
<span class="step-num">1</span>
|
||||||
|
<span lang="en">Setup Asset Directories and TLS</span>
|
||||||
|
<span lang="de">Ordner und Zertifikate vorbereiten</span>
|
||||||
|
</h3>
|
||||||
|
<p lang="en">Since MiauInv natively enforces encrypted communication channels, initialize your tracking asset directory and place your TLS files there (or generate a self-signed keypair for testing targets):</p>
|
||||||
|
<p lang="de">Da MiauInv standardmäßig verschlüsseltes HTTPS erzwingt, erstelle den Appdata-Ordner und lege deine SSL-Zertifikate ab (oder generiere ein Self-Signed Cert für Tests):</p>
|
||||||
|
<pre>mkdir -p appdata
|
||||||
|
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem -sha256 -days 365 -nodes -subj "/CN=localhost"</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>
|
||||||
|
<span class="step-num">2</span>
|
||||||
|
<span lang="en">Configure Docker Compose</span>
|
||||||
|
<span lang="de">Docker Compose Datei anlegen</span>
|
||||||
|
</h3>
|
||||||
|
<p lang="en">Create a <code>docker-compose.yaml</code> layout file in the parent folder, and inject the orchestration service blocks. Ensure your <code>JWT_SECRET</code> environment variable meets the minimum security threshold length requirements:</p>
|
||||||
|
<p lang="de">Erstelle eine <code>docker-compose.yaml</code> im selben Verzeichnis und füge folgenden Inhalt ein. Passe den <code>JWT_SECRET</code>-String an:</p>
|
||||||
|
<pre>services:
|
||||||
|
miauinv:
|
||||||
|
image: git.miaurizius.de/miaurizius/miauinv:latest
|
||||||
|
container_name: MiauInv
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- JWT_SECRET=SECURE_RANDOM_STRING_HERE_MIN_32_CHARS
|
||||||
|
volumes:
|
||||||
|
- ./appdata:/appdata</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<h3>
|
||||||
|
<span class="step-num">3</span>
|
||||||
|
<span lang="en">Boot the Environment</span>
|
||||||
|
<span lang="de">Container starten</span>
|
||||||
|
</h3>
|
||||||
|
<p lang="en">Fire up the production background workers by executing your infrastructure controls daemon tasks:</p>
|
||||||
|
<p lang="de">Führe nun einfach den Start-Befehl aus, um das Image zu ziehen und im Hintergrund zu starten:</p>
|
||||||
|
<pre>docker-compose up -d</pre>
|
||||||
|
|
||||||
|
<p style="margin-top: 0.5rem;" lang="en">Everything is set up! Your instance is running and fully accessible at <strong>https://localhost:8080</strong>.</p>
|
||||||
|
<p style="margin-top: 0.5rem;" lang="de">Fertig! Dein Server läuft und ist sofort unter <strong>https://localhost:8080</strong> erreichbar.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p lang="en">© 2026 Maurice Larivière.</p>
|
||||||
|
<p lang="de">© 2026 Maurice Larivière.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function setLanguage(lang) {
|
||||||
|
document.documentElement.setAttribute('data-lang', lang);
|
||||||
|
document.getElementById('langBtn').innerText = lang === 'en' ? 'DE' : 'EN';
|
||||||
|
localStorage.setItem('miauinv-lang', lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLanguage() {
|
||||||
|
const currentLang = document.documentElement.getAttribute('data-lang') || 'en';
|
||||||
|
const nextLang = currentLang === 'en' ? 'de' : 'en';
|
||||||
|
setLanguage(nextLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedLang = localStorage.getItem('miauinv-lang') || 'en';
|
||||||
|
setLanguage(savedLang);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
101
auth/jwt.go
101
auth/jwt.go
@@ -1,17 +1,36 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PurposeTwoFactorLogin = "2fa_login"
|
||||||
|
PurposeTwoFactorSetup = "2fa_setup"
|
||||||
|
)
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PurposeClaims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwoFactorSetupClaims struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
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 +44,41 @@ 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 GenerateTwoFactorSetupJWT(userID, twoFactorSecret string, secret []byte, ttl time.Duration) (string, error) {
|
||||||
|
claims := TwoFactorSetupClaims{
|
||||||
|
UserID: userID,
|
||||||
|
Purpose: PurposeTwoFactorSetup,
|
||||||
|
Secret: twoFactorSecret,
|
||||||
|
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 +87,54 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateTwoFactorSetupJWT(tokenStr string, secret []byte) (*TwoFactorSetupClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &TwoFactorSetupClaims{}, 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.(*TwoFactorSetupClaims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
if claims.Purpose != PurposeTwoFactorSetup {
|
||||||
|
return nil, errors.New("invalid token purpose")
|
||||||
|
}
|
||||||
|
if claims.Secret == "" {
|
||||||
|
return nil, errors.New("missing 2FA setup secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
return claims, nil
|
return claims, nil
|
||||||
|
|||||||
@@ -10,9 +10,16 @@ type contextKey string
|
|||||||
|
|
||||||
const UserContextKey contextKey = contextKey("user")
|
const UserContextKey contextKey = contextKey("user")
|
||||||
|
|
||||||
|
// middleware.go
|
||||||
func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
|
func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
if r.URL.Path == "/login" || r.URL.Path == "/register" || r.URL.Path == "/" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
tokenStr := ""
|
tokenStr := ""
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
|
||||||
@@ -37,22 +44,23 @@ func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
claims, err := ValidateJWT(tokenStr, secret)
|
claims, err := ValidateJWT(tokenStr, secret)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||||
} else {
|
} else {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "access_token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: false,
|
||||||
|
})
|
||||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(
|
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
||||||
r.Context(),
|
|
||||||
UserContextKey,
|
|
||||||
claims,
|
|
||||||
)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
sudo docker buildx build --platform linux/amd64,linux/arm64 -t git.miaurizius.de/miaurizius/miauinv:latest --push .
|
|
||||||
219
docs/AUTHENTICATION.md
Normal file
219
docs/AUTHENTICATION.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Authentication Architecture
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variable. They are not encrypted. Access tokens and normal purpose tokens should therefore contain identity and authorization metadata only, not secrets. The short-lived 2FA setup token intentionally carries the not-yet-enabled TOTP secret because the same secret is already returned to the authenticated browser for QR/manual setup and is not stored server-side until confirmation.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| 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 Types
|
||||||
|
|
||||||
|
| 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`. |
|
||||||
|
| 2FA setup token | JSON response from `/api/2fa/setup` | 10 minutes | Carries the not-yet-enabled TOTP secret until `/api/2fa/enable` validates the first code. It is purpose-bound to `2fa_setup`. |
|
||||||
|
|
||||||
|
## Normal Login Flow Without 2FA
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Login Flow With 2FA Enabled
|
||||||
|
|
||||||
|
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": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. The frontend shows the second login step.
|
||||||
|
6. Client sends `POST /api/login/2fa` with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"two_factor_token": "...",
|
||||||
|
"code": "123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## TOTP Setup Flow
|
||||||
|
|
||||||
|
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 creates a short-lived setup token containing the not-yet-enabled TOTP secret.
|
||||||
|
4. The secret is not written to `users.two_factor_secret` during setup.
|
||||||
|
5. Server returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"secret": "BASE32SECRET",
|
||||||
|
"setup_token": "...",
|
||||||
|
"otpauth_url": "otpauth://totp/...",
|
||||||
|
"qr_code": "data:image/png;base64,..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. The frontend displays the QR code and the manual setup key.
|
||||||
|
7. User scans the QR code or enters the secret manually into an authenticator app.
|
||||||
|
8. User submits the setup token and a current TOTP code to `POST /api/2fa/enable`.
|
||||||
|
9. Server validates the setup token and the TOTP code.
|
||||||
|
10. Server stores the TOTP secret, enables 2FA, invalidates any previous recovery codes, and generates a fresh recovery-code set.
|
||||||
|
11. Server revokes existing refresh sessions and issues a new current session.
|
||||||
|
12. 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. Enabling 2FA deletes any old recovery-code rows before inserting the new set.
|
||||||
|
|
||||||
|
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. The account settings UI warns the user when the number of unused recovery codes is low.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
2FA activation also revokes existing refresh tokens and issues a new session for the current browser. Other devices must log in again and complete 2FA.
|
||||||
|
|
||||||
|
## 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:
|
||||||
|
|
||||||
|
- Replace the current in-memory rate limiter with persistent or distributed rate limiting if the app is deployed across multiple instances.
|
||||||
|
- 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.
|
||||||
195
docs/DATABASE.md
Normal file
195
docs/DATABASE.md
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
# Database Documentation
|
||||||
|
|
||||||
|
MiauInv uses SQLite for persistent storage. The schema is initialized in `storage.InitDB` and foreign-key enforcement is explicitly enabled with:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
```
|
||||||
|
|
||||||
|
The database stores users, refresh tokens, 2FA recovery codes, inventory items, locations, projects, stock mappings, and project allocations.
|
||||||
|
|
||||||
|
## Entity Overview
|
||||||
|
|
||||||
|
```text
|
||||||
|
[users] 1 ──── N [refresh_tokens]
|
||||||
|
[users] 1 ──── N [two_factor_recovery_codes]
|
||||||
|
|
||||||
|
[items] 1 ──── N [stock] N ──── 1 [locations]
|
||||||
|
[items] 1 ──── N [project_items] N ──── 1 [projects]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### `users`
|
||||||
|
|
||||||
|
Stores account credentials, roles, and 2FA state.
|
||||||
|
|
||||||
|
| 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. During setup the secret is held in a short-lived signed setup token and is only stored after the first valid TOTP code. |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### `refresh_tokens`
|
||||||
|
|
||||||
|
Stores refresh-token sessions. Tokens are stored as hashes, not plaintext.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | `TEXT` | Primary key | Refresh-token row UUID. |
|
||||||
|
| `user_id` | `TEXT` | Not null, foreign key to `users(id)` | Owning user. |
|
||||||
|
| `token_hash` | `TEXT` | Not null | Hash of the refresh token. |
|
||||||
|
| `expires_at` | `INTEGER` | Not null | Unix timestamp for expiry. |
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
Refresh-token rotation revokes the used refresh token and inserts a new row for the next token.
|
||||||
|
|
||||||
|
### `two_factor_recovery_codes`
|
||||||
|
|
||||||
|
Stores recovery-code hashes for 2FA fallback login.
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### `items`
|
||||||
|
|
||||||
|
Stores tracked inventory items.
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
|
||||||
|
### `locations`
|
||||||
|
|
||||||
|
Stores physical or logical storage locations.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | `INTEGER` | Primary key, autoincrement | Location ID. |
|
||||||
|
| `name` | `TEXT` | Not null, unique | Location name. |
|
||||||
|
|
||||||
|
### `projects`
|
||||||
|
|
||||||
|
Stores project contexts for item allocation.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | `INTEGER` | Primary key, autoincrement | Project ID. |
|
||||||
|
| `name` | `TEXT` | Not null, unique | Project name. |
|
||||||
|
| `description` | `TEXT` | Optional | Project description. |
|
||||||
|
|
||||||
|
### `stock`
|
||||||
|
|
||||||
|
Maps item quantities to locations.
|
||||||
|
|
||||||
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `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 temporary setup token is validated.
|
||||||
|
2. The supplied TOTP code is validated against the setup secret.
|
||||||
|
3. Existing recovery codes are deleted.
|
||||||
|
4. New recovery-code hashes are inserted.
|
||||||
|
5. `users.two_factor_enabled` is set to `1` and `users.two_factor_secret` is set to the confirmed secret.
|
||||||
|
6. Existing refresh tokens are revoked and a new session is issued for the current browser.
|
||||||
|
|
||||||
|
### 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.
|
||||||
83
docs/SECURITY.md
Normal file
83
docs/SECURITY.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Security Notes
|
||||||
|
|
||||||
|
This document summarizes the current security-relevant behavior of MiauInv. It is intended as implementation documentation, not as a guarantee that the application is production-ready for untrusted public deployments.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
MiauInv uses signed JWT access tokens, database-backed refresh tokens, and optional TOTP-based two-factor authentication.
|
||||||
|
|
||||||
|
JWTs are signed with `JWT_SECRET`. They are not encrypted. Normal access tokens and purpose tokens should therefore contain only identity and authorization metadata.
|
||||||
|
|
||||||
|
The short-lived 2FA setup token is a narrow exception: it carries the not-yet-enabled TOTP secret until the first authenticator code is validated. This avoids storing the setup secret in the database before 2FA is confirmed.
|
||||||
|
|
||||||
|
## Passwords
|
||||||
|
|
||||||
|
Passwords are hashed with bcrypt. Password updates require the current password. New passwords longer than bcrypt's effective 72-byte limit are rejected.
|
||||||
|
|
||||||
|
When a password is changed:
|
||||||
|
|
||||||
|
1. The new password is bcrypt-hashed.
|
||||||
|
2. The stored password hash is updated.
|
||||||
|
3. Existing refresh tokens for the user are revoked.
|
||||||
|
4. A new session is issued for the current browser.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Access tokens expire after 15 minutes. Refresh tokens expire after 7 days.
|
||||||
|
|
||||||
|
Refresh tokens are stored only as hashes in the database. Refresh-token rotation revokes the used refresh token and inserts a new token hash.
|
||||||
|
|
||||||
|
Security-sensitive account changes revoke existing refresh-token sessions.
|
||||||
|
|
||||||
|
## Cookies
|
||||||
|
|
||||||
|
Authentication cookies are set as HTTP-only secure cookies using `SameSite=Lax`.
|
||||||
|
|
||||||
|
Because the cookies are marked `Secure`, local development should use HTTPS. If the application is placed behind a reverse proxy, the deployment should preserve HTTPS semantics between the user and the proxy.
|
||||||
|
|
||||||
|
## Two-Factor Authentication
|
||||||
|
|
||||||
|
TOTP 2FA is optional per account.
|
||||||
|
|
||||||
|
The setup flow returns a QR code, a manual setup key, and a short-lived setup token. The TOTP secret is stored only after the user submits a valid code from their authenticator app.
|
||||||
|
|
||||||
|
When 2FA is enabled:
|
||||||
|
|
||||||
|
1. Any previous recovery codes are deleted.
|
||||||
|
2. A new recovery-code set is generated.
|
||||||
|
3. The TOTP secret is stored.
|
||||||
|
4. Existing refresh sessions are revoked.
|
||||||
|
5. A new current session is issued.
|
||||||
|
|
||||||
|
When 2FA is disabled:
|
||||||
|
|
||||||
|
1. The TOTP secret is cleared.
|
||||||
|
2. Recovery codes are deleted.
|
||||||
|
3. Existing refresh sessions are revoked.
|
||||||
|
4. Authentication cookies are cleared.
|
||||||
|
|
||||||
|
## Recovery Codes
|
||||||
|
|
||||||
|
Recovery codes are generated with cryptographically secure randomness and stored only as hashes.
|
||||||
|
|
||||||
|
They are displayed only immediately after generation or regeneration. They cannot be recovered later because the plaintext values are not stored.
|
||||||
|
|
||||||
|
Recovery codes are single-use. During login, a submitted value is first checked as a TOTP code. If that fails, the value is normalized, hashed, and matched against unused recovery-code hashes.
|
||||||
|
|
||||||
|
The account settings UI warns the user when the remaining unused recovery-code count is low.
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Basic in-memory rate limiting protects login, 2FA, refresh, registration, and sensitive account endpoints.
|
||||||
|
|
||||||
|
This is suitable for a single-instance private deployment. It is not sufficient for multi-instance deployments because limiter state is process-local. A public or multi-instance deployment should use persistent or distributed rate limiting at the application, reverse proxy, or infrastructure layer.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- Automated testing is currently limited.
|
||||||
|
- TOTP secrets are stored in the database after confirmation because the server must validate future codes.
|
||||||
|
- TOTP secrets are not encrypted at rest.
|
||||||
|
- There is no dedicated session/device management UI yet.
|
||||||
|
- There is no audit log for account security changes yet.
|
||||||
|
- The current rate limiter is process-local and memory-only.
|
||||||
|
- Passkeys/WebAuthn are intentionally not implemented yet.
|
||||||
BIN
docs/img/dashboard.png
Normal file
BIN
docs/img/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
docs/img/inventory.png
Normal file
BIN
docs/img/inventory.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/img/locations.png
Normal file
BIN
docs/img/locations.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/img/projects.png
Normal file
BIN
docs/img/projects.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
108
frontend/assets/css/register-blocked.css
Normal file
108
frontend/assets/css/register-blocked.css
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #111827;
|
||||||
|
--card: #1f2937;
|
||||||
|
--border: #374151;
|
||||||
|
--text: #f9fafb;
|
||||||
|
--text-muted: #9ca3af;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--success: #10b981;
|
||||||
|
--error: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.dashboard-layout) {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h1 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #1f2937;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
24
frontend/assets/css/under-construction.css
Normal file
24
frontend/assets/css/under-construction.css
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.construction-icon-wrapper {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: rgba(59, 130, 246, 0.1); /* Nutzt die var(--accent) Transparenz */
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gear-icon {
|
||||||
|
animation: gearRotate 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gearRotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const dropdownMenu = document.getElementById('dropdown-menu');
|
const dropdownMenu = document.getElementById('dropdown-menu');
|
||||||
const menuBtn = document.getElementById('menu-btn');
|
const menuBtn = document.getElementById('menu-btn');
|
||||||
const mainNav = document.getElementById('main-nav');
|
const mainNav = document.getElementById('main-nav');
|
||||||
|
const logoutBtn = document.getElementById('logout-btn');
|
||||||
|
|
||||||
if (profileBtn && dropdownMenu) {
|
if (profileBtn && dropdownMenu) {
|
||||||
profileBtn.addEventListener('click', (e) => {
|
profileBtn.addEventListener('click', (e) => {
|
||||||
@@ -22,7 +23,15 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('click', () => {
|
logoutBtn.addEventListener('click', () => {
|
||||||
|
console.log("Logout")
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
|
||||||
|
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', () => {
|
||||||
if (dropdownMenu) dropdownMenu.classList.remove('show');
|
if (dropdownMenu) dropdownMenu.classList.remove('show');
|
||||||
if (mainNav) mainNav.classList.remove('show');
|
if (mainNav) mainNav.classList.remove('show');
|
||||||
});
|
});
|
||||||
@@ -30,6 +39,9 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
function closeModal(id) {
|
function closeModal(id) {
|
||||||
@@ -279,17 +291,18 @@ async function reloadStockTable(itemId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
data.stock.forEach(st => {
|
for (const st of data.stock) {
|
||||||
|
const locData = await apiRequest(`/api/location?id=${st.location_id}`);
|
||||||
tbody.innerHTML += `
|
tbody.innerHTML += `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${st.location_name || `Location #${st.location_id}`}</td>
|
<td>${locData.name || `Location #${st.location_id}`}</td>
|
||||||
<td><span class="badge success">${st.quantity}</span></td>
|
<td><span class="badge success">${st.quantity}</span></td>
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">
|
||||||
<button class="btn btn-secondary danger-btn" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;" onclick="deleteStock(${st.id}, ${itemId})">Del</button>
|
<button class="btn btn-secondary danger-btn" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;" onclick="deleteStock(${st.id}, ${itemId})">Del</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
});
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">Failed to load.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">Failed to load.</td></tr>';
|
||||||
}
|
}
|
||||||
@@ -349,17 +362,19 @@ async function reloadAssociationTable(projectId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
data.associations.forEach(asc => {
|
for (const asc of data.associations) {
|
||||||
|
const itemData = await apiRequest(`/api/item?id=${asc.item_id}`);
|
||||||
|
console.log(itemData)
|
||||||
tbody.innerHTML += `
|
tbody.innerHTML += `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${asc.item_name || `Item #${asc.item_id}`}</td>
|
<td>${itemData.name || `Item #${asc.item_id}`}</td>
|
||||||
<td><span class="badge success">${asc.quantity}</span></td>
|
<td><span class="badge success">${asc.quantity}</span></td>
|
||||||
<td style="text-align: right;">
|
<td style="text-align: right;">
|
||||||
<button class="btn btn-secondary danger-btn" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;" onclick="deleteAssociation(${asc.id}, ${projectId})">Del</button>
|
<button class="btn btn-secondary danger-btn" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;" onclick="deleteAssociation(${asc.id}, ${projectId})">Del</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
});
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">Failed to load.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">Failed to load.</td></tr>';
|
||||||
}
|
}
|
||||||
@@ -452,4 +467,288 @@ async function openDashboardModal(url, title, dataKey) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
tbody.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- PROFILE ----
|
||||||
|
async function loadProfile() {
|
||||||
|
const avatar = document.getElementById("avatar");
|
||||||
|
const username = document.getElementById('username');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/profile');
|
||||||
|
|
||||||
|
avatar.innerText = data.username[0].toLocaleUpperCase();
|
||||||
|
username.innerText = data.username;
|
||||||
|
} catch (e) {
|
||||||
|
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 = [];
|
||||||
|
let pendingTwoFactorSetupToken = "";
|
||||||
|
|
||||||
|
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 updateRecoveryCodeWarning(remaining, warning) {
|
||||||
|
const warningBox = document.getElementById('recovery-codes-warning');
|
||||||
|
if (!warningBox) return;
|
||||||
|
|
||||||
|
if (warning) {
|
||||||
|
warningBox.textContent = `You only have ${remaining} recovery code${remaining === 1 ? '' : 's'} left. Generate and download new codes soon.`;
|
||||||
|
warningBox.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
warningBox.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;
|
||||||
|
updateRecoveryCodeWarning(data.recovery_codes_remaining || 0, !!data.recovery_codes_warning);
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
pendingTwoFactorSetupToken = data.setup_token || '';
|
||||||
|
|
||||||
|
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(),
|
||||||
|
setup_token: pendingTwoFactorSetupToken
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('two-factor-enable-form').reset();
|
||||||
|
document.getElementById('two-factor-setup-panel').style.display = 'none';
|
||||||
|
pendingTwoFactorSetupToken = '';
|
||||||
|
setTwoFactorPanels(true);
|
||||||
|
renderRecoveryCodes(data.recovery_codes || []);
|
||||||
|
|
||||||
|
const remaining = document.getElementById('recovery-codes-remaining');
|
||||||
|
if (remaining) remaining.innerText = data.recovery_codes_remaining || (data.recovery_codes || []).length;
|
||||||
|
updateRecoveryCodeWarning(data.recovery_codes_remaining || (data.recovery_codes || []).length, !!data.recovery_codes_warning);
|
||||||
|
|
||||||
|
if (data.access_token && data.refresh_token) {
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
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([]);
|
||||||
|
updateRecoveryCodeWarning(0, false);
|
||||||
|
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;
|
||||||
|
updateRecoveryCodeWarning((data.recovery_codes || []).length, false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,23 +13,20 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookieAccessToken = getCookie("access_token");
|
function clearAllAuth() {
|
||||||
const cookieRefreshToken = getCookie("refresh_token");
|
console.log("Clearing all auth remnants from everywhere...");
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
const localAccessToken = localStorage.getItem("access_token");
|
localStorage.removeItem("refresh_token");
|
||||||
const localRefreshToken = localStorage.getItem("refresh_token");
|
sessionStorage.removeItem("is_refreshing");
|
||||||
|
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; Secure; SameSite=Lax;";
|
||||||
const accessToken = cookieAccessToken || localAccessToken;
|
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; Secure; SameSite=Lax;";
|
||||||
const refreshToken = cookieRefreshToken || localRefreshToken;
|
|
||||||
|
|
||||||
if (!accessToken && !refreshToken) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryTokenRefresh() {
|
async function tryTokenRefresh(refreshToken) {
|
||||||
if (!refreshToken) return false;
|
if (!refreshToken) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log("Sending refresh token to /api/refresh...");
|
||||||
const response = await fetch("/api/refresh", {
|
const response = await fetch("/api/refresh", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -38,72 +35,94 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
localStorage.setItem("access_token", data.access_token);
|
localStorage.setItem("access_token", data.access_token);
|
||||||
localStorage.setItem("refresh_token", data.refresh_token);
|
localStorage.setItem("refresh_token", data.refresh_token);
|
||||||
|
|
||||||
document.cookie = `access_token=${data.access_token}; path=/; max-age=900; SameSite=Lax; Secure`;
|
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`;
|
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=604800; SameSite=Lax; Secure`;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Refresh request failed:", err);
|
console.error("Refresh request failed:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
|
|
||||||
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
console.log("Auth check started...");
|
const cookieAccessToken = getCookie("access_token");
|
||||||
console.log("AccessToken present:", !!accessToken);
|
const cookieRefreshToken = getCookie("refresh_token");
|
||||||
console.log("RefreshToken present:", !!refreshToken);
|
const localAccessToken = localStorage.getItem("access_token");
|
||||||
|
const localRefreshToken = localStorage.getItem("refresh_token");
|
||||||
|
|
||||||
if (!cookieAccessToken && accessToken) {
|
const accessToken = cookieAccessToken || localAccessToken;
|
||||||
console.log("Access token cookie missing, but present in localStorage. Forcing refresh...");
|
const refreshToken = cookieRefreshToken || localRefreshToken;
|
||||||
} else if (accessToken) {
|
|
||||||
|
console.log("Auth check started...");
|
||||||
|
console.log("AccessToken available (Cookie/Local):", !!cookieAccessToken, "/", !!localAccessToken);
|
||||||
|
console.log("RefreshToken available (Cookie/Local):", !!cookieRefreshToken, "/", !!localRefreshToken);
|
||||||
|
|
||||||
|
if (!accessToken && !refreshToken) {
|
||||||
|
console.log("No tokens found in cookies or localStorage. User is a guest.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
try {
|
try {
|
||||||
console.log("Attempting ping with access token...");
|
console.log("Validating token against /api/userinfo...");
|
||||||
const response = await fetch("/api/ping", {
|
|
||||||
|
const response = await fetch("/api/userinfo", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { "Authorization": `Bearer ${accessToken}` }
|
headers: { "Authorization": `Bearer ${accessToken}` }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log("Ping successful! Redirecting to dashboard...");
|
console.log("Token is perfectly valid!");
|
||||||
|
sessionStorage.removeItem("is_refreshing");
|
||||||
|
|
||||||
|
if (!cookieAccessToken) {
|
||||||
|
document.cookie = `access_token=${accessToken}; path=/; max-age=900; SameSite=Lax; Secure`;
|
||||||
|
}
|
||||||
|
if (!cookieRefreshToken && localRefreshToken) {
|
||||||
|
document.cookie = `refresh_token=${localRefreshToken}; path=/; max-age=604800; SameSite=Lax; Secure`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Redirecting to dashboard...");
|
||||||
window.location.href = "/dashboard";
|
window.location.href = "/dashboard";
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.log("Ping failed. Status:", response.status);
|
console.log("Token rejected by /api/userinfo. Status:", response.status);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Network error during ping:", err);
|
console.error("Network error during token verification:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sessionStorage.getItem("is_refreshing") === "true") {
|
||||||
|
console.warn("Loop protection triggered! Tokens appear to be corrupt. Clearing storage.");
|
||||||
|
clearAllAuth();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
console.log("Starting token refresh to rebuild cookies...");
|
console.log("Access token has expired. Starting refresh process...");
|
||||||
const refreshSuccessful = await tryTokenRefresh();
|
sessionStorage.setItem("is_refreshing", "true");
|
||||||
|
|
||||||
|
const refreshSuccessful = await tryTokenRefresh(refreshToken);
|
||||||
|
|
||||||
if (refreshSuccessful) {
|
if (refreshSuccessful) {
|
||||||
console.log("Refresh successful! Redirecting to dashboard...");
|
console.log("Refresh successful! Reloading page to apply cookies...");
|
||||||
window.location.href = "/dashboard";
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
console.log("Refresh failed. Staying on login.");
|
console.log("Refresh failed. Refresh token has also expired.");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("No refresh token present. User must log in normally.");
|
console.log("No refresh token available for recovery.");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Authentication completely failed. Clearing remnants...");
|
clearAllAuth();
|
||||||
localStorage.removeItem("access_token");
|
console.log("Authentication completely failed. User remains on login page.");
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
|
|
||||||
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAuth();
|
setTimeout(checkAuth, 50);
|
||||||
})();
|
})();
|
||||||
@@ -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;
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
if (pendingTwoFactorToken) {
|
||||||
body: JSON.stringify({ username, password })
|
response = await fetch("/api/login/2fa", {
|
||||||
});
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package frontend
|
package frontend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"MiauInv/storage"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tdewolff/minify/v2"
|
||||||
|
"github.com/tdewolff/minify/v2/css"
|
||||||
|
"github.com/tdewolff/minify/v2/js"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dashboard = template.Must(template.ParseFiles(
|
var dashboard = template.Must(template.ParseFiles(
|
||||||
@@ -27,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) {
|
||||||
@@ -45,10 +55,30 @@ 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")
|
||||||
err := dashboard.ExecuteTemplate(w, "base.html", struct {
|
|
||||||
|
var itemHive, projectHive, locationHive int
|
||||||
|
|
||||||
|
err := storage.DB.QueryRow("SELECT COUNT(*) FROM items").Scan(&itemHive)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to count items", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = storage.DB.QueryRow("SELECT COUNT(*) FROM projects").Scan(&projectHive)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to count projects", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = storage.DB.QueryRow("SELECT COUNT(*) FROM locations").Scan(&locationHive)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to count locations", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = dashboard.ExecuteTemplate(w, "base.html", struct {
|
||||||
Title string
|
Title string
|
||||||
Stats struct {
|
Stats struct {
|
||||||
Items int
|
Items int
|
||||||
@@ -62,9 +92,9 @@ func Dashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
Projects int
|
Projects int
|
||||||
Locations int
|
Locations int
|
||||||
}{
|
}{
|
||||||
Items: 1,
|
Items: itemHive,
|
||||||
Projects: 1,
|
Projects: projectHive,
|
||||||
Locations: 3,
|
Locations: locationHive,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,8 +145,61 @@ 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
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
minifier = minify.New()
|
||||||
|
// Füge die Minifier für CSS und JS hinzu
|
||||||
|
minifier.AddFunc("text/css", css.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/")
|
||||||
http.ServeFile(w, r, filepath.Join("frontend/assets", path))
|
fullPath := filepath.Join("frontend/assets", path)
|
||||||
|
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
|
||||||
|
var mimeType string
|
||||||
|
if strings.HasSuffix(path, ".min.js") {
|
||||||
|
mimeType = "text/javascript"
|
||||||
|
fullPath = strings.Replace(fullPath, ".min.js", ".js", 1)
|
||||||
|
} else if strings.HasSuffix(path, ".min.css") {
|
||||||
|
mimeType = "text/css"
|
||||||
|
fullPath = strings.Replace(fullPath, ".min.css", ".css", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(fullPath)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
http.Error(w, "Asset not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mimeType != "" {
|
||||||
|
content, err := os.ReadFile(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
minifiedContent, err := minifier.Bytes(mimeType, content)
|
||||||
|
if err == nil {
|
||||||
|
w.Header().Set("Content-Type", mimeType)
|
||||||
|
w.Write(minifiedContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeFile(w, r, fullPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>404 - Page Not Found | MiauInv</title>
|
<title>404 - Page Not Found | MiauInv</title>
|
||||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
<link rel="stylesheet" href="/assets/css/theme.min.css">
|
||||||
<link rel="stylesheet" href="/assets/css/error404.css">
|
<link rel="stylesheet" href="/assets/css/error404.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
140
frontend/htmx/contents/dash/account_settings.html
Normal file
140
frontend/htmx/contents/dash/account_settings.html
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
{{ 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: 0.5rem;">Recovery codes remaining: <strong id="recovery-codes-remaining">0</strong></p>
|
||||||
|
<p id="recovery-codes-warning" class="message error" style="display: none; margin-bottom: 1rem;">You are running low on recovery codes. Generate and download new codes soon.</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 }}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
{{ define "base.html" }}
|
{{ define "base.html" }}
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ .Title }} | MiauInv</title>
|
<title>{{ .Title }} | MiauInv</title>
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
<script src="/assets/js/api.js"></script>
|
<script src="/assets/js/api.min.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
<link rel="stylesheet" href="/assets/css/theme.min.css">
|
||||||
<link rel="stylesheet" href="/assets/css/dashboard.css">
|
<link rel="stylesheet" href="/assets/css/dashboard.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-layout">
|
<body class="dashboard-layout">
|
||||||
|
|
||||||
@@ -31,15 +30,15 @@
|
|||||||
|
|
||||||
<div class="profile-dropdown">
|
<div class="profile-dropdown">
|
||||||
<button class="profile-trigger" id="profile-btn">
|
<button class="profile-trigger" id="profile-btn">
|
||||||
<div class="avatar">M</div>
|
<div id="avatar" class="avatar">M</div>
|
||||||
<span class="username">Admin</span>
|
<span id="username" class="username">Loading...</span>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" id="dropdown-menu">
|
<div class="dropdown-menu" id="dropdown-menu">
|
||||||
<a href="/profile/settings">Account Settings</a>
|
<a href="/profile/settings">Account Settings</a>
|
||||||
<a href="/profile/activity">Activity Log</a>
|
<a href="/profile/activity">Activity Log</a>
|
||||||
<hr class="dropdown-divider">
|
<hr class="dropdown-divider">
|
||||||
<button class="logout-btn"
|
<button id="logout-btn" class="logout-btn"
|
||||||
hx-post="/api/logout"
|
hx-post="/api/logout"
|
||||||
hx-on::after-request="window.location.href='/login'">
|
hx-on::after-request="window.location.href='/login'">
|
||||||
Log Out
|
Log Out
|
||||||
@@ -53,7 +52,6 @@
|
|||||||
{{ template "content" . }}
|
{{ template "content" . }}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/assets/js/api.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>MiauInv | Private Instance</title>
|
<title>MiauInv | Private Instance</title>
|
||||||
<script src="/assets/js/auth.js"></script>
|
<script src="/assets/js/auth.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
<link rel="stylesheet" href="/assets/css/theme.min.css">
|
||||||
<link rel="stylesheet" href="/assets/css/home.css">
|
<link rel="stylesheet" href="/assets/css/home.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Sign In | MiauInv</title>
|
<title>Sign In | MiauInv</title>
|
||||||
|
|
||||||
<script src="/assets/js/auth.js" defer></script>
|
<script src="/assets/js/auth.min.js" defer></script>
|
||||||
<script src="/assets/js/login.js" defer></script>
|
<script src="/assets/js/login.min.js" defer></script>
|
||||||
|
|
||||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
<link rel="stylesheet" href="/assets/css/theme.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
26
frontend/htmx/register-blocked.html
Normal file
26
frontend/htmx/register-blocked.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Registration Disabled - MiauInv</title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/register-blocked.min.css">
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1>Registration</h1>
|
||||||
|
<div class="subtitle">Create a new account</div>
|
||||||
|
|
||||||
|
<div class="message error">
|
||||||
|
<strong>Access Denied:</strong> Public registration is currently disabled for this system. Please contact your system administrator to request an account.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-text">
|
||||||
|
<a class="btn btn-secondary" href="/login" style="margin-top: 1.5rem;">Back to Login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Register | MiauInv</title>
|
<title>Register | MiauInv</title>
|
||||||
<script src="/assets/js/auth.js"></script>
|
<script src="/assets/js/auth.min.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/css/theme.css">
|
<script src="/assets/js/register.min.js"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/css/theme.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -36,6 +37,5 @@
|
|||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/assets/js/register.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Registration Disabled - MiauInv</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #111827;
|
|
||||||
--card: #1f2937;
|
|
||||||
--border: #374151;
|
|
||||||
--text: #f9fafb;
|
|
||||||
--text-muted: #9ca3af;
|
|
||||||
--accent: #3b82f6;
|
|
||||||
--accent-hover: #2563eb;
|
|
||||||
--success: #10b981;
|
|
||||||
--error: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:not(.dashboard-layout) {
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h1 {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card .subtitle {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.85rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #1f2937;
|
|
||||||
color: white;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-text {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.error {
|
|
||||||
background: rgba(239, 68, 68, 0.1);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
||||||
color: var(--error);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h1>Registration</h1>
|
|
||||||
<div class="subtitle">Create a new account</div>
|
|
||||||
|
|
||||||
<div class="message error">
|
|
||||||
<strong>Access Denied:</strong> Public registration is currently disabled for this system. Please contact your system administrator to request an account.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer-text">
|
|
||||||
<a class="btn btn-secondary" href="/login" style="margin-top: 1.5rem;">Back to Login</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
39
frontend/htmx/under-construction.html
Normal file
39
frontend/htmx/under-construction.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Feature Under Development | MiauInv</title>
|
||||||
|
<link rel="stylesheet" href="/assets/css/theme.min.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/under-construction.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="construction-icon-wrapper">
|
||||||
|
<svg class="gear-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Coming Soon</h1>
|
||||||
|
<div class="subtitle">Feature Under Development</div>
|
||||||
|
|
||||||
|
<div class="message success" style="display: block; margin-bottom: 2rem;">
|
||||||
|
<strong>Development in progress!</strong><br>
|
||||||
|
Our team is actively working on this feature to bring you the best experience possible.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/dashboard" class="btn btn-secondary">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
|
||||||
|
Back to Dashboard
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="footer-text">
|
||||||
|
Need urgent assistance? <a href="mailto:maurice@miaurizius.de">Contact Developer</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
go.mod
20
go.mod
@@ -5,18 +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
|
||||||
golang.org/x/crypto v0.52.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/pquerna/otp v1.5.0
|
||||||
|
github.com/tdewolff/minify/v2 v2.24.13
|
||||||
|
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/google/uuid v1.5.0 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
||||||
modernc.org/libc v1.37.6 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/libc v1.73.0 // indirect
|
||||||
modernc.org/memory v1.7.2 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/sqlite v1.28.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.52.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
41
go.sum
41
go.sum
@@ -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,26 +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/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
|
||||||
|
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.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.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||||
|
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=
|
||||||
|
|||||||
@@ -2,18 +2,25 @@ 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"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cfg, _ = config.LoadConfig()
|
const recoveryCodeWarningThreshold = 3
|
||||||
|
|
||||||
func APIRegister(w http.ResponseWriter, r *http.Request) {
|
func APIRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
var user models.User
|
var user models.User
|
||||||
@@ -29,6 +36,12 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(user.Password) > 72 {
|
||||||
|
log.Println("POST [api/register] User password too long")
|
||||||
|
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hashed, err := auth.HashPassword(user.Password)
|
hashed, err := auth.HashPassword(user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
||||||
@@ -48,6 +61,7 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
||||||
}
|
}
|
||||||
|
|
||||||
func APILogin(w http.ResponseWriter, r *http.Request) {
|
func APILogin(w http.ResponseWriter, r *http.Request) {
|
||||||
var creds struct {
|
var creds struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -79,76 +93,453 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := auth.GenerateJWT(user.ID, user.Role, secret)
|
if user.TwoFactorEnabled {
|
||||||
if err != nil {
|
twoFactorToken, err := auth.GeneratePurposeJWT(user.ID, auth.PurposeTwoFactorLogin, secret, 5*time.Minute)
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
if err != nil {
|
||||||
http.Error(w, "Could not generate token", http.StatusInternalServerError)
|
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not generate 2FA challenge", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"requires_2fa": true,
|
||||||
|
"two_factor_token": twoFactorToken,
|
||||||
|
})
|
||||||
|
log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTokenPlain, err := utils.GenerateRefreshToken()
|
issueLoginSession(w, r, user)
|
||||||
if err != nil {
|
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
|
||||||
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{
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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, auth.PurposeTwoFactorLogin, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := []byte(os.Getenv("JWT_SECRET"))
|
||||||
|
if len(secret) == 0 {
|
||||||
|
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": Server misconfiguration")
|
||||||
|
http.Error(w, "Server misconfiguration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setupToken, err := auth.GenerateTwoFactorSetupJWT(user.ID, key.Secret(), secret, 10*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/2fa/setup] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not create 2FA setup challenge", 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(),
|
||||||
|
"setup_token": setupToken,
|
||||||
|
"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"`
|
||||||
|
SetupToken string `json:"setup_token"`
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSecret := ""
|
||||||
|
secret := []byte(os.Getenv("JWT_SECRET"))
|
||||||
|
if req.SetupToken != "" {
|
||||||
|
setupClaims, err := auth.ValidateTwoFactorSetupJWT(req.SetupToken, secret)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid or expired 2FA setup challenge", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if setupClaims.UserID != user.ID {
|
||||||
|
http.Error(w, "Invalid 2FA setup challenge", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setupSecret = setupClaims.Secret
|
||||||
|
} else if !user.TwoFactorEnabled && user.TwoFactorSecret != "" {
|
||||||
|
// Compatibility for accounts that started setup before temporary setup tokens existed.
|
||||||
|
setupSecret = user.TwoFactorSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
if setupSecret == "" {
|
||||||
|
http.Error(w, "2FA setup has not been started", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !totp.Validate(strings.TrimSpace(req.Code), setupSecret) {
|
||||||
|
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.EnableUserTwoFactorWithSecretAndRecoveryCodes(user.ID, setupSecret, recoveryCodeHashes); err != nil {
|
||||||
|
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not enable 2FA", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
|
||||||
|
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.TwoFactorEnabled = true
|
||||||
|
user.TwoFactorSecret = setupSecret
|
||||||
|
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
|
||||||
|
"two_factor_enabled": true,
|
||||||
|
"recovery_codes": recoveryCodes,
|
||||||
|
"recovery_codes_remaining": len(recoveryCodes),
|
||||||
|
"recovery_codes_warning": false,
|
||||||
|
"recovery_codes_warning_at": recoveryCodeWarningThreshold,
|
||||||
|
})
|
||||||
|
log.Println("POST [api/2fa/enable] " + r.RemoteAddr + ": Enabled 2FA, replaced recovery codes, and revoked old sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -157,8 +548,10 @@ 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)
|
||||||
|
|
||||||
@@ -174,13 +567,24 @@ func TestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection")
|
log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection")
|
||||||
}
|
}
|
||||||
|
|
||||||
func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,43 +601,17 @@ 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) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed")
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Method " + r.Method + " not allowed")
|
||||||
@@ -242,21 +620,215 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
idParam := query.Get("id")
|
idParam := query.Get("id")
|
||||||
|
|
||||||
|
if idParam == "" {
|
||||||
|
tokenStr := ""
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
|
||||||
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenStr == "" {
|
||||||
|
cookie, err := r.Cookie("access_token")
|
||||||
|
if err == nil {
|
||||||
|
tokenStr = cookie.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenStr == "" {
|
||||||
|
http.Error(w, "Missing token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := auth.ValidateJWT(tokenStr, models.JWTSecret)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Unauthorized: Invalid or expired token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idParam = claims.UserID
|
||||||
|
}
|
||||||
|
|
||||||
user, err := storage.GetUserById(idParam)
|
user, err := storage.GetUserById(idParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": User " + idParam + " not found")
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": User " + idParam + " not found")
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
twoFactorStatus := "disabled"
|
||||||
|
if user.TwoFactorEnabled {
|
||||||
|
twoFactorStatus = "enabled"
|
||||||
|
} else if user.TwoFactorSecret != "" {
|
||||||
|
twoFactorStatus = "setup_pending"
|
||||||
|
}
|
||||||
|
recoveryCodesWarning := user.TwoFactorEnabled && recoveryCodesRemaining <= recoveryCodeWarningThreshold
|
||||||
|
|
||||||
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,
|
||||||
"name": user.Username,
|
"username": user.Username,
|
||||||
"avatar_url": "",
|
"avatar_url": "",
|
||||||
|
"two_factor_enabled": user.TwoFactorEnabled,
|
||||||
|
"two_factor_status": twoFactorStatus,
|
||||||
|
"recovery_codes_remaining": recoveryCodesRemaining,
|
||||||
|
"recovery_codes_warning": recoveryCodesWarning,
|
||||||
|
"recovery_codes_warning_at": recoveryCodeWarningThreshold,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info")
|
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) {
|
||||||
|
issueLoginSessionWithExtra(w, r, user, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func issueLoginSessionWithExtra(w http.ResponseWriter, r *http.Request, user models.User, extra map[string]interface{}) {
|
||||||
|
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)
|
||||||
|
response := 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for key, value := range extra {
|
||||||
|
response[key] = value
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,20 @@ func Location(w http.ResponseWriter, r *http.Request) {
|
|||||||
contentMode := r.URL.Query().Get("content")
|
contentMode := r.URL.Query().Get("content")
|
||||||
|
|
||||||
if idStr != "" && contentMode == "true" {
|
if idStr != "" && contentMode == "true" {
|
||||||
_, _ = strconv.Atoi(idStr)
|
locationID, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid location ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT s.item_id, i.name, s.quantity
|
SELECT s.item_id, i.name, s.quantity
|
||||||
FROM stock s
|
FROM stock s
|
||||||
JOIN items i ON s.item_id = i.id
|
JOIN items i ON s.item_id = i.id
|
||||||
WHERE s.location_id = ? AND s.quantity > 0
|
WHERE s.location_id = ? AND s.quantity > 0
|
||||||
`
|
`
|
||||||
rows, err := storage.DB.Query(query)
|
|
||||||
|
rows, err := storage.DB.Query(query, locationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -38,9 +44,13 @@ func Location(w http.ResponseWriter, r *http.Request) {
|
|||||||
var contents []models.LocationContent
|
var contents []models.LocationContent
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var c models.LocationContent
|
var c models.LocationContent
|
||||||
rows.Scan(&c.ItemID, &c.ItemName, &c.Quantity)
|
if err := rows.Scan(&c.ItemID, &c.ItemName, &c.Quantity); err != nil {
|
||||||
|
http.Error(w, "Row scan error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
contents = append(contents, c)
|
contents = append(contents, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"contents": contents})
|
json.NewEncoder(w).Encode(map[string]interface{}{"contents": contents})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -214,6 +224,34 @@ func Item(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
|
|
||||||
|
idStr := r.URL.Query().Get("id")
|
||||||
|
|
||||||
|
if idStr != "" {
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid ID parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var item models.Item
|
||||||
|
err = storage.DB.QueryRow("SELECT id, name, category, description, total_quantity FROM items WHERE id = ?", id).Scan(&item.ID, &item.Name, &item.Category, &item.Description, &item.TotalQuantity)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
log.Println("GET [api/locations] " + r.RemoteAddr + ": Location not found (ID " + idStr + ")")
|
||||||
|
http.Error(w, "Location not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("GET [api/item] DB Error: " + err.Error())
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(item)
|
||||||
|
log.Println("GET [api/item] " + r.RemoteAddr + ": Successfully retrieved item ID " + idStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT
|
SELECT
|
||||||
i.id, i.name, i.category, i.description,
|
i.id, i.name, i.category, i.description,
|
||||||
@@ -320,14 +358,20 @@ func Project(w http.ResponseWriter, r *http.Request) {
|
|||||||
detailsMode := r.URL.Query().Get("details")
|
detailsMode := r.URL.Query().Get("details")
|
||||||
|
|
||||||
if idStr != "" && detailsMode == "true" {
|
if idStr != "" && detailsMode == "true" {
|
||||||
_, _ = strconv.Atoi(idStr)
|
projectID, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid project ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
SELECT pi.item_id, i.name, pi.quantity
|
SELECT pi.item_id, i.name, pi.quantity
|
||||||
FROM project_items pi
|
FROM project_items pi
|
||||||
JOIN items i ON pi.item_id = i.id
|
JOIN items i ON pi.item_id = i.id
|
||||||
WHERE pi.project_id = ?
|
WHERE pi.project_id = ?
|
||||||
`
|
`
|
||||||
rows, err := storage.DB.Query(query)
|
|
||||||
|
rows, err := storage.DB.Query(query, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -337,9 +381,13 @@ func Project(w http.ResponseWriter, r *http.Request) {
|
|||||||
var details []models.ProjectDetailItem
|
var details []models.ProjectDetailItem
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d models.ProjectDetailItem
|
var d models.ProjectDetailItem
|
||||||
rows.Scan(&d.ItemID, &d.ItemName, &d.Quantity)
|
if err := rows.Scan(&d.ItemID, &d.ItemName, &d.Quantity); err != nil {
|
||||||
|
http.Error(w, "Row scan error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
details = append(details, d)
|
details = append(details, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"items": details})
|
json.NewEncoder(w).Encode(map[string]interface{}{"items": details})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -525,7 +573,6 @@ func Associations(w http.ResponseWriter, r *http.Request) {
|
|||||||
idStr := r.URL.Query().Get("id")
|
idStr := r.URL.Query().Get("id")
|
||||||
projectIDStr := r.URL.Query().Get("project_id")
|
projectIDStr := r.URL.Query().Get("project_id")
|
||||||
|
|
||||||
// Optionaler Filter: Alle Items für ein bestimmtes Projekt holen (?project_id=X)
|
|
||||||
if projectIDStr != "" {
|
if projectIDStr != "" {
|
||||||
pID, _ := strconv.Atoi(projectIDStr)
|
pID, _ := strconv.Atoi(projectIDStr)
|
||||||
rows, err := storage.DB.Query("SELECT id, item_id, project_id, quantity FROM project_items WHERE project_id = ?", pID)
|
rows, err := storage.DB.Query("SELECT id, item_id, project_id, quantity FROM project_items WHERE project_id = ?", pID)
|
||||||
@@ -545,7 +592,6 @@ func Associations(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einzelne Assoziation anhand der Tabellen-ID (?id=X)
|
|
||||||
if idStr != "" {
|
if idStr != "" {
|
||||||
id, _ := strconv.Atoi(idStr)
|
id, _ := strconv.Atoi(idStr)
|
||||||
var pi models.ProjectItem
|
var pi models.ProjectItem
|
||||||
@@ -559,7 +605,6 @@ func Associations(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gar kein Parameter -> Komplett-Dump aller Zuweisungen
|
|
||||||
rows, err := storage.DB.Query("SELECT id, item_id, project_id, quantity FROM project_items")
|
rows, err := storage.DB.Query("SELECT id, item_id, project_id, quantity FROM project_items")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
var JWTSecret []byte
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
const (
|
const (
|
||||||
RoleUser = "user"
|
RoleUser = "user"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
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:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
101
server/ratelimit.go
Normal file
101
server/ratelimit.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateLimitState struct {
|
||||||
|
count int
|
||||||
|
resetAt time.Time
|
||||||
|
blockedUntil time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
states map[string]*rateLimitState
|
||||||
|
maxRequests int
|
||||||
|
window time.Duration
|
||||||
|
blockFor time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateLimiter(maxRequests int, window, blockFor time.Duration) *rateLimiter {
|
||||||
|
return &rateLimiter{
|
||||||
|
states: make(map[string]*rateLimitState),
|
||||||
|
maxRequests: maxRequests,
|
||||||
|
window: window,
|
||||||
|
blockFor: blockFor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limiter *rateLimiter) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !limiter.allow(r) {
|
||||||
|
w.Header().Set("Retry-After", strconvSeconds(limiter.blockFor))
|
||||||
|
http.Error(w, "Too many requests", http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (limiter *rateLimiter) allow(r *http.Request) bool {
|
||||||
|
key := clientIP(r) + ":" + r.URL.Path
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
limiter.mu.Lock()
|
||||||
|
defer limiter.mu.Unlock()
|
||||||
|
|
||||||
|
state, ok := limiter.states[key]
|
||||||
|
if ok && now.Before(state.blockedUntil) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok || now.After(state.resetAt) {
|
||||||
|
limiter.states[key] = &rateLimitState{
|
||||||
|
count: 1,
|
||||||
|
resetAt: now.Add(limiter.window),
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
state.count++
|
||||||
|
if state.count > limiter.maxRequests {
|
||||||
|
state.blockedUntil = now.Add(limiter.blockFor)
|
||||||
|
state.resetAt = now.Add(limiter.window)
|
||||||
|
state.count = 0
|
||||||
|
log.Printf("Rate limit triggered for %s on %s", clientIP(r), r.URL.Path)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" {
|
||||||
|
parts := strings.Split(forwardedFor, ",")
|
||||||
|
if ip := strings.TrimSpace(parts[0]); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func strconvSeconds(duration time.Duration) string {
|
||||||
|
seconds := int(duration.Seconds())
|
||||||
|
if seconds < 1 {
|
||||||
|
seconds = 1
|
||||||
|
}
|
||||||
|
return strconv.Itoa(seconds)
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"MiauInv/config"
|
"MiauInv/config"
|
||||||
"MiauInv/frontend"
|
"MiauInv/frontend"
|
||||||
"MiauInv/handlers"
|
"MiauInv/handlers"
|
||||||
|
"MiauInv/models"
|
||||||
utils "MiauInv/util"
|
utils "MiauInv/util"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -44,6 +46,8 @@ func InitServer() *Server {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
models.JWTSecret = []byte(jwtSecret)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
Port: cfg.Port,
|
Port: cfg.Port,
|
||||||
JWTSecret: []byte(jwtSecret),
|
JWTSecret: []byte(jwtSecret),
|
||||||
@@ -68,20 +72,34 @@ 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"))
|
||||||
if this.AllowRegistration {
|
if this.AllowRegistration {
|
||||||
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
||||||
} else {
|
} else {
|
||||||
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register_blocked.html"))
|
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register-blocked.html"))
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// API
|
// API
|
||||||
//
|
//
|
||||||
mux.HandleFunc("/api/login", handlers.APILogin)
|
loginLimiter := newRateLimiter(10, time.Minute, 5*time.Minute)
|
||||||
mux.HandleFunc("/api/refresh", handlers.RefreshToken)
|
accountLimiter := newRateLimiter(20, time.Minute, 2*time.Minute)
|
||||||
|
|
||||||
|
mux.Handle("/api/login", loginLimiter.Middleware(http.HandlerFunc(handlers.APILogin)))
|
||||||
|
mux.Handle("/api/login/2fa", loginLimiter.Middleware(http.HandlerFunc(handlers.APILoginTwoFactor)))
|
||||||
|
mux.Handle("/api/refresh", loginLimiter.Middleware(http.HandlerFunc(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/2fa/setup", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorSetup))))
|
||||||
|
mux.Handle("/api/2fa/enable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorEnable))))
|
||||||
|
mux.Handle("/api/2fa/disable", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.TwoFactorDisable))))
|
||||||
|
mux.Handle("/api/2fa/recovery-codes/regenerate", loginLimiter.Middleware(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", accountLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdateUsername))))
|
||||||
|
mux.Handle("/api/account/password", loginLimiter.Middleware(auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.AccountUpdatePassword))))
|
||||||
if this.AllowRegistration {
|
if this.AllowRegistration {
|
||||||
mux.HandleFunc("/api/register", handlers.APIRegister)
|
mux.Handle("/api/register", loginLimiter.Middleware(http.HandlerFunc(handlers.APIRegister)))
|
||||||
}
|
}
|
||||||
|
|
||||||
mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item)))
|
mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item)))
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
if err := ensureUserTwoFactorColumns(); err != nil {
|
||||||
|
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 EnableUserTwoFactorWithSecretAndRecoveryCodes(userID, twoFactorSecret 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, two_factor_secret = ? WHERE id = ?", twoFactorSecret, 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 (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
|||||||
Reference in New Issue
Block a user