|
|
|
@@ -17,6 +17,8 @@ const log = createLogger('Auth');
|
|
|
|
|
const router = express.Router();
|
|
|
|
|
const API_TOKEN_PREFIX = 'oikos_';
|
|
|
|
|
const FAMILY_ROLES = ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'];
|
|
|
|
|
const MAX_AVATAR_DATA_LENGTH = 768 * 1024;
|
|
|
|
|
const USER_PUBLIC_COLUMNS = 'id, username, display_name, avatar_color, avatar_data, role, family_role, created_at';
|
|
|
|
|
|
|
|
|
|
// --------------------------------------------------------
|
|
|
|
|
// Session-Store (better-sqlite3, gleiche DB-Instanz wie App)
|
|
|
|
@@ -151,13 +153,61 @@ function publicApiToken(row) {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publicUser(row) {
|
|
|
|
|
return {
|
|
|
|
|
id: row.id,
|
|
|
|
|
username: row.username,
|
|
|
|
|
display_name: row.display_name,
|
|
|
|
|
avatar_color: row.avatar_color,
|
|
|
|
|
avatar_data: row.avatar_data ?? null,
|
|
|
|
|
role: row.role,
|
|
|
|
|
family_role: row.family_role,
|
|
|
|
|
created_at: row.created_at,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeAvatarData(value) {
|
|
|
|
|
if (value === undefined) return undefined;
|
|
|
|
|
if (value === null || value === '') return null;
|
|
|
|
|
if (typeof value !== 'string') return { error: 'Avatar image must be a data URL string.' };
|
|
|
|
|
if (value.length > MAX_AVATAR_DATA_LENGTH) {
|
|
|
|
|
return { error: 'Avatar image is too large.' };
|
|
|
|
|
}
|
|
|
|
|
if (!/^data:image\/(?:png|jpeg|webp);base64,[a-z0-9+/=]+$/i.test(value)) {
|
|
|
|
|
return { error: 'Avatar image must be PNG, JPEG, or WebP.' };
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function assertAdminWouldRemain(targetUserId, nextRole) {
|
|
|
|
|
if (nextRole === 'admin') return null;
|
|
|
|
|
const current = db.get().prepare('SELECT role FROM users WHERE id = ?').get(targetUserId);
|
|
|
|
|
if (!current || current.role !== 'admin') return null;
|
|
|
|
|
const row = db.get().prepare('SELECT COUNT(*) AS count FROM users WHERE role = ? AND id != ?').get('admin', targetUserId);
|
|
|
|
|
return row.count > 0 ? null : 'At least one system admin must remain.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateUserRoleSessions(userId, role) {
|
|
|
|
|
const allSessions = db.get().prepare('SELECT sid, sess FROM sessions').all();
|
|
|
|
|
const updateSession = db.get().prepare('UPDATE sessions SET sess = ? WHERE sid = ?');
|
|
|
|
|
for (const row of allSessions) {
|
|
|
|
|
try {
|
|
|
|
|
const sess = JSON.parse(row.sess);
|
|
|
|
|
if (sess.userId === userId) {
|
|
|
|
|
sess.role = role;
|
|
|
|
|
updateSession.run(JSON.stringify(sess), row.sid);
|
|
|
|
|
}
|
|
|
|
|
} catch { /* ignore malformed session */ }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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, u.family_role
|
|
|
|
|
SELECT t.*, u.role, u.username, u.display_name, u.avatar_color, u.avatar_data, u.family_role
|
|
|
|
|
FROM api_tokens t
|
|
|
|
|
JOIN users u ON u.id = t.created_by
|
|
|
|
|
WHERE t.token_hash = ?
|
|
|
|
@@ -176,6 +226,7 @@ function authenticateApiToken(req) {
|
|
|
|
|
username: row.username,
|
|
|
|
|
display_name: row.display_name,
|
|
|
|
|
avatar_color: row.avatar_color,
|
|
|
|
|
avatar_data: row.avatar_data,
|
|
|
|
|
role: row.role,
|
|
|
|
|
family_role: row.family_role,
|
|
|
|
|
};
|
|
|
|
@@ -278,6 +329,7 @@ router.post('/login', loginLimiter, async (req, res) => {
|
|
|
|
|
username: user.username,
|
|
|
|
|
display_name: user.display_name,
|
|
|
|
|
avatar_color: user.avatar_color,
|
|
|
|
|
avatar_data: user.avatar_data,
|
|
|
|
|
role: user.role,
|
|
|
|
|
family_role: user.family_role,
|
|
|
|
|
},
|
|
|
|
@@ -347,7 +399,7 @@ router.post('/setup', loginLimiter, async (req, res) => {
|
|
|
|
|
.run(username, display_name, hash, avatarColor, 'admin');
|
|
|
|
|
|
|
|
|
|
res.status(201).json({
|
|
|
|
|
user: { id: result.lastInsertRowid, username, display_name, avatar_color: avatarColor, role: 'admin', family_role: 'other' },
|
|
|
|
|
user: { id: result.lastInsertRowid, username, display_name, avatar_color: avatarColor, avatar_data: null, role: 'admin', family_role: 'other' },
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err.message?.includes('UNIQUE constraint')) {
|
|
|
|
@@ -365,7 +417,7 @@ router.post('/setup', loginLimiter, async (req, res) => {
|
|
|
|
|
router.get('/me', requireAuth, (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const user = db.get()
|
|
|
|
|
.prepare('SELECT id, username, display_name, avatar_color, role, family_role FROM users WHERE id = ?')
|
|
|
|
|
.prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`)
|
|
|
|
|
.get(req.authUserId);
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
@@ -376,7 +428,7 @@ router.get('/me', requireAuth, (req, res) => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (req.authMethod === 'api_token') {
|
|
|
|
|
return res.json({ user });
|
|
|
|
|
return res.json({ user: publicUser(user) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CSRF-Token erneuern falls vorhanden (wichtig fuer iOS-PWA-Resume:
|
|
|
|
@@ -392,7 +444,7 @@ router.get('/me', requireAuth, (req, res) => {
|
|
|
|
|
maxAge: 1000 * 60 * 60 * 24 * 7,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
res.json({ user, csrfToken: req.session.csrfToken });
|
|
|
|
|
res.json({ user: publicUser(user), csrfToken: req.session.csrfToken });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
log.error('/me error:', err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
|
|
|
@@ -407,9 +459,9 @@ router.get('/me', requireAuth, (req, res) => {
|
|
|
|
|
router.get('/users', requireAuth, requireAdmin, (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const users = db.get()
|
|
|
|
|
.prepare('SELECT id, username, display_name, avatar_color, role, family_role, created_at FROM users ORDER BY display_name')
|
|
|
|
|
.prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users ORDER BY display_name`)
|
|
|
|
|
.all();
|
|
|
|
|
res.json({ data: users });
|
|
|
|
|
res.json({ data: users.map(publicUser) });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
log.error('Users error:', err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
|
|
|
@@ -501,6 +553,7 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
|
|
|
|
|
display_name,
|
|
|
|
|
password,
|
|
|
|
|
avatar_color = '#007AFF',
|
|
|
|
|
avatar_data,
|
|
|
|
|
family_role = 'other',
|
|
|
|
|
system_admin = req.body.role === 'admin',
|
|
|
|
|
} = req.body;
|
|
|
|
@@ -526,17 +579,24 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
|
|
|
|
|
return res.status(400).json({ error: 'Invalid family role.', code: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalizedAvatarData = normalizeAvatarData(avatar_data);
|
|
|
|
|
if (normalizedAvatarData?.error) {
|
|
|
|
|
return res.status(400).json({ error: normalizedAvatarData.error, code: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hash = await bcrypt.hash(password, 12);
|
|
|
|
|
|
|
|
|
|
const result = db.get()
|
|
|
|
|
.prepare(`
|
|
|
|
|
INSERT INTO users (username, display_name, password_hash, avatar_color, role, family_role)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
|
|
|
INSERT INTO users (username, display_name, password_hash, avatar_color, avatar_data, role, family_role)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
|
|
|
`)
|
|
|
|
|
.run(username, display_name, hash, avatar_color, role, family_role);
|
|
|
|
|
.run(username, display_name, hash, avatar_color, normalizedAvatarData ?? null, role, family_role);
|
|
|
|
|
|
|
|
|
|
const createdUser = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(result.lastInsertRowid);
|
|
|
|
|
|
|
|
|
|
res.status(201).json({
|
|
|
|
|
user: { id: result.lastInsertRowid, username, display_name, avatar_color, role, family_role },
|
|
|
|
|
user: publicUser(createdUser),
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err.message && err.message.includes('UNIQUE constraint')) {
|
|
|
|
@@ -547,6 +607,107 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PATCH /api/v1/auth/users/:id
|
|
|
|
|
* Admin only. Updates a family member profile and system-admin flag.
|
|
|
|
|
*/
|
|
|
|
|
router.patch('/users/:id', requireAuth, requireAdmin, csrfMiddleware, async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const userId = parseInt(req.params.id, 10);
|
|
|
|
|
if (!Number.isFinite(userId)) return res.status(400).json({ error: 'Invalid user ID.', code: 400 });
|
|
|
|
|
|
|
|
|
|
const existing = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(userId);
|
|
|
|
|
if (!existing) return res.status(404).json({ error: 'User not found.', code: 404 });
|
|
|
|
|
|
|
|
|
|
const username = req.body.username !== undefined ? String(req.body.username || '').trim() : existing.username;
|
|
|
|
|
const displayName = req.body.display_name !== undefined ? String(req.body.display_name || '').trim() : existing.display_name;
|
|
|
|
|
const avatarColor = req.body.avatar_color !== undefined ? String(req.body.avatar_color || '').trim() : existing.avatar_color;
|
|
|
|
|
const familyRole = req.body.family_role !== undefined ? String(req.body.family_role || '').trim() : existing.family_role;
|
|
|
|
|
const nextRole = req.body.system_admin !== undefined
|
|
|
|
|
? (req.body.system_admin === true || req.body.system_admin === 'true' ? 'admin' : 'member')
|
|
|
|
|
: existing.role;
|
|
|
|
|
const avatarData = req.body.avatar_data !== undefined
|
|
|
|
|
? normalizeAvatarData(req.body.avatar_data)
|
|
|
|
|
: existing.avatar_data;
|
|
|
|
|
|
|
|
|
|
if (!username || !displayName) {
|
|
|
|
|
return res.status(400).json({ error: 'Username and display name are required.', code: 400 });
|
|
|
|
|
}
|
|
|
|
|
if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) {
|
|
|
|
|
return res.status(400).json({ error: 'Username must be 3-64 characters long and may only contain letters, numbers, dots, hyphens, and underscores.', code: 400 });
|
|
|
|
|
}
|
|
|
|
|
if (displayName.length > 128) {
|
|
|
|
|
return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 });
|
|
|
|
|
}
|
|
|
|
|
if (!FAMILY_ROLES.includes(familyRole)) {
|
|
|
|
|
return res.status(400).json({ error: 'Invalid family role.', code: 400 });
|
|
|
|
|
}
|
|
|
|
|
if (avatarData?.error) {
|
|
|
|
|
return res.status(400).json({ error: avatarData.error, code: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const adminError = assertAdminWouldRemain(userId, nextRole);
|
|
|
|
|
if (adminError) return res.status(400).json({ error: adminError, code: 400 });
|
|
|
|
|
|
|
|
|
|
db.get().prepare(`
|
|
|
|
|
UPDATE users
|
|
|
|
|
SET username = ?, display_name = ?, avatar_color = ?, avatar_data = ?, role = ?, family_role = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
`).run(username, displayName, avatarColor || '#007AFF', avatarData ?? null, nextRole, familyRole, userId);
|
|
|
|
|
|
|
|
|
|
if (nextRole !== existing.role) {
|
|
|
|
|
updateUserRoleSessions(userId, nextRole);
|
|
|
|
|
if (userId === req.authUserId && req.session) req.session.role = nextRole;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(userId);
|
|
|
|
|
res.json({ user: publicUser(updated) });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (err.message && err.message.includes('UNIQUE constraint')) {
|
|
|
|
|
return res.status(409).json({ error: 'Username is already taken.', code: 409 });
|
|
|
|
|
}
|
|
|
|
|
log.error('User update error:', err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PATCH /api/v1/auth/me/profile
|
|
|
|
|
* Updates the current user's profile picture and basic profile fields.
|
|
|
|
|
*/
|
|
|
|
|
router.patch('/me/profile', requireAuth, csrfMiddleware, (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const existing = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(req.authUserId);
|
|
|
|
|
if (!existing) return res.status(404).json({ error: 'User not found.', code: 404 });
|
|
|
|
|
|
|
|
|
|
const displayName = req.body.display_name !== undefined ? String(req.body.display_name || '').trim() : existing.display_name;
|
|
|
|
|
const avatarColor = req.body.avatar_color !== undefined ? String(req.body.avatar_color || '').trim() : existing.avatar_color;
|
|
|
|
|
const avatarData = req.body.avatar_data !== undefined
|
|
|
|
|
? normalizeAvatarData(req.body.avatar_data)
|
|
|
|
|
: existing.avatar_data;
|
|
|
|
|
|
|
|
|
|
if (!displayName) return res.status(400).json({ error: 'Display name is required.', code: 400 });
|
|
|
|
|
if (displayName.length > 128) {
|
|
|
|
|
return res.status(400).json({ error: 'Display name may be at most 128 characters long.', code: 400 });
|
|
|
|
|
}
|
|
|
|
|
if (avatarData?.error) {
|
|
|
|
|
return res.status(400).json({ error: avatarData.error, code: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
db.get().prepare(`
|
|
|
|
|
UPDATE users
|
|
|
|
|
SET display_name = ?, avatar_color = ?, avatar_data = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
`).run(displayName, avatarColor || '#007AFF', avatarData ?? null, req.authUserId);
|
|
|
|
|
|
|
|
|
|
const updated = db.get().prepare(`SELECT ${USER_PUBLIC_COLUMNS} FROM users WHERE id = ?`).get(req.authUserId);
|
|
|
|
|
res.json({ user: publicUser(updated) });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
log.error('Profile update error:', err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error.', code: 500 });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PATCH /api/v1/auth/me/password
|
|
|
|
|
* Ändert das eigene Passwort.
|
|
|
|
|