From 0b76f182a02709036f6a8723da29ac6ef98e1ab9 Mon Sep 17 00:00:00 2001 From: BlazingAsher Date: Thu, 27 Jul 2023 22:04:55 -0400 Subject: [PATCH] Add Discord linking via OAuth2 (#116) * add support for Discord OAuth for linked roles * add support for discord linked roles * fix discord sync overriding data and move most discord management to organizer * set user initial discord sync state as softfail * protect discord bot verify queue --- .env.template | 6 +- src/controller/DiscordController.ts | 28 ++- src/controller/UserController.ts | 190 +++++++++++++++- src/models/externaluser/fields.ts | 2 +- .../queuedverification/QueuedVerification.ts | 19 ++ src/models/queuedverification/fields.ts | 47 ++++ src/models/shared/discordShared.ts | 36 ++- src/routes/action.ts | 86 ++++++- src/services/discordApi.ts | 211 ++++++++++++++++++ src/services/hmacSigner.ts | 6 + src/services/logger.ts | 6 +- src/services/permissions.ts | 9 +- .../discord-controller/verify-user.test.ts | 2 - src/types/types.ts | 8 +- src/util/cleanUserObject.ts | 9 + 15 files changed, 638 insertions(+), 27 deletions(-) create mode 100644 src/models/queuedverification/QueuedVerification.ts create mode 100644 src/models/queuedverification/fields.ts create mode 100644 src/services/discordApi.ts create mode 100644 src/services/hmacSigner.ts create mode 100644 src/util/cleanUserObject.ts diff --git a/.env.template b/.env.template index f63dc62..b72fbc6 100644 --- a/.env.template +++ b/.env.template @@ -23,4 +23,8 @@ ENABLE_TRACING=true # Application deadline overrides. # Specific emails can be directly listed. Domains must start with an @ symbol. # Any override emails bypass application/rsvp open and close times. Separate with a comma. -EMAILS_CAN_ALWAYS_APPLY=cool_hacker@gmail.com,@hackthe6ix.com \ No newline at end of file +EMAILS_CAN_ALWAYS_APPLY=cool_hacker@gmail.com,@hackthe6ix.com + +# Discord Connection Configuration +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= \ No newline at end of file diff --git a/src/controller/DiscordController.ts b/src/controller/DiscordController.ts index 60361ec..bae3328 100644 --- a/src/controller/DiscordController.ts +++ b/src/controller/DiscordController.ts @@ -3,6 +3,8 @@ import { IRoles, IUser } from '../models/user/fields'; import User from '../models/user/User'; import { BadRequestError, NotFoundError } from '../types/errors'; import { BasicUser, DiscordVerifyInfo } from '../types/types'; +import QueuedVerification from "../models/queuedverification/QueuedVerification"; +import {IQueuedVerification} from "../models/queuedverification/fields"; const _assembleReturnInfo = (userInfo: BasicUser): DiscordVerifyInfo => { const returnInfo = { @@ -66,8 +68,7 @@ export const verifyDiscordUser = async (email: string, discordID: string, discor }, { 'discord.discordID': discordID, 'discord.username': discordUsername, - 'discord.verifyTime': timeOverride || Date.now(), - 'status.checkedIn': true, + 'discord.verifyTime': timeOverride || Date.now() }); if (userInfo) { @@ -87,4 +88,25 @@ export const verifyDiscordUser = async (email: string, discordID: string, discor return _assembleReturnInfo(userInfo); } -}; \ No newline at end of file +}; + +export const queueVerification = async (discordID: string, userData: BasicUser, revert=false): Promise => { + await QueuedVerification.create({ + queuedTime: Date.now(), + discordID, + revert: false, + verifyData: _assembleReturnInfo(userData) + }); +} + +export const getNextQueuedVerification = async ():Promise => { + return QueuedVerification.findOneAndUpdate({ + processed: false + }, { + processed: true + }, { + sort: { + queuedTime: 1 + } + }); +} \ No newline at end of file diff --git a/src/controller/UserController.ts b/src/controller/UserController.ts index 2f0b22a..874c2a9 100644 --- a/src/controller/UserController.ts +++ b/src/controller/UserController.ts @@ -1,4 +1,4 @@ -import { Mongoose } from 'mongoose'; +import {Mongoose} from 'mongoose'; import * as qrcode from 'qrcode'; import { enumOptions } from '../models/user/enums'; import {fields, IPartialApplication, IUser} from '../models/user/fields'; @@ -18,7 +18,7 @@ import { } from '../types/errors'; import { MailTemplate } from '../types/mailer'; import { - AllUserTypes, BasicUser, + AllUserTypes, BasicUser, DiscordSyncState, IRSVP, QRCodeGenerateBulkResponse, QRCodeGenerateRequest @@ -29,6 +29,16 @@ import { testCanUpdateApplication, validateSubmission } from './util/checker'; import { fetchUniverseState, getModels } from './util/resources'; import {log} from "../services/logger"; import ExternalUser from "../models/externaluser/ExternalUser"; +import { + DiscordConnectionMetadata, + getAccessToken, + getDiscordTokensFromUser, getMetadata, + getOAuthTokens, + getUserData, + pushMetadata +} from "../services/discordApi"; +import {JsonWebTokenError, TokenExpiredError} from "jsonwebtoken"; +import {queueVerification} from "./DiscordController"; export const createFederatedUser = async (linkID: string, email: string, firstName: string, lastName: string, groupsList: string[], groupsHaveIDPPrefix = true): Promise => { @@ -559,3 +569,179 @@ export const checkIn = async (userID: string, userType: AllUserTypes, checkInTim throw new BadRequestError("Given user type is invalid.") } + +/** + * Updates the user's Discord linked roles + */ +export const syncRoles = async (userID: string): Promise => { + const user = await User.findOne({ + _id: userID + }); + + if(!user) { + throw new NotFoundError("Unable to find user with the given ID."); + } + + let discordTokens = getDiscordTokensFromUser(user); + + discordTokens = await getAccessToken(userID, discordTokens); + + const userMetadata = { + isorganizer: user.roles.organizer ? 1 : 0, + isconfirmedhacker: user.roles.hacker && user.status.confirmed ? 1 : 0 + } as DiscordConnectionMetadata; + + await pushMetadata(discordTokens, userMetadata); + + await User.updateOne({ + _id: userID + }, { + 'discord.lastSyncStatus': "SUCCESS" as DiscordSyncState, + 'discord.lastSyncTime': Date.now() + }); + + return "OK"; +} + +/** + * Fetches and stores a user's Discord access and refresh token given a code + * + * @param userID + * @param stateString + * @param code + */ + +export const associateWithDiscord = async (userID: string, stateString: string, code: string): Promise => { + const userInfo = await User.findOne({ + _id: userID + }, 'discord.discordID discord.accessToken discord.accessTokenExpireTime discord.refreshToken'); + + if(!userInfo) { + throw new NotFoundError("Unable to find user with the given ID."); + } + + let tokens = undefined; + + try { + tokens = await getOAuthTokens(stateString, code); + } + catch(e) { + if(e instanceof TokenExpiredError) { + throw new BadRequestError("The authorization state is expired."); + } + else if(e instanceof JsonWebTokenError) { + throw new BadRequestError("Unable to verify the state token."); + } + throw new InternalServerError("Unable to fetch Discord OAuth tokens."); + } + + const userDiscordData = await getUserData(tokens); + + if(userInfo.discord?.discordID !== undefined && userDiscordData.user.id !== userInfo.discord?.discordID) { + throw new BadRequestError("The given user is already linked to a Discord account."); + } + + const otherUser = await User.findOne({ + 'discord.discordID': userDiscordData.user.id + }, ['_id']); + + if(otherUser && !userInfo._id.equals(otherUser?._id)) { + throw new BadRequestError("The given Discord user is already linked to a user."); + } + + const nowTimestamp = Date.now(); + + const newUser = await User.findOneAndUpdate({ + _id: userID + }, { + discord: { + discordID: userDiscordData.user.id, + username: userDiscordData.user.username + (userDiscordData.user.discriminator === "0" ? "" : userDiscordData.user.discriminator), + accessToken: tokens.access_token, + accessTokenExpireTime: tokens.expires_at, + refreshToken: tokens.refresh_token, + lastSyncStatus: "SOFTFAIL" as DiscordSyncState, + lastSyncTime: nowTimestamp, + ...(userInfo.discord?.refreshToken !== undefined ? {} : { + verifyTime: nowTimestamp, + }) + } + }, { + new: true + }); + + if(!newUser) { + throw new InternalServerError("Unable to update user that was associated with a Discord account."); + } + + try { + await syncRoles(userID); + } + catch(e) { + log.error(`Unable to complete initial role sync for ${userID}.`, e); + } + await queueVerification(userDiscordData.user.id, newUser); + + return "OK"; +} + +export const disassociateFromDiscord = async (userID: string):Promise => { + const user = await User.findOne({ + _id: userID + }); + + if(!user) { + throw new NotFoundError("Unable to find the given user."); + } + + try { + let discordTokens = getDiscordTokensFromUser(user); + discordTokens = await getAccessToken(userID, discordTokens); + + const userMetadata = { + isorganizer: 0, + isconfirmedhacker: 0 + } as DiscordConnectionMetadata; + + await pushMetadata(discordTokens, userMetadata); + } + catch(e) { + log.error("Encountered error pushing metadata on Discord disassociation.", e); + } + + if(user.discord.discordID) { + await queueVerification(user.discord.discordID, user, true); + } + + await User.updateOne({ + _id: userID + }, { + discord: {} + }); + + return "Disassociated user from the linked Discord account."; +} + +export const fetchDiscordConnectionMetadata = async(userID?: string):Promise => { + if(!userID) { + throw new BadRequestError("UserID must be specified.") + } + + const user = await User.findOne({ + _id: userID + }, ['discord.accessToken', 'discord.accessTokenExpireTime', 'discord.refreshToken']); + + if(!user) { + throw new NotFoundError("Unable to find user with the given ID."); + } + + if(!user.discord?.accessToken || user.discord?.accessTokenExpireTime === undefined || !user.discord?.refreshToken) { + throw new BadRequestError("The given user is not linked to a Discord account via OAuth."); + } + + let discordTokens = getDiscordTokensFromUser(user); + + discordTokens = await getAccessToken(userID, discordTokens); + + return getMetadata(discordTokens); +} \ No newline at end of file diff --git a/src/models/externaluser/fields.ts b/src/models/externaluser/fields.ts index 7dd5f65..633a262 100644 --- a/src/models/externaluser/fields.ts +++ b/src/models/externaluser/fields.ts @@ -65,7 +65,7 @@ export const fields = { }, checkInNotes: { type: [String], - default: ["MUST_SUBMIT_COVID19_VACCINE_QR", "MUST_PRESENT_COVID19_VACCINE_QR"], + default: [], writeCheck: (request: WriteCheckRequest) => isOrganizer(request.requestUser), readCheck: true }, diff --git a/src/models/queuedverification/QueuedVerification.ts b/src/models/queuedverification/QueuedVerification.ts new file mode 100644 index 0000000..eee92bb --- /dev/null +++ b/src/models/queuedverification/QueuedVerification.ts @@ -0,0 +1,19 @@ +import mongoose from 'mongoose'; +import { extractFields } from '../util'; +import { fields, IQueuedVerification } from './fields'; + +const schema = new mongoose.Schema(extractFields(fields), { + toObject: { + virtuals: true, + }, + toJSON: { + virtuals: true, + }, +}); + +schema.index({ + processed: -1, + queuedTime: 1 +}); + +export default mongoose.model('QueuedVerification', schema); diff --git a/src/models/queuedverification/fields.ts b/src/models/queuedverification/fields.ts new file mode 100644 index 0000000..4b25aa8 --- /dev/null +++ b/src/models/queuedverification/fields.ts @@ -0,0 +1,47 @@ +import mongoose from 'mongoose'; +import { + CreateCheckRequest, + DeleteCheckRequest, + ReadCheckRequest, + WriteCheckRequest, +} from '../../types/checker'; +import { isOrganizer } from '../validator'; +import {DiscordVerifyInfo} from "../../types/types"; + +export const fields = { + createCheck: (request: CreateCheckRequest) => isOrganizer(request.requestUser), + readCheck: (request: ReadCheckRequest) => isOrganizer(request.requestUser), + deleteCheck: (request: DeleteCheckRequest) => isOrganizer(request.requestUser), + writeCheck: (request: WriteCheckRequest) => isOrganizer(request.requestUser), + FIELDS: { + queuedTime: { + type: Number, + required: true, + }, + processed: { + type: Boolean, + required: true, + default: false + }, + discordID: { + type: String, + required: true + }, + revert: { + type: Boolean, + required: true + }, + verifyData: { + type: Object, + required: true + } + }, +}; + +export interface IQueuedVerification extends mongoose.Document { + queuedTime: number, + processed: boolean, + discordID: string, + revert: boolean, + verifyData: DiscordVerifyInfo +} diff --git a/src/models/shared/discordShared.ts b/src/models/shared/discordShared.ts index 2f27133..1d9a196 100644 --- a/src/models/shared/discordShared.ts +++ b/src/models/shared/discordShared.ts @@ -1,12 +1,11 @@ -import { ReadCheckRequest, WriteCheckRequest } from '../../types/checker'; -import { IExternalUser } from '../externaluser/fields'; +import {ReadCheckRequest, ReadInterceptRequest, WriteCheckRequest} from '../../types/checker'; import { IUser } from '../user/fields'; -import { isOrganizer } from '../validator'; +import {isOrganizer, isAdmin} from '../validator'; export default { - readCheck: (request: ReadCheckRequest) => isOrganizer(request.requestUser), - writeCheck: (request: WriteCheckRequest) => isOrganizer(request.requestUser), + readCheck: (request: ReadCheckRequest) => isOrganizer(request.requestUser), + writeCheck: (request: WriteCheckRequest) => isOrganizer(request.requestUser), FIELDS: { discordID: { @@ -38,5 +37,32 @@ export default { writeCheck: (request: WriteCheckRequest) => isOrganizer(request.requestUser), inTextSearch: true, }, + accessToken: { + type: String, + readCheck: false, + writeCheck: (request: WriteCheckRequest) => isAdmin(request.requestUser) + }, + accessTokenExpireTime: { + type: Number, + readCheck: false, + writeCheck: (request: WriteCheckRequest) => isAdmin(request.requestUser) + }, + refreshToken: { + type: String, + readCheck: (request: ReadCheckRequest) => isOrganizer(request.requestUser), + writeCheck: (request: WriteCheckRequest) => isAdmin(request.requestUser), + readInterceptor: (request: ReadInterceptRequest) => request.fieldValue ? "***MASKED***" : undefined + }, + lastSyncTime: { + type: Number, + readCheck: (request: ReadCheckRequest) => isOrganizer(request.requestUser), + writeCheck: (request: WriteCheckRequest) => isAdmin(request.requestUser) + }, + lastSyncStatus: { + type: String, + index: true, + readCheck: (request: ReadCheckRequest) => isOrganizer(request.requestUser), + writeCheck: (request: WriteCheckRequest) => isAdmin(request.requestUser) + } }, }; diff --git a/src/routes/action.ts b/src/routes/action.ts index ddade40..da2e3f3 100644 --- a/src/routes/action.ts +++ b/src/routes/action.ts @@ -7,7 +7,7 @@ import { resumeExport } from '../services/dataexport'; import assignAdmissionStatus from '../controller/applicationStatus/assignApplicationStatus'; import getRanks from '../controller/applicationStatus/getRanks'; import { createAPIToken } from '../controller/AuthController'; -import { verifyDiscordUser } from '../controller/DiscordController'; +import {getNextQueuedVerification, verifyDiscordUser} from '../controller/DiscordController'; import { recordJoin, recordLeave } from '../controller/MeetingController'; import {getObject, initializeSettingsMapper} from '../controller/ModelController'; import { createTeam, getTeam, joinTeam, leaveTeam } from '../controller/TeamController'; @@ -21,7 +21,7 @@ import { rsvp, updateApplication, updateResume, - fetchUserByDiscordID + fetchUserByDiscordID, associateWithDiscord, fetchDiscordConnectionMetadata, disassociateFromDiscord } from '../controller/UserController'; import { logResponse } from '../services/logger'; import sendAllTemplates from '../services/mailer/sendAllTemplates'; @@ -31,6 +31,7 @@ import verifyMailingList from '../services/mailer/verifyMailingList'; import {mongoose} from '../services/mongoose_service'; import {isAdmin, isHacker, isOrganizer, isVolunteer} from '../services/permissions'; import { getStatistics } from '../services/statistics'; +import {generateDiscordOAuthUrl} from "../services/discordApi"; const actionRouter = express.Router(); @@ -205,7 +206,39 @@ actionRouter.get('/checkInQR', isHacker, (req: Request, res:Response) => { req.executor!._id, "User" ) ) -}) +}); + +/** + * (Hacker) + * + * Generate Discord link URL + */ +actionRouter.post('/discordOAuthUrl', isHacker, (req: Request, res: Response) => { + logResponse( + req, + res, + generateDiscordOAuthUrl( + req.body.redirectUrl + ) + ) +}); + +/** + * (Hacker) + * + * Associate Discord account given a state and OAuth code + */ +actionRouter.post('/associateDiscord', isHacker, (req: Request, res: Response) => { + logResponse( + req, + res, + associateWithDiscord( + req.executor!._id, + req.body.state, + req.body.code + ) + ) +}); // Volunteer endpoints @@ -419,7 +452,7 @@ actionRouter.post('/verifyDiscord', isOrganizer, (req: Request, res: Response) = /** * (Organizer) * - * Associate a user on Discord + * Fetch user by Discord ID */ actionRouter.get('/getUserByDiscordID', isOrganizer, (req: Request, res: Response) => { logResponse( @@ -495,4 +528,47 @@ actionRouter.post('/multiCheckInQR', isOrganizer, (req: Request, res:Response) = res, generateCheckInQR(req.executor!, req.body.userList) ) -}) \ No newline at end of file +}) + +/** + * (Organizer) + * + * Disassociate Discord account from a user + */ +actionRouter.post('/disassociateDiscord', isOrganizer, (req: Request, res: Response) => { + logResponse( + req, + res, + disassociateFromDiscord( + req.body.userID + ) + ) +}); + +/** + * (Organizer) + * + * Get linked Discord metadata for a user + */ +actionRouter.get('/discordMetadata', isOrganizer, (req: Request, res: Response) => { + logResponse( + req, + res, + fetchDiscordConnectionMetadata( + req.query.userID as string + ) + ) +}); + +/** + * (Organizer) + * + * Get next queued Discord verification + */ +actionRouter.get('/getNextQueuedDiscordVerification', isOrganizer, (req: Request, res: Response) => { + logResponse( + req, + res, + getNextQueuedVerification() + ) +}); \ No newline at end of file diff --git a/src/services/discordApi.ts b/src/services/discordApi.ts new file mode 100644 index 0000000..70543e0 --- /dev/null +++ b/src/services/discordApi.ts @@ -0,0 +1,211 @@ +import {BadRequestError, InternalServerError} from "../types/errors"; +import {createJwt, verifyToken} from "./permissions"; +import axios from "axios"; +import {IUser} from "../models/user/fields"; +import User from "../models/user/User"; +import {DiscordSyncState} from "../types/types"; +import {log} from "./logger"; + +export interface DiscordTokenResponse { + access_token: string; + refresh_token: string; + expires_at: number; // this is in milliseconds +} + +export interface DiscordConnectionMetadata { + isconfirmedhacker?: 0 | 1, + isorganizer?: 0 | 1 +} + +export const getDiscordTokensFromUser = (user: IUser): DiscordTokenResponse => { + if(!user.discord.refreshToken || !user.discord.accessToken || user.discord.accessTokenExpireTime === undefined) { + throw new BadRequestError("User does not have a Discord account linked."); + } + + return { + access_token: user.discord.accessToken, + expires_at: user.discord.accessTokenExpireTime, + refresh_token: user.discord.refreshToken + }; +} +/** + * Generates a Discord OAuth2 login URL + */ +export const generateDiscordOAuthUrl = async (redirectUrl: string):Promise => { + const stateData = { + redirectUrl + }; + + const stateString = createJwt(stateData, '15 minutes'); + + const url = new URL('https://discord.com/api/oauth2/authorize'); + url.searchParams.set('client_id', process.env.DISCORD_CLIENT_ID!); + url.searchParams.set('redirect_uri', redirectUrl); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('state', stateString); + url.searchParams.set('scope', 'role_connections.write identify'); + url.searchParams.set('prompt', 'consent'); + return url.toString(); +} + +/** + * Given an OAuth2 code from the scope approval page, make a request to Discord's + * OAuth2 service to retrieve an access token, refresh token, and expiration. + */ +export async function getOAuthTokens(stateToken: string, code: string): Promise { + const stateInfo = verifyToken(stateToken); + const redirectUrl = stateInfo.redirectUrl; + + + if(!redirectUrl) { + throw new BadRequestError("Unable to retrieve the state redirect url.") + } + + const url = 'https://discord.com/api/v10/oauth2/token'; + const body = new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT_ID!, + client_secret: process.env.DISCORD_CLIENT_SECRET!, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUrl, + }); + + const response = await axios({ + url, + method: "POST", + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data: body + }); + + const tokenData = response.data; + tokenData["expires_at"] = Math.floor(Date.now() + (tokenData["expires_in"] * 1000 * 0.8)); + + delete tokenData["expires_in"]; + + return tokenData as DiscordTokenResponse; +} + +/** + * The initial token request comes with both an access token and a refresh + * token. Check if the access token has expired, and if it has, use the + * refresh token to acquire a new, fresh access token. + */ +export async function getAccessToken(userID: string, tokens: DiscordTokenResponse):Promise { + if (Date.now() > tokens.expires_at) { + console.log("we are refrehsing", Date.now(), tokens); + const url = 'https://discord.com/api/v10/oauth2/token'; + const body = new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT_ID!, + client_secret: process.env.DISCORD_CLIENT_SECRET!, + grant_type: 'refresh_token', + refresh_token: tokens.refresh_token, + }); + + try { + const response = await axios({ + url, + method: "POST", + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: body + }); + + const tokenData = response.data; + tokenData["expires_at"] = Math.floor(Date.now() + (tokenData["expires_in"] * 1000 * 0.8)); + + delete tokenData["expires_in"]; + + await User.updateOne({ + _id: userID + }, { + 'discord.lastSyncTime': Date.now(), + 'discord.lastSyncStatus': "SUCCESS" as DiscordSyncState, + 'discord.accessTokenExpireTime': tokenData["expires_at"], + 'discord.refreshToken': tokenData.refresh_token + }); + + return tokenData as DiscordTokenResponse; + } + catch(e: any) { + let syncStatus: DiscordSyncState = "SOFTFAIL"; + + if(!isNaN(e.response?.status)) { + // retry for rate limit or server error, otherwise hard fail it + if(e.response?.status !== 429 && (e.response?.status < 500 || e.response?.status > 599)) { + syncStatus = "HARDFAIL"; + } + } + + await User.updateOne({ + _id: userID + }, { + 'discord.lastSyncStatus': syncStatus, + 'discord.lastSyncTime': Date.now() + }).catch((err) => { + log.error(`The was an error writing an updated Discord refresh token for ${userID}.`, err); + }); + + throw new InternalServerError("Unable to retrieve access token from refresh token."); + } + + } + return tokens; +} + +/** + * Given a user based access token, fetch profile information for the current user. + */ +export async function getUserData(tokens: DiscordTokenResponse): Promise> { + const url = 'https://discord.com/api/v10/oauth2/@me'; + const response = await axios({ + url, + headers: { + Authorization: `Bearer ${tokens.access_token}`, + } + }); + + return response.data; +} + +/** + * Given metadata that matches the schema, push that data to Discord on behalf + * of the current user. + */ +export async function pushMetadata(tokens: DiscordTokenResponse, metadata: DiscordConnectionMetadata): Promise { + const url = `https://discord.com/api/v10/users/@me/applications/${process.env.DISCORD_CLIENT_ID}/role-connection`; + + await axios({ + url, + method: "PUT", + headers: { + Authorization: `Bearer ${tokens.access_token}`, + 'Content-Type': 'application/json', + }, + data: { + platform_name: 'Hack the 6ix', + metadata, + } + }); +} + +/** + * Fetch the metadata currently pushed to Discord for the currently logged + * in user, for this specific bot. + */ +export async function getMetadata(tokens: DiscordTokenResponse):Promise { + // GET /users/@me/applications/:id/role-connection + const url = `https://discord.com/api/v10/users/@me/applications/${process.env.DISCORD_CLIENT_ID}/role-connection`; + + const response = await axios({ + url, + headers: { + Authorization: `Bearer ${tokens.access_token}`, + 'Content-Type': 'application/json', + } + }); + + return response.data.metadata as DiscordConnectionMetadata; +} \ No newline at end of file diff --git a/src/services/hmacSigner.ts b/src/services/hmacSigner.ts new file mode 100644 index 0000000..cd2dce9 --- /dev/null +++ b/src/services/hmacSigner.ts @@ -0,0 +1,6 @@ +import * as crypto from 'crypto'; + +function signString (msg: string) { + const key = new Buffer(process.env.HMAC_KEY!, 'hex'); + return crypto.createHmac('sha256', key).update(msg).digest('hex'); +} \ No newline at end of file diff --git a/src/services/logger.ts b/src/services/logger.ts index 3457abf..fa77fa6 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -5,6 +5,7 @@ import * as util from 'util'; import winston from 'winston'; import { HTTPError } from '../types/errors'; import * as process from "process"; +import {cleanUserObject} from "../util/cleanUserObject"; const maxMessageSize = 50000; // Cap is 64KB, so we're going a bit lower to be safe @@ -134,7 +135,7 @@ export function logRequest(req: Request, alwaysLog?: boolean, additional?: any): ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress, uid: req.executor?._id || 'N/A', requestBody: req.body, - executorUser: req.executor, + executorUser: cleanUserObject(req.executor), ...(additional !== undefined ? additional : {}) }); @@ -167,7 +168,6 @@ export const logResponse = (req: Request, res: Response, promise: Promise, }); }) .catch((error: HTTPError) => { - const status = error.status || 500; // When we send out the response, we do NOT send the full error by default for security @@ -192,7 +192,7 @@ export const logResponse = (req: Request, res: Response, promise: Promise, requestBody: req.body, error: error, responseBody: body, - executorUser: req.executor, + executorUser: cleanUserObject(req.executor), }); log.error(`[${req.method} ${req.url}]`, logPayload); diff --git a/src/services/permissions.ts b/src/services/permissions.ts index 4155fe8..bdcdfd6 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -5,6 +5,7 @@ import { IUser } from '../models/user/fields'; import User from '../models/user/User'; import { ErrorMessage } from '../types/types'; import { jsonify, log } from './logger'; +import {cleanUserObject} from "../util/cleanUserObject"; export const verifyToken = (token: string): Record => { return verify(token, process.env.JWT_SECRET!, { @@ -17,10 +18,10 @@ export const decodeToken = (token: string): Record => { return decode(token) as Record; }; -export const createJwt = (data: Record): string => { +export const createJwt = (data: Record, expiresIn?: string): string => { return sign(data, process.env.JWT_SECRET!, { algorithm: 'HS256', - expiresIn: '1 day', + expiresIn: expiresIn ?? '1 day', issuer: 'hackthe6ix-backend', audience: 'hackthe6ix-backend', }); @@ -116,7 +117,7 @@ const isRole = async (req: Request, res: Response, next: NextFunction, role: 'ha requestBody: req.body, role: role, responseBody: 'Invalid Token', - executorUser: req.executor, + executorUser: cleanUserObject(req.executor), })); return res.status(401).send({ @@ -133,7 +134,7 @@ const isRole = async (req: Request, res: Response, next: NextFunction, role: 'ha requestBody: req.body, role: role, responseBody: 'Invalid Token', - executorUser: req.executor, + executorUser: cleanUserObject(req.executor), })); return res.status(403).send({ diff --git a/src/tests/mongo/discord-controller/verify-user.test.ts b/src/tests/mongo/discord-controller/verify-user.test.ts index 240baf3..39735ea 100644 --- a/src/tests/mongo/discord-controller/verify-user.test.ts +++ b/src/tests/mongo/discord-controller/verify-user.test.ts @@ -90,7 +90,6 @@ describe('Verify user in Discord', () => { _id: confirmedHackerUser._id, }); - expect(newUser.status?.checkedIn).toEqual(true); expect(newUser.discord?.discordID).toEqual(DISCORD_ID); expect(newUser.discord?.username).toEqual(DISCORD_NAME); expect(newUser.discord?.verifyTime).toEqual(SIM_TIME); @@ -120,7 +119,6 @@ describe('Verify user in Discord', () => { _id: confirmedHackerUser._id, }); - expect(newUser.status?.checkedIn).toEqual(true); expect(newUser.discord?.discordID).toEqual(DISCORD_ID); expect(newUser.discord?.username).toEqual(DISCORD_NAME); expect(newUser.discord?.verifyTime).toEqual(SIM_TIME); diff --git a/src/types/types.ts b/src/types/types.ts index a26ee09..995222b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,6 +4,7 @@ import {IExternalUser} from "../models/externaluser/fields"; import {Model} from "mongoose"; export type ErrorMessage = { status: number, message: string, error?: string }; +export type DiscordSyncState = "SUCCESS" | "SOFTFAIL" | "HARDFAIL"; /** * Status of the universe @@ -36,7 +37,12 @@ export interface BasicUser extends mongoose.Document { username?: string, verifyTime?: number, additionalRoles?: string[], - suffix?: string + suffix?: string, + accessToken?: string, + accessTokenExpireTime?: number, + refreshToken?: string, + lastSyncTime?: number, + lastSyncStatus?: DiscordSyncState }, checkInNotes: string[] } diff --git a/src/util/cleanUserObject.ts b/src/util/cleanUserObject.ts new file mode 100644 index 0000000..548d294 --- /dev/null +++ b/src/util/cleanUserObject.ts @@ -0,0 +1,9 @@ +import {IApplication, IUser} from "../models/user/fields"; + +export const cleanUserObject = (user?: IUser):IUser|undefined => { + if(user?.["hackerApplication"] !== undefined) { + user["hackerApplication"] = {} as IApplication; + } + + return user; +} \ No newline at end of file