M1 기반: 모노레포, 공통 엔진, 공용 프로토콜, 오목 엔진, 서버 골격
- packages/engine: 시드 RNG(sfc32), GameDefinition 타입, 무작위 대국·정보 유출 테스트 도구 - packages/shared: WS 프로토콜 스키마, 닉네임·방 코드 규칙, 게임 카탈로그 - packages/games/omok: 한국식·렌주·자유룰, 재귀 금수 판정, 무르기·무승부·시계 (테스트 27개) - apps/server: SQLite 스키마, 게스트·디스코드 로그인, 세션, 방 관리(저장·복구·타이머·재접속), WS 게이트웨이, HTTP API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
59
apps/server/src/auth/discord.ts
Normal file
59
apps/server/src/auth/discord.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** Discord OAuth2 (authorization code + state), docs/05-accounts-auth.md §3. */
|
||||
export interface DiscordUser {
|
||||
id: string;
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
const API = 'https://discord.com/api/v10';
|
||||
|
||||
export function authorizeUrl(clientId: string, redirectUri: string, state: string): string {
|
||||
const p = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
scope: 'identify',
|
||||
state,
|
||||
prompt: 'none',
|
||||
});
|
||||
return `https://discord.com/oauth2/authorize?${p}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
cfg: { clientId: string; clientSecret: string },
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<DiscordUser> {
|
||||
const tokenRes = await fetchImpl(`${API}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: cfg.clientId,
|
||||
client_secret: cfg.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!tokenRes.ok) throw new Error(`discord token exchange failed: ${tokenRes.status}`);
|
||||
const token = (await tokenRes.json()) as { access_token: string; token_type: string };
|
||||
const meRes = await fetchImpl(`${API}/users/@me`, {
|
||||
headers: { Authorization: `${token.token_type} ${token.access_token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!meRes.ok) throw new Error(`discord /users/@me failed: ${meRes.status}`);
|
||||
return (await meRes.json()) as DiscordUser;
|
||||
}
|
||||
|
||||
export function avatarUrl(u: DiscordUser): string | null {
|
||||
return u.avatar ? `https://cdn.discordapp.com/avatars/${u.id}/${u.avatar}.png?size=128` : null;
|
||||
}
|
||||
|
||||
/** Only same-site relative paths are allowed as post-login redirect targets. */
|
||||
export function safeNext(next: string | null | undefined): string {
|
||||
if (!next || !next.startsWith('/') || next.startsWith('//') || next.includes('\\')) return '/';
|
||||
return next;
|
||||
}
|
||||
86
apps/server/src/auth/sessions.ts
Normal file
86
apps/server/src/auth/sessions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
const DAY = 24 * 3600_000;
|
||||
export const SESSION_TTL = { guest: 365 * DAY, member: 90 * DAY } as const;
|
||||
export const SESSION_COOKIE = 'sid';
|
||||
|
||||
export function randomToken(bytes = 32): string {
|
||||
const buf = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(buf);
|
||||
return Buffer.from(buf).toString('base64url');
|
||||
}
|
||||
|
||||
export function sha256Hex(input: string): string {
|
||||
return new Bun.CryptoHasher('sha256').update(input).digest('hex');
|
||||
}
|
||||
|
||||
export class Sessions {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
create(userId: string, kind: 'guest' | 'member', userAgent: string | null, now = Date.now()): { token: string; expiresAt: number } {
|
||||
const token = randomToken();
|
||||
const expiresAt = now + SESSION_TTL[kind];
|
||||
this.db
|
||||
.query('INSERT INTO sessions (token_hash, user_id, created_at, expires_at, last_used_at, user_agent) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(sha256Hex(token), userId, now, expiresAt, now, userAgent?.slice(0, 200) ?? null);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
/** Resolves a token to a user id; slides expiry at most once per day. */
|
||||
resolve(token: string, now = Date.now()): string | null {
|
||||
const hash = sha256Hex(token);
|
||||
const row = this.db
|
||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
|
||||
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND u.deleted_at IS NULL`,
|
||||
)
|
||||
.get(hash);
|
||||
if (!row) return null;
|
||||
if (row.expires_at <= now) {
|
||||
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(hash);
|
||||
return null;
|
||||
}
|
||||
if (now - row.last_used_at > DAY) {
|
||||
this.db
|
||||
.query('UPDATE sessions SET last_used_at = ?, expires_at = ? WHERE token_hash = ?')
|
||||
.run(now, now + SESSION_TTL[row.kind], hash);
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
revoke(token: string): void {
|
||||
this.db.query('DELETE FROM sessions WHERE token_hash = ?').run(sha256Hex(token));
|
||||
}
|
||||
|
||||
revokeAll(userId: string): void {
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
purgeExpired(now = Date.now()): number {
|
||||
return this.db.query('DELETE FROM sessions WHERE expires_at <= ?').run(now).changes;
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal HMAC-signed value for short-lived cookies (OAuth state + PKCE verifier). */
|
||||
export async function signValue(secret: string, value: string): Promise<string> {
|
||||
const mac = await hmac(secret, value);
|
||||
return `${Buffer.from(value).toString('base64url')}.${mac}`;
|
||||
}
|
||||
|
||||
export async function unsignValue(secret: string, signed: string): Promise<string | null> {
|
||||
const dot = signed.lastIndexOf('.');
|
||||
if (dot < 0) return null;
|
||||
const value = Buffer.from(signed.slice(0, dot), 'base64url').toString();
|
||||
const expected = await hmac(secret, value);
|
||||
const given = signed.slice(dot + 1);
|
||||
if (expected.length !== given.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
|
||||
return diff === 0 ? value : null;
|
||||
}
|
||||
|
||||
async function hmac(secret: string, value: string): Promise<string> {
|
||||
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(value));
|
||||
return Buffer.from(sig).toString('base64url');
|
||||
}
|
||||
144
apps/server/src/auth/users.ts
Normal file
144
apps/server/src/auth/users.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { ulid } from 'ulid';
|
||||
import type { PublicUser } from '@bg/shared';
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
kind: 'guest' | 'member';
|
||||
nickname: string;
|
||||
avatar_url: string | null;
|
||||
use_avatar: number;
|
||||
settings_json: string;
|
||||
nick_changed_at: number | null;
|
||||
nick_change_count: number;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
deleted_at: number | null;
|
||||
}
|
||||
|
||||
export const NICK_CHANGES_PER_DAY = 10;
|
||||
|
||||
export class Users {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
get(id: string): UserRow | null {
|
||||
return this.db.query<UserRow, [string]>('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id);
|
||||
}
|
||||
|
||||
createGuest(nickname: string, now = Date.now()): UserRow {
|
||||
const id = ulid(now);
|
||||
this.db
|
||||
.query('INSERT INTO users (id, kind, nickname, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, 'guest', nickname, now, now);
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
findByOauth(provider: string, providerUserId: string): UserRow | null {
|
||||
return this.db
|
||||
.query<UserRow, [string, string]>(
|
||||
`SELECT u.* FROM users u JOIN oauth_accounts o ON o.user_id = u.id
|
||||
WHERE o.provider = ? AND o.provider_user_id = ? AND u.deleted_at IS NULL`,
|
||||
)
|
||||
.get(provider, providerUserId);
|
||||
}
|
||||
|
||||
createMember(nickname: string, avatarUrl: string | null, now = Date.now()): UserRow {
|
||||
const id = ulid(now);
|
||||
this.db
|
||||
.query('INSERT INTO users (id, kind, nickname, avatar_url, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(id, 'member', nickname, avatarUrl, now, now);
|
||||
return this.get(id)!;
|
||||
}
|
||||
|
||||
linkOauth(userId: string, provider: string, providerUserId: string, username: string | null, now = Date.now()): void {
|
||||
this.db
|
||||
.query('INSERT INTO oauth_accounts (provider, provider_user_id, user_id, username, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(provider, providerUserId, userId, username, now);
|
||||
}
|
||||
|
||||
promoteToMember(userId: string, avatarUrl: string | null): void {
|
||||
this.db.query("UPDATE users SET kind = 'member', avatar_url = ? WHERE id = ?").run(avatarUrl, userId);
|
||||
}
|
||||
|
||||
setAvatar(userId: string, avatarUrl: string | null): void {
|
||||
this.db.query('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId);
|
||||
}
|
||||
|
||||
/** Moves stats from a guest into a member account and deletes the guest (docs/05 §4). */
|
||||
mergeGuestInto(guestId: string, memberId: string): void {
|
||||
this.db.transaction(() => {
|
||||
const rows = this.db
|
||||
.query<{ game_id: string; played: number; wins: number; draws: number }, [string]>(
|
||||
'SELECT game_id, played, wins, draws FROM user_stats WHERE user_id = ?',
|
||||
)
|
||||
.all(guestId);
|
||||
for (const r of rows) {
|
||||
this.db
|
||||
.query(
|
||||
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + excluded.played,
|
||||
wins = wins + excluded.wins, draws = draws + excluded.draws`,
|
||||
)
|
||||
.run(memberId, r.game_id, r.played, r.wins, r.draws);
|
||||
}
|
||||
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(guestId);
|
||||
this.db.query('UPDATE game_players SET user_id = ? WHERE user_id = ?').run(memberId, guestId);
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(guestId);
|
||||
this.db.query('DELETE FROM users WHERE id = ?').run(guestId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Returns an error message or null. Enforces the daily change limit. */
|
||||
changeNickname(userId: string, nickname: string, now = Date.now()): string | null {
|
||||
const u = this.get(userId);
|
||||
if (!u) return '사용자를 찾을 수 없어요.';
|
||||
if (u.nickname === nickname) return null;
|
||||
const sameDay = u.nick_changed_at !== null && now - u.nick_changed_at < 24 * 3600_000;
|
||||
const count = sameDay ? u.nick_change_count : 0;
|
||||
if (count >= NICK_CHANGES_PER_DAY) return '닉네임은 하루에 10번까지 바꿀 수 있어요.';
|
||||
this.db
|
||||
.query('UPDATE users SET nickname = ?, nick_changed_at = ?, nick_change_count = ? WHERE id = ?')
|
||||
.run(nickname, sameDay ? u.nick_changed_at : now, count + 1, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
updateSettings(userId: string, patch: { useAvatar?: boolean; settings?: Record<string, unknown> }): void {
|
||||
if (patch.useAvatar !== undefined) {
|
||||
this.db.query('UPDATE users SET use_avatar = ? WHERE id = ?').run(patch.useAvatar ? 1 : 0, userId);
|
||||
}
|
||||
if (patch.settings) {
|
||||
this.db.query('UPDATE users SET settings_json = ? WHERE id = ?').run(JSON.stringify(patch.settings), userId);
|
||||
}
|
||||
}
|
||||
|
||||
touch(userId: string, now = Date.now()): void {
|
||||
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
|
||||
}
|
||||
|
||||
softDelete(userId: string, now = Date.now()): void {
|
||||
this.db.transaction(() => {
|
||||
this.db
|
||||
.query("UPDATE users SET deleted_at = ?, nickname = '탈퇴한 사용자', avatar_url = NULL WHERE id = ?")
|
||||
.run(now, userId);
|
||||
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
|
||||
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
stats(userId: string): { gameId: string; played: number; wins: number; draws: number }[] {
|
||||
return this.db
|
||||
.query<{ gameId: string; played: number; wins: number; draws: number }, [string]>(
|
||||
'SELECT game_id AS gameId, played, wins, draws FROM user_stats WHERE user_id = ? ORDER BY played DESC',
|
||||
)
|
||||
.all(userId);
|
||||
}
|
||||
}
|
||||
|
||||
export function toPublicUser(u: UserRow): PublicUser {
|
||||
return {
|
||||
id: u.id,
|
||||
nickname: u.nickname,
|
||||
avatar: u.use_avatar && u.avatar_url ? u.avatar_url : null,
|
||||
kind: u.kind,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user