Перейти к содержанию

Владелец файла и авторизация запросов

Политика заявки определила допустимые фотографии, но ещё не определила, кто вправе работать с конкретной карточкой. Добавим владельца запроса. В «Архиве» Алиса и Боб используют разные лабораторные bearer-токены. Сервер получает идентичность из заголовка Authorization, а не из JSON и не из параметра URL. Теперь даже будущие операции подтверждения и удаления будут иметь одну понятную границу доступа.

В этой главе важно различать аутентификацию и авторизацию. Первая устанавливает, какой пользователь отправил запрос. Вторая проверяет, принадлежит ли этому пользователю выбранная карточка. Успешная аутентификация Боба не даёт ему права на файл Алисы. Такие две проверки часто ошибочно объединяют в одну middleware-функцию, после которой считают любое чтение по UUID безопасным.

Лабораторная идентичность

В .env.example токены оставлены пустыми. Для самостоятельной лаборатории нужно задать два независимых случайных значения длиной хотя бы тридцать два символа. В исходниках нет рабочего секрета и нет эндпоинта, который выдаёт токен по имени сотрудника. Такая простая модель позволяет изучить владение файлами, не добавляя отдельный курс регистрации, паролей и восстановления доступа.

Полный файл auth.mjs состояния 03:

import { createHash, timingSafeEqual } from 'node:crypto';
import { Failure } from './http.mjs';
const digest=value=>createHash('sha256').update(value).digest();
const identities=[['alice',process.env.ALICE_TOKEN],['bob',process.env.BOB_TOKEN]];
if(identities.some(([,token])=>!token || token.length<32))
  throw new Error('Set two distinct lab bearer tokens of at least 32 characters');
if(identities[0][1]===identities[1][1]) throw new Error('Tokens must differ');
export function authenticate(req) {
  const header=req.headers.authorization || '';
  if(!header.startsWith('Bearer ')) throw new Failure(401,'authentication_required');
  const actual=digest(header.slice(7)); let owner;
  for(const [id,token] of identities) if(timingSafeEqual(actual,digest(token))) owner=id;
  if(!owner) throw new Failure(401,'authentication_required');
  return owner;
}
export async function owned(client,id,owner,lock=false) {
  const q=await client.query(`SELECT * FROM archive_files WHERE id=$1 AND owner_id=$2
    ${lock?'FOR UPDATE':''}`,[id,owner]);
  if(!q.rowCount) throw new Failure(404,'file_not_found'); return q.rows[0];
}

Для сравнения значений сначала вычисляем SHA-256, затем используем timingSafeEqual над буферами одинаковой длины. Это устраняет обычное сравнение строки с ранним завершением. Хеш здесь не становится механизмом выпуска токенов или хранения пользовательских паролей. Функция всего лишь сравнивает входное значение с локальными лабораторными секретами и возвращает одно из двух серверных имён владельцев.

Проверка конфигурации выполняется при загрузке модуля. Если токены не заданы или совпадают, процесс не должен начинать обслуживание. Иначе учебный пример незаметно объединил бы Алису и Боба либо разрешил бы вход с пустым значением. Ошибка конфигурации отличается от неудачной аутентификации запроса: её должен исправить владелец лаборатории, а не посетитель API.

Авторизация на bearer-токене предполагает HTTPS при передаче за пределами локальной машины. Токен, попавший в чужие руки, позволяет действовать от соответствующего пользователя. Поэтому его нельзя включать в URL, журналы или скриншоты. Учебная модель не содержит времени истечения, отзыва и управления сессиями; перед реальным выпуском её заменяют проверенной системой идентичности с нужными жизненными циклами.

Проверка владельца в SQL

Функция owned() выполняет запрос сразу по двум условиям: идентификатор и владелец. Это лучше, чем сначала получать запись по UUID, а затем надеяться, что каждый вызывающий код не забудет проверить owner_id. Все маршруты, которым нужна карточка, используют один и тот же механизм. Значения передаются параметрами SQL, поэтому имя владельца не становится фрагментом выражения.

Если запись не найдена, возвращаем 404. Одинаковый ответ применяется и к отсутствующему UUID, и к существующему чужому UUID. Так API не помогает Бобу перечислять фотографии Алисы по разнице между 403 и 404. Это не избавляет от ограничения частоты запросов, но делает контракт доступа последовательным. Сам сотрудник видит только собственную выборку списка.

В функции есть параметр lock. Позднее изменение состояния потребует FOR UPDATE внутри транзакции. У блокировки другая задача: она предотвращает несовместимые одновременные переходы, но не устанавливает владельца. Поэтому условие owner_id=$2 сохраняется и при блокировке. Удобно явно видеть эти две обязанности в одном запросе и не принимать одну за замену другой.

Полный файл server.mjs состояния 03:

import { createServer } from 'node:http';
import { db } from './db.mjs';
import { send,json,Failure,fileId } from './http.mjs';
import { authenticate } from './auth.mjs';
const server=createServer(async(req,res)=>{
  try {
    const path=new URL(req.url,'http://localhost').pathname;
    if(req.method==='GET' && path==='/health') return send(res,200,{service:'archive'});
    const owner=authenticate(req);
    if(req.method==='GET' && path==='/v1/files') {
      const q=await db.query('SELECT id,name,status FROM archive_files WHERE owner_id=$1 ORDER BY created_at DESC LIMIT 100',[owner]);
      return send(res,200,{items:q.rows});
    }
    throw new Failure(404,'route_not_found');
  } catch(error) { send(res,error.status||500,{error:error.code||'internal_error'}); }
});
server.listen(Number(process.env.PORT||8080),'127.0.0.1');
process.on('SIGTERM',()=>{server.close(async()=>{await db.end();});});

Ожидаемый GET /v1/files с токеном Алисы содержит только её карточки. Пока база пуста, ожидается items: []. Запрос без bearer-заголовка получает 401 и authentication_required. На этом этапе маршрута чтения отдельной карточки ещё нет, однако собственная выборка уже показывает принцип, который будет повторён при подтверждении, скачивании и удалении.

Почему здесь нет cookie

Браузер не добавляет наш bearer-заголовок к запросу автоматически. Его должен установить клиент, которому явно передано значение. Cookie сервер не читает. Поэтому в текущей модели нет обычной угрозы CSRF, основанной на автоматической отправке сессионной cookie чужой страницей. Это свойство конкретного способа авторизации, а не общий вывод, что файловому архиву CSRF-защита никогда не требуется.

Если интерфейс компании использует сессионную cookie, соответствующий сервер обязан проверять CSRF-токен для изменяющих запросов, проверять допустимый Origin и задавать cookie Secure, HttpOnly и подходящий SameSite. SameSite полезен, но не заменяет явную защиту во всех сценариях. Прямая загрузка в S3 тоже начинается с получения разрешения в приложении, поэтому этот изменяющий запрос входит в ту же границу защиты.

Не стоит хранить долгоживущий bearer-токен в localStorage как универсальный совет. Ошибка XSS позволяет прочитать такое значение. Наш пример предназначен для локальных API-запросов и разбора владения; выбор клиентского хранения зависит от полноценной модели входа. При сохранении прав доступа нужно оценивать весь путь секрета, а не только серверную функцию сравнения.

Теперь представим, что Боб отправляет будущему маршруту download UUID Алисы. authenticate() вернёт bob, а owned() не найдёт строку с этой парой. Подписывающий модуль вообще не получит чужой ключ объекта. Это принципиально: закрытый S3 не спасёт приложение, если оно само подписывает любой переданный клиентом ключ. Подпись должна быть последним шагом уже разрешённой операции.

Отдельно учтём смену владельца. В лаборатории перенос карточки не поддерживается, поэтому owner_id остаётся неизменным. Если продукту нужен перенос между сотрудниками или организациями, это самостоятельная операция с правами, аудитом и правилом для уже выданных ссылок. Изменить одну строку базы недостаточно, потому что временная ссылка живёт до своего истечения независимо от нового владельца.

После этого урока «Архив» умеет устанавливать владельца и возвращать его список. Следующий шаг подключит объектное хранилище, но не изменит эту проверку. Перед чтением полезно сформулировать два вопроса к любому маршруту: откуда получен пользователь и какой SQL ограничивает его карточки. Ответы должны оставаться ясными даже при усложнении фоновой обработки.

Предыдущий урок: Политика размера и типа файла · Следующий урок: Закрытое S3-совместимое хранилище

Оглавление серии · Исходники всех состояний.

Документация: Node.js: timingSafeEqual, PostgreSQL: SELECT.