Sign in verified accounts automatically and require eight-character passwords

This commit is contained in:
2026-10-13 16:33:52 +09:00
parent 9e0f493228
commit d6e60ecb3b
18 changed files with 538 additions and 63 deletions

View File

@@ -0,0 +1,282 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import argon2 from 'argon2';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { hash } from '../src/security.js';
const password = '12345678';
async function setup() {
const mails = [];
const f = await fixture(false, async (mail) => mails.push(mail));
f.cfg.registrationMode = 'open';
f.cfg.sessionRate = 60;
return { ...f, mails };
}
function useCookie(client, response) {
const cookie = String(response.headers['set-cookie']).split(';')[0];
assert(cookie.startsWith('hop_account='));
client.cookie = client.cookie.replace(/;\s*hop_account=[^;]*/g, '') + '; ' + cookie;
return cookie.slice('hop_account='.length);
}
async function register(f, email) {
const client = await f.create();
const response = await f.call(client, '/auth/register', 'POST', {
email,
password,
passwordConfirmation: password,
displayName: email.split('@')[0],
});
assert.equal(response.statusCode, 200, response.body);
const token = f.mails.find((mail) => mail.to === email).text.match(/#verify=([A-Za-z0-9_-]+)/)[1];
return { ...client, token, email };
}
async function verify(f, client, token = client.token) {
const response = await f.call(client, '/auth/verify-email', 'POST', { token });
assert.equal(response.statusCode, 200, response.body);
useCookie(client, response);
client.socket.disconnect();
await f.connect(client);
return response;
}
test('가입 7자 거절·8자 허용, 기존 짧은 비밀번호 로그인은 유지', async () => {
const f = await setup();
try {
const client = await f.create();
const input = {
email: 'length@hop.test',
password: '1234567',
passwordConfirmation: '1234567',
displayName: 'length',
};
const rejected = await f.call(client, '/auth/register', 'POST', input);
assert.equal(rejected.statusCode, 400, rejected.body);
assert.equal(rejected.json().code, 'PASSWORD_INVALID');
assert.equal(f.mails.length, 0);
assert.equal(f.service.db.select().from(s.users).all().length, 0);
const accepted = await f.call(client, '/auth/register', 'POST', {
...input,
password,
passwordConfirmation: password,
});
assert.equal(accepted.statusCode, 200, accepted.body);
assert.equal(f.mails.length, 1);
const id = randomUUID();
f.service.db
.insert(s.users)
.values({
id,
email: 'legacy@hop.test',
displayName: 'legacy',
passwordHash: await argon2.hash('1234'),
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
assert.equal(
(await f.call(client, '/auth/login', 'POST', { email: 'legacy@hop.test', password: '1234' }))
.statusCode,
200,
);
} finally {
await f.close();
}
});
for (const secure of [false, true]) {
test(`인증 완료 후 비밀번호 재입력 없이 로그인·쿠키 보호·기기 유지 (Secure=${secure})`, async () => {
const f = await setup();
try {
const a = await register(f, 'verify@hop.test');
const device = (await f.call(a, '/device/me')).json().device;
assert.equal((await f.call(a, '/auth/me')).json().account, null);
f.cfg.production = secure;
const response = await verify(f, a);
assert.equal(response.json().account.email, a.email);
assert.equal(response.json().account.verified, true);
assert.equal(response.json().token, undefined);
const header = String(response.headers['set-cookie']);
assert.match(header, /HttpOnly/);
assert.match(header, /SameSite=Lax/);
assert.equal(/; Secure(?:;|$)/.test(header), secure);
assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email);
assert.equal((await f.call(a, '/inbox')).statusCode, 200);
assert.equal((await f.call(a, '/device/me')).json().device.id, device.id);
const sessions = f.service.db.select().from(s.accountSessions).all();
assert.equal(sessions.length, 1);
assert.equal(sessions[0].tokenHash === a.cookie.split('hop_account=')[1], false);
assert(sessions[0].expiresAt - sessions[0].createdAt <= 30 * 86400000);
} finally {
await f.close();
}
});
}
test('다른 계정 인증 시 현재 세션·업로드·소켓 종료, 다른 기기 세션과 기기 ID 유지·보관함 격리', async () => {
const f = await setup();
try {
const a = await register(f, 'previous@hop.test');
await verify(f, a);
const previousToken = a.cookie.split('hop_account=')[1];
const previous = f.service.auth.session(previousToken);
const oldClient = { ...a };
const own = await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/previous-private'] },
randomUUID(),
);
assert.equal(own.statusCode, 200, own.body);
const second = await f.create();
const anotherLogin = await f.call(second, '/auth/login', 'POST', { email: a.email, password });
useCookie(second, anotherLogin);
const b = await register(f, 'next@hop.test');
let aborted;
const abort = f.service.storage.abortSession.bind(f.service.storage);
f.service.storage.abortSession = (id) => {
aborted = id;
abort(id);
};
const revoked = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('session notification missing')), 2000);
a.socket.once('account:revoked', () => {
clearTimeout(timer);
resolve();
});
});
const switched = await f.call(a, '/auth/verify-email', 'POST', { token: b.token });
assert.equal(switched.statusCode, 200, switched.body);
await revoked;
const serverSocket = f.service.io.sockets.sockets.get(a.socket.id);
assert.equal(serverSocket.data.accountSessionId, undefined);
assert.equal(serverSocket.rooms.has(`account:${previous.user.id}`), false);
assert.equal(aborted, previous.session.id);
useCookie(a, switched);
assert.equal((await f.call(a, '/auth/me')).json().account.email, b.email);
assert.equal((await f.call(oldClient, '/auth/me')).json().account, null);
assert.equal((await f.call(oldClient, '/vault')).statusCode, 401);
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
assert.equal((await f.call(second, '/auth/me')).json().account.email, 'previous@hop.test');
assert.equal(
(await f.call(second, '/vault')).json().items[0].url,
'https://example.com/previous-private',
);
assert.equal((await f.call(a, '/device/me')).json().device.id, a.deviceId);
} finally {
await f.close();
}
});
for (const invalid of ['expired', 'suspended', 'deleted', 'unknown']) {
test(`유효하지 않은 인증(${invalid})은 기존 로그인과 인증 토큰 상태를 변경하지 않음`, async () => {
const f = await setup();
try {
const a = await register(f, 'old@hop.test');
await verify(f, a);
const b = await register(f, 'invalid@hop.test');
const user = f.service.db.select().from(s.users).where(eq(s.users.email, b.email)).get();
const tokenRow = f.service.db
.select()
.from(s.accountTokens)
.where(eq(s.accountTokens.tokenHash, hash(b.token)))
.get();
if (invalid === 'expired')
f.service.db
.update(s.accountTokens)
.set({ expiresAt: Date.now() - 1 })
.where(eq(s.accountTokens.id, tokenRow.id))
.run();
if (['suspended', 'deleted'].includes(invalid))
f.service.db.update(s.users).set({ status: invalid }).where(eq(s.users.id, user.id)).run();
const response = await f.call(a, '/auth/verify-email', 'POST', {
token: invalid === 'unknown' ? 'A'.repeat(43) : b.token,
});
assert.equal(response.statusCode, 400, response.body);
assert.equal(response.headers['set-cookie'], undefined);
assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email);
assert.equal(
f.service.db.select().from(s.accountTokens).where(eq(s.accountTokens.id, tokenRow.id)).get()
.usedAt,
null,
);
assert.equal(
f.service.db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.userId, user.id))
.all().length,
0,
);
} finally {
await f.close();
}
});
}
test('동시·재사용 인증은 한 번만 로그인하고 기존 로그인 쿠키를 덮어쓰지 않음', async () => {
const f = await setup();
try {
const a = await register(f, 'once@hop.test');
const responses = await Promise.all([
f.call(a, '/auth/verify-email', 'POST', { token: a.token }),
f.call(a, '/auth/verify-email', 'POST', { token: a.token }),
]);
assert.deepEqual(responses.map((r) => r.statusCode).sort(), [200, 400]);
const successful = responses.find((r) => r.statusCode === 200);
useCookie(a, successful);
assert.equal(f.service.db.select().from(s.accountSessions).all().length, 1);
const replay = await f.call(a, '/auth/verify-email', 'POST', { token: a.token });
assert.equal(replay.statusCode, 400);
assert.equal(replay.headers['set-cookie'], undefined);
assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email);
} finally {
await f.close();
}
});
test('세션 발급 실패 시 인증·이전 로그아웃·토큰 소비 모두 롤백하고 같은 링크로 재시도 가능', async () => {
const f = await setup();
try {
const a = await register(f, 'keep@hop.test');
await verify(f, a);
const b = await register(f, 'retry@hop.test');
const original = f.service.auth.createSession.bind(f.service.auth);
f.service.auth.createSession = (...args) => {
original(...args);
throw Error('test issuance failure');
};
const failed = await f.call(a, '/auth/verify-email', 'POST', { token: b.token });
assert.equal(failed.statusCode, 500);
assert.equal(failed.headers['set-cookie'], undefined);
assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email);
const pending = f.service.db.select().from(s.users).where(eq(s.users.email, b.email)).get();
assert.equal(pending.status, 'pending');
assert.equal(pending.verifiedAt, null);
assert.equal(
f.service.db
.select()
.from(s.accountSessions)
.where(eq(s.accountSessions.userId, pending.id))
.all().length,
0,
);
assert.equal(
f.service.db
.select()
.from(s.accountTokens)
.where(eq(s.accountTokens.tokenHash, hash(b.token)))
.get().usedAt,
null,
);
f.service.auth.createSession = original;
await verify(f, a, b.token);
assert.equal((await f.call(a, '/auth/me')).json().account.email, b.email);
} finally {
await f.close();
}
});