865 lines
35 KiB
JavaScript
865 lines
35 KiB
JavaScript
const http = require("http");
|
||
const crypto = require("crypto");
|
||
const { URLSearchParams } = require("url");
|
||
|
||
const PORT = 3000;
|
||
|
||
const env = {
|
||
APP_URL: process.env.APP_URL || "http://localhost:3000",
|
||
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",
|
||
AUTHENTIK_LOGIN_FLOW: process.env.AUTHENTIK_LOGIN_FLOW || "mm-custom-authentication-flow",
|
||
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID || "mm-custom-frontend",
|
||
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("[authentik-debug]", ...args);
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "").replace(/[&<>"']/g, s => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'"
|
||
}[s]));
|
||
}
|
||
|
||
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) {
|
||
return crypto.randomBytes(bytes).toString("base64url");
|
||
}
|
||
|
||
function sha256base64url(value) {
|
||
return crypto.createHash("sha256").update(value).digest("base64url");
|
||
}
|
||
|
||
function makeCookie(name, value, maxAge = 3600) {
|
||
const parts = [
|
||
`${name}=${value}`,
|
||
"Path=/",
|
||
"HttpOnly",
|
||
"SameSite=Lax",
|
||
`Max-Age=${maxAge}`
|
||
];
|
||
|
||
if (env.COOKIE_SECURE) parts.push("Secure");
|
||
|
||
return parts.join("; ");
|
||
}
|
||
|
||
function clearCookie(name) {
|
||
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(value => value.trim())
|
||
.filter(Boolean)
|
||
.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");
|
||
return value ? [value] : [];
|
||
}
|
||
|
||
function mergeCookies(jar, setCookies) {
|
||
for (const raw of setCookies) {
|
||
const pair = String(raw).split(";")[0];
|
||
const index = pair.indexOf("=");
|
||
if (index > 0) jar[pair.slice(0, index)] = pair.slice(index + 1);
|
||
}
|
||
}
|
||
|
||
function cookieHeader(jar) {
|
||
return Object.entries(jar).map(([key, value]) => `${key}=${value}`).join("; ");
|
||
}
|
||
|
||
async function fetchWithJar(url, options = {}, jar = {}) {
|
||
const headers = { ...(options.headers || {}) };
|
||
const cookie = cookieHeader(jar);
|
||
if (cookie) headers.Cookie = cookie;
|
||
|
||
const res = await fetch(url, {
|
||
...options,
|
||
headers,
|
||
redirect: "manual"
|
||
});
|
||
|
||
mergeCookies(jar, collectSetCookies(res.headers));
|
||
return res;
|
||
}
|
||
|
||
async function followRedirectsWithJar(res, jar, limit = 8) {
|
||
let current = res;
|
||
|
||
for (let index = 0; index < limit; index += 1) {
|
||
const location = current.headers.get("location");
|
||
if (!location || current.status < 300 || current.status >= 400) return current;
|
||
|
||
current = await fetchWithJar(new URL(location, env.AUTHENTIK_INTERNAL_URL).toString(), {
|
||
method: "GET",
|
||
headers: { Accept: "text/html,application/xhtml+xml,application/json" }
|
||
}, jar);
|
||
}
|
||
|
||
return current;
|
||
}
|
||
|
||
async function safeJson(res) {
|
||
const text = await res.text();
|
||
if (!text) return {};
|
||
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch {
|
||
return { raw: text };
|
||
}
|
||
}
|
||
|
||
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 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
|
||
};
|
||
}
|
||
|
||
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(/</g, "\\u003c");
|
||
}
|
||
|
||
function tokenMaxAge(claims) {
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const maxAge = Number(claims.exp || 0) - now;
|
||
return Math.max(60, maxAge || 3600);
|
||
}
|
||
|
||
function getTokenCookies(req) {
|
||
const cookies = parseCookies(req);
|
||
return {
|
||
idToken: cookies.mm_id_token || "",
|
||
accessToken: cookies.mm_access_token || ""
|
||
};
|
||
}
|
||
|
||
async function authentikReady() {
|
||
try {
|
||
const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/root/config/`, {
|
||
headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` }
|
||
});
|
||
return res.status < 500;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function findAuthentikUser(email) {
|
||
const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/?search=${encodeURIComponent(email)}`, {
|
||
headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` }
|
||
});
|
||
|
||
if (!res.ok) return null;
|
||
|
||
const data = await res.json();
|
||
const users = data.results || data;
|
||
|
||
return Array.isArray(users)
|
||
? users.find(user =>
|
||
String(user.email || "").toLowerCase() === String(email).toLowerCase() ||
|
||
String(user.username || "").toLowerCase() === String(email).toLowerCase()
|
||
)
|
||
: null;
|
||
}
|
||
|
||
async function createAuthentikUser({ fullName, email, password }) {
|
||
const existing = await findAuthentikUser(email);
|
||
if (existing) throw new Error("Пользователь с таким email уже существует.");
|
||
|
||
const createRes = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`,
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({
|
||
username: String(email).toLowerCase(),
|
||
name: fullName,
|
||
email,
|
||
is_active: true,
|
||
type: "internal"
|
||
})
|
||
});
|
||
|
||
const createText = await createRes.text();
|
||
if (!createRes.ok) {
|
||
debug("create user failed", createRes.status, createText);
|
||
throw new Error("Не удалось создать аккаунт.");
|
||
}
|
||
|
||
const user = JSON.parse(createText);
|
||
const userId = user.pk || user.id;
|
||
|
||
const passRes = await fetch(`${env.AUTHENTIK_INTERNAL_URL}/api/v3/core/users/${userId}/set_password/`, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`,
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({ password })
|
||
});
|
||
|
||
const passText = await passRes.text();
|
||
if (!passRes.ok) {
|
||
debug("set password failed", passRes.status, passText);
|
||
throw new Error("Аккаунт создан, но пароль не был сохранён.");
|
||
}
|
||
|
||
return user;
|
||
}
|
||
|
||
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 минуты и попробуйте снова.");
|
||
}
|
||
|
||
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),
|
||
jwks_uri: config.jwks_uri.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL)
|
||
};
|
||
|
||
discoveryCache.value = discovery;
|
||
discoveryCache.fetchedAt = now;
|
||
return discovery;
|
||
}
|
||
|
||
async function getJwks(force = false) {
|
||
const now = Date.now();
|
||
if (!force && jwksCache.keys && now - jwksCache.fetchedAt < 5 * 60 * 1000) {
|
||
return jwksCache.keys;
|
||
}
|
||
|
||
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) {
|
||
const jar = {};
|
||
const flow = env.AUTHENTIK_LOGIN_FLOW;
|
||
const instanceUrl = `${env.AUTHENTIK_INTERNAL_URL}/api/v3/flows/instances/${flow}/execute/?next=/`;
|
||
const executorUrl = `${env.AUTHENTIK_INTERNAL_URL}/api/v3/flows/executor/${flow}/?query=${encodeURIComponent("next=/")}`;
|
||
|
||
await fetchWithJar(instanceUrl, {
|
||
method: "GET",
|
||
headers: { Accept: "application/json" }
|
||
}, jar);
|
||
|
||
async function getChallenge() {
|
||
const res = await fetchWithJar(executorUrl, {
|
||
method: "GET",
|
||
headers: { Accept: "application/json" }
|
||
}, jar);
|
||
return { res, challenge: await safeJson(res) };
|
||
}
|
||
|
||
async function postChallenge(component, payload = {}) {
|
||
const res = await fetchWithJar(executorUrl, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({ component, ...payload })
|
||
}, jar);
|
||
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 += 1) {
|
||
const component = challenge.component || "";
|
||
debug("auth step", step, component, res.status, challenge);
|
||
|
||
if (component === "ak-stage-access-denied") {
|
||
throw new Error("Неверный email или пароль.");
|
||
}
|
||
|
||
if (isSuccessChallenge(res, challenge)) {
|
||
if (passwordChecked) {
|
||
await followRedirectsWithJar(res, jar);
|
||
return jar;
|
||
}
|
||
|
||
throw new Error("OIDC provider ещё не готов. Подождите 1-2 минуты и попробуйте снова.");
|
||
}
|
||
|
||
if (component === "ak-stage-identification") {
|
||
const payload = { uid_field: email };
|
||
const hasPasswordOnIdentification =
|
||
challenge.password_fields === true ||
|
||
(Array.isArray(challenge.password_fields) && challenge.password_fields.length > 0);
|
||
|
||
if (hasPasswordOnIdentification) {
|
||
payload.password = password;
|
||
passwordChecked = true;
|
||
}
|
||
|
||
({ res, challenge } = await postChallenge("ak-stage-identification", payload));
|
||
continue;
|
||
}
|
||
|
||
if (component === "ak-stage-password") {
|
||
passwordChecked = true;
|
||
({ res, challenge } = await postChallenge("ak-stage-password", { password }));
|
||
continue;
|
||
}
|
||
|
||
if (component === "ak-stage-user-login") {
|
||
if (!passwordChecked) throw new Error("Authentik flow не запросил пароль.");
|
||
({ res, challenge } = await postChallenge("ak-stage-user-login"));
|
||
continue;
|
||
}
|
||
|
||
if (component === "ak-stage-consent") {
|
||
if (!passwordChecked) throw new Error("Authentik flow не запросил пароль.");
|
||
({ res, challenge } = await postChallenge("ak-stage-consent", { consent: true }));
|
||
continue;
|
||
}
|
||
|
||
if (component === "ak-stage-authenticator-validate") {
|
||
throw new Error("Для пользователя требуется дополнительная проверка.");
|
||
}
|
||
|
||
if (passwordChecked && res.ok && !component) {
|
||
await followRedirectsWithJar(res, jar);
|
||
return jar;
|
||
}
|
||
|
||
throw new Error("Не удалось завершить вход. Проверьте custom flow Authentik.");
|
||
}
|
||
|
||
throw new Error("Неверный email или пароль.");
|
||
}
|
||
|
||
async function getAuthentikTokensWithSession(jar) {
|
||
const config = await discoverOidc();
|
||
|
||
const state = randomString(24);
|
||
const verifier = randomString(48);
|
||
const challenge = sha256base64url(verifier);
|
||
|
||
const authorizeUrl = `${config.authorization_endpoint}?${new URLSearchParams({
|
||
response_type: "code",
|
||
client_id: env.OIDC_CLIENT_ID,
|
||
redirect_uri: env.OIDC_REDIRECT_URI,
|
||
scope: "openid profile email",
|
||
state,
|
||
code_challenge: challenge,
|
||
code_challenge_method: "S256",
|
||
prompt: "none"
|
||
}).toString()}`;
|
||
|
||
const authRes = await fetchWithJar(authorizeUrl, {
|
||
method: "GET",
|
||
headers: { Accept: "text/html,application/xhtml+xml,application/json" }
|
||
}, jar);
|
||
|
||
const location = authRes.headers.get("location");
|
||
debug("authorize status", authRes.status, "location", location);
|
||
|
||
if (!location) {
|
||
throw new Error("Authentik не вернул authorization code. Проверьте OIDC Provider.");
|
||
}
|
||
|
||
const redirectUrl = new URL(location, env.APP_URL);
|
||
const code = redirectUrl.searchParams.get("code");
|
||
const returnedState = redirectUrl.searchParams.get("state");
|
||
|
||
if (!code || returnedState !== state) {
|
||
throw new Error("Не удалось получить authorization code от Authentik.");
|
||
}
|
||
|
||
const tokenRes = await fetch(config.token_endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body: new URLSearchParams({
|
||
grant_type: "authorization_code",
|
||
code,
|
||
redirect_uri: env.OIDC_REDIRECT_URI,
|
||
client_id: env.OIDC_CLIENT_ID,
|
||
client_secret: env.OIDC_CLIENT_SECRET,
|
||
code_verifier: verifier
|
||
})
|
||
});
|
||
|
||
const tokens = await tokenRes.json();
|
||
debug("token response", tokenRes.status, tokens);
|
||
|
||
if (!tokenRes.ok || !tokens.id_token || !tokens.access_token) {
|
||
throw new Error("Не удалось получить токены от Authentik.");
|
||
}
|
||
|
||
return tokens;
|
||
}
|
||
|
||
function page(title, body, extra = "") {
|
||
return `<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
<title>${escapeHtml(title)}</title>
|
||
<style>
|
||
:root{--gold:#D4AF37;--gold-light:#F0D77D;--line:rgba(255,255,255,.11);--muted:rgba(255,255,255,.62);--muted2:rgba(255,255,255,.42)}
|
||
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#050505;color:#fff;font-family:Inter,Manrope,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif}a{color:inherit;text-decoration:none}button,input{font:inherit}
|
||
.screen{min-height:100vh;display:grid;place-items:center;padding:28px;background:radial-gradient(circle at 78% 12%,rgba(212,175,55,.16),transparent 27%),radial-gradient(circle at 8% 86%,rgba(18,44,90,.38),transparent 34%),linear-gradient(180deg,#111,#060606 58%,#030303)}
|
||
.shell{width:min(1080px,100%);min-height:700px;display:grid;grid-template-columns:1fr 455px;overflow:hidden;border:1px solid var(--line);border-radius:38px;background:rgba(255,255,255,.025);box-shadow:0 38px 120px rgba(0,0,0,.58)}
|
||
.brand-side{position:relative;padding:44px;display:flex;flex-direction:column;justify-content:space-between;background:radial-gradient(circle at 70% 24%,rgba(212,175,55,.18),transparent 34%),linear-gradient(145deg,rgba(255,255,255,.06),rgba(255,255,255,.015))}
|
||
.logo{display:flex;align-items:center;gap:14px}.logo-mark{width:46px;height:46px;border-radius:50%;display:grid;place-items:center;border:1px solid rgba(212,175,55,.38);background:linear-gradient(145deg,rgba(212,175,55,.28),rgba(255,255,255,.045));color:var(--gold);font-weight:800}.logo-title{display:block;font-size:15px;font-weight:800;letter-spacing:.22em;text-transform:uppercase}.logo-sub{display:block;margin-top:3px;font-size:12px;color:var(--muted2);letter-spacing:.12em}
|
||
.brand-copy h1{margin:0;max-width:560px;font-size:clamp(50px,6vw,80px);line-height:.9;letter-spacing:-.075em}.gold{color:var(--gold)}.brand-copy p{max-width:520px;margin:28px 0 0;color:var(--muted);font-size:18px;line-height:1.75}.brand-footer{display:flex;gap:12px;flex-wrap:wrap;color:var(--muted2);font-size:13px}.brand-footer span{min-width:118px;padding-top:12px;border-top:1px solid var(--line)}
|
||
.form-side{padding:42px;display:grid;align-items:center;background:rgba(8,8,8,.78);backdrop-filter:blur(28px);border-left:1px solid var(--line)}.form-card h2{margin:0;font-size:36px;letter-spacing:-.045em}.form-card p{margin:12px 0 28px;color:var(--muted);line-height:1.65}.label{display:block;margin:18px 0 8px;color:rgba(255,255,255,.66);font-size:14px}.input{width:100%;border:1px solid var(--line);background:rgba(255,255,255,.045);border-radius:18px;padding:16px;color:#fff;outline:none;transition:.2s}.input::placeholder{color:rgba(255,255,255,.28)}.input:focus{border-color:rgba(212,175,55,.75);background:rgba(255,255,255,.065);box-shadow:0 0 0 4px rgba(212,175,55,.06)}
|
||
.btn{width:100%;min-height:52px;margin-top:24px;border:0;border-radius:999px;background:linear-gradient(135deg,var(--gold),var(--gold-light));color:#050505;font-weight:750;cursor:pointer;transition:.2s}.btn:hover{transform:translateY(-1px);box-shadow:0 20px 48px rgba(212,175,55,.22)}.link-row{margin-top:24px;text-align:center;color:var(--muted2);font-size:14px}.link-row a{color:var(--gold)}
|
||
.alert{margin:0 0 18px;padding:14px 16px;border:1px solid;border-radius:18px;font-size:14px;line-height:1.5}.alert.error{color:#fecaca;background:rgba(248,113,113,.09);border-color:rgba(248,113,113,.25)}.alert.ok{color:#bbf7d0;background:rgba(52,211,153,.08);border-color:rgba(52,211,153,.25)}
|
||
.dashboard-card{width:min(980px,100%);padding:34px;border:1px solid var(--line);border-radius:36px;background:rgba(255,255,255,.04);box-shadow:0 38px 120px rgba(0,0,0,.58)}.dashboard-top{display:flex;align-items:center;justify-content:space-between;gap:20px}.logout{width:auto;min-height:46px;margin:0;padding:0 20px}.profile{margin-top:34px;display:flex;align-items:center;gap:22px}.avatar{width:82px;height:82px;border-radius:26px;display:grid;place-items:center;background:linear-gradient(145deg,var(--gold),var(--gold-light));color:#050505;font-size:30px;font-weight:800}.profile h1{margin:0;font-size:42px;letter-spacing:-.05em}.profile p{margin:8px 0 0;color:var(--muted)}
|
||
.jwt-info{margin-top:24px;border:1px solid rgba(212,175,55,.22);border-radius:24px;background:rgba(212,175,55,.055);padding:20px}.jwt-info h2{margin:0 0 16px;font-size:22px;letter-spacing:-.035em}.jwt-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}.jwt-item{border:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.22);border-radius:18px;padding:14px}.jwt-item b{display:block;margin-bottom:8px;color:var(--muted2);font-size:11px;text-transform:uppercase;letter-spacing:.12em}.jwt-item span{color:rgba(255,255,255,.88);word-break:break-word;font-size:14px}
|
||
.claims-json{margin-top:16px;border:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.24);border-radius:18px;padding:14px}.claims-json b{display:block;margin-bottom:8px;color:var(--muted2);font-size:11px;text-transform:uppercase;letter-spacing:.12em}.claims-json pre{margin:0;white-space:pre-wrap;word-break:break-word;color:rgba(255,255,255,.9);font-size:13px;line-height:1.6}
|
||
@media(max-width:900px){.shell{grid-template-columns:1fr}.form-side{border-left:0;border-top:1px solid var(--line)}.jwt-grid{grid-template-columns:1fr}}
|
||
@media(max-width:620px){.screen{padding:14px}.brand-side,.form-side,.dashboard-card{padding:24px}.dashboard-top,.profile{align-items:flex-start;flex-direction:column}}
|
||
</style>
|
||
${extra}
|
||
</head>
|
||
<body>${body}</body>
|
||
</html>`;
|
||
}
|
||
|
||
function authLayout({ title, subtitle, heading, text, form }) {
|
||
return `<main class="screen"><section class="shell"><div class="brand-side"><a class="logo" href="/login"><span class="logo-mark">MM</span><span><span class="logo-title">Million Miles</span><span class="logo-sub">Private Client Access</span></span></a><div class="brand-copy"><h1>${heading}</h1><p>${text}</p></div><div class="brand-footer"><span>Secure access</span><span>Private area</span><span>Premium service</span></div></div><div class="form-side"><div class="form-card"><h2>${title}</h2><p>${subtitle}</p>${form}</div></div></section></main>`;
|
||
}
|
||
|
||
function loginPage(req, res, msg = "", headers = {}) {
|
||
const form = `<form method="post" action="/login">${msg}<label class="label">Email</label><input class="input" name="email" type="email" autocomplete="email" placeholder="you@example.com" required /><label class="label">Пароль</label><input class="input" name="password" type="password" autocomplete="current-password" placeholder="Ваш пароль" required /><button class="btn" type="submit">Войти</button><div class="link-row">Нет аккаунта? <a href="/register">Зарегистрироваться</a></div></form>`;
|
||
send(res, 200, page("Вход | Million Miles", authLayout({ title: "Вход", subtitle: "Войдите в личный кабинет Million Miles.", heading: "Добро пожаловать", text: "Закрытый доступ для клиентов премиального сервиса.", form })), headers);
|
||
}
|
||
|
||
function registerPage(req, res, msg = "") {
|
||
const form = `<form method="post" action="/register">${msg}<label class="label">Полное имя</label><input class="input" name="fullName" autocomplete="name" placeholder="Ваше имя" required minlength="2" /><label class="label">Email</label><input class="input" name="email" type="email" autocomplete="email" placeholder="you@example.com" required /><label class="label">Пароль</label><input class="input" name="password" type="password" autocomplete="new-password" placeholder="Минимум 8 символов" minlength="8" required /><label class="label">Подтвердите пароль</label><input class="input" name="confirmPassword" type="password" autocomplete="new-password" placeholder="Повторите пароль" minlength="8" required /><button class="btn" type="submit">Зарегистрироваться</button><div class="link-row">Уже есть аккаунт? <a href="/login">Войти</a></div></form>`;
|
||
send(res, 200, page("Регистрация | Million Miles", authLayout({ title: "Регистрация", subtitle: "Создайте личный кабинет Million Miles.", heading: `Персональный доступ <span class="gold">Million Miles</span>`, text: "Создайте профиль для доступа в личный кабинет Million Miles.", form })));
|
||
}
|
||
|
||
async function registerPost(req, res) {
|
||
const data = await parseBody(req);
|
||
const email = String(data.email || "").trim().toLowerCase();
|
||
const fullName = String(data.fullName || "").trim();
|
||
const password = String(data.password || "");
|
||
const confirmPassword = String(data.confirmPassword || "");
|
||
|
||
try {
|
||
if (!fullName || !email || !password || !confirmPassword) {
|
||
throw new Error("Проверьте заполненные данные.");
|
||
}
|
||
|
||
if (password !== confirmPassword) {
|
||
throw new Error("Пароли не совпадают.");
|
||
}
|
||
|
||
const passwordError = validatePassword(password);
|
||
if (passwordError) throw new Error(passwordError);
|
||
|
||
if (!(await authentikReady())) {
|
||
throw new Error("Сервис авторизации запускается. Попробуйте позже.");
|
||
}
|
||
|
||
await createAuthentikUser({ fullName, email, password });
|
||
|
||
return redirect(res, "/login", {
|
||
"Set-Cookie": makeCookie("mm_flash", "registered", 120)
|
||
});
|
||
} catch (error) {
|
||
return registerPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Не удалось создать аккаунт.")}</div>`);
|
||
}
|
||
}
|
||
|
||
async function loginPost(req, res) {
|
||
const data = await parseBody(req);
|
||
const email = String(data.email || "").trim().toLowerCase();
|
||
const password = String(data.password || "");
|
||
|
||
try {
|
||
if (!email || !password) throw new Error("Введите email и пароль.");
|
||
|
||
if (!(await authentikReady())) {
|
||
throw new Error("Сервис авторизации запускается. Попробуйте позже.");
|
||
}
|
||
|
||
const jar = await authentikPasswordLogin(email, password);
|
||
const tokens = await getAuthentikTokensWithSession(jar);
|
||
const claims = await validateIdToken(tokens.id_token);
|
||
const maxAge = tokenMaxAge(claims);
|
||
|
||
return redirect(res, "/dashboard", {
|
||
"Set-Cookie": [
|
||
makeCookie("mm_id_token", encodeURIComponent(tokens.id_token), maxAge),
|
||
makeCookie("mm_access_token", encodeURIComponent(tokens.access_token), maxAge)
|
||
]
|
||
});
|
||
} catch (error) {
|
||
debug("login failed", error);
|
||
return loginPage(req, res, `<div class="alert error">${escapeHtml(error.message || "Неверный email или пароль.")}</div>`);
|
||
}
|
||
}
|
||
|
||
async function me(req, res) {
|
||
try {
|
||
const user = await getCurrentUser(req);
|
||
if (!user) {
|
||
res.writeHead(401, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ error: "Unauthorized" }));
|
||
}
|
||
|
||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||
return res.end(JSON.stringify(user.profile));
|
||
} catch (error) {
|
||
res.writeHead(401, {
|
||
"Content-Type": "application/json; charset=utf-8",
|
||
"Set-Cookie": [
|
||
clearCookie("mm_id_token"),
|
||
clearCookie("mm_access_token")
|
||
]
|
||
});
|
||
return res.end(JSON.stringify({ error: error.message || "Unauthorized" }));
|
||
}
|
||
}
|
||
|
||
async function dashboard(req, res) {
|
||
let user;
|
||
|
||
try {
|
||
user = await getCurrentUser(req);
|
||
} catch (error) {
|
||
return redirect(res, "/login", {
|
||
"Set-Cookie": [
|
||
clearCookie("mm_id_token"),
|
||
clearCookie("mm_access_token")
|
||
]
|
||
});
|
||
}
|
||
|
||
if (!user) return redirect(res, "/login");
|
||
|
||
const profile = user.profile;
|
||
const body = `<main class="screen"><section class="dashboard-card"><div class="dashboard-top"><a class="logo" href="/dashboard"><span class="logo-mark">MM</span><span><span class="logo-title">Million Miles</span><span class="logo-sub">Private Client Access</span></span></a><form method="post" action="/logout"><button class="btn logout" type="submit">Выйти</button></form></div><div class="profile"><div class="avatar" id="profile-avatar">${escapeHtml(initials(profile.name, profile.email))}</div><div><h1 id="profile-name">${escapeHtml(profile.name || "Клиент")}</h1><p>Данные ниже получены из /me. Backend валидирует JWT Authentik из HttpOnly cookie.</p></div></div><div class="jwt-info"><h2>Claims</h2><div class="jwt-grid"><div class="jwt-item"><b>sub</b><span id="claim-sub">${escapeHtml(profile.sub || "—")}</span></div><div class="jwt-item"><b>name</b><span id="claim-name">${escapeHtml(profile.name || "—")}</span></div><div class="jwt-item"><b>email</b><span id="claim-email">${escapeHtml(profile.email || "—")}</span></div><div class="jwt-item"><b>username</b><span id="claim-username">${escapeHtml(profile.username || "—")}</span></div><div class="jwt-item"><b>iat</b><span id="claim-iat">${escapeHtml(formatTimestamp(profile.iat))}</span></div><div class="jwt-item"><b>exp</b><span id="claim-exp">${escapeHtml(formatTimestamp(profile.exp))}</span></div><div class="jwt-item"><b>iss</b><span id="claim-iss">${escapeHtml(profile.iss || "—")}</span></div><div class="jwt-item"><b>aud</b><span id="claim-aud">${escapeHtml(Array.isArray(profile.aud) ? profile.aud.join(", ") : profile.aud || "—")}</span></div></div><div class="claims-json"><b>/me JSON</b><pre id="me-json">${escapeHtml(JSON.stringify(profile, null, 2))}</pre></div></div></section></main>`;
|
||
|
||
const script = `<script id="me-bootstrap" type="application/json">${jsonScript(profile)}</script><script>
|
||
const claimIds = ["sub", "name", "email", "username", "iat", "exp", "iss", "aud"];
|
||
const initial = JSON.parse(document.getElementById("me-bootstrap").textContent || "{}");
|
||
|
||
function formatValue(key, value) {
|
||
if (value == null || value === "") return "—";
|
||
if (key === "iat" || key === "exp") {
|
||
const date = new Date(Number(value) * 1000);
|
||
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("ru-RU");
|
||
}
|
||
if (Array.isArray(value)) return value.join(", ");
|
||
return String(value);
|
||
}
|
||
|
||
function initials(name, email) {
|
||
const source = name || email || "MM";
|
||
return source.split(/[ ._-]+/).filter(Boolean).slice(0, 2).map(part => part[0]).join("").toUpperCase();
|
||
}
|
||
|
||
function render(data) {
|
||
document.getElementById("profile-name").textContent = data.name || data.username || data.email || "Клиент";
|
||
document.getElementById("profile-avatar").textContent = initials(data.name, data.email);
|
||
|
||
for (const key of claimIds) {
|
||
const node = document.getElementById("claim-" + key);
|
||
if (node) node.textContent = formatValue(key, data[key]);
|
||
}
|
||
|
||
document.getElementById("me-json").textContent = JSON.stringify(data, null, 2);
|
||
}
|
||
|
||
render(initial);
|
||
|
||
fetch("/me", { headers: { Accept: "application/json" } })
|
||
.then(async response => {
|
||
if (response.status === 401) {
|
||
window.location.href = "/login";
|
||
return null;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
throw new Error("Не удалось получить /me.");
|
||
}
|
||
|
||
return response.json();
|
||
})
|
||
.then(data => {
|
||
if (data) render(data);
|
||
})
|
||
.catch(error => {
|
||
console.error(error);
|
||
});
|
||
</script>`;
|
||
|
||
send(res, 200, page("Dashboard | Million Miles", body, script));
|
||
}
|
||
|
||
function redirect(res, location, headers = {}) {
|
||
res.writeHead(302, { Location: location, ...headers });
|
||
res.end();
|
||
}
|
||
|
||
function send(res, status, content, headers = {}) {
|
||
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8", ...headers });
|
||
res.end(content);
|
||
}
|
||
|
||
function logout(req, res) {
|
||
redirect(res, "/login", {
|
||
"Set-Cookie": [
|
||
clearCookie("mm_id_token"),
|
||
clearCookie("mm_access_token")
|
||
]
|
||
});
|
||
}
|
||
|
||
http.createServer(async (req, res) => {
|
||
const url = new URL(req.url, env.APP_URL);
|
||
|
||
if (req.method === "GET" && url.pathname === "/") return redirect(res, "/login");
|
||
|
||
if (req.method === "GET" && url.pathname === "/login") {
|
||
const cookies = parseCookies(req);
|
||
if (cookies.mm_flash === "registered") {
|
||
return loginPage(req, res, `<div class="alert ok">Аккаунт создан. Теперь войдите через форму Authentik.</div>`, {
|
||
"Set-Cookie": clearCookie("mm_flash")
|
||
});
|
||
}
|
||
|
||
return loginPage(req, res);
|
||
}
|
||
|
||
if (req.method === "POST" && url.pathname === "/login") return loginPost(req, res);
|
||
if (req.method === "GET" && url.pathname === "/register") return registerPage(req, res);
|
||
if (req.method === "POST" && url.pathname === "/register") return registerPost(req, res);
|
||
if (req.method === "GET" && url.pathname === "/dashboard") return dashboard(req, res);
|
||
if (req.method === "GET" && url.pathname === "/me") return me(req, res);
|
||
if (req.method === "POST" && url.pathname === "/logout") return logout(req, res);
|
||
|
||
if (req.method === "GET" && url.pathname === "/health") {
|
||
res.writeHead(200, { "Content-Type": "application/json" });
|
||
return res.end(JSON.stringify({ ok: true }));
|
||
}
|
||
|
||
redirect(res, "/login");
|
||
}).listen(PORT, () => {
|
||
discoverOidc().catch(error => debug("startup oidc setup failed", error.message));
|
||
console.log(`Million Miles Authentik POC on http://localhost:${PORT}`);
|
||
});
|