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 и таблица двух переменных, решающих, кто
  может достучаться до инстанса.
This commit is contained in:
ZProger
2026-09-10 21:34:40 +03:00
parent f9413aa6e1
commit 14948aff1d
17 changed files with 454 additions and 70 deletions

View File

@@ -3,7 +3,7 @@ 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, setCredentials } from "@/lib/auth";
import { buildBasicAuthHeader, checkCredentials, isRememberSupported, setCredentials } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
type Status = "idle" | "submitting" | "invalid" | "unreachable";
@@ -19,13 +19,18 @@ export function LoginScreen() {
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, remember);
setCredentials(username, password, canRemember && remember);
return;
}
setStatus(result === "unauthorized" ? "invalid" : "unreachable");
@@ -67,15 +72,19 @@ export function LoginScreen() {
/>
</div>
<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>
{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>}