Authentik auth
This commit is contained in:
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
AUTHENTIK_IMAGE=docker.io/authentik/server
|
||||||
|
AUTHENTIK_TAG=2026.2.2
|
||||||
|
|
||||||
|
PG_DB=authentik
|
||||||
|
PG_USER=authentik
|
||||||
|
PG_PASS=change-me-postgres-password
|
||||||
|
|
||||||
|
AUTHENTIK_SECRET_KEY=change-me-authentik-secret-key
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL=admin@example.local
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD=change-me-admin-password
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN=change-me-bootstrap-token
|
||||||
|
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
SESSION_SECRET=change-me-session-secret
|
||||||
|
|
||||||
|
AUTHENTIK_PUBLIC_URL=http://localhost:9000
|
||||||
|
AUTHENTIK_INTERNAL_URL=http://host.docker.internal:9000
|
||||||
|
AUTHENTIK_API_TOKEN=change-me-api-token
|
||||||
|
|
||||||
|
AUTHENTIK_LOGIN_FLOW=mm-custom-authentication-flow
|
||||||
|
|
||||||
|
OIDC_CLIENT_ID=mm-custom-frontend
|
||||||
|
OIDC_CLIENT_SECRET=change-me-oidc-client-secret
|
||||||
|
OIDC_ISSUER=http://localhost:9000/application/o/mm-custom-frontend/
|
||||||
|
OIDC_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||||
|
AUTH_DEBUG=false
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
authentik-data/
|
||||||
|
frontend/node_modules/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
130
README.md
Normal file
130
README.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# Million Miles Auth
|
||||||
|
|
||||||
|
Локальный POC с `Authentik` и кастомным frontend:
|
||||||
|
|
||||||
|
- регистрация пользователя на своей форме
|
||||||
|
- вход пользователя на своей форме
|
||||||
|
- Authentik используется как backend авторизации
|
||||||
|
- после входа frontend показывает данные из `id_token`
|
||||||
|
|
||||||
|
## Что в проекте
|
||||||
|
|
||||||
|
- `docker-compose.yml` поднимает `postgresql`, `redis`, `authentik`, `authentik-worker` и `frontend`
|
||||||
|
- `authentik/blueprints/mm-custom-authentication-flow.yaml` создаёт flow, provider и application
|
||||||
|
- `frontend/server.js` содержит UI и логику регистрации/входа
|
||||||
|
|
||||||
|
## Порты
|
||||||
|
|
||||||
|
- frontend: `http://localhost:3000`
|
||||||
|
- Authentik admin: `http://localhost:9000`
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Если нужно полностью пересобрать проект с чистыми данными:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый запуск Authentik может занять несколько минут. Нужно дождаться, пока `authentik-worker` применит blueprint.
|
||||||
|
|
||||||
|
## Удобный локальный `.env` для теста
|
||||||
|
|
||||||
|
Для быстрого ручного тестирования можно использовать такие локальные значения в `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTHENTIK_IMAGE=docker.io/authentik/server
|
||||||
|
AUTHENTIK_TAG=2026.2.2
|
||||||
|
|
||||||
|
PG_DB=authentik
|
||||||
|
PG_USER=authentik
|
||||||
|
PG_PASS=authentik
|
||||||
|
|
||||||
|
AUTHENTIK_SECRET_KEY=change-me-local-secret
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL=admin@millionmiles.local
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD=admin123456
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN=local-bootstrap-token-change-me
|
||||||
|
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
SESSION_SECRET=local-session-secret-change-me
|
||||||
|
|
||||||
|
AUTHENTIK_PUBLIC_URL=http://localhost:9000
|
||||||
|
AUTHENTIK_INTERNAL_URL=http://host.docker.internal:9000
|
||||||
|
AUTHENTIK_API_TOKEN=local-bootstrap-token-change-me
|
||||||
|
|
||||||
|
AUTHENTIK_LOGIN_FLOW=mm-custom-authentication-flow
|
||||||
|
|
||||||
|
OIDC_CLIENT_ID=mm-custom-frontend
|
||||||
|
OIDC_CLIENT_SECRET=mm-custom-secret
|
||||||
|
OIDC_ISSUER=http://localhost:9000/application/o/mm-custom-frontend/
|
||||||
|
OIDC_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||||
|
AUTH_DEBUG=false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как зайти в админку Authentik
|
||||||
|
|
||||||
|
|
||||||
|
```txt
|
||||||
|
http://localhost:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
Логин администратора берётся из `.env`:
|
||||||
|
|
||||||
|
- email: значение `AUTHENTIK_BOOTSTRAP_EMAIL`
|
||||||
|
- password: значение `AUTHENTIK_BOOTSTRAP_PASSWORD`
|
||||||
|
|
||||||
|
Или
|
||||||
|
|
||||||
|
```txt
|
||||||
|
admin@millionmiles.local
|
||||||
|
admin123456
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что создаётся автоматически
|
||||||
|
|
||||||
|
Blueprint создаёт:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Flow: Million Miles Custom Authentication
|
||||||
|
Slug: mm-custom-authentication-flow
|
||||||
|
|
||||||
|
OAuth2/OIDC Provider: Million Miles Custom Frontend
|
||||||
|
Application slug: mm-custom-frontend
|
||||||
|
Client ID: mm-custom-frontend
|
||||||
|
Client Secret: mm-custom-secret
|
||||||
|
Redirect URI: http://localhost:3000/auth/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как быстро всё протестировать
|
||||||
|
|
||||||
|
1. Открой `http://localhost:3000/register`
|
||||||
|
2. Зарегистрируй нового пользователя
|
||||||
|
3. После регистрации тебя перекинет на `/login`
|
||||||
|
4. Войди с только что созданными `email/password`
|
||||||
|
5. После успешного входа откроется `/dashboard`
|
||||||
|
6. На dashboard увидишь данные пользователя из JWT `id_token`
|
||||||
|
|
||||||
|
## Что проверить в админке
|
||||||
|
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Applications -> Applications -> Million Miles
|
||||||
|
Applications -> Providers -> Million Miles Custom Frontend
|
||||||
|
Flows and Stages -> Flows -> Million Miles Custom Authentication
|
||||||
|
Directory -> Users
|
||||||
|
```
|
||||||
|
|
||||||
|
## Если OIDC provider ещё не применился
|
||||||
|
|
||||||
|
Если frontend пишет, что провайдер ещё не готов, проверь worker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f authentik-worker
|
||||||
|
```
|
||||||
|
|
||||||
100
authentik/blueprints/mm-custom-authentication-flow.yaml
Normal file
100
authentik/blueprints/mm-custom-authentication-flow.yaml
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
version: 1
|
||||||
|
metadata:
|
||||||
|
name: Million Miles Authentik POC
|
||||||
|
entries:
|
||||||
|
- model: authentik_flows.flow
|
||||||
|
id: mm-custom-authentication-flow
|
||||||
|
identifiers:
|
||||||
|
slug: mm-custom-authentication-flow
|
||||||
|
attrs:
|
||||||
|
name: Million Miles Custom Authentication
|
||||||
|
title: Million Miles
|
||||||
|
designation: authentication
|
||||||
|
authentication: none
|
||||||
|
layout: stacked
|
||||||
|
denied_action: message_continue
|
||||||
|
|
||||||
|
- model: authentik_stages_password.passwordstage
|
||||||
|
id: mm-custom-authentication-password
|
||||||
|
identifiers:
|
||||||
|
name: mm-custom-authentication-password
|
||||||
|
attrs:
|
||||||
|
backends:
|
||||||
|
- authentik.core.auth.InbuiltBackend
|
||||||
|
failed_attempts_before_cancel: 5
|
||||||
|
allow_show_password: true
|
||||||
|
|
||||||
|
- model: authentik_stages_identification.identificationstage
|
||||||
|
id: mm-custom-authentication-identification
|
||||||
|
identifiers:
|
||||||
|
name: mm-custom-authentication-identification
|
||||||
|
attrs:
|
||||||
|
user_fields:
|
||||||
|
- email
|
||||||
|
- username
|
||||||
|
case_insensitive_matching: true
|
||||||
|
show_matched_user: false
|
||||||
|
password_stage: !KeyOf mm-custom-authentication-password
|
||||||
|
|
||||||
|
- model: authentik_stages_user_login.userloginstage
|
||||||
|
id: mm-custom-authentication-login
|
||||||
|
identifiers:
|
||||||
|
name: mm-custom-authentication-login
|
||||||
|
attrs:
|
||||||
|
session_duration: hours=8
|
||||||
|
|
||||||
|
- model: authentik_flows.flowstagebinding
|
||||||
|
identifiers:
|
||||||
|
target: !KeyOf mm-custom-authentication-flow
|
||||||
|
stage: !KeyOf mm-custom-authentication-identification
|
||||||
|
order: 10
|
||||||
|
attrs:
|
||||||
|
re_evaluate_policies: true
|
||||||
|
invalid_response_action: retry
|
||||||
|
policy_engine_mode: all
|
||||||
|
|
||||||
|
- model: authentik_flows.flowstagebinding
|
||||||
|
identifiers:
|
||||||
|
target: !KeyOf mm-custom-authentication-flow
|
||||||
|
stage: !KeyOf mm-custom-authentication-login
|
||||||
|
order: 20
|
||||||
|
attrs:
|
||||||
|
re_evaluate_policies: true
|
||||||
|
invalid_response_action: retry
|
||||||
|
policy_engine_mode: all
|
||||||
|
|
||||||
|
- model: authentik_providers_oauth2.oauth2provider
|
||||||
|
id: mm-custom-frontend-provider
|
||||||
|
identifiers:
|
||||||
|
name: Million Miles Custom Frontend
|
||||||
|
attrs:
|
||||||
|
name: Million Miles Custom Frontend
|
||||||
|
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||||
|
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||||
|
client_type: confidential
|
||||||
|
client_id: mm-custom-frontend
|
||||||
|
client_secret: mm-custom-secret
|
||||||
|
access_code_validity: minutes=10
|
||||||
|
access_token_validity: minutes=5
|
||||||
|
refresh_token_validity: days=30
|
||||||
|
include_claims_in_id_token: true
|
||||||
|
issuer_mode: per_provider
|
||||||
|
sub_mode: hashed_user_id
|
||||||
|
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
|
||||||
|
redirect_uris:
|
||||||
|
- url: http://localhost:3000/auth/callback
|
||||||
|
matching_mode: strict
|
||||||
|
property_mappings:
|
||||||
|
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
|
||||||
|
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, email]]
|
||||||
|
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, profile]]
|
||||||
|
|
||||||
|
- model: authentik_core.application
|
||||||
|
id: mm-custom-frontend-application
|
||||||
|
identifiers:
|
||||||
|
slug: mm-custom-frontend
|
||||||
|
attrs:
|
||||||
|
name: Million Miles
|
||||||
|
slug: mm-custom-frontend
|
||||||
|
provider: !KeyOf mm-custom-frontend-provider
|
||||||
|
policy_engine_mode: any
|
||||||
1
custom-templates/.gitkeep
Normal file
1
custom-templates/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Place custom Authentik templates here.
|
||||||
111
docker-compose.yml
Normal file
111
docker-compose.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
services:
|
||||||
|
postgresql:
|
||||||
|
image: docker.io/library/postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${PG_DB:-authentik}
|
||||||
|
POSTGRES_USER: ${PG_USER:-authentik}
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASS:-authentik}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
volumes:
|
||||||
|
- database:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: docker.io/library/redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --save "" --appendonly no
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
authentik:
|
||||||
|
image: ${AUTHENTIK_IMAGE:-docker.io/authentik/server}:${AUTHENTIK_TAG:-2026.2.2}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: postgresql
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:-authentik}
|
||||||
|
AUTHENTIK_REDIS__HOST: redis
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||||
|
AUTHENTIK_LOG_LEVEL: info
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9000:9000"
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./authentik-data:/data
|
||||||
|
- ./custom-templates:/templates
|
||||||
|
- ./authentik/blueprints:/blueprints/custom
|
||||||
|
|
||||||
|
authentik-worker:
|
||||||
|
image: ${AUTHENTIK_IMAGE:-docker.io/authentik/server}:${AUTHENTIK_TAG:-2026.2.2}
|
||||||
|
restart: unless-stopped
|
||||||
|
command: worker
|
||||||
|
user: root
|
||||||
|
environment:
|
||||||
|
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||||
|
AUTHENTIK_POSTGRESQL__HOST: postgresql
|
||||||
|
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||||
|
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:-authentik}
|
||||||
|
AUTHENTIK_REDIS__HOST: redis
|
||||||
|
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
|
||||||
|
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
|
||||||
|
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
|
||||||
|
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||||
|
AUTHENTIK_LOG_LEVEL: info
|
||||||
|
depends_on:
|
||||||
|
postgresql:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./authentik-data:/data
|
||||||
|
- ./custom-templates:/templates
|
||||||
|
- ./authentik/blueprints:/blueprints/custom
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
APP_URL: ${APP_URL}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
AUTHENTIK_PUBLIC_URL: ${AUTHENTIK_PUBLIC_URL}
|
||||||
|
AUTHENTIK_INTERNAL_URL: ${AUTHENTIK_INTERNAL_URL}
|
||||||
|
AUTHENTIK_API_TOKEN: ${AUTHENTIK_API_TOKEN}
|
||||||
|
AUTHENTIK_LOGIN_FLOW: ${AUTHENTIK_LOGIN_FLOW}
|
||||||
|
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||||
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||||
|
OIDC_ISSUER: ${OIDC_ISSUER}
|
||||||
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
|
||||||
|
AUTH_DEBUG: ${AUTH_DEBUG}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
|
depends_on:
|
||||||
|
authentik:
|
||||||
|
condition: service_started
|
||||||
|
authentik-worker:
|
||||||
|
condition: service_started
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
database:
|
||||||
8
frontend/.dockerignore
Normal file
8
frontend/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
7
frontend/Dockerfile
Normal file
7
frontend/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY server.js ./server.js
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
737
frontend/server.js
Normal file
737
frontend/server.js
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
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",
|
||||||
|
SESSION_SECRET: process.env.SESSION_SECRET || "change-me-session-secret",
|
||||||
|
AUTHENTIK_PUBLIC_URL: process.env.AUTHENTIK_PUBLIC_URL || "http://localhost:9000",
|
||||||
|
AUTHENTIK_INTERNAL_URL: process.env.AUTHENTIK_INTERNAL_URL || "http://host.docker.internal:9000",
|
||||||
|
AUTHENTIK_API_TOKEN: process.env.AUTHENTIK_API_TOKEN || "change-me-api-token",
|
||||||
|
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",
|
||||||
|
AUTH_DEBUG: process.env.AUTH_DEBUG === "true"
|
||||||
|
};
|
||||||
|
|
||||||
|
function debug(...args) {
|
||||||
|
if (env.AUTH_DEBUG) console.log("[auth-debug]", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? "").replace(/[&<>"']/g, (s) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
}[s]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64url(input) {
|
||||||
|
return Buffer.from(input).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomString(bytes = 32) {
|
||||||
|
return crypto.randomBytes(bytes).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256base64url(value) {
|
||||||
|
return crypto.createHash("sha256").update(value).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(value) {
|
||||||
|
return crypto.createHmac("sha256", env.SESSION_SECRET).update(value).digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSigned(value) {
|
||||||
|
const payload = b64url(JSON.stringify(value));
|
||||||
|
return `${payload}.${sign(payload)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSigned(value) {
|
||||||
|
if (!value || !value.includes(".")) return null;
|
||||||
|
const [payload, mac] = value.split(".");
|
||||||
|
if (sign(payload) !== mac) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCookie(name, value, maxAge = 3600) {
|
||||||
|
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 idx = pair.indexOf("=");
|
||||||
|
if (idx > 0) jar[pair.slice(0, idx)] = pair.slice(idx + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieHeader(jar) {
|
||||||
|
return Object.entries(jar).map(([k, v]) => `${k}=${v}`).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 i = 0; i < limit; i++) {
|
||||||
|
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 decodeJwt(token) {
|
||||||
|
try {
|
||||||
|
const [, payload] = token.split(".");
|
||||||
|
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 apiGet(path) {
|
||||||
|
const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API GET ${path} failed: ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost(path, body) {
|
||||||
|
const res = await fetch(`${env.AUTHENTIK_INTERNAL_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${env.AUTHENTIK_API_TOKEN}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
debug("apiPost failed", path, res.status, text);
|
||||||
|
throw new Error(`API POST ${path} failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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(u =>
|
||||||
|
String(u.email || "").toLowerCase() === String(email).toLowerCase() ||
|
||||||
|
String(u.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() {
|
||||||
|
const publicDiscovery = `${env.OIDC_ISSUER.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
||||||
|
const internalDiscovery = publicDiscovery.replace(env.AUTHENTIK_PUBLIC_URL, env.AUTHENTIK_INTERNAL_URL);
|
||||||
|
const res = await fetch(internalDiscovery);
|
||||||
|
if (!res.ok) throw new Error("OIDC provider ещё не применился. Подождите 1-2 минуты или запустите docker compose down -v && docker compose up --build.");
|
||||||
|
const config = await res.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort OIDC setup. Authentik APIs can differ by version, so README has manual fallback.
|
||||||
|
|
||||||
|
|
||||||
|
async function ensureOidcProvider() {
|
||||||
|
if (ensureOidcProvider.done) return;
|
||||||
|
try {
|
||||||
|
await discoverOidc();
|
||||||
|
ensureOidcProvider.done = true;
|
||||||
|
} catch (e) {
|
||||||
|
debug("OIDC provider not ready yet", e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureOidcProvider.done = false;
|
||||||
|
|
||||||
|
function isSuccessChallenge(res, challenge) {
|
||||||
|
const component = challenge.component || "";
|
||||||
|
return (
|
||||||
|
res.status === 302 ||
|
||||||
|
component === "xak-flow-redirect" ||
|
||||||
|
component === "ak-flow-redirect" ||
|
||||||
|
component === "ak-stage-redirect" ||
|
||||||
|
challenge.type === "redirect" ||
|
||||||
|
Boolean(challenge.redirect)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let { res, challenge } = await getChallenge();
|
||||||
|
if (!res.ok) throw new Error("Сервис авторизации запускается. Попробуйте позже.");
|
||||||
|
|
||||||
|
let passwordChecked = false;
|
||||||
|
|
||||||
|
for (let step = 0; step < 12; step++) {
|
||||||
|
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 минуты или запустите docker compose down -v && docker compose up --build.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 getAuthentikIdTokenWithSession(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) {
|
||||||
|
throw new Error("Не удалось получить JWT от Authentik.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.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;--bg:#050505;--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;box-shadow:0 0 40px rgba(212,175,55,.12)}
|
||||||
|
.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}
|
||||||
|
.decor-car{position:absolute;left:44px;right:44px;bottom:132px;height:150px;opacity:.55;pointer-events:none}
|
||||||
|
.decor-car:before{content:"";position:absolute;left:0;right:0;bottom:28px;height:96px;border-bottom:2px solid rgba(212,175,55,.55);border-radius:50% 50% 18% 18% / 70% 70% 20% 20%;filter:drop-shadow(0 0 18px rgba(212,175,55,.24))}
|
||||||
|
.decor-car:after{content:"";position:absolute;left:18%;right:24%;bottom:82px;height:52px;border-top:2px solid rgba(255,255,255,.23);border-radius:70% 60% 0 0}
|
||||||
|
.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}
|
||||||
|
@media(max-width:900px){.shell{grid-template-columns:1fr}.brand-side{min-height:390px}.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>
|
||||||
|
</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="decor-car"></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.length < 8) throw new Error("Проверьте заполненные данные.");
|
||||||
|
if (!(await authentikReady())) throw new Error("Сервис авторизации запускается. Попробуйте позже.");
|
||||||
|
|
||||||
|
await createAuthentikUser({ 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 и пароль.");
|
||||||
|
if (!(await authentikReady())) throw new Error("Сервис авторизации запускается. Попробуйте позже.");
|
||||||
|
|
||||||
|
await ensureOidcProvider();
|
||||||
|
|
||||||
|
const jar = await authentikPasswordLogin(email, password);
|
||||||
|
const idToken = await getAuthentikIdTokenWithSession(jar);
|
||||||
|
const claims = decodeJwt(idToken);
|
||||||
|
|
||||||
|
const session = makeSigned({
|
||||||
|
id_token: idToken,
|
||||||
|
claims,
|
||||||
|
sub: claims.sub,
|
||||||
|
name: claims.name || claims.preferred_username || claims.email || email,
|
||||||
|
email: claims.email || email,
|
||||||
|
username: claims.preferred_username || claims.email || email,
|
||||||
|
iss: claims.iss,
|
||||||
|
aud: claims.aud,
|
||||||
|
iat: claims.iat,
|
||||||
|
exp: claims.exp
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(res, "/dashboard", {
|
||||||
|
"Set-Cookie": makeCookie("mm_session", session, 3600 * 8)
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debug("login failed", e);
|
||||||
|
return loginPage(req, res, `<div class="alert error">${escapeHtml(e.message || "Неверный email или пароль.")}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = session.name || "Клиент";
|
||||||
|
const email = session.email || "—";
|
||||||
|
const idToken = session.id_token || "—";
|
||||||
|
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 Authentik</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-item" style="margin-top:12px">
|
||||||
|
<b>full id_token</b>
|
||||||
|
<span>${escapeHtml(idToken)}</span>
|
||||||
|
</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">Аккаунт создан. Теперь можно войти.</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 === "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, () => {
|
||||||
|
ensureOidcProvider().catch(e => debug("startup oidc setup failed", e.message));
|
||||||
|
console.log(`Million Miles Authentik 100% POC on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user