Merge PR #14: SPRINT-016 office dashboard hotfix

This commit is contained in:
2026-04-09 08:59:16 +09:00
12 changed files with 475 additions and 103 deletions

View File

@@ -0,0 +1,128 @@
import { randomBytes, timingSafeEqual } from 'crypto';
import type { CookieOptions, Request, Response } from 'express';
export const ACCESS_TOKEN_COOKIE = 'hanarang_access_token';
export const REFRESH_TOKEN_COOKIE = 'hanarang_refresh_token';
export const CSRF_TOKEN_COOKIE = 'hanarang_csrf_token';
const ACCESS_TOKEN_MAX_AGE_MS = 15 * 60 * 1000;
const REFRESH_TOKEN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
function isHttpsRequest(req?: Request): boolean {
const forwardedProto = req?.headers['x-forwarded-proto'];
const protocol = Array.isArray(forwardedProto)
? forwardedProto[0]
: forwardedProto;
return (
process.env.NODE_ENV === 'production' ||
req?.secure === true ||
protocol === 'https'
);
}
function baseCookieOptions(req?: Request): CookieOptions {
return {
path: '/',
secure: isHttpsRequest(req),
};
}
export function parseCookieHeader(
header?: string | string[],
): Record<string, string> {
const raw = Array.isArray(header) ? header.join(';') : header;
if (!raw) return {};
return raw
.split(';')
.map((part) => part.trim())
.filter((part) => part.length > 0)
.reduce<Record<string, string>>((cookies, part) => {
const eqIndex = part.indexOf('=');
if (eqIndex === -1) return cookies;
const key = decodeURIComponent(part.slice(0, eqIndex).trim());
const value = decodeURIComponent(part.slice(eqIndex + 1).trim());
cookies[key] = value;
return cookies;
}, {});
}
export function getCookieValue(
req: Pick<Request, 'headers'>,
name: string,
): string | undefined {
return parseCookieHeader(req.headers.cookie)[name];
}
export function generateCsrfToken(): string {
return randomBytes(32).toString('hex');
}
export function hasValidCsrfToken(req: Request): boolean {
const cookieToken = getCookieValue(req, CSRF_TOKEN_COOKIE);
const headerToken = req.headers['x-csrf-token'];
const requestToken = Array.isArray(headerToken) ? headerToken[0] : headerToken;
if (!cookieToken || !requestToken) return false;
const cookieBuffer = Buffer.from(cookieToken);
const requestBuffer = Buffer.from(requestToken);
if (cookieBuffer.length !== requestBuffer.length) return false;
try {
return timingSafeEqual(cookieBuffer, requestBuffer);
} catch {
return false;
}
}
export function setAuthCookies(
res: Response,
req: Request,
tokens: { accessToken: string; refreshToken: string },
csrfToken = generateCsrfToken(),
) {
res.cookie(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
...baseCookieOptions(req),
httpOnly: true,
sameSite: 'lax',
maxAge: ACCESS_TOKEN_MAX_AGE_MS,
});
res.cookie(REFRESH_TOKEN_COOKIE, tokens.refreshToken, {
...baseCookieOptions(req),
httpOnly: true,
sameSite: 'strict',
maxAge: REFRESH_TOKEN_MAX_AGE_MS,
});
res.cookie(CSRF_TOKEN_COOKIE, csrfToken, {
...baseCookieOptions(req),
httpOnly: false,
sameSite: 'strict',
maxAge: REFRESH_TOKEN_MAX_AGE_MS,
});
return csrfToken;
}
export function clearAuthCookies(res: Response, req?: Request) {
res.clearCookie(ACCESS_TOKEN_COOKIE, {
...baseCookieOptions(req),
httpOnly: true,
sameSite: 'lax',
});
res.clearCookie(REFRESH_TOKEN_COOKIE, {
...baseCookieOptions(req),
httpOnly: true,
sameSite: 'strict',
});
res.clearCookie(CSRF_TOKEN_COOKIE, {
...baseCookieOptions(req),
httpOnly: false,
sameSite: 'strict',
});
}

View File

@@ -1,17 +1,30 @@
import {
Controller,
Post,
Get,
BadRequestException,
Body,
Headers,
Controller,
Get,
HttpCode,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
Request,
BadRequestException,
} from '@nestjs/common';
import type { Request as ExpressRequest } from 'express';
import { AuthService, LoginDto } from './auth.service';
import { JwtGuard } from './jwt.guard';
import type {
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { AuthService, LoginDto } from './auth.service';
import {
getCookieValue,
hasValidCsrfToken,
setAuthCookies,
clearAuthCookies,
REFRESH_TOKEN_COOKIE,
} from './auth-cookies';
import { JwtGuard } from './jwt.guard';
interface AuthenticatedRequest extends ExpressRequest {
user: { userId: number };
@@ -21,17 +34,58 @@ interface AuthenticatedRequest extends ExpressRequest {
export class AuthController {
constructor(private readonly authService: AuthService) {}
@HttpCode(200)
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
async login(
@Body() dto: LoginDto,
@Req() req: ExpressRequest,
@Res({ passthrough: true }) res: ExpressResponse,
) {
const tokens = await this.authService.login(dto);
setAuthCookies(res, req, tokens);
return {
username: tokens.username,
role: tokens.role,
};
}
@HttpCode(200)
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 3, ttl: 60000 } })
@Post('refresh')
refresh(@Headers('x-refresh-token') token: string) {
async refresh(
@Req() req: ExpressRequest,
@Res({ passthrough: true }) res: ExpressResponse,
) {
const cookieToken = getCookieValue(req, REFRESH_TOKEN_COOKIE);
const headerToken = req.headers['x-refresh-token'];
const token = cookieToken ?? (Array.isArray(headerToken) ? headerToken[0] : headerToken);
if (!token) throw new BadRequestException('Refresh token required');
return this.authService.refresh(token);
if (cookieToken && !hasValidCsrfToken(req)) {
throw new UnauthorizedException('Invalid CSRF token');
}
const tokens = await this.authService.refresh(token);
setAuthCookies(res, req, tokens);
return {
username: tokens.username,
role: tokens.role,
};
}
@HttpCode(200)
@Post('logout')
logout(
@Req() req: ExpressRequest,
@Res({ passthrough: true }) res: ExpressResponse,
) {
clearAuthCookies(res, req);
return { ok: true };
}
@UseGuards(JwtGuard)

View File

@@ -1,7 +1,9 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import type { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ACCESS_TOKEN_COOKIE, getCookieValue } from './auth-cookies';
export interface JwtPayload {
sub: number;
@@ -9,13 +11,21 @@ export interface JwtPayload {
role: string;
}
function cookieTokenExtractor(req?: Request): string | null {
if (!req) return null;
return getCookieValue(req, ACCESS_TOKEN_COOKIE) ?? null;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(config: ConfigService) {
const secret = config.get<string>('JWT_SECRET');
if (!secret) throw new Error('JWT_SECRET not set');
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
jwtFromRequest: ExtractJwt.fromExtractors([
cookieTokenExtractor,
ExtractJwt.fromAuthHeaderAsBearerToken(),
]),
ignoreExpiration: false,
secretOrKey: secret,
});

View File

@@ -11,6 +11,7 @@ import { Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { OnEvent } from '@nestjs/event-emitter';
import { ACCESS_TOKEN_COOKIE, parseCookieHeader } from '../auth/auth-cookies';
interface SocketUserPayload {
username?: string;
@@ -50,12 +51,17 @@ export class EventsGateway
handleConnection(client: SocketWithUser) {
// JWT 인증 필수 — 토큰 없거나 유효하지 않으면 disconnect
const authHeader = client.handshake.headers.authorization;
const tokenFromHeader = Array.isArray(authHeader)
? authHeader[0]?.replace('Bearer ', '')
: authHeader?.replace('Bearer ', '');
const tokenFromCookie = parseCookieHeader(client.handshake.headers.cookie)[
ACCESS_TOKEN_COOKIE
];
const token =
(client.handshake.auth?.token as string) ??
(client.handshake.headers.authorization as string)?.replace(
'Bearer ',
'',
);
(client.handshake.auth?.token as string | undefined) ??
tokenFromHeader ??
tokenFromCookie;
if (!token) {
this.logger.debug(`WS rejected (no token): ${client.id}`);

View File

@@ -1,7 +1,14 @@
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import type { NextFunction, Request, Response } from 'express';
import helmet from 'helmet';
import { AppModule } from './app.module';
import {
ACCESS_TOKEN_COOKIE,
REFRESH_TOKEN_COOKIE,
getCookieValue,
hasValidCsrfToken,
} from './auth/auth-cookies';
function getAllowedOrigins() {
return (process.env.CORS_ORIGINS ?? 'http://localhost:3004')
@@ -10,10 +17,40 @@ function getAllowedOrigins() {
.filter((origin): origin is string => origin.length > 0);
}
function shouldBypassCsrf(req: Request): boolean {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method.toUpperCase())) {
return true;
}
if (req.path === '/api/auth/login') {
return true;
}
const hasAuthorizationHeader = Boolean(req.headers.authorization);
if (hasAuthorizationHeader) {
return true;
}
const hasAuthCookie = Boolean(
getCookieValue(req, ACCESS_TOKEN_COOKIE) ||
getCookieValue(req, REFRESH_TOKEN_COOKIE),
);
return !hasAuthCookie;
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet());
app.use((req: Request, res: Response, next: NextFunction) => {
if (shouldBypassCsrf(req) || hasValidCsrfToken(req)) {
next();
return;
}
res.status(403).json({ message: 'Invalid CSRF token' });
});
const allowedOrigins = getAllowedOrigins();
@@ -29,6 +66,12 @@ async function bootstrap() {
}
},
methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-CSRF-Token',
'X-Refresh-Token',
],
credentials: true,
});

View File

@@ -1,7 +1,7 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { SshService } from './ssh.service';
import { ConfigService } from '@nestjs/config';
type SisterName = 'harang' | 'narang' | 'darang' | 'erang';
@@ -20,6 +20,15 @@ const SISTER_DESCRIPTIONS: Record<SisterName, string> = {
erang: '인프라와 배포. 서버 관리, merge, 프로덕션 배포를 담당한다.',
};
function summarizeText(value?: string | null, maxLength = 120): string | null {
if (!value) return null;
const compact = value.replace(/\s+/g, ' ').trim();
if (!compact) return null;
if (compact.length <= maxLength) return compact;
return `${compact.slice(0, maxLength - 1)}`;
}
@Injectable()
export class SisterDetailService {
private readonly logger = new Logger(SisterDetailService.name);
@@ -39,21 +48,36 @@ export class SisterDetailService {
sister.ip,
sister.user,
sshKeyPath,
'WORKSPACE=~/.hermes/workspace; [ -d "$WORKSPACE" ] || WORKSPACE=~/.openclaw/workspace; cat "$WORKSPACE"/SOUL.md 2>/dev/null | head -60; echo "---AGENTS---"; cat "$WORKSPACE"/AGENTS.md 2>/dev/null | head -40',
'WORKSPACE=~/.hermes/workspace; [ -d "$WORKSPACE" ] || WORKSPACE=~/.openclaw/workspace; for FILE in SOUL.md AGENTS.md; do if [ -f "$WORKSPACE/$FILE" ]; then printf "%s|present|%s\n" "$FILE" "$(wc -l < "$WORKSPACE/$FILE" 2>/dev/null || echo 0)"; else printf "%s|missing|0\n" "$FILE"; fi; done',
);
const files = result.stdout
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
const [file, status, lineCount] = line.split('|');
return {
file,
status,
lineCount: Number.parseInt(lineCount ?? '0', 10) || 0,
};
});
return {
name,
role: SISTER_ROLES[name],
description: SISTER_DESCRIPTIONS[name],
raw: result.stdout || '(설정 파일 없음)',
files,
summary: `${files.filter((file) => file.status === 'present').length}/${files.length} protected config files detected`,
};
} catch {
return {
name,
role: SISTER_ROLES[name],
description: SISTER_DESCRIPTIONS[name],
raw: '(SSH 연결 불가)',
files: [],
summary: '(SSH 연결 불가)',
};
}
}
@@ -70,21 +94,34 @@ export class SisterDetailService {
'SESSION_DIR=~/.hermes/agents/main/sessions; [ -d "$SESSION_DIR" ] || SESSION_DIR=~/.openclaw/agents/main/sessions; ls -lt "$SESSION_DIR"/ 2>/dev/null | head -20 || echo ""',
);
const lines = result.stdout
const sessions = result.stdout
.split('\n')
.filter((l) => l && !l.startsWith('total'));
return lines
.filter((line) => line && !line.startsWith('total'))
.map((line) => {
const parts = line.trim().split(/\s+/);
const label = parts[parts.length - 1] ?? '';
const modified = parts.slice(5, 8).join(' ');
const size = parts[4] ?? '0';
return {
name: parts[parts.length - 1] ?? '',
modified: parts.slice(5, 8).join(' '),
size: parts[4] ?? '0',
id: label,
label,
status: [modified, size !== '0' ? `${size}B` : null]
.filter((part): part is string => Boolean(part))
.join(' · '),
};
})
.filter((s) => s.name && s.name !== '');
.filter((session) => session.label.length > 0);
return {
sessions,
total: sessions.length,
};
} catch {
return [];
return {
sessions: [],
total: 0,
};
}
}
@@ -100,15 +137,25 @@ export class SisterDetailService {
'AGENT_DIR=~/.hermes/workspace/agents; [ -d "$AGENT_DIR" ] || AGENT_DIR=~/.openclaw/workspace/agents; ls "$AGENT_DIR"/ 2>/dev/null || echo ""',
);
const agents = result.stdout
const items = result.stdout
.split('\n')
.filter((l) => l.trim().endsWith('.md'));
return agents.map((a) => ({
name: a.trim().replace('.md', ''),
file: a.trim(),
}));
.map((line) => line.trim())
.filter((line) => line.endsWith('.md'))
.map((file) => ({
id: file.replace('.md', ''),
label: file.replace('.md', ''),
file,
}));
return {
items,
total: items.length,
};
} catch {
return [];
return {
items: [],
total: 0,
};
}
}
@@ -122,7 +169,16 @@ export class SisterDetailService {
}),
this.prisma.activityLog.count({ where: { sisterId: sister.id } }),
]);
return { items: logs, total };
return {
items: logs.map((log) => ({
id: log.id,
action: log.action,
detail: summarizeText(log.detail),
createdAt: log.createdAt,
})),
total,
};
}
async getOrgData() {
@@ -157,8 +213,8 @@ export class SisterDetailService {
}
private getKeyPath(): string {
const p = this.config.get<string>('SSH_KEY_PATH');
if (!p) throw new Error('SSH_KEY_PATH is not set');
return p;
const path = this.config.get<string>('SSH_KEY_PATH');
if (!path) throw new Error('SSH_KEY_PATH is not set');
return path;
}
}

View File

@@ -8,14 +8,17 @@ import {
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import { SistersService } from './sisters.service';
import { SisterDetailService } from './sister-detail.service';
import { JwtGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
import { SisterNamePipe } from '../common/sister-name.pipe';
import type { SisterName } from '../common/sister-name.pipe';
import { AvatarService } from './avatar.service';
import { JwtGuard } from '../auth/jwt.guard';
import { SisterDetailService } from './sister-detail.service';
import { SistersService } from './sisters.service';
@Controller('api/sisters')
@UseGuards(JwtGuard, RoleGuard)
@Roles('admin', 'viewer')
export class SistersController {
constructor(
private readonly sistersService: SistersService,
@@ -30,7 +33,17 @@ export class SistersController {
@Get()
async getSistersStatus() {
return this.sistersService.getAllSistersStatus();
const sisters = await this.sistersService.getAllSistersStatus();
return sisters.map(({ id, name, lxcId, role, status, lastSeen, currentTask }) => ({
id,
name,
lxcId,
role,
status,
lastSeen,
currentTask,
}));
}
@Get(':name/runtime')
@@ -49,7 +62,15 @@ export class SistersController {
@Get(':name/system')
async getSystemInfo(@Param('name', SisterNamePipe) name: SisterName) {
return this.sistersService.getSystemInfo(name);
const system = await this.sistersService.getSystemInfo(name);
if (!system) return null;
return {
uptime: system.uptime,
cpu: system.cpu,
memory: system.memory,
disk: system.disk,
};
}
@Get(':name/avatar')
@@ -64,25 +85,29 @@ export class SistersController {
const avatar = await this.avatarService.getAvatar(sister);
res.setHeader('Content-Type', avatar.contentType);
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Cache-Control', 'private, max-age=300');
return res.send(avatar.data);
}
@Roles('admin')
@Get(':name/config')
async getSisterConfig(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterConfig(name);
}
@Roles('admin')
@Get(':name/sessions')
async getSisterSessions(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterSessions(name);
}
@Roles('admin')
@Get(':name/subagents')
async getSisterSubagents(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterSubagents(name);
}
@Roles('admin')
@Get(':name/activity')
async getSisterActivity(@Param('name', SisterNamePipe) name: SisterName) {
return this.sisterDetail.getSisterActivityLog(name);

View File

@@ -381,12 +381,21 @@ export default function OfficePage() {
};
void boot();
// Polling fallback — only when socket is disconnected
// Socket handles live updates; polling is a disconnected fallback
return () => {};
}, [fetchAll]);
useEffect(() => {
if (connected) return; // socket is live, no polling needed
const interval = setInterval(() => {
void fetchAll();
}, POLL_INTERVAL_MS);
return () => clearInterval(interval);
}, [fetchAll]);
}, [connected, fetchAll]);
const hasSisterSnapshot = sisters.length > 0;
const sisterDataMode: 'live' | 'snapshot' | 'fallback' = connected ? 'live' : hasSisterSnapshot ? 'snapshot' : 'fallback';

View File

@@ -433,7 +433,9 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
const [activeSister, setActiveSister] = useState<SisterName>(initialSister);
const [allMessages, setAllMessages] = useState<Partial<Record<SisterName, ChatMessage[]>>>({});
const [runtimeBySister, setRuntimeBySister] = useState<Partial<Record<SisterName, RuntimeSnapshot>>>({});
const [input, setInput] = useState('');
// Per-sister draft — prevents input leaking across tabs
const [drafts, setDrafts] = useState<Partial<Record<SisterName, string>>>({});
const input = drafts[activeSister] ?? '';
const [sending, setSending] = useState(false);
const timelineRef = useRef<HTMLDivElement>(null);
@@ -488,7 +490,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
const token = getToken();
if (!token) {
setMessages(activeSister, (prev) => mergeMessages(prev, [{
id: `sys-${Date.now()}`,
id: `sys-${crypto.randomUUID()}`,
role: 'assistant',
content: '로그인이 풀린 것 같아. 다시 로그인한 뒤 시도해줘.',
ts: new Date().toISOString(),
@@ -497,14 +499,14 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
}
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
id: `user-${crypto.randomUUID()}`,
role: 'user',
content: text,
ts: new Date().toISOString(),
};
setMessages(activeSister, (prev) => mergeMessages(prev, [userMessage]));
setInput('');
setDrafts((prev) => ({ ...prev, [activeSister]: '' }));
setSending(true);
try {
@@ -529,7 +531,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
const reply = String(data?.reply || '').trim();
if (reply) {
setMessages(activeSister, (prev) => mergeMessages(prev, [{
id: `assistant-${Date.now()}`,
id: `assistant-${crypto.randomUUID()}`,
role: 'assistant',
content: reply,
ts: new Date().toISOString(),
@@ -540,7 +542,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
} catch (error) {
const message = error instanceof Error ? error.message : '채팅 전송 중 오류가 발생했어.';
setMessages(activeSister, (prev) => mergeMessages(prev, [{
id: `error-${Date.now()}`,
id: `error-${crypto.randomUUID()}`,
role: 'assistant',
content: `전송 실패: ${message}`,
ts: new Date().toISOString(),
@@ -614,7 +616,7 @@ export default function ChatWorkspace({ initialSister, onClose }: ChatWorkspaceP
<MessageInput
placeholder={`${SISTER_DISPLAY[activeSister]}에게 지시해...`}
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(e) => setDrafts((prev) => ({ ...prev, [activeSister]: e.target.value }))}
onKeyDown={handleKeyDown}
rows={1}
/>

View File

@@ -181,7 +181,14 @@ function SisterCircle({
const clipId = `avatar-clip-${name}`;
return (
<g onClick={onClick} style={{ cursor: 'pointer' }}>
<g
role="button"
tabIndex={0}
aria-label={`${name} — state: ${state}`}
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
style={{ cursor: 'pointer', outline: 'none' }}
>
{selected && (
<circle
cx={cx}
@@ -264,7 +271,14 @@ function SubagentCircle({
const color = STATE_COLORS[state];
return (
<g onClick={onClick} style={{ cursor: 'pointer' }}>
<g
role="button"
tabIndex={0}
aria-label={`subagent ${label} — state: ${state}`}
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }}
style={{ cursor: 'pointer', outline: 'none' }}
>
{motion && (
<animateTransform
attributeName="transform"

View File

@@ -3,6 +3,7 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { API_URL } from './config';
import { withSessionRequest } from './csrf';
interface AuthUser {
userId: number;
@@ -31,38 +32,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const loadUser = useCallback(async () => {
const token = localStorage.getItem('hanarang_access_token');
if (!token) { setLoading(false); return; }
try {
const res = await fetch(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
const res = await fetch(`${API_URL}/api/auth/me`, withSessionRequest());
if (res.ok) {
const data = await res.json();
setUser({ userId: data.id, username: data.username, role: data.role });
} else if (res.status === 401) {
// access token 만료 → refresh 시도
const refreshToken = localStorage.getItem('hanarang_refresh_token');
if (refreshToken) {
try {
const rRes = await fetch(`${API_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'x-refresh-token': refreshToken },
});
if (rRes.ok) {
const d = await rRes.json();
localStorage.setItem('hanarang_access_token', d.accessToken);
if (d.refreshToken) localStorage.setItem('hanarang_refresh_token', d.refreshToken);
setUser({ userId: 0, username: d.username, role: d.role });
} else {
localStorage.removeItem('hanarang_access_token');
localStorage.removeItem('hanarang_refresh_token');
}
} catch { /* silent */ }
} else {
localStorage.removeItem('hanarang_access_token');
}
// access token expired → try refresh via cookie
try {
const rRes = await fetch(`${API_URL}/api/auth/refresh`, withSessionRequest({ method: 'POST' }, { csrf: true }));
if (rRes.ok) {
const d = await rRes.json();
setUser({ userId: 0, username: d.username, role: d.role });
}
} catch { /* silent */ }
}
} catch {
// silent
@@ -74,11 +57,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { loadUser(); }, [loadUser]);
const login = async (username: string, password: string) => {
const res = await fetch(`${API_URL}/api/auth/login`, {
const res = await fetch(`${API_URL}/api/auth/login`, withSessionRequest({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
}, { csrf: true }));
if (!res.ok) {
const d = await res.json();
@@ -86,27 +69,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
const d = await res.json();
localStorage.setItem('hanarang_access_token', d.accessToken);
if (d.refreshToken) {
localStorage.setItem('hanarang_refresh_token', d.refreshToken);
}
// me API로 실제 userId 가져오기
// Tokens are set as HttpOnly cookies by the server
setUser({ userId: 0, username: d.username, role: d.role });
// Fetch actual userId from /me
try {
const meRes = await fetch(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${d.accessToken}` },
});
const meRes = await fetch(`${API_URL}/api/auth/me`, withSessionRequest());
if (meRes.ok) {
const me = await meRes.json();
setUser({ userId: me.id, username: me.username, role: me.role });
return;
}
} catch { /* fallback */ }
setUser({ userId: 0, username: d.username, role: d.role });
} catch { /* fallback — already set basic info */ }
};
const logout = () => {
localStorage.removeItem('hanarang_access_token');
localStorage.removeItem('hanarang_refresh_token');
const logout = async () => {
try {
await fetch(`${API_URL}/api/auth/logout`, withSessionRequest({ method: 'POST' }, { csrf: true }));
} catch { /* silent */ }
setUser(null);
};

46
frontend/lib/csrf.ts Normal file
View File

@@ -0,0 +1,46 @@
export const CSRF_COOKIE_NAME = 'hanarang_csrf_token';
export function getCookieValue(name: string): string {
if (typeof document === 'undefined') return '';
const match = document.cookie
.split('; ')
.find((item) => item.startsWith(`${encodeURIComponent(name)}=`));
if (!match) return '';
return decodeURIComponent(match.split('=').slice(1).join('='));
}
export function getCsrfToken(): string {
return getCookieValue(CSRF_COOKIE_NAME);
}
export function needsCsrf(method?: string): boolean {
const normalized = (method ?? 'GET').toUpperCase();
return !['GET', 'HEAD', 'OPTIONS'].includes(normalized);
}
export function withCsrfHeaders(headers?: HeadersInit): Headers {
const nextHeaders = new Headers(headers);
const csrfToken = getCsrfToken();
if (csrfToken) {
nextHeaders.set('x-csrf-token', csrfToken);
}
return nextHeaders;
}
export function withSessionRequest(
init: RequestInit = {},
options: { csrf?: boolean } = {},
): RequestInit {
const useCsrf = options.csrf ?? needsCsrf(init.method);
return {
...init,
credentials: 'include',
headers: useCsrf ? withCsrfHeaders(init.headers) : new Headers(init.headers),
};
}