Compare commits
38 Commits
v1.0.2
...
feature/13
| Author | SHA1 | Date | |
|---|---|---|---|
|
0442e4f699
|
|||
|
96f1a40266
|
|||
|
959ba7d2d1
|
|||
|
c579fc95be
|
|||
|
d70aa85f99
|
|||
|
e5276053f2
|
|||
|
9f9386eba8
|
|||
|
1ff7d6b776
|
|||
|
9fd789bb6a
|
|||
|
043d4c0d5e
|
|||
|
59ba5a00e1
|
|||
|
50da145feb
|
|||
|
0b5943f792
|
|||
|
ba31c4f582
|
|||
|
370b875df1
|
|||
|
9393004434
|
|||
|
aec68c3ea5
|
|||
|
baad115f01
|
|||
|
09e1b2bcdc
|
|||
|
2ffe17ca60
|
|||
|
3596998f28
|
|||
|
2dc854c65a
|
|||
|
c080c51aec
|
|||
|
fb3be56959
|
|||
|
01ec41288a
|
|||
|
afea712f43
|
|||
|
d854c8e02a
|
|||
|
36ff377ac9
|
|||
|
2c30f9c055
|
|||
|
6acfde5617
|
|||
|
58f098d4ca
|
|||
|
ae41b96fa4
|
|||
|
fabe5319ae
|
|||
|
e2926df62c
|
|||
|
ea8ea45c4c
|
|||
|
26b363fc34
|
|||
|
4700faf03c
|
|||
|
79f3692ad2
|
24
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
24
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: "Bug Report"
|
||||||
|
about: Report an error or unexpected behavior in MiauInv
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: ["Kind/Bug"]
|
||||||
|
---
|
||||||
|
|
||||||
|
### Description
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
### Steps to Reproduce
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Scroll down to '...'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
### Expected Behavior
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
### Logs and Error Messages
|
||||||
|
If applicable, add server logs or browser console outputs here:
|
||||||
|
```text
|
||||||
|
Insert logs here
|
||||||
|
```
|
||||||
18
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
18
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: "Feature Request"
|
||||||
|
about: Suggest an idea or enhancement for MiauInv
|
||||||
|
title: "[FEATURE] "
|
||||||
|
labels: ["Kind/Feature"]
|
||||||
|
---
|
||||||
|
|
||||||
|
### Problem Statement
|
||||||
|
Is your feature request related to a problem? Please describe. (e.g., I am frustrated when...)
|
||||||
|
|
||||||
|
### Proposed Solution
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
### Alternative Solutions
|
||||||
|
A clear and concise description of any alternative solutions or features you have considered.
|
||||||
|
|
||||||
|
### Additional Context
|
||||||
|
Add any other context, screenshots, or mockup ideas about the feature request here.
|
||||||
14
.gitea/pull_request_template.md
Normal file
14
.gitea/pull_request_template.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## Description
|
||||||
|
Please include a summary of the changes and which issue is fixed.
|
||||||
|
|
||||||
|
Closes # (Issue Number)
|
||||||
|
|
||||||
|
## Type of Change
|
||||||
|
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change which adds functionality)
|
||||||
|
- [ ] Chore / Refactoring (code cleanup or configuration updates)
|
||||||
|
|
||||||
|
## Testing Environment
|
||||||
|
- [ ] Verified via local development server
|
||||||
|
- [ ] Verified compilation inside the Docker container
|
||||||
|
- [ ] All unit tests passing successfully
|
||||||
43
.gitea/workflows/release-docker.yaml
Normal file
43
.gitea/workflows/release-docker.yaml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Build and Push Docker Image on Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: docker-builder
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Enable QEMU for multi-arch builds
|
||||||
|
run: |
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||||
|
|
||||||
|
- name: Create and use Docker Buildx builder
|
||||||
|
run: |
|
||||||
|
docker buildx create --name miauinv-builder --use || docker buildx use miauinv-builder
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.miaurizius.de \
|
||||||
|
--username "${{ secrets.REGISTRY_USER }}" \
|
||||||
|
--password-stdin
|
||||||
|
|
||||||
|
- name: Prepare Docker image tags
|
||||||
|
run: |
|
||||||
|
IMAGE_REPO="$(echo "git.miaurizius.de/${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "IMAGE_REPO=$IMAGE_REPO" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build and push multi-arch Docker image
|
||||||
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--file ./Dockerfile \
|
||||||
|
--tag "$IMAGE_REPO:latest" \
|
||||||
|
--tag "$IMAGE_REPO:${{ gitea.event.release.tag_name }}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
33
.gitea/workflows/test-and-lint.yaml
Normal file
33
.gitea/workflows/test-and-lint.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: test-and-lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-lint:
|
||||||
|
runs-on: go-1.26
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: |
|
||||||
|
files="$(gofmt -l .)"
|
||||||
|
if [ -n "$files" ]; then
|
||||||
|
echo "$files"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check modules
|
||||||
|
run: |
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@ appdata
|
|||||||
.idea
|
.idea
|
||||||
*.exe
|
*.exe
|
||||||
*.cmd
|
*.cmd
|
||||||
.run
|
.run
|
||||||
|
*.out
|
||||||
238
CONTRIBUTING.md
Normal file
238
CONTRIBUTING.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# Contributing to MiauInv
|
||||||
|
|
||||||
|
Thank you for considering a contribution to MiauInv.
|
||||||
|
|
||||||
|
MiauInv is a self-hosted/private inventory management system. Contributions are accepted through pull requests only. Direct pushes to the `main` branch are not allowed.
|
||||||
|
|
||||||
|
## Contribution Model
|
||||||
|
|
||||||
|
All changes must be submitted through pull requests.
|
||||||
|
|
||||||
|
This applies to:
|
||||||
|
|
||||||
|
- new features,
|
||||||
|
- bug fixes,
|
||||||
|
- refactoring,
|
||||||
|
- documentation updates,
|
||||||
|
- CI/CD changes,
|
||||||
|
- security improvements,
|
||||||
|
- release preparation work.
|
||||||
|
|
||||||
|
Contributors should work from a fork of the repository. Changes should be developed in a dedicated branch in the fork and then submitted as a pull request to the upstream repository.
|
||||||
|
|
||||||
|
Code snippets, patches, or implementation ideas are only accepted into the project if they are submitted through a pull request.
|
||||||
|
|
||||||
|
## Protected Main Branch
|
||||||
|
|
||||||
|
The `main` branch is protected.
|
||||||
|
|
||||||
|
The following rules apply:
|
||||||
|
|
||||||
|
- nobody may push directly to `main`,
|
||||||
|
- all changes must go through pull requests,
|
||||||
|
- pull requests must target `main` unless a maintainer explicitly requests a different target,
|
||||||
|
- every commit in a pull request must be signed,
|
||||||
|
- unsigned commits will not be accepted.
|
||||||
|
|
||||||
|
## Signed Commits
|
||||||
|
|
||||||
|
All commits must be signed.
|
||||||
|
|
||||||
|
This is mandatory for:
|
||||||
|
|
||||||
|
- commits on feature branches,
|
||||||
|
- commits on bugfix branches,
|
||||||
|
- commits on chore/refactoring branches,
|
||||||
|
- commits in pull requests,
|
||||||
|
- release preparation commits.
|
||||||
|
|
||||||
|
Pull requests containing unsigned commits must be fixed before they can be merged.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -S -m "feat(auth): add account recovery flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether commits are signed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --show-signature
|
||||||
|
```
|
||||||
|
|
||||||
|
If a commit was created without a signature, rewrite or recreate it before opening the pull request.
|
||||||
|
|
||||||
|
## Branch Naming
|
||||||
|
|
||||||
|
Branches should use lowercase, hyphen-separated descriptions and include the related issue ID where possible.
|
||||||
|
|
||||||
|
Recommended format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<type>/<issue-id>-<lowercase-description>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
feature/5-mfa-support
|
||||||
|
bugfix/12-fix-refresh-token-rotation
|
||||||
|
chore/18-update-ci-workflow
|
||||||
|
docs/21-update-authentication-docs
|
||||||
|
refactor/24-clean-up-storage-layer
|
||||||
|
```
|
||||||
|
|
||||||
|
Common branch types:
|
||||||
|
|
||||||
|
| Type | Usage |
|
||||||
|
| --- | --- |
|
||||||
|
| `feature` | New functionality. |
|
||||||
|
| `bugfix` | Bug fixes. |
|
||||||
|
| `chore` | Maintenance work, tooling, dependency updates, CI changes. |
|
||||||
|
| `docs` | Documentation-only changes. |
|
||||||
|
| `refactor` | Code restructuring without functional behavior changes. |
|
||||||
|
| `release` | Release preparation branches. |
|
||||||
|
|
||||||
|
## Feature and Bugfix Workflow
|
||||||
|
|
||||||
|
When working on a feature, bugfix, or other change:
|
||||||
|
|
||||||
|
1. Create or choose the related issue.
|
||||||
|
2. Fork the repository.
|
||||||
|
3. Create a branch in your fork using the branch naming scheme.
|
||||||
|
4. Implement the change.
|
||||||
|
5. Keep commits signed.
|
||||||
|
6. Push the branch to your fork.
|
||||||
|
7. Open a pull request against the upstream `main` branch.
|
||||||
|
8. Use the pull request template.
|
||||||
|
9. Ensure the pull request describes the change and links the issue.
|
||||||
|
|
||||||
|
When a feature, bugfix, or chore branch is complete, a pull request may be created from that branch into `main`.
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
Pull requests must:
|
||||||
|
|
||||||
|
- use the repository pull request template,
|
||||||
|
- describe the change clearly,
|
||||||
|
- link the related issue where applicable,
|
||||||
|
- contain signed commits only,
|
||||||
|
- avoid unrelated changes,
|
||||||
|
- keep documentation updated when behavior changes,
|
||||||
|
- keep the implementation consistent with the existing code structure.
|
||||||
|
|
||||||
|
Pull requests should be focused. A pull request should usually address one feature, one bug, or one maintenance task.
|
||||||
|
|
||||||
|
If a pull request changes authentication, authorization, account security, database schema, or deployment behavior, the relevant documentation should be updated in the same pull request.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
Issues should use the available issue templates whenever possible.
|
||||||
|
|
||||||
|
A good issue should include:
|
||||||
|
|
||||||
|
- a clear description,
|
||||||
|
- expected behavior,
|
||||||
|
- actual behavior, if reporting a bug,
|
||||||
|
- reproduction steps, if applicable,
|
||||||
|
- relevant logs, screenshots, or configuration snippets,
|
||||||
|
- the affected version or branch, if known.
|
||||||
|
|
||||||
|
Feature requests should describe the use case and the expected behavior instead of only describing an implementation detail.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Release preparation work should happen on release branches.
|
||||||
|
|
||||||
|
Release branch format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
release/vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
release/v1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Version identifiers follow the `vX.Y.Z` format, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
v1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
When the changes for a version are complete:
|
||||||
|
|
||||||
|
1. finalize the release branch,
|
||||||
|
2. open a pull request from the release branch into `main`,
|
||||||
|
3. merge the release changes into `main`,
|
||||||
|
4. create the release,
|
||||||
|
5. update and publish Docker images.
|
||||||
|
|
||||||
|
Release branches should only contain changes intended for that release.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Documentation should be updated when a change affects:
|
||||||
|
|
||||||
|
- setup,
|
||||||
|
- configuration,
|
||||||
|
- deployment,
|
||||||
|
- API behavior,
|
||||||
|
- authentication,
|
||||||
|
- authorization,
|
||||||
|
- database schema,
|
||||||
|
- security behavior,
|
||||||
|
- CI/CD behavior.
|
||||||
|
|
||||||
|
Documentation should be technical, accurate, and written in English.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
Follow the existing project structure and style.
|
||||||
|
|
||||||
|
General expectations:
|
||||||
|
|
||||||
|
- keep handlers in `handlers/`,
|
||||||
|
- keep database access in `storage/`,
|
||||||
|
- keep shared types in `models/`,
|
||||||
|
- keep authentication helpers in `auth/`,
|
||||||
|
- keep frontend behavior in `frontend/assets/js/`,
|
||||||
|
- keep server route wiring in `server/`,
|
||||||
|
- use clear names,
|
||||||
|
- avoid unrelated rewrites,
|
||||||
|
- keep changes as small as reasonably possible.
|
||||||
|
|
||||||
|
Go code must be formatted with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -w .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Before opening a pull request, run the checks that are relevant to the change.
|
||||||
|
|
||||||
|
Recommended commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -w .
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
If the change affects Docker deployment, also verify that the Docker image builds successfully.
|
||||||
|
|
||||||
|
## Security-Relevant Changes
|
||||||
|
|
||||||
|
Changes affecting authentication, authorization, session handling, 2FA, recovery codes, cookies, rate limiting, or database security should be kept especially focused.
|
||||||
|
|
||||||
|
Security-sensitive pull requests should clearly explain:
|
||||||
|
|
||||||
|
- what behavior changed,
|
||||||
|
- what threat or issue is addressed,
|
||||||
|
- whether sessions, tokens, or stored secrets are affected,
|
||||||
|
- whether existing users or deployments need migration steps.
|
||||||
|
|
||||||
|
Do not include secrets, private keys, real tokens, production database files, or private configuration values in commits or pull requests.
|
||||||
289
README.md
289
README.md
@@ -1,169 +1,99 @@
|
|||||||
# MiauInv - Inventory and Project Management System
|
# MiauInv
|
||||||
|
|
||||||
MiauInv is a secure, light-weight inventory, stock, and project allocation tracking system written in Go. It utilizes HTMX for a dynamic, reactive single-page experience over traditional server-rendered HTML blocks, backed by an encrypted JWT and dual-token refresh architecture alongside an embedded SQLite instance.
|
MiauInv is a secure, lightweight inventory, stock, and project allocation tracking system written in Go. It uses server-rendered HTML blocks with a small HTMX-style frontend layer for a dynamic single-page feel, backed by signed JWT access tokens, database-backed refresh-token rotation, optional TOTP 2FA, optional WebAuthn passkeys, an authenticated activity log, and embedded SQLite storage.
|
||||||
|
|
||||||
## Table of Contents
|
It is built for self-hosted/private deployments where you want a small, understandable asset and stock tracker without running a large enterprise inventory suite.
|
||||||
|
|
||||||
* [Technical Specifications](#technical-specifications)
|
## Features
|
||||||
* [Architecture Overview](#architecture-overview)
|
|
||||||
* [Detailed Documentation](#detailed-documentation)
|
|
||||||
* [Configuration](#configuration)
|
|
||||||
* [Configuration File (config.yaml)](#configuration-file-configyaml)
|
|
||||||
* [Environment Variables](#environment-variables)
|
|
||||||
* [Route and Endpoint Matrix](#route-and-endpoint-matrix)
|
|
||||||
* [Frontend Web Routes (HTML Views)](#frontend-web-routes-html-views)
|
|
||||||
* [Backend API Endpoints (JSON Serialization)](#backend-api-endpoints-json-serialization)
|
|
||||||
* [Setup and Deployment Tutorial](#setup-and-deployment-tutorial)
|
|
||||||
* [Prerequisites](#prerequisites)
|
|
||||||
* [Option 1: Native Local Deployment](#option-1-native-local-deployment)
|
|
||||||
* [Option 2: Docker Deployment (Recommended)](#option-2-docker-deployment-recommended)
|
|
||||||
* [Reverse Proxy Integration with Caddy](#reverse-proxy-integration-with-caddy)
|
|
||||||
* [Images](#images)
|
|
||||||
|
|
||||||
## Technical Specifications
|
- Inventory item tracking with categories, descriptions, and quantities.
|
||||||
|
- Location management for physical or logical storage places.
|
||||||
|
- Stock distribution between items and locations.
|
||||||
|
- Project allocation tracking for reserving item quantities for projects.
|
||||||
|
- Dashboard overview for items, locations, and projects.
|
||||||
|
- Account settings for username, password, 2FA, and passkey management.
|
||||||
|
- Optional TOTP two-factor authentication with QR setup and recovery codes.
|
||||||
|
- Optional discoverable WebAuthn passkey login.
|
||||||
|
- Activity log for account, authentication, security, and inventory changes.
|
||||||
|
- Docker-ready deployment with SQLite persistence.
|
||||||
|
|
||||||
* **Backend Language:** Go (Golang 1.22+)
|
## Current Status
|
||||||
* **Frontend Interactivity:** HTMX (v2.0.4) & Vanilla JavaScript (API Client integration)
|
|
||||||
* **Database Engine:** SQLite (via standard driver dependencies)
|
|
||||||
* **Security & Session Layer:** JSON Web Tokens (JWT) with HTTP-Only/Secure dual cookie strategy (Access and Refresh Token Rotation)
|
|
||||||
* **Styling Architecture:** Modern dark-theme customized native CSS variables (`--bg`, `--card`, `--accent`, `--border`)
|
|
||||||
* **Transport Security:** Compulsory HTTPS / Native TLS Listener implementation
|
|
||||||
|
|
||||||
---
|
MiauInv is an active private project. Core inventory workflows, authentication, account security settings, passkeys, and activity logging are implemented. Automated testing is currently limited and should be expanded before treating the project as high-assurance software.
|
||||||
|
|
||||||
## Architecture Overview
|
## Technical Stack
|
||||||
|
|
||||||
MiauInv splits responsibility cleanly across modularized architecture packages:
|
| 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` |
|
||||||
|
| Passkeys | WebAuthn via `github.com/go-webauthn/webauthn` |
|
||||||
|
| Frontend | Server-rendered HTML, HTMX-style structure, vanilla JavaScript |
|
||||||
|
| Deployment | Docker / Docker Compose |
|
||||||
|
|
||||||
* **`main.go`**: Entrypoint initializing components and connecting layers.
|
## Project Structure
|
||||||
* **`server/`**: Configures variables, spins up TLS mechanisms, and exposes route endpoints.
|
|
||||||
* **`auth/`**: Custom HTTP Middleware interceptors validating JWT signatures and parsing sub-claims.
|
|
||||||
* **`handlers/`**: Core API Controller actions processing CRUD functions on database entities.
|
|
||||||
* **`storage/`**: Direct abstraction queries interacting with the underlying SQLite database schema.
|
|
||||||
* **`frontend/`**: Serving standard static assets and injecting structural data into components.
|
|
||||||
|
|
||||||
---
|
| Path | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `main.go` | Application entrypoint. |
|
||||||
|
| `server/` | Route registration, TLS listener, and rate limiting. |
|
||||||
|
| `config/` | Runtime configuration creation and loading. |
|
||||||
|
| `auth/` | JWT, middleware, roles, and password helpers. |
|
||||||
|
| `handlers/` | JSON API handlers for auth, activity, account security, and inventory. |
|
||||||
|
| `storage/` | SQLite schema, migrations, and database access helpers. |
|
||||||
|
| `models/` | Shared data structures and constants. |
|
||||||
|
| `frontend/` | HTML templates, CSS, and JavaScript. |
|
||||||
|
| `docs/` | Technical documentation. |
|
||||||
|
|
||||||
---
|
## Documentation
|
||||||
|
|
||||||
## Detailed Documentation
|
- [Authentication](docs/AUTHENTICATION.md)
|
||||||
|
- [Database](docs/DATABASE.md)
|
||||||
For deep dives into specific subsystems, database layouts, and security mechanisms, please refer to the dedicated documentation files:
|
- [API Endpoints](docs/ENDPOINTS.md)
|
||||||
|
- [Security](docs/SECURITY.md)
|
||||||
* **[Database Schema & Integrity](docs/DATABASE.md):** Comprehensive breakdown of the SQLite table structures, fields, and foreign key relations.
|
- [Contributing](CONTRIBUTING.md)
|
||||||
* **[Authentication Architecture](docs/AUTHENTICATION.md):** Detailed explanation of the dual-token rotation flow, JWT lifecycle, and frontend loop protection.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The system uses a combination of a structural JSON configuration file and environment variables for system runtime flags.
|
On first startup, MiauInv creates a config file if none exists. The server also requires a `JWT_SECRET` environment variable with at least 32 characters.
|
||||||
|
|
||||||
### Configuration File (`config.yaml`)
|
```bash
|
||||||
|
export JWT_SECRET="replace-this-with-a-random-secret-of-at-least-32-chars"
|
||||||
The application automatically creates or reads a configuration file named `config.json` in the working directory on startup.
|
```
|
||||||
|
|
||||||
```yaml
|
For local development, generate TLS files first:
|
||||||
port: "8080"
|
|
||||||
database_path: ./appdata/database.db
|
|
||||||
certificate_path: ./appdata/cert.pem
|
|
||||||
private_key_path: ./appdata/key.pem
|
|
||||||
allow_registration: true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
For cryptographic functions, a mandatory environment variable must be exported before executing the binary:
|
|
||||||
|
|
||||||
| Variable | Type | Description | Minimum Requirement |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `JWT_SECRET` | String | Symmetric secret signature key used to sign access tokens | Minimum 32 characters |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Route and Endpoint Matrix
|
|
||||||
|
|
||||||
All communication with the application happens over defined HTTP transport interfaces. The routes are divided into User-Facing Views (HTML renders) and programmatic Data Hooks (JSON APIs).
|
|
||||||
|
|
||||||
### Frontend Web Routes (HTML Views)
|
|
||||||
|
|
||||||
| Route Path | HTTP Method | Auth Required | Description |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `/` | `GET` | No | Root index landing view. |
|
|
||||||
| `/login` | `GET` | No | Renders login page component. |
|
|
||||||
| `/register` | `GET` | No | Registration layout or block alert based on authorization properties. |
|
|
||||||
| `/dashboard` | `GET` | **Yes** | Aggregated stats layout covering items, projects, and locations. |
|
|
||||||
| `/inventory` | `GET` | **Yes** | General overview interface managing stock quantities. |
|
|
||||||
| `/items` | `GET` | **Yes** | Standard component interface targeting primary atomic assets. |
|
|
||||||
| `/locations` | `GET` | **Yes** | Physical or logical facility structures view. |
|
|
||||||
| `/projects` | `GET` | **Yes** | Overview interface listing active construction and logistics operations. |
|
|
||||||
| `/profile/` | `GET` | **Yes** | Component context under development. |
|
|
||||||
| `/assets/*` | `GET` | No | Serves global minified system design files (`.css`, `.js`). |
|
|
||||||
|
|
||||||
### Backend API Endpoints (JSON Serialization)
|
|
||||||
|
|
||||||
| Endpoint Path | Method | Auth | Query Parameters | Request/Response Behavior |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| `/api/register` | `POST` | No | None | Creates a new user record. Requires plain JSON payload containing raw `username` and `password`. Returns status code `201 Created`. |
|
|
||||||
| `/api/login` | `POST` | No | None | Performs credential authentication. Sets `access_token` and `refresh_token` as secure, HTTP-Only cookies, and returns user identity metadata. |
|
|
||||||
| `/api/refresh` | `POST` | No | None | Accepts JSON containing `refresh_token`. Invalidates previous token structures, rotates identities, and hands over a newly active pair. |
|
|
||||||
| `/api/logout` | `POST` | **Yes** | None | Revokes active database-linked refresh session IDs for the user context and drops current browser state tracking. Returns status `204`. |
|
|
||||||
| `/api/profile` | `GET` | **Yes** | `id` *(Optional)* | Returns `id`, `username`, and metadata of either the target parameter or active identity mapped from token signatures. |
|
|
||||||
| `/api/item` | `GET` | **Yes** | `id` *(Optional)* | Empty parameters fetch all available items with aggregated `allocated` and `available` calculations. Providing `id` isolates a specific item. |
|
|
||||||
| `/api/item` | `POST` | **Yes** | None | Inserts a new tracked inventory item schema definition. |
|
|
||||||
| `/api/item` | `PUT` | **Yes** | `id` *(Required)* | Modifies values (`name`, `category`, `description`, `total_quantity`) of an active asset by primary key. |
|
|
||||||
| `/api/item` | `DELETE` | **Yes** | `id` *(Required)* | Removes an entry. SQLite blocks cascade execution if foreign key assignments still exist in stock or project tracking. |
|
|
||||||
| `/api/location` | `GET` | **Yes** | `id`, `content` | Fetching with `id` and `content=true` extracts an array of items grouped at the facility (`item_id`, `name`, `quantity`). Without `content`, returns details or global catalogs. |
|
|
||||||
| `/api/location` | `POST` | **Yes** | None | Instantiates a singular distinct location boundary identifier. |
|
|
||||||
| `/api/location` | `PUT` | **Yes** | `id` *(Required)* | Renames a location while maintaining foreign keys. |
|
|
||||||
| `/api/location` | `DELETE` | **Yes** | `id` *(Required)* | Destroys location configurations if currently cleared of active items. |
|
|
||||||
| `/api/project` | `GET` | **Yes** | `id`, `details` | Providing `id` with `details=true` unrolls associated items allocated to that project context. |
|
|
||||||
| `/api/project` | `POST` | **Yes** | None | Inserts a tracking context entity for targeted hardware allocation. |
|
|
||||||
| `/api/project` | `PUT` | **Yes** | `id` *(Required)* | Updates a project metadata record definition. |
|
|
||||||
| `/api/project` | `DELETE` | **Yes** | `id` *(Required)* | Drops an empty project wrapper. |
|
|
||||||
| `/api/stock` | `GET` | **Yes** | `id` *(Optional)* | Obtains exact relationship matrices between location nodes and items. |
|
|
||||||
| `/api/stock` | `POST` | **Yes** | None | Links allocations across specific site nodes. |
|
|
||||||
| `/api/stock` | `PUT` | **Yes** | `id` *(Required)* | Modifies stock metrics directly on specified maps. |
|
|
||||||
| `/api/stock` | `DELETE` | **Yes** | `id` *(Required)* | Completely severs relationship entry allocations between nodes. |
|
|
||||||
| `/api/association` | `GET` | **Yes** | `id`, `project_id` | Dumps general configuration links, isolated optionally by operational `project_id`. |
|
|
||||||
| `/api/association` | `POST` | **Yes** | None | Allocates an inventory batch to a dedicated project infrastructure requirement. |
|
|
||||||
| `/api/association` | `PUT` | **Yes** | `id` *(Required)* | Alters active quantity indicators inside a specific deployment matrix. |
|
|
||||||
| `/api/association` | `DELETE` | **Yes** | `id` *(Required)* | Frees up allocations, returning tracking variables back to unassigned states. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup and Deployment Tutorial
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
Before deployment, you must generate SSL/TLS certificates since MiauInv enforces native transport encryption layer communication (or use a bought one).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create directory for certs
|
|
||||||
mkdir -p appdata
|
mkdir -p appdata
|
||||||
|
openssl req -x509 -newkey rsa:4096 \
|
||||||
# Generate self-signed certificate and private key
|
-keyout appdata/key.pem \
|
||||||
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem -sha256 -days 365 -nodes
|
-out appdata/cert.pem \
|
||||||
|
-days 365 \
|
||||||
|
-nodes \
|
||||||
|
-subj "/CN=localhost"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 1: Native Local Deployment
|
Run locally:
|
||||||
|
|
||||||
1. Make sure your Go environment path variable properties are populated (Go 1.22+).
|
|
||||||
2. Create your environmental token and execute the initialization routine task:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export JWT_SECRET="your_minimum_thirty_two_char_secret_key_here"
|
go mod tidy
|
||||||
go build -o miauinv main.go
|
go build -o miauinv .
|
||||||
./miauinv
|
./miauinv
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Then open:
|
||||||
|
|
||||||
### Option 2: Docker Deployment (Recommended)
|
```text
|
||||||
|
https://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
MiauInv includes container definition orchestrations to package configurations cleanly, binding storage databases outside running containers into persistent volumes.
|
## Docker Deployment
|
||||||
|
|
||||||
#### 1. Create the Docker Compose Descriptor
|
Example `docker-compose.yaml`:
|
||||||
|
|
||||||
Write the configuration definition mapping layer blocks directly inside a standard file named `docker-compose.yaml`:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -174,59 +104,38 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
- JWT_SECRET=SECURE_RANDOM_STRING # Must be at least 32 characters long
|
- JWT_SECRET=replace-this-with-a-random-secret-of-at-least-32-chars
|
||||||
volumes:
|
|
||||||
- ./appdata:/appdata # To edit your configuration files
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Execution Commands
|
|
||||||
|
|
||||||
To bring up your background container image instance pipelines, execute the compose environment controls:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build and execute the container in background detached mode
|
|
||||||
docker-compose up --build -d
|
|
||||||
|
|
||||||
# Verify container operation statuses
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# Monitor execution system logs
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
Once running successfully via Docker orchestration loops, navigate your web browser context safely to `https://localhost:8080` to interact with your MiauInv control panel workspace.
|
|
||||||
|
|
||||||
## Reverse Proxy Integration with Caddy
|
|
||||||
|
|
||||||
If you deploy MiauInv behind a global Caddy server, Caddy must act as an HTTPS reverse proxy. Since the MiauInv binary enforces native TLS transport, Caddy needs to be configured to establish a secure backend connection and bypass verification for self-signed backend certificates.
|
|
||||||
|
|
||||||
### 1. Docker Compose Network Configuration
|
|
||||||
Ensure your MiauInv container shares an external network with your Caddy container (e.g., a network named `proxy`). The container does not need to expose public ports since Caddy communicates with it internally over port `8080`.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
miauinv:
|
|
||||||
image: git.miaurizius.de/miaurizius/miauinv:latest
|
|
||||||
container_name: MiauInv
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
environment:
|
|
||||||
- JWT_SECRET=SECURE_RANDOM_STRING
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./appdata:/appdata
|
- ./appdata:/appdata
|
||||||
|
|
||||||
networks:
|
|
||||||
proxy:
|
|
||||||
external: true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Caddyfile Configuration
|
Start or update the container:
|
||||||
|
|
||||||
Add the following block to your server's `Caddyfile`. The `https://` prefix forces Caddy to use TLS for the backend connection, and `tls_insecure_skip_verify` allows the proxy to accept the internal self-signed certificate generated during the prerequisites step.
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
View logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
For production updates, pin a versioned image tag and back up `appdata` before upgrading:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image: git.miaurizius.de/miaurizius/miauinv:v1.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse Proxy Deployment
|
||||||
|
|
||||||
|
MiauInv currently listens with native TLS. When using Caddy or another reverse proxy, either proxy to the backend with HTTPS or intentionally adjust the server to listen without TLS.
|
||||||
|
|
||||||
|
Example Caddy configuration with a self-signed backend certificate:
|
||||||
|
|
||||||
```caddy
|
```caddy
|
||||||
inv.yourdomain.com {
|
inv.example.com {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
|
|
||||||
reverse_proxy https://miauinv:8080 {
|
reverse_proxy https://miauinv:8080 {
|
||||||
@@ -238,20 +147,14 @@ inv.yourdomain.com {
|
|||||||
header {
|
header {
|
||||||
X-Content-Type-Options nosniff
|
X-Content-Type-Options nosniff
|
||||||
Referrer-Policy strict-origin-when-cross-origin
|
Referrer-Policy strict-origin-when-cross-origin
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Apply Configuration
|
For Docker deployments, place Caddy and MiauInv on the same Docker network and reverse proxy to the service name.
|
||||||
|
|
||||||
Reload your Caddy instance to apply the reverse proxy routing rules:
|
## Screenshots
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -w /etc/caddy caddy caddy reload
|
|
||||||
```
|
|
||||||
|
|
||||||
## Images
|
|
||||||
#### Dashboard
|
#### Dashboard
|
||||||
<img src="docs/img/dashboard.png">
|
<img src="docs/img/dashboard.png">
|
||||||
|
|
||||||
@@ -262,4 +165,4 @@ docker compose exec -w /etc/caddy caddy caddy reload
|
|||||||
<img src="docs/img/locations.png">
|
<img src="docs/img/locations.png">
|
||||||
|
|
||||||
#### Projects
|
#### Projects
|
||||||
<img src="docs/img/projects.png">
|
<img src="docs/img/projects.png">
|
||||||
|
|||||||
481
ad.html
481
ad.html
@@ -1,359 +1,173 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-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>MiauInv - Modern Inventory & Project Management</title>
|
<title>MiauInv - Lightweight Inventory Tracking</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0f19;
|
--bg: #0b0f19;
|
||||||
--card: #111827;
|
--card: #111827;
|
||||||
--border: #1f2937;
|
--card-soft: #151f31;
|
||||||
|
--border: #233047;
|
||||||
--text: #f9fafb;
|
--text: #f9fafb;
|
||||||
--text-muted: #9ca3af;
|
--muted: #9ca3af;
|
||||||
--accent: #3b82f6;
|
--accent: #3b82f6;
|
||||||
--accent-hover: #2563eb;
|
|
||||||
--success: #10b981;
|
--success: #10b981;
|
||||||
--code-bg: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg);
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 34rem),
|
||||||
|
radial-gradient(circle at bottom right, rgba(16, 185, 129, 0.10), transparent 30rem),
|
||||||
|
var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container { max-width: 1120px; margin: 0 auto; padding: 1.25rem 1.5rem 4rem; }
|
||||||
|
.topbar { display: flex; justify-content: flex-end; margin-bottom: 3rem; }
|
||||||
.lang-switch {
|
.lang-switch {
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(17, 24, 39, 0.8);
|
||||||
color: var(--text);
|
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;
|
border-radius: 999px;
|
||||||
font-size: 0.85rem;
|
padding: 0.45rem 0.9rem;
|
||||||
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;
|
font-weight: 700;
|
||||||
margin-bottom: 1.25rem;
|
cursor: pointer;
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
|
.hero { text-align: center; padding: 2.5rem 0 3.5rem; }
|
||||||
.features-list {
|
.brand { font-size: clamp(3rem, 7vw, 5.5rem); font-weight: 900; letter-spacing: -0.07em; line-height: 0.95; }
|
||||||
list-style: none;
|
.brand span { color: var(--accent); }
|
||||||
text-align: left;
|
.tagline { max-width: 820px; margin: 1.25rem auto 0; color: var(--muted); font-size: clamp(1.05rem, 2vw, 1.35rem); }
|
||||||
}
|
.hero-actions { display: flex; justify-content: center; flex-wrap: wrap; gap: 0.8rem; margin-top: 1.75rem; }
|
||||||
|
.hero-link {
|
||||||
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 0.5rem;
|
||||||
font-size: 0.8rem;
|
border-radius: 999px;
|
||||||
font-weight: bold;
|
padding: 0.75rem 1.1rem;
|
||||||
}
|
text-decoration: none;
|
||||||
|
font-weight: 800;
|
||||||
pre {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 1rem 1.25rem;
|
color: var(--text);
|
||||||
border-radius: 10px;
|
background: rgba(17, 24, 39, 0.78);
|
||||||
font-family: monospace;
|
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
|
||||||
font-size: 0.9rem;
|
}
|
||||||
color: #e5e7eb;
|
.hero-link:hover { transform: translateY(-1px); border-color: rgba(59, 130, 246, 0.7); background: rgba(59, 130, 246, 0.12); }
|
||||||
|
.hero-link.primary { border-color: rgba(59, 130, 246, 0.48); background: rgba(59, 130, 246, 0.18); color: #dbeafe; }
|
||||||
|
.hero-link svg { width: 1rem; height: 1rem; }
|
||||||
|
.badges { display: flex; justify-content: center; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; }
|
||||||
|
.badge {
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.28);
|
||||||
|
background: rgba(59, 130, 246, 0.10);
|
||||||
|
color: #bfdbfe;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.badge.green { border-color: rgba(16, 185, 129, 0.28); background: rgba(16, 185, 129, 0.10); color: #a7f3d0; }
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 1rem 0 3rem; }
|
||||||
|
.card {
|
||||||
|
background: linear-gradient(180deg, rgba(21, 31, 49, 0.92), rgba(17, 24, 39, 0.92));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 1.1rem; margin-bottom: 0.65rem; }
|
||||||
|
.card p { color: var(--muted); }
|
||||||
|
.check { color: var(--success); font-weight: 900; margin-right: 0.4rem; }
|
||||||
|
|
||||||
|
.install {
|
||||||
|
background: rgba(17, 24, 39, 0.82);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.install h2 { font-size: 1.7rem; margin-bottom: 0.5rem; }
|
||||||
|
.install > p { color: var(--muted); margin-bottom: 1.25rem; }
|
||||||
|
pre {
|
||||||
|
background: #050816;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: pre;
|
color: #e5e7eb;
|
||||||
margin-bottom: 1rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
footer { text-align: center; color: var(--muted); padding-top: 3rem; }
|
||||||
|
|
||||||
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; }
|
[lang="de"] { display: none; }
|
||||||
html[data-lang="de"] [lang="de"] { display: block; }
|
html[data-lang="de"] [lang="de"] { display: initial; }
|
||||||
html[data-lang="de"] [lang="en"] { display: none; }
|
html[data-lang="de"] [lang="en"] { display: none; }
|
||||||
|
html[data-lang="de"] p[lang="de"], html[data-lang="de"] div[lang="de"] { display: block; }
|
||||||
|
html[data-lang="de"] span[lang="de"], html[data-lang="de"] strong[lang="de"] { display: inline; }
|
||||||
|
|
||||||
html[data-lang="de"] span[lang="de"],
|
@media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
|
||||||
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="lang-en">
|
<body>
|
||||||
|
|
||||||
<div class="navbar">
|
|
||||||
<button class="lang-switch" id="langBtn" onclick="toggleLanguage()">DE</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<div class="topbar">
|
||||||
<div class="brand">Miau<span>Inv</span></div>
|
<button class="lang-switch" id="langBtn" onclick="toggleLanguage()">DE</button>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="install-section">
|
<section class="hero">
|
||||||
<h2 lang="en">Install Your Server</h2>
|
<div class="brand">Miau<span>Inv</span></div>
|
||||||
<h2 lang="de">Server installieren</h2>
|
<p class="tagline" lang="en">Secure, lightweight inventory, stock, and project allocation tracking for self-hosted environments.</p>
|
||||||
|
<p class="tagline" lang="de">Sicheres, leichtgewichtiges Inventory-, Lager- und Projektzuweisungs-Tracking für Self-Hosting.</p>
|
||||||
<p lang="en">Deploy MiauInv to your server infrastructure or local machine in less than a minute utilizing our streamlined container configurations.</p>
|
<div class="hero-actions">
|
||||||
<p lang="de">MiauInv lässt sich dank vorkonfiguriertem Docker-Setup innerhalb weniger Sekunden auf deinem Server oder lokalen System starten.</p>
|
<a class="hero-link primary" href="https://git.miaurizius.de/MiauRizius/MiauInv" target="_blank" rel="noopener noreferrer">
|
||||||
|
<span lang="en">View repository</span>
|
||||||
<div class="step">
|
<span lang="de">Repository ansehen</span>
|
||||||
<h3>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17L17 7"></path><path d="M7 7h10v10"></path></svg>
|
||||||
<span class="step-num">1</span>
|
</a>
|
||||||
<span lang="en">Setup Asset Directories and TLS</span>
|
<a class="hero-link" href="https://git.miaurizius.de/MiauRizius/MiauInv/packages" target="_blank" rel="noopener noreferrer">
|
||||||
<span lang="de">Ordner und Zertifikate vorbereiten</span>
|
<span lang="en">Container image</span>
|
||||||
</h3>
|
<span lang="de">Container-Image</span>
|
||||||
<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>
|
</a>
|
||||||
<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>
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge">Go</span>
|
||||||
|
<span class="badge">SQLite</span>
|
||||||
|
<span class="badge">HTMX-style UI</span>
|
||||||
|
<span class="badge green">Docker ready</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="step">
|
<section class="grid">
|
||||||
<h3>
|
<article class="card">
|
||||||
<span class="step-num">2</span>
|
<h2 lang="en"><span class="check">✓</span>Track stock clearly</h2>
|
||||||
<span lang="en">Configure Docker Compose</span>
|
<h2 lang="de"><span class="check">✓</span>Bestände klar verfolgen</h2>
|
||||||
<span lang="de">Docker Compose Datei anlegen</span>
|
<p lang="en">Manage items, locations, quantities, and project allocations from one small dashboard.</p>
|
||||||
</h3>
|
<p lang="de">Verwalte Items, Lagerorte, Mengen und Projektzuweisungen in einem kleinen Dashboard.</p>
|
||||||
<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>
|
</article>
|
||||||
<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>
|
<article class="card">
|
||||||
<pre>services:
|
<h2 lang="en"><span class="check">✓</span>Built-in account security</h2>
|
||||||
|
<h2 lang="de"><span class="check">✓</span>Account-Sicherheit inklusive</h2>
|
||||||
|
<p lang="en">Signed JWT sessions, refresh-token rotation, TOTP 2FA, recovery codes, passkeys, and an activity log.</p>
|
||||||
|
<p lang="de">Signierte JWT-Sessions, Refresh-Token-Rotation, TOTP-2FA, Recovery-Codes, Passkeys und Activity Log.</p>
|
||||||
|
</article>
|
||||||
|
<article class="card">
|
||||||
|
<h2 lang="en"><span class="check">✓</span>Small deployment footprint</h2>
|
||||||
|
<h2 lang="de"><span class="check">✓</span>Kleiner Deployment-Footprint</h2>
|
||||||
|
<p lang="en">A compiled Go binary with embedded SQLite persistence and a minimal Docker image.</p>
|
||||||
|
<p lang="de">Kompilierte Go-Binary mit eingebetteter SQLite-Persistenz und minimalem Docker-Image.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="install">
|
||||||
|
<h2 lang="en">Run with Docker Compose</h2>
|
||||||
|
<h2 lang="de">Mit Docker Compose starten</h2>
|
||||||
|
<p lang="en">Create an appdata directory, provide TLS files, set a strong JWT secret, and start the container.</p>
|
||||||
|
<p lang="de">Appdata-Ordner erstellen, TLS-Dateien bereitstellen, starkes JWT-Secret setzen und Container starten.</p>
|
||||||
|
<pre>services:
|
||||||
miauinv:
|
miauinv:
|
||||||
image: git.miaurizius.de/miaurizius/miauinv:latest
|
image: git.miaurizius.de/miaurizius/miauinv:latest
|
||||||
container_name: MiauInv
|
container_name: MiauInv
|
||||||
@@ -361,47 +175,26 @@ openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
environment:
|
environment:
|
||||||
- JWT_SECRET=SECURE_RANDOM_STRING_HERE_MIN_32_CHARS
|
- JWT_SECRET=replace-this-with-a-random-secret-of-at-least-32-chars
|
||||||
volumes:
|
volumes:
|
||||||
- ./appdata:/appdata</pre>
|
- ./appdata:/appdata</pre>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<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>
|
<footer>
|
||||||
<p lang="en">© 2026 Maurice Larivière.</p>
|
<p>© 2026 Maurice Larivière.</p>
|
||||||
<p lang="de">© 2026 Maurice Larivière.</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function setLanguage(lang) {
|
function setLanguage(lang) {
|
||||||
document.documentElement.setAttribute('data-lang', lang);
|
document.documentElement.setAttribute('data-lang', lang);
|
||||||
document.getElementById('langBtn').innerText = lang === 'en' ? 'DE' : 'EN';
|
document.getElementById('langBtn').innerText = lang === 'en' ? 'DE' : 'EN';
|
||||||
localStorage.setItem('miauinv-lang', lang);
|
localStorage.setItem('miauinv-lang', lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLanguage() {
|
function toggleLanguage() {
|
||||||
const currentLang = document.documentElement.getAttribute('data-lang') || 'en';
|
const current = document.documentElement.getAttribute('data-lang') || 'en';
|
||||||
const nextLang = currentLang === 'en' ? 'de' : 'en';
|
setLanguage(current === 'en' ? 'de' : 'en');
|
||||||
setLanguage(nextLang);
|
|
||||||
}
|
}
|
||||||
|
setLanguage(localStorage.getItem('miauinv-lang') || 'en');
|
||||||
const savedLang = localStorage.getItem('miauinv-lang') || 'en';
|
|
||||||
setLanguage(savedLang);
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
sudo docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
-t git.miaurizius.de/miaurizius/miauinv:latest \
|
|
||||||
-t git.miaurizius.de/miaurizius/miauinv:v1.0.2 \
|
|
||||||
--push .
|
|
||||||
@@ -1,51 +1,285 @@
|
|||||||
# Authentication Architecture
|
# Authentication Architecture
|
||||||
|
|
||||||
MiauInv implements a stateless JSON Web Token (JWT) architecture combined with a persistent database-backed Refresh Token mechanism to provide high security alongside seamless session retention.
|
MiauInv uses signed JWT access tokens, database-backed refresh tokens, optional TOTP-based two-factor authentication, and optional WebAuthn passkey authentication. The authentication flow is implemented in the `auth/`, `handlers/`, `storage/`, and `frontend/assets/js/` packages.
|
||||||
|
|
||||||
## Token Lifetime and Properties
|
JWTs are signed with a symmetric secret from the `JWT_SECRET` environment variable. They are not encrypted. 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.
|
||||||
|
|
||||||
| Token Type | Transport Vector | Storage Location | Lifetime | Purpose |
|
## Components
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| **Access Token** | HTTP-Only Cookie & Auth Header | Memory / Browser Cookies | 15 Minutes | Signed payload validating current session identity for immediate API interaction. |
|
|
||||||
| **Refresh Token** | Secure Cookie & JSON Payload | LocalStorage / Secure Cookies | 7 Days | Long-lived high-entropy string used to request a new token pair when the Access Token expires. |
|
|
||||||
|
|
||||||
---
|
| Component | Location | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| JWT helpers | `auth/jwt.go` | Access-token and purpose-token generation/validation. |
|
||||||
|
| Middleware | `auth/middleware.go` | Extracts access tokens from bearer headers or cookies and injects claims into the request context. |
|
||||||
|
| Password helpers | `auth/password.go` | bcrypt hashing and verification. |
|
||||||
|
| Login/account handlers | `handlers/account.go` | Register, login, 2FA, refresh, logout, account settings, and user metadata. |
|
||||||
|
| Activity handlers | `handlers/activity.go` | Activity log endpoint, activity metadata recording, and audit middleware. |
|
||||||
|
| Passkey handlers | `handlers/passkeys.go` | WebAuthn/passkey registration, login, removal, and disable flows. |
|
||||||
|
| Persistent session storage | `storage/storage.go`, `storage/passkeys.go` | Refresh tokens, 2FA state, TOTP secret, recovery-code hashes, passkey credentials, and WebAuthn challenge state. |
|
||||||
|
| Frontend auth logic | `frontend/assets/js/auth.js`, `frontend/assets/js/login.js`, `frontend/assets/js/api.js` | Login UI, passkey login, token refresh, account settings, 2FA UI interactions, and passkey UI interactions. |
|
||||||
|
|
||||||
## Token Rotation and Flow
|
## Token Types
|
||||||
|
|
||||||
The application coordinates token validation through cooperative interactions between Go authentication middlewares and the frontend runtime environment.
|
| Token | Storage/Transport | Lifetime | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Access token | `access_token` HTTP-only secure cookie and JSON response body | 15 minutes | Authenticates normal API and page requests. |
|
||||||
|
| Refresh token | `refresh_token` HTTP-only secure cookie and JSON response body | 7 days | Rotates sessions after access-token expiry. Stored in the database only as a hash. |
|
||||||
|
| 2FA challenge token | JSON response from `/api/login` | 5 minutes | Allows `/api/login/2fa` to complete login after password verification. It is purpose-bound to `2fa_login`. |
|
||||||
|
| 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`. |
|
||||||
|
| Passkey ceremony token | JSON response from `/api/passkeys/*/options` | 5 minutes | Opaque server-side reference to WebAuthn session data stored in `passkey_challenges`. Used for passkey registration and login. |
|
||||||
|
|
||||||
### 1. Normal Authenticated Requests
|
## Normal Login Flow Without 2FA
|
||||||
During standard interaction loops, the Go server intercepts requests via auth middleware. It checks the incoming context for validity in the following order:
|
|
||||||
1. `Authorization: Bearer <token>` request header.
|
|
||||||
2. `access_token` cookie values.
|
|
||||||
|
|
||||||
If a valid, unexpired Access Token is recovered, the middleware parses the claims (ID, username, role) and injects them into the request context before execution routes fire.
|
1. Client sends `POST /api/login` with `username` and `password`.
|
||||||
|
2. Server loads the user by username.
|
||||||
|
3. Server verifies the bcrypt password hash.
|
||||||
|
4. If 2FA is disabled, the server issues:
|
||||||
|
- a signed access token,
|
||||||
|
- a high-entropy refresh token,
|
||||||
|
- HTTP-only secure cookies for both tokens,
|
||||||
|
- a JSON response containing the same token values and user metadata.
|
||||||
|
5. The refresh token is hashed before being stored in the `refresh_tokens` table.
|
||||||
|
|
||||||
### 2. Token Refresh Flow
|
## Login Flow With 2FA Enabled
|
||||||
When an Access Token expires mid-session, the following workflow occurs automatically:
|
|
||||||
1. The backend rejects an API call or routing intent with an HTTP state indicating token expiration.
|
|
||||||
2. The frontend execution scope identifies the expiration status and reads the `refresh_token` from storage assets.
|
|
||||||
3. The client submits a POST request containing the token payload to `/api/refresh`.
|
|
||||||
4. The backend verifies the signature, looks up the hash inside the `refresh_tokens` table, and verifies that `revoked == 0` and `expires_at > now`.
|
|
||||||
5. If the validation succeeds, a brand-new Access Token and a rotated Refresh Token pair are generated, saved to secure cookies/storage, and the user session continues without explicit re-authentication.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Mitigations
|
|
||||||
|
|
||||||
### Loop Protection
|
|
||||||
To prevent broken, expired, or malformed credentials from triggering infinite network refresh loops (which degrade browser performance and strain backend lookup performance), the frontend utilizes an explicit safety lock.
|
|
||||||
|
|
||||||
|
1. Client sends `POST /api/login` with `username` and `password`.
|
||||||
|
2. Server verifies the password.
|
||||||
|
3. If `two_factor_enabled` is true, the server does not issue a full session.
|
||||||
|
4. Instead, the server returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"requires_2fa": true,
|
||||||
|
"two_factor_token": "..."
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Token Expired -> Check 'is_refreshing' flag -> True -> Clear Auth & Force Login
|
5. The frontend shows the second login step.
|
||||||
-> False -> Set flag 'true' -> Send Request
|
6. Client sends `POST /api/login/2fa` with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"two_factor_token": "...",
|
||||||
|
"code": "123456"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Before issuing an evaluation request to `/api/refresh`, the application checks a temporary session variable (`is_refreshing` within `sessionStorage`). If the flag is already set to `true`, the loop protection triggers a hard clearance routine via `clearAllAuth()`, drops all token storage records, and routes the user back to the primary login view safely.
|
7. The server validates the purpose token against the expected purpose `2fa_login`.
|
||||||
|
8. The server loads the user from the purpose-token claim.
|
||||||
|
9. The supplied code is checked as a TOTP code first.
|
||||||
|
10. If the TOTP check fails, the supplied value is normalized and checked as a recovery code.
|
||||||
|
11. If either check succeeds, the server issues the normal access/refresh token session.
|
||||||
|
12. If a recovery code was used, it is marked as used and cannot be used again.
|
||||||
|
|
||||||
### Database Revocation
|
|
||||||
Refresh sessions can be killed immediately from the server side. When a user requests `/api/logout`, the backend switches the corresponding row state within the `refresh_tokens` database container to `revoked = 1`. Any subsequent rotation requests relying on that token family are automatically dropped, protecting against stolen credential replay attacks.
|
## Passkey Registration Flow
|
||||||
|
|
||||||
|
Passkeys are managed from `/profile/settings`. Registration requires an authenticated session and current-password confirmation.
|
||||||
|
|
||||||
|
1. Client sends `POST /api/passkeys/register/options` with a passkey name and the current password.
|
||||||
|
2. Server verifies the password.
|
||||||
|
3. Server creates WebAuthn registration options with resident-key and user-verification requirements.
|
||||||
|
4. Server stores the WebAuthn session data in `passkey_challenges` and returns only an opaque `session_token` plus the public registration options.
|
||||||
|
5. Browser calls `navigator.credentials.create()` with the returned public-key options.
|
||||||
|
6. Client sends the browser credential response to `POST /api/passkeys/register/finish`.
|
||||||
|
7. Server consumes the one-time challenge, verifies the WebAuthn response, stores the credential, revokes existing refresh sessions, and issues a new current session.
|
||||||
|
|
||||||
|
The server stores credential metadata and public-key credential data. It does not store private keys.
|
||||||
|
|
||||||
|
## Passkey Login Flow
|
||||||
|
|
||||||
|
Passkey login is available from the normal login page.
|
||||||
|
|
||||||
|
1. Client sends `POST /api/passkeys/login/options`.
|
||||||
|
2. Server creates a discoverable passkey login challenge without requiring a username.
|
||||||
|
3. Server stores the WebAuthn session data in `passkey_challenges` and returns an opaque `session_token` plus assertion options.
|
||||||
|
4. Browser calls `navigator.credentials.get()` with the returned public-key options.
|
||||||
|
5. Client sends the browser assertion response to `POST /api/passkeys/login/finish`.
|
||||||
|
6. Server consumes the one-time challenge and verifies the WebAuthn assertion.
|
||||||
|
7. The stored credential data is updated after successful login.
|
||||||
|
8. Server issues a normal access/refresh session.
|
||||||
|
|
||||||
|
Passkey login is treated as a complete phishing-resistant sign-in method. The application requires WebAuthn user verification for passkey registration and login, so a valid passkey assertion is not followed by a separate TOTP challenge.
|
||||||
|
|
||||||
|
## Passkey Management
|
||||||
|
|
||||||
|
The account settings page supports:
|
||||||
|
|
||||||
|
- listing registered passkeys,
|
||||||
|
- adding a passkey,
|
||||||
|
- removing a single passkey,
|
||||||
|
- disabling all passkeys.
|
||||||
|
|
||||||
|
Adding, removing, or disabling passkeys revokes existing refresh sessions and issues a fresh session for the current browser. Removing or disabling passkeys requires current-password confirmation.
|
||||||
|
|
||||||
|
## 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,
|
||||||
|
- passkey registration,
|
||||||
|
- passkey removal,
|
||||||
|
- passkey disable,
|
||||||
|
- 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. Passkey changes also revoke existing refresh tokens and issue a new current session. Other devices must log in again and complete the configured authentication flow.
|
||||||
|
|
||||||
|
## 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 and passkey credential data at rest if the deployment threat model includes database disclosure.
|
||||||
|
- Expand tests for all authentication and account settings handlers.
|
||||||
|
|
||||||
|
## Activity Log
|
||||||
|
|
||||||
|
MiauInv records account, authentication, security, and inventory activity in the `activity_logs` table. The log is designed for user-visible traceability and lightweight security review.
|
||||||
|
|
||||||
|
Recorded examples include:
|
||||||
|
|
||||||
|
- password login success and failure,
|
||||||
|
- 2FA login success and failure,
|
||||||
|
- refresh-token rotation,
|
||||||
|
- logout,
|
||||||
|
- username and password changes,
|
||||||
|
- TOTP setup, enable, disable, and recovery-code regeneration,
|
||||||
|
- passkey registration, login, removal, and disable,
|
||||||
|
- inventory, location, project, stock, and allocation mutations.
|
||||||
|
|
||||||
|
The log does not store request bodies or secret values. Passwords, TOTP codes, recovery codes, refresh tokens, and WebAuthn payloads are excluded.
|
||||||
|
|
||||||
|
`GET /api/activity` returns the current user's recent activity with bounded `limit` and `offset` pagination. Admin users may request all activity with `?all=true`.
|
||||||
|
|||||||
305
docs/DATABASE.md
305
docs/DATABASE.md
@@ -1,97 +1,282 @@
|
|||||||
# Database Documentation
|
# Database Documentation
|
||||||
|
|
||||||
MiauInv utilizes an embedded SQLite database instance for persistent data storage. Foreign key constraints are strictly enforced at the database level.
|
MiauInv uses SQLite for persistent storage. The schema is initialized in `storage.InitDB` and foreign-key enforcement is explicitly enabled with:
|
||||||
|
|
||||||
## Configuration
|
|
||||||
To ensure data integrity, every database connection initialization explicitly executes the following command before handling queries:
|
|
||||||
```sql
|
```sql
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
The database stores users, refresh tokens, 2FA recovery codes, passkey credentials, passkey challenge state, activity log entries, inventory items, locations, projects, stock mappings, and project allocations.
|
||||||
|
|
||||||
## Schema Architecture
|
## Entity Overview
|
||||||
|
|
||||||
### Entity-Relationship Summary
|
```text
|
||||||
|
[users] 1 ──── N [refresh_tokens]
|
||||||
|
[users] 1 ──── N [two_factor_recovery_codes]
|
||||||
|
[users] 1 ──── N [passkey_credentials]
|
||||||
|
[users] 1 ──── N [activity_logs]
|
||||||
|
|
||||||
The database consists of primary entity tables (`users`, `items`, `locations`, `projects`) and relational junction tables (`stock`, `project_items`, `refresh_tokens`) designed to track stock distribution and access sessions.
|
[passkey_challenges] stores short-lived WebAuthn ceremony state
|
||||||
|
|
||||||
```
|
[items] 1 ──── N [stock] N ──── 1 [locations]
|
||||||
[users] <--- (1:N) ---> [refresh_tokens]
|
[items] 1 ──── N [project_items] N ──── 1 [projects]
|
||||||
[items] <--- (1:N) ---> [stock] <--- (N:1) ---> [locations]
|
|
||||||
[items] <--- (1:N) ---> [project_items] <--- (N:1) ---> [projects]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Tables
|
||||||
|
|
||||||
## Table Definitions
|
### `users`
|
||||||
|
|
||||||
### 1. users
|
Stores account credentials, roles, and 2FA state.
|
||||||
|
|
||||||
Stores user credentials and operational roles within the system.
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | `TEXT` | Primary key | User UUID. |
|
||||||
|
| `username` | `TEXT` | Not null, unique | Lowercased account name. |
|
||||||
|
| `password` | `TEXT` | Not null | bcrypt password hash. |
|
||||||
|
| `role` | `TEXT` | Not null | User role, for example `user` or `admin`. |
|
||||||
|
| `two_factor_enabled` | `INTEGER` | Not null, default `0` | Boolean flag for TOTP 2FA state. |
|
||||||
|
| `two_factor_secret` | `TEXT` | Not null, default `''` | TOTP secret used to validate authenticator codes. Empty when 2FA is disabled. During setup the secret is held in a short-lived signed setup token and is only stored after the first valid TOTP code. |
|
||||||
|
|
||||||
* **id (TEXT, PK):** Unique UUID
|
Migration note: existing databases are migrated with `ALTER TABLE` statements for `two_factor_enabled` and `two_factor_secret` if those columns do not exist yet.
|
||||||
* **username (TEXT, Unique):** Unique account identifier.
|
|
||||||
* **password (TEXT):** Hashed user password.
|
|
||||||
* **role (TEXT):** Access control flag (e.g., admin, user).
|
|
||||||
|
|
||||||
### 2. refresh_tokens
|
### `refresh_tokens`
|
||||||
|
|
||||||
Tracks valid extended sessions linked to specific user accounts.
|
Stores refresh-token sessions. Tokens are stored as hashes, not plaintext.
|
||||||
|
|
||||||
* **id (TEXT, PK):** Unique identifier.
|
| Column | Type | Constraints | Description |
|
||||||
* **user_id (TEXT, FK):** References `users(id)`.
|
| --- | --- | --- | --- |
|
||||||
* **token_hash (TEXT):** Cryptographic hash of the active refresh token.
|
| `id` | `TEXT` | Primary key | Refresh-token row UUID. |
|
||||||
* **expires_at (INTEGER):** Unix timestamp indicating token expiration.
|
| `user_id` | `TEXT` | Not null, foreign key to `users(id)` | Owning user. |
|
||||||
* **created_at (INTEGER):** Unix timestamp indicating session creation.
|
| `token_hash` | `TEXT` | Not null | Hash of the refresh token. |
|
||||||
* **revoked (INTEGER):** Boolean flag (0 or 1) indicating if the session was manually invalidated.
|
| `expires_at` | `INTEGER` | Not null | Unix timestamp for expiry. |
|
||||||
* **device_info (TEXT, Optional):** Client metadata for auditing.
|
| `created_at` | `INTEGER` | Not null | Unix timestamp for creation. |
|
||||||
|
| `revoked` | `INTEGER` | Not null, default `0` | Boolean revocation flag. |
|
||||||
|
| `device_info` | `TEXT` | Optional | User-Agent string recorded when the session is created. |
|
||||||
|
|
||||||
### 3. items
|
Refresh-token rotation revokes the used refresh token and inserts a new row for the next token.
|
||||||
|
|
||||||
Represents individual tracked assets.
|
### `two_factor_recovery_codes`
|
||||||
|
|
||||||
* **id (INTEGER, PK, Autoincrement):** Primary key.
|
Stores recovery-code hashes for 2FA fallback login.
|
||||||
* **name (TEXT):** Asset designation.
|
|
||||||
* **category (TEXT, Optional):** Grouping classification.
|
|
||||||
* **description (TEXT, Optional):** Detailed asset context.
|
|
||||||
* **total_quantity (INTEGER):** Absolute global stock baseline counter.
|
|
||||||
|
|
||||||
### 4. locations
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | `TEXT` | Primary key | Recovery-code row UUID. |
|
||||||
|
| `user_id` | `TEXT` | Not null, foreign key to `users(id)` with `ON DELETE CASCADE` | Owning user. |
|
||||||
|
| `code_hash` | `TEXT` | Not null, unique with `user_id` | Hash of the normalized recovery code. |
|
||||||
|
| `created_at` | `INTEGER` | Not null | Unix timestamp for creation. |
|
||||||
|
| `used_at` | `INTEGER` | Nullable | Unix timestamp when the code was consumed. `NULL` means unused. |
|
||||||
|
|
||||||
Defines logical or physical facilities.
|
Recovery codes are deleted and replaced when the user regenerates them. A recovery code is consumed with an atomic update that only matches unused codes.
|
||||||
|
|
||||||
* **id (INTEGER, PK, Autoincrement):** Primary key.
|
### `passkey_credentials`
|
||||||
* **name (TEXT, Unique):** Unique facility naming constraint.
|
|
||||||
|
|
||||||
### 5. projects
|
Stores WebAuthn passkey credentials for account login. Private keys are not stored by MiauInv; they remain in the authenticator, platform passkey provider, browser, or security key.
|
||||||
|
|
||||||
Defines distinct tasks or allocation targets.
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | `TEXT` | Primary key | Passkey row UUID. |
|
||||||
|
| `user_id` | `TEXT` | Not null, foreign key to `users(id)` with `ON DELETE CASCADE` | Owning user. |
|
||||||
|
| `credential_id` | `TEXT` | Not null, unique | Base64url-encoded WebAuthn credential ID. |
|
||||||
|
| `name` | `TEXT` | Not null | User-visible passkey name. |
|
||||||
|
| `credential_data` | `TEXT` | Not null | Serialized WebAuthn credential data, including public-key material and authenticator metadata. |
|
||||||
|
| `created_at` | `INTEGER` | Not null | Unix timestamp for creation. |
|
||||||
|
| `last_used_at` | `INTEGER` | Nullable | Unix timestamp when the passkey was last used successfully. |
|
||||||
|
|
||||||
* **id (INTEGER, PK, Autoincrement):** Primary key.
|
### `passkey_challenges`
|
||||||
* **name (TEXT, Unique):** Unique operational tracking name.
|
|
||||||
* **description (TEXT, Optional):** Scope description.
|
|
||||||
|
|
||||||
### 6. stock
|
Stores short-lived server-side WebAuthn session data for registration and login ceremonies. The browser receives only an opaque `session_token`.
|
||||||
|
|
||||||
Junction table mapping physical asset distributions across facilities.
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `token` | `TEXT` | Primary key | Opaque challenge token returned to the client. |
|
||||||
|
| `user_id` | `TEXT` | Not null, default `''` | Owning user for registration challenges. Empty for discoverable login challenges. |
|
||||||
|
| `ceremony` | `TEXT` | Not null | Challenge type, for example `register` or `login`. |
|
||||||
|
| `session_data` | `TEXT` | Not null | Serialized WebAuthn session data. |
|
||||||
|
| `expires_at` | `INTEGER` | Not null | Unix timestamp after which the challenge is rejected. |
|
||||||
|
|
||||||
* **id (INTEGER, PK, Autoincrement):** Primary key.
|
Challenge rows are consumed once during the finish step and expired rows are cleaned up opportunistically.
|
||||||
* **item_id (INTEGER, FK):** References `items(id)`.
|
|
||||||
* **location_id (INTEGER, FK):** References `locations(id)`.
|
|
||||||
* **quantity (INTEGER):** Specific quantity present at this location node.
|
|
||||||
|
|
||||||
### 7. project_items
|
|
||||||
|
|
||||||
Junction table tracking asset assignments dedicated to specific ongoing project environments.
|
### `activity_logs`
|
||||||
|
|
||||||
* **id (INTEGER, PK, Autoincrement):** Primary key.
|
Stores account, authentication, security, and inventory activity metadata. Request bodies, passwords, TOTP codes, recovery codes, passkey payloads, and token values are not stored.
|
||||||
* **item_id (INTEGER, FK):** References `items(id)`.
|
|
||||||
* **project_id (INTEGER, FK):** References `projects(id)`.
|
|
||||||
* **quantity (INTEGER):** Quantity allocated to this project context.
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Integrity Constraints
|
| Column | Type | Constraints | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | `TEXT` | Primary key | Activity row UUID. |
|
||||||
|
| `user_id` | `TEXT` | Not null, default `''` | User associated with the activity. Empty for unknown-user failed login attempts. |
|
||||||
|
| `username` | `TEXT` | Not null, default `''` | Username known at the time of the event. |
|
||||||
|
| `action` | `TEXT` | Not null | Normalized action name, for example `auth.login.succeeded` or `inventory.update`. |
|
||||||
|
| `entity_type` | `TEXT` | Not null, default `''` | Logical entity, for example `auth`, `security`, `item`, `location`, or `project`. |
|
||||||
|
| `entity_id` | `TEXT` | Not null, default `''` | Optional target ID when available. |
|
||||||
|
| `details` | `TEXT` | Not null, default `''` | Short sanitized status summary. |
|
||||||
|
| `method` | `TEXT` | Not null, default `''` | HTTP method. |
|
||||||
|
| `path` | `TEXT` | Not null, default `''` | Request path without query parameters. |
|
||||||
|
| `status_code` | `INTEGER` | Not null, default `0` | Final HTTP status code. |
|
||||||
|
| `success` | `INTEGER` | Not null, default `0` | Boolean success flag derived from the status code. |
|
||||||
|
| `ip_address` | `TEXT` | Not null, default `''` | Client IP address, considering common reverse-proxy headers. |
|
||||||
|
| `user_agent` | `TEXT` | Not null, default `''` | User-Agent metadata. |
|
||||||
|
| `created_at` | `INTEGER` | Not null | Unix timestamp for the event. |
|
||||||
|
|
||||||
* **Foreign Keys:** Because standard `ON DELETE` cascades are not defined explicitly in the schema rules, SQLite blocks parent deletion actions if dependent rows exist in `stock` or `project_items`. You must clear out stock allocations and project associations manually before deleting an item, location, or project.
|
Indexes are created for per-user timeline lookups, global timestamp ordering, and action filtering. Non-admin users can only read their own activity through `/api/activity`. Admin users can request all activity with `?all=true`.
|
||||||
* **Uniqueness:** String uniqueness constraints protect against duplicate namespace registration on `users(username)`, `locations(name)`, and `projects(name)`.
|
|
||||||
|
### `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` and `passkey_credentials.user_id` use `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code and passkey rows.
|
||||||
|
|
||||||
|
### Uniqueness
|
||||||
|
|
||||||
|
The following values are unique:
|
||||||
|
|
||||||
|
- `users.username`
|
||||||
|
- `locations.name`
|
||||||
|
- `projects.name`
|
||||||
|
- `two_factor_recovery_codes(user_id, code_hash)`
|
||||||
|
- `passkey_credentials.credential_id`
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Passkey registration
|
||||||
|
|
||||||
|
When a passkey is registered:
|
||||||
|
|
||||||
|
1. The current password is verified.
|
||||||
|
2. A WebAuthn registration challenge is stored in `passkey_challenges`.
|
||||||
|
3. The finish step consumes the challenge.
|
||||||
|
4. The WebAuthn credential is verified.
|
||||||
|
5. A row is inserted into `passkey_credentials`.
|
||||||
|
6. Existing refresh tokens for the user are revoked.
|
||||||
|
7. A new session is issued for the current browser.
|
||||||
|
|
||||||
|
### Passkey login
|
||||||
|
|
||||||
|
When passkey login completes:
|
||||||
|
|
||||||
|
1. A discoverable WebAuthn login challenge is consumed from `passkey_challenges`.
|
||||||
|
2. The credential assertion is verified against the stored credential data.
|
||||||
|
3. The stored credential data and `last_used_at` value are updated.
|
||||||
|
4. A normal access/refresh session is issued.
|
||||||
|
|
||||||
|
### Passkey removal
|
||||||
|
|
||||||
|
When a passkey is removed or all passkeys are disabled:
|
||||||
|
|
||||||
|
1. The current password is verified.
|
||||||
|
2. The matching passkey row, or all rows for the user, are deleted.
|
||||||
|
3. Existing refresh tokens for the user are revoked.
|
||||||
|
4. A new session is issued for the current browser.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
91
docs/ENDPOINTS.md
Normal file
91
docs/ENDPOINTS.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# API Endpoints
|
||||||
|
|
||||||
|
This document lists the public page routes and JSON API endpoints exposed by MiauInv. API endpoints that modify account or inventory state require authentication unless explicitly marked as public.
|
||||||
|
|
||||||
|
## Page Routes
|
||||||
|
|
||||||
|
| Route | Authentication | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/` | No | Landing page. |
|
||||||
|
| `/login` | No | Login page with password and passkey login. |
|
||||||
|
| `/register` | Optional | Registration page when registration is enabled. |
|
||||||
|
| `/dashboard` | Yes | Dashboard overview. |
|
||||||
|
| `/inventory` | Yes | Inventory item management. |
|
||||||
|
| `/items` | Yes | Item list view. |
|
||||||
|
| `/locations` | Yes | Location management. |
|
||||||
|
| `/projects` | Yes | Project allocation management. |
|
||||||
|
| `/profile/settings` | Yes | Account, 2FA, and passkey settings. |
|
||||||
|
| `/profile/activity` | Yes | User activity log. |
|
||||||
|
|
||||||
|
## Authentication and Account API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/register` | `POST` | No | Create a user when registration is enabled. |
|
||||||
|
| `/api/login` | `POST` | No | Password login. Returns a 2FA challenge if required. |
|
||||||
|
| `/api/login/2fa` | `POST` | No | Complete TOTP or recovery-code login. |
|
||||||
|
| `/api/passkeys/login/options` | `POST` | No | Start discoverable passkey login. |
|
||||||
|
| `/api/passkeys/login/finish` | `POST` | No | Complete passkey login. |
|
||||||
|
| `/api/refresh` | `POST` | No | Rotate a refresh token and issue a new session. |
|
||||||
|
| `/api/logout` | `POST` | Yes | Revoke refresh sessions and clear auth cookies. |
|
||||||
|
| `/api/userinfo` | `GET` | Yes | Return current user metadata and security status. |
|
||||||
|
| `/api/profile` | `GET` | Yes | Alias for current user metadata. |
|
||||||
|
| `/api/account/username` | `POST` | Yes | Change username with password confirmation. |
|
||||||
|
| `/api/account/password` | `POST` | Yes | Change password and refresh the current session. |
|
||||||
|
|
||||||
|
## Two-Factor Authentication API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/2fa/setup` | `POST` | Yes | Create a short-lived setup challenge, QR code, and manual setup secret. |
|
||||||
|
| `/api/2fa/enable` | `POST` | Yes | Confirm the setup challenge, enable 2FA, and generate recovery codes. |
|
||||||
|
| `/api/2fa/disable` | `POST` | Yes | Disable 2FA with password and TOTP confirmation. |
|
||||||
|
| `/api/2fa/recovery-codes/regenerate` | `POST` | Yes | Replace recovery codes with password and TOTP confirmation. |
|
||||||
|
|
||||||
|
## Passkey Management API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/passkeys` | `GET` | Yes | List registered passkeys. |
|
||||||
|
| `/api/passkeys` | `DELETE` | Yes | Remove a passkey with password confirmation. |
|
||||||
|
| `/api/passkeys/register/options` | `POST` | Yes | Start passkey registration. |
|
||||||
|
| `/api/passkeys/register/finish` | `POST` | Yes | Finish passkey registration and store the credential. |
|
||||||
|
| `/api/passkeys/disable` | `POST` | Yes | Remove all passkeys with password confirmation. |
|
||||||
|
|
||||||
|
## Activity API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/activity` | `GET` | Yes | Return recent activity entries for the current user. Admin users may request `?all=true`. |
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
|
||||||
|
| Parameter | Default | Max | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | `50` | `100` | Number of entries to return. |
|
||||||
|
| `offset` | `0` | `100000` | Offset for pagination. |
|
||||||
|
| `all` | `false` | n/a | Admin-only flag for reading all users' activity. |
|
||||||
|
|
||||||
|
## Inventory API
|
||||||
|
|
||||||
|
| Endpoint | Method | Authentication | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `/api/item` | `GET` | Yes | List items or read an item by `id`. |
|
||||||
|
| `/api/item` | `POST` | Yes | Create an item. |
|
||||||
|
| `/api/item` | `PUT` | Yes | Update an item by `id`. |
|
||||||
|
| `/api/item` | `DELETE` | Yes | Delete an item by `id`. |
|
||||||
|
| `/api/location` | `GET` | Yes | List locations, read a location by `id`, or read location contents with `content=true`. |
|
||||||
|
| `/api/location` | `POST` | Yes | Create a location. |
|
||||||
|
| `/api/location` | `PUT` | Yes | Update a location by `id`. |
|
||||||
|
| `/api/location` | `DELETE` | Yes | Delete a location by `id`. |
|
||||||
|
| `/api/project` | `GET` | Yes | List projects, read a project by `id`, or read project allocation details with `details=true`. |
|
||||||
|
| `/api/project` | `POST` | Yes | Create a project. |
|
||||||
|
| `/api/project` | `PUT` | Yes | Update a project by `id`. |
|
||||||
|
| `/api/project` | `DELETE` | Yes | Delete a project by `id`. |
|
||||||
|
| `/api/stock` | `GET` | Yes | List stock rows, optionally filtered by `item_id`. |
|
||||||
|
| `/api/stock` | `POST` | Yes | Add stock to a location. |
|
||||||
|
| `/api/stock` | `DELETE` | Yes | Delete a stock row by `id`. |
|
||||||
|
| `/api/association` | `GET` | Yes | List project-item allocations, optionally filtered by `project_id`. |
|
||||||
|
| `/api/association` | `POST` | Yes | Allocate item quantity to a project. |
|
||||||
|
| `/api/association` | `PUT` | Yes | Update an allocation by `id`. |
|
||||||
|
| `/api/association` | `DELETE` | Yes | Delete an allocation by `id`. |
|
||||||
104
docs/SECURITY.md
Normal file
104
docs/SECURITY.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
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, optional TOTP-based two-factor authentication, and optional WebAuthn passkeys.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Passkeys
|
||||||
|
|
||||||
|
Passkeys use WebAuthn public-key credentials. The server stores credential metadata and public-key material, but not private keys. Private keys remain controlled by the authenticator, browser, operating system, or security key.
|
||||||
|
|
||||||
|
Passkey registration requires the current account password. Registration uses a server-side challenge stored in `passkey_challenges` and returned to the browser only as an opaque challenge token. The browser response is verified before the credential is stored.
|
||||||
|
|
||||||
|
Passkey login creates a one-time server-side challenge. The login flow uses discoverable passkeys and does not require entering a username first. User verification is required for passkey registration and login.
|
||||||
|
|
||||||
|
Passkey login is treated as a complete phishing-resistant sign-in method. Because passkey registration and login require WebAuthn user verification, the server issues a normal session after a valid passkey assertion instead of asking for an additional TOTP code.
|
||||||
|
|
||||||
|
When passkeys are added, removed, or disabled, existing refresh sessions are revoked and a fresh current session is issued.
|
||||||
|
|
||||||
|
Passkey ceremonies require HTTPS except for localhost. Reverse proxy deployments must preserve the correct public `Host`, `X-Forwarded-Host`, and `X-Forwarded-Proto` information so that the relying party origin and ID match the browser-visible origin.
|
||||||
|
|
||||||
|
## Activity Logging
|
||||||
|
|
||||||
|
MiauInv stores an authenticated activity log for security-relevant and state-changing actions, including login attempts, refresh-token rotation, account changes, 2FA changes, passkey management, logout, and inventory mutations.
|
||||||
|
|
||||||
|
The activity log intentionally stores metadata only: action name, entity type, optional target ID, HTTP method, path, status, success flag, IP address, user agent, and timestamp. Request bodies are not logged, so passwords, TOTP codes, recovery codes, refresh tokens, and WebAuthn payloads are not persisted in the activity table.
|
||||||
|
|
||||||
|
Users can read their own entries from `/profile/activity` and `/api/activity`. Admin users may request all users' activity with `?all=true`. The API enforces authentication, bounds pagination limits, and is protected by rate limiting.
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Basic in-memory rate limiting protects login, passkey ceremonies, 2FA, refresh, registration, activity, 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.
|
||||||
|
- Passkey credential metadata and public-key data are stored in the database after registration.
|
||||||
|
- TOTP secrets are not encrypted at rest.
|
||||||
|
- There is no dedicated session/device management UI yet.
|
||||||
|
- The current rate limiter is process-local and memory-only.
|
||||||
@@ -291,6 +291,152 @@ tr:hover td {
|
|||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.activity-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-header h1 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-refresh,
|
||||||
|
.activity-load-more {
|
||||||
|
width: auto;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-controls-card {
|
||||||
|
max-width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 1.35rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1.25fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-control-panel {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: rgba(17, 24, 39, 0.62);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-control-copy h2 {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0.1rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-control-copy p,
|
||||||
|
.activity-note {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-eyebrow {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-menu {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-option {
|
||||||
|
min-width: 3.2rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-option:hover,
|
||||||
|
.activity-entry-option:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-option.active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 8px 18px rgba(59, 130, 246, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.22);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-note strong {
|
||||||
|
color: #bfdbfe;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-load-more-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#activity-load-more {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.activity-controls-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-control-panel {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-menu {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-entry-option {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#modal {
|
#modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|||||||
@@ -130,6 +130,51 @@ input::placeholder {
|
|||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.password-input-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input-wrapper input {
|
||||||
|
padding-right: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.45rem;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-toggle svg {
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-text {
|
.footer-text {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ 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();
|
||||||
|
if (document.getElementById('activity-log-body')) loadActivityLog(true);
|
||||||
|
|
||||||
|
setupPasswordVisibilityToggles();
|
||||||
loadProfile();
|
loadProfile();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -481,4 +484,651 @@ async function loadProfile() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
|
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- ACCOUNT SETTINGS ----
|
||||||
|
let latestRecoveryCodes = [];
|
||||||
|
let pendingTwoFactorSetupToken = "";
|
||||||
|
let currentPasskeys = [];
|
||||||
|
|
||||||
|
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 setupPasswordVisibilityToggles(root = document) {
|
||||||
|
const eyeIcon = `
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M2.25 12s3.5-6.75 9.75-6.75S21.75 12 21.75 12 18.25 18.75 12 18.75 2.25 12 2.25 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="2.75" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||||
|
</svg>`;
|
||||||
|
const eyeOffIcon = `
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M3 3l18 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M10.58 10.58A2.75 2.75 0 0 0 13.42 13.42" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M7.1 7.7C3.95 9.55 2.25 12 2.25 12s3.5 6.75 9.75 6.75c1.65 0 3.08-.47 4.29-1.15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.8 5.55A9.2 9.2 0 0 1 12 5.25c6.25 0 9.75 6.75 9.75 6.75a15.3 15.3 0 0 1-2.3 2.95" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
root.querySelectorAll('input[type="password"]').forEach((input) => {
|
||||||
|
if (input.dataset.visibilityToggleAttached === 'true') return;
|
||||||
|
input.dataset.visibilityToggleAttached = 'true';
|
||||||
|
|
||||||
|
let wrapper = input.closest('.password-input-wrapper');
|
||||||
|
if (!wrapper) {
|
||||||
|
wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'password-input-wrapper';
|
||||||
|
input.parentNode.insertBefore(wrapper, input);
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = document.createElement('button');
|
||||||
|
toggle.type = 'button';
|
||||||
|
toggle.className = 'password-toggle';
|
||||||
|
toggle.innerHTML = eyeIcon;
|
||||||
|
toggle.setAttribute('aria-label', `Show ${input.placeholder || 'password'}`);
|
||||||
|
toggle.setAttribute('title', 'Show password');
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const show = input.type === 'password';
|
||||||
|
input.type = show ? 'text' : 'password';
|
||||||
|
toggle.innerHTML = show ? eyeOffIcon : eyeIcon;
|
||||||
|
toggle.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.placeholder || 'password'}`);
|
||||||
|
toggle.setAttribute('title', show ? 'Hide password' : 'Show password');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(toggle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 remaining = document.getElementById('recovery-codes-remaining');
|
||||||
|
|
||||||
|
if (usernameInput) usernameInput.value = data.username || '';
|
||||||
|
if (remaining) remaining.innerText = data.recovery_codes_remaining || 0;
|
||||||
|
updateRecoveryCodeWarning(data.recovery_codes_remaining || 0, !!data.recovery_codes_warning);
|
||||||
|
|
||||||
|
setTwoFactorPanels(!!data.two_factor_enabled);
|
||||||
|
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
|
||||||
|
renderRecoveryCodes([]);
|
||||||
|
loadPasskeys();
|
||||||
|
setupPasswordVisibilityToggles(document.getElementById('account-settings-content') || document);
|
||||||
|
} 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');
|
||||||
|
if (username) username.innerText = data.username;
|
||||||
|
if (avatar && data.username) avatar.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();
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function webauthnSupported() {
|
||||||
|
return window.PublicKeyCredential && navigator.credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webauthnBase64URLToBuffer(value) {
|
||||||
|
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webauthnBufferToBase64URL(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareCredentialCreationOptions(options) {
|
||||||
|
const publicKey = options.publicKey || options;
|
||||||
|
publicKey.challenge = webauthnBase64URLToBuffer(publicKey.challenge);
|
||||||
|
publicKey.user.id = webauthnBase64URLToBuffer(publicKey.user.id);
|
||||||
|
if (Array.isArray(publicKey.excludeCredentials)) {
|
||||||
|
publicKey.excludeCredentials = publicKey.excludeCredentials.map((credential) => ({
|
||||||
|
...credential,
|
||||||
|
id: webauthnBase64URLToBuffer(credential.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attestationCredentialToJSON(credential) {
|
||||||
|
const response = credential.response;
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: webauthnBufferToBase64URL(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: webauthnBufferToBase64URL(response.clientDataJSON),
|
||||||
|
attestationObject: webauthnBufferToBase64URL(response.attestationObject),
|
||||||
|
transports: typeof response.getTransports === 'function' ? response.getTransports() : []
|
||||||
|
},
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPasskeyPanels(enabled, count) {
|
||||||
|
const badge = document.getElementById('passkey-badge');
|
||||||
|
const status = document.getElementById('passkey-status');
|
||||||
|
const unsupported = document.getElementById('passkey-unsupported-message');
|
||||||
|
const addForm = document.getElementById('passkey-add-form');
|
||||||
|
|
||||||
|
if (unsupported) unsupported.style.display = webauthnSupported() ? 'none' : 'block';
|
||||||
|
if (addForm) {
|
||||||
|
for (const element of addForm.elements) {
|
||||||
|
element.disabled = !webauthnSupported();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!badge || !status) return;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
badge.textContent = 'Enabled';
|
||||||
|
badge.classList.add('success');
|
||||||
|
status.textContent = `${count} passkey${count === 1 ? '' : 's'} registered for this account.`;
|
||||||
|
} else {
|
||||||
|
badge.textContent = 'Disabled';
|
||||||
|
badge.classList.remove('success');
|
||||||
|
status.textContent = 'No passkeys are registered for this account.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPasskeys(passkeys) {
|
||||||
|
currentPasskeys = passkeys || [];
|
||||||
|
const list = document.getElementById('passkey-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (currentPasskeys.length === 0) {
|
||||||
|
list.innerHTML = '<p style="color: var(--text-muted); margin: 0;">No passkeys registered.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = currentPasskeys.map((passkey) => {
|
||||||
|
const created = passkey.created_at ? new Date(passkey.created_at * 1000).toLocaleString() : 'unknown';
|
||||||
|
const lastUsed = passkey.last_used_at ? new Date(passkey.last_used_at * 1000).toLocaleString() : 'never';
|
||||||
|
const id = escapeAttr(passkey.id);
|
||||||
|
return `
|
||||||
|
<div style="border: 1px solid var(--border); border-radius: 12px; padding: 1rem; display: grid; gap: 0.75rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; flex-wrap: wrap;">
|
||||||
|
<div>
|
||||||
|
<strong style="color: var(--text);">${escapeHTML(passkey.name || 'Passkey')}</strong>
|
||||||
|
<div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.25rem;">Created: ${escapeHTML(created)}</div>
|
||||||
|
<div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.25rem;">Last used: ${escapeHTML(lastUsed)}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary danger-btn" style="width: auto; padding: 0.45rem 0.8rem;" onclick="showPasskeyDeleteForm('${id}')">Remove</button>
|
||||||
|
</div>
|
||||||
|
<div id="passkey-delete-form-${id}" style="display: none; border-top: 1px solid var(--border); padding-top: 0.75rem;">
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">Confirm with your current password to remove this passkey.</p>
|
||||||
|
<div class="form-group" style="margin-bottom: 0.75rem;">
|
||||||
|
<input type="password" id="passkey-delete-password-${id}" placeholder="Current password" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||||
|
<button type="button" class="btn btn-secondary danger-btn" style="width: auto; padding: 0.45rem 0.8rem;" onclick="confirmDeletePasskey('${id}')">Confirm removal</button>
|
||||||
|
<button type="button" class="btn btn-secondary" style="width: auto; padding: 0.45rem 0.8rem;" onclick="hidePasskeyDeleteForm('${id}')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
setupPasswordVisibilityToggles(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPasskeys() {
|
||||||
|
if (!document.getElementById('passkey-list')) return;
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/passkeys');
|
||||||
|
renderPasskeys(data.passkeys || []);
|
||||||
|
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not load passkeys.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPasskey(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!webauthnSupported()) {
|
||||||
|
showAccountSettingsMessage('Passkeys are not supported by this browser.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = document.getElementById('passkey-name').value.trim() || 'Passkey';
|
||||||
|
const password = document.getElementById('passkey-add-password').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const optionsData = await apiRequest('/api/passkeys/register/options', 'POST', { name, password });
|
||||||
|
const publicKey = prepareCredentialCreationOptions(optionsData.options);
|
||||||
|
const credential = await navigator.credentials.create({ publicKey });
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Passkey creation was cancelled.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await apiRequest('/api/passkeys/register/finish', 'POST', {
|
||||||
|
session_token: optionsData.session_token,
|
||||||
|
name,
|
||||||
|
credential: attestationCredentialToJSON(credential)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.access_token && data.refresh_token) {
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('passkey-add-form').reset();
|
||||||
|
renderPasskeys(data.passkeys || []);
|
||||||
|
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
|
||||||
|
showAccountSettingsMessage('Passkey added. Your session was refreshed.');
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not add passkey.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPasskeyDeleteForm(id) {
|
||||||
|
const form = document.getElementById(`passkey-delete-form-${id}`);
|
||||||
|
const input = document.getElementById(`passkey-delete-password-${id}`);
|
||||||
|
if (!form) return;
|
||||||
|
form.style.display = 'block';
|
||||||
|
setupPasswordVisibilityToggles(form);
|
||||||
|
if (input) input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePasskeyDeleteForm(id) {
|
||||||
|
const form = document.getElementById(`passkey-delete-form-${id}`);
|
||||||
|
const input = document.getElementById(`passkey-delete-password-${id}`);
|
||||||
|
if (input) input.value = '';
|
||||||
|
if (form) form.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeletePasskey(id) {
|
||||||
|
if (!id) return;
|
||||||
|
const input = document.getElementById(`passkey-delete-password-${id}`);
|
||||||
|
const password = input ? input.value : '';
|
||||||
|
if (!password) {
|
||||||
|
showAccountSettingsMessage('Current password required to remove passkey.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/passkeys', 'DELETE', { id, password });
|
||||||
|
if (data.access_token && data.refresh_token) {
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
}
|
||||||
|
renderPasskeys(data.passkeys || []);
|
||||||
|
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
|
||||||
|
showAccountSettingsMessage('Passkey removed. Your session was refreshed.');
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not remove passkey.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePasskey(id) {
|
||||||
|
showPasskeyDeleteForm(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disablePasskeys(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest('/api/passkeys/disable', 'POST', {
|
||||||
|
password: document.getElementById('passkey-disable-password').value
|
||||||
|
});
|
||||||
|
if (data.access_token && data.refresh_token) {
|
||||||
|
localStorage.setItem('access_token', data.access_token);
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
}
|
||||||
|
document.getElementById('passkey-disable-form').reset();
|
||||||
|
renderPasskeys([]);
|
||||||
|
setPasskeyPanels(false, 0);
|
||||||
|
showAccountSettingsMessage('Passkeys disabled. Your session was refreshed.');
|
||||||
|
} catch (err) {
|
||||||
|
showAccountSettingsMessage(err.message || 'Could not disable passkeys.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- ACTIVITY LOG ----
|
||||||
|
let activityOffset = 0;
|
||||||
|
let activityLimit = 50;
|
||||||
|
|
||||||
|
async function loadActivityLog(reset = true) {
|
||||||
|
const tbody = document.getElementById('activity-log-body');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
activityLimit = getSelectedActivityLimit();
|
||||||
|
if (reset) {
|
||||||
|
activityOffset = 0;
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="table-loader">Loading activity...</td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiRequest(`/api/activity?limit=${activityLimit}&offset=${activityOffset}`);
|
||||||
|
const entries = data.activity || [];
|
||||||
|
|
||||||
|
if (reset) tbody.innerHTML = '';
|
||||||
|
if (entries.length === 0 && activityOffset === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="table-loader">No activity recorded yet.</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.insertAdjacentHTML('beforeend', entries.map(renderActivityEntry).join(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
activityOffset += entries.length;
|
||||||
|
const loadMore = document.getElementById('activity-load-more');
|
||||||
|
if (loadMore) loadMore.style.display = entries.length === activityLimit ? 'inline-flex' : 'none';
|
||||||
|
} catch (err) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" class="table-loader" style="color:#fca5a5;">${escapeHTML(err.message || 'Failed to load activity log.')}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedActivityLimit() {
|
||||||
|
const activeButton = document.querySelector('.activity-entry-option.active');
|
||||||
|
if (!activeButton) return activityLimit || 50;
|
||||||
|
return parseInt(activeButton.dataset.limit, 10) || 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivityLimit(limit) {
|
||||||
|
activityLimit = limit;
|
||||||
|
document.querySelectorAll('.activity-entry-option').forEach((button) => {
|
||||||
|
const isActive = parseInt(button.dataset.limit, 10) === limit;
|
||||||
|
button.classList.toggle('active', isActive);
|
||||||
|
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
loadActivityLog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreActivity() {
|
||||||
|
loadActivityLog(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivityEntry(entry) {
|
||||||
|
const timestamp = entry.created_at ? new Date(entry.created_at * 1000).toLocaleString() : 'unknown';
|
||||||
|
const statusClass = entry.success ? 'badge success' : 'badge';
|
||||||
|
const statusLabel = entry.success ? 'Success' : 'Failed';
|
||||||
|
const entity = [entry.entity_type, entry.entity_id ? `#${entry.entity_id}` : ''].filter(Boolean).join(' ') || 'account';
|
||||||
|
const client = entry.user_agent ? summarizeUserAgent(entry.user_agent) : 'unknown';
|
||||||
|
const path = entry.path ? `<div style="color:var(--text-muted); font-size:0.8rem; margin-top:0.2rem;">${escapeHTML(entry.method || '')} ${escapeHTML(entry.path)}</div>` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td style="white-space:nowrap; color:var(--text-muted);">${escapeHTML(timestamp)}</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight:600; color:var(--text);">${escapeHTML(formatActivityAction(entry.action))}</div>
|
||||||
|
${path}
|
||||||
|
</td>
|
||||||
|
<td>${escapeHTML(entity)}</td>
|
||||||
|
<td><span class="${statusClass}">${statusLabel} ${entry.status_code || ''}</span></td>
|
||||||
|
<td style="font-family:monospace; font-size:0.85rem;">${escapeHTML(entry.ip_address || 'unknown')}</td>
|
||||||
|
<td title="${escapeAttr(entry.user_agent || '')}" style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-muted);">${escapeHTML(client)}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActivityAction(action) {
|
||||||
|
if (!action) return 'Unknown action';
|
||||||
|
return action
|
||||||
|
.replace(/^auth\./, 'Auth: ')
|
||||||
|
.replace(/^account\./, 'Account: ')
|
||||||
|
.replace(/^security\./, 'Security: ')
|
||||||
|
.replace(/^inventory\./, 'Inventory: ')
|
||||||
|
.replace(/[._]/g, ' ')
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeUserAgent(userAgent) {
|
||||||
|
if (userAgent.length <= 80) return userAgent;
|
||||||
|
return `${userAgent.slice(0, 77)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return escapeHTML(value).replace(/`/g, '`');
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,22 +2,233 @@
|
|||||||
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");
|
||||||
|
const passkeyLoginButton = document.getElementById("passkey-login-button");
|
||||||
|
const passkeyLoginHint = document.getElementById("passkey-login-hint");
|
||||||
|
|
||||||
|
let pendingTwoFactorToken = null;
|
||||||
|
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
form.addEventListener("submit", async (e) => {
|
setupPasswordVisibilityToggles();
|
||||||
e.preventDefault();
|
|
||||||
errorBox.style.display = "none";
|
|
||||||
|
|
||||||
const username = document.getElementById("username").value;
|
function showError(message) {
|
||||||
const password = document.getElementById("password").value;
|
errorBox.textContent = message || "Login failed.";
|
||||||
|
errorBox.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorBox.textContent = "";
|
||||||
|
errorBox.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupPasswordVisibilityToggles(root = document) {
|
||||||
|
const eyeIcon = `
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M2.25 12s3.5-6.75 9.75-6.75S21.75 12 21.75 12 18.25 18.75 12 18.75 2.25 12 2.25 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="12" r="2.75" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||||||
|
</svg>`;
|
||||||
|
const eyeOffIcon = `
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M3 3l18 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M10.58 10.58A2.75 2.75 0 0 0 13.42 13.42" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
<path d="M7.1 7.7C3.95 9.55 2.25 12 2.25 12s3.5 6.75 9.75 6.75c1.65 0 3.08-.47 4.29-1.15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.8 5.55A9.2 9.2 0 0 1 12 5.25c6.25 0 9.75 6.75 9.75 6.75a15.3 15.3 0 0 1-2.3 2.95" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
root.querySelectorAll('input[type="password"]').forEach((input) => {
|
||||||
|
if (input.dataset.visibilityToggleAttached === 'true') return;
|
||||||
|
input.dataset.visibilityToggleAttached = 'true';
|
||||||
|
|
||||||
|
let wrapper = input.closest('.password-input-wrapper');
|
||||||
|
if (!wrapper) {
|
||||||
|
wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'password-input-wrapper';
|
||||||
|
input.parentNode.insertBefore(wrapper, input);
|
||||||
|
wrapper.appendChild(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = document.createElement('button');
|
||||||
|
toggle.type = 'button';
|
||||||
|
toggle.className = 'password-toggle';
|
||||||
|
toggle.innerHTML = eyeIcon;
|
||||||
|
toggle.setAttribute('aria-label', `Show ${input.placeholder || 'password'}`);
|
||||||
|
toggle.setAttribute('title', 'Show password');
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const show = input.type === 'password';
|
||||||
|
input.type = show ? 'text' : 'password';
|
||||||
|
toggle.innerHTML = show ? eyeOffIcon : eyeIcon;
|
||||||
|
toggle.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.placeholder || 'password'}`);
|
||||||
|
toggle.setAttribute('title', show ? 'Hide password' : 'Show password');
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(toggle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
|
||||||
|
twoFactorGroup.style.display = "block";
|
||||||
|
twoFactorInput.required = true;
|
||||||
|
twoFactorInput.focus();
|
||||||
|
submitButton.textContent = "Verify code";
|
||||||
|
}
|
||||||
|
|
||||||
|
function webauthnSupported() {
|
||||||
|
return window.PublicKeyCredential && navigator.credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64URLToBuffer(value) {
|
||||||
|
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
||||||
|
const binary = atob(padded);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64URL(buffer) {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareCredentialRequestOptions(options) {
|
||||||
|
const publicKey = options.publicKey || options;
|
||||||
|
publicKey.challenge = base64URLToBuffer(publicKey.challenge);
|
||||||
|
if (Array.isArray(publicKey.allowCredentials)) {
|
||||||
|
publicKey.allowCredentials = publicKey.allowCredentials.map((credential) => ({
|
||||||
|
...credential,
|
||||||
|
id: base64URLToBuffer(credential.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialToJSON(credential) {
|
||||||
|
const response = credential.response;
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferToBase64URL(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
response: {
|
||||||
|
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
|
||||||
|
authenticatorData: bufferToBase64URL(response.authenticatorData),
|
||||||
|
signature: bufferToBase64URL(response.signature),
|
||||||
|
userHandle: response.userHandle ? bufferToBase64URL(response.userHandle) : null
|
||||||
|
},
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasskeyLogin() {
|
||||||
|
if (!webauthnSupported()) {
|
||||||
|
showError("Passkeys are not supported by this browser.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError();
|
||||||
|
if (passkeyLoginButton) passkeyLoginButton.disabled = true;
|
||||||
|
submitButton.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/login", {
|
const optionsResponse = await fetch("/api/passkeys/login/options", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
|
if (!optionsResponse.ok) {
|
||||||
|
throw new Error(await optionsResponse.text());
|
||||||
|
}
|
||||||
|
const optionsData = await optionsResponse.json();
|
||||||
|
const publicKey = prepareCredentialRequestOptions(optionsData.options);
|
||||||
|
const assertion = await navigator.credentials.get({ publicKey });
|
||||||
|
if (!assertion) {
|
||||||
|
throw new Error("Passkey authentication was cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishResponse = await fetch("/api/passkeys/login/finish", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_token: optionsData.session_token,
|
||||||
|
credential: credentialToJSON(assertion)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!finishResponse.ok) {
|
||||||
|
throw new Error(await finishResponse.text());
|
||||||
|
}
|
||||||
|
const data = await finishResponse.json();
|
||||||
|
|
||||||
|
if (data.requires_2fa) {
|
||||||
|
switchToTwoFactorMode(data.two_factor_token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storeTokens(data);
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
} finally {
|
||||||
|
if (!pendingTwoFactorToken && passkeyLoginButton) passkeyLoginButton.disabled = false;
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passkeyLoginButton) {
|
||||||
|
if (!webauthnSupported()) {
|
||||||
|
passkeyLoginButton.disabled = true;
|
||||||
|
if (passkeyLoginHint) passkeyLoginHint.textContent = "Passkeys are not supported by this browser.";
|
||||||
|
} else {
|
||||||
|
passkeyLoginButton.addEventListener("click", handlePasskeyLogin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
submitButton.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (pendingTwoFactorToken) {
|
||||||
|
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 +237,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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ 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 activity = template.Must(template.ParseFiles(
|
||||||
|
"frontend/htmx/contents/dash/base.html",
|
||||||
|
"frontend/htmx/contents/dash/activity.html"))
|
||||||
|
|
||||||
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
|
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
|
||||||
|
|
||||||
func Home(w http.ResponseWriter, r *http.Request) {
|
func Home(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -51,7 +59,6 @@ func Home(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Dashboard(w http.ResponseWriter, r *http.Request) {
|
func Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
|
||||||
@@ -142,6 +149,28 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func Activity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
err := activity.ExecuteTemplate(w, "base.html", struct {
|
||||||
|
Title string
|
||||||
|
}{
|
||||||
|
Title: "Activity Log",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var minifier *minify.M
|
var minifier *minify.M
|
||||||
|
|
||||||
@@ -151,7 +180,6 @@ func init() {
|
|||||||
minifier.AddFunc("text/css", css.Minify)
|
minifier.AddFunc("text/css", css.Minify)
|
||||||
minifier.AddFunc("text/javascript", js.Minify)
|
minifier.AddFunc("text/javascript", js.Minify)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Assets(w http.ResponseWriter, r *http.Request) {
|
func Assets(w http.ResponseWriter, r *http.Request) {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/assets/")
|
path := strings.TrimPrefix(r.URL.Path, "/assets/")
|
||||||
fullPath := filepath.Join("frontend/assets", path)
|
fullPath := filepath.Join("frontend/assets", path)
|
||||||
|
|||||||
172
frontend/htmx/contents/dash/account_settings.html
Normal file
172
frontend/htmx/contents/dash/account_settings.html
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
{{ 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.</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>
|
||||||
|
|
||||||
|
<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 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);">Passkeys</h2>
|
||||||
|
<p id="passkey-status" style="color: var(--text-muted); margin-bottom: 1rem;">Loading passkey status...</p>
|
||||||
|
</div>
|
||||||
|
<span id="passkey-badge" class="badge">Unknown</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">Use device-bound or synced passkeys for phishing-resistant sign-in. Passkeys are protected by your browser, operating system, or security key.</p>
|
||||||
|
|
||||||
|
<div id="passkey-unsupported-message" class="message error" style="display: none; margin-bottom: 1rem;">This browser does not support passkeys.</div>
|
||||||
|
|
||||||
|
<div id="passkey-list" style="display: grid; gap: 0.75rem; margin-bottom: 1.5rem;"></div>
|
||||||
|
|
||||||
|
<div class="modal-split" style="align-items: start;">
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Add passkey</h3>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">Confirm with your current password, then follow your browser's passkey prompt.</p>
|
||||||
|
<form id="passkey-add-form" onsubmit="addPasskey(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="passkey-name" placeholder="Passkey name" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="passkey-add-password" placeholder="Current password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;">Add passkey</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Disable passkeys</h3>
|
||||||
|
<p style="color: var(--text-muted); margin-bottom: 1rem;">This removes all stored passkeys and revokes active refresh sessions.</p>
|
||||||
|
<form id="passkey-disable-form" onsubmit="disablePasskeys(event)">
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="password" id="passkey-disable-password" placeholder="Current password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary danger-btn">Disable all passkeys</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
49
frontend/htmx/contents/dash/activity.html
Normal file
49
frontend/htmx/contents/dash/activity.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="page-header activity-header">
|
||||||
|
<div>
|
||||||
|
<h1>Activity Log</h1>
|
||||||
|
<p>Recent account, security, and inventory actions for your account.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary activity-refresh" onclick="loadActivityLog(true)">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card activity-controls-card">
|
||||||
|
<div class="activity-control-panel">
|
||||||
|
<div class="activity-control-copy">
|
||||||
|
<span class="activity-eyebrow">Display</span>
|
||||||
|
<h2>Entries per page</h2>
|
||||||
|
<p>Choose how many log entries should be loaded at once.</p>
|
||||||
|
</div>
|
||||||
|
<div class="activity-entry-menu" role="group" aria-label="Entries per page">
|
||||||
|
<button type="button" class="activity-entry-option" data-limit="25" aria-pressed="false" onclick="setActivityLimit(25)">25</button>
|
||||||
|
<button type="button" class="activity-entry-option active" data-limit="50" aria-pressed="true" onclick="setActivityLimit(50)">50</button>
|
||||||
|
<button type="button" class="activity-entry-option" data-limit="100" aria-pressed="false" onclick="setActivityLimit(100)">100</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="activity-note">
|
||||||
|
<strong>Privacy:</strong> Sensitive request bodies are never stored. The log keeps request metadata, action type, status, IP address, and user agent.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container activity-table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Client</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="activity-log-body">
|
||||||
|
<tr><td colspan="6" class="table-loader">Loading activity...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activity-load-more-wrap">
|
||||||
|
<button type="button" id="activity-load-more" class="btn btn-secondary activity-load-more" onclick="loadMoreActivity()">Load more</button>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
@@ -24,12 +24,20 @@
|
|||||||
<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>
|
||||||
|
<button type="button" id="passkey-login-button" class="btn btn-secondary" style="margin-top: 0.75rem;">Sign in with passkey</button>
|
||||||
|
<p id="passkey-login-hint" class="subtitle" style="margin-top: 0.75rem;">Use a saved passkey from this device, your browser, or a security key. No username is required for passkey sign-in.</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div id="error" class="message error"></div>
|
<div id="error" class="message error"></div>
|
||||||
|
|||||||
29
go.mod
29
go.mod
@@ -4,21 +4,32 @@ go 1.26
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/glebarez/go-sqlite v1.22.0
|
github.com/glebarez/go-sqlite v1.22.0
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.5.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/tdewolff/minify/v2 v2.24.13
|
github.com/tdewolff/minify/v2 v2.24.13
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.53.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.1.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.2.6 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tdewolff/parse/v2 v2.8.12 // indirect
|
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
modernc.org/libc v1.37.6 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
modernc.org/memory v1.7.2 // indirect
|
modernc.org/libc v1.73.0 // indirect
|
||||||
modernc.org/sqlite v1.28.0 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.52.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
106
go.sum
106
go.sum
@@ -1,38 +1,100 @@
|
|||||||
|
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/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/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||||
|
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||||
|
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||||
|
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
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/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
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/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
|
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/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
|
||||||
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
|
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
|
||||||
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
|
github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick=
|
||||||
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
|
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 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
|
||||||
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||||
|
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
|
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||||
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/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||||
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
|
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||||
|
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
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.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||||
|
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -2,36 +2,45 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"MiauInv/auth"
|
"MiauInv/auth"
|
||||||
"MiauInv/config"
|
|
||||||
"MiauInv/models"
|
"MiauInv/models"
|
||||||
"MiauInv/storage"
|
"MiauInv/storage"
|
||||||
"MiauInv/util"
|
utils "MiauInv/util"
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"image/png"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"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
|
||||||
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", "", "auth.register.failed", "auth", "", "Invalid request body", http.StatusBadRequest)
|
||||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Username == "" || user.Password == "" {
|
if user.Username == "" || user.Password == "" {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Username or Password is empty")
|
log.Println("POST [api/register] " + r.RemoteAddr + ": Username or Password is empty")
|
||||||
|
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "Username and password required", http.StatusBadRequest)
|
||||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(user.Password) > 72 {
|
if len(user.Password) > 72 {
|
||||||
log.Println("POST [api/register] User password too long")
|
log.Println("POST [api/register] User password too long")
|
||||||
|
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "Password exceeds maximum length", http.StatusUnprocessableEntity)
|
||||||
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
|
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -48,13 +57,16 @@ func APIRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if err := storage.AddUser(&user); err != nil {
|
if err := storage.AddUser(&user); err != nil {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.failed", "auth", "", "User already exists or could not be created", http.StatusBadRequest)
|
||||||
http.Error(w, "user already exists", http.StatusBadRequest)
|
http.Error(w, "user already exists", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecordActivity(r, user.ID, strings.ToLower(strings.TrimSpace(user.Username)), "auth.register.succeeded", "auth", "", "User registered", http.StatusCreated)
|
||||||
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"`
|
||||||
@@ -62,6 +74,7 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", "", "auth.login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -69,12 +82,14 @@ func APILogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
user, err := storage.GetUserByUsername(creds.Username)
|
user, err := storage.GetUserByUsername(creds.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/login] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", strings.ToLower(strings.TrimSpace(creds.Username)), "auth.login.failed", "auth", "", "Invalid credentials", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !auth.CheckPasswordHash(creds.Password, user.Password) {
|
if !auth.CheckPasswordHash(creds.Password, user.Password) {
|
||||||
log.Println("POST [api/login] " + r.RemoteAddr + ": Invalid credentials")
|
log.Println("POST [api/login] " + r.RemoteAddr + ": Invalid credentials")
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.login.failed", "auth", "", "Invalid credentials", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -86,76 +101,460 @@ 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,
|
||||||
|
})
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.login.password_accepted_2fa_required", "auth", "", "Password accepted; 2FA required", http.StatusOK)
|
||||||
|
log.Println("POST [api/login] " + r.RemoteAddr + ": Password accepted, 2FA required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTokenPlain, err := utils.GenerateRefreshToken()
|
issueLoginSession(w, r, user)
|
||||||
if err != nil {
|
RecordActivity(r, user.ID, user.Username, "auth.login.succeeded", "auth", "", "Password login succeeded", http.StatusOK)
|
||||||
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())
|
||||||
|
RecordActivity(r, "", "", "auth.2fa_login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||||
|
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())
|
||||||
|
RecordActivity(r, "", "", "auth.2fa_login.failed", "auth", "", "Invalid or expired 2FA challenge", http.StatusUnauthorized)
|
||||||
|
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")
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.2fa_login.failed", "auth", "", "Invalid 2FA or recovery code", http.StatusUnauthorized)
|
||||||
|
http.Error(w, "Invalid 2FA or recovery code", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issueLoginSession(w, r, user)
|
||||||
|
if usedRecoveryCode {
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.recovery_code_login.succeeded", "auth", "", "Recovery-code login succeeded", http.StatusOK)
|
||||||
|
log.Println("POST [api/login/2fa] " + r.RemoteAddr + ": Successfully logged in with recovery code")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.2fa_login.succeeded", "auth", "", "2FA login succeeded", http.StatusOK)
|
||||||
|
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)
|
||||||
@@ -164,8 +563,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)
|
||||||
|
|
||||||
@@ -181,13 +582,25 @@ 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")
|
||||||
|
RecordActivity(r, "", "", "auth.refresh.failed", "auth", "", "Missing refresh token", http.StatusUnauthorized)
|
||||||
|
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +609,7 @@ func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
tokenRow, err := storage.GetRefreshToken(hashed)
|
tokenRow, err := storage.GetRefreshToken(hashed)
|
||||||
if err != nil || tokenRow.Revoked || tokenRow.ExpiresAt < time.Now().Unix() {
|
if err != nil || tokenRow.Revoked || tokenRow.ExpiresAt < time.Now().Unix() {
|
||||||
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Invalid refresh token")
|
log.Println("POST [api/refresh] " + r.RemoteAddr + ": Invalid refresh token")
|
||||||
|
RecordActivity(r, tokenRow.UserID, "", "auth.refresh.failed", "auth", "", "Invalid refresh token", http.StatusUnauthorized)
|
||||||
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
http.Error(w, "Invalid refresh token", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -204,43 +618,18 @@ 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,
|
RecordActivity(r, user.ID, user.Username, "auth.refresh.succeeded", "auth", "", "Refresh token rotated", http.StatusOK)
|
||||||
"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")
|
||||||
@@ -287,11 +676,37 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "User not found", http.StatusNotFound)
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recoveryCodesRemaining := 0
|
||||||
|
if user.TwoFactorEnabled {
|
||||||
|
if count, err := storage.CountUnusedRecoveryCodes(user.ID); err == nil {
|
||||||
|
recoveryCodesRemaining = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
twoFactorStatus := "disabled"
|
||||||
|
if user.TwoFactorEnabled {
|
||||||
|
twoFactorStatus = "enabled"
|
||||||
|
} else if user.TwoFactorSecret != "" {
|
||||||
|
twoFactorStatus = "setup_pending"
|
||||||
|
}
|
||||||
|
recoveryCodesWarning := user.TwoFactorEnabled && recoveryCodesRemaining <= recoveryCodeWarningThreshold
|
||||||
|
passkeyCount := 0
|
||||||
|
if count, err := storage.CountPasskeyCredentials(user.ID); err == nil {
|
||||||
|
passkeyCount = count
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
err = json.NewEncoder(w).Encode(map[string]interface{}{
|
err = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
"avatar_url": "",
|
"avatar_url": "",
|
||||||
|
"two_factor_enabled": user.TwoFactorEnabled,
|
||||||
|
"two_factor_status": twoFactorStatus,
|
||||||
|
"recovery_codes_remaining": recoveryCodesRemaining,
|
||||||
|
"recovery_codes_warning": recoveryCodesWarning,
|
||||||
|
"recovery_codes_warning_at": recoveryCodeWarningThreshold,
|
||||||
|
"passkeys_enabled": passkeyCount > 0,
|
||||||
|
"passkey_count": passkeyCount,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
|
||||||
@@ -299,3 +714,152 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func issueLoginSession(w http.ResponseWriter, r *http.Request, user models.User) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeyCount := 0
|
||||||
|
if count, err := storage.CountPasskeyCredentials(user.ID); err == nil {
|
||||||
|
passkeyCount = count
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
"passkeys_enabled": passkeyCount > 0,
|
||||||
|
"passkey_count": passkeyCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
223
handlers/activity.go
Normal file
223
handlers/activity.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"MiauInv/auth"
|
||||||
|
"MiauInv/models"
|
||||||
|
"MiauInv/storage"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type activityResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *activityResponseWriter) WriteHeader(statusCode int) {
|
||||||
|
w.statusCode = statusCode
|
||||||
|
w.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *activityResponseWriter) Write(data []byte) (int, error) {
|
||||||
|
if w.statusCode == 0 {
|
||||||
|
w.statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
return w.ResponseWriter.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActivityMiddleware(entityType string, includeGET bool) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
recorder := &activityResponseWriter{ResponseWriter: w}
|
||||||
|
next.ServeHTTP(recorder, r)
|
||||||
|
|
||||||
|
statusCode := recorder.statusCode
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldRecordActivity(r, includeGET) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok || claims == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := ""
|
||||||
|
if user, err := storage.GetUserById(claims.UserID); err == nil {
|
||||||
|
username = user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordActivity(r, claims.UserID, username, activityAction(r.Method, r.URL.Path), entityType, activityEntityID(r), activityDetails(statusCode), statusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActivityLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
if !ok || claims == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := parseBoundedInt(r.URL.Query().Get("limit"), 50, 1, 100)
|
||||||
|
offset := parseBoundedInt(r.URL.Query().Get("offset"), 0, 0, 100000)
|
||||||
|
includeAll := claims.Role == models.RoleAdmin && strings.EqualFold(r.URL.Query().Get("all"), "true")
|
||||||
|
|
||||||
|
entries, err := storage.ListActivityLogs(claims.UserID, includeAll, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("GET [api/activity] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not load activity log", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"activity": entries,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"all": includeAll,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordActivity(r *http.Request, userID, username, action, entityType, entityID, details string, statusCode int) {
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
entry := models.ActivityLogEntry{
|
||||||
|
UserID: userID,
|
||||||
|
Username: truncateForActivity(username, 120),
|
||||||
|
Action: truncateForActivity(action, 120),
|
||||||
|
EntityType: truncateForActivity(entityType, 80),
|
||||||
|
EntityID: truncateForActivity(entityID, 120),
|
||||||
|
Details: truncateForActivity(details, 500),
|
||||||
|
Method: r.Method,
|
||||||
|
Path: truncateForActivity(r.URL.Path, 255),
|
||||||
|
StatusCode: statusCode,
|
||||||
|
Success: statusCode >= 200 && statusCode < 400,
|
||||||
|
IPAddress: truncateForActivity(clientIP(r), 80),
|
||||||
|
UserAgent: truncateForActivity(r.UserAgent(), 500),
|
||||||
|
}
|
||||||
|
if err := storage.AddActivityLog(entry); err != nil {
|
||||||
|
log.Println("ACTIVITY " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRecordActivity(r *http.Request, includeGET bool) bool {
|
||||||
|
if r.Method == http.MethodOptions || r.Method == http.MethodHead {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if includeGET {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return r.Method != http.MethodGet
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityAction(method, path string) string {
|
||||||
|
path = strings.TrimSuffix(path, "/")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case path == "/api/logout":
|
||||||
|
return "auth.logout"
|
||||||
|
case path == "/api/account/username":
|
||||||
|
return "account.username.update"
|
||||||
|
case path == "/api/account/password":
|
||||||
|
return "account.password.update"
|
||||||
|
case path == "/api/2fa/setup":
|
||||||
|
return "security.2fa.setup"
|
||||||
|
case path == "/api/2fa/enable":
|
||||||
|
return "security.2fa.enable"
|
||||||
|
case path == "/api/2fa/disable":
|
||||||
|
return "security.2fa.disable"
|
||||||
|
case path == "/api/2fa/recovery-codes/regenerate":
|
||||||
|
return "security.2fa.recovery_codes.regenerate"
|
||||||
|
case path == "/api/passkeys/register/options":
|
||||||
|
return "security.passkey.registration.start"
|
||||||
|
case path == "/api/passkeys/register/finish":
|
||||||
|
return "security.passkey.registration.finish"
|
||||||
|
case path == "/api/passkeys/disable":
|
||||||
|
return "security.passkey.disable"
|
||||||
|
case path == "/api/passkeys":
|
||||||
|
if method == http.MethodDelete {
|
||||||
|
return "security.passkey.delete"
|
||||||
|
}
|
||||||
|
return "security.passkey.read"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case http.MethodPost:
|
||||||
|
return "inventory.create"
|
||||||
|
case http.MethodPut:
|
||||||
|
return "inventory.update"
|
||||||
|
case http.MethodDelete:
|
||||||
|
return "inventory.delete"
|
||||||
|
case http.MethodGet:
|
||||||
|
return "inventory.read"
|
||||||
|
default:
|
||||||
|
return strings.ToLower(method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityEntityID(r *http.Request) string {
|
||||||
|
if id := strings.TrimSpace(r.URL.Query().Get("id")); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func activityDetails(statusCode int) string {
|
||||||
|
if statusCode >= 200 && statusCode < 400 {
|
||||||
|
return "Request completed successfully."
|
||||||
|
}
|
||||||
|
return http.StatusText(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
if forwardedFor := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); forwardedFor != "" {
|
||||||
|
parts := strings.Split(forwardedFor, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBoundedInt(raw string, fallback, min, max int) int {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForActivity(value string, max int) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
runes := []rune(value)
|
||||||
|
if max <= 0 || len(runes) <= max {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return string(runes[:max])
|
||||||
|
}
|
||||||
583
handlers/passkeys.go
Normal file
583
handlers/passkeys.go
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"MiauInv/auth"
|
||||||
|
"MiauInv/models"
|
||||||
|
"MiauInv/storage"
|
||||||
|
utils "MiauInv/util"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const passkeyChallengeTTL = 5 * time.Minute
|
||||||
|
|
||||||
|
type passkeyWebAuthnUser struct {
|
||||||
|
user models.User
|
||||||
|
credentials []webauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
func (user passkeyWebAuthnUser) WebAuthnID() []byte {
|
||||||
|
return []byte(user.user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (user passkeyWebAuthnUser) WebAuthnName() string {
|
||||||
|
return user.user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func (user passkeyWebAuthnUser) WebAuthnDisplayName() string {
|
||||||
|
return user.user.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
func (user passkeyWebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||||
|
return user.credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
func Passkeys(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
listPasskeys(w, r)
|
||||||
|
case http.MethodDelete:
|
||||||
|
deletePasskey(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyRegisterOptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.Password) == "" {
|
||||||
|
http.Error(w, "Current 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/passkeys/register/options] " + 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
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser, err := loadPasskeyWebAuthnUser(user)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := webAuthnForRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creation, sessionData, err := wa.BeginRegistration(waUser,
|
||||||
|
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
|
||||||
|
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||||
|
RequireResidentKey: protocol.ResidentKeyRequired(),
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||||
|
UserVerification: protocol.VerificationRequired,
|
||||||
|
}),
|
||||||
|
webauthn.WithExclusions(webauthn.Credentials(waUser.WebAuthnCredentials()).CredentialDescriptors()),
|
||||||
|
webauthn.WithExtensions(map[string]any{"credProps": true}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not create passkey registration challenge", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken, err := utils.GenerateOpaqueToken()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not create passkey challenge", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := storage.SavePasskeyChallenge(sessionToken, user.ID, storage.PasskeyCeremonyRegister, *sessionData, passkeyChallengeTTL); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not save passkey challenge", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = storage.CleanupExpiredPasskeyChallenges()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"session_token": sessionToken,
|
||||||
|
"options": creation,
|
||||||
|
})
|
||||||
|
log.Println("POST [api/passkeys/register/options] " + r.RemoteAddr + ": Created passkey registration challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
SessionToken string `json:"session_token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.SessionToken == "" || len(req.Credential) == 0 {
|
||||||
|
http.Error(w, "Session token and credential 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/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge, sessionData, err := storage.ConsumePasskeyChallenge(req.SessionToken, storage.PasskeyCeremonyRegister)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid or expired passkey registration challenge", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if challenge.UserID != user.ID {
|
||||||
|
http.Error(w, "Invalid passkey registration challenge", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser, err := loadPasskeyWebAuthnUser(user)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := webAuthnForRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialRequest := cloneRequestWithJSONBody(r, req.Credential)
|
||||||
|
credential, err := wa.FinishRegistration(waUser, sessionData, credentialRequest)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not verify passkey registration", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "Passkey"
|
||||||
|
}
|
||||||
|
if _, err := storage.AddPasskeyCredential(user.ID, name, credential); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not save passkey", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeys, _ := storage.ListPasskeyCredentials(user.ID)
|
||||||
|
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
|
||||||
|
"passkeys_enabled": true,
|
||||||
|
"passkey_count": len(passkeys),
|
||||||
|
"passkeys": publicPasskeys(passkeys),
|
||||||
|
})
|
||||||
|
log.Println("POST [api/passkeys/register/finish] " + r.RemoteAddr + ": Registered passkey and revoked old sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyLoginOptions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := webAuthnForRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assertion, sessionData, err := wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired))
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not create passkey login challenge", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionToken, err := utils.GenerateOpaqueToken()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not create passkey challenge", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := storage.SavePasskeyChallenge(sessionToken, "", storage.PasskeyCeremonyLogin, *sessionData, passkeyChallengeTTL); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not save passkey challenge", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = storage.CleanupExpiredPasskeyChallenges()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"session_token": sessionToken,
|
||||||
|
"options": assertion,
|
||||||
|
})
|
||||||
|
log.Println("POST [api/passkeys/login/options] " + r.RemoteAddr + ": Created passkey login challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
SessionToken string `json:"session_token"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, "", "", "auth.passkey_login.failed", "auth", "", "Invalid request", http.StatusBadRequest)
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.SessionToken == "" || len(req.Credential) == 0 {
|
||||||
|
http.Error(w, "Session token and credential required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge, sessionData, err := storage.ConsumePasskeyChallenge(req.SessionToken, storage.PasskeyCeremonyLogin)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid or expired passkey login challenge", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := webAuthnForRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid WebAuthn origin", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialRequest := cloneRequestWithJSONBody(r, req.Credential)
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
var credential *webauthn.Credential
|
||||||
|
if challenge.UserID == "" {
|
||||||
|
var webAuthnUser webauthn.User
|
||||||
|
webAuthnUser, credential, err = wa.FinishPasskeyLogin(passkeyDiscoverableUserHandler, sessionData, credentialRequest)
|
||||||
|
if err == nil {
|
||||||
|
resolvedUser, ok := webAuthnUser.(passkeyWebAuthnUser)
|
||||||
|
if !ok {
|
||||||
|
err = errors.New("invalid passkey user type")
|
||||||
|
} else {
|
||||||
|
user = resolvedUser.user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user, err = storage.GetUserById(challenge.UserID)
|
||||||
|
if err == nil {
|
||||||
|
waUser, loadErr := loadPasskeyWebAuthnUser(user)
|
||||||
|
if loadErr != nil {
|
||||||
|
err = loadErr
|
||||||
|
} else {
|
||||||
|
credential, err = wa.FinishLogin(waUser, sessionData, credentialRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.passkey_login.failed", "auth", "", "Could not verify passkey login", http.StatusUnauthorized)
|
||||||
|
http.Error(w, "Could not verify passkey login", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.UpdatePasskeyCredentialAfterLogin(user.ID, credential); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not update passkey", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issueLoginSession(w, r, user)
|
||||||
|
RecordActivity(r, user.ID, user.Username, "auth.passkey_login.succeeded", "auth", "", "Passkey login succeeded", http.StatusOK)
|
||||||
|
log.Println("POST [api/passkeys/login/finish] " + r.RemoteAddr + ": Successfully logged in with passkey")
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyDisable(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"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/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/passkeys/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 err := storage.DeleteAllPasskeyCredentials(user.ID); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not disable passkeys", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
|
||||||
|
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
|
||||||
|
"passkeys_enabled": false,
|
||||||
|
"passkey_count": 0,
|
||||||
|
"passkeys": []map[string]interface{}{},
|
||||||
|
})
|
||||||
|
log.Println("POST [api/passkeys/disable] " + r.RemoteAddr + ": Disabled passkeys and revoked old sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func listPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims := r.Context().Value(auth.UserContextKey).(*auth.Claims)
|
||||||
|
passkeys, err := storage.ListPasskeyCredentials(claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("GET [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not load passkeys", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"passkeys": publicPasskeys(passkeys),
|
||||||
|
"passkeys_enabled": len(passkeys) > 0,
|
||||||
|
"passkey_count": len(passkeys),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ID == "" || req.Password == "" {
|
||||||
|
http.Error(w, "Passkey ID 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("DELETE [api/passkeys] " + 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.GetPasskeyCredentialByID(user.ID, req.ID); err != nil {
|
||||||
|
http.Error(w, "Passkey not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := storage.DeletePasskeyCredential(user.ID, req.ID); err != nil {
|
||||||
|
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not delete passkey", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := storage.RevokeAllRefreshTokensForUser(user.ID); err != nil {
|
||||||
|
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": " + err.Error())
|
||||||
|
http.Error(w, "Could not revoke old sessions", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeys, _ := storage.ListPasskeyCredentials(user.ID)
|
||||||
|
issueLoginSessionWithExtra(w, r, user, map[string]interface{}{
|
||||||
|
"passkeys_enabled": len(passkeys) > 0,
|
||||||
|
"passkey_count": len(passkeys),
|
||||||
|
"passkeys": publicPasskeys(passkeys),
|
||||||
|
})
|
||||||
|
log.Println("DELETE [api/passkeys] " + r.RemoteAddr + ": Deleted passkey and revoked old sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPasskeyWebAuthnUser(user models.User) (passkeyWebAuthnUser, error) {
|
||||||
|
rows, err := storage.ListPasskeyCredentials(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return passkeyWebAuthnUser{}, err
|
||||||
|
}
|
||||||
|
credentials, err := storage.DecodeWebAuthnCredentials(rows)
|
||||||
|
if err != nil {
|
||||||
|
return passkeyWebAuthnUser{}, err
|
||||||
|
}
|
||||||
|
return passkeyWebAuthnUser{user: user, credentials: credentials}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func passkeyDiscoverableUserHandler(rawID, userHandle []byte) (webauthn.User, error) {
|
||||||
|
row, err := storage.GetPasskeyCredentialByCredentialID(utils.EncodeBase64URL(rawID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row.UserID != string(userHandle) {
|
||||||
|
return nil, errors.New("passkey user handle mismatch")
|
||||||
|
}
|
||||||
|
user, err := storage.GetUserById(row.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return loadPasskeyWebAuthnUser(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicPasskeys(passkeys []models.PasskeyCredential) []map[string]interface{} {
|
||||||
|
out := make([]map[string]interface{}, 0, len(passkeys))
|
||||||
|
for _, passkey := range passkeys {
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"id": passkey.ID,
|
||||||
|
"name": passkey.Name,
|
||||||
|
"credential_id": passkey.CredentialID,
|
||||||
|
"created_at": passkey.CreatedAt,
|
||||||
|
}
|
||||||
|
if passkey.LastUsedAt > 0 {
|
||||||
|
item["last_used_at"] = passkey.LastUsedAt
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, error) {
|
||||||
|
origin, rpID, err := passkeyOriginAndRPID(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return webauthn.New(&webauthn.Config{
|
||||||
|
RPID: rpID,
|
||||||
|
RPDisplayName: "MiauInv",
|
||||||
|
RPOrigins: []string{origin},
|
||||||
|
RPTopOrigins: []string{origin},
|
||||||
|
RPTopOriginVerificationMode: protocol.TopOriginExplicitVerificationMode,
|
||||||
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||||
|
RequireResidentKey: protocol.ResidentKeyRequired(),
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||||
|
UserVerification: protocol.VerificationRequired,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func passkeyOriginAndRPID(r *http.Request) (string, string, error) {
|
||||||
|
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||||
|
if origin == "" {
|
||||||
|
scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
host := requestHost(r)
|
||||||
|
if host == "" {
|
||||||
|
return "", "", errors.New("missing request host")
|
||||||
|
}
|
||||||
|
origin = scheme + "://" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedOrigin, err := url.Parse(origin)
|
||||||
|
if err != nil || parsedOrigin.Scheme == "" || parsedOrigin.Host == "" {
|
||||||
|
return "", "", errors.New("invalid origin")
|
||||||
|
}
|
||||||
|
if parsedOrigin.Scheme != "https" && parsedOrigin.Hostname() != "localhost" {
|
||||||
|
return "", "", errors.New("passkeys require HTTPS except for localhost")
|
||||||
|
}
|
||||||
|
|
||||||
|
originHost := strings.ToLower(parsedOrigin.Hostname())
|
||||||
|
allowedHost := strings.ToLower(stripPort(requestHost(r)))
|
||||||
|
if allowedHost != "" && originHost != allowedHost {
|
||||||
|
return "", "", errors.New("origin host does not match request host")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedOrigin.Scheme + "://" + parsedOrigin.Host, originHost, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHost(r *http.Request) string {
|
||||||
|
if forwardedHost := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
|
||||||
|
parts := strings.Split(forwardedHost, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(r.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripPort(host string) string {
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
return parsedHost
|
||||||
|
}
|
||||||
|
if strings.Count(host, ":") == 0 {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneRequestWithJSONBody(r *http.Request, raw json.RawMessage) *http.Request {
|
||||||
|
clone := r.Clone(r.Context())
|
||||||
|
clone.Body = io.NopCloser(bytes.NewReader(raw))
|
||||||
|
clone.ContentLength = int64(len(raw))
|
||||||
|
clone.Header = r.Header.Clone()
|
||||||
|
clone.Header.Set("Content-Type", "application/json")
|
||||||
|
return clone
|
||||||
|
}
|
||||||
18
models/activity.go
Normal file
18
models/activity.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type ActivityLogEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
EntityType string `json:"entity_type"`
|
||||||
|
EntityID string `json:"entity_id"`
|
||||||
|
Details string `json:"details"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -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:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
19
models/passkeys.go
Normal file
19
models/passkeys.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type PasskeyCredential struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
CredentialID string `json:"credential_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CredentialData string `json:"-"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
LastUsedAt int64 `json:"last_used_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PasskeyChallenge struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Ceremony string `json:"ceremony"`
|
||||||
|
SessionData string `json:"-"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -71,6 +72,8 @@ 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.Handle("/profile/activity", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Activity)))
|
||||||
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
|
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
|
||||||
if this.AllowRegistration {
|
if this.AllowRegistration {
|
||||||
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
|
||||||
@@ -81,20 +84,45 @@ func (this *Server) Run() {
|
|||||||
//
|
//
|
||||||
// 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/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout)))
|
activityLimiter := newRateLimiter(60, time.Minute, 2*time.Minute)
|
||||||
mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
|
|
||||||
mux.HandleFunc("/api/userinfo", handlers.UserInfo)
|
authed := func(handler http.HandlerFunc) http.Handler {
|
||||||
if this.AllowRegistration {
|
return auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handler))
|
||||||
mux.HandleFunc("/api/register", handlers.APIRegister)
|
}
|
||||||
|
audited := func(entityType string, handler http.HandlerFunc) http.Handler {
|
||||||
|
return auth.AuthMiddleware(this.JWTSecret)(handlers.ActivityMiddleware(entityType, false)(http.HandlerFunc(handler)))
|
||||||
}
|
}
|
||||||
|
|
||||||
mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item)))
|
mux.Handle("/api/login", loginLimiter.Middleware(http.HandlerFunc(handlers.APILogin)))
|
||||||
mux.Handle("/api/location", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Location)))
|
mux.Handle("/api/login/2fa", loginLimiter.Middleware(http.HandlerFunc(handlers.APILoginTwoFactor)))
|
||||||
mux.Handle("/api/project", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Project)))
|
mux.Handle("/api/refresh", loginLimiter.Middleware(http.HandlerFunc(handlers.RefreshToken)))
|
||||||
mux.Handle("/api/stock", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Stock)))
|
mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(handlers.ActivityMiddleware("auth", true)(http.HandlerFunc(handlers.Logout))))
|
||||||
mux.Handle("/api/association", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Associations)))
|
mux.Handle("/api/profile", authed(handlers.UserInfo))
|
||||||
|
mux.Handle("/api/activity", activityLimiter.Middleware(authed(handlers.ActivityLog)))
|
||||||
|
mux.Handle("/api/2fa/setup", accountLimiter.Middleware(audited("security", handlers.TwoFactorSetup)))
|
||||||
|
mux.Handle("/api/2fa/enable", loginLimiter.Middleware(audited("security", handlers.TwoFactorEnable)))
|
||||||
|
mux.Handle("/api/2fa/disable", loginLimiter.Middleware(audited("security", handlers.TwoFactorDisable)))
|
||||||
|
mux.Handle("/api/2fa/recovery-codes/regenerate", loginLimiter.Middleware(audited("security", handlers.TwoFactorRegenerateRecoveryCodes)))
|
||||||
|
mux.Handle("/api/userinfo", authed(handlers.UserInfo))
|
||||||
|
mux.Handle("/api/account/username", accountLimiter.Middleware(audited("account", handlers.AccountUpdateUsername)))
|
||||||
|
mux.Handle("/api/account/password", loginLimiter.Middleware(audited("account", handlers.AccountUpdatePassword)))
|
||||||
|
mux.Handle("/api/passkeys", accountLimiter.Middleware(audited("security", handlers.Passkeys)))
|
||||||
|
mux.Handle("/api/passkeys/register/options", accountLimiter.Middleware(audited("security", handlers.PasskeyRegisterOptions)))
|
||||||
|
mux.Handle("/api/passkeys/register/finish", accountLimiter.Middleware(audited("security", handlers.PasskeyRegisterFinish)))
|
||||||
|
mux.Handle("/api/passkeys/disable", loginLimiter.Middleware(audited("security", handlers.PasskeyDisable)))
|
||||||
|
mux.Handle("/api/passkeys/login/options", loginLimiter.Middleware(http.HandlerFunc(handlers.PasskeyLoginOptions)))
|
||||||
|
mux.Handle("/api/passkeys/login/finish", loginLimiter.Middleware(http.HandlerFunc(handlers.PasskeyLoginFinish)))
|
||||||
|
if this.AllowRegistration {
|
||||||
|
mux.Handle("/api/register", loginLimiter.Middleware(http.HandlerFunc(handlers.APIRegister)))
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.Handle("/api/item", audited("item", handlers.Item))
|
||||||
|
mux.Handle("/api/location", audited("location", handlers.Location))
|
||||||
|
mux.Handle("/api/project", audited("project", handlers.Project))
|
||||||
|
mux.Handle("/api/stock", audited("stock", handlers.Stock))
|
||||||
|
mux.Handle("/api/association", audited("association", handlers.Associations))
|
||||||
|
|
||||||
// Assets
|
// Assets
|
||||||
mux.HandleFunc("/assets/", frontend.Assets)
|
mux.HandleFunc("/assets/", frontend.Assets)
|
||||||
|
|||||||
262
storage/passkeys.go
Normal file
262
storage/passkeys.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"MiauInv/models"
|
||||||
|
utils "MiauInv/util"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PasskeyCeremonyRegister = "register"
|
||||||
|
PasskeyCeremonyLogin = "login"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AddPasskeyCredential(userID, name string, credential *webauthn.Credential) (models.PasskeyCredential, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return models.PasskeyCredential{}, errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialJSON, err := json.Marshal(credential)
|
||||||
|
if err != nil {
|
||||||
|
return models.PasskeyCredential{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
row := models.PasskeyCredential{
|
||||||
|
ID: utils.GenerateUUID(),
|
||||||
|
UserID: userID,
|
||||||
|
CredentialID: utils.EncodeBase64URL(credential.ID),
|
||||||
|
Name: name,
|
||||||
|
CredentialData: string(credentialJSON),
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
if row.Name == "" {
|
||||||
|
row.Name = "Passkey"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = DB.Exec(`
|
||||||
|
INSERT INTO passkey_credentials(id, user_id, credential_id, name, credential_data, created_at, last_used_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
||||||
|
`, row.ID, row.UserID, row.CredentialID, row.Name, row.CredentialData, row.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return models.PasskeyCredential{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListPasskeyCredentials(userID string) ([]models.PasskeyCredential, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return nil, errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := DB.Query(`
|
||||||
|
SELECT id, user_id, credential_id, name, credential_data, created_at, COALESCE(last_used_at, 0)
|
||||||
|
FROM passkey_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
credentials := make([]models.PasskeyCredential, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var credential models.PasskeyCredential
|
||||||
|
if err := rows.Scan(&credential.ID, &credential.UserID, &credential.CredentialID, &credential.Name, &credential.CredentialData, &credential.CreatedAt, &credential.LastUsedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
credentials = append(credentials, credential)
|
||||||
|
}
|
||||||
|
return credentials, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CountPasskeyCredentials(userID string) (int, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return 0, errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := DB.QueryRow("SELECT COUNT(*) FROM passkey_credentials WHERE user_id = ?", userID).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPasskeyCredentialByCredentialID(credentialID string) (models.PasskeyCredential, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return models.PasskeyCredential{}, errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
row := DB.QueryRow(`
|
||||||
|
SELECT id, user_id, credential_id, name, credential_data, created_at, COALESCE(last_used_at, 0)
|
||||||
|
FROM passkey_credentials
|
||||||
|
WHERE credential_id = ?
|
||||||
|
`, credentialID)
|
||||||
|
return scanPasskeyCredential(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPasskeyCredentialByID(userID, id string) (models.PasskeyCredential, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return models.PasskeyCredential{}, errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
row := DB.QueryRow(`
|
||||||
|
SELECT id, user_id, credential_id, name, credential_data, created_at, COALESCE(last_used_at, 0)
|
||||||
|
FROM passkey_credentials
|
||||||
|
WHERE user_id = ? AND id = ?
|
||||||
|
`, userID, id)
|
||||||
|
return scanPasskeyCredential(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanPasskeyCredential(row *sql.Row) (models.PasskeyCredential, error) {
|
||||||
|
var credential models.PasskeyCredential
|
||||||
|
err := row.Scan(&credential.ID, &credential.UserID, &credential.CredentialID, &credential.Name, &credential.CredentialData, &credential.CreatedAt, &credential.LastUsedAt)
|
||||||
|
return credential, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeWebAuthnCredential(row models.PasskeyCredential) (webauthn.Credential, error) {
|
||||||
|
var credential webauthn.Credential
|
||||||
|
err := json.Unmarshal([]byte(row.CredentialData), &credential)
|
||||||
|
return credential, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeWebAuthnCredentials(rows []models.PasskeyCredential) ([]webauthn.Credential, error) {
|
||||||
|
credentials := make([]webauthn.Credential, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
credential, err := DecodeWebAuthnCredential(row)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
credentials = append(credentials, credential)
|
||||||
|
}
|
||||||
|
return credentials, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePasskeyCredentialAfterLogin(userID string, credential *webauthn.Credential) error {
|
||||||
|
if DB == nil {
|
||||||
|
return errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialID := utils.EncodeBase64URL(credential.ID)
|
||||||
|
credentialJSON, err := json.Marshal(credential)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := DB.Exec(`
|
||||||
|
UPDATE passkey_credentials
|
||||||
|
SET credential_data = ?, last_used_at = ?
|
||||||
|
WHERE user_id = ? AND credential_id = ?
|
||||||
|
`, string(credentialJSON), time.Now().Unix(), userID, credentialID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePasskeyCredential(userID, id string) error {
|
||||||
|
if DB == nil {
|
||||||
|
return errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := DB.Exec("DELETE FROM passkey_credentials WHERE user_id = ? AND id = ?", userID, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteAllPasskeyCredentials(userID string) error {
|
||||||
|
if DB == nil {
|
||||||
|
return errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := DB.Exec("DELETE FROM passkey_credentials WHERE user_id = ?", userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func SavePasskeyChallenge(token, userID, ceremony string, sessionData webauthn.SessionData, ttl time.Duration) error {
|
||||||
|
if DB == nil {
|
||||||
|
return errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedSession, err := json.Marshal(sessionData)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = DB.Exec(`
|
||||||
|
INSERT INTO passkey_challenges(token, user_id, ceremony, session_data, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, token, userID, ceremony, string(encodedSession), time.Now().Add(ttl).Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConsumePasskeyChallenge(token, ceremony string) (models.PasskeyChallenge, webauthn.SessionData, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return models.PasskeyChallenge{}, webauthn.SessionData{}, errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
row := tx.QueryRow(`
|
||||||
|
SELECT token, user_id, ceremony, session_data, expires_at
|
||||||
|
FROM passkey_challenges
|
||||||
|
WHERE token = ? AND ceremony = ?
|
||||||
|
`, token, ceremony)
|
||||||
|
|
||||||
|
var challenge models.PasskeyChallenge
|
||||||
|
if err := row.Scan(&challenge.Token, &challenge.UserID, &challenge.Ceremony, &challenge.SessionData, &challenge.ExpiresAt); err != nil {
|
||||||
|
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec("DELETE FROM passkey_challenges WHERE token = ?", token); err != nil {
|
||||||
|
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if challenge.ExpiresAt < time.Now().Unix() {
|
||||||
|
return models.PasskeyChallenge{}, webauthn.SessionData{}, errors.New("passkey challenge expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionData webauthn.SessionData
|
||||||
|
if err := json.Unmarshal([]byte(challenge.SessionData), &sessionData); err != nil {
|
||||||
|
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return models.PasskeyChallenge{}, webauthn.SessionData{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return challenge, sessionData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupExpiredPasskeyChallenges() error {
|
||||||
|
if DB == nil {
|
||||||
|
return errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := DB.Exec("DELETE FROM passkey_challenges WHERE expires_at < ?", time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -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,52 @@ 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 passkey_credentials (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
credential_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
credential_data TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used_at INTEGER DEFAULT NULL,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS passkey_challenges (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL DEFAULT '',
|
||||||
|
ceremony TEXT NOT NULL,
|
||||||
|
session_data TEXT NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL DEFAULT '',
|
||||||
|
username TEXT NOT NULL DEFAULT '',
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL DEFAULT '',
|
||||||
|
entity_id TEXT NOT NULL DEFAULT '',
|
||||||
|
details TEXT NOT NULL DEFAULT '',
|
||||||
|
method TEXT NOT NULL DEFAULT '',
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
status_code INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
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,27 +134,284 @@ func InitDB(filepath string) error {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureUserTwoFactorColumns(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureActivityLogIndexes(); 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureActivityLogIndexes() error {
|
||||||
|
indexes := []string{
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created ON activity_logs(user_id, created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_activity_logs_created ON activity_logs(created_at DESC)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action)",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range indexes {
|
||||||
|
if _, err := DB.Exec(stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity logs
|
||||||
|
func AddActivityLog(entry models.ActivityLogEntry) error {
|
||||||
|
if DB == nil {
|
||||||
|
return errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
if entry.ID == "" {
|
||||||
|
entry.ID = utils.GenerateUUID()
|
||||||
|
}
|
||||||
|
if entry.CreatedAt == 0 {
|
||||||
|
entry.CreatedAt = time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
if entry.Success {
|
||||||
|
success = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := DB.Exec(`
|
||||||
|
INSERT INTO activity_logs(
|
||||||
|
id, user_id, username, action, entity_type, entity_id, details,
|
||||||
|
method, path, status_code, success, ip_address, user_agent, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, entry.ID, entry.UserID, entry.Username, entry.Action, entry.EntityType, entry.EntityID, entry.Details,
|
||||||
|
entry.Method, entry.Path, entry.StatusCode, success, entry.IPAddress, entry.UserAgent, entry.CreatedAt)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListActivityLogs(userID string, includeAll bool, limit, offset int) ([]models.ActivityLogEntry, error) {
|
||||||
|
if DB == nil {
|
||||||
|
return nil, errors.New("db not initialized")
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, username, action, entity_type, entity_id, details,
|
||||||
|
method, path, status_code, success, ip_address, user_agent, created_at
|
||||||
|
FROM activity_logs
|
||||||
|
`
|
||||||
|
args := []interface{}{}
|
||||||
|
if !includeAll {
|
||||||
|
query += " WHERE user_id = ?"
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
query += " ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?"
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
|
rows, err := DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
logs := []models.ActivityLogEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var entry models.ActivityLogEntry
|
||||||
|
var success int
|
||||||
|
if err := rows.Scan(
|
||||||
|
&entry.ID, &entry.UserID, &entry.Username, &entry.Action, &entry.EntityType, &entry.EntityID, &entry.Details,
|
||||||
|
&entry.Method, &entry.Path, &entry.StatusCode, &success, &entry.IPAddress, &entry.UserAgent, &entry.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry.Success = success == 1
|
||||||
|
logs = append(logs, entry)
|
||||||
|
}
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
func AddUser(user *models.User) error {
|
func AddUser(user *models.User) error {
|
||||||
_, err := DB.Exec("INSERT INTO users(id, username, password, role) VALUES (?, ?, ?, ?)", user.ID, strings.ToLower(user.Username), user.Password, user.Role)
|
_, err := DB.Exec("INSERT INTO users(id, username, password, role) VALUES (?, ?, ?, ?)", user.ID, strings.ToLower(user.Username), user.Password, user.Role)
|
||||||
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 (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
|||||||
@@ -23,12 +23,19 @@ func GenerateSecret() string {
|
|||||||
return base64.StdEncoding.EncodeToString(b)
|
return base64.StdEncoding.EncodeToString(b)
|
||||||
}
|
}
|
||||||
func GenerateRefreshToken() (string, error) {
|
func GenerateRefreshToken() (string, error) {
|
||||||
|
return GenerateOpaqueToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateOpaqueToken() (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
func EncodeBase64URL(data []byte) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
func HashToken(token string) string {
|
func HashToken(token string) string {
|
||||||
hash := sha256.Sum256([]byte(token))
|
hash := sha256.Sum256([]byte(token))
|
||||||
return hex.EncodeToString(hash[:])
|
return hex.EncodeToString(hash[:])
|
||||||
|
|||||||
Reference in New Issue
Block a user