From f1caa62f28830348af5217c7e3cbfae28ca6633a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 24 Aug 2026 13:34:37 +0500 Subject: [PATCH] =?UTF-8?q?refactor:=20=D1=81=D0=B5=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=82=20=D0=B8=20=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=20=D0=B2=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B5=20secrets/,=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=20=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Локально путь к сертификату вёл в ~/Downloads у каждого свой, а в контейнере задавался двумя отдельными переменными. Теперь каталог один и подменяется целиком через SECRETS_DIR: по умолчанию secrets/ в корне репозитория, в контейнере /certs (смонтированный каталог с хоста, GRAPH_CERT_DIR не меняется). Имена файлов внутри фиксированы, поэтому GRAPH_CERT_PATH и GRAPH_KEY_PATH больше не нужны. Содержимое secrets/ в .gitignore, кроме README. Отсутствие файлов теперь даёт понятную ошибку вместо ENOENT — на свежем клоне это основной сценарий отказа. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 +++++ README.md | 8 ++++---- docker-compose.yml | 5 +++-- secrets/README.md | 34 ++++++++++++++++++++++++++++++++++ server/.env.example | 12 ++++++------ server/README.md | 8 +++++--- server/src/config.ts | 16 ++++++++++++++-- server/src/graphAuth.ts | 19 +++++++++++++++++-- 8 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 secrets/README.md diff --git a/.gitignore b/.gitignore index b38ea7c..fecb284 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ yarn-error.log* pnpm-debug.log* .env + +# Сертификаты и ключи. Каталог подменяется целиком: локально это secrets/, +# в контейнере — примонтированный /certs (см. docker-compose.yml). +secrets/* +!secrets/README.md diff --git a/README.md b/README.md index 02ee682..6812e9e 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ npm run dev так же, как nginx в проде (`nginx.conf`). Без запущенного `[auth]` отправка формы отдаёт 500 от прокси. -Сервису нужен `server/.env` с сертификатом Azure AD — скопируйте -[server/.env.example](server/.env.example) и подставьте свои пути. Сами -`.pem`-файлы в git не хранятся, их надо взять у администратора. Подробнее — -в [server/README.md](server/README.md). +Сервису нужны две вещи: `server/.env` (скопируйте +[server/.env.example](server/.env.example)) и сертификат Azure AD в каталоге +[`secrets/`](secrets/README.md). Сами `.pem`-файлы в git не хранятся — их надо +взять у администратора. Подробнее — в [server/README.md](server/README.md). Прочие команды: diff --git a/docker-compose.yml b/docker-compose.yml index 463e2b6..3621671 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,9 @@ services: environment: GRAPH_TENANT_ID: ${GRAPH_TENANT_ID:?в .env на сервере не задан GRAPH_TENANT_ID} GRAPH_CLIENT_ID: ${GRAPH_CLIENT_ID:?в .env на сервере не задан GRAPH_CLIENT_ID} - GRAPH_CERT_PATH: /certs/SPFxDeploy_cert.pem - GRAPH_KEY_PATH: /certs/SPFxDeploy_key.pem + # Один параметр на весь каталог с секретами — имена файлов внутри + # фиксированы (см. server/src/config.ts). + SECRETS_DIR: /certs volumes: # Каталог с сертификатом/ключом на сервере — путь задаётся в .env # (GRAPH_CERT_DIR), сами файлы в git не попадают. diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..aafbd12 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,34 @@ +# secrets + +Сертификат и приватный ключ приложения Azure AD, под которым сервис +`hr-appeals-auth` получает app-only токен. **В git не попадают** — всё +содержимое каталога, кроме этого файла, в `.gitignore`. + +Положите сюда два файла (взять у администратора): + +``` +secrets/ + SPFxDeploy_cert.pem + SPFxDeploy_key.pem +``` + +Имена фиксированные — их ждёт [server/src/config.ts](../server/src/config.ts). + +## Подменить каталог целиком + +Путь задаётся одной переменной `SECRETS_DIR`, поэтому каталог можно заменить +не трогая код: + +```bash +SECRETS_DIR=/path/to/other/secrets npm run dev --prefix server +``` + +По умолчанию — этот каталог. В продовом контейнере `SECRETS_DIR=/certs`, куда +смонтирован каталог с хоста: путь на хосте задаёт `GRAPH_CERT_DIR` в `.env` +рядом с `docker-compose.yml` на сервере. + +## Если ключ утёк + +Сертификат перевыпускается в Azure AD (App registrations → нужное приложение → +Certificates & secrets), старый оттуда удаляется. Замена файлов здесь без этого +ничего не даёт: Azure AD продолжит принимать подпись старым ключом. diff --git a/server/.env.example b/server/.env.example index 00eca37..09c7f86 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,13 +1,13 @@ -# Локальный запуск: скопируйте в server/.env и подставьте свои пути к сертификату. -# На проде эти переменные задаёт docker-compose (см. docker-compose.yml), .env там свой. +# Локальный запуск: скопируйте в server/.env. +# На проде эти переменные задаёт docker-compose (см. docker-compose.yml). GRAPH_TENANT_ID=b4c2af35-bfd6-4cff-9af9-556efb53e65a GRAPH_CLIENT_ID=26d2e058-0b27-4a00-b49f-1f17c4eecd1d -# Сертификат и приватный ключ приложения — в git не хранятся, взять у администратора. -GRAPH_CERT_PATH=C:/path/to/SPFxDeploy_cert.pem -GRAPH_KEY_PATH=C:/path/to/SPFxDeploy_key.pem - # 3000 занят vite (см. vite.config.ts), поэтому локально сервис слушает 3100 — # именно туда проксирует /api из dev-сервера. PORT=3100 + +# Каталог с сертификатом и ключом. По умолчанию — secrets/ в корне репозитория, +# задавать нужно только чтобы подменить его на другой (см. secrets/README.md). +# SECRETS_DIR=/path/to/secrets diff --git a/server/README.md b/server/README.md index 5d8c100..969f236 100644 --- a/server/README.md +++ b/server/README.md @@ -13,8 +13,7 @@ assertion) и используется для авторизованных за |---|---| | `GRAPH_TENANT_ID` | Tenant ID Azure AD | | `GRAPH_CLIENT_ID` | Client ID App Registration, к которому привязан сертификат `SPFxDeploy` | -| `GRAPH_CERT_PATH` | Путь к `SPFxDeploy_cert.pem` внутри контейнера (см. `docker-compose.yml` в корне репозитория) | -| `GRAPH_KEY_PATH` | Путь к `SPFxDeploy_key.pem` внутри контейнера | +| `SECRETS_DIR` | Каталог с `SPFxDeploy_cert.pem` и `SPFxDeploy_key.pem` (имена файлов фиксированы). По умолчанию — [`secrets/`](../secrets/README.md) в корне репозитория; в контейнере `/certs`, куда смонтирован каталог с хоста. Задавать нужно только чтобы подменить каталог целиком | | `GRAPH_SCOPE` | OAuth-scope запрашиваемого токена. По умолчанию — App ID URI самого `api.tnsu.kz` (`api://9232d9e2-.../.default`), задавать не нужно. Токен для `https://graph.microsoft.com/.default` тут не подойдёт: `api.tnsu.kz` проверяет `aud` входящего JWT и отвечает 401 | | `PORT` | Порт HTTP-сервера сервиса, по умолчанию `3000` (локально — `3100`, см. `.env.example`) | @@ -23,10 +22,13 @@ assertion) и используется для авторизованных за Из корня репозитория `npm run dev` поднимает сервис вместе с фронтендом. Отдельно: ```bash -cp .env.example .env # подставить свои пути к SPFxDeploy_cert.pem / _key.pem +cp .env.example .env # tenant/client id и порт npm run dev # tsc + запуск с чтением .env (--env-file, Node 20+) ``` +Сертификат и ключ при этом должны лежать в [`secrets/`](../secrets/README.md) — +в git они не хранятся. + Проверить, что авторизация работает: `curl http://localhost:3100/diag/token` → `{"ok":true}`. Если вернулась ошибка — в ней будет ответ Azure AD целиком. diff --git a/server/src/config.ts b/server/src/config.ts index 37fecd7..e460dff 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,3 +1,5 @@ +import { join, resolve } from 'node:path'; + function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -6,11 +8,21 @@ function requireEnv(name: string): string { return value; } +/** + * Каталог с сертификатом и приватным ключом приложения. Одна переменная на весь + * каталог, а не путь к каждому файлу: так его можно подменить централизованно — + * локально это `secrets/` в корне репозитория (в git не попадает), в контейнере + * SECRETS_DIR=/certs, куда смонтирован каталог с хоста (см. docker-compose.yml). + * + * __dirname здесь — server/dist, поэтому до корня репозитория два уровня вверх. + */ +const secretsDir = process.env.SECRETS_DIR ?? resolve(__dirname, '../../secrets'); + export const config = { tenantId: requireEnv('GRAPH_TENANT_ID'), clientId: requireEnv('GRAPH_CLIENT_ID'), - certPath: requireEnv('GRAPH_CERT_PATH'), - keyPath: requireEnv('GRAPH_KEY_PATH'), + certPath: join(secretsDir, 'SPFxDeploy_cert.pem'), + keyPath: join(secretsDir, 'SPFxDeploy_key.pem'), // Токен нужен не для Graph напрямую, а для api.tnsu.kz — он сам ходит в // SharePoint. Это его App ID URI: с graph.microsoft.com/.default api вернёт // 401, потому что проверяет aud во входящем JWT. diff --git a/server/src/graphAuth.ts b/server/src/graphAuth.ts index 624422f..32e50d1 100644 --- a/server/src/graphAuth.ts +++ b/server/src/graphAuth.ts @@ -21,9 +21,24 @@ const REFRESH_MARGIN_MS = 60_000; let cached: CachedToken | null = null; +function readSecret(path: string): string { + try { + return readFileSync(path, 'utf8'); + } catch (cause) { + // Самая частая причина на свежем клоне — файлов просто нет: в git они не + // хранятся. Ошибка ENOENT сама по себе не подсказывает, что делать. + throw new Error( + `Не удалось прочитать ${path}. Сертификат и ключ в git не хранятся — ` + + 'положите их в каталог secrets/ (см. secrets/README.md) или укажите ' + + 'другой каталог через SECRETS_DIR.', + { cause } + ); + } +} + function buildClientAssertion(): string { - const certificatePem = readFileSync(config.certPath, 'utf8'); - const privateKey = readFileSync(config.keyPath, 'utf8'); + const certificatePem = readSecret(config.certPath); + const privateKey = readSecret(config.keyPath); // x5t — SHA-1 отпечаток DER-сертификата, base64url. По нему Azure AD находит // публичный ключ приложения и проверяет подпись JWT.