Adding Rest API token with expiration and revocation options.
This commit is contained in:
+146
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user