Compare commits

...

2 Commits

8 changed files with 1014 additions and 2 deletions

View File

@ -0,0 +1,77 @@
# NODE.DC branded login RFC
## Цель
Пользователь должен видеть единое окно входа NODE.DC в визуальной логике текущего Plane login: темный фон, компактная карточка, NODE.DC logo, крупный заголовок, email/password, яркая CTA-кнопка.
При этом Authentik остается внутренним identity provider и продолжает владеть паролями, MFA, recovery, lockout, session, OAuth/OIDC и audit events.
## Жесткие запреты
Запрещенные варианты реализации:
- reverse proxy, который переписывает HTML Authentik на лету;
- frontend-only форма в Launcher, которая принимает email/password;
- BFF endpoint в Launcher, который принимает пароль пользователя и пересылает его в Authentik;
- OAuth password grant / ROPC;
- хранение паролей, refresh tokens или Authentik session cookies в frontend/localStorage;
- обход Authentik CSRF, MFA, recovery, lockout, enrollment и audit flow.
Эти варианты расширяют trusted boundary платформы: Launcher становится обработчиком паролей, а это ломает разделение ответственности и усложняет аудит.
## Выбранный безопасный путь
Базовый production-safe путь: Authentik-native branded flow.
- UI кастомизируется через Brand/Flow внутри Authentik.
- `branding_custom_css` задает NODE.DC look без HTML-прокси.
- Password stage, Identification stage, User Login stage, MFA/recovery остаются штатными Authentik stages.
- Identification stage связан со штатным Password stage, поэтому email и password показываются в одном Authentik challenge без передачи пароля в Launcher.
- Launcher и Task Manager остаются OIDC clients и не получают пароль пользователя.
- Direct links на Launcher/Task Manager продолжают идти через Authorization Code Flow + PKCE.
- OAuth2 provider end-session использует штатный Authentik invalidation flow с UserLogoutStage, чтобы пользователь не попадал на Authentik application logout dashboard.
Если Brand/CSS не даст pixel-level соответствие Plane login, следующий допустимый уровень — кастомный Authentik template/flow executor внутри Authentik deployment. Это тоже остается IdP-side custom UI, но требует отдельной security review при каждом upgrade Authentik.
## Runtime prototype
Локальный bootstrap настраивает Brand для `auth.local.nodedc`:
- title: `NODE.DC`;
- default authentication flow: `default-authentication-flow`;
- flow title: `Работайте во всех измерениях.`;
- default locale: `ru`;
- theme: `dark`;
- dark NODE.DC CSS из `/templates/branding/nodedc-login.css`;
- без изменения password/MFA/recovery mechanics.
Файлы:
- `infra/authentik/bootstrap-dev.py` — idempotent Brand/Flow bootstrap;
- `infra/authentik/custom-templates/branding/nodedc-login.css` — native Authentik Brand CSS.
## Security acceptance
Перед переводом в production нужно проверить:
- пароль не проходит через Launcher/BFF/network logs вне Authentik;
- CSRF token и Authentik cookies остаются штатными;
- MFA/passkeys/recovery/enrollment работают после стилизации;
- failed login/lockout/audit events остаются в Authentik;
- logout/SLO закрывает Launcher и app sessions;
- прямой заход `task.local.nodedc` и `launcher.local.nodedc` возвращает пользователя после login;
- UI не содержит слова `authentik` в пользовательском flow;
- кастомизация переживает restart контейнеров и повторный bootstrap;
- fallback admin URL к Authentik остается доступен только как служебный контур.
## Upgrade policy
Brand/CSS считается безопасным maintenance layer. Template override считается повышенным риском: перед обновлением Authentik нужно прогонять visual/security regression, потому что DOM web components может измениться.
## Источники
- Authentik Flows: https://docs.goauthentik.io/docs/add-secure-apps/flows-stages/flow/
- Authentik Stages: https://docs.goauthentik.io/docs/flow/stages
- Password stage: https://docs.goauthentik.io/add-secure-apps/flows-stages/stages/password/
- Custom CSS: https://docs.goauthentik.io/brands/custom-css/
- Single Logout: https://docs.goauthentik.io/add-secure-apps/providers/single-logout/

View File

@ -48,6 +48,8 @@ Production login должен быть NODE.DC-branded:
Запрещено делать быстрый frontend-only password form, который обходит IdP protections, хранит секреты в браузере или ломает MFA/recovery/audit.
Текущее безопасное решение зафиксировано в `docs/AUTH_BRANDED_LOGIN_RFC.md`: сначала используем Authentik-native Brand/CSS/Flow customization. Reverse proxy HTML-rewrite, password form в Launcher и пересылка пароля через BFF запрещены.
## Required claims
Минимальный normalized user object:

View File

@ -33,3 +33,13 @@ Later phases should add reproducible configuration for:
- groups and policies;
- admin service token scope;
- exports or blueprints for repeatable setup.
## NODE.DC branded login
`custom-templates/branding/nodedc-login.css` is mounted into Authentik at `/templates/branding/nodedc-login.css` and applied by `bootstrap-dev.py` through the native Authentik Brand `branding_custom_css` field.
`custom-templates/base/header_js.html` keeps Authentik's native config script and adds a minimal NODE.DC field enhancement for the email clear control and password placeholder only.
OAuth2 providers are assigned Authentik's `default-invalidation-flow` so application logout completes the IdP session and returns through the NODE.DC launcher route instead of showing the default Authentik application logout screen.
This is intentionally not an HTML-rewriting proxy. Passwords, MFA, recovery, sessions and audit remain inside Authentik; Launcher and Task Manager stay OIDC clients.

View File

@ -1,11 +1,13 @@
from os import environ
from pathlib import Path
from django.db import transaction
from authentik.brands.models import Brand
from authentik.common.oauth.constants import SubModes
from authentik.core.models import Application, Group, User
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.flows.models import Flow, FlowStageBinding
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import (
ClientTypes,
@ -16,6 +18,10 @@ from authentik.providers.oauth2.models import (
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.stages.identification.models import IdentificationStage
from authentik.stages.password.models import PasswordStage
BRANDING_CSS_PATH = Path("/templates/branding/nodedc-login.css")
GROUP_SPECS = [
("nodedc:superadmin", False),
@ -158,9 +164,67 @@ def default_scope_mappings():
return mappings
def read_branding_css():
if BRANDING_CSS_PATH.exists():
return BRANDING_CSS_PATH.read_text(encoding="utf-8")
return ""
def ensure_nodedc_brand():
auth_domain = environ.get("AUTH_DOMAIN", "auth.local.nodedc").strip() or "auth.local.nodedc"
authentication_flow = Flow.objects.get(slug="default-authentication-flow")
invalidation_flow = Flow.objects.get(slug="default-invalidation-flow")
authentication_flow.name = "NODE.DC authentication"
authentication_flow.title = "Работайте во всех измерениях."
authentication_flow.layout = "stacked"
authentication_flow.background = ""
authentication_flow.save()
identification_stage = IdentificationStage.objects.get(
name="default-authentication-identification"
)
password_stage = PasswordStage.objects.get(name="default-authentication-password")
password_stage.allow_show_password = True
password_stage.save()
identification_stage.user_fields = ["email"]
identification_stage.password_stage = password_stage
identification_stage.show_matched_user = False
identification_stage.enable_remember_me = False
identification_stage.save()
FlowStageBinding.objects.filter(target=authentication_flow, stage=password_stage).delete()
brand = Brand.objects.filter(domain=auth_domain).first()
if brand is None:
brand = Brand(domain=auth_domain)
Brand.objects.exclude(brand_uuid=brand.brand_uuid).update(default=False)
brand.default = True
brand.domain = auth_domain
brand.branding_title = "NODE.DC"
brand.branding_logo = ""
brand.branding_favicon = ""
brand.branding_custom_css = read_branding_css()
brand.flow_authentication = authentication_flow
brand.flow_invalidation = invalidation_flow
brand.attributes = {
**(brand.attributes or {}),
"settings": {
**((brand.attributes or {}).get("settings") or {}),
"locale": "ru",
"theme": {
**(((brand.attributes or {}).get("settings") or {}).get("theme") or {}),
"base": "dark",
},
},
}
brand.save()
return brand
def ensure_provider(spec, mappings):
authorization_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
invalidation_flow = Flow.objects.get(slug="default-provider-invalidation-flow")
invalidation_flow = Flow.objects.get(slug="default-invalidation-flow")
signing_key = (
CertificateKeyPair.objects.filter(name="authentik Self-signed Certificate").first()
or CertificateKeyPair.objects.first()
@ -228,6 +292,7 @@ def ensure_application(spec, provider, groups):
@transaction.atomic
def main():
brand = ensure_nodedc_brand()
groups = ensure_groups()
user = ensure_user_groups(groups)
mappings = default_scope_mappings()
@ -243,6 +308,7 @@ def main():
summary = {
"groups": list(groups),
"admin_user": user.email if user else None,
"brand": brand.domain,
"applications": [application.slug for application in applications],
"providers": [provider.name for provider in providers],
}

View File

@ -0,0 +1,297 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<script data-id="authentik-config">
"use strict";
window.authentik = {
locale: "ru",
config: JSON.parse('{{ config_json|escapejs }}' || "{}"),
brand: JSON.parse('{{ brand_json|escapejs }}' || "{}"),
versionFamily: "{{ version_family }}",
versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}",
api: {
base: "{{ base_url }}",
relBase: "{{ base_url_rel }}",
},
};
window.addEventListener("DOMContentLoaded", function () {
{% for message in messages %}
window.dispatchEvent(
new CustomEvent("ak-message", {
bubbles: true,
composed: true,
detail: {
level: "{{ message.tags|escapejs }}",
message: "{{ message.message|escapejs }}",
},
}),
);
{% endfor %}
});
</script>
<script data-id="nodedc-auth-field-enhancements">
"use strict";
(function () {
const logoSvg = `
<svg id="nodedc-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220.82 54.55" aria-hidden="true">
<defs>
<style>
.cls-1{fill:#e2e1e1;}
.cls-2{fill:#dbdbdb;stroke:#dbdbdb;stroke-miterlimit:10;stroke-width:0.75px;}
</style>
</defs>
<path class="cls-1" d="M52.8,23.61,46.92,33.76,41.05,23.61H52.8m18-10.39H23.06L46.92,54.55Z"></path>
<polygon class="cls-1" points="31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13"></polygon>
<path class="cls-2" d="M116.35,18.49V1h1.27l10.34,15V1h1.33V18.49H128l-10.34-15v15Z"></path>
<path class="cls-2" d="M140.43,18.64c-4.79,0-8.16-3.72-8.16-8.89S135.64.86,140.43.86s8.17,3.72,8.17,8.89S145.25,18.64,140.43,18.64Zm0-1.25c4,0,6.79-3.17,6.79-7.64s-2.77-7.64-6.79-7.64-6.77,3.17-6.77,7.64S136.44,17.39,140.43,17.39Z"></path>
<path class="cls-2" d="M151.6,18.49V1h5.1c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74ZM153,17.24h3.75c4.77,0,7.42-2.92,7.42-7.49s-2.65-7.49-7.42-7.49H153Z"></path>
<path class="cls-2" d="M168.49,1h10.77V2.26h-9.42V8.93h7.89v1.25h-7.89v7.06h9.74v1.25H168.49Z"></path>
<path class="cls-2" d="M188.88,18.49V1H194c5.54,0,8.79,3.42,8.79,8.74s-3.25,8.74-8.79,8.74Zm1.35-1.25H194c4.77,0,7.41-2.92,7.41-7.49S198.75,2.26,194,2.26h-3.75Z"></path>
<path class="cls-2" d="M205.15,9.75c0-5.24,3.19-8.89,8.11-8.89a6.8,6.8,0,0,1,7.1,5.52h-1.43a5.54,5.54,0,0,0-5.74-4.27c-4.05,0-6.64,3.17-6.64,7.64s2.54,7.64,6.59,7.64a5.46,5.46,0,0,0,5.74-4.29h1.43c-.75,3.52-3.4,5.54-7.15,5.54C208.27,18.64,205.15,15.05,205.15,9.75Z"></path>
</svg>
`;
const clearIcon = `
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"></circle>
<path d="M9 9l6 6M15 9l-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
`;
const authTranslations = new Map([
["Failed to authenticate.", "Не удалось выполнить вход."],
["Invalid credentials.", "Неверная почта или пароль."],
["Invalid password.", "Неверный пароль."],
["Incorrect password.", "Неверный пароль."],
["Authentication failed.", "Не удалось выполнить вход."],
["This field is required.", "Заполните это поле."],
["Please enter your password", "Введите пароль"],
["Please enter your password.", "Введите пароль."],
["Please enter a valid email address.", "Введите корректную электронную почту."],
["Caps Lock is enabled.", "Включён Caps Lock."],
["Forgot username or password?", "Забыли пароль?"],
["Forgot password?", "Забыли пароль?"],
["Remember me on this device", "Запомнить на этом устройстве"],
["Email", "Эл. почта"],
["Email or Username", "Эл. почта"],
["Password", "Пароль"],
["Log in", "Войти"],
["Continue", "Продолжить"],
["Show password", "Показать пароль"],
["Hide password", "Скрыть пароль"],
]);
function visitRoots(root, callback) {
callback(root);
root.querySelectorAll("*").forEach((element) => {
if (element.shadowRoot) {
visitRoots(element.shadowRoot, callback);
}
});
}
function ensureLogo() {
if (!document.body || document.querySelector("[data-nodedc-auth-logo='true']")) return;
document.body.classList.add("nodedc-auth-enhanced");
const logo = document.createElement("div");
logo.className = "nodedc-auth-logo";
logo.dataset.nodedcAuthLogo = "true";
logo.innerHTML = logoSvg;
document.body.appendChild(logo);
}
function translateTextValue(value) {
if (!value) return value;
const trimmed = value.trim();
const translated = authTranslations.get(trimmed);
if (!translated) return value;
return value.replace(trimmed, translated);
}
function translateAuthText(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || !node.nodeValue?.trim()) return NodeFilter.FILTER_REJECT;
if (["SCRIPT", "STYLE", "SVG", "PATH", "POLYGON"].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
textNodes.forEach((node) => {
const translated = translateTextValue(node.nodeValue);
if (translated !== node.nodeValue) {
node.nodeValue = translated;
}
});
root.querySelectorAll("[aria-label], [title], [placeholder], input[value], button").forEach((element) => {
["aria-label", "title", "placeholder"].forEach((attribute) => {
const value = element.getAttribute(attribute);
const translated = translateTextValue(value);
if (translated !== value) element.setAttribute(attribute, translated);
});
});
}
function enhanceIdentifierField(root) {
const input = root.querySelector("#ak-identifier-input");
if (!input || input.dataset.nodedcClearBound === "true") return;
const fieldGroup = input.closest(".pf-c-form__group") || input.parentElement;
if (!fieldGroup) return;
input.dataset.nodedcClearBound = "true";
fieldGroup.style.position = "relative";
const button = document.createElement("button");
button.type = "button";
button.tabIndex = -1;
button.className = "nodedc-auth-clear-input";
button.dataset.nodedcClearInput = "true";
button.setAttribute("aria-label", "Очистить электронную почту");
button.innerHTML = clearIcon;
const update = () => {
button.dataset.visible = input.value ? "true" : "false";
};
button.addEventListener("click", (event) => {
event.preventDefault();
input.value = "";
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
input.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
input.focus();
update();
});
input.addEventListener("input", update);
input.addEventListener("change", update);
fieldGroup.appendChild(button);
update();
}
function maskPasswordInput(input) {
if (!input || input.type !== "text") return;
input.type = "password";
const button = input.closest(".pf-c-input-group")?.querySelector(".pf-c-button.pf-m-control");
const icon = button?.querySelector("i");
button?.setAttribute("aria-label", "Показать пароль");
icon?.classList.remove("fa-eye-slash");
icon?.classList.add("fa-eye");
}
function maskVisiblePasswords(target) {
visitRoots(document, (root) => {
root.querySelectorAll("#ak-stage-identification-password, #ak-stage-password-input").forEach((input) => {
const inputGroup = input.closest(".pf-c-input-group");
if (inputGroup?.contains(target)) return;
maskPasswordInput(input);
});
});
}
function enhancePasswordFields(root) {
root.querySelectorAll("#ak-stage-identification-password, #ak-stage-password-input").forEach((input) => {
if (input.placeholder !== "Введите пароль") {
input.placeholder = "Введите пароль";
}
if (input.dataset.nodedcBlurBound === "true") return;
input.dataset.nodedcBlurBound = "true";
input.addEventListener("blur", () => {
window.setTimeout(() => {
const inputGroup = input.closest(".pf-c-input-group");
if (inputGroup?.contains(document.activeElement)) return;
maskPasswordInput(input);
}, 0);
});
});
}
function normalizePasswordFields(root) {
enhancePasswordFields(root);
}
function enhanceSubmitHandoff(root) {
root.querySelectorAll("form").forEach((form) => {
if (form.dataset.nodedcSubmitBound === "true") return;
form.dataset.nodedcSubmitBound = "true";
form.addEventListener("submit", () => {
document.body?.classList.add("nodedc-auth-submitting");
}, true);
});
}
function hasAuthError(root) {
const hasErrorElement = root.querySelector(
".pf-c-alert, .pf-m-error, .pf-c-helper-text__item.pf-m-error, [aria-invalid='true']"
);
if (hasErrorElement) return true;
const text = root.textContent || "";
return [
"Failed to authenticate.",
"Не удалось выполнить вход.",
"Invalid credentials.",
"Неверная почта или пароль.",
"Invalid password.",
"Неверный пароль.",
].some((message) => text.includes(message));
}
function restoreCardOnErrors(root) {
if (hasAuthError(root)) {
document.body?.classList.remove("nodedc-auth-submitting");
}
}
let scheduled = false;
function enhanceAuthFields() {
scheduled = false;
ensureLogo();
visitRoots(document, (root) => {
enhanceIdentifierField(root);
normalizePasswordFields(root);
enhanceSubmitHandoff(root);
translateAuthText(root);
restoreCardOnErrors(root);
});
}
function scheduleEnhancement() {
if (scheduled) return;
scheduled = true;
window.requestAnimationFrame(enhanceAuthFields);
}
window.addEventListener("DOMContentLoaded", scheduleEnhancement);
window.addEventListener("load", scheduleEnhancement);
document.addEventListener("pointerdown", (event) => {
const target = event.composedPath?.()[0] || event.target;
maskVisiblePasswords(target);
}, true);
window.addEventListener("blur", () => maskVisiblePasswords(null));
new MutationObserver(scheduleEnhancement).observe(document.documentElement, {
childList: true,
subtree: true,
});
})();
</script>

View File

@ -0,0 +1,553 @@
:root,
:host {
--ak-global--primary: rgb(195, 255, 102);
--ak-global--primary-hover: rgb(218, 255, 139);
--ak-global--background: #0e0f10;
--ak-global--background-image: none !important;
--ak-c-login--MaxWidth: 28rem;
--ak-c-login--spacer: 2.2rem;
--ak-c-login__main--BackgroundColor: transparent;
--ak-c-login__main--Color: #f3f3f3;
--ak-c-login__main--BoxShadow: none;
--ak-c-login__footer--PaddingBlock: 0;
--pf-global--primary-color--100: rgb(195, 255, 102);
--pf-global--primary-color--200: rgb(218, 255, 139);
--pf-global--link--Color: rgb(195, 255, 102);
--pf-global--link--Color--hover: rgb(218, 255, 139);
--pf-global--BackgroundColor--100: #0e0f10;
--pf-global--BackgroundColor--light-100: transparent;
--pf-global--Color--100: #f3f3f3;
--pf-global--Color--200: #9d9da3;
--pf-global--BorderRadius--lg: 1.9rem;
--pf-global--BorderRadius--sm: 1.15rem;
--pf-v5-global--BorderRadius--lg: 1.9rem;
--pf-v5-global--BorderRadius--sm: 1.15rem;
--pf-v6-global--BorderRadius--large: 1.9rem;
--pf-c-form-control--BackgroundColor: rgba(255, 255, 255, 0.04);
--pf-c-form-control--BorderColor: transparent;
--pf-c-form-control--BorderBottomColor: transparent;
--pf-v5-c-form-control--BackgroundColor: rgba(255, 255, 255, 0.04);
--pf-v5-c-form-control--BorderColor: transparent;
--pf-v5-c-form-control--BorderBottomColor: transparent;
--nodedc-auth-bg: #0e0f10;
--nodedc-auth-card-bg: rgba(9, 9, 12, 0.84);
--nodedc-auth-primary: rgb(195, 255, 102);
--nodedc-auth-primary-hover: rgb(218, 255, 139);
--nodedc-auth-on-primary: rgb(11, 17, 23);
--nodedc-auth-text-primary: #e4e6e7;
--nodedc-auth-text-secondary: #cacdce;
--nodedc-auth-text-placeholder: #959a9d;
color-scheme: dark;
}
html,
body,
.pf-c-login {
background: var(--nodedc-auth-bg) !important;
color: var(--nodedc-auth-text-primary) !important;
font-family: Inter, "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
font-synthesis: none;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
:host {
background: transparent !important;
color: inherit !important;
font-family: Inter, "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif !important;
font-synthesis: none;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
:host(.pf-c-login) {
display: flex !important;
min-width: 100vw !important;
min-height: 100vh !important;
align-items: center !important;
justify-content: center !important;
background: var(--nodedc-auth-bg) !important;
color: var(--nodedc-auth-text-primary) !important;
}
body::before,
.pf-c-login::before {
background: none !important;
}
body:not(.nodedc-auth-enhanced)::after {
content: "";
display: block;
position: fixed;
top: 1.6rem;
left: 1.25rem;
width: 7.25rem;
height: 1.79rem;
background: center / contain no-repeat url("data:image/svg+xml,%3Csvg id='nodedc-logo' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 220.82 54.55'%3E%3Cpath fill='%23e2e1e1' d='M52.8 23.61 46.92 33.76 41.05 23.61H52.8m18-10.39H23.06l23.86 41.33Z'/%3E%3Cpolygon fill='%23e2e1e1' points='31.28 33.13 18.11 10.34 75.73 10.34 62.59 33.13 74.28 33.13 93.22 0 0 0 19.61 33.13 31.28 33.13'/%3E%3Cpath fill='%23dbdbdb' d='M116.35 18.49V1h1.27l10.34 15V1h1.33v17.49H128l-10.34-15v15Zm24.08.15c-4.79 0-8.16-3.72-8.16-8.89S135.64.86 140.43.86s8.17 3.72 8.17 8.89-3.35 8.89-8.17 8.89Zm0-1.25c4 0 6.79-3.17 6.79-7.64s-2.77-7.64-6.79-7.64-6.77 3.17-6.77 7.64 2.78 7.64 6.77 7.64ZM151.6 18.49V1h5.1c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.4-1.25h3.75c4.77 0 7.42-2.92 7.42-7.49s-2.65-7.49-7.42-7.49H153ZM168.49 1h10.77v1.26h-9.42v6.67h7.89v1.25h-7.89v7.06h9.74v1.25h-11.09Zm20.39 17.49V1H194c5.54 0 8.79 3.42 8.79 8.74s-3.25 8.74-8.79 8.74Zm1.35-1.25H194c4.77 0 7.41-2.92 7.41-7.49S198.75 2.26 194 2.26h-3.75Zm14.92-7.49c0-5.24 3.19-8.89 8.11-8.89a6.8 6.8 0 0 1 7.1 5.52h-1.43a5.54 5.54 0 0 0-5.74-4.27c-4.05 0-6.64 3.17-6.64 7.64s2.54 7.64 6.59 7.64a5.46 5.46 0 0 0 5.74-4.29h1.43c-.75 3.52-3.4 5.54-7.15 5.54-4.89 0-8.01-3.59-8.01-8.89Z'/%3E%3C/svg%3E");
image-rendering: auto;
z-index: 20;
pointer-events: none;
}
.nodedc-auth-logo {
position: fixed;
top: 1.6rem;
left: 1.25rem;
width: 7.25rem;
height: 1.79rem;
z-index: 20;
pointer-events: none;
color: var(--nodedc-auth-text-primary);
}
.nodedc-auth-logo svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
shape-rendering: geometricPrecision;
text-rendering: geometricPrecision;
}
.pf-c-page__drawer,
.pf-c-drawer,
.pf-c-drawer__main,
.pf-c-drawer__content,
.pf-c-drawer__body {
background: transparent !important;
}
.pf-c-drawer__body {
display: flex !important;
min-height: 100vh !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
}
ak-flow-executor.pf-c-login::before,
:host(.pf-c-login)::before {
display: none !important;
}
ak-flow-executor.pf-c-login {
display: flex !important;
width: 100% !important;
min-height: 100vh !important;
position: relative !important;
align-items: center !important;
justify-content: center !important;
padding-right: 0 !important;
box-sizing: border-box !important;
}
ak-brand-links,
ak-flow-inspector,
ak-locale-context,
ak-locale-switcher,
ak-locale-select,
ak-flow-executor [slot="footer"],
.pf-c-login__footer,
.pf-c-login__header,
.pf-c-login__main-header.pf-c-brand,
.pf-c-form > p:first-child {
display: none !important;
}
ak-flow-executor::part(locale-select),
ak-flow-executor::part(footer),
ak-flow-executor::part(branding) {
display: none !important;
}
ak-flow-executor::part(main),
.pf-c-login__main {
position: fixed !important;
top: 50% !important;
left: 50% !important;
right: auto !important;
transform: translate(-50%, -50%) !important;
width: min(100%, 28rem) !important;
max-width: 28rem !important;
min-height: 0 !important;
margin: 0 !important;
padding: 2.2rem !important;
box-sizing: border-box !important;
overflow: hidden !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 1.9rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%),
var(--nodedc-auth-card-bg) !important;
-webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
justify-content: flex-start !important;
}
ak-flow-card,
ak-stage-identification,
ak-stage-password {
display: block !important;
width: 100% !important;
background: transparent !important;
}
.pf-c-login__main-body,
.pf-c-form,
.pf-c-form__group,
.pf-c-form__group-control,
.pf-c-input-group,
.pf-c-input-group__item,
.pf-c-card,
.pf-c-card__body {
background: transparent !important;
box-shadow: none !important;
border: 0 !important;
}
.pf-c-login__main-header {
display: block !important;
width: 100% !important;
min-width: 0 !important;
padding: 0 0 1.75rem !important;
}
.pf-c-login__main-header .pf-c-title,
.pf-c-title.pf-m-3xl,
h1.pf-c-title {
display: block !important;
width: 100% !important;
max-width: 22rem !important;
margin: 0 !important;
color: var(--nodedc-auth-text-primary) !important;
font-size: 2rem !important;
line-height: 2.5rem !important;
font-weight: 650 !important;
letter-spacing: -0.045em !important;
text-wrap: balance !important;
white-space: normal !important;
word-break: normal !important;
overflow-wrap: normal !important;
hyphens: none !important;
overflow: visible !important;
line-clamp: unset !important;
-webkit-line-clamp: unset !important;
-webkit-box-orient: initial !important;
text-transform: none !important;
}
.pf-c-login__main-header::after {
content: "С возвращением в NODE.DC.";
display: block;
max-width: 22rem;
margin-top: 0.75rem;
color: var(--nodedc-auth-text-placeholder);
font-size: 1.9rem;
line-height: 2.5rem;
font-weight: 650;
letter-spacing: -0.045em;
text-transform: none;
}
.pf-c-login__main-body {
padding: 0 !important;
}
.pf-c-form {
display: flex !important;
flex-direction: column !important;
gap: 1.05rem !important;
}
.pf-c-form__group,
ak-flow-input-password.pf-c-form__group {
margin: 0 !important;
}
.pf-c-form__label,
.pf-c-form__label-text,
label {
color: var(--nodedc-auth-text-placeholder) !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
font-weight: 400 !important;
letter-spacing: -0.01em !important;
text-transform: none !important;
}
.pf-c-form__label-required {
display: none !important;
}
label[for="ak-identifier-input"],
label[for="ak-stage-identification-password"] {
font-size: 0 !important;
}
label[for="ak-identifier-input"] .pf-c-form__label-text,
label[for="ak-identifier-input"] .pf-c-form__label-required,
label[for="ak-stage-identification-password"] .pf-c-form__label-text,
label[for="ak-stage-identification-password"] .pf-c-form__label-required {
display: none !important;
}
label[for="ak-identifier-input"]::after {
content: "Эл. почта";
font-size: 0.75rem;
color: var(--nodedc-auth-text-placeholder);
font-weight: 400;
}
label[for="ak-stage-identification-password"]::after {
content: "Пароль";
font-size: 0.75rem;
color: var(--nodedc-auth-text-placeholder);
font-weight: 400;
}
.pf-c-form-control,
.pf-c-input-group .pf-c-form-control,
input.pf-c-form-control {
width: 100% !important;
min-height: 3rem !important;
padding: 0 1rem !important;
border: 0 !important;
border-bottom: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 1.15rem !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.03) !important;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
color: var(--nodedc-auth-text-primary) !important;
font-size: 0.875rem !important;
font-style: normal !important;
font-weight: 400 !important;
text-transform: none !important;
caret-color: var(--nodedc-auth-primary) !important;
}
.pf-c-form-control::placeholder,
input.pf-c-form-control::placeholder {
color: var(--nodedc-auth-text-placeholder) !important;
font-style: normal !important;
opacity: 1 !important;
}
.pf-c-form-control:focus,
input.pf-c-form-control:focus {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
rgba(255, 255, 255, 0.03) !important;
border: 0 !important;
border-bottom: 0 !important;
outline: none !important;
box-shadow: none !important;
}
.pf-c-form-control:-webkit-autofill,
.pf-c-form-control:-webkit-autofill:hover,
.pf-c-form-control:-webkit-autofill:focus,
input.pf-c-form-control:-webkit-autofill,
input.pf-c-form-control:-webkit-autofill:hover,
input.pf-c-form-control:-webkit-autofill:focus {
border: 0 !important;
outline: none !important;
-webkit-text-fill-color: var(--nodedc-auth-text-primary) !important;
caret-color: var(--nodedc-auth-primary) !important;
transition: background-color 999999s ease-in-out 0s !important;
box-shadow: inset 0 0 0 1000px rgba(255, 255, 255, 0.03) !important;
-webkit-box-shadow: inset 0 0 0 1000px rgba(255, 255, 255, 0.03) !important;
}
.pf-c-input-group {
position: relative !important;
display: block !important;
border: 0 !important;
box-shadow: none !important;
}
.pf-c-form__group:has(#ak-identifier-input) {
position: relative !important;
}
.pf-c-input-group .pf-c-button.pf-m-control {
position: absolute !important;
top: 0.5rem !important;
right: 0.75rem !important;
display: grid !important;
width: 2rem !important;
min-width: 2rem !important;
height: 2rem !important;
place-items: center !important;
padding: 0 !important;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
color: var(--nodedc-auth-text-placeholder) !important;
box-shadow: none !important;
text-decoration: none !important;
appearance: none !important;
-webkit-appearance: none !important;
}
.pf-c-input-group .pf-c-button.pf-m-control::before,
.pf-c-input-group .pf-c-button.pf-m-control::after,
.pf-c-input-group .pf-c-button.pf-m-control:hover::before,
.pf-c-input-group .pf-c-button.pf-m-control:hover::after,
.pf-c-input-group .pf-c-button.pf-m-control:focus::before,
.pf-c-input-group .pf-c-button.pf-m-control:focus::after {
display: none !important;
border: 0 !important;
box-shadow: none !important;
content: none !important;
}
.pf-c-input-group .pf-c-button.pf-m-control:hover,
.pf-c-input-group .pf-c-button.pf-m-control:focus,
.pf-c-input-group .pf-c-button.pf-m-control:active {
border: 0 !important;
outline: none !important;
box-shadow: none !important;
background: transparent !important;
text-decoration: none !important;
}
.pf-c-input-group .pf-c-button.pf-m-control i,
.nodedc-auth-clear-input svg {
width: 1.25rem !important;
height: 1.25rem !important;
color: var(--nodedc-auth-text-placeholder) !important;
opacity: 0.9 !important;
}
.pf-c-input-group input.pf-c-form-control {
padding-right: 3rem !important;
}
.nodedc-auth-clear-input {
position: absolute !important;
right: 0.75rem !important;
bottom: 0.5rem !important;
display: none !important;
width: 2rem !important;
height: 2rem !important;
padding: 0 !important;
place-items: center !important;
border: 0 !important;
border-radius: 999px !important;
background: transparent !important;
color: var(--nodedc-auth-text-placeholder) !important;
box-shadow: none !important;
cursor: pointer !important;
}
.nodedc-auth-clear-input[data-visible="true"] {
display: grid !important;
}
.pf-c-button.pf-m-primary,
button.pf-m-primary,
button[type="submit"],
.pf-c-form .pf-c-button.pf-m-primary {
width: 100% !important;
min-height: 3.25rem !important;
margin-top: 1.25rem !important;
border: 0 !important;
outline: none !important;
box-shadow: none !important;
border-radius: 1.25rem !important;
background: var(--nodedc-auth-primary) !important;
color: var(--nodedc-auth-on-primary) !important;
font-size: 0.95rem !important;
font-weight: 600 !important;
letter-spacing: -0.02em !important;
text-transform: none !important;
}
.pf-c-button.pf-m-primary:hover,
button.pf-m-primary:hover,
button[type="submit"]:hover {
background: var(--nodedc-auth-primary-hover) !important;
color: var(--nodedc-auth-on-primary) !important;
}
.pf-c-alert,
.pf-c-alert.pf-m-inline {
border: 0 !important;
border-radius: 1rem !important;
margin: 0 0 1.15rem !important;
padding: 0 !important;
background: transparent !important;
color: var(--nodedc-auth-primary) !important;
box-shadow: none !important;
}
.pf-c-alert__icon,
.pf-c-alert__title,
.pf-c-alert__description {
color: var(--nodedc-auth-primary) !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
font-weight: 600 !important;
}
a,
.pf-c-button.pf-m-link,
button.pf-m-link {
color: var(--nodedc-auth-primary) !important;
font-size: 0.75rem !important;
line-height: 1rem !important;
font-weight: 600 !important;
text-decoration: none !important;
}
body.nodedc-auth-submitting ak-flow-executor::part(main),
body.nodedc-auth-submitting .pf-c-login__main,
body.nodedc-auth-submitting .pf-c-empty-state,
body.nodedc-auth-submitting .pf-c-spinner,
body.nodedc-auth-submitting [role="progressbar"] {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
ak-library-application,
ak-application-wizard,
ak-user-interface,
.pf-c-page__sidebar,
.pf-c-page__header,
.pf-c-nav,
.pf-c-brand {
color-scheme: dark;
}
@media (max-width: 640px) {
ak-flow-executor.pf-c-login::before,
:host(.pf-c-login)::before {
display: none !important;
}
body::after,
.nodedc-auth-logo {
top: 1.6rem;
left: 1.25rem;
width: 7.25rem;
height: 1.79rem;
}
ak-flow-executor::part(main),
.pf-c-login__main {
position: fixed !important;
top: 50% !important;
right: auto !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
width: calc(100vw - 2rem) !important;
padding: 1.5rem !important;
}
}

View File

@ -3,6 +3,12 @@
}
http://{$AUTH_DOMAIN:auth.local.nodedc} {
@auth_root path /
redir @auth_root http://{$LAUNCHER_DOMAIN:launcher.local.nodedc}/ 302
@auth_user_dashboard path /if/user /if/user/*
redir @auth_user_dashboard http://{$LAUNCHER_DOMAIN:launcher.local.nodedc}/ 302
reverse_proxy authentik-server:9000 {
header_up Host {host}
header_up X-Forwarded-Proto {scheme}

View File

@ -67,6 +67,7 @@ set +a
cd "$ROOT_DIR"
docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" exec -T \
-e AUTH_DOMAIN="${AUTH_DOMAIN:-auth.local.nodedc}" \
-e NODEDC_BOOTSTRAP_ADMIN_EMAIL="${NODEDC_BOOTSTRAP_ADMIN_EMAIL:-}" \
-e NODEDC_BOOTSTRAP_ADMIN_PASSWORD="${NODEDC_BOOTSTRAP_ADMIN_PASSWORD:-}" \
-e LAUNCHER_OIDC_CLIENT_ID="$LAUNCHER_OIDC_CLIENT_ID" \