Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
374
apps/server/src/auth.ts
Normal file
374
apps/server/src/auth.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
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 * 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 = this.service.cfg.production ? 12 : 1;
|
||||
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) {
|
||||
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: purpose === 'reset' ? 'hop. 비밀번호 재설정' : 'hop. 이메일 인증',
|
||||
text: `${purpose === 'reset' ? '비밀번호를 다시 설정하려면' : '이메일 인증을 완료하려면'} 다음 주소를 열어 주세요.\n${this.service.cfg.appUrl}/#${action}=${token}\n이 링크는 한 번만 사용할 수 있습니다.`,
|
||||
});
|
||||
}
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
ip: string,
|
||||
setupToken?: string,
|
||||
) {
|
||||
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');
|
||||
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) {
|
||||
return 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();
|
||||
});
|
||||
}
|
||||
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);
|
||||
if (!valid || !user || !['pending', 'active'].includes(user.status))
|
||||
fail(401, 'LOGIN_FAILED', '이메일 또는 비밀번호를 확인해 주세요.');
|
||||
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 };
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user