치지직 앱으로 연동하고, 연동 확인된 ID만 접속할 수 있도록 수정, 개발용으로는 채널ID를 수동으로 접속할 수 있도록 변경.
최초 추천 고정 배치 추가.
This commit is contained in:
46
lib/auth/mode.ts
Normal file
46
lib/auth/mode.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 개발: 채널 ID 입장 + 치지직 로그인 모두 허용
|
||||
* 릴리즈(production): 치지직 로그인(세션)으로만 채널 룸 입장
|
||||
*
|
||||
* 강제 오버라이드:
|
||||
* - DRINKMARBLE_ALLOW_CHANNEL_ID_JOIN=true → production에서도 채널 ID 입장 허용
|
||||
* - DRINKMARBLE_REQUIRE_CHZZK_LOGIN=true → development에서도 로그인 필수
|
||||
*/
|
||||
export function allowChannelIdJoin(): boolean {
|
||||
if (process.env.DRINKMARBLE_REQUIRE_CHZZK_LOGIN === 'true') return false;
|
||||
if (process.env.DRINKMARBLE_ALLOW_CHANNEL_ID_JOIN === 'true') return true;
|
||||
return process.env.NODE_ENV !== 'production';
|
||||
}
|
||||
|
||||
export function requireChzzkLoginForChannelRooms(): boolean {
|
||||
return !allowChannelIdJoin();
|
||||
}
|
||||
|
||||
export function isChzzkOAuthConfigured(): boolean {
|
||||
return Boolean(
|
||||
process.env.CHZZK_CLIENT_ID?.trim() &&
|
||||
process.env.CHZZK_CLIENT_SECRET?.trim() &&
|
||||
process.env.CHZZK_REDIRECT_URI?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getChzzkOAuthConfig() {
|
||||
const clientId = process.env.CHZZK_CLIENT_ID?.trim() || '';
|
||||
const clientSecret = process.env.CHZZK_CLIENT_SECRET?.trim() || '';
|
||||
const redirectUri = process.env.CHZZK_REDIRECT_URI?.trim() || '';
|
||||
if (!clientId || !clientSecret || !redirectUri) {
|
||||
throw new Error(
|
||||
'치지직 OAuth 환경변수(CHZZK_CLIENT_ID, CHZZK_CLIENT_SECRET, CHZZK_REDIRECT_URI)가 필요합니다.',
|
||||
);
|
||||
}
|
||||
return { clientId, clientSecret, redirectUri };
|
||||
}
|
||||
|
||||
export function getAuthSessionSecret(): string {
|
||||
const explicit = process.env.AUTH_SESSION_SECRET?.trim();
|
||||
if (explicit) return explicit;
|
||||
const fallback = process.env.CHZZK_CLIENT_SECRET?.trim();
|
||||
if (fallback) return fallback;
|
||||
// 개발 전용 폴백 (production에서는 OAuth 설정이 있어야 함)
|
||||
return 'drinkmarble-dev-session-secret';
|
||||
}
|
||||
59
lib/auth/public-origin.ts
Normal file
59
lib/auth/public-origin.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Caddy 등 리버스 프록시 뒤에서 request.url 이
|
||||
* http://0.0.0.0:13000 처럼 잡히는 경우가 있어, 공개 Origin 을 따로 계산한다.
|
||||
*/
|
||||
export function getPublicOrigin(request: Request): string {
|
||||
const fromEnv =
|
||||
process.env.APP_ORIGIN?.trim() ||
|
||||
process.env.NEXT_PUBLIC_APP_ORIGIN?.trim();
|
||||
if (fromEnv) {
|
||||
try {
|
||||
return new URL(fromEnv).origin;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUri = process.env.CHZZK_REDIRECT_URI?.trim();
|
||||
if (redirectUri) {
|
||||
try {
|
||||
return new URL(redirectUri).origin;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const xfHost = request.headers.get('x-forwarded-host')?.split(',')[0]?.trim();
|
||||
const xfProto =
|
||||
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() || 'https';
|
||||
if (xfHost && !isBadHost(xfHost)) {
|
||||
return `${xfProto}://${xfHost}`;
|
||||
}
|
||||
|
||||
const host = request.headers.get('host')?.trim();
|
||||
if (host && !isBadHost(host)) {
|
||||
const proto =
|
||||
request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ||
|
||||
(host.includes('localhost') || host.startsWith('127.') ? 'http' : 'https');
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const origin = new URL(request.url).origin;
|
||||
if (!isBadHost(new URL(origin).host)) return origin;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return 'https://drink.kimyu.xyz';
|
||||
}
|
||||
|
||||
function isBadHost(host: string) {
|
||||
const h = host.toLowerCase().split(':')[0];
|
||||
return (
|
||||
h === '0.0.0.0' ||
|
||||
h === '::' ||
|
||||
h === '[::]' ||
|
||||
h === 'host.docker.internal'
|
||||
);
|
||||
}
|
||||
68
lib/auth/room-access.ts
Normal file
68
lib/auth/room-access.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
allowChannelIdJoin,
|
||||
isChzzkOAuthConfigured,
|
||||
requireChzzkLoginForChannelRooms,
|
||||
} from '@/lib/auth/mode';
|
||||
import { readAuthSession, type AuthSession } from '@/lib/auth/session';
|
||||
import type { RoomRecord } from '@/lib/rooms/store';
|
||||
|
||||
export async function getRequestAuthSession(
|
||||
request?: Request,
|
||||
): Promise<AuthSession | null> {
|
||||
// Route Handler에서는 cookies()가 기본. request Cookie 헤더도 보조 파싱.
|
||||
const fromCookies = await readAuthSession();
|
||||
if (fromCookies) return fromCookies;
|
||||
if (!request) return null;
|
||||
const header = request.headers.get('cookie') || '';
|
||||
const match = /(?:^|;\s*)drinkmarble_auth=([^;]+)/.exec(header);
|
||||
if (!match?.[1]) return null;
|
||||
const { parseAuthSession } = await import('@/lib/auth/session');
|
||||
return parseAuthSession(decodeURIComponent(match[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 치지직 연동 룸 접근 검사.
|
||||
* - 개발(채널 ID 허용): 세션 없어도 OK
|
||||
* - 릴리즈: 세션의 channelId가 룸 채널과 일치해야 함
|
||||
*/
|
||||
export async function assertChannelRoomAccess(
|
||||
room: RoomRecord,
|
||||
request?: Request,
|
||||
): Promise<{ ok: true; session: AuthSession | null } | { ok: false; response: NextResponse }> {
|
||||
if (!room.settings.chzzkEnabled) {
|
||||
return { ok: true, session: null };
|
||||
}
|
||||
|
||||
if (!requireChzzkLoginForChannelRooms()) {
|
||||
return { ok: true, session: await getRequestAuthSession(request) };
|
||||
}
|
||||
|
||||
const session = await getRequestAuthSession(request);
|
||||
const roomChannel = room.settings.channelId?.toLowerCase() || '';
|
||||
if (!session || !roomChannel || session.channelId !== roomChannel) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'릴리즈 환경에서는 치지직 로그인으로 본인 채널만 입장할 수 있습니다. 홈에서 다시 로그인해 주세요.',
|
||||
code: 'CHZZK_AUTH_REQUIRED',
|
||||
allowChannelIdJoin: allowChannelIdJoin(),
|
||||
oauthConfigured: isChzzkOAuthConfigured(),
|
||||
},
|
||||
{ status: 401 },
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, session };
|
||||
}
|
||||
|
||||
export function authModePublic() {
|
||||
return {
|
||||
allowChannelIdJoin: allowChannelIdJoin(),
|
||||
requireChzzkLogin: requireChzzkLoginForChannelRooms(),
|
||||
oauthConfigured: isChzzkOAuthConfigured(),
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
};
|
||||
}
|
||||
137
lib/auth/session.ts
Normal file
137
lib/auth/session.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getAuthSessionSecret } from './mode';
|
||||
|
||||
export const AUTH_COOKIE = 'drinkmarble_auth';
|
||||
export const OAUTH_STATE_COOKIE = 'drinkmarble_oauth_state';
|
||||
|
||||
export type AuthSession = {
|
||||
channelId: string;
|
||||
channelName: string | null;
|
||||
/** unix ms */
|
||||
exp: number;
|
||||
};
|
||||
|
||||
export type OAuthStatePayload = {
|
||||
state: string;
|
||||
cheesePerDice?: number;
|
||||
multiplyByCheese?: boolean;
|
||||
maxMultiplier?: number;
|
||||
/** unix ms */
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function b64urlEncode(buf: Buffer | string) {
|
||||
const b = typeof buf === 'string' ? Buffer.from(buf, 'utf8') : buf;
|
||||
return b
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string) {
|
||||
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
||||
const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + pad;
|
||||
return Buffer.from(b64, 'base64');
|
||||
}
|
||||
|
||||
function sign(payloadB64: string) {
|
||||
return createHmac('sha256', getAuthSessionSecret())
|
||||
.update(payloadB64)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
function seal<T extends object>(data: T): string {
|
||||
const payloadB64 = b64urlEncode(JSON.stringify(data));
|
||||
return `${payloadB64}.${sign(payloadB64)}`;
|
||||
}
|
||||
|
||||
function unseal<T extends object>(token: string): T | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [payloadB64, sig] = parts;
|
||||
if (!payloadB64 || !sig) return null;
|
||||
const expected = sign(payloadB64);
|
||||
try {
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(b64urlDecode(payloadB64).toString('utf8')) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 브라우저 종료 시까지 + 최대 12시간 (치지직 토큰은 저장하지 않음) */
|
||||
const SESSION_MAX_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
export function createAuthSession(
|
||||
channelId: string,
|
||||
channelName: string | null,
|
||||
): AuthSession {
|
||||
return {
|
||||
channelId: channelId.trim().toLowerCase(),
|
||||
channelName,
|
||||
exp: Date.now() + SESSION_MAX_MS,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeAuthSession(session: AuthSession) {
|
||||
return seal(session);
|
||||
}
|
||||
|
||||
export function parseAuthSession(token: string | undefined | null): AuthSession | null {
|
||||
if (!token) return null;
|
||||
const data = unseal<AuthSession>(token);
|
||||
if (!data?.channelId || !data.exp) return null;
|
||||
if (Date.now() > data.exp) return null;
|
||||
return {
|
||||
channelId: String(data.channelId).toLowerCase(),
|
||||
channelName: data.channelName ?? null,
|
||||
exp: data.exp,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readAuthSession(): Promise<AuthSession | null> {
|
||||
const jar = await cookies();
|
||||
return parseAuthSession(jar.get(AUTH_COOKIE)?.value);
|
||||
}
|
||||
|
||||
export function authCookieOptions(maxAgeSec = Math.floor(SESSION_MAX_MS / 1000)) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
maxAge: maxAgeSec,
|
||||
};
|
||||
}
|
||||
|
||||
export function createOAuthState(input: {
|
||||
cheesePerDice?: number;
|
||||
multiplyByCheese?: boolean;
|
||||
maxMultiplier?: number;
|
||||
}) {
|
||||
const state = b64urlEncode(randomBytes(24));
|
||||
const payload: OAuthStatePayload = {
|
||||
state,
|
||||
cheesePerDice: input.cheesePerDice,
|
||||
multiplyByCheese: input.multiplyByCheese,
|
||||
maxMultiplier: input.maxMultiplier,
|
||||
exp: Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
return { state, sealed: seal(payload) };
|
||||
}
|
||||
|
||||
export function parseOAuthStateCookie(token: string | undefined | null): OAuthStatePayload | null {
|
||||
if (!token) return null;
|
||||
const data = unseal<OAuthStatePayload>(token);
|
||||
if (!data?.state || !data.exp) return null;
|
||||
if (Date.now() > data.exp) return null;
|
||||
return data;
|
||||
}
|
||||
Reference in New Issue
Block a user