Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@
"revoke_current_confirm": "This is the device you're currently using. Signing out will log you out here too. Continue?",
"revoke_others": "Sign out all other devices",
"revoke_others_confirm": "Sign out every session except this device?",
"revoke_all": "Sign out all devices",
"revoke_all_confirm": "Sign out every session, including this device? You will need to sign in again.",
"empty": "No active sessions.",
"revoked_notice": "The selected session was signed out.",
"revoked_others_notice": "Signed out of all other devices.",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@
"revoke_current_confirm": "현재 사용 중인 기기입니다. 로그아웃하면 이 화면에서도 로그아웃됩니다. 계속할까요?",
"revoke_others": "다른 모든 기기에서 로그아웃",
"revoke_others_confirm": "현재 기기를 제외한 모든 세션을 로그아웃할까요?",
"revoke_all": "모든 기기에서 로그아웃",
"revoke_all_confirm": "현재 기기를 포함한 모든 세션을 로그아웃할까요? 로그아웃 후 다시 로그인해야 합니다.",
"empty": "활성 세션이 없습니다.",
"revoked_notice": "선택한 세션을 로그아웃했습니다.",
"revoked_others_notice": "다른 모든 기기에서 로그아웃했습니다.",
Expand Down
5 changes: 3 additions & 2 deletions src/lib/server/auth/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,9 @@ export async function createSessionRecord(
}

/**
* 새로 발급된 세션을 제외한 동일 사용자의 모든 활성 세션을 무효화한다.
* 로그인/MFA/패스키 인증 직후 호출하면 기존 세션 탈취 흔적을 제거할 수 있다.
* 지정한 세션을 제외한 동일 사용자의 모든 활성 세션을 무효화한다.
* 셀프서비스 세션 관리 화면의 "다른 세션 모두 로그아웃" 에서 사용한다.
* (로그인 시에는 호출하지 않는다 — 디바이스별 동시 세션을 허용한다.)
*/
export async function revokeOtherSessions(db: DB, userId: string, keepSessionId: string, revokedAt = new Date()) {
await db
Expand Down
7 changes: 2 additions & 5 deletions src/routes/(auth)/login/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit";
import { requireDbContext } from "$lib/server/auth/guards";
import { createSessionRecord, revokeOtherSessions, setSessionCookie } from "$lib/server/auth/session";
import { createSessionRecord, setSessionCookie } from "$lib/server/auth/session";
import { authenticateLocalUser, authenticatePendingDeletionUser, hasTotpCredential, normalizeUsername } from "$lib/server/auth/users";
import { createMfaPendingToken, MFA_PENDING_COOKIE } from "$lib/server/auth/mfa";
import { AMR_PASSWORD, amrToAcr } from "$lib/server/auth/constants";
Expand Down Expand Up @@ -323,7 +323,7 @@ export const actions: Actions = {
}

// MFA 없음 — 세션 바로 생성
const { sessionToken, expiresAt, sessionId } = await createSessionRecord(db, {
const { sessionToken, expiresAt } = await createSessionRecord(db, {
tenantId: tenant.id,
userId: user.id,
ip: requestMetadata.ip,
Expand All @@ -332,9 +332,6 @@ export const actions: Actions = {
acr: amrToAcr([AMR_PASSWORD]),
});

// 기존 세션 회수 — 새 세션만 살아남도록.
await revokeOtherSessions(db, user.id, sessionId);

setSessionCookie(event.cookies, event.url, sessionToken, expiresAt);
await recordAuditEvent(db, {
tenantId: tenant.id,
Expand Down
7 changes: 2 additions & 5 deletions src/routes/(auth)/mfa/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { eq, and, isNull } from "drizzle-orm";
import type { Actions, PageServerLoad } from "./$types";
import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit";
import { requireDbContext } from "$lib/server/auth/guards";
import { createSessionRecord, revokeOtherSessions, setSessionCookie } from "$lib/server/auth/session";
import { createSessionRecord, setSessionCookie } from "$lib/server/auth/session";
import { verifyMfaPendingToken, MFA_PENDING_COOKIE } from "$lib/server/auth/mfa";
import { tryWithSecrets, tryWithSecretsNullable } from "$lib/server/crypto/keys";
import { verifyTotp, decryptTotpSecret, encryptTotpSecret, isLegacyTotpCiphertext, verifyBackupCode } from "$lib/server/auth/totp";
Expand Down Expand Up @@ -233,7 +233,7 @@ export const actions: Actions = {
// MFA 통과 — 세션 생성
event.cookies.delete(MFA_PENDING_COOKIE, { path: "/" });

const { sessionToken, expiresAt, sessionId } = await createSessionRecord(db, {
const { sessionToken, expiresAt } = await createSessionRecord(db, {
tenantId: claims.tenantId,
userId: user.id,
ip: requestMetadata.ip,
Expand All @@ -242,9 +242,6 @@ export const actions: Actions = {
acr: amrToAcr([AMR_PASSWORD, amrMethod]),
});

// 기존 세션 회수 — 새 세션만 살아남도록.
await revokeOtherSessions(db, user.id, sessionId);

setSessionCookie(event.cookies, event.url, sessionToken, expiresAt);
await recordAuditEvent(db, {
tenantId: claims.tenantId,
Expand Down
36 changes: 34 additions & 2 deletions src/routes/account/sessions/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { fail, redirect } from "@sveltejs/kit";
import type { Actions, PageServerLoad } from "./$types";
import { getRequestMetadata, recordAuditEvent } from "$lib/server/audit";
import { requireDbContext } from "$lib/server/auth/guards";
import { clearSessionCookie, listActiveSessions, revokeOtherSessions, revokeSessionById } from "$lib/server/auth/session";
import { revokeRefreshTokensForSession } from "$lib/server/oidc/refresh";
import { clearSessionCookie, listActiveSessions, revokeAllUserSessions, revokeOtherSessions, revokeSessionById } from "$lib/server/auth/session";
import { revokeAllUserRefreshTokens, revokeRefreshTokensForSession } from "$lib/server/oidc/refresh";
import { dispatchSecurityAlert } from "$lib/server/security-notify";
import { translate } from "$lib/i18n/server";

Expand Down Expand Up @@ -104,4 +104,36 @@ export const actions: Actions = {

return { revokedOthers: true };
},

// 일괄 로그아웃 — 현재 세션을 포함한 모든 세션 + 모든 OIDC refresh token 폐기.
// 관리자 강제 로그아웃(forceLogout)과 동일한 폐기 조합을 셀프서비스로 제공한다.
revokeAll: async (event) => {
const { locals } = event;
if (!locals.user) throw redirect(303, "/login");

const { db, tenant } = requireDbContext(locals);
const active = await listActiveSessions(db, locals.user.id);

await revokeAllUserSessions(db, locals.user.id);
await revokeAllUserRefreshTokens(db, locals.user.id);

const requestMetadata = getRequestMetadata(event);
await recordAuditEvent(db, {
tenantId: tenant.id,
userId: locals.user.id,
actorId: locals.user.id,
kind: "sessions_revoked",
outcome: "success",
ip: requestMetadata.ip,
userAgent: requestMetadata.userAgent,
detail: { all: true, count: active.length },
});

// 보안 알림(best-effort, waitUntil 격리) — 세션 탈취 방어를 위해 항상 발송한다.
dispatchSecurityAlert({ to: locals.user.email, locale: locals.user.locale, kind: "sessions_revoked_all", platform: event.platform });

// 현재 세션도 폐기되었으므로 쿠키를 지우고 로그인으로 보낸다.
clearSessionCookie(event.cookies, event.url);
throw redirect(303, "/login");
},
};
17 changes: 16 additions & 1 deletion src/routes/account/sessions/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ const otherCount = $derived(data.sessions.filter((s: (typeof data.sessions)[numb
{/if}

{#if otherCount > 0}
<form method="POST" action="?/revokeOthers" use:enhance>
<form method="POST" action="?/revokeOthers" use:enhance class="mb-3">
<button
type="submit"
class="w-full rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
Expand All @@ -109,6 +109,21 @@ const otherCount = $derived(data.sessions.filter((s: (typeof data.sessions)[numb
</button>
</form>
{/if}

{#if data.sessions.length > 0}
<form method="POST" action="?/revokeAll" use:enhance>
<button
type="submit"
class="w-full rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-600 shadow-sm transition hover:bg-red-50"
onclick={(e: MouseEvent) => {
if (!confirm(t("account.sessions.revoke_all_confirm"))) {
e.preventDefault();
}
}}>
{t("account.sessions.revoke_all")}
</button>
</form>
{/if}
</div>
</div>
</div>
7 changes: 2 additions & 5 deletions src/routes/api/webauthn/authenticate/verify/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { eq } from "drizzle-orm";
import type { RequestHandler } from "./$types";
import { requireDbContext } from "$lib/server/auth/guards";
import { recordAuditEvent, getRequestMetadata } from "$lib/server/audit/index";
import { createSessionRecord, revokeOtherSessions, setSessionCookie } from "$lib/server/auth/session";
import { createSessionRecord, setSessionCookie } from "$lib/server/auth/session";
import { sanitizeRedirectTarget } from "$lib/server/auth/redirect";
import { AMR_WEBAUTHN, amrToAcr } from "$lib/server/auth/constants";
import { checkRateLimit } from "$lib/server/ratelimit";
Expand Down Expand Up @@ -99,7 +99,7 @@ export const POST: RequestHandler = async (event) => {
}

const requestMetadata = getRequestMetadata(event);
const { sessionToken, expiresAt, sessionId } = await createSessionRecord(db, {
const { sessionToken, expiresAt } = await createSessionRecord(db, {
tenantId: tenant.id,
userId: user.id,
ip: requestMetadata.ip,
Expand All @@ -108,9 +108,6 @@ export const POST: RequestHandler = async (event) => {
acr: amrToAcr([AMR_WEBAUTHN]),
});

// 기존 세션 회수 — 새 세션만 살아남도록.
await revokeOtherSessions(db, user.id, sessionId);

setSessionCookie(cookies, url, sessionToken, expiresAt);

await recordAuditEvent(db, {
Expand Down
Loading