вход переехал на доверенный заголовок Authelia вместо собственного OIDC

- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
av
2026-08-22 20:24:22 +03:00
parent e4441f3c49
commit 7f33c957e5
63 changed files with 5257 additions and 2305 deletions
+15 -10
View File
@@ -17,14 +17,15 @@ interface ErrorBody {
}
/**
* Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи:
* экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт,
* иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение →
* вход → приложение».
* Исход запроса. «Сервис вас не узнал» — свой исход, а не разновидность
* неудачи: человек по нему делает не то, что по «сервис не отвечает». Прежде
* этот исход уводил ко входу; уводить стало некуда — своего входа у сервиса
* нет, а заголовок ставит обратный прокси, — и различие сохраняется ради
* текста, а не ради перехода.
*/
export type ApiResult<T> =
| { status: 'ok'; value: T }
| { status: 'unauthorized' }
| { status: 'unrecognized'; message: string }
| { status: 'failed'; message: string }
const noConnectionMessage = 'Связи нет'
@@ -54,7 +55,11 @@ async function request<T>(path: string): Promise<ApiResult<T>> {
}
if (response.status === 401) {
return { status: 'unauthorized' }
// Текст берётся у сервера, а не сочиняется здесь: единая форма отказа —
// обязанность API, и второй словарь на клиенте разошёлся бы с первым.
// Свой исход у этого кода всё равно есть: «сервис вас не узнал» и «сервис
// не отвечает» ведут человека к разному.
return { status: 'unrecognized', message: await failureMessage(response) }
}
if (!response.ok) {
@@ -68,10 +73,10 @@ async function request<T>(path: string): Promise<ApiResult<T>> {
}
}
/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */
/**
* Спрашивает сервис, кто пришёл. Заголовок ставит обратный прокси, и приложение
* его не видит вовсе — этот адрес единственный способ узнать вошедшего.
*/
export function fetchMe(): Promise<ApiResult<Me>> {
return request<Me>('/me')
}
/** Адрес, которым сервис уводит человека ко входу. */
export const loginPath = '/auth/login'
+38 -7
View File
@@ -4,6 +4,10 @@ import HomeScreen from './HomeScreen.vue'
// Экран судится по тому, что видит человек, а не по внутреннему состоянию:
// переписанная реализация обязана остаться зелёной.
//
// Переход к адресу входа проверяется тем, что его **нет**: своего входа у
// сервиса не осталось, и экран, снова начавший куда-то уводить, обязан упасть
// здесь, а не обнаружиться пустой страницей у человека.
const assign = vi.fn()
@@ -23,7 +27,7 @@ afterEach(() => {
})
describe('экран показывает вошедшего', () => {
it('показывает имя, когда сессия есть', async () => {
it('показывает имя, когда сервис узнал пришедшего', async () => {
answerWith({
ok: true,
status: 200,
@@ -37,16 +41,43 @@ describe('экран показывает вошедшего', () => {
expect(assign).not.toHaveBeenCalled()
})
it('ведёт ко входу, когда сессии нет', async () => {
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
it('показывает текст сервера, когда тот не узнал пришедшего, и никуда не уводит', async () => {
answerWith({
ok: false,
status: 401,
json: () => Promise.resolve({ error_code: 'unauthorized', message: 'Сервис вас не узнал' }),
})
mount(HomeScreen)
const screen = mount(HomeScreen)
await flushPromises()
expect(assign).toHaveBeenCalledWith('/auth/login')
expect(screen.text()).toContain('Сервис вас не узнал')
expect(assign).not.toHaveBeenCalled()
})
it('на прочий отказ показывает строку и ко входу не ведёт', async () => {
it('на неузнавание без текста сервера показывает общее, а не пустоту', async () => {
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Сервис не отвечает как надо')
})
it('на неразобранное тело показывает строку, а не падает', async () => {
answerWith({
ok: true,
status: 200,
json: () => Promise.reject(new SyntaxError('not json')),
})
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Сервис не отвечает как надо')
})
it('прочий отказ отличается от неузнавания', async () => {
answerWith({
ok: false,
status: 500,
@@ -70,7 +101,7 @@ describe('экран показывает вошедшего', () => {
expect(assign).not.toHaveBeenCalled()
})
it('учётная запись без имени: вход выполнен, почты на экране нет', async () => {
it('учётная запись без имени: человек узнан, почты на экране нет', async () => {
answerWith({
ok: true,
status: 200,
+12 -6
View File
@@ -1,19 +1,23 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchMe, loginPath } from '../api'
import { fetchMe } from '../api'
// Состояние экрана живёт в экране: общего между экранами пока нет.
const state = ref<'loading' | 'signed-in' | 'failed'>('loading')
const state = ref<'loading' | 'signed-in' | 'unrecognized' | 'failed'>('loading')
const name = ref('')
const failure = ref('')
const unrecognized = ref('')
async function load() {
const result = await fetchMe()
if (result.status === 'unauthorized') {
// Единственная ветка, уводящая ко входу. Прочие отказы ведут сюда же
// только через круг «приложение → вход → приложение», поэтому их здесь нет.
window.location.assign(loginPath)
if (result.status === 'unrecognized') {
// Уводить некуда: своего входа у сервиса нет, а имя пришедшего ставит
// обратный прокси. Показываем строкой — той, что прислал сервер, — и
// отдельной от прочей неудачи: «вас не узнали» и «сервис не отвечает»
// ведут человека к разному.
unrecognized.value = result.message
state.value = 'unrecognized'
return
}
@@ -36,6 +40,8 @@ onMounted(load)
<p v-if="state === 'loading'">Загружается</p>
<p v-else-if="state === 'unrecognized'">{{ unrecognized }}</p>
<p v-else-if="state === 'failed'">{{ failure }}</p>
<!-- Имени у учётной записи может не быть вовсе: тогда говорим, что вход