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

@@ -2,7 +2,7 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
import { and, eq, gt, isNull, inArray } from 'drizzle-orm';
import argon2 from 'argon2';
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 { fail, hash, randomToken } from './security.js';
export type Mail = { to: string; subject: string; text: string; idempotencyKey?: string };
@@ -83,7 +83,7 @@ export class Auth {
return current!;
}
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)
fail(400, 'PASSWORD_INVALID', `비밀번호는 ${minimum}~1024자로 입력해 주세요.`);
return argon2.hash(value, passwordOptions);
@@ -208,14 +208,19 @@ export class Auth {
return { ok: true };
});
}
verify(token: string) {
return this.consume(token, 'verify', (_t, user) => {
verify(token: string, oldToken?: string) {
const previous = this.session(oldToken);
let login!: ReturnType<Auth['createSession']>;
const verified = this.consume(token, 'verify', (_t, user) => {
this.service.db
.update(s.users)
.set({ status: 'active', verifiedAt: Date.now() })
.where(eq(s.users.id, user.id))
.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) {
const normalized = normalizeEmail(email);
@@ -228,6 +233,12 @@ export class Auth {
.catch(() => false);
if (!valid || !user || !['pending', 'active'].includes(user.status))
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(),
now = Date.now();
this.service.db.transaction(() => {
@@ -241,7 +252,7 @@ export class Auth {
.insert(s.accountSessions)
.values({
id: randomUUID(),
userId: user!.id,
userId: user.id,
tokenHash: hash(token),
createdAt: now,
lastSeen: now,
@@ -249,22 +260,28 @@ export class Auth {
})
.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) {
const current = this.session(token);
if (!current) return;
this.service.storage.abortSession(current.session.id);
this.service.db
.update(s.accountSessions)
.set({ revokedAt: Date.now() })
.where(eq(s.accountSessions.id, current.session.id))
.run();
this.service.storage.abortSession(current.session.id);
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}`);
this.endSession(current);
}
revoke(userId: string) {
this.service.storage.abortUser(userId);