fix(auth): address PR 51 review findings
CI / build-and-test (pull_request) Has been cancelled

This commit is contained in:
admin committed 2026-09-25 20:15:24 +03:00
1 parent dcf62c882f
commit 1ce43495dd
10 files changed
+78 -17

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ CORS_ORIGIN=http://localhost:5173
# ─── Auth / sessions ─────────────────────────────────────────
# Базовый URL ссылок подтверждения и сброса пароля. В production по умолчанию
# используется https://run.vakanaut.ru; задайте URL явно для другого домена.
APP_BASE_URL=http://localhost:5173
# APP_BASE_URL=http://localhost:5173
SESSION_SECRET=replace_with_32plus_char_random_secret
# Production defaults to __Host-sid + Secure cookies. Local dev can stay insecure over http.
# SESSION_COOKIE_NAME=__Host-sid
+14
View File
@@ -8,6 +8,14 @@
## Backend
### [1.7.2] — подготовлено локально, 2026-09-25
#### Fixed
- Запросы сброса пароля и повторной отправки подтверждения сериализованы со сменой email.
- Письма для локальной разработки снова содержат ссылку в dev-логах; в production токены не логируются.
- Шаблон окружения оставляет `APP_BASE_URL` незаданным, чтобы production использовал домен по умолчанию.
### [1.7.1] — подготовлено локально, 2026-09-24
#### Fixed
@@ -128,6 +136,12 @@
## Frontend
### [0.11.2] — подготовлено локально, 2026-09-25
#### Fixed
- Таблица пользователей прокручивается внутри контейнера на узких экранах, не расширяя страницу.
### [0.11.1] — подготовлено локально, 2026-09-24
#### Fixed
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.7.0",
"version": "1.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.7.0",
"version": "1.7.2",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.7.1",
"version": "1.7.2",
"private": true,
"scripts": {
"build": "tsc",
+23 -9
View File
@@ -456,14 +456,21 @@ export async function verifyEmailToken(token: string): Promise<boolean> {
export async function requestPasswordReset(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user) {
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<UserRow>(
`SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1 FOR UPDATE`,
[normalized],
);
const user = rows[0];
if (!user) {
await client.query("COMMIT");
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
return;
}
const token = await createResetToken(client, user.id);
await client.query("COMMIT");
await sendResetEmail(user.email, token);
@@ -521,13 +528,20 @@ export async function resetPassword(token: string, password: string): Promise<bo
export async function resendVerification(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user || user.email_verified_at) {
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<UserRow>(
`SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1 FOR UPDATE`,
[normalized],
);
const user = rows[0];
if (!user || user.email_verified_at) {
await client.query("COMMIT");
return;
}
const token = await createVerificationToken(client, user.id);
await client.query("COMMIT");
await sendVerificationEmail(user.email, token);
+1 -1
View File
@@ -6,7 +6,7 @@ export async function sendMail(to: string, subject: string, text: string): Promi
if (config.securityProfile === "production") {
throw new Error("SMTP is required in production to send email");
}
console.info("[mail] SMTP is not configured; email skipped.", { to, subject });
console.info("[mail] SMTP is not configured; development email:", { to, subject, text });
return;
}
+30
View File
@@ -9,6 +9,8 @@ import {
cleanupExpiredAuthRows,
createResetToken,
createVerificationToken,
requestPasswordReset,
resendVerification,
resetPassword,
verifyEmailToken,
} from "../src/authService";
@@ -552,6 +554,34 @@ test("email verification token is single use", async () => {
assert.equal(await verifyEmailToken(token), false);
});
test("email changes serialize password reset and verification token issuance", async () => {
for (const [email, issueToken] of [
["reset-race@example.com", requestPasswordReset],
["verify-race@example.com", resendVerification],
] as const) {
const userId = await createUnverifiedUser(email, "correct horse battery staple");
const locker = await pool.connect();
await locker.query("BEGIN");
await locker.query("SELECT id FROM users WHERE id = $1 FOR UPDATE", [userId]);
const issuing = issueToken(email);
await new Promise((resolve) => setTimeout(resolve, 20));
await locker.query("UPDATE users SET email = $2, email_verified_at = NULL WHERE id = $1", [
userId,
email.replace("@", ".changed@"),
]);
await locker.query("COMMIT");
locker.release();
await issuing;
const table = issueToken === requestPasswordReset ? "password_reset_tokens" : "email_verification_tokens";
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE user_id = $1 AND used_at IS NULL`,
[userId],
);
assert.equal(rows[0]?.count, "0");
}
});
test("auth cleanup removes expired rows and keeps active rows", async () => {
const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple");
const past = new Date(Date.now() - 60_000);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "calendar-run-frontend",
"version": "0.11.0",
"version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.11.0",
"version": "0.11.2",
"dependencies": {
"ag-charts-community": "^14.0.0",
"ag-charts-react": "^14.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.11.1",
"version": "0.11.2",
"type": "module",
"scripts": {
"dev": "vite",
+3
View File
@@ -27,10 +27,12 @@ a {
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
}
.app-shell__header {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
@@ -85,6 +87,7 @@ a {
}
.app-shell__main {
min-width: 0;
width: min(1080px, 100%);
margin: 0 auto;
padding: var(--space-6);