Sign in verified accounts automatically and require eight-character passwords
This commit is contained in:
282
apps/server/test/auth-verification.test.js
Normal file
282
apps/server/test/auth-verification.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user