Files
hop.noname.company/apps/server/src/auth.ts

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