Skip to content

Commit

Permalink
Merge pull request #194 from chingu-x/feature/discord-oauth-p2
Browse files Browse the repository at this point in the history
Feature/discord oauth p2 - functionality
  • Loading branch information
cherylli authored Sep 3, 2024
2 parents 6d01dfa + 3d96337 commit f81b90a
Show file tree
Hide file tree
Showing 13 changed files with 165 additions and 47 deletions.
10 changes: 7 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ Another example [here](https://co-pilot.dev/changelog)
## [Unreleased]

### Added
- Add units tests for the teams controller & services([#189](https://github.com/chingu-x/chingu-dashboard-be/pull/189))
- Add discord oauth and e2e test ([#194](https://github.com/chingu-x/chingu-dashboard-be/pull/194))

### Changed

### Fixed
- Fix seed data for alpha test (check in form question changes, gravatar) ([#190](https://github.com/chingu-x/chingu-dashboard-be/pull/190))

### Removed

Expand Down Expand Up @@ -63,7 +66,7 @@ Another example [here](https://co-pilot.dev/changelog)



- Add units tests for the teams controller & services([#189](https://github.com/chingu-x/chingu-dashboard-be/pull/189))


### Changed

Expand Down Expand Up @@ -105,8 +108,9 @@ Another example [here](https://co-pilot.dev/changelog)
- Fix bug with reading roles after reseeding causes the db to not recognize the tokens stored by the user's browser ([#134](https://github.com/chingu-x/chingu-dashboard-be/pull/134))
- Fix form responses giving error and not inserting values when the boolean value is false ([#156](https://github.com/chingu-x/chingu-dashboard-be/pull/156))
- Fix a bug for check on voyageTeamMemberId ([#159](https://github.com/chingu-x/chingu-dashboard-be/pull/159))
- Fix users unit test failing due to a schema change
- Fix seed data for alpha test (check in form question changes, gravatar) ([#190](https://github.com/chingu-x/chingu-dashboard-be/pull/190))
- Fix users unit test failing due to a schema change ([#182](https://github.com/chingu-x/chingu-dashboard-be/pull/182))



### Removed

Expand Down
5 changes: 5 additions & 0 deletions prisma/seed/data/oauth-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default [
{
name: "discord",
},
];
3 changes: 3 additions & 0 deletions prisma/seed/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import InputTypes from "./data/input-types";
import OptionGroups from "./data/option-groups";
import Roles from "./data/roles";
import { prisma } from "./prisma-client";
import OauthProviders from "./data/oauth-providers";

const populateTable = async (tableName: string, data) => {
await prisma[tableName].createMany({
Expand All @@ -19,6 +20,7 @@ const populateTable = async (tableName: string, data) => {
console.log(`${tableName} table populated.`);
};

// These are basic data table that will be used in both dev and prod
export const populateTables = async () => {
await populateTable("tier", Tiers);
await populateTable("gender", Genders);
Expand All @@ -30,4 +32,5 @@ export const populateTables = async () => {
await populateTable("formType", FormTypes);
await populateTable("inputType", InputTypes);
await populateTable("optionGroup", OptionGroups);
await populateTable("oAuthProvider", OauthProviders);
};
27 changes: 8 additions & 19 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,20 +155,7 @@ export class AuthController {
@Request() req: CustomRequest,
@Res({ passthrough: true }) res: Response,
) {
const { access_token, refresh_token } = await this.authService.login(
req.user,
req.cookies?.refresh_token,
);
res.cookie("access_token", access_token, {
maxAge: AT_MAX_AGE * 1000,
httpOnly: true,
secure: true,
});
res.cookie("refresh_token", refresh_token, {
maxAge: RT_MAX_AGE * 1000,
httpOnly: true,
secure: true,
});
await this.authService.returnTokensOnLoginSuccess(req, res);
res.status(HttpStatus.OK).send({ message: "Login Success" });
}

Expand Down Expand Up @@ -358,15 +345,17 @@ export class AuthController {
@Public()
@Get("/discord/login")
handleDiscordLogin() {
return { msg: "Discord Authentication" };
return;
}

@UseGuards(DiscordAuthGuard)
@Public()
@Get("/discord/redirect")
handleDiscordRedirect() {
return { msg: "Discord Redirect" };
async handleDiscordRedirect(
@Request() req: CustomRequest,
@Res({ passthrough: true }) res: Response,
) {
await this.authService.returnTokensOnLoginSuccess(req, res);
res.redirect(`${process.env.FRONTEND_URL}`);
}

// TODO: Discord logout, will probably just be in the normal logout route
}
23 changes: 21 additions & 2 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { ResetPasswordDto } from "./dto/reset-password.dto";
import * as process from "process";
import { AT_MAX_AGE, RT_MAX_AGE } from "../global/constants";
import { RevokeRTDto } from "./dto/revoke-refresh-token.dto";
import { Response } from "express";
import { CustomRequest } from "../global/types/CustomRequest";

@Injectable()
export class AuthService {
Expand All @@ -47,7 +49,7 @@ export class AuthService {
};

// access token and refresh token
private generateAtRtTokens = async (payload: object) => {
generateAtRtTokens = async (payload: object) => {
const [at, rt] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: process.env.AT_SECRET,
Expand All @@ -68,7 +70,7 @@ export class AuthService {
return crypto.createHash("sha256").update(jwt).digest("hex");
};

private updateRtHash = async (
updateRtHash = async (
userId: string,
rt: string,
oldRtInCookies?: string,
Expand Down Expand Up @@ -122,6 +124,23 @@ export class AuthService {
}
};

async returnTokensOnLoginSuccess(req: CustomRequest, res: Response) {
const { access_token, refresh_token } = await this.login(
req.user,
req.cookies?.refresh_token,
);
res.cookie("access_token", access_token, {
maxAge: AT_MAX_AGE * 1000,
httpOnly: true,
secure: true,
});
res.cookie("refresh_token", refresh_token, {
maxAge: RT_MAX_AGE * 1000,
httpOnly: true,
secure: true,
});
}

/**
* Checks user email/username match database - for passport
*/
Expand Down
70 changes: 63 additions & 7 deletions src/auth/discord-auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,78 @@
import { Injectable } from "@nestjs/common";
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { IAuthProvider } from "../global/interfaces/oauth.interface";
import { PrismaService } from "../prisma/prisma.service";
import { DiscordUser } from "../global/types/auth.types";
import { generatePasswordHash } from "../utils/auth";
import { AuthService } from "./auth.service";

@Injectable()
export class DiscordAuthService implements IAuthProvider {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private authService: AuthService,
) {}

async validateUser(user: DiscordUser) {
const userInDb = await this.prisma.findUserByOAuthId(
"discord",
user.discordId,
);
console.log(
`discord-auth.service.ts (14): userInDb = ${JSON.stringify(userInDb)}`,
);

if (userInDb)
return {
id: userInDb.userId,
email: user.email,
};
return this.createUser(user);
}

createUser() {}
async createUser(user: DiscordUser) {
// generate a random password and not tell them so they can't login, but they will be able to reset password,
// and will be able to login with this in future,
// or maybe the app will prompt user the input the password, exact oauth flow is to be determined

findUserById() {}
// this should not happen when "email" is in the scope
if (!user.email)
throw new InternalServerErrorException(
"[discord-auth.service]: Cannot get email from discord to create a new Chingu account",
);

// check if email is in the database, add oauth profile to existing account, otherwise, create a new user account
return this.prisma.user.upsert({
where: {
email: user.email,
},
update: {
emailVerified: true,
oAuthProfiles: {
create: {
provider: {
connect: {
name: "discord",
},
},
providerUserId: user.discordId,
providerUsername: user.username,
},
},
},
create: {
email: user.email,
password: await generatePasswordHash(),
emailVerified: true,
avatar: `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png`,
oAuthProfiles: {
create: {
provider: {
connect: {
name: "discord",
},
},
providerUserId: user.discordId,
providerUsername: user.username,
},
},
},
});
}
}
7 changes: 5 additions & 2 deletions src/auth/guards/discord-auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { AuthGuard } from "@nestjs/passport";

@Injectable()
export class DiscordAuthGuard extends AuthGuard("discord") {
constructor() {
super({
session: false,
});
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const activate = (await super.canActivate(context)) as boolean;
console.log(`discord-auth.guard.ts (8): activate = ${activate}`);
const request = context.switchToHttp().getRequest();
console.log(`discord-auth.guard.ts (10): request = ${request}`);
await super.logIn(request);
return activate;
}
Expand Down
13 changes: 3 additions & 10 deletions src/auth/strategies/discord.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class DiscordStrategy extends PassportStrategy(Strategy, "discord") {
clientID: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackURL: process.env.DISCORD_CALLBACK_URL,
scope: ["identify"],
scope: ["identify", "email"],
});
}

Expand All @@ -23,19 +23,12 @@ export class DiscordStrategy extends PassportStrategy(Strategy, "discord") {
profile: Profile,
): Promise<any> {
const { username, id, avatar, email } = profile;
console.log(`discord.strategy.ts (22): accessToken = ${accessToken}`);
console.log(`discord.strategy.ts (23): refreshToken = ${refreshToken}`);
console.log(
`discord.strategy.ts (24): profile = ${JSON.stringify(profile)})}`,
);
console.log(
`discord.strategy.ts (24): profile = ${username}, ${id}, ${avatar}, ${email})}`,
);

await this.discordAuthService.validateUser({
return this.discordAuthService.validateUser({
discordId: id,
username,
avatar,
email,
});
}
}
7 changes: 4 additions & 3 deletions src/global/interfaces/oauth.interface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { DiscordUser } from "../types/auth.types";

export interface IAuthProvider {
validateUser(user: DiscordUser);
createUser();
findUserById();
// TODO: Maybe change it to OAuthUser: DiscordUser | GithubUser etc
// Or change it to a more general type name
validateUser(user: DiscordUser): void;
createUser(user: DiscordUser): void;
}
1 change: 1 addition & 0 deletions src/global/types/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export type DiscordUser = {
discordId: string;
username: string;
avatar?: string | null;
email: string | undefined;
};
14 changes: 13 additions & 1 deletion src/prisma/prisma-extensions.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { Prisma, PrismaClient } from "@prisma/client";
import { InternalServerErrorException } from "@nestjs/common";

export const prismaExtension = Prisma.defineExtension({
name: "findUserByOAuthId",
client: {
async findUserByOAuthId(providerName: string, providerUserId: string) {
const context = Prisma.getExtensionContext(this);

return context.$queryRaw`
const userInDb = await context.$queryRaw`
SELECT "UserOAuthProfile".*
FROM "UserOAuthProfile"
JOIN "OAuthProvider" ON "UserOAuthProfile"."providerId" = "OAuthProvider"."id"
WHERE "OAuthProvider".name = ${providerName} AND "UserOAuthProfile"."providerUserId" = ${providerUserId}
`;

switch (userInDb.length) {
case 0:
return null;
case 1:
return userInDb[0];
default:
throw new InternalServerErrorException(
`Found more than one user with the same OAuthID for ${providerName}`,
);
}
},
},
});
Expand Down
13 changes: 13 additions & 0 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as bcrypt from "bcrypt";
import * as crypto from "crypto";

const roundsOfHashing = parseInt(process.env.BCRYPT_HASHING_ROUNDS as string);

Expand All @@ -9,3 +10,15 @@ export const hashPassword = async (password: string) => {
export const comparePassword = async (password, hashedPassword) => {
return await bcrypt.compare(password, hashedPassword);
};

// for oauth temp password
export const generatePasswordHash = async (length = 16) => {
const charset =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+{}[]|:;<>,.?/~";

return hashPassword(
Array.from(crypto.randomBytes(length))
.map((byte) => charset[byte % charset.length])
.join(""),
);
};
19 changes: 19 additions & 0 deletions test/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,4 +635,23 @@ describe("AuthController e2e Tests", () => {
});
});
});

describe("Initiate Discord OAuth GET /auth/discord/login", () => {
it("should redirect ", async () => {
const res = await request(app.getHttpServer())
.get("/auth/discord/login")
.expect(302);

const clientId = process.env.DISCORD_CLIENT_ID;
const responseType = "code";
const redirectUrl = ".*auth%2Fdiscord%2Fredirect";
const scope = "identify%20email";

const re = new RegExp(
String.raw`https:\/\/discord\.com\/api\/oauth2\/authorize\?response_type=${responseType}&redirect_uri=${redirectUrl}&scope=${scope}&client_id=${clientId}`,
);

expect(res.headers.location).toMatch(re);
});
});
});

0 comments on commit f81b90a

Please sign in to comment.