From 4e7c2a1df2141c4fda6c91eb8d7a8e04360d857e Mon Sep 17 00:00:00 2001 From: TheClashFruit Date: Sun, 1 Sep 2024 16:45:58 +0200 Subject: [PATCH] feat: authentication --- lib/Database.ts | 180 +++++++++++++++++++++++++++++++++++++++++-- pages/api/v1/auth.ts | 52 +++++++++++++ utils/token_util.ts | 8 +- 3 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 pages/api/v1/auth.ts diff --git a/lib/Database.ts b/lib/Database.ts index cdb18c3..6a31f45 100644 --- a/lib/Database.ts +++ b/lib/Database.ts @@ -2,6 +2,9 @@ import { TeamMember, User } from '@/interfaces'; import { getBadges } from '@/utils/badges'; import { getPermissions } from '@/utils/permissions'; import mysql, { Pool, QueryResult } from 'mysql2/promise'; +import Discord from './Discord'; +import { createSessionToken, DiscordTokenData } from '@/utils/token_util'; +import snowflake from '@/utils/snowflake'; class Database { static instance: (Database | null) = null; @@ -32,11 +35,11 @@ class Database { * * @returns User */ - async getUser(id: BigInt): Promise { + async getUser(id: string): Promise { const [ rows ] = await this.mysqlPool!.query('SELECT * FROM users WHERE id = ?', [ id ]); if((rows as any[]).length === 0) - throw new Error('User Not Found'); + return undefined; const row = (rows as any[])[0]; @@ -63,18 +66,179 @@ class Database { } as User; } - // Meta ----------- + /** + * Get a user from their username. + * + * @param username User's username + * + * @returns User + */ + async getUserUsername(username: string): Promise { + const [ rows ] = await this.mysqlPool!.query('SELECT * FROM users WHERE username = ?', [ username ]); - async getTeam(): Promise { + if((rows as any[]).length === 0) + return undefined; + + const row = (rows as any[])[0]; + + return { + id: row.id as BigInt, + + username: row.username, + displayName: row.display_name, + + email: row.email, + + avatar: row.avatar, + banner: row.banner, + + accentColor: row.accent_color, + + discordId: row.discord_id as BigInt, + + permissions: getPermissions(row.permissions), + badges: getBadges(row.badges), + + createdAt: row.created_at, + updatedAt: row.updated_at + } as User; + } + + private async getUserDiscord(discordId: BigInt): Promise { + const [ rows ] = await this.mysqlPool!.query('SELECT * FROM users WHERE discord_id = ?', [ discordId ]); + + if((rows as any[]).length === 0) + return undefined; + + const row = (rows as any[])[0]; + + return { + id: row.id as BigInt, + + username: row.username, + displayName: row.display_name, + + email: row.email, + + avatar: row.avatar, + banner: row.banner, + + accentColor: row.accent_color, + + discordId: row.discord_id as BigInt, + + permissions: getPermissions(row.permissions), + badges: getBadges(row.badges), + + createdAt: row.created_at, + updatedAt: row.updated_at + } as User; + } + + async newUser(user: any): Promise { + const userExists = await this.getUserDiscord(user.id); + + if(userExists) + return userExists; + + const id = snowflake.getUniqueID(); + + await this.mysqlPool!.query('INSERT INTO users (id, username, display_name, email, avatar, banner, accent_color, discord_id, permissions, badges) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ + id.toString(), + + user.username, + user.global_name, + user.email, + user.avatar, + user.banner, + user.accent_color, + user.id + ]); + + return { + id: id, + + username: user.username, + displayName: user.display_name, + + email: user.email, + + avatar: user.avatar, + banner: user.banner, + + accentColor: user.accent_color, + + discordId: user.discord as BigInt, + + permissions: getPermissions(user.permissions), + badges: getBadges(user.badges), + + createdAt: new Date(), + updatedAt: new Date() + } as User; + } + + // Session ------------- + + /** + * Create a new session. + * + * @param oauthData The data returned from the `/oauth2/token` endpoint. + * @param userAgent Request's User-Agent + * @param ip Request's IP + * + * @returns + */ + async newSession(oauthData: DiscordTokenData, userAgent: string, ip: string): Promise { + const discord = new Discord(oauthData.access_token); + + const dcUser = await discord.user(); + const token = await createSessionToken(oauthData); + + if(!dcUser.ok || !token) + return undefined; + + const user = await this.newUser(dcUser.data); + + if(!user) + return undefined; + + try { + await this.mysqlPool!.query('INSERT INTO user_sessions (token, user_id, dc_access_token, dc_refresh_token, dc_id_token, user_agent, ip, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ + token, + user.id, + oauthData.access_token, + oauthData.refresh_token, + oauthData.id_token, + userAgent, + ip, + new Date(Date.now() + (oauthData.expires_in * 1000)) + ]); + } catch (e) { + console.error(e); + + return undefined; + } + + return token; + } + + // Meta ---------------- + + async getTeam(): Promise<(TeamMember | undefined)[]> { const [ rows ] = await this.mysqlPool!.query('SELECT * FROM team'); const usersPromises = (rows as any[]).map(row => this.getUser(row.user_id)); const users = await Promise.all(usersPromises); - return users.map((user, i) => ({ - role: (rows as any[])[i].role, - ...user, - } as TeamMember)); + return users.map((user, i) => { + if (!user) return undefined; + + return { + ...user, + role: (rows as any[])[i].role + } as TeamMember; + }); } } diff --git a/pages/api/v1/auth.ts b/pages/api/v1/auth.ts new file mode 100644 index 0000000..1de3897 --- /dev/null +++ b/pages/api/v1/auth.ts @@ -0,0 +1,52 @@ +import Database from '@/lib/Database'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const db = new Database(); + + const { code, state } = req.query; + + if (typeof code !== 'string' || typeof state !== 'string') { + return res.status(400).json({ + code: 400, + message: 'Invalid Request' + }); + } + + try { + const data = await fetch(`${process.env.DISCORD_API}/oauth2/token`, { + method: 'POST', + body: new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT!, + client_secret: process.env.DISCORD_SECRET!, + grant_type: 'authorization_code', + code, + redirect_uri: process.env.DISCORD_REDIRECT!, + }), + }); + + const json = await data.json(); + const token = await db.newSession(json, req.headers['user-agent']!, req.socket.remoteAddress!); + + if (!token) { + return res.status(500).json({ + code: 500, + message: 'Internal Server Error' + }); + } + + res.status(200).json({ + token + }); + } catch (e) { + console.error(e); + + return res.status(500).json({ + code: 500, + message: 'Internal Server Error' + }); + } +} \ No newline at end of file diff --git a/utils/token_util.ts b/utils/token_util.ts index 6b73fc5..9179df5 100644 --- a/utils/token_util.ts +++ b/utils/token_util.ts @@ -4,7 +4,7 @@ import snowflake from './snowflake'; import Discord from '@/lib/Discord'; -interface DiscordTokenData { +export interface DiscordTokenData { token_type: string; access_token: string; expires_in: number; @@ -20,18 +20,18 @@ interface DiscordTokenData { * * @returns The session token. */ -export async function createSessionToken(oauthData: DiscordTokenData): Promise { +export async function createSessionToken(oauthData: DiscordTokenData): Promise { const discord = new Discord(oauthData.access_token); const res = await discord.user(); if (!res.ok) - throw new Error('Failed to fetch user data from Discord.'); + return undefined; const dcUser = res.data; const hmac = crypto .createHmac('sha256', process.env.AUTH_SECRET!) - .update(dcUser.access_token) + .update(oauthData.access_token) .digest('hex'); const tuid = Buffer