diff --git a/bun.lock b/bun.lock index 3666004..2c54530 100644 --- a/bun.lock +++ b/bun.lock @@ -84,12 +84,14 @@ "name": "elysia-api", "version": "0.7.0", "dependencies": { + "@alikia/random-key": "^1.1.1", "@elysiajs/cors": "^1.4.0", "@elysiajs/openapi": "^1.4.0", "@elysiajs/server-timing": "^1.4.0", "@rabbit-company/argon2id": "^2.1.0", "chalk": "^5.6.2", "elysia": "^1.4.0", + "elysia-ip": "^1.0.10", "zod": "^4.1.11", }, "devDependencies": { @@ -266,6 +268,8 @@ "@alikia/dark-theme-hook": ["@alikia/dark-theme-hook@1.0.2", "", { "dependencies": { "react": "^18.3.1" } }, "sha512-OloatZRefHB7Ey3zjhfsKFZoHbfzewPfOeEwA7q9zDXViNKGyTA4CiCF3US5vzqfhpR16wpYcPSmpVabKI3MYg=="], + "@alikia/random-key": ["@alikia/random-key@1.1.1", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-B1u15FRJoLBfx+n32YvnvMzxjK5QARJAzqkNbyI+Ve16jxL+SX8Q+hU4J/uEhCYxqdzs/eBKCTf6L5ThGFjxWw=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], @@ -1516,6 +1520,8 @@ "elysia-api": ["elysia-api@workspace:packages/elysia"], + "elysia-ip": ["elysia-ip@1.0.10", "", { "peerDependencies": { "elysia": ">= 1.0.9" } }, "sha512-xmCxPOl4266sq6CLk5d82P3BZOatG9z0gMP473cYEnORssuopbEI8GAwpOhiaz69X76AOrkYgvCdLkqMJC49dQ=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], diff --git a/packages/elysia/lib/auth.ts b/packages/elysia/lib/auth.ts index a1dc9f3..d4d614d 100644 --- a/packages/elysia/lib/auth.ts +++ b/packages/elysia/lib/auth.ts @@ -1 +1,132 @@ -import Argon2id from "@rabbit-company/argon2id"; \ No newline at end of file +import Argon2id from "@rabbit-company/argon2id"; +import { dbMain } from "@core/drizzle"; +import { usersInCredentials, loginSessionsInCredentials } from "@core/drizzle/main/schema"; +import { eq, and, isNull } from "drizzle-orm"; +import { generate as generateId } from "@alikia/random-key"; +import logger from "@core/log"; + +export interface User { + id: number; + username: string; + nickname: string | null; + role: string; +} + +export async function verifyUser(username: string, password: string): Promise { + const user = await dbMain + .select() + .from(usersInCredentials) + .where(eq(usersInCredentials.username, username)) + .limit(1); + + if (user.length === 0) { + return null; + } + + const foundUser = user[0]; + const isPasswordValid = await Argon2id.verify(foundUser.password, password); + if (!isPasswordValid) { + return null; + } + + return { + id: foundUser.id, + username: foundUser.username, + nickname: foundUser.nickname, + role: foundUser.role + }; +} + +export async function createSession( + userId: number, + ipAddress: string | null, + userAgent: string, + expiresInDays: number = 30 +): Promise { + const sessionId = await generateId(24); + const expireAt = new Date(); + expireAt.setDate(expireAt.getDate() + expiresInDays); + + try { + await dbMain.insert(loginSessionsInCredentials).values({ + id: sessionId, + uid: userId, + ipAddress, + userAgent, + lastUsedAt: new Date().toISOString(), + expireAt: expireAt.toISOString() + }); + } catch (error) { + logger.error(error as Error); + throw error; + } + + return sessionId; +} + +export async function validateSession( + sessionId: string +): Promise<{ user: User; session: any } | null> { + const session = await dbMain + .select() + .from(loginSessionsInCredentials) + .where( + and( + eq(loginSessionsInCredentials.id, sessionId), + isNull(loginSessionsInCredentials.deactivatedAt) + ) + ) + .limit(1); + + if (session.length === 0) { + return null; + } + + const foundSession = session[0]; + + if (foundSession.expireAt && new Date(foundSession.expireAt) < new Date()) { + return null; + } + + const user = await dbMain + .select() + .from(usersInCredentials) + .where(eq(usersInCredentials.id, foundSession.uid)) + .limit(1); + + if (user.length === 0) { + return null; + } + + await dbMain + .update(loginSessionsInCredentials) + .set({ lastUsedAt: new Date().toISOString() }) + .where(eq(loginSessionsInCredentials.id, sessionId)); + + return { + user: { + id: user[0].id, + username: user[0].username, + nickname: user[0].nickname, + role: user[0].role + }, + session: foundSession + }; +} + +export async function deactivateSession(sessionId: string): Promise { + const result = await dbMain + .update(loginSessionsInCredentials) + .set({ + deactivatedAt: new Date().toISOString() + }) + .where(eq(loginSessionsInCredentials.id, sessionId)); + + return result.length ? result.length > 0 : false; +} + +export function getSessionExpirationDate(days: number = 30): Date { + const expireAt = new Date(); + expireAt.setDate(expireAt.getDate() + days); + return expireAt; +} diff --git a/packages/elysia/package.json b/packages/elysia/package.json index eb89517..eaaee1b 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -7,12 +7,14 @@ "format": "prettier --write ." }, "dependencies": { + "@alikia/random-key": "^1.1.1", "@elysiajs/cors": "^1.4.0", "@elysiajs/openapi": "^1.4.0", "@elysiajs/server-timing": "^1.4.0", "@rabbit-company/argon2id": "^2.1.0", "chalk": "^5.6.2", "elysia": "^1.4.0", + "elysia-ip": "^1.0.10", "zod": "^4.1.11" }, "devDependencies": { diff --git a/packages/elysia/routes/auth/session.ts b/packages/elysia/routes/auth/session.ts new file mode 100644 index 0000000..6fdd50c --- /dev/null +++ b/packages/elysia/routes/auth/session.ts @@ -0,0 +1,104 @@ +import { Elysia, t } from "elysia"; +import { ip } from "elysia-ip"; +import { verifyUser, createSession, deactivateSession, getSessionExpirationDate } from "@elysia/lib/auth"; + +export const authHandler = new Elysia({ prefix: "/auth" }) + .use(ip()) + .post( + "/session", + async ({ body, set, cookie, ip, request }) => { + const { username, password } = body; + + const user = await verifyUser(username, password); + if (!user) { + set.status = 401; + return { message: "Invalid credentials." }; + } + + const userAgent = request.headers.get("user-agent") || "Unknown"; + const sessionId = await createSession(user.id, ip || null, userAgent); + + const expiresAt = getSessionExpirationDate(); + cookie.sessionId.value = sessionId; + cookie.sessionId.httpOnly = true; + cookie.sessionId.secure = process.env.NODE_ENV === 'production'; + cookie.sessionId.sameSite = 'strict'; + cookie.sessionId.expires = expiresAt; + + return { + message: "You are logged in.", + user: { + id: user.id, + username: user.username, + nickname: user.nickname, + role: user.role + } + }; + }, + { + response: { + 200: t.Object({ + message: t.String(), + user: t.Object({ + id: t.Integer(), + username: t.String(), + nickname: t.Optional(t.String()), + role: t.String() + }) + }), + 401: t.Object({ + message: t.String() + }) + }, + body: t.Object({ + username: t.String(), + password: t.String() + }) + } + ) + .delete( + "/session", + async ({ set, cookie }) => { + const sessionId = cookie.sessionId?.value; + + if (!sessionId) { + set.status = 401; + return { message: "Not authenticated." }; + } + + await deactivateSession(sessionId as string); + cookie.sessionId.remove(); + + return { message: "Successfully logged out." }; + }, + { + response: { + 200: t.Object({ + message: t.String() + }), + 401: t.Object({ + message: t.String() + }) + } + } + ) + .delete( + "/session/:id", + async ({ params }) => { + const sessionId = params.id; + + await deactivateSession(sessionId as string); + + return { message: "Successfully logged out." }; + }, + { + response: { + 200: t.Object({ + message: t.String() + }), + 401: t.Object({ + message: t.String() + }) + } + } + ); \ No newline at end of file diff --git a/packages/elysia/routes/song/info.ts b/packages/elysia/routes/song/info.ts index 2eb42a8..e812a0f 100644 --- a/packages/elysia/routes/song/info.ts +++ b/packages/elysia/routes/song/info.ts @@ -66,13 +66,13 @@ export const songInfoHandler = new Elysia({ prefix: "/song" }) const songID = await getSongID(id); if (!songID) { return status(404, { - message: "song not found" + message: "Given song cannot be found." }); } const info = await getSongInfo(songID); if (!info) { return status(404, { - message: "song not found" + message: "Given song cannot be found." }); } const singers = await getSingers(info.id); @@ -117,13 +117,13 @@ export const songInfoHandler = new Elysia({ prefix: "/song" }) const songID = await getSongID(id); if (!songID) { return status(404, { - message: "song not found" + message: "Given song cannot be found." }); } const info = await getSongInfo(songID); if (!info) { return status(404, { - message: "song not found" + message: "Given song cannot be found." }); } if (body.name) { @@ -133,16 +133,23 @@ export const songInfoHandler = new Elysia({ prefix: "/song" }) await dbMain .update(songs) .set({ producer: body.producer }) - .where(eq(songs.id, songID)); + .where(eq(songs.id, songID)) + .returning(); } + const updatedData = await dbMain + .select() + .from(songs) + .where(eq(songs.id, songID)); return { - message: "success" + message: "Successfully updated song info.", + updated: updatedData.length > 0 ? updatedData[0] : null }; }, { response: { 200: t.Object({ - message: t.String() + message: t.String(), + updated: t.Any() }), 404: t.Object({ message: t.String() diff --git a/packages/elysia/routes/user/login.ts b/packages/elysia/routes/user/login.ts deleted file mode 100644 index 85f8add..0000000 --- a/packages/elysia/routes/user/login.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { dbCred } from "@core/drizzle"; -import { users } from "@core/drizzle/cred/schema"; -import { Elysia, t } from "elysia"; - -export const loginHandler = new Elysia({ prefix: "/login" }).post( - "/session", - async ({ params, status, body }) => { - const { username, password } = body; - - return {}; - }, - { - response: { - 200: t.Object({}), - 404: t.Object({ - message: t.String() - }) - }, - body: t.Object({ - username: t.String(), - password: t.String() - }) - } -); diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 536f293..63004bf 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -7,6 +7,7 @@ import { songInfoHandler } from "@elysia/routes/song/info"; import { rootHandler } from "@elysia/routes/root"; import { getVideoMetadataHandler } from "@elysia/routes/video/metadata"; import { closeMileStoneHandler } from "@elysia/routes/song/milestone"; +import { authHandler } from "@elysia/routes/auth/session"; const [host, port] = getBindingInfo(); logStartup(host, port); @@ -63,6 +64,7 @@ const app = new Elysia({ .use(openapi()) .use(rootHandler) .use(pingHandler) + .use(authHandler) .use(getVideoMetadataHandler) .use(songInfoHandler) .use(closeMileStoneHandler)