Adding Rest API token with expiration and revocation options.

This commit is contained in:
Rafael Foster
2026-04-25 12:22:58 -03:00
parent bdd6e559d5
commit f43dee4cc0
22 changed files with 681 additions and 6 deletions
+146
View File
@@ -8,12 +8,14 @@ import express from 'express';
import bcrypt from 'bcrypt';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
import crypto from 'node:crypto';
import * as db from './db.js';
import { generateToken, csrfMiddleware } from './middleware/csrf.js';
import { createLogger } from './logger.js';
const log = createLogger('Auth');
const router = express.Router();
const API_TOKEN_PREFIX = 'oikos_';
// --------------------------------------------------------
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
@@ -124,6 +126,60 @@ const loginLimiter = rateLimit({
message: { error: 'Zu viele Login-Versuche. Bitte warte kurz.', code: 429 },
});
function hashApiToken(token) {
return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
}
function extractApiToken(req) {
const auth = req.headers.authorization || '';
if (auth.toLowerCase().startsWith('bearer ')) return auth.slice(7).trim();
return String(req.headers['x-api-key'] || '').trim();
}
function publicApiToken(row) {
return {
id: row.id,
name: row.name,
token_prefix: row.token_prefix,
created_by: row.created_by,
creator_name: row.creator_name,
expires_at: row.expires_at,
revoked_at: row.revoked_at,
last_used_at: row.last_used_at,
created_at: row.created_at,
};
}
function authenticateApiToken(req) {
const token = extractApiToken(req);
if (!token) return null;
const tokenHash = hashApiToken(token);
const row = db.get().prepare(`
SELECT t.*, u.role, u.username, u.display_name, u.avatar_color
FROM api_tokens t
JOIN users u ON u.id = t.created_by
WHERE t.token_hash = ?
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
`).get(tokenHash);
if (!row) return null;
db.get().prepare(`
UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?
`).run(row.id);
req.apiToken = publicApiToken(row);
req.user = {
id: row.created_by,
username: row.username,
display_name: row.display_name,
avatar_color: row.avatar_color,
role: row.role,
};
return row;
}
// --------------------------------------------------------
// Auth-Guard Middleware
// --------------------------------------------------------
@@ -133,7 +189,18 @@ const loginLimiter = rateLimit({
* Schützt alle API-Routen außer /auth/login.
*/
function requireAuth(req, res, next) {
const apiToken = authenticateApiToken(req);
if (apiToken) {
req.authMethod = 'api_token';
req.session = {
userId: apiToken.created_by,
role: apiToken.role,
};
return next();
}
if (req.session && req.session.userId) {
req.authMethod = 'session';
return next();
}
res.status(401).json({ error: 'Not authenticated.', code: 401 });
@@ -225,6 +292,9 @@ router.post('/login', loginLimiter, async (req, res) => {
* Response: { ok: true }
*/
router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
if (req.authMethod === 'api_token') {
return res.json({ ok: true });
}
req.session.destroy((err) => {
if (err) {
log.error('Logout error:', err);
@@ -300,6 +370,10 @@ router.get('/me', requireAuth, (req, res) => {
return res.status(401).json({ error: 'User not found.', code: 401 });
}
if (req.authMethod === 'api_token') {
return res.json({ user });
}
// CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume:
// iOS kann den CSRF-Cookie verwerfen waehrend die Session-Cookie erhalten bleibt.
// /me ist der erste API-Call nach App-Resume, also hier den Cookie wiederherstellen.)
@@ -337,6 +411,78 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => {
}
});
router.get('/api-tokens', requireAuth, requireAdmin, (req, res) => {
try {
const rows = db.get().prepare(`
SELECT t.*, u.display_name AS creator_name
FROM api_tokens t
LEFT JOIN users u ON u.id = t.created_by
ORDER BY t.created_at DESC
`).all();
res.json({ data: rows.map(publicApiToken) });
} catch (err) {
log.error('API token list error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
router.post('/api-tokens', requireAuth, requireAdmin, csrfMiddleware, (req, res) => {
try {
const name = String(req.body.name || '').trim();
const expiresAt = req.body.expires_at ? String(req.body.expires_at).trim() : null;
if (!name) return res.status(400).json({ error: 'Token name is required.', code: 400 });
if (name.length > 100) return res.status(400).json({ error: 'Token name may be at most 100 characters long.', code: 400 });
if (expiresAt && Number.isNaN(Date.parse(expiresAt))) {
return res.status(400).json({ error: 'expires_at must be a valid ISO date/time.', code: 400 });
}
if (expiresAt && new Date(expiresAt).getTime() <= Date.now()) {
return res.status(400).json({ error: 'Expiration date must be in the future.', code: 400 });
}
const token = API_TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url');
const tokenHash = hashApiToken(token);
const tokenPrefix = token.slice(0, 12);
const normalizedExpiresAt = expiresAt ? new Date(expiresAt).toISOString() : null;
const result = db.get().prepare(`
INSERT INTO api_tokens (name, token_hash, token_prefix, created_by, expires_at)
VALUES (?, ?, ?, ?, ?)
`).run(name, tokenHash, tokenPrefix, req.session.userId, normalizedExpiresAt);
const row = db.get().prepare(`
SELECT t.*, u.display_name AS creator_name
FROM api_tokens t
LEFT JOIN users u ON u.id = t.created_by
WHERE t.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ data: publicApiToken(row), token });
} catch (err) {
log.error('API token creation error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
router.delete('/api-tokens/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res) => {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid token ID.', code: 400 });
const result = db.get().prepare(`
UPDATE api_tokens
SET revoked_at = COALESCE(revoked_at, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
WHERE id = ?
`).run(id);
if (result.changes === 0) return res.status(404).json({ error: 'API token not found.', code: 404 });
res.json({ ok: true });
} catch (err) {
log.error('API token revocation error:', err);
res.status(500).json({ error: 'Internal server error.', code: 500 });
}
});
/**
* POST /api/v1/auth/users
* Admin only. Erstellt neues Familienmitglied.
+13
View File
@@ -144,6 +144,17 @@ const MIGRATIONS_SQL = {
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(category_key, name)
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT,
revoked_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
AFTER UPDATE ON users FOR EACH ROW
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
@@ -185,6 +196,8 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned);
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
`,
2: `
CREATE TABLE IF NOT EXISTS sync_config (
+20
View File
@@ -671,6 +671,26 @@ const MIGRATIONS = [
GROUP BY category, subcategory;
`,
},
{
version: 17,
description: 'API tokens for non-interactive authentication',
up: `
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT,
revoked_at TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
`,
},
];
/**
+2
View File
@@ -28,6 +28,8 @@ function generateToken() {
* Muss NACH requireAuth eingebunden werden.
*/
function csrfMiddleware(req, res, next) {
if (req.authMethod === 'api_token') return next();
// Token generieren falls noch nicht vorhanden (erste Request nach Login)
if (!req.session.csrfToken) {
req.session.csrfToken = generateToken();