Skip to content

Commit

Permalink
Merge pull request #117 from hack-the-6ix/develop
Browse files Browse the repository at this point in the history
Add Discord linking via OAuth2 (#116)
  • Loading branch information
BlazingAsher authored Jul 28, 2023
2 parents af92cd1 + 0b76f18 commit 1b01316
Show file tree
Hide file tree
Showing 15 changed files with 638 additions and 27 deletions.
6 changes: 5 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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
EMAILS_CAN_ALWAYS_APPLY=cool_hacker@gmail.com,@hackthe6ix.com

# Discord Connection Configuration
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
28 changes: 25 additions & 3 deletions src/controller/DiscordController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) {
Expand All @@ -87,4 +88,25 @@ export const verifyDiscordUser = async (email: string, discordID: string, discor

return _assembleReturnInfo(userInfo);
}
};
};

export const queueVerification = async (discordID: string, userData: BasicUser, revert=false): Promise<void> => {
await QueuedVerification.create({
queuedTime: Date.now(),
discordID,
revert: false,
verifyData: _assembleReturnInfo(userData)
});
}

export const getNextQueuedVerification = async ():Promise<IQueuedVerification | null | undefined> => {
return QueuedVerification.findOneAndUpdate({
processed: false
}, {
processed: true
}, {
sort: {
queuedTime: 1
}
});
}
190 changes: 188 additions & 2 deletions src/controller/UserController.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,7 +18,7 @@ import {
} from '../types/errors';
import { MailTemplate } from '../types/mailer';
import {
AllUserTypes, BasicUser,
AllUserTypes, BasicUser, DiscordSyncState,
IRSVP,
QRCodeGenerateBulkResponse,
QRCodeGenerateRequest
Expand All @@ -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<IUser> => {
Expand Down Expand Up @@ -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<string> => {
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<string> => {
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<string> => {
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<DiscordConnectionMetadata> => {
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);
}
2 changes: 1 addition & 1 deletion src/models/externaluser/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IUser>) => isOrganizer(request.requestUser),
readCheck: true
},
Expand Down
19 changes: 19 additions & 0 deletions src/models/queuedverification/QueuedVerification.ts
Original file line number Diff line number Diff line change
@@ -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<IQueuedVerification>('QueuedVerification', schema);
47 changes: 47 additions & 0 deletions src/models/queuedverification/fields.ts
Original file line number Diff line number Diff line change
@@ -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<any, IQueuedVerification>) => isOrganizer(request.requestUser),
readCheck: (request: ReadCheckRequest<IQueuedVerification>) => isOrganizer(request.requestUser),
deleteCheck: (request: DeleteCheckRequest<IQueuedVerification>) => isOrganizer(request.requestUser),
writeCheck: (request: WriteCheckRequest<any, IQueuedVerification>) => 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
}
36 changes: 31 additions & 5 deletions src/models/shared/discordShared.ts
Original file line number Diff line number Diff line change
@@ -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<IExternalUser>) => isOrganizer(request.requestUser),
writeCheck: (request: WriteCheckRequest<any, IExternalUser>) => isOrganizer(request.requestUser),
readCheck: (request: ReadCheckRequest<IUser>) => isOrganizer(request.requestUser),
writeCheck: (request: WriteCheckRequest<any, IUser>) => isOrganizer(request.requestUser),

FIELDS: {
discordID: {
Expand Down Expand Up @@ -38,5 +37,32 @@ export default {
writeCheck: (request: WriteCheckRequest<string, IUser>) => isOrganizer(request.requestUser),
inTextSearch: true,
},
accessToken: {
type: String,
readCheck: false,
writeCheck: (request: WriteCheckRequest<string, IUser>) => isAdmin(request.requestUser)
},
accessTokenExpireTime: {
type: Number,
readCheck: false,
writeCheck: (request: WriteCheckRequest<string, IUser>) => isAdmin(request.requestUser)
},
refreshToken: {
type: String,
readCheck: (request: ReadCheckRequest<IUser>) => isOrganizer(request.requestUser),
writeCheck: (request: WriteCheckRequest<string, IUser>) => isAdmin(request.requestUser),
readInterceptor: (request: ReadInterceptRequest<string, IUser>) => request.fieldValue ? "***MASKED***" : undefined
},
lastSyncTime: {
type: Number,
readCheck: (request: ReadCheckRequest<IUser>) => isOrganizer(request.requestUser),
writeCheck: (request: WriteCheckRequest<string, IUser>) => isAdmin(request.requestUser)
},
lastSyncStatus: {
type: String,
index: true,
readCheck: (request: ReadCheckRequest<IUser>) => isOrganizer(request.requestUser),
writeCheck: (request: WriteCheckRequest<string, IUser>) => isAdmin(request.requestUser)
}
},
};
Loading

0 comments on commit 1b01316

Please sign in to comment.