Files
picpeak/backend/src/middleware/photoAuth.js
T
Paul Nothaft 5d5db4e766 fix(security): authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (stable) (#951)
* 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>
2026-08-02 08:39:32 +02:00

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;