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

@@ -83,9 +83,9 @@ NAS의 파일 원본은 `/volume1/docker/projects/apps/hop/uploads/originals`에
## 개발 테스트 계정 ## 개발 테스트 계정
`node --import tsx scripts/seed-test-accounts.mjs`로 개발 DB에 인증된 일반 사용자 `test1@hop.test`, `test2@hop.test`를 준비합니다. 비밀번호는 둘 다 `1234`입니다. 메일을 보내지 않으며 운영 환경에서는 실행을 거절합니다. 같은 이름의 테스트 계정은 비밀번호만 재설정하고 다른 기존 계정은 변경하지 않습니다. `node --import tsx scripts/seed-test-accounts.mjs`로 개발 DB에 인증된 일반 사용자 `test1@hop.test`, `test2@hop.test`를 준비합니다. 새로 준비하는 테스트 계정의 비밀번호는 둘 다 `12345678`입니다. 메일을 보내지 않으며 운영 환경에서는 실행을 거절합니다. 같은 이름의 테스트 계정은 비밀번호만 재설정하고 다른 기존 계정은 변경하지 않습니다.
개발·테스트 환경은 1자 이상 비밀번호를 허용합니다. 운영 환경(`NODE_ENV=production`)은 기존 12자 이상 규칙을 유지하며 가입·재설정 화면도 서버가 제공한 최소 길이를 적용합니다. 새 비밀번호는 개발·운영 모두 최소 8자입니다. 가입·재설정·비밀번호 변경에 같은 기준을 적용하며 화면도 서버의 최소 길이를 사용합니다. 기존 계정의 비밀번호는 일괄 변경하지 않습니다. 가입 이메일의 인증 완료 버튼을 누르면 인증한 계정으로 자동 로그인하고 받은 항목을 표시합니다. 같은 브라우저의 이전 계정 세션은 종료하며, 다른 기기의 세션은 유지합니다. 인증 토큰은 일회용·만료·정지 계정 차단을 유지합니다.
## 현재 전송·보관함 흐름 ## 현재 전송·보관함 흐름

View File

@@ -12,6 +12,7 @@ import { timingSafeEqual } from 'node:crypto';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { z } from 'zod'; import { z } from 'zod';
import { import {
PASSWORD_MIN_LENGTH,
devicePatch, devicePatch,
requestInput, requestInput,
spaceInput, spaceInput,
@@ -676,12 +677,20 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
secure: cfg.production, secure: cfg.production,
sameSite: 'lax', sameSite: 'lax',
}); });
const setAccountCookie = (reply: any, result: { token: string; expiresAt: number }) =>
reply.setCookie('hop_account', result.token, {
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
path: '/',
expires: new Date(result.expiresAt),
});
app.get('/api/auth/me', async (req) => { app.get('/api/auth/me', async (req) => {
const current = service.auth.session(req.cookies.hop_account); const current = service.auth.session(req.cookies.hop_account);
return { return {
account: current ? service.auth.serialize(current.user) : null, account: current ? service.auth.serialize(current.user) : null,
registrationOpen: cfg.registrationMode === 'open', registrationOpen: cfg.registrationMode === 'open',
passwordMinLength: cfg.production ? 12 : 1, passwordMinLength: PASSWORD_MIN_LENGTH,
}; };
}); });
app.post('/api/auth/register', async (req) => { app.post('/api/auth/register', async (req) => {
@@ -705,9 +714,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
input.setupToken, input.setupToken,
); );
}); });
app.post('/api/auth/verify-email', async (req) => app.post('/api/auth/verify-email', async (req, reply) => {
service.auth.verify(tokenInput.parse(req.body).token), const result = service.auth.verify(tokenInput.parse(req.body).token, req.cookies.hop_account);
); setAccountCookie(reply, result);
return { ok: true, account: result.account };
});
app.post('/api/auth/verify-email-change', async (req) => app.post('/api/auth/verify-email-change', async (req) =>
service.auth.verifyEmailChange(tokenInput.parse(req.body).token), service.auth.verifyEmailChange(tokenInput.parse(req.body).token),
); );
@@ -729,13 +740,7 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
ip(req) ?? 'unknown', ip(req) ?? 'unknown',
req.cookies.hop_account, req.cookies.hop_account,
); );
reply.setCookie('hop_account', result.token, { setAccountCookie(reply, result);
httpOnly: true,
secure: cfg.production,
sameSite: 'lax',
path: '/',
expires: new Date(result.expiresAt),
});
return { account: result.account }; return { account: result.account };
}); });
app.post('/api/auth/logout', async (req, reply) => { app.post('/api/auth/logout', async (req, reply) => {

View File

@@ -2,7 +2,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
import { and, eq, gt, isNull, inArray } from 'drizzle-orm'; import { and, eq, gt, isNull, inArray } from 'drizzle-orm';
import argon2 from 'argon2'; import argon2 from 'argon2';
import type { Service } from './service.js'; import type { Service } from './service.js';
import type { Account } from '@hop/shared'; import { PASSWORD_MIN_LENGTH, type Account } from '@hop/shared';
import * as s from './db/schema.js'; import * as s from './db/schema.js';
import { fail, hash, randomToken } from './security.js'; import { fail, hash, randomToken } from './security.js';
export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string }; export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string };
@@ -83,7 +83,7 @@ export class Auth {
return current!; return current!;
} }
async password(value: string) { async password(value: string) {
const minimum = this.service.cfg.production ? 12 : 1; const minimum = PASSWORD_MIN_LENGTH;
if (Array.from(value).length < minimum || value.length > 1024) if (Array.from(value).length < minimum || value.length > 1024)
fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`); fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`);
return argon2.hash(value, passwordOptions); return argon2.hash(value, passwordOptions);
@@ -208,14 +208,19 @@ export class Auth {
return { ok: true }; return { ok: true };
}); });
} }
verify(token: string) { verify(token: string, oldToken?: string) {
return this.consume(token, 'verify', (_t, user) => { const previous = this.session(oldToken);
let login!: ReturnType<Auth['createSession']>;
const verified = this.consume(token, 'verify', (_t, user) => {
this.service.db this.service.db
.update(s.users) .update(s.users)
.set({ status: 'active', verifiedAt: Date.now() }) .set({ status: 'active', verifiedAt: Date.now() })
.where(eq(s.users.id, user.id)) .where(eq(s.users.id, user.id))
.run(); .run();
login = this.createSession(this.user(user.id), oldToken);
}); });
if (previous) this.endSession(previous);
return { ...verified, ...login };
} }
async login(email: string, password: string, ip: string, oldToken?: string) { async login(email: string, password: string, ip: string, oldToken?: string) {
const normalized = normalizeEmail(email); const normalized = normalizeEmail(email);
@@ -228,6 +233,12 @@ export class Auth {
.catch(() => false); .catch(() => false);
if (!valid || !user || !['pending', 'active'].includes(user.status)) if (!valid || !user || !['pending', 'active'].includes(user.status))
fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.'); fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.');
const previous = this.session(oldToken);
const result = this.createSession(user!, oldToken);
if (previous) this.endSession(previous);
return result;
}
private createSession(user: User, oldToken?: string) {
const token = randomToken(), const token = randomToken(),
now = Date.now(); now = Date.now();
this.service.db.transaction(() => { this.service.db.transaction(() => {
@@ -241,7 +252,7 @@ export class Auth {
.insert(s.accountSessions) .insert(s.accountSessions)
.values({ .values({
id: randomUUID(), id: randomUUID(),
userId: user!.id, userId: user.id,
tokenHash: hash(token), tokenHash: hash(token),
createdAt: now, createdAt: now,
lastSeen: now, lastSeen: now,
@@ -249,22 +260,28 @@ export class Auth {
}) })
.run(); .run();
}); });
return { token, account: this.serialize(user!), expiresAt: now + 30 * 86400000 }; return { token, account: this.serialize(user), expiresAt: now + 30 * 86400000 };
}
private endSession(current: NonNullable<ReturnType<Auth['session']>>) {
this.service.storage.abortSession(current.session.id);
for (const socket of this.service.io?.sockets.sockets.values() ?? []) {
if (socket.data.accountSessionId !== current.session.id) continue;
socket.leave(`account:${current.user.id}`);
socket.leave(`account-session:${current.session.id}`);
socket.data.accountId = undefined;
socket.data.accountSessionId = undefined;
socket.emit('account:revoked', {});
}
} }
logout(token: string | undefined) { logout(token: string | undefined) {
const current = this.session(token); const current = this.session(token);
if (!current) return; if (!current) return;
this.service.storage.abortSession(current.session.id);
this.service.db this.service.db
.update(s.accountSessions) .update(s.accountSessions)
.set({ revokedAt: Date.now() }) .set({ revokedAt: Date.now() })
.where(eq(s.accountSessions.id, current.session.id)) .where(eq(s.accountSessions.id, current.session.id))
.run(); .run();
this.service.storage.abortSession(current.session.id); this.endSession(current);
this.service.io?.to(`account-session:${current.session.id}`).emit('account:revoked', {});
this.service.io
?.in(`account-session:${current.session.id}`)
.socketsLeave(`account:${current.user.id}`);
} }
revoke(userId: string) { revoke(userId: string) {
this.service.storage.abortUser(userId); this.service.storage.abortUser(userId);

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();
}
});

View File

@@ -30,13 +30,13 @@ async function signed(f, client, email) {
id, id,
email, email,
displayName: email, displayName: email,
passwordHash: await f.service.auth.password('1234'), passwordHash: await f.service.auth.password('12345678'),
status: 'active', status: 'active',
verifiedAt: Date.now(), verifiedAt: Date.now(),
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
const login = await f.call(client, '/auth/login', 'POST', { email, password: '1234' }); const login = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(login.statusCode, 200); assert.equal(login.statusCode, 200);
client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0]; client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
return id; return id;

View File

@@ -17,13 +17,13 @@ async function signIn(f, client) {
id, id,
email, email,
displayName: email, displayName: email,
passwordHash: await f.service.auth.password('1234'), passwordHash: await f.service.auth.password('12345678'),
status: 'active', status: 'active',
verifiedAt: Date.now(), verifiedAt: Date.now(),
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
const response = await f.call(client, '/auth/login', 'POST', { email, password: '1234' }); const response = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(response.statusCode, 200, response.body); assert.equal(response.statusCode, 200, response.body);
client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0]; client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
client.socket.disconnect(); client.socket.disconnect();

View File

@@ -374,8 +374,12 @@ test('수락 직전 오프라인 검사 및 여러 탭 합산', async () => {
await new Promise((r) => setTimeout(r, 35)); await new Promise((r) => setTimeout(r, 35));
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1); assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
const id = (await f.request(a, b)).json().requestId; const id = (await f.request(a, b)).json().requestId;
const disconnected = new Promise<void>((resolve) => {
f.service.io!.sockets.sockets.get(second.id!)!.once('disconnect', () => resolve());
});
second.disconnect(); second.disconnect();
await new Promise((r) => setTimeout(r, 35)); await disconnected;
await new Promise((r) => setTimeout(r, f.cfg.offlineGraceMs + 15));
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0); assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal( assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,

View File

@@ -7,16 +7,17 @@ import type { Mail } from '../src/auth.js';
import { normalizeEmail } from '../src/auth.js'; import { normalizeEmail } from '../src/auth.js';
import * as s from '../src/db/schema.js'; import * as s from '../src/db/schema.js';
const password = 'correct horse battery staple'; const password = 'correct horse battery staple';
test('개발은 1자 비밀번호를 허용하고 운영은 12자 규칙을 유지', async () => { test('새 비밀번호는 개발·운영 모두 최소 8자이며 API 안내와 일치', async () => {
const f = await fixture(); const f = await fixture();
try { try {
assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 1); assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 8);
assert.match(await f.service.auth.password('1'), /^\$argon2id\$/); await assert.rejects(() => f.service.auth.password('1234567'), /8~1024/);
assert.match(await f.service.auth.password('12345678'), /^\$argon2id\$/);
await assert.rejects(() => f.service.auth.password('')); await assert.rejects(() => f.service.auth.password(''));
f.cfg.production = true; f.cfg.production = true;
assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 12); assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 8);
await assert.rejects(() => f.service.auth.password('1234'), /12~1024/); await assert.rejects(() => f.service.auth.password('1234567'), /8~1024/);
assert.match(await f.service.auth.password('123412341234'), /^\$argon2id\$/); assert.match(await f.service.auth.password('12345678'), /^\$argon2id\$/);
} finally { } finally {
await f.close(); await f.close();
} }
@@ -44,9 +45,9 @@ async function register(
assert.equal(result.statusCode, 200, result.body); assert.equal(result.statusCode, 200, result.body);
const mail = f.mails.find((m) => m.to === email)!; const mail = f.mails.find((m) => m.to === email)!;
const token = mail.text.match(/#verify=([A-Za-z0-9_-]+)/)![1]!; const token = mail.text.match(/#verify=([A-Za-z0-9_-]+)/)![1]!;
if (verify) const login = verify
assert.equal((await f.call(c, '/auth/verify-email', 'POST', { token })).statusCode, 200); ? await f.call(c, '/auth/verify-email', 'POST', { token })
const login = await f.call(c, '/auth/login', 'POST', { email, password }); : await f.call(c, '/auth/login', 'POST', { email, password });
assert.equal(login.statusCode, 200, login.body); assert.equal(login.statusCode, 200, login.body);
c.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0]; c.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
c.socket?.disconnect(); c.socket?.disconnect();
@@ -89,10 +90,13 @@ test('가입은 메일 게이트웨이로 인증 토큰을 보내고 해시만
assert.match(user.passwordHash!, /^\$argon2id\$/); assert.match(user.passwordHash!, /^\$argon2id\$/);
assert.equal(f.service.db.select().from(s.accountTokens).get()!.tokenHash === a.token, false); assert.equal(f.service.db.select().from(s.accountTokens).get()!.tokenHash === a.token, false);
assert.equal((await request(f, a, 'alice@example.com')).statusCode, 403); assert.equal((await request(f, a, 'alice@example.com')).statusCode, 403);
assert.equal( const verified = await f.call(a, '/auth/verify-email', 'POST', { token: a.token });
(await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode, assert.equal(verified.statusCode, 200, verified.body);
200, a.cookie =
); a.cookie.replace(/;\s*hop_account=[^;]*/g, '') +
'; ' +
String(verified.headers['set-cookie']).split(';')[0];
assert.equal((await f.call(a, '/auth/me')).json().account.verified, true);
assert.equal( assert.equal(
(await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode, (await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode,
400, 400,

View File

@@ -15,13 +15,13 @@ async function signed(f, client, email) {
id, id,
email, email,
displayName: email, displayName: email,
passwordHash: await f.service.auth.password('1234'), passwordHash: await f.service.auth.password('12345678'),
status: 'active', status: 'active',
verifiedAt: Date.now(), verifiedAt: Date.now(),
createdAt: Date.now(), createdAt: Date.now(),
}) })
.run(); .run();
const login = await f.call(client, '/auth/login', 'POST', { email, password: '1234' }); const login = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(login.statusCode, 200, login.body); assert.equal(login.statusCode, 200, login.body);
client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0]; client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
return id; return id;
@@ -168,7 +168,7 @@ test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장,
); );
const login = await f.call(b, '/auth/login', 'POST', { const login = await f.call(b, '/auth/login', 'POST', {
email: 'receiver@hop.test', email: 'receiver@hop.test',
password: '1234', password: '12345678',
}); });
b.cookie = b.cookie =
b.cookie.split('; hop_account=')[0] + b.cookie.split('; hop_account=')[0] +

View File

@@ -253,17 +253,25 @@ async function authSubmit() {
return; return;
} }
if (authMode.value === 'verify' || authMode.value === 'email-change') { if (authMode.value === 'verify' || authMode.value === 'email-change') {
const signupVerification = authMode.value === 'verify';
const previousOwner = account.value?.id;
await api( await api(
authMode.value === 'verify' ? '/auth/verify-email' : '/auth/verify-email-change', authMode.value === 'verify' ? '/auth/verify-email' : '/auth/verify-email-change',
'POST', 'POST',
{ token: token.value }, { token: token.value },
); );
if (signupVerification) {
if (previousOwner) clearAccountUploads(previousOwner);
clearPrivate();
view.value = 'inbox';
}
authMessage.value = '';
await refreshAccount();
token.value = ''; token.value = '';
authMode.value = 'login'; authMode.value = 'login';
authMessage.value = '이메일 인증을 완료했습니다. 로그인해 주세요.';
await refreshAccount();
reconnect(); reconnect();
await refreshLists(); await refreshLists();
message(signupVerification ? '이메일 인증을 완료했어요.' : '이메일 주소를 변경했습니다.');
return; return;
} }
await api('/auth/reset-password', 'POST', { await api('/auth/reset-password', 'POST', {

View File

@@ -1,16 +1,17 @@
import { readDeviceInfo } from './deviceInfo'; import { readDeviceInfo } from './deviceInfo';
import { PASSWORD_MIN_LENGTH } from '@hop/shared';
import { createRefreshQueue } from './refreshQueue'; import { createRefreshQueue } from './refreshQueue';
import { ref } from 'vue'; import { ref } from 'vue';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
export const account = ref(null), export const account = ref(null),
registrationOpen = ref(false), registrationOpen = ref(false),
passwordMinLength = ref(12); passwordMinLength = ref(PASSWORD_MIN_LENGTH);
export async function refreshAccount() { export const refreshAccount = createRefreshQueue(async () => {
const result = await api('/auth/me'); const result = await api('/auth/me');
account.value = result.account; account.value = result.account;
registrationOpen.value = result.registrationOpen; registrationOpen.value = result.registrationOpen;
passwordMinLength.value = result.passwordMinLength ?? 12; passwordMinLength.value = result.passwordMinLength ?? PASSWORD_MIN_LENGTH;
} });
export function reconnect() { export function reconnect() {
connect(); connect();
} }

View File

@@ -6,7 +6,7 @@
아래 코드 블록을 하나씩 복사하고 Enter를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요. 아래 코드 블록을 하나씩 복사하고 Enter를 누르세요. 오류가 나오면 다음 단계로 넘어가지 마세요.
이미 배포했다면 이번에는 **7-1. 업로드 폴더 전환과 테스트 자료 초기화**부터 진행하세요. 파일은 hop 폴더 안의 `uploads`에 저장합니다. 사용자가 기존 테스트 자료 삭제를 요청했으므로 최초 전환 때만 DB·기존 업로드를 초기화합니다. `.env`, 도메인·포트·메일 설정은 유지합니다. 전환 후 일반 업데이트는 7단계만 진행합니다. 파일은 hop 폴더 안의 `uploads`에 저장합니다. 이 폴더로 전환한 설치는 **7. 다음 버전으로 업데이트**만 진행합니다. 이전 `hop_hop_uploads` 볼륨을 사용하는 설치의 최초 전환은 **7-1. 업로드 폴더 전환과 테스트 자료 초기화**를 따릅니다. 사용자가 삭제를 허용한 기존 테스트 자료의 초기화는 최초 전환 때 한 번만 하며 `.env`, 도메인·포트·메일 설정은 유지합니다.
**‘허용되지 않은 접속 경로’가 나오면:** 주소창을 `https://hop.noname.company`로 바꾸세요. `http://`는 운영 `APP_URL`과 달라 차단됩니다. 5-1단계에서 HTTP를 HTTPS로 자동 이동시킵니다. **‘허용되지 않은 접속 경로’가 나오면:** 주소창을 `https://hop.noname.company`로 바꾸세요. `http://`는 운영 `APP_URL`과 달라 차단됩니다. 5-1단계에서 HTTP를 HTTPS로 자동 이동시킵니다.
@@ -187,7 +187,7 @@ https://hop.noname.company
주변 자동 발견은 **5-2단계를 실행해 활성화**합니다. 임의로 `TRUSTED_PROXY_CIDRS`에 모든 IP를 넣지 마세요. 주변 자동 발견은 **5-2단계를 실행해 활성화**합니다. 임의로 `TRUSTED_PROXY_CIDRS`에 모든 IP를 넣지 마세요.
운영 비밀번호는 **12자 이상**입니다. 개발 DB와 `1234` 테스트 계정은 NAS에 복사되지 않습니다. 가입 비밀번호는 **8자 이상**입니다. 이메일 인증 완료 후 해당 계정으로 자동 로그인하고 보관함의 받은 항목을 표시합니다. 다른 계정으로 로그인한 브라우저에서는 이전 로그인 세션을 종료하고 인증한 계정으로 전환합니다. 개발 DB와 테스트 계정은 NAS에 복사되지 않습니다.
모바일에서는 상단 **설치** 버튼을 누릅니다. Android 등 지원 브라우저는 설치 창이 열리고, iPhone은 Safari의 **공유 → 홈 화면에 추가** 안내가 열립니다. 설치한 앱 안에서는 설치 버튼을 숨깁니다. 전송은 앱을 열고 인터넷에 연결된 상태에서 합니다. PWA는 파일·전송 기록·API 응답을 오프라인 캐시에 저장하지 않습니다. 모바일에서는 상단 **설치** 버튼을 누릅니다. Android 등 지원 브라우저는 설치 창이 열리고, iPhone은 Safari의 **공유 → 홈 화면에 추가** 안내가 열립니다. 설치한 앱 안에서는 설치 버튼을 숨깁니다. 전송은 앱을 열고 인터넷에 연결된 상태에서 합니다. PWA는 파일·전송 기록·API 응답을 오프라인 캐시에 저장하지 않습니다.
@@ -427,3 +427,9 @@ Docker 앱에서 **새 compose.nas.yaml 다시 불러오기 → 저장 → 재
## 화면 안내 정리 ## 화면 안내 정리
보관함 탭은 **받은 항목 → 임시 저장** 순서입니다. 항목의 날짜는 **보관 만료 · 날짜/시간까지**로 표시합니다. 계정 설정에서 이메일은 표시만 하고 변경 버튼은 숨깁니다. 비밀번호 변경은 유지합니다. 오류 토스트는 텍스트와 닫기 아이콘의 양쪽 여백을 맞추며 기본 5초 뒤 닫힙니다. 네트워크 연결 실패는 한국어로 안내하고 닫기 버튼으로 즉시 닫을 수도 있습니다. 보관함 탭은 **받은 항목 → 임시 저장** 순서입니다. 항목의 날짜는 **보관 만료 · 날짜/시간까지**로 표시합니다. 계정 설정에서 이메일은 표시만 하고 변경 버튼은 숨깁니다. 비밀번호 변경은 유지합니다. 오류 토스트는 텍스트와 닫기 아이콘의 양쪽 여백을 맞추며 기본 5초 뒤 닫힙니다. 네트워크 연결 실패는 한국어로 안내하고 닫기 버튼으로 즉시 닫을 수도 있습니다.
## 이메일 인증 자동 로그인 업데이트
가입 비밀번호는 최소 8자입니다. 인증 메일의 링크를 열고 **이메일 인증 완료하기**를 누르면 해당 계정으로 자동 로그인하며 보관함의 받은 항목을 표시합니다. 다른 계정으로 로그인한 브라우저에서는 이전 세션을 종료하고 인증한 계정으로 전환합니다. 다른 기기의 로그인은 유지합니다. 만료·재사용된 링크로는 현재 로그인을 변경하지 않습니다.
이번 인증 변경은 **Git pull → 이미지 재빌드 → 재배포**로 적용합니다. 기존 계정·파일은 유지하고 YAML·환경변수·DB 구조는 변경하지 않습니다. 업로드 폴더 전환을 완료했다면 7-1의 초기화를 반복하지 마세요.

View File

@@ -4,6 +4,8 @@
## 화면과 전송 ## 화면과 전송
새 비밀번호는 가입·재설정·변경 모두 최소 8자다. 가입 이메일 인증을 완료하면 인증한 계정으로 자동 로그인하고 받은 항목을 표시한다. 현재 브라우저의 이전 로그인 세션은 종료하고 다른 기기의 세션은 유지한다. 기존 비밀번호를 일괄 변경하지 않으며 인증 토큰의 일회용·만료·정지 계정 차단 정책을 유지한다.
메인은 중앙의 기기 선택 화면이다. 헤더는 홈으로 돌아가는 로고, 로그인/보관함, 더보기 버튼을 제공한다. 앱 설치·테마·서비스 정보는 더보기 안에 둔다. 기기 목록은 최대 데스크톱 4열, 태블릿 3열, 모바일 2열이며 최대 화면 너비는 1080px이다. 내 기기는 자연스럽게 스크롤되는 푸터의 작은 상태 버튼으로 표시하고, 설정에서는 이름만 변경한다. 발견 허용 스위치는 표시하지 않으며 사이트 접속 시 자동 발견에 참여한다. 이전에 발견을 꺼둔 기기도 재접속 시 허용으로 바꾸고 기기 이름·세션은 유지한다. QR 연결은 기기 카드와 같은 영역의 보조 카드로 둔다. 헤더 로고와 짧은 보조 문구는 작게 표시하고 푸터의 서버 경유 안내는 제거한다. 메인은 중앙의 기기 선택 화면이다. 헤더는 홈으로 돌아가는 로고, 로그인/보관함, 더보기 버튼을 제공한다. 앱 설치·테마·서비스 정보는 더보기 안에 둔다. 기기 목록은 최대 데스크톱 4열, 태블릿 3열, 모바일 2열이며 최대 화면 너비는 1080px이다. 내 기기는 자연스럽게 스크롤되는 푸터의 작은 상태 버튼으로 표시하고, 설정에서는 이름만 변경한다. 발견 허용 스위치는 표시하지 않으며 사이트 접속 시 자동 발견에 참여한다. 이전에 발견을 꺼둔 기기도 재접속 시 허용으로 바꾸고 기기 이름·세션은 유지한다. QR 연결은 기기 카드와 같은 영역의 보조 카드로 둔다. 헤더 로고와 짧은 보조 문구는 작게 표시하고 푸터의 서버 경유 안내는 제거한다.
기기를 누르면 PC 중앙 모달·모바일 하단 시트에서 현재 콘텐츠 하나를 보낸다. 클립보드 링크가 있으면 링크가 기본이며, 실제로 파일을 선택하면 링크를 대체한다. 링크 입력으로 전환했을 때 클립보드가 비어 있으면 빈 URL 입력과 클립보드 가져오기를 제공한다. 링크·파일·보관함 자료를 함께 보내지 않으며 전송/취소 버튼은 언제나 하나다. 선택 방법을 바꿔도 모달의 너비·높이를 유지하고 내용 영역 안에서만 스크롤한다. 기기 선택 전에는 전송 입력을 표시하지 않는다. 클립보드의 유효한 HTTP/HTTPS 링크만 제안하며 읽는 것만으로 전송하지 않는다. 다시 선택하거나 페이지로 돌아오면 최신 클립보드를 읽는다. 권한 제한 시 직접 새로고침·붙여넣기를 제공한다. 수동 편집 중인 URL은 자동 갱신으로 덮어쓰지 않는다. 파일 선택은 클립보드 실패와 독립적이다. 기기를 누르면 PC 중앙 모달·모바일 하단 시트에서 현재 콘텐츠 하나를 보낸다. 클립보드 링크가 있으면 링크가 기본이며, 실제로 파일을 선택하면 링크를 대체한다. 링크 입력으로 전환했을 때 클립보드가 비어 있으면 빈 URL 입력과 클립보드 가져오기를 제공한다. 링크·파일·보관함 자료를 함께 보내지 않으며 전송/취소 버튼은 언제나 하나다. 선택 방법을 바꿔도 모달의 너비·높이를 유지하고 내용 영역 안에서만 스크롤한다. 기기 선택 전에는 전송 입력을 표시하지 않는다. 클립보드의 유효한 HTTP/HTTPS 링크만 제안하며 읽는 것만으로 전송하지 않는다. 다시 선택하거나 페이지로 돌아오면 최신 클립보드를 읽는다. 권한 제한 시 직접 새로고침·붙여넣기를 제공한다. 수동 편집 중인 URL은 자동 갱신으로 덮어쓰지 않는다. 파일 선택은 클립보드 실패와 독립적이다.

View File

@@ -146,3 +146,11 @@
- 계정 브라우저 검증에서 NAS 저장소에 해당하는 실제 서버 원본을 삭제한 뒤 다운로드 전 안내·양쪽 보관함과 사용량 정리·다른 기기의 열린 전송 선택 해제·전송 버튼 비활성화를 확인했다. 기존 저장·메모·삭제/복원·수락 후 자동 열기/다운로드·로그아웃 권한 격리는 유지한다. - 계정 브라우저 검증에서 NAS 저장소에 해당하는 실제 서버 원본을 삭제한 뒤 다운로드 전 안내·양쪽 보관함과 사용량 정리·다른 기기의 열린 전송 선택 해제·전송 버튼 비활성화를 확인했다. 기존 저장·메모·삭제/복원·수락 후 자동 열기/다운로드·로그아웃 권한 격리는 유지한다.
- 오류 토스트의 좌우 여백·텍스트/아이콘 중앙 정렬·44px 닫기 버튼·5초 자동 닫힘, 모바일 네트워크 실패의 한국어 안내와 수동 닫힘을 실제 Chromium에서 확인했다. 링크 수신 안내도 5초 뒤 닫히고 새 전송 모달을 가리지 않는다. 이메일 변경 버튼은 숨기고 받은 항목/임시 저장 탭 순서와 보관 만료 날짜 문구를 정리했다. - 오류 토스트의 좌우 여백·텍스트/아이콘 중앙 정렬·44px 닫기 버튼·5초 자동 닫힘, 모바일 네트워크 실패의 한국어 안내와 수동 닫힘을 실제 Chromium에서 확인했다. 링크 수신 안내도 5초 뒤 닫히고 새 전송 모달을 가리지 않는다. 이메일 변경 버튼은 숨기고 받은 항목/임시 저장 탭 순서와 보관 만료 날짜 문구를 정리했다.
- 전체 빌드·타입 검사, 계정·반응형 레이아웃·익명 파일/링크·클립보드 텍스트 브라우저 검증을 통과했다. 운영 Docker 이미지 빌드, 기본 UID와 다른 NAS UID/GID를 사용하는 실제 격리 Compose 시작/health·영구 DB·호스트 바인드 마운트·공개 호스트 포트 없음, 재시작 뒤 파일/텍스트/세션 보존과 8MiB 미완료 조각 이어 보내기·SQLite integrity·호스트 파일 삭제를 확인했다. 실제 NAS·파일 관리자·실물 Safari의 확인과 재배포는 수행하지 않았다. - 전체 빌드·타입 검사, 계정·반응형 레이아웃·익명 파일/링크·클립보드 텍스트 브라우저 검증을 통과했다. 운영 Docker 이미지 빌드, 기본 UID와 다른 NAS UID/GID를 사용하는 실제 격리 Compose 시작/health·영구 DB·호스트 바인드 마운트·공개 호스트 포트 없음, 재시작 뒤 파일/텍스트/세션 보존과 8MiB 미완료 조각 이어 보내기·SQLite integrity·호스트 파일 삭제를 확인했다. 실제 NAS·파일 관리자·실물 Safari의 확인과 재배포는 수행하지 않았다.
## 2026-10-13 가입 8자 기준과 인증 자동 로그인
- 새 비밀번호 최소 길이는 공유 상수 8자로 통일했다. 가입·재설정·변경의 서버 검사와 화면 안내에 같은 기준을 적용하고 기존 짧은 비밀번호 로그인은 유지한다. 개발 시드와 테스트 가입 데이터도 8자로 맞췄으며 실제 기존 개발 DB를 변경하거나 메일을 보내지는 않았다.
- 인증 토큰 소비·계정 활성화·새 세션 발급·현재 브라우저의 이전 세션 취소를 같은 트랜잭션으로 처리한다. 성공 후 이전 세션의 업로드·소켓 구독을 정리하고 보호된 계정 쿠키를 설정한다. 다른 기기의 이전 계정 세션은 유지한다. 인증 화면에서는 개인 초안을 지우고 해당 계정으로 접속해 받은 항목을 표시한다.
- 새 인증 테스트 10개를 포함한 API 총 98개 통과. 7자 거절/8자 허용, 자동 로그인·HttpOnly/Secure/SameSite 쿠키, 게스트 기기 유지, 이전 쿠키·소켓 무효화와 보관함 격리, 다른 기기 세션 유지, 만료·미등록·정지·삭제 계정·동시/재사용 토큰 거절, 세션 발급 실패 시 전체 롤백과 재시도를 확인했다. 기존 오프라인 검사는 35ms 대기 전에 실제 서버의 소켓 종료를 기다리도록 안정화했다.
- PC/모바일 실제 Chromium에서 7자 가입 차단·8자 가입·인증 후 추가 로그인 없이 보관함 진입을 확인했다. 다른 계정의 인증 링크를 같은 브라우저에서 열고 인증한 계정으로 전환·이전 쿠키 401·기존 자료 미노출·인증 토큰의 주소창 제거를 확인했다. 지연된 이전 계정 조회를 재현하고 순차 조회 큐가 최종 계정을 유지하는지 검사했다. 기존 보관함·수동 파일 삭제·익명 파일/링크 전송도 통과했다.
- 전체 빌드·타입 검사와 운영 Docker 빌드를 통과했다. 격리한 운영 컨테이너에서도 보호된 인증 쿠키·자동 로그인·이전 세션 무효화·토큰 재사용 거절·재시작 후 로그인 유지, 기존 파일/텍스트 보존·조각 이어 보내기·SQLite integrity를 확인했다. Docker 인증 토큰은 테스트 DB에 생성한 것으로 실제 Resend 발송과 NAS 배포는 수행하지 않았다. 코드 업데이트에 DB 초기화·YAML 재생성은 필요 없다.

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
export { classifyClipboard, clipboardCharacterCount } from './clipboard.js'; export { classifyClipboard, clipboardCharacterCount } from './clipboard.js';
export const DEVICE_NAME_MAX_LENGTH = 40; export const DEVICE_NAME_MAX_LENGTH = 40;
export const PASSWORD_MIN_LENGTH = 8;
export const devicePatch = z export const devicePatch = z
.object({ .object({
name: z name: z

View File

@@ -20,7 +20,7 @@ const cfg = loadConfig({
}); });
const runtime = await createApp(cfg); const runtime = await createApp(cfg);
try { try {
const password = '1234'; const password = '12345678';
const passwordHash = await runtime.service.auth.password(password); const passwordHash = await runtime.service.auth.password(password);
const accounts = [1, 2].map((n) => ({ const accounts = [1, 2].map((n) => ({
email: `test${n}@hop.test`, email: `test${n}@hop.test`,

View File

@@ -75,6 +75,43 @@ try {
`const base='http://127.0.0.1:${port}';const r=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test','Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error('session failed');const session=await r.json(),cookie=r.headers.get('set-cookie').split(';')[0];if(!r.headers.get('set-cookie').includes('Secure'))throw Error('insecure cookie');const renamed=await fetch(base+'/api/device/me',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({name:'Docker 저장 검증'})});if(!renamed.ok)throw Error('rename failed');console.log(JSON.stringify({cookie,id:session.device.id}));`, `const base='http://127.0.0.1:${port}';const r=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test','Content-Type':'application/json'},body:'{}'});if(!r.ok)throw Error('session failed');const session=await r.json(),cookie=r.headers.get('set-cookie').split(';')[0];if(!r.headers.get('set-cookie').includes('Secure'))throw Error('insecure cookie');const renamed=await fetch(base+'/api/device/me',{method:'PATCH',headers:{Origin:'https://hop.test',Cookie:cookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'},body:JSON.stringify({name:'Docker 저장 검증'})});if(!renamed.ok)throw Error('rename failed');console.log(JSON.stringify({cookie,id:session.device.id}));`,
), ),
); );
let verifiedAccount;
if (process.env.HOP_CHECK_AUTH === '1') {
verifiedAccount = JSON.parse(
node(`
import Database from 'better-sqlite3';
import {drizzle} from 'drizzle-orm/better-sqlite3';
import argon2 from 'argon2';
import {randomUUID} from 'node:crypto';
import {users, accountTokens} from './apps/server/dist/db/schema.js';
import {hash, randomToken} from './apps/server/dist/security.js';
const base='http://127.0.0.1:${port}', guestCookie=${JSON.stringify(state.cookie)};
const passwordHash=await argon2.hash('12345678'), token=randomToken(), now=Date.now();
const database=new Database('/data/database/hop.sqlite'), db=drizzle(database);
const oldId=randomUUID(), nextId=randomUUID();
db.insert(users).values({id:oldId,email:'previous@docker.hop.test',displayName:'previous',passwordHash,status:'active',verifiedAt:now,createdAt:now}).run();
db.insert(users).values({id:nextId,email:'verified@docker.hop.test',displayName:'verified',passwordHash,status:'pending',createdAt:now}).run();
db.insert(accountTokens).values({id:randomUUID(),userId:nextId,purpose:'verify',tokenHash:hash(token),createdAt:now,expiresAt:now+60000}).run();
database.close();
const sessionResponse=await fetch(base+'/api/guest/session',{method:'POST',headers:{Origin:'https://hop.test',Cookie:guestCookie,'Content-Type':'application/json'},body:'{}'});
const session=await sessionResponse.json();
const headers={Origin:'https://hop.test',Cookie:guestCookie,'X-CSRF-Token':session.csrfToken,'Content-Type':'application/json'};
const login=await fetch(base+'/api/auth/login',{method:'POST',headers,body:JSON.stringify({email:'previous@docker.hop.test',password:'12345678'})});
if(!login.ok)throw Error(await login.text());
const oldCookie=guestCookie+'; '+login.headers.get('set-cookie').split(';')[0];
const verification=await fetch(base+'/api/auth/verify-email',{method:'POST',headers:{...headers,Cookie:oldCookie},body:JSON.stringify({token})});
if(!verification.ok)throw Error(await verification.text());
const verified=await verification.json(), cookie=verification.headers.get('set-cookie');
if(verified.account.id!==nextId||!verified.account.verified||verified.token)throw Error('wrong verified account');
if(!cookie.includes('Secure')||!cookie.includes('HttpOnly')||!cookie.includes('SameSite=Lax'))throw Error('unprotected verification cookie');
const old=await fetch(base+'/api/auth/me',{headers:{Cookie:oldCookie}});
if((await old.json()).account!==null)throw Error('previous session still valid');
const replay=await fetch(base+'/api/auth/verify-email',{method:'POST',headers:{...headers,Cookie:guestCookie+'; '+cookie.split(';')[0]},body:JSON.stringify({token})});
if(replay.status!==400||replay.headers.get('set-cookie'))throw Error('token replay allowed');
console.log(JSON.stringify({cookie:guestCookie+'; '+cookie.split(';')[0],userId:nextId}));
`),
);
}
const fileState = JSON.parse( const fileState = JSON.parse(
node( node(
`import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`, `import {io} from 'socket.io-client';import {randomUUID} from 'node:crypto';const base='http://127.0.0.1:${port}';const headers={Origin:'https://hop.test','Content-Type':'application/json'};async function guest(){const r=await fetch(base+'/api/guest/session',{method:'POST',headers,body:'{}'});return {cookie:r.headers.get('set-cookie').split(';')[0],...await r.json()};}const a=await guest(),b=await guest();async function call(g,path,body,key){const r=await fetch(base+path,{method:'POST',headers:{...headers,Cookie:g.cookie,'X-CSRF-Token':g.csrfToken,...(key?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)});if(!r.ok)throw Error(await r.text());return r.json();}const sockets=[];for(const g of [a,b]){const socket=io(base,{transports:['websocket'],auth:{csrfToken:g.csrfToken},extraHeaders:{Origin:'https://hop.test',Cookie:g.cookie},reconnection:false});sockets.push(socket);await new Promise((resolve,reject)=>{socket.once('connect',resolve);socket.once('connect_error',reject);});}const invite=await call(a,'/api/spaces',{});await call(b,'/api/spaces/join',{code:invite.code});const bytes=Buffer.from('Docker file persistence');const transfer=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:bytes.length,count:1,files:[{name:'docker.txt',size:bytes.length,type:'text/plain'}]},randomUUID());await call(b,'/api/transfer-requests/'+transfer.requestId+'/accept',{},randomUUID());const form=new FormData();form.append('file',new Blob([bytes]),'docker.txt');const uploaded=await fetch(base+'/api/uploads?requestId='+transfer.requestId+'&fileIndex=0',{method:'POST',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Idempotency-Key':randomUUID()},body:form});if(!uploaded.ok)throw Error(await uploaded.text());const list=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const item=(await list.json()).items[0];const original=${JSON.stringify(' Docker 텍스트 日本語 😀\r\n 공백과 줄바꿈\n')};const message=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'text',count:1,byteSize:Buffer.byteLength(original)},randomUUID());await call(b,'/api/transfer-requests/'+message.requestId+'/accept',{},randomUUID());await call(a,'/api/transfers/'+message.requestId+'/complete',{text:original},randomUUID());const receivedText=await fetch(base+'/api/device/items',{headers:{Cookie:b.cookie}});const textItem=(await receivedText.json()).items.find(i=>i.kind==='text');if(textItem.text!==original)throw Error('text content changed');const {createHash}=await import('node:crypto');const chunkSize=8*1024*1024,big=Buffer.alloc(chunkSize+7,120),digest=v=>createHash('sha256').update(v).digest();const fingerprint=digest(Buffer.concat([digest(big.subarray(0,chunkSize)),digest(big.subarray(chunkSize))])).toString('hex');const pending=await call(a,'/api/transfer-requests',{targetDeviceId:b.device.id,kind:'file',byteSize:big.length,count:1,files:[{name:'resume.bin',size:big.length,type:'application/octet-stream'}]},randomUUID());await call(b,'/api/transfer-requests/'+pending.requestId+'/accept',{},randomUUID());const staged=await call(a,'/api/uploads/resumable',{requestId:pending.requestId,fileIndex:0,fingerprint});const first=await fetch(base+'/api/uploads/resumable/'+staged.uploadId,{method:'PUT',headers:{Origin:'https://hop.test',Cookie:a.cookie,'X-CSRF-Token':a.csrfToken,'Content-Type':'application/octet-stream','Upload-Offset':'0'},body:big.subarray(0,chunkSize)});if(!first.ok)throw Error(await first.text());for(const socket of sockets)socket.disconnect();console.log(JSON.stringify({cookie:b.cookie,accessId:item.accessId,text:{accessId:textItem.accessId,value:original},pending:{uploadId:staged.uploadId,cookie:a.cookie,csrf:a.csrfToken}}));`,
@@ -89,6 +126,16 @@ try {
); );
assert.equal(result.device.name, 'Docker 저장 검증'); assert.equal(result.device.name, 'Docker 저장 검증');
assert.equal(result.device.id, state.id); assert.equal(result.device.id, state.id);
if (verifiedAccount) {
const restored = JSON.parse(
node(
`const r=await fetch('http://127.0.0.1:${port}/api/auth/me',{headers:{Cookie:${JSON.stringify(verifiedAccount.cookie)}}});console.log(JSON.stringify(await r.json()));`,
),
);
assert.equal(restored.passwordMinLength, 8);
assert.equal(restored.account.id, verifiedAccount.userId);
assert.equal(restored.account.verified, true);
}
const preservedText = JSON.parse( const preservedText = JSON.parse(
node( node(
`const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('text list failed');const item=(await r.json()).items.find(i=>i.accessId===${JSON.stringify(fileState.text.accessId)});if(!item)throw Error('text not preserved');console.log(JSON.stringify(item.text));`, `const r=await fetch('http://127.0.0.1:${port}/api/device/items',{headers:{Cookie:${JSON.stringify(fileState.cookie)}}});if(!r.ok)throw Error('text list failed');const item=(await r.json()).items.find(i=>i.accessId===${JSON.stringify(fileState.text.accessId)});if(!item)throw Error('text not preserved');console.log(JSON.stringify(item.text));`,
@@ -142,7 +189,7 @@ try {
} }
assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}'); assert.equal(docker('inspect', '--format', '{{json .NetworkSettings.Ports}}', name), '{}');
console.log( console.log(
`PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}`, `PASS: production Docker startup, HTTPS Secure session cookie, migration, anonymous session/name/file and Unicode text persistence and partial chunk resume after restart, SQLite integrity, no host ports published.${bindUploads ? ' Bind uploads visible on host, custom NAS UID ownership, manual host deletion reconciliation.' : ''}${verifiedAccount ? ' Verified account auto-login, protected cookie, previous session revocation, one-use token and session persistence after restart.' : ''}`,
); );
} catch (error) { } catch (error) {
console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' })); console.error(execFileSync('docker', ['logs', name], { encoding: 'utf8', stdio: 'inherit' }));

View File

@@ -68,18 +68,35 @@ try {
const auth = page.locator('.account-auth'); const auth = page.locator('.account-auth');
await auth.getByLabel('이메일', { exact: true }).fill(email); await auth.getByLabel('이메일', { exact: true }).fill(email);
await auth.getByLabel('표시 이름', { exact: true }).fill(name); await auth.getByLabel('표시 이름', { exact: true }).fill(name);
await auth.getByLabel('비밀번호', { exact: true }).fill('1234'); assert.equal(await auth.getByLabel('비밀번호', { exact: true }).getAttribute('minlength'), '8');
await auth.getByLabel('비밀번호 확인', { exact: true }).fill('1234'); assert.equal(
await auth.getByLabel('비밀번호', { exact: true }).getAttribute('placeholder'),
'8자 이상',
);
await auth.getByLabel('비밀번호', { exact: true }).fill('1234567');
await auth.getByLabel('비밀번호 확인', { exact: true }).fill('1234567');
const beforeShortPassword = mails.length;
await auth.getByRole('button', { name: '가입하고 인증 메일 받기' }).click();
assert.equal(
await auth.getByLabel('비밀번호', { exact: true }).evaluate((el) => el.validity.tooShort),
true,
);
assert.equal(mails.length, beforeShortPassword);
await auth.getByLabel('비밀번호', { exact: true }).fill('12345678');
await auth.getByLabel('비밀번호 확인', { exact: true }).fill('12345678');
await auth.getByRole('button', { name: '가입하고 인증 메일 받기' }).click(); await auth.getByRole('button', { name: '가입하고 인증 메일 받기' }).click();
await auth.locator('.auth-message').waitFor(); await auth.locator('.auth-message').waitFor();
const token = mails.find((m) => m.to === email).text.match(/#verify=([A-Za-z0-9_-]+)/)[1]; const token = mails.find((m) => m.to === email).text.match(/#verify=([A-Za-z0-9_-]+)/)[1];
await page.goto(`${url}/#verify=${token}`); await page.goto(`${url}/#verify=${token}`);
await page.getByRole('button', { name: '이메일 인증 완료하기' }).click(); await page.getByRole('button', { name: '이메일 인증 완료하기' }).click();
await auth.getByRole('button', { name: '로그인', exact: true }).waitFor();
await auth.getByLabel('이메일', { exact: true }).fill(email);
await auth.getByLabel('비밀번호', { exact: true }).fill('1234');
await auth.getByRole('button', { name: '로그인', exact: true }).click();
await page.locator('.account-workspace').waitFor(); await page.locator('.account-workspace').waitFor();
assert.equal(
await page.evaluate(async () => (await (await fetch('/api/auth/me')).json()).account.email),
email,
'인증한 계정으로 자동 로그인',
);
assert.equal(await auth.count(), 0);
assert(!(await page.locator('body').innerText()).includes('로그인해 주세요'));
assert.deepEqual( assert.deepEqual(
(await page.getByRole('tab').allTextContents()).map((text) => text.trim()), (await page.getByRole('tab').allTextContents()).map((text) => text.trim()),
['받은 항목', '임시 저장'], ['받은 항목', '임시 저장'],
@@ -377,9 +394,82 @@ try {
await receiver.getByRole('button', { name: '로그아웃', exact: true }).click(); await receiver.getByRole('button', { name: '로그아웃', exact: true }).click();
await receiver.locator('.account-auth').waitFor(); await receiver.locator('.account-auth').waitFor();
assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved')); assert(!(await receiver.locator('body').innerText()).includes('https://example.com/saved'));
const signup = receiver.locator('.account-auth');
await signup.getByRole('button', { name: '이메일로 가입', exact: true }).click();
await signup.getByLabel('이메일', { exact: true }).fill('carol@example.com');
await signup.getByLabel('표시 이름', { exact: true }).fill('Carol');
await signup.getByLabel('비밀번호', { exact: true }).fill('12345678');
await signup.getByLabel('비밀번호 확인', { exact: true }).fill('12345678');
await signup.getByRole('button', { name: '가입하고 인증 메일 받기' }).click();
await signup.locator('.auth-message').waitFor();
const carolToken = mails
.find((mail) => mail.to === 'carol@example.com')
.text.match(/#verify=([A-Za-z0-9_-]+)/)[1];
const oldAccountCookie = (await a.cookies(url)).find(
(cookie) => cookie.name === 'hop_account',
).value;
const verificationPage = await a.newPage();
verificationPage.on('pageerror', (error) => errors.push(error.message));
await verificationPage.goto(`${url}/#verify=${carolToken}`);
await verificationPage.getByRole('button', { name: '이메일 인증 완료하기' }).waitFor();
await verificationPage.locator('.app-shell[data-online="true"]').waitFor();
await verificationPage.waitForFunction(
() => document.querySelector('.account-auth button[type="submit"]')?.disabled === false,
);
assert.equal(
await verificationPage.evaluate(
async () => (await (await fetch('/api/auth/me')).json()).account.email,
),
'alice@example.com',
);
let delayedAuthLookup = false;
await verificationPage.route('**/api/auth/me', async (route) => {
const response = await route.fetch();
if (!delayedAuthLookup) {
delayedAuthLookup = true;
const stale = { ...(await response.json()), account: null };
await new Promise((resolve) => setTimeout(resolve, 260));
await route.fulfill({ response, json: stale });
} else await route.fulfill({ response });
});
await verificationPage.getByRole('button', { name: '이메일 인증 완료하기' }).click();
await verificationPage.locator('.account-workspace').waitFor();
await verificationPage.unroute('**/api/auth/me');
assert.equal(
await verificationPage.evaluate(
async () => (await (await fetch('/api/auth/me')).json()).account.email,
),
'carol@example.com',
);
assert.equal(
await verificationPage
.getByRole('tab', { name: '받은 항목', exact: true })
.getAttribute('aria-selected'),
'true',
);
assert.equal(await verificationPage.locator('.item-card,.account-auth').count(), 0);
assert(
!(await verificationPage.locator('body').innerText()).includes('https://example.com/saved'),
);
const revokedAccount = await a.request.get(`${url}/api/auth/me`, {
headers: { Cookie: `hop_account=${oldAccountCookie}` },
});
assert.equal((await revokedAccount.json()).account, null);
const inaccessibleVault = await a.request.get(`${url}/api/vault`, {
headers: { Cookie: `hop_account=${oldAccountCookie}` },
});
assert.equal(inaccessibleVault.status(), 401);
assert.equal(verificationPage.url(), `${url}/`, '인증 토큰을 주소창에 남기지 않음');
await verificationPage.getByRole('button', { name: '계정 설정', exact: true }).click();
await verificationPage
.getByRole('dialog', { name: '계정 설정', exact: true })
.getByText('carol@example.com', { exact: true })
.waitFor();
await verificationPage.screenshot({ path: 'tests/e2e/verification-switch.png' });
await verificationPage.close();
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
console.log( console.log(
'PASS: 받은 항목 기본 화면·보관 안내, PC/모바일 계정 설정·입력·키보드 탐색, 가입·인증·로그인, 날짜별 기록, 직접 링크/파일 저장·메모·수락 후 자동 열기/다운로드, 파일 재업로드 없음, 7일 만료·삭제/복원·로그아웃 권한 격리, 원본 수동 삭제 시 다운로드 전 안내·양쪽 목록/용량 정리·열린 전송 선택 해제. 실제 Resend 발송은 포함하지 않음.', 'PASS: PC/모바일 가입 8자 규칙과 인증 자동 로그인, 다른 계정의 인증 링크에서 세션 교체·이전 쿠키 무효화·보관함 격리·지연된 인증 조회 경합, 받은 항목 기본 화면·계정 설정·날짜별 기록·저장/메모/수락 후 열기/다운로드·7일 보관·삭제/복원·원본 수동 삭제 대응·토스트. 실제 Resend 발송은 포함하지 않음.',
); );
} catch (error) { } catch (error) {
console.error(error); console.error(error);