Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2443ae460 | |||
| a48b865bc9 | |||
| c88f774749 | |||
| 00d86bb70c | |||
| a835903938 | |||
| 1fd9734fff |
@@ -38,19 +38,6 @@ TOTP_ENCRYPTION_KEY= # шифрование TOTP-секрет
|
|||||||
WEBHOOK_SECRET= # подпись вебхуков
|
WEBHOOK_SECRET= # подпись вебхуков
|
||||||
LIVEKIT_API_KEY= # ключ LiveKit (фаза 3)
|
LIVEKIT_API_KEY= # ключ LiveKit (фаза 3)
|
||||||
LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3)
|
LIVEKIT_API_SECRET= # секрет LiveKit (фаза 3)
|
||||||
# --- OAuth-провайдеры (Фаза 7): вход через внешние сервисы ---
|
|
||||||
# Пусто — провайдер выключен, ручка входа отвечает oauth.provider_not_configured.
|
|
||||||
# Адрес возврата для настроек приложения у провайдера:
|
|
||||||
# <домен>/api/v1/auth/oauth/<github|google|discord>/callback
|
|
||||||
OAUTH_GITHUB_CLIENT_ID=
|
|
||||||
OAUTH_GITHUB_CLIENT_SECRET=
|
|
||||||
OAUTH_GOOGLE_CLIENT_ID=
|
|
||||||
OAUTH_GOOGLE_CLIENT_SECRET=
|
|
||||||
OAUTH_DISCORD_CLIENT_ID=
|
|
||||||
OAUTH_DISCORD_CLIENT_SECRET=
|
|
||||||
# Необязательно: внешний адрес инстанса для redirect_uri (пусто — берётся из DOMAIN).
|
|
||||||
OAUTH_REDIRECT_BASE=
|
|
||||||
|
|
||||||
# --- Web Push (Фаза 7): уведомления в браузере и на телефоне ---
|
# --- Web Push (Фаза 7): уведомления в браузере и на телефоне ---
|
||||||
# Приватный VAPID-ключ (P-256) в PKCS#8 DER (base64, одной строкой). Пусто —
|
# Приватный VAPID-ключ (P-256) в PKCS#8 DER (base64, одной строкой). Пусто —
|
||||||
# Web Push выключен. Ключ генерирует установщик; вручную — `glchat vapid-keys`.
|
# Web Push выключен. Ключ генерирует установщик; вручную — `glchat vapid-keys`.
|
||||||
|
|||||||
@@ -55,10 +55,4 @@ ${DOMAIN} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${FILES_DOMAIN} {
|
${GLCHAT_FILES_BLOCK}
|
||||||
encode zstd gzip
|
|
||||||
import security_headers
|
|
||||||
handle {
|
|
||||||
reverse_proxy app:8080
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Сгенерировано deploy/install.sh — не редактируйте вручную.
|
# Сгенерировано deploy/install.sh — не редактируйте вручную.
|
||||||
# Режим: внешний reverse proxy (--external-proxy). Собственный Caddy не запускается:
|
# Режим: внешний reverse proxy (--external-proxy). Собственный Caddy не запускается:
|
||||||
# блок ниже нужно вставить в конфиг вашего прокси (например, Caddyfile) и перезагрузить его.
|
# блок ниже нужно вставить в конфиг вашего прокси (например, Caddyfile) и перезагрузить его.
|
||||||
# Приложение и LiveKit слушают только 127.0.0.1.
|
# Приложение и LiveKit слушают ${APP_BIND}:${APP_PORT} и ${LIVEKIT_BIND}:${LIVEKIT_WS_PORT}
|
||||||
|
# (по умолчанию 127.0.0.1 — в этом случае прокси и стек на одном хосте).
|
||||||
{
|
{
|
||||||
email ${ACME_EMAIL}
|
email ${ACME_EMAIL}
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,7 @@ ${DOMAIN} {
|
|||||||
# WebSocket Gateway (фаза 1): длинные соединения без таймаутов.
|
# WebSocket Gateway (фаза 1): длинные соединения без таймаутов.
|
||||||
@gateway path /gateway
|
@gateway path /gateway
|
||||||
handle @gateway {
|
handle @gateway {
|
||||||
reverse_proxy 127.0.0.1:${APP_PORT} {
|
reverse_proxy ${APP_BIND}:${APP_PORT} {
|
||||||
transport http {
|
transport http {
|
||||||
read_timeout 1h
|
read_timeout 1h
|
||||||
write_timeout 1h
|
write_timeout 1h
|
||||||
@@ -42,7 +43,7 @@ ${DOMAIN} {
|
|||||||
# LiveKit сигналинг (WebSocket).
|
# LiveKit сигналинг (WebSocket).
|
||||||
@rtc path /rtc*
|
@rtc path /rtc*
|
||||||
handle @rtc {
|
handle @rtc {
|
||||||
reverse_proxy 127.0.0.1:${LIVEKIT_WS_PORT} {
|
reverse_proxy ${LIVEKIT_BIND}:${LIVEKIT_WS_PORT} {
|
||||||
transport http {
|
transport http {
|
||||||
read_timeout 1h
|
read_timeout 1h
|
||||||
write_timeout 1h
|
write_timeout 1h
|
||||||
@@ -52,16 +53,9 @@ ${DOMAIN} {
|
|||||||
|
|
||||||
# API и статика клиента.
|
# API и статика клиента.
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy 127.0.0.1:${APP_PORT}
|
reverse_proxy ${APP_BIND}:${APP_PORT}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Пользовательский контент — отдельный origin (изоляция cookies/CSP).
|
${GLCHAT_FILES_BLOCK}
|
||||||
${FILES_DOMAIN} {
|
|
||||||
encode zstd gzip
|
|
||||||
import glchat_security_headers
|
|
||||||
handle {
|
|
||||||
reverse_proxy 127.0.0.1:${APP_PORT}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# --- glchat: конец блока --------------------------------------------------
|
# --- glchat: конец блока --------------------------------------------------
|
||||||
|
|||||||
+13
-9
@@ -76,15 +76,19 @@ services:
|
|||||||
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
LIVEKIT_KEYS: "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
||||||
ports:
|
ports:
|
||||||
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_WS_PORT}:7880"
|
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_WS_PORT}:7880"
|
||||||
# Медиапорты: тот же адрес привязки (для инстанса за NAT это адрес туннеля).
|
# Медиапорты: по умолчанию тот же адрес привязки (инстанс за NAT
|
||||||
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_TCP_PORT}:7881"
|
# публикует их на адресе туннеля). Когда прокси стоит на том же хосте,
|
||||||
- "${GLCHAT_LIVEKIT_BIND}:${LIVEKIT_UDP_PORT}:7882/udp"
|
# что и стек, адреса расходятся: сигналинг отдаётся наружу через прокси и
|
||||||
# TURN: UDP и TURN/TLS. На хосте без белого IP эти порты доступны только
|
# публикуется только для него, а медиапорты обязаны быть доступны
|
||||||
# из туннеля (внешний прокси/релей публикует их на своём адресе).
|
# клиентам из интернета — GLCHAT_LIVEKIT_MEDIA_BIND (по умолчанию равен
|
||||||
- "${GLCHAT_LIVEKIT_BIND}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp"
|
# адресу привязки, то есть поведение прежнее).
|
||||||
- "${GLCHAT_LIVEKIT_BIND}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp"
|
- "${GLCHAT_LIVEKIT_MEDIA_BIND}:${LIVEKIT_TCP_PORT}:7881"
|
||||||
# Диапазон релея TURN (медиа через relay).
|
- "${GLCHAT_LIVEKIT_MEDIA_BIND}:${LIVEKIT_UDP_PORT}:7882/udp"
|
||||||
- "${GLCHAT_LIVEKIT_BIND}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp"
|
# TURN: UDP, TURN/TLS и диапазон релея. Строки подставляет установщик
|
||||||
|
# (GLCHAT_TURN_PORTS): при --no-turn встроенного TURN в стеке нет, и порты
|
||||||
|
# не публикуются вовсе — иначе занятый на хосте порт (например, чужим
|
||||||
|
# coturn) ломает запуск стека, а наружу открывались бы лишние порты.
|
||||||
|
${GLCHAT_TURN_PORTS}
|
||||||
volumes:
|
volumes:
|
||||||
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
|
- ./livekit/livekit.yaml:/etc/livekit.yaml:ro
|
||||||
- ${GLCHAT_TURN_CERT_DIR}:/etc/certs:ro
|
- ${GLCHAT_TURN_CERT_DIR}:/etc/certs:ro
|
||||||
|
|||||||
+67
-42
@@ -78,6 +78,11 @@ APP_BIND_ADDR="${APP_BIND_ADDR:-127.0.0.1}"
|
|||||||
ALLOW_PORTS="${ALLOW_PORTS:-}"
|
ALLOW_PORTS="${ALLOW_PORTS:-}"
|
||||||
ALLOW_FROM="${ALLOW_FROM:-10.77.0.0/24}"
|
ALLOW_FROM="${ALLOW_FROM:-10.77.0.0/24}"
|
||||||
LIVEKIT_BIND_ADDR="${LIVEKIT_BIND_ADDR:-127.0.0.1}"
|
LIVEKIT_BIND_ADDR="${LIVEKIT_BIND_ADDR:-127.0.0.1}"
|
||||||
|
# Адрес публикации медиапортов LiveKit (TCP/UDP): по умолчанию совпадает с
|
||||||
|
# адресом привязки сигналинга. Разделять нужно, когда прокси стоит на том же
|
||||||
|
# хосте: сигналинг публикуется только для прокси, а медиа обязано быть
|
||||||
|
# доступно клиентам из интернета (AGENT.md 10.4, 10.13).
|
||||||
|
LIVEKIT_MEDIA_BIND_ADDR="${LIVEKIT_MEDIA_BIND_ADDR:-}"
|
||||||
TURN_CERT_REMOTE="${TURN_CERT_REMOTE:-}"
|
TURN_CERT_REMOTE="${TURN_CERT_REMOTE:-}"
|
||||||
TURN_CERT_REMOTE_CRT="${TURN_CERT_REMOTE_CRT:-}"
|
TURN_CERT_REMOTE_CRT="${TURN_CERT_REMOTE_CRT:-}"
|
||||||
TURN_CERT_REMOTE_KEY="${TURN_CERT_REMOTE_KEY:-}"
|
TURN_CERT_REMOTE_KEY="${TURN_CERT_REMOTE_KEY:-}"
|
||||||
@@ -189,7 +194,11 @@ install.sh — установка glchat (AGENT.md 10).
|
|||||||
--allow-port <порт/прото> разрешить порт только из подсети --allow-from
|
--allow-port <порт/прото> разрешить порт только из подсети --allow-from
|
||||||
(можно указывать несколько раз), например 8080/tcp
|
(можно указывать несколько раз), например 8080/tcp
|
||||||
--allow-from <cidr> источник для --allow-port (по умолчанию 10.77.0.0/24)
|
--allow-from <cidr> источник для --allow-port (по умолчанию 10.77.0.0/24)
|
||||||
--livekit-bind-addr <addr> адрес LiveKit (по умолчанию 127.0.0.1)
|
--livekit-bind-addr <addr> адрес публикации LiveKit WS (по умолчанию 127.0.0.1)
|
||||||
|
--livekit-media-bind-addr <addr> адрес публикации медиапортов LiveKit TCP/UDP
|
||||||
|
(по умолчанию — как у WS; отдельный адрес нужен,
|
||||||
|
когда прокси стоит на том же хосте: WS публикуется
|
||||||
|
только для прокси, медиа — для клиентов из интернета)
|
||||||
--turn-cert-remote <user@host> тянуть сертификат TURN/TLS с внешнего узла по SSH
|
--turn-cert-remote <user@host> тянуть сертификат TURN/TLS с внешнего узла по SSH
|
||||||
--turn-cert-remote-crt <путь> путь к сертификату на внешнем узле
|
--turn-cert-remote-crt <путь> путь к сертификату на внешнем узле
|
||||||
--turn-cert-remote-key <путь> путь к ключу на внешнем узле
|
--turn-cert-remote-key <путь> путь к ключу на внешнем узле
|
||||||
@@ -254,6 +263,7 @@ parse_args() {
|
|||||||
--allow-port) ALLOW_PORTS="${ALLOW_PORTS} ${2:?}"; shift 2 ;;
|
--allow-port) ALLOW_PORTS="${ALLOW_PORTS} ${2:?}"; shift 2 ;;
|
||||||
--allow-from) ALLOW_FROM="${2:?}"; shift 2 ;;
|
--allow-from) ALLOW_FROM="${2:?}"; shift 2 ;;
|
||||||
--livekit-bind-addr) LIVEKIT_BIND_ADDR="${2:?}"; shift 2 ;;
|
--livekit-bind-addr) LIVEKIT_BIND_ADDR="${2:?}"; shift 2 ;;
|
||||||
|
--livekit-media-bind-addr) LIVEKIT_MEDIA_BIND_ADDR="${2:?}"; shift 2 ;;
|
||||||
--turn-cert-remote) TURN_CERT_REMOTE="${2:?}"; shift 2 ;;
|
--turn-cert-remote) TURN_CERT_REMOTE="${2:?}"; shift 2 ;;
|
||||||
--turn-cert-remote-crt) TURN_CERT_REMOTE_CRT="${2:?}"; shift 2 ;;
|
--turn-cert-remote-crt) TURN_CERT_REMOTE_CRT="${2:?}"; shift 2 ;;
|
||||||
--turn-cert-remote-key) TURN_CERT_REMOTE_KEY="${2:?}"; shift 2 ;;
|
--turn-cert-remote-key) TURN_CERT_REMOTE_KEY="${2:?}"; shift 2 ;;
|
||||||
@@ -430,11 +440,49 @@ resolve_derived_values() {
|
|||||||
fi
|
fi
|
||||||
GLCHAT_APP_BIND="${APP_BIND_ADDR}"
|
GLCHAT_APP_BIND="${APP_BIND_ADDR}"
|
||||||
GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}"
|
GLCHAT_LIVEKIT_BIND="${LIVEKIT_BIND_ADDR}"
|
||||||
|
# Пустое значение — прежнее поведение: медиа публикуется там же, где сигналинг.
|
||||||
|
[ -n "$LIVEKIT_MEDIA_BIND_ADDR" ] || LIVEKIT_MEDIA_BIND_ADDR="$LIVEKIT_BIND_ADDR"
|
||||||
|
GLCHAT_LIVEKIT_MEDIA_BIND="${LIVEKIT_MEDIA_BIND_ADDR}"
|
||||||
|
# Порты TURN публикуются только при включённом TURN. С --no-turn встроенного
|
||||||
|
# TURN в стеке нет: публикация занимала бы порты хоста (3478/udp и диапазон
|
||||||
|
# релея) и ломала запуск, если их держит чужой сервис — например, отдельный
|
||||||
|
# coturn на том же VPS (найдено при установке на VPS 27.09.2026), а наружу
|
||||||
|
# открывались бы порты, за которыми ничего не слушает.
|
||||||
|
if [ "$TURN_ENABLED" = "true" ]; then
|
||||||
|
GLCHAT_TURN_PORTS=" # TURN: UDP, TURN/TLS и диапазон релея (медиа через relay).
|
||||||
|
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_UDP_PORT}:${TURN_UDP_PORT}/udp\"
|
||||||
|
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_TLS_PORT}:${TURN_TLS_PORT}/tcp\"
|
||||||
|
- \"${LIVEKIT_MEDIA_BIND_ADDR}:${TURN_RELAY_START}-${TURN_RELAY_END}:${TURN_RELAY_START}-${TURN_RELAY_END}/udp\""
|
||||||
|
else
|
||||||
|
GLCHAT_TURN_PORTS=" # TURN выключен (--no-turn): порты 3478/5349 и диапазон релея не публикуются"
|
||||||
|
fi
|
||||||
|
# Блок пользовательского контента (files.<домен>) в конфиг прокси попадает
|
||||||
|
# только когда для него есть отдельный домен. Иначе в Caddyfile оказывались
|
||||||
|
# два блока с одним и тем же адресом, и Caddy отказывался стартовать
|
||||||
|
# («ambiguous site definition») — найдено при установке с --no-files-subdomain
|
||||||
|
# на VPS 27.09.2026. Адрес апстрима зависит от режима: во встроенном прокси
|
||||||
|
# это имя контейнера, во внешнем — адрес привязки приложения.
|
||||||
|
if [ "$PROXY_MODE" = "embedded" ]; then
|
||||||
|
files_upstream="app:8080"
|
||||||
|
else
|
||||||
|
files_upstream="${APP_BIND_ADDR}:${APP_PORT}"
|
||||||
|
fi
|
||||||
|
if [ "$FILES_DOMAIN_RESOLVED" != "$DOMAIN" ]; then
|
||||||
|
GLCHAT_FILES_BLOCK="$(printf '# Пользовательский контент — отдельный origin (изоляция cookies/CSP).\n%s {\n\tencode zstd gzip\n\timport glchat_security_headers\n\thandle {\n\t\treverse_proxy %s\n\t}\n}' \
|
||||||
|
"${FILES_DOMAIN_RESOLVED}" "${files_upstream}")"
|
||||||
|
if [ "$PROXY_MODE" = "embedded" ]; then
|
||||||
|
# Во встроенном Caddy сниппет заголовков называется иначе.
|
||||||
|
GLCHAT_FILES_BLOCK="${GLCHAT_FILES_BLOCK/import glchat_security_headers/import security_headers}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
GLCHAT_FILES_BLOCK="# Отдельного домена для пользовательского контента нет
|
||||||
|
# (--no-files-subdomain): файлы отдаются с основного домена по /files/*."
|
||||||
|
fi
|
||||||
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
|
export FILES_DOMAIN_RESOLVED CADDY_PROFILE LIVEKIT_PUBLISH TURN_EXTERNAL_TLS \
|
||||||
livekit_extra_codecs \
|
livekit_extra_codecs GLCHAT_TURN_PORTS GLCHAT_FILES_BLOCK \
|
||||||
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
|
PROFILE_LIVEKIT_DISABLE_SIMULCAST PROFILE_LIVEKIT_USE_EXTERNAL_IP \
|
||||||
GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
|
GLCHAT_DATA_DIR GLCHAT_UPDATES_DIR GLCHAT_CADDY_PROFILE GLCHAT_TURN_CERT_DIR \
|
||||||
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND TLS_ENABLED \
|
GLCHAT_APP_BIND GLCHAT_LIVEKIT_BIND GLCHAT_LIVEKIT_MEDIA_BIND TLS_ENABLED \
|
||||||
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
|
TURN_DOMAIN_RESOLVED TURN_CERT_DIR TURN_RELAY_HOST_RESOLVED TURN_RELAY_TLS_HOST \
|
||||||
TURN_RELAY_USER TURN_RELAY_PASSWORD
|
TURN_RELAY_USER TURN_RELAY_PASSWORD
|
||||||
}
|
}
|
||||||
@@ -521,6 +569,7 @@ print_plan() {
|
|||||||
if [ "$PROXY_MODE" = "external" ]; then
|
if [ "$PROXY_MODE" = "external" ]; then
|
||||||
printf ' %-18s %-10s %s\n' "app (API+web)" "${APP_BIND_ADDR}:${APP_PORT}" "внутренний, наружу не публикуется"
|
printf ' %-18s %-10s %s\n' "app (API+web)" "${APP_BIND_ADDR}:${APP_PORT}" "внутренний, наружу не публикуется"
|
||||||
printf ' %-18s %-10s %s\n' "LiveKit WS" "${LIVEKIT_BIND_ADDR}:${LIVEKIT_WS_PORT}" "внутренний"
|
printf ' %-18s %-10s %s\n' "LiveKit WS" "${LIVEKIT_BIND_ADDR}:${LIVEKIT_WS_PORT}" "внутренний"
|
||||||
|
printf ' %-18s %-10s %s\n' "LiveKit медиа" "${LIVEKIT_MEDIA_BIND_ADDR}:${LIVEKIT_TCP_PORT}/${LIVEKIT_UDP_PORT}" "TCP+UDP для клиентов"
|
||||||
printf ' %-18s %-10s %s\n' "80/443" "-" "обслуживает внешний прокси (не меняем)"
|
printf ' %-18s %-10s %s\n' "80/443" "-" "обслуживает внешний прокси (не меняем)"
|
||||||
else
|
else
|
||||||
printf ' %-18s %-10s %s\n' "HTTP" "${HTTP_PORT}/tcp" "ACME + редирект на HTTPS"
|
printf ' %-18s %-10s %s\n' "HTTP" "${HTTP_PORT}/tcp" "ACME + редирект на HTTPS"
|
||||||
@@ -669,9 +718,10 @@ generate_secrets() {
|
|||||||
# расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index,
|
# расшифровывает email/PII и TOTP-секреты, SESSION_PEPPER — blind index,
|
||||||
# LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие
|
# LIVEKIT_API_SECRET подписывает токены. Их смена делает существующие
|
||||||
# данные нечитаемыми, поэтому она возможна только явным --rotate-secrets.
|
# данные нечитаемыми, поэтому она возможна только явным --rotate-secrets.
|
||||||
# oauth_value отдаёт значение OAuth-настройки: сначала из окружения
|
# existing_value отдаёт значение необязательной настройки: сначала из
|
||||||
# (оператор задал при запуске), затем из существующего .env.
|
# окружения (оператор задал при запуске), затем из существующего .env —
|
||||||
oauth_value() {
|
# чтобы `--reconfigure` её не потерял.
|
||||||
|
existing_value() {
|
||||||
local key="$1"
|
local key="$1"
|
||||||
local value="${!key:-}"
|
local value="${!key:-}"
|
||||||
if [ -z "$value" ] && [ "$env_existing" = "1" ]; then
|
if [ -z "$value" ] && [ "$env_existing" = "1" ]; then
|
||||||
@@ -725,7 +775,7 @@ generate_secrets() {
|
|||||||
fi
|
fi
|
||||||
# Контакт администратора для push-сервисов: значение можно задать заранее,
|
# Контакт администратора для push-сервисов: значение можно задать заранее,
|
||||||
# иначе берём email администратора или адрес домена.
|
# иначе берём email администратора или адрес домена.
|
||||||
VAPID_SUBJECT="$(oauth_value VAPID_SUBJECT)"
|
VAPID_SUBJECT="$(existing_value VAPID_SUBJECT)"
|
||||||
if [ -z "$VAPID_SUBJECT" ]; then
|
if [ -z "$VAPID_SUBJECT" ]; then
|
||||||
if [ -n "${ADMIN_EMAIL:-}" ]; then
|
if [ -n "${ADMIN_EMAIL:-}" ]; then
|
||||||
VAPID_SUBJECT="mailto:${ADMIN_EMAIL}"
|
VAPID_SUBJECT="mailto:${ADMIN_EMAIL}"
|
||||||
@@ -734,17 +784,6 @@ generate_secrets() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# OAuth (Фаза 7) не генерируется: клиентские id/секреты выдаёт провайдер.
|
|
||||||
# Значение берём из окружения, иначе сохраняем прежнее из .env — иначе
|
|
||||||
# `--reconfigure` молча выключил бы вход через провайдеров.
|
|
||||||
OAUTH_GITHUB_CLIENT_ID="$(oauth_value OAUTH_GITHUB_CLIENT_ID)"
|
|
||||||
OAUTH_GITHUB_CLIENT_SECRET="$(oauth_value OAUTH_GITHUB_CLIENT_SECRET)"
|
|
||||||
OAUTH_GOOGLE_CLIENT_ID="$(oauth_value OAUTH_GOOGLE_CLIENT_ID)"
|
|
||||||
OAUTH_GOOGLE_CLIENT_SECRET="$(oauth_value OAUTH_GOOGLE_CLIENT_SECRET)"
|
|
||||||
OAUTH_DISCORD_CLIENT_ID="$(oauth_value OAUTH_DISCORD_CLIENT_ID)"
|
|
||||||
OAUTH_DISCORD_CLIENT_SECRET="$(oauth_value OAUTH_DISCORD_CLIENT_SECRET)"
|
|
||||||
OAUTH_REDIRECT_BASE="$(oauth_value OAUTH_REDIRECT_BASE)"
|
|
||||||
|
|
||||||
if [ "$DRY_RUN" = "1" ]; then
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)"
|
log_file_only "DRY-RUN: секреты сгенерированы в памяти (в файл не пишутся)"
|
||||||
ok "секреты подготовлены (dry-run)"
|
ok "секреты подготовлены (dry-run)"
|
||||||
@@ -851,6 +890,9 @@ GLCHAT_CADDY_PROFILE=@CADDY_PROFILE@
|
|||||||
GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@
|
GLCHAT_TURN_CERT_DIR=@GLCHAT_TURN_CERT_DIR@
|
||||||
GLCHAT_APP_BIND=@GLCHAT_APP_BIND@
|
GLCHAT_APP_BIND=@GLCHAT_APP_BIND@
|
||||||
GLCHAT_LIVEKIT_BIND=@GLCHAT_LIVEKIT_BIND@
|
GLCHAT_LIVEKIT_BIND=@GLCHAT_LIVEKIT_BIND@
|
||||||
|
# Адрес публикации медиапортов LiveKit: обычно совпадает с адресом WS, но при
|
||||||
|
# прокси на том же хосте отличается (WS — только для прокси, медиа — в интернет).
|
||||||
|
GLCHAT_LIVEKIT_MEDIA_BIND=@GLCHAT_LIVEKIT_MEDIA_BIND@
|
||||||
|
|
||||||
# --- порты ---
|
# --- порты ---
|
||||||
APP_PORT=@APP_PORT@
|
APP_PORT=@APP_PORT@
|
||||||
@@ -916,17 +958,6 @@ BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@
|
|||||||
BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@
|
BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@
|
||||||
AGE_RECIPIENT=@AGE_RECIPIENT@
|
AGE_RECIPIENT=@AGE_RECIPIENT@
|
||||||
|
|
||||||
# --- OAuth-провайдеры (Фаза 7) ---
|
|
||||||
# Пусто — провайдер выключен. Адрес возврата: <домен>/api/v1/auth/oauth/<id>/callback.
|
|
||||||
OAUTH_GITHUB_CLIENT_ID=@OAUTH_GITHUB_CLIENT_ID@
|
|
||||||
OAUTH_GITHUB_CLIENT_SECRET=@OAUTH_GITHUB_CLIENT_SECRET@
|
|
||||||
OAUTH_GOOGLE_CLIENT_ID=@OAUTH_GOOGLE_CLIENT_ID@
|
|
||||||
OAUTH_GOOGLE_CLIENT_SECRET=@OAUTH_GOOGLE_CLIENT_SECRET@
|
|
||||||
OAUTH_DISCORD_CLIENT_ID=@OAUTH_DISCORD_CLIENT_ID@
|
|
||||||
OAUTH_DISCORD_CLIENT_SECRET=@OAUTH_DISCORD_CLIENT_SECRET@
|
|
||||||
# Необязательно: внешний адрес инстанса для redirect_uri (пусто — из домена).
|
|
||||||
OAUTH_REDIRECT_BASE=@OAUTH_REDIRECT_BASE@
|
|
||||||
|
|
||||||
# --- Web Push (Фаза 7) ---
|
# --- Web Push (Фаза 7) ---
|
||||||
# Приватный VAPID-ключ (PKCS#8 DER в base64). Пусто — Web Push выключен.
|
# Приватный VAPID-ключ (PKCS#8 DER в base64). Пусто — Web Push выключен.
|
||||||
VAPID_PRIVATE_KEY=@VAPID_PRIVATE_KEY@
|
VAPID_PRIVATE_KEY=@VAPID_PRIVATE_KEY@
|
||||||
@@ -974,6 +1005,7 @@ CADDY_PROFILE=${CADDY_PROFILE}
|
|||||||
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
|
GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}
|
||||||
GLCHAT_APP_BIND=${APP_BIND_ADDR}
|
GLCHAT_APP_BIND=${APP_BIND_ADDR}
|
||||||
GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}
|
GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}
|
||||||
|
GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}
|
||||||
APP_PORT=${APP_PORT}
|
APP_PORT=${APP_PORT}
|
||||||
LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}
|
LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}
|
||||||
LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}
|
LIVEKIT_TCP_PORT=${LIVEKIT_TCP_PORT}
|
||||||
@@ -1002,17 +1034,6 @@ LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
|
|||||||
LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
||||||
LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}
|
LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}
|
||||||
|
|
||||||
# OAuth-провайдеры (Фаза 7): пусто — провайдер выключен. Кавычки добавляет
|
|
||||||
# установщик только для значений со спецсимволами (docker compose разбирает
|
|
||||||
# .env своим парсером и пустые кавычки ему не нравятся).
|
|
||||||
OAUTH_GITHUB_CLIENT_ID=$(env_quote "$OAUTH_GITHUB_CLIENT_ID")
|
|
||||||
OAUTH_GITHUB_CLIENT_SECRET=$(env_quote "$OAUTH_GITHUB_CLIENT_SECRET")
|
|
||||||
OAUTH_GOOGLE_CLIENT_ID=$(env_quote "$OAUTH_GOOGLE_CLIENT_ID")
|
|
||||||
OAUTH_GOOGLE_CLIENT_SECRET=$(env_quote "$OAUTH_GOOGLE_CLIENT_SECRET")
|
|
||||||
OAUTH_DISCORD_CLIENT_ID=$(env_quote "$OAUTH_DISCORD_CLIENT_ID")
|
|
||||||
OAUTH_DISCORD_CLIENT_SECRET=$(env_quote "$OAUTH_DISCORD_CLIENT_SECRET")
|
|
||||||
OAUTH_REDIRECT_BASE=$(env_quote "$OAUTH_REDIRECT_BASE")
|
|
||||||
|
|
||||||
# Web Push (Фаза 7): приватный VAPID-ключ переиспользуется при переустановке.
|
# Web Push (Фаза 7): приватный VAPID-ключ переиспользуется при переустановке.
|
||||||
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||||
VAPID_SUBJECT=$(env_quote "$VAPID_SUBJECT")
|
VAPID_SUBJECT=$(env_quote "$VAPID_SUBJECT")
|
||||||
@@ -1258,6 +1279,8 @@ render_all_templates() {
|
|||||||
"GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \
|
"GLCHAT_CADDY_PROFILE=${CADDY_PROFILE}" \
|
||||||
"GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}" \
|
"GLCHAT_TURN_CERT_DIR=${TURN_CERT_DIR}" \
|
||||||
"GLCHAT_APP_BIND=${APP_BIND_ADDR}" "GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \
|
"GLCHAT_APP_BIND=${APP_BIND_ADDR}" "GLCHAT_LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \
|
||||||
|
"GLCHAT_LIVEKIT_MEDIA_BIND=${LIVEKIT_MEDIA_BIND_ADDR}" \
|
||||||
|
"GLCHAT_TURN_PORTS=${GLCHAT_TURN_PORTS}" \
|
||||||
"TURN_UDP_PORT=${TURN_UDP_PORT}" "TURN_TLS_PORT=${TURN_TLS_PORT}" \
|
"TURN_UDP_PORT=${TURN_UDP_PORT}" "TURN_TLS_PORT=${TURN_TLS_PORT}" \
|
||||||
"TURN_RELAY_START=${TURN_RELAY_START}" "TURN_RELAY_END=${TURN_RELAY_END}" \
|
"TURN_RELAY_START=${TURN_RELAY_START}" "TURN_RELAY_END=${TURN_RELAY_END}" \
|
||||||
"LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \
|
"LIVEKIT_API_KEY=${LIVEKIT_API_KEY}" "LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}" \
|
||||||
@@ -1266,11 +1289,13 @@ render_all_templates() {
|
|||||||
if [ "$PROXY_MODE" = "external" ]; then
|
if [ "$PROXY_MODE" = "external" ]; then
|
||||||
render_template "${DEPLOY_DIR}/caddy/external-proxy.conf.tpl" "${TARGET_DIR}/external-proxy.conf" \
|
render_template "${DEPLOY_DIR}/caddy/external-proxy.conf.tpl" "${TARGET_DIR}/external-proxy.conf" \
|
||||||
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
|
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
|
||||||
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}"
|
"APP_PORT=${APP_PORT}" "LIVEKIT_WS_PORT=${LIVEKIT_WS_PORT}" \
|
||||||
|
"APP_BIND=${APP_BIND_ADDR}" "LIVEKIT_BIND=${LIVEKIT_BIND_ADDR}" \
|
||||||
|
"GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}"
|
||||||
else
|
else
|
||||||
render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \
|
render_template "${DEPLOY_DIR}/caddy/Caddyfile.tpl" "${TARGET_DIR}/caddy/Caddyfile" \
|
||||||
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
|
"DOMAIN=${DOMAIN}" "FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}" "ACME_EMAIL=${ACME_EMAIL}" \
|
||||||
"PROXY_MODE=${PROXY_MODE}"
|
"PROXY_MODE=${PROXY_MODE}" "GLCHAT_FILES_BLOCK=${GLCHAT_FILES_BLOCK}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \
|
render_template "${DEPLOY_DIR}/livekit/livekit.yaml.tpl" "${TARGET_DIR}/livekit/livekit.yaml" \
|
||||||
|
|||||||
+36
-7
@@ -357,14 +357,21 @@ python3 scripts/desktop-perf.py --runs 3 # 3 холодных старта +
|
|||||||
- **Аватары в уведомлениях** (thumb/буква) и группировка — сейчас иконка приложения.
|
- **Аватары в уведомлениях** (thumb/буква) и группировка — сейчас иконка приложения.
|
||||||
- **Захват экрана и микрофон**: разрешения macOS выдаёт webview, отдельных
|
- **Захват экрана и микрофон**: разрешения macOS выдаёт webview, отдельных
|
||||||
экранов-инструкций у обёртки нет.
|
экранов-инструкций у обёртки нет.
|
||||||
- **Developer ID и нотаризация** macOS, EV/Trusted Signing для Windows — только
|
- **Подпись и нотаризация macOS — сознательно не делаются** (решение владельца
|
||||||
в CI с платными сертификатами (ниже — точные команды).
|
26.09.2026): приложение распространяется неподписанным `.dmg`, карантин
|
||||||
|
снимает конечный пользователь (инструкция ниже). EV/Trusted Signing для
|
||||||
|
Windows — тем же решением не делаем.
|
||||||
- **`.dmg`**: собирается из обычного терминала (Finder/AppleScript для раскладки
|
- **`.dmg`**: собирается из обычного терминала (Finder/AppleScript для раскладки
|
||||||
окна); в песочнице агента `hdiutil create` и AppleScript запрещены, но образ
|
окна); в песочнице агента `hdiutil create` и AppleScript запрещены, но образ
|
||||||
**без оформления окна** собирается флагом `--sandbox-safe` к `bundle_dmg.sh`
|
**без оформления окна** собирается флагом `--sandbox-safe` к `bundle_dmg.sh`
|
||||||
(проверено 2026-09-26, см. ниже).
|
(проверено 2026-09-26, см. ниже).
|
||||||
- **Windows/Linux-бандлы**: конфигурация кросс-платформенная, но собирались и
|
- **Windows/Linux-бандлы**: конфигурация кросс-платформенная, но собирались и
|
||||||
проверялись только macOS-arm64.
|
проверялись только macOS-arm64. Сборки под другие ОС **отложены**: репозиторий
|
||||||
|
живёт в Gitea на мини-ПК, а `windows-latest`/`ubuntu-24.04`-раннеры есть у
|
||||||
|
GitHub Actions, поэтому владелец зеркалит проект в GitHub позже и запускает
|
||||||
|
сборку там. Локальная часть готова и проверена: `make desktop-check`,
|
||||||
|
`scripts/desktop-release.sh --artifact … --target windows|linux` (манифест
|
||||||
|
обновления для каждой платформы складывается в общий файл версии).
|
||||||
|
|
||||||
## Что осталось оператору (точные команды)
|
## Что осталось оператору (точные команды)
|
||||||
|
|
||||||
@@ -404,10 +411,33 @@ hdiutil verify glchat_0.1.0_aarch64.dmg
|
|||||||
Артефакты без `.dmg` (для автообновления) собираются где угодно:
|
Артефакты без `.dmg` (для автообновления) собираются где угодно:
|
||||||
`make desktop-build-app`.
|
`make desktop-build-app`.
|
||||||
|
|
||||||
### macOS: подпись Developer ID и нотаризация
|
### macOS: подпись Developer ID и нотаризация — сознательно не делаем
|
||||||
|
|
||||||
Нужны сертификат Developer ID Application в связке ключей и пароль приложения
|
**Решение владельца (2026-09-26): подпись Developer ID и нотаризацию не
|
||||||
(appleid.apple.com → App-Specific Passwords):
|
делаем** — это платный аккаунт Apple Developer, а приложение распространяется
|
||||||
|
внутри своего круга. Распространяется **неподписанный** `.dmg`; действия при
|
||||||
|
первом запуске выполняет конечный пользователь (ниже). Подпись артефакта
|
||||||
|
обновления (ed25519, ключ инстанса) от этого не зависит и проверяется всегда —
|
||||||
|
её отсутствие или порча артефакта установку отменяют.
|
||||||
|
|
||||||
|
Что делает конечный пользователь (короткая инструкция, её же можно передать
|
||||||
|
вместе с файлом):
|
||||||
|
|
||||||
|
1. Открыть `.dmg` и перетащить `glchat` в `Applications`.
|
||||||
|
2. Первый запуск — **правый клик по приложению → «Открыть» → «Открыть»** в
|
||||||
|
диалоге «разработчик не может быть проверен». Дальше приложение запускается
|
||||||
|
обычным двойным кликом.
|
||||||
|
3. Если macOS не показывает кнопку «Открыть» (карантин): в терминале
|
||||||
|
`xattr -dr com.apple.quarantine /Applications/glchat.app`, либо «Системные
|
||||||
|
настройки → Конфиденциальность и безопасность → Всё равно открыть».
|
||||||
|
|
||||||
|
Текущий установщик релиза 0.1.1:
|
||||||
|
`desktop/src-tauri/target/release/bundle/dmg/glchat_0.1.1_aarch64.dmg`
|
||||||
|
(3,1 МБ, sha256 `7f93609164dc5fdfe6255b88eaf909c02ba819a7e4fb79951cc50b142da825c0`).
|
||||||
|
Собирается `make desktop-build` (или `bundle_dmg.sh --sandbox-safe` в headless).
|
||||||
|
|
||||||
|
Если однажды понадобится подпись, команды остаются прежними (переменные читает
|
||||||
|
сам Tauri, в репозиторий они не попадают):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
security find-identity -v -p codesigning # проверить наличие сертификата
|
security find-identity -v -p codesigning # проверить наличие сертификата
|
||||||
@@ -420,7 +450,6 @@ xcrun stapler validate desktop/src-tauri/target/release/bundle/macos/glchat.app
|
|||||||
spctl --assess --type execute --verbose=4 desktop/src-tauri/target/release/bundle/macos/glchat.app
|
spctl --assess --type execute --verbose=4 desktop/src-tauri/target/release/bundle/macos/glchat.app
|
||||||
```
|
```
|
||||||
|
|
||||||
Переменные читает сам Tauri (`APPLE_*`), в репозиторий они не попадают.
|
|
||||||
Проверка «не подписано»: `codesign -dv --verbose=4 <app>` покажет `Signature=adhoc`.
|
Проверка «не подписано»: `codesign -dv --verbose=4 <app>` покажет `Signature=adhoc`.
|
||||||
|
|
||||||
### Windows (x64): MSI/NSIS + подпись
|
### Windows (x64): MSI/NSIS + подпись
|
||||||
|
|||||||
Generated
+1
-1
@@ -1474,7 +1474,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glchat-desktop"
|
name = "glchat-desktop"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "glchat-desktop"
|
name = "glchat-desktop"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
description = "glchat — desktop-обёртка вокруг веб-клиента (Tauri 2)"
|
description = "glchat — desktop-обёртка вокруг веб-клиента (Tauri 2)"
|
||||||
authors = ["glchat"]
|
authors = ["glchat"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "glchat",
|
"productName": "glchat",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"identifier": "su.mhspx.glchat",
|
"identifier": "su.mhspx.glchat",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "./ui"
|
"frontendDist": "./ui"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module glchat
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/coder/websocket v1.8.15
|
github.com/coder/websocket v1.8.15
|
||||||
github.com/danielgtaylor/huma/v2 v2.39.1
|
github.com/danielgtaylor/huma/v2 v2.39.1
|
||||||
github.com/fxamacker/cbor/v2 v2.9.4
|
github.com/fxamacker/cbor/v2 v2.9.4
|
||||||
@@ -12,11 +13,10 @@ require (
|
|||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/pressly/goose/v3 v3.28.0
|
github.com/pressly/goose/v3 v3.28.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/oauth2 v0.37.0
|
golang.org/x/net v0.59.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
|
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/go-webauthn/x v0.3.1 // indirect
|
github.com/go-webauthn/x v0.3.1 // indirect
|
||||||
@@ -29,7 +29,6 @@ require (
|
|||||||
github.com/tinylib/msgp v1.6.4 // indirect
|
github.com/tinylib/msgp v1.6.4 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/net v0.59.0 // indirect
|
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,8 +86,6 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
|||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
|
||||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|||||||
@@ -1,620 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
|
|
||||||
"glchat/internal/config"
|
|
||||||
"glchat/internal/crypto"
|
|
||||||
"glchat/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OAuth-вход (AGENT.md 7.1): провайдеры включаются переменными окружения,
|
|
||||||
// привязка внешнего аккаунта идёт по подтверждённому email через blind index.
|
|
||||||
// Токены провайдера не сохраняются: они нужны только на время обмена кода.
|
|
||||||
const (
|
|
||||||
// oauthStateTTL — срок жизни подписанного state (защита от CSRF).
|
|
||||||
oauthStateTTL = 10 * time.Minute
|
|
||||||
// oauthHTTPTimeout ограничивает запросы к провайдеру.
|
|
||||||
oauthHTTPTimeout = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrOAuthNotConfigured — провайдер выключен (нет client id/secret).
|
|
||||||
ErrOAuthNotConfigured = errors.New("oauth.provider_not_configured")
|
|
||||||
// ErrOAuthUnknownProvider — неизвестное имя провайдера.
|
|
||||||
ErrOAuthUnknownProvider = errors.New("oauth.provider_unknown")
|
|
||||||
// ErrOAuthState — state не прошёл проверку подписи или истёк.
|
|
||||||
ErrOAuthState = errors.New("oauth.state_invalid")
|
|
||||||
// ErrOAuthExchange — обмен кода или запрос профиля не удался.
|
|
||||||
ErrOAuthExchange = errors.New("oauth.exchange_failed")
|
|
||||||
// ErrOAuthEmailUnverified — провайдер не подтвердил email.
|
|
||||||
ErrOAuthEmailUnverified = errors.New("oauth.email_unverified")
|
|
||||||
// ErrOAuthEmailMissing — провайдер не отдал email.
|
|
||||||
ErrOAuthEmailMissing = errors.New("oauth.email_missing")
|
|
||||||
)
|
|
||||||
|
|
||||||
// oauthProvider — описание провайдера: всё, что нужно для входа.
|
|
||||||
type oauthProvider struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
Endpoint oauth2.Endpoint
|
|
||||||
Scopes []string
|
|
||||||
// FetchProfile получает профиль по токену: email, признак подтверждения,
|
|
||||||
// внешний идентификатор и логин.
|
|
||||||
FetchProfile func(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthProfile — нормализованный профиль внешнего аккаунта.
|
|
||||||
type oauthProfile struct {
|
|
||||||
Subject string
|
|
||||||
Email string
|
|
||||||
EmailVerified bool
|
|
||||||
Login string
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthProviderInfo — описание включённого провайдера для клиента.
|
|
||||||
type OAuthProviderInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthProviderSet — каталог провайдеров. Переменная, а не функция, чтобы
|
|
||||||
// тесты подменяли адреса провайдеров на httptest-сервер (в бою — значения по
|
|
||||||
// умолчанию, см. defaultOAuthProviders).
|
|
||||||
var oauthProviderSet = defaultOAuthProviders()
|
|
||||||
|
|
||||||
// oauthProviders возвращает каталог поддерживаемых провайдеров (AGENT.md 7.1:
|
|
||||||
// Google, GitHub, Discord). Новый провайдер добавляется одной записью.
|
|
||||||
func oauthProviders() map[string]oauthProvider { return oauthProviderSet }
|
|
||||||
|
|
||||||
//nolint:gosec // G101: здесь публичные адреса провайдеров, а не учётные данные
|
|
||||||
func defaultOAuthProviders() map[string]oauthProvider {
|
|
||||||
return map[string]oauthProvider{
|
|
||||||
"github": {
|
|
||||||
ID: "github",
|
|
||||||
Name: "GitHub",
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: "https://github.com/login/oauth/authorize",
|
|
||||||
TokenURL: "https://github.com/login/oauth/access_token",
|
|
||||||
},
|
|
||||||
Scopes: []string{"read:user", "user:email"},
|
|
||||||
FetchProfile: fetchGitHubProfile,
|
|
||||||
},
|
|
||||||
"google": {
|
|
||||||
ID: "google",
|
|
||||||
Name: "Google",
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
||||||
TokenURL: "https://oauth2.googleapis.com/token",
|
|
||||||
},
|
|
||||||
Scopes: []string{"openid", "email", "profile"},
|
|
||||||
FetchProfile: fetchGoogleProfile,
|
|
||||||
},
|
|
||||||
"discord": {
|
|
||||||
ID: "discord",
|
|
||||||
Name: "Discord",
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: "https://discord.com/oauth2/authorize",
|
|
||||||
TokenURL: "https://discord.com/api/oauth2/token",
|
|
||||||
},
|
|
||||||
Scopes: []string{"identify", "email"},
|
|
||||||
FetchProfile: fetchDiscordProfile,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthConfigs — включённые провайдеры: id → параметры клиента.
|
|
||||||
func (s *Service) oauthConfigs() map[string]oauthProvider {
|
|
||||||
providers := oauthProviders()
|
|
||||||
enabled := make(map[string]oauthProvider, len(providers))
|
|
||||||
for id, provider := range providers {
|
|
||||||
credentials, ok := s.oauth[id]
|
|
||||||
if !ok || credentials.clientID == "" || credentials.clientSecret == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
provider.Scopes = append([]string{}, provider.Scopes...)
|
|
||||||
enabled[id] = provider
|
|
||||||
if id == "google" {
|
|
||||||
// Google требует offline-доступ только при необходимости refresh;
|
|
||||||
// нам достаточно одноразового обмена кода.
|
|
||||||
enabled[id] = provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthProviders перечисляет включённые провайдеры для клиента.
|
|
||||||
func (s *Service) OAuthProviders() []OAuthProviderInfo {
|
|
||||||
configs := s.oauthConfigs()
|
|
||||||
// Порядок стабилен, чтобы клиент не переставлял кнопки между запросами.
|
|
||||||
order := []string{"github", "google", "discord"}
|
|
||||||
infos := make([]OAuthProviderInfo, 0, len(configs))
|
|
||||||
for _, id := range order {
|
|
||||||
if provider, ok := configs[id]; ok {
|
|
||||||
infos = append(infos, OAuthProviderInfo{ID: provider.ID, Name: provider.Name})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return infos
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthEnabled сообщает, настроен ли хотя бы один провайдер.
|
|
||||||
func (s *Service) OAuthEnabled() bool { return len(s.oauthConfigs()) > 0 }
|
|
||||||
|
|
||||||
// oauthClientCredentials — client id/secret конкретного провайдера.
|
|
||||||
type oauthClientCredentials struct {
|
|
||||||
clientID string
|
|
||||||
clientSecret string
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthAuthorizeURL строит ссылку на провайдера с подписанным state.
|
|
||||||
// redirect — куда вернуть пользователя внутри клиента после входа.
|
|
||||||
func (s *Service) OAuthAuthorizeURL(providerID, redirect string) (string, error) {
|
|
||||||
configs := s.oauthConfigs()
|
|
||||||
provider, ok := configs[strings.ToLower(strings.TrimSpace(providerID))]
|
|
||||||
if !ok {
|
|
||||||
if _, known := oauthProviders()[strings.ToLower(strings.TrimSpace(providerID))]; known {
|
|
||||||
return "", ErrOAuthNotConfigured
|
|
||||||
}
|
|
||||||
return "", ErrOAuthUnknownProvider
|
|
||||||
}
|
|
||||||
state, err := s.signOAuthState(provider.ID, redirect)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
oauthConfig := s.oauthConfig(provider)
|
|
||||||
return oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOnline), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthCallback обменивает код на профиль, находит или создаёт аккаунт и
|
|
||||||
// выдаёт сессию. Возвращает также признак «аккаунт создан впервые».
|
|
||||||
func (s *Service) OAuthCallback(ctx context.Context, providerID, code, state, ip, userAgent string) (*store.User, string, *store.Session, string, error) {
|
|
||||||
if strings.TrimSpace(code) == "" {
|
|
||||||
return nil, "", nil, "", fmt.Errorf("%w: empty code", ErrOAuthExchange)
|
|
||||||
}
|
|
||||||
redirect, err := s.verifyOAuthState(providerID, state)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", nil, "", err
|
|
||||||
}
|
|
||||||
configs := s.oauthConfigs()
|
|
||||||
provider, ok := configs[strings.ToLower(strings.TrimSpace(providerID))]
|
|
||||||
if !ok {
|
|
||||||
if _, known := oauthProviders()[strings.ToLower(strings.TrimSpace(providerID))]; known {
|
|
||||||
return nil, "", nil, "", ErrOAuthNotConfigured
|
|
||||||
}
|
|
||||||
return nil, "", nil, "", ErrOAuthUnknownProvider
|
|
||||||
}
|
|
||||||
oauthConfig := s.oauthConfig(provider)
|
|
||||||
client := &http.Client{Timeout: oauthHTTPTimeout}
|
|
||||||
token, err := oauthConfig.Exchange(ctx, code)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", nil, "", fmt.Errorf("%w: exchange: %w", ErrOAuthExchange, err)
|
|
||||||
}
|
|
||||||
profile, err := provider.FetchProfile(ctx, client, token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", nil, "", err
|
|
||||||
}
|
|
||||||
if profile.Email == "" {
|
|
||||||
return nil, "", nil, "", ErrOAuthEmailMissing
|
|
||||||
}
|
|
||||||
if !profile.EmailVerified {
|
|
||||||
return nil, "", nil, "", ErrOAuthEmailUnverified
|
|
||||||
}
|
|
||||||
email := crypto.NormalizeEmail(profile.Email)
|
|
||||||
emailIndex := s.masterKey.BlindIndex(email)
|
|
||||||
subjectIndex := s.masterKey.BlindIndex(provider.ID + ":" + profile.Subject)
|
|
||||||
|
|
||||||
// 1. Уже привязанный внешний аккаунт — вход без создания новых записей.
|
|
||||||
if account, lookupErr := s.store.OAuthAccountBySubject(ctx, provider.ID, subjectIndex); lookupErr == nil {
|
|
||||||
user, userErr := s.store.GetUser(ctx, account.UserID)
|
|
||||||
if userErr != nil {
|
|
||||||
return nil, "", nil, "", userErr
|
|
||||||
}
|
|
||||||
if err := s.oauthBanCheck(ctx, user, ip, userAgent); err != nil {
|
|
||||||
return nil, "", nil, "", err
|
|
||||||
}
|
|
||||||
if err := s.store.TouchOAuthAccount(ctx, account.ID); err != nil {
|
|
||||||
s.logger.WarnContext(ctx, "не удалось обновить время входа OAuth-аккаунта", slog.Any("error", err))
|
|
||||||
}
|
|
||||||
sessionToken, session, loginErr := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_login")
|
|
||||||
return user, sessionToken, session, redirect, loginErr
|
|
||||||
} else if !errors.Is(lookupErr, store.ErrNotFound) {
|
|
||||||
return nil, "", nil, "", lookupErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Аккаунт с таким email — привязываем провайдера к нему.
|
|
||||||
if user, lookupErr := s.store.GetUserByEmailIndex(ctx, emailIndex); lookupErr == nil {
|
|
||||||
if err := s.oauthBanCheck(ctx, user, ip, userAgent); err != nil {
|
|
||||||
return nil, "", nil, "", err
|
|
||||||
}
|
|
||||||
account, linkErr := s.store.LinkOAuthAccount(ctx, user.ID, provider.ID, subjectIndex, emailIndex)
|
|
||||||
if linkErr != nil {
|
|
||||||
return nil, "", nil, "", linkErr
|
|
||||||
}
|
|
||||||
if err := s.store.TouchOAuthAccount(ctx, account.ID); err != nil {
|
|
||||||
s.logger.WarnContext(ctx, "не удалось обновить время входа OAuth-аккаунта", slog.Any("error", err))
|
|
||||||
}
|
|
||||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "oauth_linked", ip, userAgent,
|
|
||||||
`{"provider":`+quoteJSON(provider.ID)+`}`)
|
|
||||||
if err := s.store.RecordAudit(ctx, store.AuditParams{
|
|
||||||
ActorID: &user.ID,
|
|
||||||
Action: "user.oauth_link",
|
|
||||||
TargetType: "user",
|
|
||||||
TargetID: &user.ID,
|
|
||||||
Changes: map[string]any{"provider": provider.ID},
|
|
||||||
}); err != nil {
|
|
||||||
s.logger.WarnContext(ctx, "не удалось записать привязку OAuth в аудит", slog.Any("error", err))
|
|
||||||
}
|
|
||||||
sessionToken, session, loginErr := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_linked")
|
|
||||||
return user, sessionToken, session, redirect, loginErr
|
|
||||||
} else if !errors.Is(lookupErr, store.ErrNotFound) {
|
|
||||||
return nil, "", nil, "", lookupErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Новый аккаунт: регистрация должна быть разрешена (флаг инстанса).
|
|
||||||
settings, err := s.settings.InstanceSettings(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", nil, "", fmt.Errorf("load instance settings: %w", err)
|
|
||||||
}
|
|
||||||
if !settings.RegistrationEnabled {
|
|
||||||
return nil, "", nil, "", ErrRegistrationOff
|
|
||||||
}
|
|
||||||
user, err := s.createOAuthUser(ctx, email, emailIndex, profile, ip, userAgent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", nil, "", err
|
|
||||||
}
|
|
||||||
if _, err := s.store.LinkOAuthAccount(ctx, user.ID, provider.ID, subjectIndex, emailIndex); err != nil {
|
|
||||||
return nil, "", nil, "", err
|
|
||||||
}
|
|
||||||
sessionToken, session, err := s.finishOAuthLogin(ctx, user, ip, userAgent, "oauth_register")
|
|
||||||
return user, sessionToken, session, redirect, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// finishOAuthLogin создаёт сессию и пишет событие безопасности.
|
|
||||||
func (s *Service) finishOAuthLogin(ctx context.Context, user *store.User, ip, userAgent, event string) (string, *store.Session, error) {
|
|
||||||
token, session, err := s.createSession(ctx, user.ID, userAgent, ip)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, event, ip, userAgent, "")
|
|
||||||
return token, session, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthBanCheck проверяет глобальный бан инстанса до выдачи сессии (7.18).
|
|
||||||
func (s *Service) oauthBanCheck(ctx context.Context, user *store.User, ip, userAgent string) error {
|
|
||||||
banned, err := s.store.IsInstanceBanned(ctx, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if banned {
|
|
||||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", ip, userAgent, `{"method":"oauth"}`)
|
|
||||||
return ErrUserBanned
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createOAuthUser заводит аккаунт по данным провайдера: логин из профиля,
|
|
||||||
// пароль — случайный (вход только через провайдера), email шифруется.
|
|
||||||
func (s *Service) createOAuthUser(ctx context.Context, email, emailIndex string, profile oauthProfile, ip, userAgent string) (*store.User, error) {
|
|
||||||
username, err := s.uniqueUsername(ctx, profile.Login, email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
randomPassword, _, err := crypto.NewSessionToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
passwordHash, err := s.hasher.Hash(randomPassword)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("hash password: %w", err)
|
|
||||||
}
|
|
||||||
userID := s.store.NextID()
|
|
||||||
encrypted, err := s.encryptEmail(userID, email)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
displayName := strings.TrimSpace(profile.Login)
|
|
||||||
if displayName == "" {
|
|
||||||
displayName = username
|
|
||||||
}
|
|
||||||
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
|
|
||||||
ID: userID,
|
|
||||||
Username: username,
|
|
||||||
DisplayName: defaultDisplayName(displayName, username),
|
|
||||||
EmailEnc: encrypted,
|
|
||||||
EmailIndex: emailIndex,
|
|
||||||
PasswordHash: passwordHash,
|
|
||||||
Locale: "ru",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "oauth_register", ip, userAgent, "")
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uniqueUsername подбирает свободный username: из логина провайдера, иначе из
|
|
||||||
// локальной части email; суффикс добавляется только при конфликте.
|
|
||||||
func (s *Service) uniqueUsername(ctx context.Context, login, email string) (string, error) {
|
|
||||||
candidate := sanitizeUsername(login)
|
|
||||||
if candidate == "" {
|
|
||||||
local := email
|
|
||||||
if at := strings.Index(email, "@"); at > 0 {
|
|
||||||
local = email[:at]
|
|
||||||
}
|
|
||||||
candidate = sanitizeUsername(local)
|
|
||||||
}
|
|
||||||
if candidate == "" {
|
|
||||||
candidate = "user"
|
|
||||||
}
|
|
||||||
for attempt := 0; attempt < 20; attempt++ {
|
|
||||||
value := candidate
|
|
||||||
if attempt > 0 {
|
|
||||||
suffix := strconv.Itoa(attempt + 1)
|
|
||||||
limit := 32 - len(suffix)
|
|
||||||
if len(value) > limit {
|
|
||||||
value = value[:limit]
|
|
||||||
}
|
|
||||||
value += suffix
|
|
||||||
}
|
|
||||||
if _, err := s.store.GetUserByUsername(ctx, value); errors.Is(err, store.ErrNotFound) {
|
|
||||||
return value, nil
|
|
||||||
} else if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("cannot pick a free username for oauth account")
|
|
||||||
}
|
|
||||||
|
|
||||||
// signOAuthState подписывает state ключом сессий: хранить его в БД не нужно,
|
|
||||||
// подпись защищает от подмены (AGENT.md 9.2).
|
|
||||||
func (s *Service) signOAuthState(providerID, redirect string) (string, error) {
|
|
||||||
payload, err := json.Marshal(map[string]string{
|
|
||||||
"provider": providerID,
|
|
||||||
"redirect": safeRedirect(redirect),
|
|
||||||
"issued": strconv.FormatInt(time.Now().UTC().Unix(), 10),
|
|
||||||
"nonce": crypto.HashToken(strconv.FormatInt(time.Now().UTC().UnixNano(), 10))[:16],
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
|
||||||
return encoded + "." + s.signStatePayload(encoded), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifyOAuthState проверяет подпись, срок и провайдера, возвращая redirect.
|
|
||||||
func (s *Service) verifyOAuthState(providerID, state string) (string, error) {
|
|
||||||
parts := strings.Split(state, ".")
|
|
||||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
||||||
return "", ErrOAuthState
|
|
||||||
}
|
|
||||||
expected := s.signStatePayload(parts[0])
|
|
||||||
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
|
|
||||||
return "", ErrOAuthState
|
|
||||||
}
|
|
||||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return "", ErrOAuthState
|
|
||||||
}
|
|
||||||
var values map[string]string
|
|
||||||
if err := json.Unmarshal(payload, &values); err != nil {
|
|
||||||
return "", ErrOAuthState
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(values["provider"], providerID) {
|
|
||||||
return "", ErrOAuthState
|
|
||||||
}
|
|
||||||
issued, err := strconv.ParseInt(values["issued"], 10, 64)
|
|
||||||
if err != nil || time.Since(time.Unix(issued, 0).UTC()) > oauthStateTTL {
|
|
||||||
return "", ErrOAuthState
|
|
||||||
}
|
|
||||||
return safeRedirect(values["redirect"]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) signStatePayload(encoded string) string {
|
|
||||||
mac := hmac.New(sha256.New, []byte(s.stateKey))
|
|
||||||
mac.Write([]byte("oauth-state:" + encoded))
|
|
||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthConfig собирает oauth2.Config провайдера с адресом возврата.
|
|
||||||
func (s *Service) oauthConfig(provider oauthProvider) *oauth2.Config {
|
|
||||||
credentials := s.oauth[provider.ID]
|
|
||||||
return &oauth2.Config{
|
|
||||||
ClientID: credentials.clientID,
|
|
||||||
ClientSecret: credentials.clientSecret,
|
|
||||||
Endpoint: provider.Endpoint,
|
|
||||||
RedirectURL: strings.TrimRight(s.oauthRedirectBase, "/") + "/api/v1/auth/oauth/" + provider.ID + "/callback",
|
|
||||||
Scopes: provider.Scopes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthRedirectURL возвращает адрес возврата: его оператор прописывает в
|
|
||||||
// настройках приложения у провайдера.
|
|
||||||
func (s *Service) OAuthRedirectURL() string {
|
|
||||||
return s.oauthRedirectBase + "/api/v1/auth/oauth/{provider}/callback"
|
|
||||||
}
|
|
||||||
|
|
||||||
// safeRedirect пропускает только внутренние пути клиента: открытый редирект
|
|
||||||
// после входа недопустим (AGENT.md 9.7).
|
|
||||||
func safeRedirect(value string) string {
|
|
||||||
if value == "" || !strings.HasPrefix(value, "/") {
|
|
||||||
return "/app"
|
|
||||||
}
|
|
||||||
// `//host` и `/\host` браузеры понимают как чужой хост: оставляем только
|
|
||||||
// внутренние пути клиента (AGENT.md 9.7).
|
|
||||||
if strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\") {
|
|
||||||
return "/app"
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitizeUsername приводит логин провайдера к правилам glchat (7.1).
|
|
||||||
func sanitizeUsername(value string) string {
|
|
||||||
value = strings.ToLower(strings.TrimSpace(value))
|
|
||||||
var builder strings.Builder
|
|
||||||
for _, r := range value {
|
|
||||||
switch {
|
|
||||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '.':
|
|
||||||
builder.WriteRune(r)
|
|
||||||
case r == '-' || r == ' ':
|
|
||||||
builder.WriteByte('_')
|
|
||||||
}
|
|
||||||
if builder.Len() >= 32 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result := strings.Trim(builder.String(), "._")
|
|
||||||
if len(result) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- провайдеры: получение нормализованного профиля ---
|
|
||||||
|
|
||||||
func fetchGitHubProfile(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
|
||||||
var user struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Login string `json:"login"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
if err := oauthGetJSON(ctx, client, token, "https://api.github.com/user", &user); err != nil {
|
|
||||||
return oauthProfile{}, err
|
|
||||||
}
|
|
||||||
var emails []struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Primary bool `json:"primary"`
|
|
||||||
Verified bool `json:"verified"`
|
|
||||||
}
|
|
||||||
if err := oauthGetJSON(ctx, client, token, "https://api.github.com/user/emails", &emails); err != nil {
|
|
||||||
return oauthProfile{}, err
|
|
||||||
}
|
|
||||||
profile := oauthProfile{Subject: strconv.FormatInt(user.ID, 10), Login: user.Login}
|
|
||||||
for _, candidate := range emails {
|
|
||||||
// Берём только подтверждённый адрес: иначе чужой email в профиле GitHub
|
|
||||||
// позволил бы войти в чужой аккаунт glchat.
|
|
||||||
if candidate.Primary && candidate.Verified && candidate.Email != "" {
|
|
||||||
profile.Email = candidate.Email
|
|
||||||
profile.EmailVerified = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.Email == "" {
|
|
||||||
for _, candidate := range emails {
|
|
||||||
if candidate.Verified && candidate.Email != "" {
|
|
||||||
profile.Email = candidate.Email
|
|
||||||
profile.EmailVerified = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if profile.Email == "" && user.Email != "" {
|
|
||||||
// Публичный email из профиля GitHub подтверждённым не считаем.
|
|
||||||
profile.Email = user.Email
|
|
||||||
}
|
|
||||||
return profile, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchGoogleProfile(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
|
||||||
var user struct {
|
|
||||||
Sub string `json:"sub"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
EmailVerified bool `json:"email_verified"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
if err := oauthGetJSON(ctx, client, token, "https://openidconnect.googleapis.com/v1/userinfo", &user); err != nil {
|
|
||||||
return oauthProfile{}, err
|
|
||||||
}
|
|
||||||
return oauthProfile{
|
|
||||||
Subject: user.Sub,
|
|
||||||
Email: user.Email,
|
|
||||||
EmailVerified: user.EmailVerified,
|
|
||||||
Login: user.Name,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchDiscordProfile(ctx context.Context, client *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
|
||||||
var user struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
GlobalName string `json:"global_name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Verified bool `json:"verified"`
|
|
||||||
EmailVerified bool `json:"email_verified"`
|
|
||||||
}
|
|
||||||
if err := oauthGetJSON(ctx, client, token, "https://discord.com/api/users/@me", &user); err != nil {
|
|
||||||
return oauthProfile{}, err
|
|
||||||
}
|
|
||||||
login := user.GlobalName
|
|
||||||
if login == "" {
|
|
||||||
login = user.Username
|
|
||||||
}
|
|
||||||
return oauthProfile{
|
|
||||||
Subject: user.ID,
|
|
||||||
Email: user.Email,
|
|
||||||
EmailVerified: user.Verified || user.EmailVerified,
|
|
||||||
Login: login,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthGetJSON выполняет GET к API провайдера с токеном доступа.
|
|
||||||
func oauthGetJSON(ctx context.Context, client *http.Client, token *oauth2.Token, endpoint string, dst any) error {
|
|
||||||
if _, err := url.Parse(endpoint); err != nil {
|
|
||||||
return fmt.Errorf("%w: bad endpoint", ErrOAuthExchange)
|
|
||||||
}
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: build request: %w", ErrOAuthExchange, err)
|
|
||||||
}
|
|
||||||
request.Header.Set("Accept", "application/json")
|
|
||||||
request.Header.Set("User-Agent", "glchat")
|
|
||||||
token.SetAuthHeader(request)
|
|
||||||
response, err := client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: request: %w", ErrOAuthExchange, err)
|
|
||||||
}
|
|
||||||
defer func() { _ = response.Body.Close() }()
|
|
||||||
if response.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("%w: provider returned %d", ErrOAuthExchange, response.StatusCode)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: read body: %w", ErrOAuthExchange, err)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, dst); err != nil {
|
|
||||||
return fmt.Errorf("%w: decode profile: %w", ErrOAuthExchange, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthFromConfig собирает включённые провайдеры из конфигурации инстанса.
|
|
||||||
func oauthFromConfig(cfg config.Config, baseURL string) (map[string]oauthClientCredentials, string) {
|
|
||||||
credentials := map[string]oauthClientCredentials{}
|
|
||||||
add := func(id, clientID, clientSecret string) {
|
|
||||||
if clientID != "" && clientSecret != "" {
|
|
||||||
credentials[id] = oauthClientCredentials{clientID: clientID, clientSecret: clientSecret}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
add("github", cfg.OAuthGitHubClientID, cfg.OAuthGitHubClientSecret)
|
|
||||||
add("google", cfg.OAuthGoogleClientID, cfg.OAuthGoogleClientSecret)
|
|
||||||
add("discord", cfg.OAuthDiscordClientID, cfg.OAuthDiscordClientSecret)
|
|
||||||
redirectBase := strings.TrimSpace(cfg.OAuthRedirectBase)
|
|
||||||
if redirectBase == "" {
|
|
||||||
redirectBase = baseURL
|
|
||||||
}
|
|
||||||
return credentials, redirectBase
|
|
||||||
}
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
|
|
||||||
"glchat/internal/config"
|
|
||||||
"glchat/internal/crypto"
|
|
||||||
"glchat/internal/database"
|
|
||||||
"glchat/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// oauthTestConfig — конфиг тестового сервиса с включённым GitHub-провайдером.
|
|
||||||
func oauthTestConfig() config.Config {
|
|
||||||
return config.Config{
|
|
||||||
Domain: "gl.mhspx.su",
|
|
||||||
InstanceName: "glchat",
|
|
||||||
SessionPepper: "unit-test-session-pepper",
|
|
||||||
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
|
|
||||||
TOTPEncryptionKey: "unit-test-totp-key",
|
|
||||||
SessionTTLHours: 24,
|
|
||||||
// Низкие параметры Argon2id: тесты не должны работать секундами.
|
|
||||||
Argon2MemoryKiB: 1024,
|
|
||||||
Argon2Iterations: 1,
|
|
||||||
Argon2Parallelism: 1,
|
|
||||||
OAuthGitHubClientID: "client-id",
|
|
||||||
OAuthGitHubClientSecret: "client-secret",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthTestBaseConfig — конфиг без OAuth: проверяем выключенные провайдеры.
|
|
||||||
func oauthTestBaseConfig() config.Config {
|
|
||||||
cfg := oauthTestConfig()
|
|
||||||
cfg.OAuthGitHubClientID = ""
|
|
||||||
cfg.OAuthGitHubClientSecret = ""
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthTestServer поднимает фальшивого провайдера: обмен кода на токен и
|
|
||||||
// профиль. Адреса подставляются в каталог провайдеров на время теста.
|
|
||||||
func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.Form.Get("code") != "good-code" {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
_, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Провайдер отвечает JSON: так же ведёт себя GitHub при
|
|
||||||
// `Accept: application/json`, который ставит golang.org/x/oauth2.
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
||||||
"access_token": "test-access-token",
|
|
||||||
"token_type": "bearer",
|
|
||||||
"expires_in": 3600,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Header.Get("Authorization") != "Bearer test-access-token" {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
|
||||||
})
|
|
||||||
server := httptest.NewServer(mux)
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
|
|
||||||
original := oauthProviderSet
|
|
||||||
oauthProviderSet = map[string]oauthProvider{
|
|
||||||
"github": {
|
|
||||||
ID: "github",
|
|
||||||
Name: "GitHub",
|
|
||||||
Endpoint: oauth2.Endpoint{
|
|
||||||
AuthURL: server.URL + "/authorize",
|
|
||||||
TokenURL: server.URL + "/token",
|
|
||||||
},
|
|
||||||
Scopes: []string{"user:email"},
|
|
||||||
FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
|
||||||
// Провайдер получает токен так же, как в бою: через oauth2-клиент.
|
|
||||||
authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil)
|
|
||||||
if err != nil {
|
|
||||||
return oauthProfile{}, err
|
|
||||||
}
|
|
||||||
response, err := authorized.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return oauthProfile{}, err
|
|
||||||
}
|
|
||||||
defer func() { _ = response.Body.Close() }()
|
|
||||||
if response.StatusCode != http.StatusOK {
|
|
||||||
return oauthProfile{}, errors.New("profile request failed")
|
|
||||||
}
|
|
||||||
return profile, nil
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { oauthProviderSet = original })
|
|
||||||
return server
|
|
||||||
}
|
|
||||||
|
|
||||||
func oauthTestService(t *testing.T, cfg config.Config) (*Service, *store.Store) {
|
|
||||||
t.Helper()
|
|
||||||
ctx := context.Background()
|
|
||||||
db, err := database.Open(ctx, database.Options{
|
|
||||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
|
||||||
ReadPool: 2,
|
|
||||||
Migrate: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open database: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
|
||||||
st := store.New(db)
|
|
||||||
service, err := New(ctx, cfg, st, slog.New(slog.DiscardHandler))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("auth.New: %v", err)
|
|
||||||
}
|
|
||||||
return service, st
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthNotConfigured — без клиентских id ручки отвечают понятной ошибкой,
|
|
||||||
// а не пытаются увести пользователя к провайдеру (AGENT.md 7.1).
|
|
||||||
func TestOAuthNotConfigured(t *testing.T) {
|
|
||||||
service, _ := oauthTestService(t, oauthTestBaseConfig())
|
|
||||||
if service.OAuthEnabled() {
|
|
||||||
t.Fatal("oauth must be disabled without client credentials")
|
|
||||||
}
|
|
||||||
if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) {
|
|
||||||
t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err)
|
|
||||||
}
|
|
||||||
if _, err := service.OAuthAuthorizeURL("vk", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) {
|
|
||||||
t.Fatalf("unknown provider: err = %v, want ErrOAuthUnknownProvider", err)
|
|
||||||
}
|
|
||||||
if len(service.OAuthProviders()) != 0 {
|
|
||||||
t.Fatalf("providers = %v, want empty", service.OAuthProviders())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthAuthorizeURL — ссылка ведёт к провайдеру и несёт подписанный state.
|
|
||||||
func TestOAuthAuthorizeURL(t *testing.T) {
|
|
||||||
oauthTestServer(t, oauthProfile{Subject: "1", Email: "user@example.com", EmailVerified: true})
|
|
||||||
service, _ := oauthTestService(t, oauthTestConfig())
|
|
||||||
|
|
||||||
providers := service.OAuthProviders()
|
|
||||||
if len(providers) != 1 || providers[0].ID != "github" {
|
|
||||||
t.Fatalf("providers = %v, want [github]", providers)
|
|
||||||
}
|
|
||||||
authorizeURL, err := service.OAuthAuthorizeURL("github", "/app/invite/abc")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("authorize url: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(authorizeURL, "http") || !strings.Contains(authorizeURL, "client_id=client-id") {
|
|
||||||
t.Fatalf("unexpected authorize url: %s", authorizeURL)
|
|
||||||
}
|
|
||||||
parsed, err := url.Parse(authorizeURL)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse authorize url: %v", err)
|
|
||||||
}
|
|
||||||
state := parsed.Query().Get("state")
|
|
||||||
if state == "" || !strings.Contains(state, ".") {
|
|
||||||
t.Fatalf("state is not signed: %q", state)
|
|
||||||
}
|
|
||||||
redirect, err := service.verifyOAuthState("github", state)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("verify state: %v", err)
|
|
||||||
}
|
|
||||||
if redirect != "/app/invite/abc" {
|
|
||||||
t.Fatalf("redirect = %q", redirect)
|
|
||||||
}
|
|
||||||
// Подмена state и чужой провайдер отклоняются.
|
|
||||||
if _, err := service.verifyOAuthState("google", state); !errors.Is(err, ErrOAuthState) {
|
|
||||||
t.Fatalf("state for another provider must fail, got %v", err)
|
|
||||||
}
|
|
||||||
if _, err := service.verifyOAuthState("github", state+"x"); !errors.Is(err, ErrOAuthState) {
|
|
||||||
t.Fatalf("tampered state must fail, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthCallbackCreatesAndLinksAccounts — вход новым аккаунтом, привязка к
|
|
||||||
// существующему по email, повторный вход и запрет для забаненного инстанса.
|
|
||||||
func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
|
|
||||||
oauthTestServer(t, oauthProfile{Subject: "42", Email: "OAuth@Example.com", EmailVerified: true, Login: "Octo Cat"})
|
|
||||||
service, st := oauthTestService(t, oauthTestConfig())
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
state, err := service.signOAuthState("github", "/app")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign state: %v", err)
|
|
||||||
}
|
|
||||||
user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("oauth callback: %v", err)
|
|
||||||
}
|
|
||||||
if redirect != "/app" || token == "" || session == nil {
|
|
||||||
t.Fatalf("unexpected callback result: redirect=%q token=%q", redirect, token)
|
|
||||||
}
|
|
||||||
if user.Username != "octo_cat" {
|
|
||||||
t.Fatalf("username = %q, want octo_cat", user.Username)
|
|
||||||
}
|
|
||||||
// Email зашифрован, наружу не отдаётся, но расшифровывается сервисом.
|
|
||||||
email, err := service.Email(ctx, user.ID)
|
|
||||||
if err != nil || email != "oauth@example.com" {
|
|
||||||
t.Fatalf("email = %q, err = %v", email, err)
|
|
||||||
}
|
|
||||||
accounts, err := st.ListOAuthAccounts(ctx, user.ID)
|
|
||||||
if err != nil || len(accounts) != 1 {
|
|
||||||
t.Fatalf("oauth accounts = %v, err = %v", accounts, err)
|
|
||||||
}
|
|
||||||
if accounts[0].SubjectIndex == "" || strings.Contains(accounts[0].SubjectIndex, "42") {
|
|
||||||
t.Fatalf("subject must be stored as blind index: %q", accounts[0].SubjectIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Повторный вход тем же аккаунтом: новой записи не появляется.
|
|
||||||
state, err = service.signOAuthState("github", "/app")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign state: %v", err)
|
|
||||||
}
|
|
||||||
again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second oauth callback: %v", err)
|
|
||||||
}
|
|
||||||
if again.ID != user.ID {
|
|
||||||
t.Fatalf("second login created another account: %d != %d", again.ID, user.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Бан инстанса запрещает вход через провайдера.
|
|
||||||
if err := st.BanInstanceUser(ctx, user.ID, user.ID, "тест"); err != nil {
|
|
||||||
t.Fatalf("ban user: %v", err)
|
|
||||||
}
|
|
||||||
state, err = service.signOAuthState("github", "/app")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign state: %v", err)
|
|
||||||
}
|
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) {
|
|
||||||
t.Fatalf("banned oauth login: err = %v, want ErrUserBanned", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthCallbackLinksExistingAccountByEmail — провайдер привязывается к
|
|
||||||
// уже существующему аккаунту с тем же подтверждённым email (blind index).
|
|
||||||
func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) {
|
|
||||||
oauthTestServer(t, oauthProfile{Subject: "777", Email: "existing@example.com", EmailVerified: true, Login: "existing"})
|
|
||||||
service, st := oauthTestService(t, oauthTestConfig())
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, _, _, err := service.Register(ctx, RegisterInput{
|
|
||||||
Username: "existing", Email: "existing@example.com", Password: "correct-horse-battery",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("register: %v", err)
|
|
||||||
}
|
|
||||||
existing, err := service.UserByEmail(ctx, "existing@example.com")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("find user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := service.signOAuthState("github", "/app")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign state: %v", err)
|
|
||||||
}
|
|
||||||
user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "agent")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("oauth callback: %v", err)
|
|
||||||
}
|
|
||||||
if user.ID != existing.ID {
|
|
||||||
t.Fatalf("oauth login must reuse the account: %d != %d", user.ID, existing.ID)
|
|
||||||
}
|
|
||||||
accounts, err := st.ListOAuthAccounts(ctx, user.ID)
|
|
||||||
if err != nil || len(accounts) != 1 {
|
|
||||||
t.Fatalf("link was not created: %v, err = %v", accounts, err)
|
|
||||||
}
|
|
||||||
events, err := st.ListSecurityEvents(ctx, user.ID, 20)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list security events: %v", err)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, event := range events {
|
|
||||||
if event.Type == "oauth_linked" {
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("security events have no oauth_linked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthCallbackRejectsUnverifiedEmail — неподтверждённый email не даёт
|
|
||||||
// войти: иначе чужой адрес в профиле провайдера уводил бы в чужой аккаунт.
|
|
||||||
func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) {
|
|
||||||
oauthTestServer(t, oauthProfile{Subject: "5", Email: "unverified@example.com", EmailVerified: false})
|
|
||||||
service, _ := oauthTestService(t, oauthTestConfig())
|
|
||||||
|
|
||||||
state, err := service.signOAuthState("github", "/app")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign state: %v", err)
|
|
||||||
}
|
|
||||||
if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) {
|
|
||||||
t.Fatalf("unverified email: err = %v, want ErrOAuthEmailUnverified", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthCallbackStateAndCodeErrors — неверный state и код отклоняются.
|
|
||||||
func TestOAuthCallbackStateAndCodeErrors(t *testing.T) {
|
|
||||||
oauthTestServer(t, oauthProfile{Subject: "6", Email: "user@example.com", EmailVerified: true})
|
|
||||||
service, _ := oauthTestService(t, oauthTestConfig())
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) {
|
|
||||||
t.Fatalf("bad state: err = %v, want ErrOAuthState", err)
|
|
||||||
}
|
|
||||||
state, err := service.signOAuthState("github", "/app")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign state: %v", err)
|
|
||||||
}
|
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
|
||||||
t.Fatalf("empty code: err = %v, want ErrOAuthExchange", err)
|
|
||||||
}
|
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
|
||||||
t.Fatalf("bad code: err = %v, want ErrOAuthExchange", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthRegistrationDisabledBlocksNewAccounts — существующие аккаунты
|
|
||||||
// входят, новые не создаются при выключенной регистрации (7.1).
|
|
||||||
func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) {
|
|
||||||
oauthTestServer(t, oauthProfile{Subject: "8", Email: "new@example.com", EmailVerified: true, Login: "new"})
|
|
||||||
service, st := oauthTestService(t, oauthTestConfig())
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil {
|
|
||||||
t.Fatalf("disable registration: %v", err)
|
|
||||||
}
|
|
||||||
state, err := service.signOAuthState("github", "/app")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sign state: %v", err)
|
|
||||||
}
|
|
||||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) {
|
|
||||||
t.Fatalf("registration disabled: err = %v, want ErrRegistrationOff", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthStateExpires — state старше окна не принимается, даже если подпись
|
|
||||||
// верна: иначе ссылку можно было бы использовать неограниченно долго.
|
|
||||||
func TestOAuthStateExpires(t *testing.T) {
|
|
||||||
service, _ := oauthTestService(t, oauthTestConfig())
|
|
||||||
payload, err := json.Marshal(map[string]string{
|
|
||||||
"provider": "github",
|
|
||||||
"redirect": "/app",
|
|
||||||
"issued": strconv.FormatInt(time.Now().UTC().Add(-time.Hour).Unix(), 10),
|
|
||||||
"nonce": "deadbeefdeadbeef",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal payload: %v", err)
|
|
||||||
}
|
|
||||||
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
|
||||||
expired := encoded + "." + service.signStatePayload(encoded)
|
|
||||||
if _, err := service.verifyOAuthState("github", expired); !errors.Is(err, ErrOAuthState) {
|
|
||||||
t.Fatalf("expired state: err = %v, want ErrOAuthState", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPasskeyHandleStableAndOpaque — user handle не раскрывает id аккаунта и
|
|
||||||
// стабилен между вызовами.
|
|
||||||
func TestPasskeyHandleStableAndOpaque(t *testing.T) {
|
|
||||||
service, _ := oauthTestService(t, oauthTestBaseConfig())
|
|
||||||
first := service.passkeyHandle(12345)
|
|
||||||
second := service.passkeyHandle(12345)
|
|
||||||
if len(first) != 16 || !bytes.Equal(first, second) {
|
|
||||||
t.Fatalf("handle must be 16 stable bytes: %v / %v", first, second)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(first), "12345") {
|
|
||||||
t.Fatal("handle must not contain the account id")
|
|
||||||
}
|
|
||||||
if bytes.Equal(service.passkeyHandle(12346), first) {
|
|
||||||
t.Fatal("handles of different accounts must differ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPasskeyCeremonyStoreIsOneTime — церемония выдаётся один раз и по TTL.
|
|
||||||
func TestPasskeyCeremonyStoreIsOneTime(t *testing.T) {
|
|
||||||
store := newCeremonyStore()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
store.now = func() time.Time { return now }
|
|
||||||
store.put("token", ceremony{userID: 1, expiresAt: now.Add(time.Minute)})
|
|
||||||
if _, ok := store.take("token"); !ok {
|
|
||||||
t.Fatal("ceremony must be available once")
|
|
||||||
}
|
|
||||||
if _, ok := store.take("token"); ok {
|
|
||||||
t.Fatal("ceremony must not be reusable")
|
|
||||||
}
|
|
||||||
store.put("expired", ceremony{userID: 1, expiresAt: now.Add(-time.Second)})
|
|
||||||
if _, ok := store.take("expired"); ok {
|
|
||||||
t.Fatal("expired ceremony must not be usable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSanitizeUsername — логин провайдера приводится к правилам glchat.
|
|
||||||
func TestSanitizeUsername(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
"Octo-Cat": "octo_cat",
|
|
||||||
" John Doe ": "john_doe",
|
|
||||||
"ю": "",
|
|
||||||
"a": "",
|
|
||||||
"user.name": "user.name",
|
|
||||||
"UPPER": "upper",
|
|
||||||
}
|
|
||||||
for input, expected := range cases {
|
|
||||||
if got := sanitizeUsername(input); got != expected {
|
|
||||||
t.Errorf("sanitizeUsername(%q) = %q, want %q", input, got, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSafeRedirect — открытый редирект невозможен.
|
|
||||||
func TestSafeRedirect(t *testing.T) {
|
|
||||||
if safeRedirect("//evil.example") != "/app" || safeRedirect("https://evil.example") != "/app" {
|
|
||||||
t.Fatal("external redirects must be rejected")
|
|
||||||
}
|
|
||||||
if safeRedirect("/app/invite/x") != "/app/invite/x" || safeRedirect("") != "/app" {
|
|
||||||
t.Fatal("internal redirects must be preserved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// проверяем, что ключ blind index провайдера не совпадает с email-индексом.
|
|
||||||
func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) {
|
|
||||||
key, err := crypto.ParseMasterKey(oauthTestConfig().MasterKey)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse master key: %v", err)
|
|
||||||
}
|
|
||||||
subject := key.BlindIndex("github:42")
|
|
||||||
email := key.BlindIndex("user@example.com")
|
|
||||||
if subject == email || len(subject) != len(email) {
|
|
||||||
t.Fatal("blind indexes must be independent")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,10 +60,7 @@ type Service struct {
|
|||||||
webauthn *webauthn.WebAuthn
|
webauthn *webauthn.WebAuthn
|
||||||
// ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые).
|
// ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые).
|
||||||
ceremonies *ceremonyStore
|
ceremonies *ceremonyStore
|
||||||
// oauth — включённые провайдеры (client id/secret из env), oauthRedirectBase —
|
|
||||||
// внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state.
|
// внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state.
|
||||||
oauth map[string]oauthClientCredentials
|
|
||||||
oauthRedirectBase string
|
|
||||||
stateKey string
|
stateKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +110,8 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L
|
|||||||
sessionTTL = store.SessionTTL
|
sessionTTL = store.SessionTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth-провайдеры и RP-конфигурация passkeys: включаются конфигом, при
|
// RP-конфигурация passkeys: включается конфигом, при
|
||||||
// отсутствии настроек соответствующие ручки отвечают понятной ошибкой.
|
// отсутствии настроек соответствующие ручки отвечают понятной ошибкой.
|
||||||
oauthClients, oauthRedirectBase := oauthFromConfig(cfg, cfg.BaseURL())
|
|
||||||
|
|
||||||
service := &Service{
|
service := &Service{
|
||||||
store: st,
|
store: st,
|
||||||
@@ -126,9 +122,6 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L
|
|||||||
policy: policy,
|
policy: policy,
|
||||||
cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute},
|
cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute},
|
||||||
logger: logger,
|
logger: logger,
|
||||||
// state OAuth подписывается секретом сессий: отдельного секрета не нужно.
|
|
||||||
oauth: oauthClients,
|
|
||||||
oauthRedirectBase: oauthRedirectBase,
|
|
||||||
stateKey: cfg.SessionPepper,
|
stateKey: cfg.SessionPepper,
|
||||||
ceremonies: newCeremonyStore(),
|
ceremonies: newCeremonyStore(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,17 +62,6 @@ type Config struct {
|
|||||||
RetainAuditDays int
|
RetainAuditDays int
|
||||||
RetainOrphanHours int
|
RetainOrphanHours int
|
||||||
RetainInterval time.Duration
|
RetainInterval time.Duration
|
||||||
// OAuth-провайдеры (AGENT.md 7.1, Фаза 7): включаются парами
|
|
||||||
// client id/secret, пустые значения означают «провайдер выключен».
|
|
||||||
OAuthGitHubClientID string
|
|
||||||
OAuthGitHubClientSecret string
|
|
||||||
OAuthGoogleClientID string
|
|
||||||
OAuthGoogleClientSecret string
|
|
||||||
OAuthDiscordClientID string
|
|
||||||
OAuthDiscordClientSecret string
|
|
||||||
// OAuthRedirectBase — внешний адрес инстанса для redirect_uri (пусто —
|
|
||||||
// берётся BaseURL из домена). Нужен, если клиент ходит по другому адресу.
|
|
||||||
OAuthRedirectBase string
|
|
||||||
// Web Push (AGENT.md 7.16, Фаза 7): приватный VAPID-ключ (PKCS#8 DER в
|
// Web Push (AGENT.md 7.16, Фаза 7): приватный VAPID-ключ (PKCS#8 DER в
|
||||||
// base64, генерирует установщик) и контакт администратора для push-сервисов
|
// base64, генерирует установщик) и контакт администратора для push-сервисов
|
||||||
// (mailto:… или https://…). Пустой ключ — Web Push выключен.
|
// (mailto:… или https://…). Пустой ключ — Web Push выключен.
|
||||||
@@ -121,14 +110,6 @@ func Load() (Config, error) {
|
|||||||
RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24),
|
RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24),
|
||||||
RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second,
|
RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second,
|
||||||
|
|
||||||
OAuthGitHubClientID: env("OAUTH_GITHUB_CLIENT_ID", ""),
|
|
||||||
OAuthGitHubClientSecret: env("OAUTH_GITHUB_CLIENT_SECRET", ""),
|
|
||||||
OAuthGoogleClientID: env("OAUTH_GOOGLE_CLIENT_ID", ""),
|
|
||||||
OAuthGoogleClientSecret: env("OAUTH_GOOGLE_CLIENT_SECRET", ""),
|
|
||||||
OAuthDiscordClientID: env("OAUTH_DISCORD_CLIENT_ID", ""),
|
|
||||||
OAuthDiscordClientSecret: env("OAUTH_DISCORD_CLIENT_SECRET", ""),
|
|
||||||
OAuthRedirectBase: env("OAUTH_REDIRECT_BASE", ""),
|
|
||||||
|
|
||||||
VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
|
VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
|
||||||
VAPIDSubject: env("VAPID_SUBJECT", ""),
|
VAPIDSubject: env("VAPID_SUBJECT", ""),
|
||||||
|
|
||||||
@@ -180,9 +161,6 @@ func (c Config) VoiceEnabled() bool {
|
|||||||
return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != ""
|
return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthEnabled сообщает, настроен ли хотя бы один OAuth-провайдер (AGENT.md 7.1).
|
|
||||||
func (c Config) OAuthEnabled() bool { return len(c.OAuthProviders()) > 0 }
|
|
||||||
|
|
||||||
// WebPushEnabled сообщает, настроен ли Web Push: без VAPID-ключа подписки
|
// WebPushEnabled сообщает, настроен ли Web Push: без VAPID-ключа подписки
|
||||||
// браузеров принимать некуда (AGENT.md 7.16).
|
// браузеров принимать некуда (AGENT.md 7.16).
|
||||||
func (c Config) WebPushEnabled() bool { return strings.TrimSpace(c.VAPIDPrivateKey) != "" }
|
func (c Config) WebPushEnabled() bool { return strings.TrimSpace(c.VAPIDPrivateKey) != "" }
|
||||||
@@ -204,25 +182,6 @@ func (c Config) WebAuthnEnabled() bool {
|
|||||||
return net.ParseIP(host) == nil
|
return net.ParseIP(host) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthProviders перечисляет включённые провайдеры в стабильном порядке.
|
|
||||||
func (c Config) OAuthProviders() []string {
|
|
||||||
providers := make([]string, 0, 3)
|
|
||||||
for _, candidate := range []struct {
|
|
||||||
id string
|
|
||||||
client string
|
|
||||||
secret string
|
|
||||||
}{
|
|
||||||
{"github", c.OAuthGitHubClientID, c.OAuthGitHubClientSecret},
|
|
||||||
{"google", c.OAuthGoogleClientID, c.OAuthGoogleClientSecret},
|
|
||||||
{"discord", c.OAuthDiscordClientID, c.OAuthDiscordClientSecret},
|
|
||||||
} {
|
|
||||||
if candidate.client != "" && candidate.secret != "" {
|
|
||||||
providers = append(providers, candidate.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return providers
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain }
|
func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain }
|
||||||
func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain }
|
func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Вход через внешние провайдеры (OAuth) удалён по решению владельца
|
||||||
|
-- 27.09.2026: самодостаточному инстансу «на своём сервере» он не нужен, а
|
||||||
|
-- оператору пришлось бы заводить отдельное приложение у каждого провайдера
|
||||||
|
-- (свой client id/secret, свои redirect URI, модерация) — слишком много
|
||||||
|
-- трения ради способа входа, который дублирует пароль и ключи доступа.
|
||||||
|
-- Решение и разбор — D-084.
|
||||||
|
--
|
||||||
|
-- Таблица осталась от Фазы 7 (миграция 00019, её не переписываем: она уже
|
||||||
|
-- применена на стендах), кода, который её читает, больше нет. Данные в ней
|
||||||
|
-- могли быть только у инсталляций, которые реально включали OAuth; на наших
|
||||||
|
-- стендах провайдеры ни разу не настраивались, поэтому удаление безопасно.
|
||||||
|
DROP TABLE IF EXISTS oauth_accounts;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- Откат возвращает структуру таблицы (пустую): сам код входа лежит в истории
|
||||||
|
-- git, повторное включение потребует и его, и новые ключи провайдеров.
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
subject_index TEXT NOT NULL,
|
||||||
|
email_index TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_login_at TEXT,
|
||||||
|
UNIQUE (provider, subject_index)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS oauth_accounts_user_idx ON oauth_accounts (user_id);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pressly/goose/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tableExists сообщает, есть ли таблица в схеме.
|
||||||
|
func tableExists(ctx context.Context, t *testing.T, db *DB, name string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var count int
|
||||||
|
if err := db.Reader.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("sqlite_master(%s): %v", name, err)
|
||||||
|
}
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOAuthRemovalMigration проверяет миграцию 00027 (D-084): вход через
|
||||||
|
// внешние провайдеры удалён, таблица связи внешних аккаунтов уходит из схемы.
|
||||||
|
// Откат возвращает структуру (пустую) — код входа лежит в истории git.
|
||||||
|
func TestOAuthRemovalMigration(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := Open(ctx, Options{
|
||||||
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||||
|
ReadPool: 2,
|
||||||
|
Migrate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
if tableExists(ctx, t, db, "oauth_accounts") {
|
||||||
|
t.Fatal("после миграций таблицы oauth_accounts быть не должно")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Откат одной миграции возвращает таблицу, повторный накат снова убирает.
|
||||||
|
if err := goose.DownToContext(ctx, db.Writer, "migrations", 26); err != nil {
|
||||||
|
t.Fatalf("down to 26: %v", err)
|
||||||
|
}
|
||||||
|
if !tableExists(ctx, t, db, "oauth_accounts") {
|
||||||
|
t.Fatal("откат 00027 должен вернуть структуру oauth_accounts")
|
||||||
|
}
|
||||||
|
if err := goose.UpContext(ctx, db.Writer, "migrations"); err != nil {
|
||||||
|
t.Fatalf("up: %v", err)
|
||||||
|
}
|
||||||
|
if tableExists(ctx, t, db, "oauth_accounts") {
|
||||||
|
t.Fatal("повторный накат 00027 должен снова удалить oauth_accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,7 @@ type Features struct {
|
|||||||
VoiceURL string `json:"voice_url,omitempty"`
|
VoiceURL string `json:"voice_url,omitempty"`
|
||||||
WebPushEnabled bool `json:"web_push_enabled"`
|
WebPushEnabled bool `json:"web_push_enabled"`
|
||||||
UnfurlEnabled bool `json:"unfurl_enabled"`
|
UnfurlEnabled bool `json:"unfurl_enabled"`
|
||||||
OAuthEnabled bool `json:"oauth_enabled"`
|
|
||||||
PasskeysEnabled bool `json:"passkeys_enabled"`
|
PasskeysEnabled bool `json:"passkeys_enabled"`
|
||||||
// OAuthProviders — включённые провайдеры входа (id): клиент рисует по
|
|
||||||
// списку кнопки, выключенные не показывает (Фаза 7).
|
|
||||||
OAuthProviders []string `json:"oauth_providers"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Response struct {
|
type Response struct {
|
||||||
@@ -52,9 +48,7 @@ func New(cfg config.Config) Response {
|
|||||||
VoiceURL: cfg.LiveKitURL,
|
VoiceURL: cfg.LiveKitURL,
|
||||||
WebPushEnabled: cfg.WebPushEnabled(),
|
WebPushEnabled: cfg.WebPushEnabled(),
|
||||||
UnfurlEnabled: cfg.UnfurlEnabled,
|
UnfurlEnabled: cfg.UnfurlEnabled,
|
||||||
OAuthEnabled: cfg.OAuthEnabled(),
|
|
||||||
PasskeysEnabled: cfg.WebAuthnEnabled(),
|
PasskeysEnabled: cfg.WebAuthnEnabled(),
|
||||||
OAuthProviders: cfg.OAuthProviders(),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func TestJSONUsesSnakeCaseKeys(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("features is not an object: %s", raw)
|
t.Fatalf("features is not an object: %s", raw)
|
||||||
}
|
}
|
||||||
for _, key := range []string{"registration_enabled", "anti_bot_enabled", "voice_enabled", "web_push_enabled", "oauth_enabled", "passkeys_enabled"} {
|
for _, key := range []string{"registration_enabled", "anti_bot_enabled", "voice_enabled", "web_push_enabled", "passkeys_enabled"} {
|
||||||
if _, ok := features[key]; !ok {
|
if _, ok := features[key]; !ok {
|
||||||
t.Errorf("features is missing key %q", key)
|
t.Errorf("features is missing key %q", key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,25 +81,6 @@ func newAPIError(err error) apiError {
|
|||||||
case errors.Is(err, auth.ErrPasskeyLimit):
|
case errors.Is(err, auth.ErrPasskeyLimit):
|
||||||
candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit"
|
candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit"
|
||||||
candidate.Message = "passkey limit reached for this account"
|
candidate.Message = "passkey limit reached for this account"
|
||||||
// OAuth-провайдеры (Фаза 7).
|
|
||||||
case errors.Is(err, auth.ErrOAuthNotConfigured):
|
|
||||||
candidate.Status, candidate.Code = http.StatusNotFound, "oauth.provider_not_configured"
|
|
||||||
candidate.Message = "oauth provider is not configured on this instance"
|
|
||||||
case errors.Is(err, auth.ErrOAuthUnknownProvider):
|
|
||||||
candidate.Status, candidate.Code = http.StatusNotFound, "oauth.provider_unknown"
|
|
||||||
candidate.Message = "unknown oauth provider"
|
|
||||||
case errors.Is(err, auth.ErrOAuthState):
|
|
||||||
candidate.Status, candidate.Code = http.StatusBadRequest, "oauth.state_invalid"
|
|
||||||
candidate.Message = "oauth state is invalid or expired"
|
|
||||||
case errors.Is(err, auth.ErrOAuthEmailUnverified):
|
|
||||||
candidate.Status, candidate.Code = http.StatusForbidden, "oauth.email_unverified"
|
|
||||||
candidate.Message = "provider did not confirm the email address"
|
|
||||||
case errors.Is(err, auth.ErrOAuthEmailMissing):
|
|
||||||
candidate.Status, candidate.Code = http.StatusForbidden, "oauth.email_missing"
|
|
||||||
candidate.Message = "provider did not return an email address"
|
|
||||||
case errors.Is(err, auth.ErrOAuthExchange):
|
|
||||||
candidate.Status, candidate.Code = http.StatusBadGateway, "oauth.exchange_failed"
|
|
||||||
candidate.Message = "oauth provider rejected the request"
|
|
||||||
case errors.Is(err, store.ErrNotFound):
|
case errors.Is(err, store.ErrNotFound):
|
||||||
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
|
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
|
||||||
case errors.Is(err, store.ErrConflict):
|
case errors.Is(err, store.ErrConflict):
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"glchat/internal/auth"
|
|
||||||
"glchat/internal/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
// registerOAuthRoutes вешает ручки входа через внешние провайдеры (AGENT.md 7.1).
|
|
||||||
// `start` и `callback` — публичные (это вход), лимит по IP общий с другими
|
|
||||||
// ручками аутентификации.
|
|
||||||
func (s *Server) registerOAuthRoutes(router chi.Router) {
|
|
||||||
oauthLimit := s.oauthLimiter.Middleware(func(r *http.Request) string {
|
|
||||||
return httpx.ClientIP(r, nil)
|
|
||||||
})
|
|
||||||
router.Get("/auth/oauth/providers", s.handleOAuthProviders)
|
|
||||||
router.With(oauthLimit).Get("/auth/oauth/{provider}/start", s.handleOAuthStart)
|
|
||||||
router.With(oauthLimit).Get("/auth/oauth/{provider}/callback", s.handleOAuthCallback)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleOAuthProviders отдаёт список включённых провайдеров: клиент рисует по
|
|
||||||
// нему кнопки входа, а закрытые провайдеры не показывает вовсе.
|
|
||||||
func (s *Server) handleOAuthProviders(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.auth == nil {
|
|
||||||
writeJSON(w, map[string]any{"providers": []any{}, "redirect_url": ""})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, map[string]any{
|
|
||||||
"providers": s.auth.OAuthProviders(),
|
|
||||||
"redirect_url": s.auth.OAuthRedirectURL(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleOAuthStart уводит браузер на страницу согласия провайдера.
|
|
||||||
// Если провайдер не настроен — отвечаем понятной ошибкой, а не редиректом.
|
|
||||||
func (s *Server) handleOAuthStart(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.auth == nil {
|
|
||||||
writeAPIError(w, auth.ErrSessionExpired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
provider := chi.URLParam(r, "provider")
|
|
||||||
redirect := r.URL.Query().Get("redirect")
|
|
||||||
authorizeURL, err := s.auth.OAuthAuthorizeURL(provider, redirect)
|
|
||||||
if err != nil {
|
|
||||||
writeAPIError(w, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
//nolint:gosec // адрес берётся из каталога провайдеров, пользователь его не задаёт
|
|
||||||
http.Redirect(w, r, authorizeURL, http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleOAuthCallback принимает код, находит или создаёт аккаунт и возвращает
|
|
||||||
// пользователя в клиент. Ошибки уходят в `?oauth_error=<код>`: браузер видит
|
|
||||||
// понятное сообщение на странице входа, а не JSON.
|
|
||||||
func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.auth == nil {
|
|
||||||
writeAPIError(w, auth.ErrSessionExpired)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
provider := chi.URLParam(r, "provider")
|
|
||||||
query := r.URL.Query()
|
|
||||||
_, token, session, redirect, err := s.auth.OAuthCallback(r.Context(), provider,
|
|
||||||
query.Get("code"), query.Get("state"),
|
|
||||||
httpx.ClientIPFromContext(r.Context()), httpx.UserAgentFromContext(r.Context()))
|
|
||||||
if err != nil {
|
|
||||||
code := oauthErrorCode(err)
|
|
||||||
s.logger.InfoContext(r.Context(), "вход через OAuth не удался",
|
|
||||||
slog.String("provider", provider), slog.String("code", code))
|
|
||||||
http.Redirect(w, r, "/login?oauth_error="+url.QueryEscape(code), http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
|
|
||||||
// Признак в адресе нужен клиенту, чтобы перечитать профиль после возврата.
|
|
||||||
target := redirect
|
|
||||||
if target == "" || target == "/" {
|
|
||||||
target = "/app"
|
|
||||||
}
|
|
||||||
separator := "?"
|
|
||||||
if strings.Contains(target, "?") {
|
|
||||||
separator = "&"
|
|
||||||
}
|
|
||||||
//nolint:gosec // target — только внутренний путь: его проверил safeRedirect
|
|
||||||
http.Redirect(w, r, target+separator+"oauth=ok", http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// oauthErrorCode переводит доменную ошибку в код для `?oauth_error=`.
|
|
||||||
func oauthErrorCode(err error) string {
|
|
||||||
for _, candidate := range []error{
|
|
||||||
auth.ErrOAuthNotConfigured,
|
|
||||||
auth.ErrOAuthUnknownProvider,
|
|
||||||
auth.ErrOAuthState,
|
|
||||||
auth.ErrOAuthEmailUnverified,
|
|
||||||
auth.ErrOAuthEmailMissing,
|
|
||||||
auth.ErrRegistrationOff,
|
|
||||||
auth.ErrUserBanned,
|
|
||||||
auth.ErrOAuthExchange,
|
|
||||||
} {
|
|
||||||
if errors.Is(err, candidate) {
|
|
||||||
return strings.TrimPrefix(candidate.Error(), "oauth.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "failed"
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestOAuthProviderNotConfigured — ручка входа через провайдера отвечает
|
|
||||||
// понятной ошибкой, пока оператор не задал OAUTH_* (Фаза 7).
|
|
||||||
func TestOAuthProviderNotConfigured(t *testing.T) {
|
|
||||||
srv, _ := newTestServer(t)
|
|
||||||
|
|
||||||
rec := doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/github/start", "")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("oauth start without config = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
|
|
||||||
t.Fatalf("error code = %q, want oauth.provider_not_configured", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/vk/start", "")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("unknown provider = %d, want 404", rec.Code)
|
|
||||||
}
|
|
||||||
if code := errorCodeOf(t, rec); code != "oauth.provider_unknown" {
|
|
||||||
t.Fatalf("error code = %q, want oauth.provider_unknown", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Список провайдеров пуст, клиент не рисует кнопки.
|
|
||||||
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/providers", "")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("providers list = %d, body = %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
var payload struct {
|
|
||||||
Providers []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"providers"`
|
|
||||||
RedirectURL string `json:"redirect_url"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
||||||
t.Fatalf("decode providers: %v", err)
|
|
||||||
}
|
|
||||||
if len(payload.Providers) != 0 {
|
|
||||||
t.Fatalf("providers = %s, want empty", rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Мета инстанса честно сообщает, что провайдеры выключены.
|
|
||||||
meta := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
|
||||||
if meta.Code != http.StatusOK {
|
|
||||||
t.Fatalf("meta = %d", meta.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(meta.Body.String(), `"oauth_enabled":false`) {
|
|
||||||
t.Fatalf("meta must report oauth_enabled=false: %s", meta.Body.String())
|
|
||||||
}
|
|
||||||
if !strings.Contains(meta.Body.String(), `"passkeys_enabled":true`) {
|
|
||||||
t.Fatalf("meta must report passkeys_enabled=true for a domain: %s", meta.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestOAuthCallbackRejectsUnsignedState — колбэк без валидного state
|
|
||||||
// возвращает пользователя на вход с кодом ошибки, а не создаёт сессию.
|
|
||||||
func TestOAuthCallbackRejectsUnsignedState(t *testing.T) {
|
|
||||||
srv, _ := newTestServer(t)
|
|
||||||
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
||||||
"/api/v1/auth/oauth/github/callback?code=abc&state=forged", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.Handler().ServeHTTP(rec, request)
|
|
||||||
if rec.Code != http.StatusFound {
|
|
||||||
t.Fatalf("callback with forged state = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
location := rec.Header().Get("Location")
|
|
||||||
if !strings.HasPrefix(location, "/login?oauth_error=") {
|
|
||||||
t.Fatalf("callback must redirect to the login page with a code, got %q", location)
|
|
||||||
}
|
|
||||||
if len(rec.Result().Cookies()) != 0 {
|
|
||||||
t.Fatal("failed oauth callback must not set a session cookie")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -71,10 +71,9 @@ type Server struct {
|
|||||||
uploadDailyLimiter *httpx.RateLimiter
|
uploadDailyLimiter *httpx.RateLimiter
|
||||||
// reactionLimiter — 20 реакций в минуту на пользователя.
|
// reactionLimiter — 20 реакций в минуту на пользователя.
|
||||||
reactionLimiter *httpx.RateLimiter
|
reactionLimiter *httpx.RateLimiter
|
||||||
// passkeyLimiter и oauthLimiter ограничивают новые ручки входа (Фаза 7):
|
// passkeyLimiter ограничивает ручки входа по ключу доступа (Фаза 7):
|
||||||
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
|
// 10 запросов в минуту на IP — с запасом на пару begin/finish.
|
||||||
passkeyLimiter *httpx.RateLimiter
|
passkeyLimiter *httpx.RateLimiter
|
||||||
oauthLimiter *httpx.RateLimiter
|
|
||||||
// pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами
|
// pushLimiter ограничивает подписки на Web Push, pushNotifyLimiter — сами
|
||||||
// уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6).
|
// уведомления на получателя (Фаза 7, AGENT.md 7.16, 8.6).
|
||||||
pushLimiter *httpx.RateLimiter
|
pushLimiter *httpx.RateLimiter
|
||||||
@@ -147,7 +146,6 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
|
uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
|
||||||
reactionLimiter: httpx.NewRateLimiter(20, 20),
|
reactionLimiter: httpx.NewRateLimiter(20, 20),
|
||||||
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||||
oauthLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
|
||||||
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
pushLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
|
||||||
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные
|
// Новые превью ссылок: 20 в минуту на пользователя (кэшированные
|
||||||
// ответы лимит не тратят).
|
// ответы лимит не тратят).
|
||||||
@@ -203,7 +201,6 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerAuthRoutes(apiRouter)
|
s.registerAuthRoutes(apiRouter)
|
||||||
if deps.Auth != nil {
|
if deps.Auth != nil {
|
||||||
s.registerPasskeyRoutes(apiRouter)
|
s.registerPasskeyRoutes(apiRouter)
|
||||||
s.registerOAuthRoutes(apiRouter)
|
|
||||||
}
|
}
|
||||||
if deps.Store != nil {
|
if deps.Store != nil {
|
||||||
s.registerUserRoutes(s.api)
|
s.registerUserRoutes(s.api)
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OAuthAccount — связь внешнего провайдера с пользователем инстанса.
|
|
||||||
// Сырого идентификатора и email здесь нет: только blind index (AGENT.md 9.2).
|
|
||||||
type OAuthAccount struct {
|
|
||||||
ID uint64
|
|
||||||
UserID uint64
|
|
||||||
Provider string
|
|
||||||
SubjectIndex string
|
|
||||||
EmailIndex string
|
|
||||||
CreatedAt time.Time
|
|
||||||
LastLoginAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// LinkOAuthAccount привязывает внешний аккаунт к пользователю. Повторная
|
|
||||||
// привязка того же провайдера к другому пользователю отклоняется уникальным
|
|
||||||
// индексом (provider, subject_index) — это защита от подмены владельца.
|
|
||||||
func (s *Store) LinkOAuthAccount(ctx context.Context, userID uint64, provider, subjectIndex, emailIndex string) (*OAuthAccount, error) {
|
|
||||||
id := s.NextID()
|
|
||||||
_, err := s.writer.ExecContext(ctx, `
|
|
||||||
INSERT INTO oauth_accounts (id, user_id, provider, subject_index, email_index, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
||||||
int64(id), int64(userID), provider, subjectIndex, emailIndex, s.Now())
|
|
||||||
if err != nil {
|
|
||||||
return nil, mapError(err)
|
|
||||||
}
|
|
||||||
return s.GetOAuthAccount(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetOAuthAccount(ctx context.Context, id uint64) (*OAuthAccount, error) {
|
|
||||||
row := s.reader.QueryRowContext(ctx, `
|
|
||||||
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
|
|
||||||
FROM oauth_accounts WHERE id = ?`, int64(id))
|
|
||||||
return scanOAuthAccount(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthAccountBySubject ищет привязку по blind index внешнего идентификатора.
|
|
||||||
func (s *Store) OAuthAccountBySubject(ctx context.Context, provider, subjectIndex string) (*OAuthAccount, error) {
|
|
||||||
row := s.reader.QueryRowContext(ctx, `
|
|
||||||
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
|
|
||||||
FROM oauth_accounts WHERE provider = ? AND subject_index = ?`, provider, subjectIndex)
|
|
||||||
return scanOAuthAccount(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListOAuthAccounts возвращает привязки пользователя: показываются в настройках
|
|
||||||
// безопасности, чтобы входы через провайдеров не были невидимыми.
|
|
||||||
func (s *Store) ListOAuthAccounts(ctx context.Context, userID uint64) ([]OAuthAccount, error) {
|
|
||||||
rows, err := s.reader.QueryContext(ctx, `
|
|
||||||
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
|
|
||||||
FROM oauth_accounts WHERE user_id = ? ORDER BY created_at`, int64(userID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
accounts := make([]OAuthAccount, 0, 2)
|
|
||||||
for rows.Next() {
|
|
||||||
account, err := scanOAuthAccount(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
accounts = append(accounts, *account)
|
|
||||||
}
|
|
||||||
return accounts, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TouchOAuthAccount отмечает вход через провайдера.
|
|
||||||
func (s *Store) TouchOAuthAccount(ctx context.Context, id uint64) error {
|
|
||||||
_, err := s.writer.ExecContext(ctx, `UPDATE oauth_accounts SET last_login_at = ? WHERE id = ?`, s.Now(), int64(id))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanOAuthAccount(scanner interface{ Scan(...any) error }) (*OAuthAccount, error) {
|
|
||||||
var (
|
|
||||||
account OAuthAccount
|
|
||||||
createdAt string
|
|
||||||
lastLoginAt sql.NullString
|
|
||||||
)
|
|
||||||
if err := scanner.Scan(&account.ID, &account.UserID, &account.Provider, &account.SubjectIndex,
|
|
||||||
&account.EmailIndex, &createdAt, &lastLoginAt); err != nil {
|
|
||||||
return nil, mapError(err)
|
|
||||||
}
|
|
||||||
account.CreatedAt = parseTimestamp(createdAt)
|
|
||||||
if lastLoginAt.Valid {
|
|
||||||
value := parseTimestamp(lastLoginAt.String)
|
|
||||||
account.LastLoginAt = &value
|
|
||||||
}
|
|
||||||
return &account, nil
|
|
||||||
}
|
|
||||||
+22
-5
@@ -63,12 +63,22 @@ export async function postWithRetry(
|
|||||||
api: APIRequestContext,
|
api: APIRequestContext,
|
||||||
path: string,
|
path: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
attempts = 6,
|
attempts = 8,
|
||||||
): Promise<Awaited<ReturnType<APIRequestContext['post']>>> {
|
): Promise<Awaited<ReturnType<APIRequestContext['post']>>> {
|
||||||
let response = await api.post(path, { data });
|
let response = await api.post(path, { data });
|
||||||
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
|
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
|
||||||
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number };
|
// Пауза лежит в конверте ошибки (§8.5: {"error":{"retry_after_ms":…}}), а не
|
||||||
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 500), 20_000);
|
// в корне тела: без `error` хелпер ждал фиксированную секунду и сдавался
|
||||||
|
// раньше, чем сервер разрешал повтор — на свежем инстансе регистрация
|
||||||
|
// пробных аккаунтов из-за этого падала (VPS, 27.09.2026).
|
||||||
|
const payload = (await response.json().catch(() => ({}))) as {
|
||||||
|
retry_after_ms?: number;
|
||||||
|
error?: { retry_after_ms?: number };
|
||||||
|
};
|
||||||
|
const wait = Math.min(
|
||||||
|
Math.max(payload.error?.retry_after_ms ?? payload.retry_after_ms ?? 1_000, 500),
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, wait + 200));
|
await new Promise((resolve) => setTimeout(resolve, wait + 200));
|
||||||
response = await api.post(path, { data });
|
response = await api.post(path, { data });
|
||||||
}
|
}
|
||||||
@@ -359,9 +369,16 @@ export async function repeat429(
|
|||||||
): Promise<APIResponse> {
|
): Promise<APIResponse> {
|
||||||
let response = await request();
|
let response = await request();
|
||||||
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
|
for (let attempt = 1; attempt < attempts && response.status() === 429; attempt += 1) {
|
||||||
const payload = (await response.json().catch(() => ({}))) as { retry_after_ms?: number };
|
const payload = (await response.json().catch(() => ({}))) as {
|
||||||
|
retry_after_ms?: number;
|
||||||
|
error?: { retry_after_ms?: number };
|
||||||
|
};
|
||||||
// Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту.
|
// Ждём столько, сколько просит сервер: лимит — 120 запросов в минуту.
|
||||||
const wait = Math.min(Math.max(payload.retry_after_ms ?? 1_000, 1_000), 30_000);
|
// Пауза лежит в конверте ошибки (§8.5), поэтому читаем и `error`.
|
||||||
|
const wait = Math.min(
|
||||||
|
Math.max(payload.error?.retry_after_ms ?? payload.retry_after_ms ?? 1_000, 1_000),
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, wait + 250));
|
await new Promise((resolve) => setTimeout(resolve, wait + 250));
|
||||||
response = await request();
|
response = await request();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,12 @@ test.describe('голос: два клиента в одной комнате',
|
|||||||
await session.page
|
await session.page
|
||||||
.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true })
|
.getByRole('link', { name: `Открыть сервер ${fixture.guildName}`, exact: true })
|
||||||
.click();
|
.click();
|
||||||
await session.page.getByRole('link', { name: /Открыть комнату Голос/u }).click();
|
// Имя комнаты — точно: пока сайдбар дорисовывает новый сервер, в нём
|
||||||
|
// могут быть видны комнаты предыдущего (например, «Голос A»/«Голос B»
|
||||||
|
// служебных проверок), и нечёткий локатор ловил бы две ссылки.
|
||||||
|
await session.page
|
||||||
|
.getByRole('link', { name: 'Открыть комнату Голос', exact: true })
|
||||||
|
.click();
|
||||||
await joinVoice(session.page);
|
await joinVoice(session.page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export interface InstanceFeatures {
|
|||||||
anti_bot_enabled: boolean;
|
anti_bot_enabled: boolean;
|
||||||
voice_enabled: boolean;
|
voice_enabled: boolean;
|
||||||
web_push_enabled: boolean;
|
web_push_enabled: boolean;
|
||||||
oauth_enabled: boolean;
|
|
||||||
passkeys_enabled: boolean;
|
passkeys_enabled: boolean;
|
||||||
/** Превью ссылок в сообщениях (Фаза 7): карточку готовит сервер. */
|
/** Превью ссылок в сообщениях (Фаза 7): карточку готовит сервер. */
|
||||||
unfurl_enabled: boolean;
|
unfurl_enabled: boolean;
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { request } from './client';
|
|
||||||
|
|
||||||
/** Провайдер входа, включённый на инстансе (AGENT.md 7.1, Фаза 7). */
|
|
||||||
export interface OAuthProvider {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OAuthProvidersResponse {
|
|
||||||
providers: OAuthProvider[];
|
|
||||||
/** Адрес возврата: его оператор прописывает в настройках приложения. */
|
|
||||||
redirect_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchOAuthProviders(signal?: AbortSignal): Promise<OAuthProvidersResponse> {
|
|
||||||
return request<OAuthProvidersResponse>(
|
|
||||||
'/auth/oauth/providers',
|
|
||||||
signal === undefined ? {} : { signal },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ссылка входа через провайдера. Браузер уходит по ней целиком (обычная
|
|
||||||
* навигация): cookie сессии выставляет сервер после возврата.
|
|
||||||
*/
|
|
||||||
export function oauthStartURL(provider: string, redirect?: string): string {
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (redirect !== undefined && redirect !== '') {
|
|
||||||
query.set('redirect', redirect);
|
|
||||||
}
|
|
||||||
const suffix = query.toString();
|
|
||||||
return `/api/v1/auth/oauth/${encodeURIComponent(provider)}/start${suffix === '' ? '' : `?${suffix}`}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Код ошибки возврата (`?oauth_error=`), если вход через провайдера не удался. */
|
|
||||||
export function oauthErrorCode(value: string | null): string | null {
|
|
||||||
if (value === null || value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
@@ -81,12 +81,6 @@
|
|||||||
"auth.passkey_unknown": "This key is not registered on the account.",
|
"auth.passkey_unknown": "This key is not registered on the account.",
|
||||||
"auth.passkey_name_invalid": "Enter a key name (up to 64 characters).",
|
"auth.passkey_name_invalid": "Enter a key name (up to 64 characters).",
|
||||||
"auth.passkey_limit": "The account reached its passkey limit.",
|
"auth.passkey_limit": "The account reached its passkey limit.",
|
||||||
"oauth.provider_not_configured": "The provider is not configured on this instance.",
|
|
||||||
"oauth.provider_unknown": "Unknown sign-in provider.",
|
|
||||||
"oauth.state_invalid": "The sign-in link expired. Start again.",
|
|
||||||
"oauth.email_unverified": "The provider did not confirm the email — sign-in is impossible.",
|
|
||||||
"oauth.email_missing": "The provider returned no email — sign-in is impossible.",
|
|
||||||
"oauth.exchange_failed": "The provider rejected the request. Try later.",
|
|
||||||
"permissions.denied": "You do not have permission for this action.",
|
"permissions.denied": "You do not have permission for this action.",
|
||||||
"instance.admin_required": "This action is available to the instance admin only.",
|
"instance.admin_required": "This action is available to the instance admin only.",
|
||||||
"guild.not_found": "Server not found.",
|
"guild.not_found": "Server not found.",
|
||||||
@@ -194,14 +188,7 @@
|
|||||||
"passkeySubmit": "Sign in with a passkey",
|
"passkeySubmit": "Sign in with a passkey",
|
||||||
"passkeyPending": "Waiting for the key…",
|
"passkeyPending": "Waiting for the key…",
|
||||||
"passkeyCancelled": "Passkey sign-in was cancelled or timed out. Try again.",
|
"passkeyCancelled": "Passkey sign-in was cancelled or timed out. Try again.",
|
||||||
"passkeyUnsupported": "This browser does not support passkeys. Sign in with your password.",
|
"passkeyUnsupported": "This browser does not support passkeys. Sign in with your password."
|
||||||
"oauthHint": "Or sign in with an external service:",
|
|
||||||
"oauthSubmit": "Sign in with {{provider}}",
|
|
||||||
"oauthDone": "Signed in. Finishing…",
|
|
||||||
"oauthFailed": "External sign-in failed. Try again or use your password.",
|
|
||||||
"oauthNotConfigured": "This sign-in method is disabled on the instance. Use your password.",
|
|
||||||
"oauthEmailUnverified": "The provider did not confirm your email — sign-in is impossible.",
|
|
||||||
"oauthEmailMissing": "The provider returned no email — use your password or contact the administrator."
|
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Create account",
|
"title": "Create account",
|
||||||
@@ -900,11 +887,7 @@
|
|||||||
"passkeysTimeout": "The authenticator did not respond in time. Try again.",
|
"passkeysTimeout": "The authenticator did not respond in time. Try again.",
|
||||||
"passkeysAlreadyRegistered": "This key is already registered on the account.",
|
"passkeysAlreadyRegistered": "This key is already registered on the account.",
|
||||||
"passkeysFailed": "Could not create the key. Try another authenticator.",
|
"passkeysFailed": "Could not create the key. Try another authenticator.",
|
||||||
"passkeysNameRequired": "Enter a key name (up to 64 characters).",
|
"passkeysNameRequired": "Enter a key name (up to 64 characters)."
|
||||||
"oauthTitle": "External sign-in providers",
|
|
||||||
"oauthDescription": "Providers are enabled by the instance administrator. An account is linked automatically on sign-in when the provider confirms the email.",
|
|
||||||
"oauthDisabled": "No external providers are configured on this instance.",
|
|
||||||
"oauthLinkedByEmail": "sign-in via confirmed email"
|
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
|
|||||||
@@ -81,12 +81,6 @@
|
|||||||
"auth.passkey_unknown": "Этот ключ не зарегистрирован на аккаунте.",
|
"auth.passkey_unknown": "Этот ключ не зарегистрирован на аккаунте.",
|
||||||
"auth.passkey_name_invalid": "Укажите название ключа (до 64 символов).",
|
"auth.passkey_name_invalid": "Укажите название ключа (до 64 символов).",
|
||||||
"auth.passkey_limit": "Достигнут предел числа ключей на аккаунте.",
|
"auth.passkey_limit": "Достигнут предел числа ключей на аккаунте.",
|
||||||
"oauth.provider_not_configured": "Провайдер не настроен на инстансе.",
|
|
||||||
"oauth.provider_unknown": "Неизвестный провайдер входа.",
|
|
||||||
"oauth.state_invalid": "Ссылка входа устарела. Начните вход заново.",
|
|
||||||
"oauth.email_unverified": "Провайдер не подтвердил почту — вход невозможен.",
|
|
||||||
"oauth.email_missing": "Провайдер не отдал почту — вход невозможен.",
|
|
||||||
"oauth.exchange_failed": "Провайдер отклонил запрос. Попробуйте позже.",
|
|
||||||
"permissions.denied": "Недостаточно прав для этого действия.",
|
"permissions.denied": "Недостаточно прав для этого действия.",
|
||||||
"instance.admin_required": "Действие доступно только администратору инстанса.",
|
"instance.admin_required": "Действие доступно только администратору инстанса.",
|
||||||
"guild.not_found": "Сервер не найден.",
|
"guild.not_found": "Сервер не найден.",
|
||||||
@@ -194,14 +188,7 @@
|
|||||||
"passkeySubmit": "Войти по ключу доступа",
|
"passkeySubmit": "Войти по ключу доступа",
|
||||||
"passkeyPending": "Ждём ключ…",
|
"passkeyPending": "Ждём ключ…",
|
||||||
"passkeyCancelled": "Вход по ключу отменён или истёк таймаут. Попробуйте снова.",
|
"passkeyCancelled": "Вход по ключу отменён или истёк таймаут. Попробуйте снова.",
|
||||||
"passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем.",
|
"passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем."
|
||||||
"oauthHint": "Или войдите через внешний сервис:",
|
|
||||||
"oauthSubmit": "Войти через {{provider}}",
|
|
||||||
"oauthDone": "Вход выполнен. Завершаем…",
|
|
||||||
"oauthFailed": "Вход через внешний сервис не удался. Попробуйте ещё раз или войдите с паролем.",
|
|
||||||
"oauthNotConfigured": "Этот способ входа выключен на инстансе. Войдите с паролем.",
|
|
||||||
"oauthEmailUnverified": "Внешний сервис не подтвердил вашу почту — вход невозможен.",
|
|
||||||
"oauthEmailMissing": "Внешний сервис не отдал почту — войдите с паролем или обратитесь к администратору."
|
|
||||||
},
|
},
|
||||||
"register": {
|
"register": {
|
||||||
"title": "Регистрация",
|
"title": "Регистрация",
|
||||||
@@ -900,11 +887,7 @@
|
|||||||
"passkeysTimeout": "Аутентификатор не ответил вовремя. Попробуйте снова.",
|
"passkeysTimeout": "Аутентификатор не ответил вовремя. Попробуйте снова.",
|
||||||
"passkeysAlreadyRegistered": "Этот ключ уже зарегистрирован на аккаунте.",
|
"passkeysAlreadyRegistered": "Этот ключ уже зарегистрирован на аккаунте.",
|
||||||
"passkeysFailed": "Не удалось создать ключ. Попробуйте другой аутентификатор.",
|
"passkeysFailed": "Не удалось создать ключ. Попробуйте другой аутентификатор.",
|
||||||
"passkeysNameRequired": "Укажите название ключа (до 64 символов).",
|
"passkeysNameRequired": "Укажите название ключа (до 64 символов)."
|
||||||
"oauthTitle": "Вход через внешние сервисы",
|
|
||||||
"oauthDescription": "Провайдеры включаются администратором инстанса. Аккаунт привязывается автоматически при входе, если почта подтверждена провайдером.",
|
|
||||||
"oauthDisabled": "На этом инстансе внешние провайдеры не настроены.",
|
|
||||||
"oauthLinkedByEmail": "вход по подтверждённой почте"
|
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Внешний вид",
|
"title": "Внешний вид",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
|
|||||||
|
|
||||||
import { login } from '@/api/auth';
|
import { login } from '@/api/auth';
|
||||||
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
import { fetchMeta, metaQueryKey } from '@/api/meta';
|
||||||
import { fetchOAuthProviders, oauthStartURL } from '@/api/oauth';
|
|
||||||
import { loginWithPasskey } from '@/api/passkeys';
|
import { loginWithPasskey } from '@/api/passkeys';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field } from '@/components/ui/Field';
|
import { Field } from '@/components/ui/Field';
|
||||||
@@ -46,13 +45,6 @@ export default function LoginPage() {
|
|||||||
const stateFrom = (location.state as LocationState | null)?.from ?? null;
|
const stateFrom = (location.state as LocationState | null)?.from ?? null;
|
||||||
const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app');
|
const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app');
|
||||||
|
|
||||||
const oauthReturned = searchParams.get('oauth');
|
|
||||||
const oauthError = searchParams.get('oauth_error');
|
|
||||||
const providers = useQuery({
|
|
||||||
queryKey: ['auth', 'oauth', 'providers'],
|
|
||||||
queryFn: ({ signal }) => fetchOAuthProviders(signal),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
// Признак доступности passkeys берём из меты инстанса: на IP-адресе (стенд
|
// Признак доступности passkeys берём из меты инстанса: на IP-адресе (стенд
|
||||||
// без домена) WebAuthn невозможен, и кнопку показывать нельзя.
|
// без домена) WebAuthn невозможен, и кнопку показывать нельзя.
|
||||||
const meta = useQuery({
|
const meta = useQuery({
|
||||||
@@ -97,24 +89,8 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const registrationEnabled = instance.data?.registration_enabled === true;
|
const registrationEnabled = instance.data?.registration_enabled === true;
|
||||||
const providerList = providers.data?.providers ?? [];
|
|
||||||
|
|
||||||
// Ошибка возврата от провайдера: показываем человеческий текст.
|
// Ошибка возврата от провайдера: показываем человеческий текст.
|
||||||
const oauthErrorMessage =
|
|
||||||
oauthError === null
|
|
||||||
? null
|
|
||||||
: t(
|
|
||||||
oauthError === 'provider_not_configured'
|
|
||||||
? 'auth.login.oauthNotConfigured'
|
|
||||||
: oauthError === 'email_unverified'
|
|
||||||
? 'auth.login.oauthEmailUnverified'
|
|
||||||
: oauthError === 'email_missing'
|
|
||||||
? 'auth.login.oauthEmailMissing'
|
|
||||||
: oauthError === 'user.banned'
|
|
||||||
? 'errors.user.banned'
|
|
||||||
: 'auth.login.oauthFailed',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ошибка клиента WebAuthn важнее серверной: она объясняет, что делать.
|
// Ошибка клиента WebAuthn важнее серверной: она объясняет, что делать.
|
||||||
const passkeyErrorKey =
|
const passkeyErrorKey =
|
||||||
passkey.error instanceof WebAuthnClientError && passkey.error.code === 'cancelled'
|
passkey.error instanceof WebAuthnClientError && passkey.error.code === 'cancelled'
|
||||||
@@ -131,17 +107,6 @@ export default function LoginPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
{oauthReturned === 'ok' ? (
|
|
||||||
<p className="mb-4 text-sm text-success" role="status">
|
|
||||||
{t('auth.login.oauthDone')}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{oauthErrorMessage === null ? null : (
|
|
||||||
<p className="mb-4 text-sm text-danger" role="alert" data-testid="oauth-error">
|
|
||||||
{oauthErrorMessage}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
<form className="flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||||
<Field
|
<Field
|
||||||
label={t('auth.login.email')}
|
label={t('auth.login.email')}
|
||||||
@@ -209,24 +174,6 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{providerList.length === 0 ? null : (
|
|
||||||
<div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-4">
|
|
||||||
<p className="text-xs text-fg-muted">{t('auth.login.oauthHint')}</p>
|
|
||||||
{providerList.map((provider) => (
|
|
||||||
<Button
|
|
||||||
key={provider.id}
|
|
||||||
variant="ghost"
|
|
||||||
data-testid={`oauth-${provider.id}`}
|
|
||||||
onClick={() => {
|
|
||||||
window.location.assign(oauthStartURL(provider.id, from));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('auth.login.oauthSubmit', { provider: provider.name })}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-4 text-sm text-fg-muted">
|
<p className="mt-4 text-sm text-fg-muted">
|
||||||
{t('auth.login.noAccount')}{' '}
|
{t('auth.login.noAccount')}{' '}
|
||||||
{registrationEnabled ? (
|
{registrationEnabled ? (
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import { fetchOAuthProviders } from '@/api/oauth';
|
|
||||||
import { Card } from '@/components/ui/primitives';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Вход через внешние провайдеры (Фаза 7). Раздел только показывает, какие
|
|
||||||
* провайдеры включены на инстансе: привязка происходит при входе по
|
|
||||||
* подтверждённому email, а отвязка — действие оператора/администратора.
|
|
||||||
*/
|
|
||||||
export function LinkedAccountsSection() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const providers = useQuery({
|
|
||||||
queryKey: ['auth', 'oauth', 'providers'],
|
|
||||||
queryFn: ({ signal }) => fetchOAuthProviders(signal),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
const enabled = providers.data?.providers ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<h2 className="text-lg font-semibold">{t('settings.security.oauthTitle')}</h2>
|
|
||||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.security.oauthDescription')}</p>
|
|
||||||
|
|
||||||
{enabled.length === 0 ? (
|
|
||||||
<p className="mt-3 text-sm text-fg-muted" data-testid="oauth-none">
|
|
||||||
{t('settings.security.oauthDisabled')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="mt-3 flex flex-col gap-2" data-testid="oauth-providers">
|
|
||||||
{enabled.map((provider) => (
|
|
||||||
<li
|
|
||||||
key={provider.id}
|
|
||||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-border/60 bg-surface-2/60 p-3 text-sm"
|
|
||||||
>
|
|
||||||
<span>{provider.name}</span>
|
|
||||||
<span className="text-xs text-fg-muted">
|
|
||||||
{t('settings.security.oauthLinkedByEmail')}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { currentUserQueryKey, fetchCurrentUser } from '@/api/users';
|
import { currentUserQueryKey, fetchCurrentUser } from '@/api/users';
|
||||||
import { LinkedAccountsSection } from '@/pages/settings/LinkedAccountsSection';
|
|
||||||
import { PasskeysSection } from '@/pages/settings/PasskeysSection';
|
import { PasskeysSection } from '@/pages/settings/PasskeysSection';
|
||||||
import { PasswordSection } from '@/pages/settings/PasswordSection';
|
import { PasswordSection } from '@/pages/settings/PasswordSection';
|
||||||
import { SessionsSection } from '@/pages/settings/SessionsSection';
|
import { SessionsSection } from '@/pages/settings/SessionsSection';
|
||||||
@@ -39,7 +38,6 @@ export default function SecuritySettingsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<PasskeysSection twoFactorEnabled={twoFactorEnabled} />
|
<PasskeysSection twoFactorEnabled={twoFactorEnabled} />
|
||||||
<LinkedAccountsSection />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ const meta: InstanceMeta = {
|
|||||||
web_push_enabled: false,
|
web_push_enabled: false,
|
||||||
unfurl_enabled: false,
|
unfurl_enabled: false,
|
||||||
passkeys_enabled: false,
|
passkeys_enabled: false,
|
||||||
oauth_enabled: false,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -310,13 +310,11 @@ const metaWithPasskeys = {
|
|||||||
voice_enabled: false,
|
voice_enabled: false,
|
||||||
web_push_enabled: false,
|
web_push_enabled: false,
|
||||||
unfurl_enabled: false,
|
unfurl_enabled: false,
|
||||||
oauth_enabled: true,
|
|
||||||
passkeys_enabled: true,
|
passkeys_enabled: true,
|
||||||
oauth_providers: ['github'],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('страница входа: ключи и внешние провайдеры', () => {
|
describe('страница входа: ключи доступа', () => {
|
||||||
it('входит по ключу и переводит в приложение', async () => {
|
it('входит по ключу и переводит в приложение', async () => {
|
||||||
const assertion = {
|
const assertion = {
|
||||||
id: 'credential-id',
|
id: 'credential-id',
|
||||||
@@ -359,11 +357,6 @@ describe('страница входа: ключи и внешние провай
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
response: () => json({ user }),
|
response: () => json({ user }),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
match: '/api/v1/auth/oauth/providers',
|
|
||||||
response: () =>
|
|
||||||
json({ providers: [{ id: 'github', name: 'GitHub' }], redirect_url: 'https://x' }),
|
|
||||||
},
|
|
||||||
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
]);
|
]);
|
||||||
@@ -371,8 +364,6 @@ describe('страница входа: ключи и внешние провай
|
|||||||
const { router } = renderApp('/login');
|
const { router } = renderApp('/login');
|
||||||
const visitor = userEvent.setup();
|
const visitor = userEvent.setup();
|
||||||
|
|
||||||
// Кнопка внешнего провайдера нарисована по списку с сервера.
|
|
||||||
expect(await screen.findByTestId('oauth-github')).toBeVisible();
|
|
||||||
await visitor.click(await screen.findByTestId('passkey-login'));
|
await visitor.click(await screen.findByTestId('passkey-login'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -382,39 +373,11 @@ describe('страница входа: ключи и внешние провай
|
|||||||
expect((finish?.body as { ceremony: string }).ceremony).toBe('ceremony-login');
|
expect((finish?.body as { ceremony: string }).ceremony).toBe('ceremony-login');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ошибка возврата от провайдера показывается на странице входа', async () => {
|
|
||||||
installFetch([
|
|
||||||
{
|
|
||||||
match: '/api/v1/meta',
|
|
||||||
response: () =>
|
|
||||||
json({
|
|
||||||
...metaWithPasskeys,
|
|
||||||
features: { ...metaWithPasskeys.features, passkeys_enabled: false },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
|
||||||
{
|
|
||||||
match: '/api/v1/auth/oauth/providers',
|
|
||||||
response: () => json({ providers: [], redirect_url: 'https://x' }),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
renderApp('/login?oauth_error=provider_not_configured');
|
|
||||||
|
|
||||||
expect(await screen.findByTestId('oauth-error')).toHaveTextContent(
|
|
||||||
'Этот способ входа выключен на инстансе',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('без поддержки браузера кнопка входа по ключу не показывается', async () => {
|
it('без поддержки браузера кнопка входа по ключу не показывается', async () => {
|
||||||
vi.stubGlobal('PublicKeyCredential', undefined);
|
vi.stubGlobal('PublicKeyCredential', undefined);
|
||||||
installFetch([
|
installFetch([
|
||||||
{ match: '/api/v1/meta', response: () => json(metaWithPasskeys) },
|
{ match: '/api/v1/meta', response: () => json(metaWithPasskeys) },
|
||||||
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
{ match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) },
|
||||||
{
|
|
||||||
match: '/api/v1/auth/oauth/providers',
|
|
||||||
response: () => json({ providers: [], redirect_url: 'https://x' }),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderApp('/login');
|
renderApp('/login');
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ const enabledMeta: InstanceMeta = {
|
|||||||
web_push_enabled: false,
|
web_push_enabled: false,
|
||||||
unfurl_enabled: false,
|
unfurl_enabled: false,
|
||||||
passkeys_enabled: false,
|
passkeys_enabled: false,
|
||||||
oauth_enabled: false,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user