Files
aurum/frontend/src/components/auth/LoginScreen.tsx
ZProger 14948aff1d security: TLS, проверка Host, шифрованное запоминание входа и аудит-лог
- TLS через подключаемый overlay docker-compose.tls.yml + Caddyfile:
  Caddy сам получает и продлевает сертификат Let's Encrypt, HSTS
  добавляется на уровне прокси — намеренно там, а не в nginx.conf,
  иначе установки без TLS заперли бы сами себя;
- проверка заголовка Host против DNS rebinding: AURUM_ALLOWED_HOSTS,
  по умолчанию localhost и 127.0.0.1, чужой Host получает 421 с
  подсказкой, какую переменную поправить;
- «запомнить меня на 7 дней» вернулось в безопасном виде: пароль
  шифруется AES-GCM ключом с extractable: false в IndexedDB, вместо
  прежнего base64 в localStorage, который разворачивался обратно в
  пароль одним atob. Требует secure context, поэтому по обычному HTTP
  к LAN-адресу чекбокс не показывается, а не притворяется рабочим;
- аудит-лог необратимых операций (восстановление бэкапа, массовое
  создание транзакций, удаление счёта) в лог контейнера. Не в БД
  сознательно: таблица аудита исчезла бы ровно в том событии, которое
  должна объяснять;
- README: раздел про TLS и таблица двух переменных, решающих, кто
  может достучаться до инстанса.
2026-09-10 21:34:40 +03:00

101 lines
4.2 KiB
TypeScript

import { type FormEvent, useState } from "react";
import { Logo } from "@/components/layout/Logo";
import { Button } from "@/components/ui/Button";
import { Card, CardContent } from "@/components/ui/Card";
import { Input, Label } from "@/components/ui/Input";
import { buildBasicAuthHeader, checkCredentials, isRememberSupported, setCredentials } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
type Status = "idle" | "submitting" | "invalid" | "unreachable";
/** Shown by LoginGate when this instance requires Basic Auth and no valid
* credentials are stored yet. Doesn't implement auth itself — it just
* collects a username/password, checks them against a real protected
* endpoint, and on success hands them to lib/auth.ts's setCredentials(),
* which is what every subsequent request actually authenticates with. */
export function LoginScreen() {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false);
const [status, setStatus] = useState<Status>("idle");
// Remembering encrypts the credential with WebCrypto, which browsers only
// expose in a secure context — over plain HTTP to a LAN address there's no
// safe way to persist it, so the option is hidden rather than offered and
// quietly ignored. See lib/credentialVault.ts.
const canRemember = isRememberSupported();
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setStatus("submitting");
const result = await checkCredentials(buildBasicAuthHeader(username, password));
if (result === "ok") {
setCredentials(username, password, canRemember && remember);
return;
}
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
};
return (
<div className="flex min-h-screen items-center justify-center bg-surface-0 px-4">
<Card className="w-full max-w-sm">
<CardContent className="flex flex-col items-center gap-6 p-6 pt-8 sm:p-8">
<div className="flex flex-col items-center gap-1.5">
<Logo size={40} />
<span className="text-lg font-semibold tracking-tight text-text-primary">Aurum</span>
<span className="text-xs text-text-muted">{t("auth.subtitle")}</span>
</div>
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-4">
<div>
<Label htmlFor="auth-username">{t("auth.usernameLabel")}</Label>
<Input
id="auth-username"
name="username"
autoComplete="username"
autoFocus
value={username}
onChange={(event) => setUsername(event.target.value)}
required
/>
</div>
<div>
<Label htmlFor="auth-password">{t("auth.passwordLabel")}</Label>
<Input
id="auth-password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</div>
{canRemember ? (
<label className="flex items-center gap-2 text-xs text-text-muted">
<input
type="checkbox"
checked={remember}
onChange={(event) => setRemember(event.target.checked)}
className="h-3.5 w-3.5 accent-text-primary"
/>
{t("auth.rememberMe")}
</label>
) : (
<p className="text-xs text-text-muted">{t("auth.sessionOnlyHint")}</p>
)}
{status === "invalid" && <p className="text-sm text-danger">{t("auth.errorInvalidCredentials")}</p>}
{status === "unreachable" && <p className="text-sm text-danger">{t("auth.errorUnreachable")}</p>}
<Button type="submit" className="w-full" disabled={status === "submitting"}>
{status === "submitting" ? t("auth.submitting") : t("auth.submitButton")}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}