Corp/docs/guides/OIDC.md
Игорь Надеин 86fed54811
All checks were successful
Deploy production / deploy (push) Successful in 10m4s
Add OIDC back-channel logout for deactivated accounts
2026-10-27 20:01:26 +03:00

236 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Корпоративный OpenID Connect
## Back-Channel Logout при отключении сотрудника
Необязательный механизм включается настройкой `OIDC_BACKCHANNEL_LOGOUT_ENABLED=true`.
Он не синхронизирует справочник сотрудников: провайдер запоминает только пары
«сотрудник — OIDC-клиент», для которых прошёл вход. В админке суперпользователь
может зарегистрировать для отдельного клиента HTTPS-адрес back-channel logout.
Без регистрации адресов уведомления никуда не отправляются. Поддерживаются
RS256-клиенты и выход всех сессий по `sub`; механизм с отдельными `sid` не реализован.
Раз в минуту задача Celery проверяет фактический статус запомненных сотрудников,
включая кадровые события с будущей датой. Когда учётная запись отключена или
увольнение вступило в силу, для каждого зарегистрированного клиента создаётся
запись очереди. Отправляется подписанный JWT `logout_token` согласно OIDC
Back-Channel Logout. Временные ошибки сети, 408, 429 и 5xx повторяются с
увеличением интервала; остальные HTTP-ошибки отмечаются как неудачные в админке,
где суперпользователь может запустить повтор. Если сотрудник восстановлен до
отправки, устаревшее уведомление отменяется.
Для входов, состоявшихся до включения механизма, можно дополнить список по ещё
сохранённым OIDC-токенам: команда `python manage.py seed_backchannel_identities`
показывает количество, а с `--apply` добавляет отсутствующие записи. Это не
восстанавливает информацию об уже удалённых или очищенных токенах; старые сессии
таких пользователей требуют отдельной проверки или повторного входа.
Корректное закрытие сессий, собственных токенов и SSH-ключей зависит от каждого
подключённого приложения; регистрация адреса не подтверждает его совместимость.
При передаче уведомления в момент повторного приёма возможна гонка с новой
сессией: после восстановления в случае её закрытия потребуется повторный вход.
Портал выступает доверенным OpenID Connect Provider для внутренних приложений. Первое подключение — Gitea; тот же контракт можно использовать для других клиентов с поддержкой OIDC Authorization Code Flow.
## Контракт
- issuer: `https://corp.example.com/o`
- discovery: `https://corp.example.com/o/.well-known/openid-configuration`
- flow: Authorization Code
- подпись ID token: RS256
- client authentication: `client_secret_basic` или `client_secret_post`
- scopes: `openid`, `profile`, `email`, `groups`
- идентификатор пользователя `sub`: неизменяемый Django PK сотрудника
- рекомендуемый логин: claim `preferred_username` из LDAP username; fallback `employee-<pk>`
Пароль портала стороннему приложению не передаётся. Обычный JWT-вход портала создаёт короткую browser session только перед возвратом на `/o/authorize/`. Redirect из bridge ограничен собственным authorization endpoint и не может увести пользователя на произвольный сайт.
Неактивный, уволенный или не подтвердивший email сотрудник не может обменять authorization code, использовать access token либо обновить токен. Внешний клиент получает только claims из согласованных scopes.
## Production-настройка портала
OIDC и callback клиентов должны работать по HTTPS. Ключ подписи хранится вне Git и резервируется как production secret.
Создать отдельный RSA-ключ:
```bash
install -d -m 700 /srv/eusrr/secrets
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 \
-out /srv/eusrr/secrets/oidc-private.pem
chmod 600 /srv/eusrr/secrets/oidc-private.pem
```
Передать файл backend-контейнеру read-only и задать окружение:
```dotenv
SITE_URL=https://corp.example.com
PORTAL_FRONTEND_URL=https://corp.example.com
OIDC_PROVIDER_ENABLED=true
OIDC_RSA_PRIVATE_KEY_PATH=/run/secrets/eusrr_oidc_private_key.pem
# При ротации: OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE=/run/secrets/oidc-private-old.pem
OIDC_ISSUER_URL=https://corp.example.com/o
OIDC_PORTAL_LOGIN_URL=https://corp.example.com/login
OIDC_BROWSER_SESSION_SECONDS=28800
OIDC_ACCESS_TOKEN_SECONDS=3600
OIDC_ID_TOKEN_SECONDS=600
OIDC_REFRESH_TOKEN_SECONDS=43200
SESSION_COOKIE_SECURE=true
```
`OIDC_ISSUER_URL` — постоянный публичный идентификатор. Его нельзя менять после подключения клиентов без их перенастройки. Reverse proxy должен сохранять исходный HTTPS scheme и host.
Публичный reverse proxy должен отправлять весь prefix `/o/` в Django так же, как `/api/`. Пример для Nginx:
```nginx
location ^~ /o/ {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://django_backend;
}
```
После установки зависимостей выполнить миграции:
```bash
python manage.py migrate
python manage.py check --deploy
```
## Регистрация Gitea в портале
Сначала выберите неизменяемое имя authentication source в Gitea, например `corp`. Оно входит в callback URL:
```text
https://git.example.com/user/oauth2/corp/callback
```
Создать confidential-клиент и сохранить показанный secret в менеджер секретов:
```bash
python manage.py provision_oidc_client \
--name Gitea \
--client-id gitea \
--redirect-uri https://git.example.com/user/oauth2/corp/callback
```
Повторный запуск обновляет параметры, не меняя secret. Для плановой ротации используйте `--rotate-secret` и сразу обновите Gitea. Клиент также доступен администраторам в Django Admin в разделе OAuth2 Provider, но management command безопаснее для первичного получения секрета.
## Настройка Gitea
В `app.ini` включить закрытый режим экземпляра. Значение
`DISABLE_REGISTRATION = false` здесь намеренное: Gitea 1.23 применяет
`ALLOW_ONLY_EXTERNAL_REGISTRATION` только при разрешённом механизме
регистрации. В результате самостоятельной формы регистрации нет, но LDAP и
корпоративный OIDC могут создать связанную учётную запись:
```ini
[server]
LANDING_PAGE = login
[service]
DISABLE_REGISTRATION = false
ALLOW_ONLY_INTERNAL_REGISTRATION = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
SHOW_REGISTRATION_BUTTON = false
FORCE_PRIVATE = true
DEFAULT_PRIVATE = private
DEFAULT_PUSH_CREATE_PRIVATE = true
[openid]
ENABLE_OPENID_SIGNIN = false
ENABLE_OPENID_SIGNUP = false
```
`REQUIRE_SIGNIN_VIEW` закрывает анонимный просмотр веб-страниц и
неаутентифицированное чтение Git по HTTP даже для репозиториев, которые раньше
были помечены публичными. `FORCE_PRIVATE` и `DEFAULT_PRIVATE` не меняют старые
репозитории, но не позволяют создавать новые публичные. Перед публикацией
экземпляра наружу отдельно проверьте список существующих публичных
репозиториев и при необходимости переведите их в private.
Локальную форму входа пока оставить включённой: она нужна для существующих
локальных и LDAP-учёток, первичной привязки и аварийного администратора.
Для OAuth-клиентов в том же `app.ini`:
```ini
[oauth2_client]
ENABLE_AUTO_REGISTRATION = true
USERNAME = preferred_username
ACCOUNT_LINKING = login
UPDATE_AVATAR = true
OPENID_CONNECT_SCOPES = profile email groups
REGISTER_EMAIL_CONFIRM = false
```
Затем в `Site Administration → Identity & Access → Authentication Sources` добавить источник:
| Поле | Значение |
| --- | --- |
| Authentication Type | OAuth2 |
| Authentication Name | `corp` |
| OAuth2 Provider | OpenID Connect |
| Client ID | значение из `provision_oidc_client` |
| Client Secret | одноразово показанный secret |
| Auto Discovery URL | `https://corp.example.com/o/.well-known/openid-configuration` |
| Additional Scopes | `profile email groups` |
| Group Claim Name | `groups` |
До переключения пользователей оставить локальный вход Gitea включённым и сохранить отдельную аварийную локальную учётную запись администратора. `ACCOUNT_LINKING = login` не удаляет и не разлогинивает существующие локальные учётки: при первом конфликте username/email Gitea попросит один раз подтвердить привязку существующим локальным входом.
LDAP-пользователь после успешного входа в портал получает в OIDC claim
`preferred_username` свой `sAMAccountName`. Успешная LDAP-проверка также
считает корпоративный адрес подтверждённым; обычная регистрация по-прежнему
требует штатного подтверждения email.
Gitea 1.23.x не отправляет PKCE challenge для confidential OIDC-клиента, поэтому портал временно допускает Authorization Code без PKCE для совместимости. После обновления Gitea и проверки поддержки PKCE следует включить `PKCE_REQUIRED` в настройках провайдера.
## Claims и группы
`userinfo` и ID token могут содержать:
| Scope | Claims |
| --- | --- |
| `openid` | `sub` |
| `profile` | `name`, `given_name`, `family_name`, `preferred_username`, `nickname`, `picture`, `locale`, `zoneinfo`, `updated_at` |
| `email` | `email`, `email_verified` |
| `groups` | Django groups, `portal-users`, а также `portal-staff`/`portal-admins` для служебных ролей |
Не назначайте Gitea administrator по общей группе `portal-users`. Для автоматического администрирования заведите отдельную узкую Django group и явно сопоставьте её в Gitea.
## Проверка
До включения кнопки входа проверить с хоста Gitea:
```bash
curl -fsS https://corp.example.com/o/.well-known/openid-configuration
curl -fsS https://corp.example.com/o/.well-known/jwks.json
```
Discovery должен публиковать один и тот же issuer, HTTPS endpoints и `RS256`; JWKS содержит только публичную часть ключа. После этого пройти вход тестовой активной учёткой, проверить создание/привязку пользователя, имя, email и avatar, затем проверить отказ для деактивированной тестовой учётки.
## Сессия браузера и смена пользователя
Портал хранит отдельную HttpOnly Django-сессию для OIDC. После каждого
успешного входа `/api/auth/browser-session/` должен перепривязать её к текущему
пользователю, включая обычный вход без параметра `next`. При выходе endpoint
`/api/auth/browser-session/logout/` очищает эту сессию независимо от состояния
JWT: access/refresh token к этому моменту уже может истечь или быть удалён в
другой вкладке. Это не позволяет внешнему приложению повторно войти под
предыдущей учётной записью.
Сессии Gitea и портала остаются независимыми: выход только из Gitea не завершает
сеанс портала. Выход из портала очищает его OIDC-сессию, поэтому следующий вход
из Gitea потребует актуальной авторизации в портале.
## Отзыв доступа и ротация
- отключение сотрудника начинает блокировать access/refresh tokens при следующем обращении;
- OAuth grants и токены можно отозвать через Django Admin;
- client secret ротируется `provision_oidc_client --rotate-secret`;
- при RSA rotation укажите старые ключи через `OIDC_RSA_PRIVATE_KEY_PATHS_INACTIVE` на переходный период; не удаляйте их до истечения ранее выданных ID token;
- периодическая очистка выполняется командой `python manage.py cleartokens`.
Dynamic Client Registration и пользовательское создание OAuth applications публично не маршрутизируются. Новый клиент регистрирует только администратор.