${heading}
${text}
${title}
${subtitle}
${form}diff --git a/.env.example b/.env.example index f1ea8aa..a62ed85 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,6 @@ AUTHENTIK_BOOTSTRAP_PASSWORD=change-me-admin-password AUTHENTIK_BOOTSTRAP_TOKEN=change-me-bootstrap-token APP_URL=http://localhost:3000 -SESSION_SECRET=change-me-session-secret AUTHENTIK_PUBLIC_URL=http://localhost:9000 AUTHENTIK_INTERNAL_URL=http://host.docker.internal:9000 @@ -23,4 +22,5 @@ OIDC_CLIENT_ID=mm-custom-frontend OIDC_CLIENT_SECRET=change-me-oidc-client-secret OIDC_ISSUER=http://localhost:9000/application/o/mm-custom-frontend/ OIDC_REDIRECT_URI=http://localhost:3000/auth/callback +COOKIE_SECURE=false AUTH_DEBUG=false diff --git a/README.md b/README.md index fd17b21..f32cec6 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,40 @@ -# Million Miles Auth +# Million Miles Authentik POC -Локальный POC с `Authentik` и кастомным frontend: +POC с кастомными формами `login/register`, где `Authentik` используется только как backend IdP. -- регистрация пользователя на своей форме -- вход пользователя на своей форме -- Authentik используется как backend авторизации -- после входа frontend показывает данные из `id_token` +## Что реализовано -## Что в проекте +- `/login` — кастомная форма `Email / Password / Войти` +- `/register` — кастомная форма `Full name / Email / Password / Confirm password` +- `Authentik` используется как backend для пользователей, паролей и OIDC +- наше приложение не хранит пароль и не хранит password hash +- регистрация создаёт пользователя через `Authentik API` +- login выполняется backend-to-backend через `Authentik flow/executor API` +- после login backend получает `id_token` и `access_token` от `Authentik` +- токены не хранятся в `localStorage` или `sessionStorage` +- токены хранятся только в `HttpOnly` cookie с `SameSite=Lax` +- `/me` возвращает claims из JWT после валидации токена на backend +- `/dashboard` показывает claims из `/me`, без вывода raw JWT -- `docker-compose.yml` поднимает `postgresql`, `redis`, `authentik`, `authentik-worker` и `frontend` -- `authentik/blueprints/mm-custom-authentication-flow.yaml` создаёт flow, provider и application -- `frontend/server.js` содержит UI и логику регистрации/входа +## Что вне scope + +- sessions UI +- consent UI +- MFA devices UI +- connected services +- app passwords ## Порты +Порты проброшены только на localhost: + - frontend: `http://localhost:3000` - Authentik admin: `http://localhost:9000` ## Быстрый старт - -```bash -docker compose up --build -``` - -Если нужно полностью пересобрать проект с чистыми данными: - -```bash -docker compose down -v -docker compose up --build -``` - -Первый запуск Authentik может занять несколько минут. Нужно дождаться, пока `authentik-worker` применит blueprint. - -## Удобный локальный `.env` для теста - -Для быстрого ручного тестирования можно использовать такие локальные значения в `.env`: +1. Скопируй `.env.example` в `.env` +2. Для локального теста можно использовать такие значения: ```env AUTHENTIK_IMAGE=docker.io/authentik/server @@ -52,43 +50,61 @@ AUTHENTIK_BOOTSTRAP_PASSWORD=admin123456 AUTHENTIK_BOOTSTRAP_TOKEN=local-bootstrap-token-change-me APP_URL=http://localhost:3000 -SESSION_SECRET=local-session-secret-change-me AUTHENTIK_PUBLIC_URL=http://localhost:9000 AUTHENTIK_INTERNAL_URL=http://host.docker.internal:9000 AUTHENTIK_API_TOKEN=local-bootstrap-token-change-me - AUTHENTIK_LOGIN_FLOW=mm-custom-authentication-flow OIDC_CLIENT_ID=mm-custom-frontend OIDC_CLIENT_SECRET=mm-custom-secret OIDC_ISSUER=http://localhost:9000/application/o/mm-custom-frontend/ OIDC_REDIRECT_URI=http://localhost:3000/auth/callback + +COOKIE_SECURE=false AUTH_DEBUG=false ``` +3. Запусти проект: + +```bash +docker compose up --build +``` + +Если нужен полностью чистый запуск: + +```bash +docker compose down -v +docker compose up --build +``` + ## Как зайти в админку Authentik +Открой: ```txt http://localhost:9000 ``` -Логин администратора берётся из `.env`: - -- email: значение `AUTHENTIK_BOOTSTRAP_EMAIL` -- password: значение `AUTHENTIK_BOOTSTRAP_PASSWORD` - -Или +Для локального теста из примера выше: ```txt -admin@millionmiles.local -admin123456 +email: admin@millionmiles.local +password: admin123456 ``` -## Что создаётся автоматически +## Как это работает -Blueprint создаёт: +1. Регистрация пользователя идёт через `Authentik API` +2. Пароль хранится в `Authentik`, не в нашем приложении +3. Login идёт через кастомную форму на нашем frontend +4. Backend проверяет логин/пароль через `Authentik flow/executor API` +5. После успешного login backend получает `id_token` и `access_token` от `Authentik` +6. Токены кладутся в `HttpOnly` cookie +7. `/me` читает токен из cookie, валидирует JWT и возвращает claims JSON +8. `/dashboard` показывает именно эти claims + +## Что создаёт blueprint ```txt Flow: Million Miles Custom Authentication @@ -101,17 +117,26 @@ Client Secret: mm-custom-secret Redirect URI: http://localhost:3000/auth/callback ``` -## Как быстро всё протестировать +## Ручная проверка 1. Открой `http://localhost:3000/register` 2. Зарегистрируй нового пользователя -3. После регистрации тебя перекинет на `/login` -4. Войди с только что созданными `email/password` -5. После успешного входа откроется `/dashboard` -6. На dashboard увидишь данные пользователя из JWT `id_token` +3. Проверь, что форма требует: + `Полное имя`, `Email`, `Пароль`, `Подтвердите пароль` +4. Проверь валидацию пароля: + минимум 8 символов, одна заглавная, одна строчная, одна цифра +5. После регистрации перейди на `http://localhost:3000/login` +6. Войди через кастомную форму `email/password` +7. После входа открой `http://localhost:3000/me` +8. Убедись, что `/me` возвращает JSON с claims: + `sub`, `email`, `name`, `preferred_username`, `username`, `iss`, `aud`, `iat`, `exp` +9. Открой `http://localhost:3000/dashboard` +10. Убедись, что dashboard показывает claims без raw JWT +11. Нажми `Выйти` и проверь, что `POST /logout` очищает cookie и возвращает на `/login` ## Что проверить в админке +В Authentik admin можно проверить: ```txt Applications -> Applications -> Million Miles @@ -120,11 +145,8 @@ Flows and Stages -> Flows -> Million Miles Custom Authentication Directory -> Users ``` -## Если OIDC provider ещё не применился +## Важно -Если frontend пишет, что провайдер ещё не готов, проверь worker: - -```bash -docker compose logs -f authentik-worker -``` +- `COOKIE_SECURE=false` подходит только для локальной разработки по `http` +- в production нужно использовать `COOKIE_SECURE=true` diff --git a/docker-compose.yml b/docker-compose.yml index 52bb4f1..d11e9a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,7 +87,6 @@ services: restart: unless-stopped environment: APP_URL: ${APP_URL} - SESSION_SECRET: ${SESSION_SECRET} AUTHENTIK_PUBLIC_URL: ${AUTHENTIK_PUBLIC_URL} AUTHENTIK_INTERNAL_URL: ${AUTHENTIK_INTERNAL_URL} AUTHENTIK_API_TOKEN: ${AUTHENTIK_API_TOKEN} @@ -96,6 +95,7 @@ services: OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET} OIDC_ISSUER: ${OIDC_ISSUER} OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI} + COOKIE_SECURE: ${COOKIE_SECURE:-false} AUTH_DEBUG: ${AUTH_DEBUG} ports: - "127.0.0.1:3000:3000" diff --git a/frontend/server.js b/frontend/server.js index a65bbea..e3be079 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -6,7 +6,6 @@ const PORT = 3000; const env = { APP_URL: process.env.APP_URL || "http://localhost:3000", - SESSION_SECRET: process.env.SESSION_SECRET || "change-me-session-secret", AUTHENTIK_PUBLIC_URL: process.env.AUTHENTIK_PUBLIC_URL || "http://localhost:9000", AUTHENTIK_INTERNAL_URL: process.env.AUTHENTIK_INTERNAL_URL || "http://host.docker.internal:9000", AUTHENTIK_API_TOKEN: process.env.AUTHENTIK_API_TOKEN || "change-me-api-token", @@ -15,21 +14,40 @@ const env = { OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET || "change-me-oidc-client-secret", OIDC_ISSUER: process.env.OIDC_ISSUER || "http://localhost:9000/application/o/mm-custom-frontend/", OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI || "http://localhost:3000/auth/callback", + COOKIE_SECURE: process.env.COOKIE_SECURE === "true", AUTH_DEBUG: process.env.AUTH_DEBUG === "true" }; +const discoveryCache = { + value: null, + fetchedAt: 0 +}; + +const jwksCache = { + keys: null, + fetchedAt: 0 +}; + function debug(...args) { - if (env.AUTH_DEBUG) console.log("[auth-debug]", ...args); + if (env.AUTH_DEBUG) console.log("[authentik-debug]", ...args); } function escapeHtml(value) { - return String(value ?? "").replace(/[&<>"']/g, (s) => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'" + return String(value ?? "").replace(/[&<>"']/g, s => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'" }[s])); } -function b64url(input) { - return Buffer.from(input).toString("base64url"); +function validatePassword(password) { + if (password.length < 8) return "Пароль должен быть не короче 8 символов."; + if (!/[A-Z]/.test(password)) return "Пароль должен содержать хотя бы одну заглавную букву."; + if (!/[a-z]/.test(password)) return "Пароль должен содержать хотя бы одну строчную букву."; + if (!/\d/.test(password)) return "Пароль должен содержать хотя бы одну цифру."; + return null; } function randomString(bytes = 32) { @@ -40,48 +58,62 @@ function sha256base64url(value) { return crypto.createHash("sha256").update(value).digest("base64url"); } -function sign(value) { - return crypto.createHmac("sha256", env.SESSION_SECRET).update(value).digest("base64url"); -} - -function makeSigned(value) { - const payload = b64url(JSON.stringify(value)); - return `${payload}.${sign(payload)}`; -} - -function readSigned(value) { - if (!value || !value.includes(".")) return null; - const [payload, mac] = value.split("."); - if (sign(payload) !== mac) return null; - try { - return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); - } catch { - return null; - } -} - function makeCookie(name, value, maxAge = 3600) { - return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`; + const parts = [ + `${name}=${value}`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + `Max-Age=${maxAge}` + ]; + + if (env.COOKIE_SECURE) parts.push("Secure"); + + return parts.join("; "); } function clearCookie(name) { - return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; + const parts = [ + `${name}=`, + "Path=/", + "HttpOnly", + "SameSite=Lax", + "Max-Age=0" + ]; + + if (env.COOKIE_SECURE) parts.push("Secure"); + + return parts.join("; "); } function parseCookies(req) { const header = req.headers.cookie || ""; + return Object.fromEntries( header .split(";") - .map(v => v.trim()) + .map(value => value.trim()) .filter(Boolean) - .map(v => { - const i = v.indexOf("="); - return [v.slice(0, i), v.slice(i + 1)]; + .map(value => { + const index = value.indexOf("="); + const rawName = index >= 0 ? value.slice(0, index) : value; + const rawValue = index >= 0 ? value.slice(index + 1) : ""; + + try { + return [rawName, decodeURIComponent(rawValue)]; + } catch { + return [rawName, rawValue]; + } }) ); } +async function parseBody(req) { + let data = ""; + for await (const chunk of req) data += chunk; + return Object.fromEntries(new URLSearchParams(data)); +} + function collectSetCookies(headers) { if (typeof headers.getSetCookie === "function") return headers.getSetCookie(); const value = headers.get("set-cookie"); @@ -91,13 +123,13 @@ function collectSetCookies(headers) { function mergeCookies(jar, setCookies) { for (const raw of setCookies) { const pair = String(raw).split(";")[0]; - const idx = pair.indexOf("="); - if (idx > 0) jar[pair.slice(0, idx)] = pair.slice(idx + 1); + const index = pair.indexOf("="); + if (index > 0) jar[pair.slice(0, index)] = pair.slice(index + 1); } } function cookieHeader(jar) { - return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join("; "); + return Object.entries(jar).map(([key, value]) => `${key}=${value}`).join("; "); } async function fetchWithJar(url, options = {}, jar = {}) { @@ -118,7 +150,7 @@ async function fetchWithJar(url, options = {}, jar = {}) { async function followRedirectsWithJar(res, jar, limit = 8) { let current = res; - for (let i = 0; i < limit; i++) { + for (let index = 0; index < limit; index += 1) { const location = current.headers.get("location"); if (!location || current.status < 300 || current.status >= 400) return current; @@ -134,6 +166,7 @@ async function followRedirectsWithJar(res, jar, limit = 8) { async function safeJson(res) { const text = await res.text(); if (!text) return {}; + try { return JSON.parse(text); } catch { @@ -141,23 +174,67 @@ async function safeJson(res) { } } -function decodeJwt(token) { - try { - const [, payload] = token.split("."); - return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); - } catch { - return {}; +function decodeJwtUnsafe(token) { + if (!token || token.split(".").length !== 3) { + throw new Error("Некорректный JWT."); } + + const [encodedHeader, encodedPayload, encodedSignature] = token.split("."); + + return { + header: JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")), + payload: JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8")), + signature: Buffer.from(encodedSignature, "base64url"), + signingInput: `${encodedHeader}.${encodedPayload}` + }; } -function getSession(req) { - return readSigned(parseCookies(req).mm_session); +function selectClaims(claims) { + return { + sub: claims.sub || null, + email: claims.email || null, + name: claims.name || claims.preferred_username || claims.email || null, + preferred_username: claims.preferred_username || null, + username: claims.preferred_username || claims.username || claims.email || null, + iss: claims.iss || null, + aud: claims.aud || null, + iat: claims.iat || null, + exp: claims.exp || null + }; } -async function parseBody(req) { - let data = ""; - for await (const chunk of req) data += chunk; - return Object.fromEntries(new URLSearchParams(data)); +function formatTimestamp(timestamp) { + if (!timestamp) return "—"; + return new Date(timestamp * 1000).toLocaleString("ru-RU"); +} + +function initials(name, email) { + const source = name || email || "MM"; + return source + .split(/[ ._-]+/) + .filter(Boolean) + .slice(0, 2) + .map(part => part[0]) + .join("") + .toUpperCase(); +} + +function jsonScript(value) { + return JSON.stringify(value).replace(/ - String(u.email || "").toLowerCase() === String(email).toLowerCase() || - String(u.username || "").toLowerCase() === String(email).toLowerCase() + ? users.find(user => + String(user.email || "").toLowerCase() === String(email).toLowerCase() || + String(user.username || "").toLowerCase() === String(email).toLowerCase() ) : null; } @@ -236,7 +288,7 @@ async function createAuthentikUser({ fullName, email, password }) { const createText = await createRes.text(); if (!createRes.ok) { debug("create user failed", createRes.status, createText); - throw new Error("Не удалось создать аккаунт. Попробуйте позже."); + throw new Error("Не удалось создать аккаунт."); } const user = JSON.parse(createText); @@ -260,44 +312,127 @@ async function createAuthentikUser({ fullName, email, password }) { return user; } -async function discoverOidc() { +async function discoverOidc(force = false) { + const now = Date.now(); + if (!force && discoveryCache.value && now - discoveryCache.fetchedAt < 5 * 60 * 1000) { + return discoveryCache.value; + } + const publicDiscovery = `${env.OIDC_ISSUER.replace(/\/$/, "")}/.well-known/openid-configuration`; const internalDiscovery = publicDiscovery.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL); - const res = await fetch(internalDiscovery); - if (!res.ok) throw new Error("OIDC provider ещё не применился. Подождите 1-2 минуты или запустите docker compose down -v && docker compose up --build."); - const config = await res.json(); - return { + const res = await fetch(internalDiscovery); + if (!res.ok) { + throw new Error("OIDC provider ещё не готов. Подождите 1-2 минуты и попробуйте снова."); + } + + const config = await res.json(); + const discovery = { + issuer: config.issuer, authorization_endpoint: config.authorization_endpoint.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL), public_authorization_endpoint: config.authorization_endpoint, - token_endpoint: config.token_endpoint.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL) + token_endpoint: config.token_endpoint.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL), + jwks_uri: config.jwks_uri.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL) }; + + discoveryCache.value = discovery; + discoveryCache.fetchedAt = now; + return discovery; } -// Best-effort OIDC setup. Authentik APIs can differ by version, so README has manual fallback. - - -async function ensureOidcProvider() { - if (ensureOidcProvider.done) return; - try { - await discoverOidc(); - ensureOidcProvider.done = true; - } catch (e) { - debug("OIDC provider not ready yet", e.message); +async function getJwks(force = false) { + const now = Date.now(); + if (!force && jwksCache.keys && now - jwksCache.fetchedAt < 5 * 60 * 1000) { + return jwksCache.keys; } -} -ensureOidcProvider.done = false; -function isSuccessChallenge(res, challenge) { - const component = challenge.component || ""; - return ( - res.status === 302 || - component === "xak-flow-redirect" || - component === "ak-flow-redirect" || - component === "ak-stage-redirect" || - challenge.type === "redirect" || - Boolean(challenge.redirect) + const discovery = await discoverOidc(force); + const res = await fetch(discovery.jwks_uri); + if (!res.ok) throw new Error("Не удалось получить JWKS Authentik."); + + const data = await res.json(); + jwksCache.keys = Array.isArray(data.keys) ? data.keys : []; + jwksCache.fetchedAt = now; + return jwksCache.keys; +} + +function findVerificationKey(keys, header) { + if (!Array.isArray(keys) || keys.length === 0) { + throw new Error("JWKS Authentik пустой."); + } + + const matchingKey = keys.find(key => + (!header.kid || key.kid === header.kid) && + (!key.use || key.use === "sig") && + (!key.alg || key.alg === header.alg) ); + + if (matchingKey) return matchingKey; + if (!header.kid && keys.length === 1) return keys[0]; + + throw new Error("Не удалось подобрать ключ для проверки JWT."); +} + +async function validateIdToken(idToken) { + const decoded = decodeJwtUnsafe(idToken); + const { header, payload, signature, signingInput } = decoded; + + if (!header.alg || !/^RS(256|384|512)$/.test(header.alg)) { + throw new Error("Неподдерживаемый алгоритм JWT Authentik."); + } + + const algorithm = { + RS256: "RSA-SHA256", + RS384: "RSA-SHA384", + RS512: "RSA-SHA512" + }[header.alg]; + + const keys = await getJwks(); + const jwk = findVerificationKey(keys, header); + const publicKey = crypto.createPublicKey({ key: jwk, format: "jwk" }); + const verifier = crypto.createVerify(algorithm); + verifier.update(signingInput); + verifier.end(); + + if (!verifier.verify(publicKey, signature)) { + throw new Error("JWT Authentik не прошёл проверку подписи."); + } + + const discovery = await discoverOidc(); + const now = Math.floor(Date.now() / 1000); + const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean); + + if (payload.iss !== discovery.issuer) { + throw new Error("JWT Authentik содержит неожиданный issuer."); + } + + if (audiences.length > 0 && !audiences.includes(env.OIDC_CLIENT_ID)) { + throw new Error("JWT Authentik содержит неожиданный audience."); + } + + if (payload.nbf && now < payload.nbf) { + throw new Error("JWT Authentik ещё не активен."); + } + + if (payload.exp && now >= payload.exp) { + throw new Error("JWT Authentik истёк."); + } + + return payload; +} + +async function getCurrentUser(req) { + const { idToken, accessToken } = getTokenCookies(req); + if (!idToken) return null; + + const claims = await validateIdToken(idToken); + + return { + idToken, + accessToken, + claims, + profile: selectClaims(claims) + }; } async function authentikPasswordLogin(email, password) { @@ -328,12 +463,24 @@ async function authentikPasswordLogin(email, password) { return { res, challenge: await safeJson(res) }; } + function isSuccessChallenge(res, challenge) { + const component = challenge.component || ""; + return ( + res.status === 302 || + component === "xak-flow-redirect" || + component === "ak-flow-redirect" || + component === "ak-stage-redirect" || + challenge.type === "redirect" || + Boolean(challenge.redirect) + ); + } + let { res, challenge } = await getChallenge(); if (!res.ok) throw new Error("Сервис авторизации запускается. Попробуйте позже."); let passwordChecked = false; - for (let step = 0; step < 12; step++) { + for (let step = 0; step < 12; step += 1) { const component = challenge.component || ""; debug("auth step", step, component, res.status, challenge); @@ -346,7 +493,8 @@ async function authentikPasswordLogin(email, password) { await followRedirectsWithJar(res, jar); return jar; } - throw new Error("OIDC provider ещё не применился. Подождите 1-2 минуты или запустите docker compose down -v && docker compose up --build."); + + throw new Error("OIDC provider ещё не готов. Подождите 1-2 минуты и попробуйте снова."); } if (component === "ak-stage-identification") { @@ -391,13 +539,13 @@ async function authentikPasswordLogin(email, password) { return jar; } - throw new Error("Не удалось завершить вход. Проверь custom flow Authentik."); + throw new Error("Не удалось завершить вход. Проверьте custom flow Authentik."); } throw new Error("Неверный email или пароль."); } -async function getAuthentikIdTokenWithSession(jar) { +async function getAuthentikTokensWithSession(jar) { const config = await discoverOidc(); const state = randomString(24); @@ -424,7 +572,7 @@ async function getAuthentikIdTokenWithSession(jar) { debug("authorize status", authRes.status, "location", location); if (!location) { - throw new Error("Authentik не вернул authorization code. Проверь OIDC Provider."); + throw new Error("Authentik не вернул authorization code. Проверьте OIDC Provider."); } const redirectUrl = new URL(location, env.APP_URL); @@ -451,14 +599,14 @@ async function getAuthentikIdTokenWithSession(jar) { const tokens = await tokenRes.json(); debug("token response", tokenRes.status, tokens); - if (!tokenRes.ok || !tokens.id_token) { - throw new Error("Не удалось получить JWT от Authentik."); + if (!tokenRes.ok || !tokens.id_token || !tokens.access_token) { + throw new Error("Не удалось получить токены от Authentik."); } - return tokens.id_token; + return tokens; } -function page(title, body) { +function page(title, body, extra = "") { return `
@@ -466,117 +614,40 @@ function page(title, body) {${text}
${subtitle}
${form}${text}
${subtitle}
${form}Данные ниже получены из /me. Backend валидирует JWT Authentik из HttpOnly cookie.
${escapeHtml(JSON.stringify(profile, null, 2))}