419 lines
15 KiB
TypeScript
419 lines
15 KiB
TypeScript
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 { PASSWORD_MIN_LENGTH, translate, 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 };
|
|
export type Mailer = (mail: Mail) => Promise<void>;
|
|
export type User = typeof s.users.$inferSelect;
|
|
const passwordOptions = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 1 };
|
|
let dummy: Promise<string> | undefined;
|
|
export function normalizeEmail(value: string) {
|
|
const trimmed = value.trim();
|
|
const split = trimmed.lastIndexOf('@');
|
|
if (
|
|
split < 1 ||
|
|
split === trimmed.length - 1 ||
|
|
trimmed.length > 254 ||
|
|
/[\s\x00-\x1f\x7f]/.test(trimmed)
|
|
)
|
|
fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.');
|
|
const local = trimmed.slice(0, split),
|
|
domain = trimmed.slice(split + 1).toLowerCase();
|
|
if (
|
|
!/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local) ||
|
|
!/^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)
|
|
)
|
|
fail(400, 'EMAIL_INVALID', '이메일 주소를 확인해 주세요.');
|
|
return `${local}@${domain}`;
|
|
}
|
|
export class Auth {
|
|
readonly sendMail: Mailer;
|
|
readonly mailReady: boolean;
|
|
constructor(
|
|
readonly service: Service,
|
|
mailer?: Mailer,
|
|
) {
|
|
this.mailReady = Boolean(mailer);
|
|
this.sendMail =
|
|
mailer ??
|
|
(async () => fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.'));
|
|
if (service.cfg.registrationMode === 'open' && !this.mailReady)
|
|
throw Error('Mail provider must be configured before opening registration');
|
|
}
|
|
serialize(user: User): Account {
|
|
return {
|
|
id: user.id,
|
|
email: user.email ?? '',
|
|
displayName: user.status === 'deleted' ? '탈퇴한 사용자' : user.displayName,
|
|
role: user.role,
|
|
status: user.status,
|
|
verified: Boolean(user.verifiedAt && user.status === 'active'),
|
|
};
|
|
}
|
|
user(id: string) {
|
|
const user = this.service.db.select().from(s.users).where(eq(s.users.id, id)).get();
|
|
if (!user) fail(404, 'ACCOUNT_NOT_FOUND', '계정을 찾을 수 없습니다.');
|
|
return user!;
|
|
}
|
|
session(token: string | undefined) {
|
|
if (!token) return undefined;
|
|
const row = this.service.db
|
|
.select({ session: s.accountSessions, user: s.users })
|
|
.from(s.accountSessions)
|
|
.innerJoin(s.users, eq(s.accountSessions.userId, s.users.id))
|
|
.where(
|
|
and(
|
|
eq(s.accountSessions.tokenHash, hash(token)),
|
|
isNull(s.accountSessions.revokedAt),
|
|
gt(s.accountSessions.expiresAt, Date.now()),
|
|
inArray(s.users.status, ['pending', 'active']),
|
|
),
|
|
)
|
|
.get();
|
|
return row;
|
|
}
|
|
require(token: string | undefined, verified = false) {
|
|
const current = this.session(token);
|
|
if (!current) fail(401, 'LOGIN_REQUIRED', '로그인이 필요합니다.');
|
|
if (verified && !this.serialize(current!.user).verified)
|
|
fail(403, 'VERIFICATION_REQUIRED', '이메일 인증을 완료해 주세요.');
|
|
return current!;
|
|
}
|
|
async password(value: string) {
|
|
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);
|
|
}
|
|
async token(
|
|
user: User,
|
|
purpose: 'verify' | 'reset' | 'email-change',
|
|
value?: string,
|
|
language = 'ko',
|
|
) {
|
|
if (!this.mailReady)
|
|
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
|
|
const token = randomToken(),
|
|
now = Date.now();
|
|
this.service.db.transaction(() => {
|
|
this.service.db
|
|
.update(s.accountTokens)
|
|
.set({ usedAt: now })
|
|
.where(
|
|
and(
|
|
eq(s.accountTokens.userId, user.id),
|
|
eq(s.accountTokens.purpose, purpose),
|
|
isNull(s.accountTokens.usedAt),
|
|
),
|
|
)
|
|
.run();
|
|
this.service.db
|
|
.insert(s.accountTokens)
|
|
.values({
|
|
id: randomUUID(),
|
|
userId: user.id,
|
|
purpose,
|
|
tokenHash: hash(token),
|
|
value,
|
|
createdAt: now,
|
|
expiresAt: now + (purpose === 'verify' ? 24 : 1) * 3600000,
|
|
})
|
|
.run();
|
|
});
|
|
const action =
|
|
purpose === 'verify'
|
|
? 'verify'
|
|
: purpose === 'reset'
|
|
? 'reset-password'
|
|
: 'verify-email-change';
|
|
await this.sendMail({
|
|
idempotencyKey: `auth-${purpose}-${hash(token)}`,
|
|
to: value ?? user.email!,
|
|
subject: translate(
|
|
purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
|
|
language,
|
|
),
|
|
text: translate(
|
|
'{p0} 다음 주소를 열어 주세요.\n{p1}/#{p2}={p3}\n이 링크는 한 번만 사용할 수 있습니다.',
|
|
language,
|
|
{
|
|
p0: translate(
|
|
purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면',
|
|
language,
|
|
),
|
|
p1: this.service.cfg.appUrl,
|
|
p2: action,
|
|
p3: token,
|
|
},
|
|
),
|
|
});
|
|
}
|
|
async register(
|
|
email: string,
|
|
password: string,
|
|
displayName: string,
|
|
ip: string,
|
|
setupToken?: string,
|
|
language = 'ko',
|
|
) {
|
|
const { db, cfg, limiter } = this.service;
|
|
limiter.hit(`register-ip:${ip}`, 5, 3600000);
|
|
let admin = false;
|
|
if (setupToken) {
|
|
if (
|
|
!cfg.setupToken ||
|
|
hash(setupToken) !== hash(cfg.setupToken) ||
|
|
db.select().from(s.users).limit(1).get()
|
|
)
|
|
fail(403, 'SETUP_DISABLED', '초기화를 진행할 수 없습니다.');
|
|
admin = true;
|
|
} else if (cfg.registrationMode !== 'open')
|
|
fail(403, 'REGISTRATION_CLOSED', '현재 공개 가입이 닫혀 있습니다.');
|
|
if (!this.mailReady)
|
|
fail(503, 'MAIL_NOT_CONFIGURED', '이메일 기능이 아직 설정되지 않았습니다.');
|
|
const normalized = normalizeEmail(email),
|
|
passwordHash = await this.password(password);
|
|
if (db.select().from(s.users).where(eq(s.users.email, normalized)).get())
|
|
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
|
|
const user = db.transaction(() => {
|
|
if (admin && db.select().from(s.users).limit(1).get())
|
|
fail(409, 'SETUP_DISABLED', '초기화가 이미 완료되었습니다.');
|
|
const id = randomUUID();
|
|
db.insert(s.users)
|
|
.values({
|
|
id,
|
|
email: normalized,
|
|
passwordHash,
|
|
displayName,
|
|
role: admin ? 'admin' : 'user',
|
|
status: 'pending',
|
|
createdAt: Date.now(),
|
|
})
|
|
.run();
|
|
return this.user(id);
|
|
});
|
|
await this.token(user, 'verify', undefined, language);
|
|
return { message: '가입을 진행할 수 있는 주소라면 인증 메일이 발송됩니다.' };
|
|
}
|
|
consume(
|
|
token: string,
|
|
purpose: 'verify' | 'reset' | 'email-change',
|
|
fn: (token: typeof s.accountTokens.$inferSelect, user: User) => void,
|
|
) {
|
|
return this.service.db.transaction(() => {
|
|
const row = this.service.db
|
|
.select()
|
|
.from(s.accountTokens)
|
|
.where(
|
|
and(
|
|
eq(s.accountTokens.tokenHash, hash(token)),
|
|
eq(s.accountTokens.purpose, purpose),
|
|
isNull(s.accountTokens.usedAt),
|
|
gt(s.accountTokens.expiresAt, Date.now()),
|
|
),
|
|
)
|
|
.get();
|
|
if (!row) fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.');
|
|
const user = this.user(row!.userId);
|
|
if (['suspended', 'deleted'].includes(user.status))
|
|
fail(400, 'TOKEN_INVALID', '링크가 유효하지 않거나 만료되었습니다.');
|
|
fn(row!, user);
|
|
this.service.db
|
|
.update(s.accountTokens)
|
|
.set({ usedAt: Date.now() })
|
|
.where(eq(s.accountTokens.id, row!.id))
|
|
.run();
|
|
return { ok: true };
|
|
});
|
|
}
|
|
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);
|
|
this.service.limiter.hit(`login-ip:${ip}`, 20);
|
|
this.service.limiter.hit(`login-email:${hash(normalized)}`, 10);
|
|
const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get();
|
|
dummy ??= argon2.hash(randomToken(), passwordOptions);
|
|
const valid = await argon2
|
|
.verify(user?.passwordHash ?? (await dummy), password)
|
|
.catch(() => false);
|
|
const currentUser = user ? this.user(user.id) : null;
|
|
if (
|
|
!valid ||
|
|
!currentUser ||
|
|
!['pending', 'active'].includes(currentUser.status) ||
|
|
currentUser.passwordHash !== user?.passwordHash
|
|
)
|
|
fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.');
|
|
const previous = this.session(oldToken);
|
|
const result = this.createSession(currentUser!, oldToken);
|
|
if (previous) this.endSession(previous);
|
|
return result;
|
|
}
|
|
private createSession(user: User, oldToken?: string) {
|
|
const token = randomToken(),
|
|
now = Date.now();
|
|
this.service.db.transaction(() => {
|
|
if (oldToken)
|
|
this.service.db
|
|
.update(s.accountSessions)
|
|
.set({ revokedAt: now })
|
|
.where(eq(s.accountSessions.tokenHash, hash(oldToken)))
|
|
.run();
|
|
this.service.db
|
|
.insert(s.accountSessions)
|
|
.values({
|
|
id: randomUUID(),
|
|
userId: user.id,
|
|
tokenHash: hash(token),
|
|
createdAt: now,
|
|
lastSeen: now,
|
|
expiresAt: now + 30 * 86400000,
|
|
})
|
|
.run();
|
|
});
|
|
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.db
|
|
.update(s.accountSessions)
|
|
.set({ revokedAt: Date.now() })
|
|
.where(eq(s.accountSessions.id, current.session.id))
|
|
.run();
|
|
this.endSession(current);
|
|
}
|
|
revoke(userId: string) {
|
|
this.service.storage.abortUser(userId);
|
|
this.service.storage.abortUser(userId);
|
|
this.service.db
|
|
.update(s.accountSessions)
|
|
.set({ revokedAt: Date.now() })
|
|
.where(eq(s.accountSessions.userId, userId))
|
|
.run();
|
|
this.service.io?.to(`account:${userId}`).emit('account:revoked', {});
|
|
this.service.io?.in(`account:${userId}`).socketsLeave(`account:${userId}`);
|
|
}
|
|
enforceSessions() {
|
|
for (const socket of this.service.io?.sockets.sockets.values() ?? []) {
|
|
const id = socket.data.accountSessionId;
|
|
if (!id) continue;
|
|
const session = this.service.db
|
|
.select({ session: s.accountSessions, user: s.users })
|
|
.from(s.accountSessions)
|
|
.innerJoin(s.users, eq(s.accountSessions.userId, s.users.id))
|
|
.where(eq(s.accountSessions.id, id))
|
|
.get();
|
|
if (
|
|
!session ||
|
|
session.session.revokedAt ||
|
|
session.session.expiresAt <= Date.now() ||
|
|
!['pending', 'active'].includes(session.user.status)
|
|
) {
|
|
socket.leave(`account:${socket.data.accountId}`);
|
|
socket.leave(`account-session:${id}`);
|
|
socket.data.accountId = undefined;
|
|
socket.data.accountSessionId = undefined;
|
|
socket.emit('account:revoked', {});
|
|
}
|
|
}
|
|
}
|
|
async resend(email: string, purpose: 'verify' | 'reset', ip: string, language = 'ko') {
|
|
const normalized = normalizeEmail(email);
|
|
this.service.limiter.hit(`mail-ip:${ip}`, 10, 3600000);
|
|
this.service.limiter.hit(`mail-email:${hash(normalized)}`, 3, 3600000);
|
|
const user = this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get();
|
|
if (
|
|
user &&
|
|
((purpose === 'verify' && user.status === 'pending') ||
|
|
(purpose === 'reset' && ['active', 'pending'].includes(user.status)))
|
|
)
|
|
await this.token(user, purpose, undefined, language);
|
|
return { message: '처리 가능한 주소라면 안내 메일이 발송됩니다.' };
|
|
}
|
|
async resetPassword(token: string, password: string) {
|
|
const passwordHash = await this.password(password);
|
|
let userId = '';
|
|
const result = this.consume(token, 'reset', (_t, user) => {
|
|
userId = user.id;
|
|
this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run();
|
|
this.service.db
|
|
.update(s.accountTokens)
|
|
.set({ usedAt: Date.now() })
|
|
.where(eq(s.accountTokens.userId, user.id))
|
|
.run();
|
|
});
|
|
this.revoke(userId);
|
|
return result;
|
|
}
|
|
async changePassword(user: User, currentPassword: string, password: string) {
|
|
if (!user.passwordHash || !(await argon2.verify(user.passwordHash, currentPassword)))
|
|
fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.');
|
|
const passwordHash = await this.password(password);
|
|
this.service.db.update(s.users).set({ passwordHash }).where(eq(s.users.id, user.id)).run();
|
|
this.revoke(user.id);
|
|
}
|
|
async changeEmail(user: User, email: string, password: string) {
|
|
if (!user.passwordHash || !(await argon2.verify(user.passwordHash, password)))
|
|
fail(401, 'PASSWORD_INVALID', '현재 비밀번호를 확인해 주세요.');
|
|
const normalized = normalizeEmail(email);
|
|
if (
|
|
normalized === user.email ||
|
|
this.service.db.select().from(s.users).where(eq(s.users.email, normalized)).get()
|
|
)
|
|
fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.');
|
|
await this.token(user, 'email-change', normalized);
|
|
}
|
|
async verifyEmailChange(token: string) {
|
|
let previous = '',
|
|
next = '';
|
|
const result = this.consume(token, 'email-change', (t, user) => {
|
|
if (
|
|
!t.value ||
|
|
this.service.db.select().from(s.users).where(eq(s.users.email, t.value)).get()
|
|
)
|
|
fail(400, 'EMAIL_UNAVAILABLE', '이 주소로 변경할 수 없습니다.');
|
|
previous = user.email!;
|
|
next = t.value!;
|
|
this.service.db
|
|
.update(s.users)
|
|
.set({ email: t.value, verifiedAt: Date.now(), status: 'active' })
|
|
.where(eq(s.users.id, user.id))
|
|
.run();
|
|
});
|
|
await this.sendMail({
|
|
to: previous,
|
|
subject: 'hop. 이메일 주소 변경 안내',
|
|
text: `계정 이메일이 ${next}로 변경되었습니다.`,
|
|
});
|
|
return result;
|
|
}
|
|
}
|