5d5db4e766
* fix(security): close authz/ownership gaps (secure-download binding, photo-auth+logout revocation, feedback/customer ownership, token logging) * fix(security): codex round-1 — complete admin-token invalidation + preserve foreign assignments - photoAuth: mirror adminAuth's active-admin lookup + iat<password_changed_at check in the admin branch, so a deactivated admin or a pre-password-change token can no longer fetch every photo (GHSA-x55x was only revoke+cutoff). - adminAuth logout: revoke req.token (the token adminAuth authenticated with, cookie OR header) instead of header-only, and clear the auth cookie — a cookie-based logout previously left the JWT live (GHSA-cjqh). - adminCustomers PUT /:id/events: preserve the customer's existing assignments to events the caller does NOT own, so a restricted admin can't revoke another admin's customer-event links via full-list replacement. * fix(security): codex round-2 — don't 403 legit restricted-admin assignment edits The Manage-galleries dialog submits the full initial assignment list, so a restricted admin editing a customer that already has a foreign assignment hit the denied.length 403 before the preservation logic ran. Reject only NEWLY-supplied foreign/nonexistent ids; retain foreign ids the customer is already assigned to (they can't be added or removed by a non-owner). --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
177 lines
6.9 KiB
JavaScript
177 lines
6.9 KiB
JavaScript
const bcrypt = require('bcrypt');
|
|
const jwt = require('jsonwebtoken');
|
|
const { db } = require('../database/db');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
|
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
|
const logger = require('../utils/logger');
|
|
|
|
async function photoAuth(req, res, next) {
|
|
try {
|
|
// Extract event slug from the path
|
|
let eventSlug;
|
|
|
|
// For thumbnails, we need to parse the filename to get the event info
|
|
if (req.path.startsWith('/thumb_')) {
|
|
// For now, we'll rely on JWT token for thumbnail access
|
|
eventSlug = null;
|
|
} else {
|
|
// For regular photos, the slug is the first part of the path
|
|
eventSlug = req.path.split('/')[1];
|
|
}
|
|
|
|
// First check for JWT token (from gallery access)
|
|
const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
|
|
if (tokenFromRequest) {
|
|
const token = tokenFromRequest;
|
|
try {
|
|
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
|
let decoded;
|
|
try {
|
|
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
|
algorithms: ['HS256'],
|
|
issuer: 'picpeak-auth'
|
|
});
|
|
} catch (issuerError) {
|
|
// If verification fails with issuer, try without issuer (backward compatibility)
|
|
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
|
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
|
} else {
|
|
throw issuerError;
|
|
}
|
|
}
|
|
|
|
// Check if it's a gallery token
|
|
if (decoded.type === 'gallery') {
|
|
// For thumbnails, we need to verify the token is for a valid event
|
|
if (!eventSlug) {
|
|
// Resolve the token's event (by id, or legacy slug fallback)...
|
|
let event = null;
|
|
if (decoded.eventId) {
|
|
event = await db('events')
|
|
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
|
|
.first();
|
|
}
|
|
if (!event && decoded.eventSlug) {
|
|
event = await db('events')
|
|
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
|
.first();
|
|
}
|
|
// ...then confirm the REQUESTED thumbnail actually belongs to
|
|
// that event. Thumbnails are stored flat (thumbnails/thumb_<name>)
|
|
// with deterministic, enumerable filenames derived from the
|
|
// public event name + a sequential counter. Without this
|
|
// ownership check any holder of a gallery token for any event
|
|
// could enumerate and fetch another (password-protected) event's
|
|
// entire thumbnail set, defeating the gallery password. A
|
|
// traversal or foreign filename simply fails to match → denied.
|
|
if (event) {
|
|
const requestedKey = `thumbnails${req.path}`;
|
|
const ownsThumbnail = await db('photos')
|
|
.where({ event_id: event.id, thumbnail_path: requestedKey })
|
|
.first();
|
|
if (ownsThumbnail) {
|
|
req.event = event;
|
|
return next();
|
|
}
|
|
}
|
|
}
|
|
// For regular photos, check if token matches the event
|
|
else if (decoded.eventSlug === eventSlug) {
|
|
const event = await db('events')
|
|
.where({ slug: eventSlug, is_active: formatBoolean(true) })
|
|
.first();
|
|
if (event) {
|
|
req.event = event;
|
|
return next();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if it's an admin token (admins can view all photos)
|
|
if (decoded.type === 'admin') {
|
|
// Enforce the same revocation / session-cutoff invalidation that
|
|
// adminAuth does — otherwise a validly-signed admin JWT keeps
|
|
// serving photos after logout, password change, or explicit
|
|
// revocation (GHSA-x55x).
|
|
if (await isTokenRevoked(decoded)) {
|
|
return res.status(401).json({ error: 'Session expired' });
|
|
}
|
|
// adminAuth also (a) rejects tokens for a now-deactivated admin and
|
|
// (b) rejects any token minted before the admin's last password
|
|
// change. Token revocation alone doesn't cover those, so without
|
|
// these two checks a stale or pre-password-change admin token still
|
|
// fetches every photo.
|
|
const admin = await db('admin_users')
|
|
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
|
.select('id', 'password_changed_at')
|
|
.first();
|
|
if (!admin) {
|
|
return res.status(401).json({ error: 'Session expired' });
|
|
}
|
|
if (admin.password_changed_at) {
|
|
const passwordChangedSeconds = Math.floor(
|
|
new Date(admin.password_changed_at).getTime() / 1000
|
|
);
|
|
if (decoded.iat < passwordChangedSeconds) {
|
|
return res.status(401).json({ error: 'Session expired' });
|
|
}
|
|
}
|
|
return next();
|
|
}
|
|
} catch (err) {
|
|
// Token invalid, fall through to password check
|
|
logger.warn('JWT verification failed in photoAuth', { error: err.message });
|
|
}
|
|
}
|
|
|
|
// Check for password header (legacy support)
|
|
const password = req.headers['x-gallery-password'];
|
|
|
|
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
|
if (!eventSlug && !password && !tokenFromRequest) {
|
|
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
|
}
|
|
|
|
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
|
if (!event) {
|
|
return res.status(404).json({ error: 'Gallery not found' });
|
|
}
|
|
|
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
|
|
|
if (!requiresPassword) {
|
|
req.event = event;
|
|
return next();
|
|
}
|
|
|
|
if (!password && !tokenFromRequest) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
|
|
if (password) {
|
|
const validPassword = await bcrypt.compare(password, event.password_hash);
|
|
if (!validPassword) {
|
|
await db('access_logs').insert({
|
|
event_id: event.id,
|
|
ip_address: req.ip,
|
|
user_agent: req.headers['user-agent'],
|
|
action: 'login_fail'
|
|
});
|
|
return res.status(401).json({ error: 'Invalid password' });
|
|
}
|
|
} else {
|
|
// No valid authentication
|
|
return res.status(401).json({ error: 'Invalid authentication' });
|
|
}
|
|
|
|
req.event = event;
|
|
next();
|
|
} catch (error) {
|
|
logger.error('Photo auth error', { error: error.message, stack: error.stack });
|
|
res.status(500).json({ error: 'Authentication error' });
|
|
}
|
|
}
|
|
|
|
module.exports = photoAuth;
|