zitadel auth
This commit is contained in:
673
frontend/server.js
Normal file
673
frontend/server.js
Normal file
@@ -0,0 +1,673 @@
|
||||
const http = require("http");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const { URLSearchParams } = require("url");
|
||||
|
||||
const PORT = 3000;
|
||||
|
||||
const env = {
|
||||
APP_URL: process.env.APP_URL || "http://localhost:3000",
|
||||
ZITADEL_URL: process.env.ZITADEL_URL || "http://host.docker.internal:8080",
|
||||
ZITADEL_PUBLIC_URL: process.env.ZITADEL_PUBLIC_URL || "http://localhost:8080",
|
||||
ZITADEL_PROJECT_NAME: process.env.ZITADEL_PROJECT_NAME || "Million Miles",
|
||||
OIDC_APP_NAME: process.env.OIDC_APP_NAME || "Million Miles Custom Frontend",
|
||||
OIDC_REDIRECT_URI: process.env.OIDC_REDIRECT_URI || "http://localhost:3000/auth/callback",
|
||||
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID || "",
|
||||
OIDC_CLIENT_SECRET: process.env.OIDC_CLIENT_SECRET || "",
|
||||
OIDC_CLIENT_STATE_FILE: process.env.OIDC_CLIENT_STATE_FILE || "/zitadel-secrets/oidc-client.json",
|
||||
ZITADEL_SERVICE_TOKEN_FILE: process.env.ZITADEL_SERVICE_TOKEN_FILE || "/zitadel-secrets/admin.pat",
|
||||
ZITADEL_LOGIN_CLIENT_TOKEN_FILE: process.env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE || "/zitadel-secrets/login-client.pat",
|
||||
SESSION_SECRET: process.env.SESSION_SECRET || "dev-secret",
|
||||
AUTH_DEBUG: process.env.AUTH_DEBUG === "true"
|
||||
};
|
||||
|
||||
const persistedOidcClient = readOidcClientConfig();
|
||||
|
||||
const state = {
|
||||
projectId: null,
|
||||
clientId: env.OIDC_CLIENT_ID || persistedOidcClient.clientId,
|
||||
clientSecret: env.OIDC_CLIENT_SECRET || persistedOidcClient.clientSecret,
|
||||
setupTried: false
|
||||
};
|
||||
|
||||
function debug(...args) {
|
||||
if (env.AUTH_DEBUG) console.log("[zitadel-debug]", ...args);
|
||||
}
|
||||
|
||||
function readTokenFile(path) {
|
||||
try {
|
||||
const value = fs.readFileSync(path, "utf8").trim();
|
||||
return value || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readOidcClientConfig() {
|
||||
try {
|
||||
const raw = fs.readFileSync(env.OIDC_CLIENT_STATE_FILE, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
clientId: parsed.clientId || "",
|
||||
clientSecret: parsed.clientSecret || ""
|
||||
};
|
||||
} catch {
|
||||
return { clientId: "", clientSecret: "" };
|
||||
}
|
||||
}
|
||||
|
||||
function persistOidcClientConfig(clientId, clientSecret) {
|
||||
try {
|
||||
fs.writeFileSync(env.OIDC_CLIENT_STATE_FILE, JSON.stringify({ clientId, clientSecret }), "utf8");
|
||||
} catch (e) {
|
||||
debug("failed to persist OIDC client", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function adminToken() {
|
||||
return readTokenFile(env.ZITADEL_SERVICE_TOKEN_FILE);
|
||||
}
|
||||
|
||||
function loginClientToken() {
|
||||
return readTokenFile(env.ZITADEL_LOGIN_CLIENT_TOKEN_FILE) || adminToken();
|
||||
}
|
||||
|
||||
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 b64url(input) {
|
||||
return Buffer.from(input).toString("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}`;
|
||||
}
|
||||
|
||||
function clearCookie(name) {
|
||||
return `${name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const header = req.headers.cookie || "";
|
||||
return Object.fromEntries(header.split(";").map(v => v.trim()).filter(Boolean).map(v => {
|
||||
const i = v.indexOf("=");
|
||||
return [v.slice(0, i), v.slice(i + 1)];
|
||||
}));
|
||||
}
|
||||
|
||||
function getSession(req) {
|
||||
return readSigned(parseCookies(req).mm_session);
|
||||
}
|
||||
|
||||
async function parseBody(req) {
|
||||
let data = "";
|
||||
for await (const chunk of req) data += chunk;
|
||||
return Object.fromEntries(new URLSearchParams(data));
|
||||
}
|
||||
|
||||
function decodeJwt(token) {
|
||||
try {
|
||||
const [, payload] = token.split(".");
|
||||
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function zFetch(path, options = {}, token = adminToken()) {
|
||||
if (!token) throw new Error("ZITADEL PAT еще не создан. Подождите первый запуск ZITADEL.");
|
||||
const res = await fetch(`${env.ZITADEL_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data = {};
|
||||
try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
|
||||
|
||||
if (!res.ok) {
|
||||
debug("zFetch failed", path, res.status, data);
|
||||
const msg = data.message || data.raw || `ZITADEL API error ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function waitForZitadel() {
|
||||
try {
|
||||
const res = await fetch(`${env.ZITADEL_URL}/debug/ready`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setupOidcApp() {
|
||||
if (state.clientId && state.clientSecret) return;
|
||||
if (state.setupTried) return;
|
||||
if (!(await waitForZitadel())) return;
|
||||
|
||||
const token = adminToken();
|
||||
if (!token) return;
|
||||
|
||||
state.setupTried = true;
|
||||
|
||||
// Create project via V1 Management API.
|
||||
let project = null;
|
||||
|
||||
try {
|
||||
const search = await zFetch("/management/v1/projects/_search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
queries: [{ nameQuery: { name: env.ZITADEL_PROJECT_NAME, method: "TEXT_QUERY_METHOD_EQUALS" } }]
|
||||
})
|
||||
}, token);
|
||||
|
||||
project = (search.result || search.projects || []).find(p => p.name === env.ZITADEL_PROJECT_NAME);
|
||||
} catch (e) {
|
||||
debug("project search failed", e.message);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
const created = await zFetch("/management/v1/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: env.ZITADEL_PROJECT_NAME,
|
||||
projectRoleAssertion: false,
|
||||
projectRoleCheck: false,
|
||||
hasProjectCheck: false
|
||||
})
|
||||
}, token);
|
||||
|
||||
project = { id: created.id || created.projectId };
|
||||
}
|
||||
|
||||
const projectId = project.id || project.projectId;
|
||||
state.projectId = projectId;
|
||||
|
||||
if (!projectId) throw new Error("Не удалось получить projectId ZITADEL.");
|
||||
|
||||
// Create OIDC app. If it already exists, user can put client values in .env.
|
||||
const app = await zFetch(`/management/v1/projects/${projectId}/apps/oidc`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: env.OIDC_APP_NAME,
|
||||
redirectUris: [env.OIDC_REDIRECT_URI],
|
||||
responseTypes: ["OIDC_RESPONSE_TYPE_CODE"],
|
||||
grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
|
||||
appType: "OIDC_APP_TYPE_WEB",
|
||||
authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC",
|
||||
version: "OIDC_VERSION_1_0",
|
||||
devMode: true,
|
||||
accessTokenType: "OIDC_TOKEN_TYPE_JWT",
|
||||
idTokenUserinfoAssertion: true
|
||||
})
|
||||
}, token);
|
||||
|
||||
state.clientId = app.clientId;
|
||||
state.clientSecret = app.clientSecret;
|
||||
persistOidcClientConfig(state.clientId, state.clientSecret);
|
||||
|
||||
debug("OIDC app created", { projectId, clientId: state.clientId });
|
||||
}
|
||||
|
||||
async function createUser({ fullName, email, password }) {
|
||||
const parts = fullName.trim().split(/\s+/);
|
||||
const givenName = parts[0] || fullName;
|
||||
const familyName = parts.slice(1).join(" ") || "User";
|
||||
|
||||
return await zFetch("/v2/users/human", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: email,
|
||||
profile: {
|
||||
givenName,
|
||||
familyName,
|
||||
displayName: fullName,
|
||||
preferredLanguage: "ru"
|
||||
},
|
||||
email: {
|
||||
email,
|
||||
isVerified: true
|
||||
},
|
||||
password: {
|
||||
password,
|
||||
changeRequired: false
|
||||
}
|
||||
})
|
||||
}, adminToken());
|
||||
}
|
||||
|
||||
async function createAuthRequest(loginHint = "") {
|
||||
await setupOidcApp();
|
||||
|
||||
const clientId = state.clientId || env.OIDC_CLIENT_ID;
|
||||
if (!clientId) throw new Error("OIDC app не настроен. Укажите OIDC_CLIENT_ID/OIDC_CLIENT_SECRET в .env.");
|
||||
|
||||
const stateValue = crypto.randomBytes(24).toString("base64url");
|
||||
const verifier = crypto.randomBytes(48).toString("base64url");
|
||||
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: env.OIDC_REDIRECT_URI,
|
||||
response_type: "code",
|
||||
scope: "openid email profile",
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: stateValue
|
||||
});
|
||||
|
||||
if (loginHint) params.set("login_hint", loginHint);
|
||||
|
||||
return {
|
||||
authorizeUrl: `${env.ZITADEL_PUBLIC_URL}/oauth/v2/authorize?${params}`,
|
||||
stateValue,
|
||||
verifier
|
||||
};
|
||||
}
|
||||
|
||||
function absoluteZitadelUrl(location) {
|
||||
return new URL(location, env.ZITADEL_URL).toString();
|
||||
}
|
||||
|
||||
function updateCookieJar(jar, headers) {
|
||||
const setCookies = typeof headers.getSetCookie === "function"
|
||||
? headers.getSetCookie()
|
||||
: (headers.get("set-cookie") ? [headers.get("set-cookie")] : []);
|
||||
|
||||
for (const raw of setCookies) {
|
||||
const [pair] = String(raw || "").split(";");
|
||||
const index = pair.indexOf("=");
|
||||
if (index > 0) {
|
||||
jar.set(pair.slice(0, index), pair.slice(index + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cookieHeader(jar) {
|
||||
return Array.from(jar.entries()).map(([name, value]) => `${name}=${value}`).join("; ");
|
||||
}
|
||||
|
||||
async function zBrowserFetch(url, jar, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
const cookies = cookieHeader(jar);
|
||||
if (cookies) headers.set("Cookie", cookies);
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
redirect: "manual"
|
||||
});
|
||||
|
||||
updateCookieJar(jar, res.headers);
|
||||
return res;
|
||||
}
|
||||
|
||||
function extractInput(html, name) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = html.match(new RegExp(`name="${escaped}"[^>]*value="([^"]*)"`, "i"));
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function extractErrorMessage(html) {
|
||||
const match = html.match(/<p class="lgn-error-message">\s*([^<]+)\s*<\/p>/i);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return String(value || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function submitHostedLogin(email, password) {
|
||||
const auth = await createAuthRequest(email);
|
||||
const jar = new Map();
|
||||
|
||||
let res = await zBrowserFetch(auth.authorizeUrl, jar);
|
||||
let location = res.headers.get("location") || "";
|
||||
if (res.status !== 302 || !location) {
|
||||
throw new Error("ZITADEL не начал авторизацию.");
|
||||
}
|
||||
|
||||
res = await zBrowserFetch(absoluteZitadelUrl(location), jar);
|
||||
let html = await res.text();
|
||||
|
||||
let authRequestID = extractInput(html, "authRequestID");
|
||||
let csrf = extractInput(html, "gorilla.csrf.Token");
|
||||
if (!authRequestID || !csrf) {
|
||||
throw new Error(extractErrorMessage(html) || "ZITADEL не отдал форму логина.");
|
||||
}
|
||||
|
||||
res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/loginname"), jar, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
loginName: email,
|
||||
authRequestID,
|
||||
"gorilla.csrf.Token": csrf
|
||||
}).toString()
|
||||
});
|
||||
html = await res.text();
|
||||
|
||||
csrf = extractInput(html, "gorilla.csrf.Token");
|
||||
authRequestID = extractInput(html, "authRequestID") || authRequestID;
|
||||
const normalizedLogin = extractInput(html, "loginName") || email;
|
||||
|
||||
if (!csrf || !/action="\/ui\/login\/password"/i.test(html)) {
|
||||
throw new Error(extractErrorMessage(html) || "ZITADEL не отдал форму пароля.");
|
||||
}
|
||||
|
||||
res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/password"), jar, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
password,
|
||||
loginName: normalizedLogin,
|
||||
authRequestID,
|
||||
"gorilla.csrf.Token": csrf
|
||||
}).toString()
|
||||
});
|
||||
|
||||
for (let step = 0; step < 8; step += 1) {
|
||||
location = res.headers.get("location") || "";
|
||||
|
||||
if (location) {
|
||||
const nextUrl = new URL(location, env.ZITADEL_PUBLIC_URL);
|
||||
if (nextUrl.origin + nextUrl.pathname === env.OIDC_REDIRECT_URI) {
|
||||
const code = nextUrl.searchParams.get("code");
|
||||
const returnedState = nextUrl.searchParams.get("state");
|
||||
if (!code || returnedState !== auth.stateValue) {
|
||||
throw new Error("Некорректный callback от ZITADEL.");
|
||||
}
|
||||
|
||||
const idToken = await exchangeCode(code, auth.verifier);
|
||||
return idToken;
|
||||
}
|
||||
|
||||
res = await zBrowserFetch(absoluteZitadelUrl(nextUrl.pathname + nextUrl.search), jar);
|
||||
continue;
|
||||
}
|
||||
|
||||
html = await res.text();
|
||||
const errorMessage = extractErrorMessage(html);
|
||||
if (errorMessage) {
|
||||
throw new Error(stripTags(errorMessage));
|
||||
}
|
||||
|
||||
if (/action="\/ui\/login\/mfa\/prompt"/i.test(html)) {
|
||||
csrf = extractInput(html, "gorilla.csrf.Token");
|
||||
authRequestID = extractInput(html, "authRequestID") || authRequestID;
|
||||
res = await zBrowserFetch(absoluteZitadelUrl("/ui/login/mfa/prompt"), jar, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
skip: "true",
|
||||
authRequestID,
|
||||
"gorilla.csrf.Token": csrf
|
||||
}).toString()
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error("ZITADEL не завершил локальный вход.");
|
||||
}
|
||||
|
||||
throw new Error("ZITADEL не завершил локальный вход.");
|
||||
}
|
||||
|
||||
async function exchangeCode(code, verifier) {
|
||||
const clientId = state.clientId || env.OIDC_CLIENT_ID;
|
||||
const clientSecret = state.clientSecret || env.OIDC_CLIENT_SECRET;
|
||||
|
||||
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
||||
|
||||
const res = await fetch(`${env.ZITADEL_URL}/oauth/v2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${basic}`
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: env.OIDC_REDIRECT_URI,
|
||||
code_verifier: verifier
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
debug("token", res.status, data);
|
||||
|
||||
if (!res.ok || !data.id_token) {
|
||||
throw new Error("ZITADEL не выдал id_token.");
|
||||
}
|
||||
|
||||
return data.id_token;
|
||||
}
|
||||
|
||||
function page(title, body) {
|
||||
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}.jwt-raw{margin-top:16px;border:1px solid rgba(255,255,255,.09);background:rgba(0,0,0,.24);border-radius:18px;padding:14px}.jwt-raw b{display:block;margin-bottom:8px;color:var(--muted2);font-size:11px;text-transform:uppercase;letter-spacing:.12em}.jwt-raw pre{margin:0;white-space:pre-wrap;word-break:break-all;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}}
|
||||
</style>
|
||||
</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 /><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 || "");
|
||||
|
||||
try {
|
||||
if (!fullName || !email || !password) throw new Error("Проверьте заполненные данные.");
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) throw new Error(passwordError);
|
||||
await createUser({ fullName, email, password });
|
||||
return redirect(res, "/login", { "Set-Cookie": makeCookie("mm_flash", "registered", 120) });
|
||||
} catch (e) {
|
||||
return registerPage(req, res, `<div class="alert error">${escapeHtml(e.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 и пароль.");
|
||||
const idToken = await submitHostedLogin(email, password);
|
||||
const claims = decodeJwt(idToken);
|
||||
const signed = makeSigned({ id_token: idToken, claims });
|
||||
return redirect(res, "/dashboard", {
|
||||
"Set-Cookie": [
|
||||
makeCookie("mm_session", signed, 3600 * 8),
|
||||
clearCookie("mm_oidc")
|
||||
]
|
||||
});
|
||||
} catch (e) {
|
||||
return loginPage(req, res, `<div class="alert error">${escapeHtml(e.message || "Неверный email или пароль.")}</div>`, {
|
||||
"Set-Cookie": clearCookie("mm_oidc")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authStart(req, res) {
|
||||
const url = new URL(req.url, env.APP_URL);
|
||||
const loginHint = String(url.searchParams.get("login_hint") || "").trim().toLowerCase();
|
||||
|
||||
try {
|
||||
const auth = await createAuthRequest(loginHint);
|
||||
const oidc = makeSigned({ state: auth.stateValue, verifier: auth.verifier });
|
||||
return redirect(res, auth.authorizeUrl, { "Set-Cookie": makeCookie("mm_oidc", oidc, 600) });
|
||||
} catch (e) {
|
||||
return loginPage(req, res, `<div class="alert error">${escapeHtml(e.message || "Не удалось начать вход.")}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
async function authCallback(req, res) {
|
||||
const url = new URL(req.url, env.APP_URL);
|
||||
const code = url.searchParams.get("code");
|
||||
const returnedState = url.searchParams.get("state");
|
||||
const error = url.searchParams.get("error");
|
||||
const cookies = parseCookies(req);
|
||||
const oidc = readSigned(cookies.mm_oidc);
|
||||
|
||||
try {
|
||||
if (error) throw new Error(url.searchParams.get("error_description") || error);
|
||||
if (!oidc || !oidc.state || !oidc.verifier) throw new Error("Сессия входа истекла. Попробуйте снова.");
|
||||
if (!code || returnedState !== oidc.state) throw new Error("Некорректный callback от ZITADEL.");
|
||||
|
||||
const idToken = await exchangeCode(code, oidc.verifier);
|
||||
const claims = decodeJwt(idToken);
|
||||
const signed = makeSigned({ id_token: idToken, claims });
|
||||
|
||||
return redirect(res, "/dashboard", {
|
||||
"Set-Cookie": [
|
||||
makeCookie("mm_session", signed, 3600 * 8),
|
||||
clearCookie("mm_oidc")
|
||||
]
|
||||
});
|
||||
} catch (e) {
|
||||
return loginPage(req, res, `<div class="alert error">${escapeHtml(e.message || "Вход не завершён.")}</div>`, {
|
||||
"Set-Cookie": clearCookie("mm_oidc")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initials(name, email) {
|
||||
const source = name || email || "MM";
|
||||
return source.split(/[ ._-]+/).filter(Boolean).slice(0, 2).map(x => x[0]).join("").toUpperCase();
|
||||
}
|
||||
|
||||
function dashboard(req, res) {
|
||||
const session = getSession(req);
|
||||
if (!session) return redirect(res, "/login");
|
||||
|
||||
const claims = session.claims || {};
|
||||
const name = claims.name || claims.preferred_username || claims.email || "Клиент";
|
||||
const email = claims.email || "—";
|
||||
const iat = claims.iat ? new Date(claims.iat * 1000).toLocaleString("ru-RU") : "—";
|
||||
const exp = claims.exp ? new Date(claims.exp * 1000).toLocaleString("ru-RU") : "—";
|
||||
|
||||
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">${escapeHtml(initials(name, email))}</div><div><h1>${escapeHtml(name)}</h1><p>Данные ниже получены из JWT ZITADEL</p></div></div><div class="jwt-info"><h2>JWT</h2><div class="jwt-grid"><div class="jwt-item"><b>sub</b><span>${escapeHtml(claims.sub || "—")}</span></div><div class="jwt-item"><b>name</b><span>${escapeHtml(claims.name || "—")}</span></div><div class="jwt-item"><b>email</b><span>${escapeHtml(claims.email || "—")}</span></div><div class="jwt-item"><b>username</b><span>${escapeHtml(claims.preferred_username || claims.email || "—")}</span></div><div class="jwt-item"><b>iat</b><span>${escapeHtml(iat)}</span></div><div class="jwt-item"><b>exp</b><span>${escapeHtml(exp)}</span></div><div class="jwt-item"><b>iss</b><span>${escapeHtml(claims.iss || "—")}</span></div><div class="jwt-item"><b>aud</b><span>${escapeHtml(Array.isArray(claims.aud) ? claims.aud.join(", ") : claims.aud || "—")}</span></div></div><div class="jwt-raw"><b>Raw JWT from ZITADEL</b><pre>${escapeHtml(session.id_token || "—")}</pre></div></div></section></main>`;
|
||||
send(res, 200, page("Dashboard | Million Miles", body));
|
||||
}
|
||||
|
||||
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_session") });
|
||||
}
|
||||
|
||||
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">Аккаунт создан. Теперь войдите через форму ZITADEL.</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 === "/auth/start") return authStart(req, res);
|
||||
if (req.method === "GET" && url.pathname === "/auth/callback") return authCallback(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 === "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, () => {
|
||||
setupOidcApp().catch(e => debug("startup setup failed", e.message));
|
||||
console.log(`Million Miles ZITADEL POC on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user