diff --git a/src/lib/components/molecules/ActionModal.svelte b/src/lib/components/molecules/ActionModal.svelte index a351403..6fe06be 100644 --- a/src/lib/components/molecules/ActionModal.svelte +++ b/src/lib/components/molecules/ActionModal.svelte @@ -12,6 +12,7 @@ confirmText: string; isOpen: boolean; onbeforeclose?: () => void; + oncancel?: () => void; onConfirmClick: ConfirmHandler; title: string; } @@ -22,6 +23,7 @@ confirmText, isOpen = $bindable(), onbeforeclose, + oncancel, onConfirmClick, title, }: Props = $props(); @@ -31,6 +33,11 @@ isOpen = false; }; + const cancelAction = () => { + oncancel?.(); + closeModal(); + }; + const confirmAction = async () => { if ((await onConfirmClick()) !== false) { closeModal(); @@ -38,13 +45,13 @@ }; - +

{title}

{@render children()}
- +
diff --git a/src/lib/server/db/session.ts b/src/lib/server/db/session.ts index a856755..d2baef7 100644 --- a/src/lib/server/db/session.ts +++ b/src/lib/server/db/session.ts @@ -46,17 +46,32 @@ export const refreshSession = async ( return { userId: session.user_id, clientId: session.client_id }; }; -export const upgradeSession = async (sessionId: string, clientId: number) => { +export const upgradeSession = async ( + userId: number, + sessionId: string, + clientId: number, + force: boolean, +) => { try { - const res = await db - .updateTable("session") - .set({ client_id: clientId }) - .where("id", "=", sessionId) - .where("client_id", "is", null) - .executeTakeFirst(); - if (res.numUpdatedRows === 0n) { - throw new IntegrityError("Session not found"); - } + await db.transaction().execute(async (trx) => { + if (force) { + await trx + .deleteFrom("session") + .where("id", "!=", sessionId) + .where("user_id", "=", userId) + .where("client_id", "=", clientId) + .execute(); + } + const res = await trx + .updateTable("session") + .set({ client_id: clientId }) + .where("id", "=", sessionId) + .where("client_id", "is", null) + .executeTakeFirst(); + if (res.numUpdatedRows === 0n) { + throw new IntegrityError("Session not found"); + } + }); } catch (e) { if (e instanceof pg.DatabaseError && e.code === "23505") { throw new IntegrityError("Session already exists"); diff --git a/src/lib/server/schemas/auth.ts b/src/lib/server/schemas/auth.ts index e90b209..b413f9d 100644 --- a/src/lib/server/schemas/auth.ts +++ b/src/lib/server/schemas/auth.ts @@ -4,28 +4,29 @@ export const passwordChangeRequest = z.object({ oldPassword: z.string().trim().nonempty(), newPassword: z.string().trim().nonempty(), }); -export type PasswordChangeRequest = z.infer; +export type PasswordChangeRequest = z.input; export const loginRequest = z.object({ email: z.string().email(), password: z.string().trim().nonempty(), }); -export type LoginRequest = z.infer; +export type LoginRequest = z.input; export const sessionUpgradeRequest = z.object({ encPubKey: z.string().base64().nonempty(), sigPubKey: z.string().base64().nonempty(), }); -export type SessionUpgradeRequest = z.infer; +export type SessionUpgradeRequest = z.input; export const sessionUpgradeResponse = z.object({ id: z.number().int().positive(), challenge: z.string().base64().nonempty(), }); -export type SessionUpgradeResponse = z.infer; +export type SessionUpgradeResponse = z.output; export const sessionUpgradeVerifyRequest = z.object({ id: z.number().int().positive(), answerSig: z.string().base64().nonempty(), + force: z.boolean().default(false), }); -export type SessionUpgradeVerifyRequest = z.infer; +export type SessionUpgradeVerifyRequest = z.input; diff --git a/src/lib/server/schemas/category.ts b/src/lib/server/schemas/category.ts index d02fa4f..55ae413 100644 --- a/src/lib/server/schemas/category.ts +++ b/src/lib/server/schemas/category.ts @@ -15,12 +15,12 @@ export const categoryInfoResponse = z.object({ .optional(), subCategories: z.number().int().positive().array(), }); -export type CategoryInfoResponse = z.infer; +export type CategoryInfoResponse = z.output; export const categoryFileAddRequest = z.object({ file: z.number().int().positive(), }); -export type CategoryFileAddRequest = z.infer; +export type CategoryFileAddRequest = z.input; export const categoryFileListResponse = z.object({ files: z.array( @@ -30,19 +30,19 @@ export const categoryFileListResponse = z.object({ }), ), }); -export type CategoryFileListResponse = z.infer; +export type CategoryFileListResponse = z.output; export const categoryFileRemoveRequest = z.object({ file: z.number().int().positive(), }); -export type CategoryFileRemoveRequest = z.infer; +export type CategoryFileRemoveRequest = z.input; export const categoryRenameRequest = z.object({ dekVersion: z.string().datetime(), name: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(), }); -export type CategoryRenameRequest = z.infer; +export type CategoryRenameRequest = z.input; export const categoryCreateRequest = z.object({ parent: categoryIdSchema, @@ -52,4 +52,4 @@ export const categoryCreateRequest = z.object({ name: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(), }); -export type CategoryCreateRequest = z.infer; +export type CategoryCreateRequest = z.input; diff --git a/src/lib/server/schemas/client.ts b/src/lib/server/schemas/client.ts index df15e39..08a76b7 100644 --- a/src/lib/server/schemas/client.ts +++ b/src/lib/server/schemas/client.ts @@ -8,29 +8,29 @@ export const clientListResponse = z.object({ }), ), }); -export type ClientListResponse = z.infer; +export type ClientListResponse = z.output; export const clientRegisterRequest = z.object({ encPubKey: z.string().base64().nonempty(), sigPubKey: z.string().base64().nonempty(), }); -export type ClientRegisterRequest = z.infer; +export type ClientRegisterRequest = z.input; export const clientRegisterResponse = z.object({ id: z.number().int().positive(), challenge: z.string().base64().nonempty(), }); -export type ClientRegisterResponse = z.infer; +export type ClientRegisterResponse = z.output; export const clientRegisterVerifyRequest = z.object({ id: z.number().int().positive(), answerSig: z.string().base64().nonempty(), }); -export type ClientRegisterVerifyRequest = z.infer; +export type ClientRegisterVerifyRequest = z.input; export const clientStatusResponse = z.object({ id: z.number().int().positive(), state: z.enum(["pending", "active"]), isInitialMekNeeded: z.boolean(), }); -export type ClientStatusResponse = z.infer; +export type ClientStatusResponse = z.output; diff --git a/src/lib/server/schemas/directory.ts b/src/lib/server/schemas/directory.ts index 016cfe8..ffd13bc 100644 --- a/src/lib/server/schemas/directory.ts +++ b/src/lib/server/schemas/directory.ts @@ -16,19 +16,19 @@ export const directoryInfoResponse = z.object({ subDirectories: z.number().int().positive().array(), files: z.number().int().positive().array(), }); -export type DirectoryInfoResponse = z.infer; +export type DirectoryInfoResponse = z.output; export const directoryDeleteResponse = z.object({ deletedFiles: z.number().int().positive().array(), }); -export type DirectoryDeleteResponse = z.infer; +export type DirectoryDeleteResponse = z.output; export const directoryRenameRequest = z.object({ dekVersion: z.string().datetime(), name: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(), }); -export type DirectoryRenameRequest = z.infer; +export type DirectoryRenameRequest = z.input; export const directoryCreateRequest = z.object({ parent: directoryIdSchema, @@ -38,4 +38,4 @@ export const directoryCreateRequest = z.object({ name: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(), }); -export type DirectoryCreateRequest = z.infer; +export type DirectoryCreateRequest = z.input; diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts index d0687b7..07fd943 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -21,42 +21,42 @@ export const fileInfoResponse = z.object({ lastModifiedAtIv: z.string().base64().nonempty(), categories: z.number().int().positive().array(), }); -export type FileInfoResponse = z.infer; +export type FileInfoResponse = z.output; export const fileRenameRequest = z.object({ dekVersion: z.string().datetime(), name: z.string().base64().nonempty(), nameIv: z.string().base64().nonempty(), }); -export type FileRenameRequest = z.infer; +export type FileRenameRequest = z.input; export const fileThumbnailInfoResponse = z.object({ updatedAt: z.string().datetime(), contentIv: z.string().base64().nonempty(), }); -export type FileThumbnailInfoResponse = z.infer; +export type FileThumbnailInfoResponse = z.output; export const fileThumbnailUploadRequest = z.object({ dekVersion: z.string().datetime(), contentIv: z.string().base64().nonempty(), }); -export type FileThumbnailUploadRequest = z.infer; +export type FileThumbnailUploadRequest = z.input; export const duplicateFileScanRequest = z.object({ hskVersion: z.number().int().positive(), contentHmac: z.string().base64().nonempty(), }); -export type DuplicateFileScanRequest = z.infer; +export type DuplicateFileScanRequest = z.input; export const duplicateFileScanResponse = z.object({ files: z.number().int().positive().array(), }); -export type DuplicateFileScanResponse = z.infer; +export type DuplicateFileScanResponse = z.output; export const missingThumbnailFileScanResponse = z.object({ files: z.number().int().positive().array(), }); -export type MissingThumbnailFileScanResponse = z.infer; +export type MissingThumbnailFileScanResponse = z.output; export const fileUploadRequest = z.object({ parent: directoryIdSchema, @@ -78,9 +78,9 @@ export const fileUploadRequest = z.object({ lastModifiedAt: z.string().base64().nonempty(), lastModifiedAtIv: z.string().base64().nonempty(), }); -export type FileUploadRequest = z.infer; +export type FileUploadRequest = z.input; export const fileUploadResponse = z.object({ file: z.number().int().positive(), }); -export type FileUploadResponse = z.infer; +export type FileUploadResponse = z.output; diff --git a/src/lib/server/schemas/hsk.ts b/src/lib/server/schemas/hsk.ts index bcea3cd..6f6b428 100644 --- a/src/lib/server/schemas/hsk.ts +++ b/src/lib/server/schemas/hsk.ts @@ -10,10 +10,10 @@ export const hmacSecretListResponse = z.object({ }), ), }); -export type HmacSecretListResponse = z.infer; +export type HmacSecretListResponse = z.output; export const initialHmacSecretRegisterRequest = z.object({ mekVersion: z.number().int().positive(), hsk: z.string().base64().nonempty(), }); -export type InitialHmacSecretRegisterRequest = z.infer; +export type InitialHmacSecretRegisterRequest = z.input; diff --git a/src/lib/server/schemas/mek.ts b/src/lib/server/schemas/mek.ts index e79f810..3d6f468 100644 --- a/src/lib/server/schemas/mek.ts +++ b/src/lib/server/schemas/mek.ts @@ -10,10 +10,10 @@ export const masterKeyListResponse = z.object({ }), ), }); -export type MasterKeyListResponse = z.infer; +export type MasterKeyListResponse = z.output; export const initialMasterKeyRegisterRequest = z.object({ mek: z.string().base64().nonempty(), mekSig: z.string().base64().nonempty(), }); -export type InitialMasterKeyRegisterRequest = z.infer; +export type InitialMasterKeyRegisterRequest = z.input; diff --git a/src/lib/server/schemas/user.ts b/src/lib/server/schemas/user.ts index e5cc8c1..9aec819 100644 --- a/src/lib/server/schemas/user.ts +++ b/src/lib/server/schemas/user.ts @@ -4,9 +4,9 @@ export const userInfoResponse = z.object({ email: z.string().email(), nickname: z.string().nonempty(), }); -export type UserInfoResponse = z.infer; +export type UserInfoResponse = z.output; export const nicknameChangeRequest = z.object({ newNickname: z.string().trim().min(2).max(8), }); -export type NicknameChangeRequest = z.infer; +export type NicknameChangeRequest = z.input; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts index 96b7675..1c6867f 100644 --- a/src/lib/server/services/auth.ts +++ b/src/lib/server/services/auth.ts @@ -87,9 +87,11 @@ export const createSessionUpgradeChallenge = async ( export const verifySessionUpgradeChallenge = async ( sessionId: string, + userId: number, ip: string, challengeId: number, answerSig: string, + force: boolean, ) => { const challenge = await consumeSessionUpgradeChallenge(challengeId, sessionId, ip); if (!challenge) { @@ -106,13 +108,13 @@ export const verifySessionUpgradeChallenge = async ( } try { - await upgradeSession(sessionId, client.id); + await upgradeSession(userId, sessionId, client.id, force); } catch (e) { if (e instanceof IntegrityError) { if (e.message === "Session not found") { error(500, "Invalid challenge answer"); - } else if (e.message === "Session already exists") { - error(403, "Already logged in"); + } else if (!force && e.message === "Session already exists") { + error(409, "Already logged in"); } } throw e; diff --git a/src/lib/services/auth.ts b/src/lib/services/auth.ts index df49e30..d1975f4 100644 --- a/src/lib/services/auth.ts +++ b/src/lib/services/auth.ts @@ -11,12 +11,14 @@ export const requestSessionUpgrade = async ( decryptKey: CryptoKey, verifyKeyBase64: string, signKey: CryptoKey, + force = false, ) => { let res = await callPostApi("/api/auth/upgradeSession", { encPubKey: encryptKeyBase64, sigPubKey: verifyKeyBase64, }); - if (!res.ok) return false; + if (res.status === 403) return [false, "Unregistered client"] as const; + else if (!res.ok) return [false] as const; const { id, challenge }: SessionUpgradeResponse = await res.json(); const answer = await decryptChallenge(challenge, decryptKey); @@ -25,6 +27,13 @@ export const requestSessionUpgrade = async ( res = await callPostApi("/api/auth/upgradeSession/verify", { id, answerSig: encodeToBase64(answerSig), + force, }); + if (res.status === 409) return [false, "Already logged in"] as const; + else return [res.ok] as const; +}; + +export const requestLogout = async () => { + const res = await callPostApi("/api/auth/logout"); return res.ok; }; diff --git a/src/routes/(fullscreen)/auth/login/+page.svelte b/src/routes/(fullscreen)/auth/login/+page.svelte index ac3b1a5..00ee71e 100644 --- a/src/routes/(fullscreen)/auth/login/+page.svelte +++ b/src/routes/(fullscreen)/auth/login/+page.svelte @@ -3,10 +3,18 @@ import { BottomDiv, Button, FullscreenDiv, TextButton, TextInput } from "$lib/components/atoms"; import { TitledDiv } from "$lib/components/molecules"; import { clientKeyStore, masterKeyStore } from "$lib/stores"; - import { requestLogin, requestSessionUpgrade, requestMasterKeyDownload } from "./service"; + import ForceLoginModal from "./ForceLoginModal.svelte"; + import { + requestLogout, + requestLogin, + requestSessionUpgrade, + requestMasterKeyDownload, + } from "./service"; let { data } = $props(); + let isForceLoginModalOpen = $state(false); + let email = $state(""); let password = $state(""); @@ -14,6 +22,32 @@ return await goto(`${url}?redirect=${encodeURIComponent(data.redirectPath)}`); }; + const upgradeSession = async (force: boolean) => { + try { + const [upgradeRes, upgradeError] = await requestSessionUpgrade($clientKeyStore!, force); + if (!force && upgradeError === "Already logged in") { + isForceLoginModalOpen = true; + return; + } else if (!upgradeRes) { + throw new Error("Failed to upgrade session"); + } + + // TODO: Multi-user support + + if ( + $masterKeyStore || + (await requestMasterKeyDownload($clientKeyStore!.decryptKey, $clientKeyStore!.verifyKey)) + ) { + await goto(data.redirectPath); + } else { + await redirect("/client/pending"); + } + } catch (e) { + // TODO + throw e; + } + }; + const login = async () => { // TODO: Validation @@ -22,19 +56,7 @@ if (!$clientKeyStore) return await redirect("/key/generate"); - if (!(await requestSessionUpgrade($clientKeyStore))) - throw new Error("Failed to upgrade session"); - - // TODO: Multi-user support - - if ( - $masterKeyStore || - (await requestMasterKeyDownload($clientKeyStore.decryptKey, $clientKeyStore.verifyKey)) - ) { - await goto(data.redirectPath); - } else { - await redirect("/client/pending"); - } + await upgradeSession(false); } catch (e) { // TODO: Alert throw e; @@ -63,3 +85,9 @@ 계정이 없어요 + + upgradeSession(true)} +/> diff --git a/src/routes/(fullscreen)/auth/login/ForceLoginModal.svelte b/src/routes/(fullscreen)/auth/login/ForceLoginModal.svelte new file mode 100644 index 0000000..fc88a4a --- /dev/null +++ b/src/routes/(fullscreen)/auth/login/ForceLoginModal.svelte @@ -0,0 +1,22 @@ + + + +

다른 디바이스에서는 로그아웃하고, 이 디바이스에서 로그인할까요?

+
diff --git a/src/routes/(fullscreen)/auth/login/service.ts b/src/routes/(fullscreen)/auth/login/service.ts index 2d267e1..43e7c0d 100644 --- a/src/routes/(fullscreen)/auth/login/service.ts +++ b/src/routes/(fullscreen)/auth/login/service.ts @@ -5,6 +5,7 @@ import { requestSessionUpgrade as requestSessionUpgradeInternal } from "$lib/ser import { requestClientRegistration } from "$lib/services/key"; import type { ClientKeys } from "$lib/stores"; +export { requestLogout } from "$lib/services/auth"; export { requestMasterKeyDownload } from "$lib/services/key"; export const requestLogin = async (email: string, password: string) => { @@ -12,26 +13,33 @@ export const requestLogin = async (email: string, password: string) => { return res.ok; }; -export const requestSessionUpgrade = async ({ - encryptKey, - decryptKey, - signKey, - verifyKey, -}: ClientKeys) => { +export const requestSessionUpgrade = async ( + { encryptKey, decryptKey, signKey, verifyKey }: ClientKeys, + force: boolean, +) => { const encryptKeyBase64 = await exportRSAKeyToBase64(encryptKey); const verifyKeyBase64 = await exportRSAKeyToBase64(verifyKey); - if (await requestSessionUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) { - return true; + const [res, error] = await requestSessionUpgradeInternal( + encryptKeyBase64, + decryptKey, + verifyKeyBase64, + signKey, + force, + ); + if (error === undefined) return [res] as const; + + if ( + error === "Unregistered client" && + !(await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) + ) { + return [false] as const; + } else if (error === "Already logged in") { + return [false, force ? undefined : error] as const; } - if (await requestClientRegistration(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey)) { - return await requestSessionUpgradeInternal( - encryptKeyBase64, - decryptKey, - verifyKeyBase64, - signKey, - ); - } else { - return false; - } + return [ + ( + await requestSessionUpgradeInternal(encryptKeyBase64, decryptKey, verifyKeyBase64, signKey) + )[0], + ] as const; }; diff --git a/src/routes/(fullscreen)/key/export/+page.svelte b/src/routes/(fullscreen)/key/export/+page.svelte index 3a4ae07..ac83504 100644 --- a/src/routes/(fullscreen)/key/export/+page.svelte +++ b/src/routes/(fullscreen)/key/export/+page.svelte @@ -59,12 +59,14 @@ await storeClientKeys($clientKeyStore); if ( - !(await requestSessionUpgrade( - data.encryptKeyBase64, - $clientKeyStore.decryptKey, - data.verifyKeyBase64, - $clientKeyStore.signKey, - )) + !( + await requestSessionUpgrade( + data.encryptKeyBase64, + $clientKeyStore.decryptKey, + data.verifyKeyBase64, + $clientKeyStore.signKey, + ) + )[0] ) throw new Error("Failed to upgrade session"); diff --git a/src/routes/(main)/menu/service.ts b/src/routes/(main)/menu/service.ts index 32dd4fd..ca63aa2 100644 --- a/src/routes/(main)/menu/service.ts +++ b/src/routes/(main)/menu/service.ts @@ -1,6 +1 @@ -import { callPostApi } from "$lib/hooks"; - -export const requestLogout = async () => { - const res = await callPostApi("/api/auth/logout"); - return res.ok; -}; +export { requestLogout } from "$lib/services/auth"; diff --git a/src/routes/api/auth/upgradeSession/verify/+server.ts b/src/routes/api/auth/upgradeSession/verify/+server.ts index 2fe4e36..bbaedca 100644 --- a/src/routes/api/auth/upgradeSession/verify/+server.ts +++ b/src/routes/api/auth/upgradeSession/verify/+server.ts @@ -5,12 +5,12 @@ import { verifySessionUpgradeChallenge } from "$lib/server/services/auth"; import type { RequestHandler } from "./$types"; export const POST: RequestHandler = async ({ locals, request }) => { - const { sessionId } = await authorize(locals, "notClient"); + const { sessionId, userId } = await authorize(locals, "notClient"); const zodRes = sessionUpgradeVerifyRequest.safeParse(await request.json()); if (!zodRes.success) error(400, "Invalid request body"); - const { id, answerSig } = zodRes.data; + const { id, answerSig, force } = zodRes.data; - await verifySessionUpgradeChallenge(sessionId, locals.ip, id, answerSig); + await verifySessionUpgradeChallenge(sessionId, userId, locals.ip, id, answerSig, force); return text("Session upgraded", { headers: { "Content-Type": "text/plain" } }); };