From fe637d59bb19e10e33f121016a02c55d5fbb9efd Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Wed, 12 May 2021 12:25:37 -0400 Subject: [PATCH 01/14] Implemented new endpoint and successfully logs in --- packages/api-client/index.ts | 29 +++- .../app/hooks/useDefaultCourseRedirect.ts | 1 + packages/app/hooks/useSaveStateChallenge.tsx | 17 +++ packages/app/package.json | 1 + packages/app/pages/login.tsx | 44 +++++- packages/app/pages/oauth.tsx | 67 +++++++++ packages/common/index.ts | 60 ++++++++ .../src/healthcheck/healthcheck.controller.ts | 3 +- packages/server/src/login/login.controller.ts | 142 +++++++++++++++++- yarn.lock | 5 + 10 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 packages/app/hooks/useSaveStateChallenge.tsx create mode 100644 packages/app/pages/oauth.tsx diff --git a/packages/api-client/index.ts b/packages/api-client/index.ts index 7c6f87133..31c4c94f0 100644 --- a/packages/api-client/index.ts +++ b/packages/api-client/index.ts @@ -27,6 +27,12 @@ import { DateRangeType, SubmitCourseParams, SemesterPartial, + OAuthAccessTokensResponse, + OAuthUserInfoResponse, + OAuthAccessTokensRequest, + AccessTokenRequest, + KhouryRedirectResponse, + RefreshTokenRequest, } from "@koh/common"; import Axios, { AxiosInstance, Method } from "axios"; import { plainToClass } from "class-transformer"; @@ -228,7 +234,28 @@ class APIClient { this.req("PATCH", `/api/v1/alerts/${alertId}`); }, }; - + oauth = { + tokens: async ( + param: OAuthAccessTokensRequest + ): Promise => + this.req( + "POST", + `/api/v1/oauth/tokens`, + OAuthAccessTokensResponse, + param + ), + renewToken: async ( + param: RefreshTokenRequest + ): Promise => + this.req( + "POST", + "/api/v1/oauth/tokens/refresh", + AccessTokenRequest, + param + ), + userInfo: async (param: AccessTokenRequest): Promise => + this.req("POST", `/api/v1/oauth/user`, undefined, param), + }; constructor(baseURL = "") { this.axios = Axios.create({ baseURL: baseURL }); } diff --git a/packages/app/hooks/useDefaultCourseRedirect.ts b/packages/app/hooks/useDefaultCourseRedirect.ts index 2e850b62d..a5c73e51b 100644 --- a/packages/app/hooks/useDefaultCourseRedirect.ts +++ b/packages/app/hooks/useDefaultCourseRedirect.ts @@ -8,6 +8,7 @@ export function useDefaultCourseRedirect(): boolean { const [defaultCourse] = useLocalStorage("defaultCourse", null); if (profile && profile.courses.length > 0) { /// defaultCourse can get out-of-sync with the user's actual registered course (dropped class etc) + // TODO: Change from !!defaultCourse to defaultCourse const isUserInDefaultCourse = !!defaultCourse && profile.courses.some((c) => c.course.id === defaultCourse?.id); diff --git a/packages/app/hooks/useSaveStateChallenge.tsx b/packages/app/hooks/useSaveStateChallenge.tsx new file mode 100644 index 000000000..dd9468808 --- /dev/null +++ b/packages/app/hooks/useSaveStateChallenge.tsx @@ -0,0 +1,17 @@ +export interface OAuthStateChallenge { + state: string; + challenge: string; +} + +export function useSaveStateChallenge( + stateLength: number, + hashLength: number +): OAuthStateChallenge { + const state = Math.random().toString(20).substr(2, stateLength); + const challenge = Math.random().toString(20).substr(2, hashLength); + + return { + state, + challenge, + }; +} diff --git a/packages/app/package.json b/packages/app/package.json index 18de0564e..efddcc466 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -35,6 +35,7 @@ "next-compose-plugins": "^2.2.0", "next-offline": "^5.0.2", "next-transpile-modules": "^3.3.0", + "node-forge": "^0.10.0", "platform": "^1.3.6", "react": "16.13.1", "react-big-calendar": "^0.24.6", diff --git a/packages/app/pages/login.tsx b/packages/app/pages/login.tsx index 7b576264b..90225a37b 100644 --- a/packages/app/pages/login.tsx +++ b/packages/app/pages/login.tsx @@ -5,6 +5,10 @@ import { useDefaultCourseRedirect } from "../hooks/useDefaultCourseRedirect"; import { User } from "@koh/common"; import Router from "next/router"; import { useProfile } from "../hooks/useProfile"; +import { useSaveStateChallenge } from "../hooks/useSaveStateChallenge"; +import { useLocalStorage } from "../hooks/useLocalStorage"; +let forge = require("node-forge"); +const isWindow = typeof window !== "undefined"; const Container = styled.div` height: 80vh; @@ -17,6 +21,28 @@ const ContentContainer = styled.div` text-align: center; `; +function openKhouryOAuthLoginPage() { + let stateVal = ""; + let codeVal = ""; + if (isWindow) { + stateVal = window.localStorage.getItem("state"); + codeVal = window.localStorage.getItem("challenge"); + } + let md = forge.md.sha256.create(); + md.update(codeVal); + const hashedCodeChallenge: string = md.digest().toHex(); + const windowReference = window.open( + "http://localhost:8000/oauth/login?response_type=code&client_id=f7af86112c35ba004b25&redirect_uri=http://localhost:3000/oauth&scopes=ta.info&scopes=user.info&scopes=student.courses&state=" + + stateVal + + "&challenge=" + + hashedCodeChallenge, + "_blank" + ); + if (window.focus) { + windowReference.focus(); + } +} + export default function Login(): ReactElement { const profile: User = useProfile(); const didRedirect = useDefaultCourseRedirect(); @@ -24,12 +50,28 @@ export default function Login(): ReactElement { Router.push("/nocourses"); } + const stateChallengeLocalStorage = useSaveStateChallenge(6, 6); + const localState = stateChallengeLocalStorage.state; + const localChallenge = stateChallengeLocalStorage.challenge; + + if ( + isWindow && + window.localStorage.getItem("state") === null && + window.localStorage.getItem("challenge") === null + ) { + window.localStorage.setItem("state", localState); + window.localStorage.setItem("challenge", localChallenge); + } return (

You are currently not logged in

Click the button below to login via Khoury Admin

-
diff --git a/packages/app/pages/oauth.tsx b/packages/app/pages/oauth.tsx new file mode 100644 index 000000000..4bdb29802 --- /dev/null +++ b/packages/app/pages/oauth.tsx @@ -0,0 +1,67 @@ +import { API } from "@koh/api-client"; +import { useRouter } from "next/router"; +import { ReactElement } from "react"; +import { StandardPageContainer } from "../components/common/PageContainer"; +import Router from "next/router"; +import { OAuthAccessTokensRequest } from "@koh/common"; +import { Spin } from "antd"; + +const isWindow = typeof window !== "undefined"; + +async function signUserIn(request: OAuthAccessTokensRequest): Promise { + const tokens = await API.oauth.tokens(request); + if (tokens === null) { + return false; + } + window.localStorage.setItem("access", tokens.access); + window.localStorage.setItem("refresh", tokens.refresh); + let params = { + access: tokens.access, + }; + const userLoginResult = await API.oauth + .userInfo(params) + .then(() => { + return true; + }) + .catch(() => { + return false; + }); + return userLoginResult; +} + +export default function OAuth(): ReactElement { + const router = useRouter(); + const state = router.query.state; + const authCode = router.query.code; + + let tokensRequestBody: OAuthAccessTokensRequest; + + if (state && authCode && isWindow) { + const storedState = window.localStorage.getItem("state"); + const storedChallenge = window.localStorage.getItem("challenge"); + + if (storedState != state) { + // if the states are not equal then a CRSF attack may be happening so do not continue with OAuth sign in and return to login + // also, may want to notify khoury admin page. An error also could have happened so that is another possibility. + Router.push("/login"); + } else { + tokensRequestBody = { + code: authCode as string, + verifier: storedChallenge, + }; + signUserIn(tokensRequestBody).then((result) => { + if (result) { + Router.push("/nocourses"); + } else { + Router.push("/login"); + } + }); + } + } + + return ( + + + + ); +} diff --git a/packages/common/index.ts b/packages/common/index.ts index accbab829..bbb9e44f1 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -10,6 +10,7 @@ import { IsNumber, IsObject, IsOptional, + isString, IsString, ValidateIf, } from "class-validator"; @@ -327,6 +328,65 @@ export type PhoneNotifBody = { // Office Hours Response Types export class GetProfileResponse extends User {} +export class OAuthAccessTokensResponse { + @IsString() + access!: string; + + @IsString() + refresh!: string; +} + +export class AccessTokenRequest { + @IsString() + access!: string; +} + +export class RefreshTokenRequest { + @IsString() + refresh!: string; +} + +export class OAuthAccessTokensRequest { + @IsString() + code!: string; + + @IsString() + verifier!: string; +} + +export class OAuthUserInfoResponse { + @IsString() + firstName!: string; + @IsString() + lastName!: string; + @IsString() + email!: string; + @IsInt() + campus!: number; + @IsArray() + accountType!: string[]; + // add photo url +} + +export class OAuthTACourseModel { + @IsString() + course!: string; + + @IsString() + semester!: string; + + @IsString() + campus!: string; +} + +export class OAuthTaInfoResponse { + @IsBoolean() + isTa!: boolean; + + @IsArray() + courses!: [OAuthTACourseModel]; +} + export class KhouryDataParams { @IsString() email!: string; diff --git a/packages/server/src/healthcheck/healthcheck.controller.ts b/packages/server/src/healthcheck/healthcheck.controller.ts index 822ee22c7..dd8c83fb1 100644 --- a/packages/server/src/healthcheck/healthcheck.controller.ts +++ b/packages/server/src/healthcheck/healthcheck.controller.ts @@ -1,5 +1,6 @@ import { Controller } from '@nestjs/common'; -import { Get } from '@nestjs/common/decorators'; +import { Get, Res } from '@nestjs/common/decorators'; +import { Request, Response } from 'express'; @Controller('healthcheck') export class HealthcheckController { diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index d0c2e7c70..30a78f57c 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -1,4 +1,11 @@ -import { KhouryDataParams, KhouryRedirectResponse } from '@koh/common'; +import { + KhouryDataParams, + KhouryRedirectResponse, + OAuthAccessTokensRequest, + OAuthAccessTokensResponse, + RefreshTokenRequest, + AccessTokenRequest, +} from '@koh/common'; import { Body, Controller, @@ -18,6 +25,7 @@ import * as httpSignature from 'http-signature'; import { Connection } from 'typeorm'; import { NonProductionGuard } from '../guards/non-production.guard'; import { LoginCourseService } from './login-course.service'; +import axios from 'axios'; @Controller() export class LoginController { @@ -28,6 +36,137 @@ export class LoginController { private configService: ConfigService, ) {} + @Post('/oauth/tokens') + async getAccessTokens( + @Res() res: Response, + @Body() body: OAuthAccessTokensRequest, + ): Promise { + const authCode = body.code; + const challenge = body.verifier; + const token = await axios.post( + `http://localhost:8000/api/oauth/token?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&grant_type=authorization_code&redirect_uri=http://localhost:3000/oauth&code=${authCode}&scopes=user.info&scopes=ta.info&scopes=student.courses&verifier=${challenge}`, + ); + if (token.status != 200) { + return null; + } + const tokens = { + access: token.data.access, + refresh: token.data.refresh, + }; + res.json(tokens); + return tokens; + } + + @Post('/oauth/tokens/refresh') + async refreshAccessTokens( + @Res() res: Response, + @Body() body: RefreshTokenRequest, + ): Promise { + const refreshToken = body.refresh; + const token = await axios + .get( + `http://localhost:8000/api/oauth/token/refresh?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&refresh_token=${refreshToken}&grant_type=refresh_token&scopes=user.info&scopes=ta.info&scopes=student.courses`, + ) + .catch(() => { + return null; + }); + if (token === null) { + res.json({ + 'OAuth Error': 'Error occurred while trying to refresh access token.', + }); + } else { + const tokens = { + access: token.data.access, + }; + res.json(tokens); + return tokens; + } + } + + @Post('/oauth/user') + async getUser( + @Res() res: Response, + @Body() body: AccessTokenRequest, + ): Promise { + let authorizationToken = 'Bearer ' + body.access; + const request = await axios.get( + `http://localhost:8000/api/oauth/userinfo/read/`, + { + headers: { + Authorization: authorizationToken, + }, + }, + ); + if (request.status !== 200) { + return null; + } + let khouryData = { + first_name: request.data.firstname, + last_name: request.data.lastname, + email: request.data.email, + accountType: request.data.account_type, + campus: request.data.campus, + ta_courses: [], + courses: [], + photo_url: '', + }; + + // this is a student signing in so get the students list of courses + if (khouryData.accountType.includes('student')) { + console.log("Getting student's list of courses"); + } + + // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? + const taRequest = await axios.get( + `http://localhost:8000/api/oauth/tainfo/read/`, + { + headers: { + Authorization: authorizationToken, + }, + }, + ); + + if (taRequest.status !== 200) { + return null; + } + + if (taRequest.data.is_ta === true) { + console.log('Account is a TA'); + for (const course of taRequest.data.courses) { + console.log(course); + khouryData.ta_courses.push({ + course: course.course, + semester: course.semester, + }); + } + } + + let user; + try { + user = await this.loginCourseService.addUserFromKhoury(khouryData); + } catch (e) { + Sentry.captureException(e); + console.error('Khoury login threw an exception, the body was ', body); + throw e; + } + + // Create temporary login token to send user to. + const token = await this.jwtService.signAsync( + { userId: user.id }, + { expiresIn: 60 }, + ); + + const isVerified = await this.jwtService.verifyAsync(token); + + if (!isVerified) { + throw new UnauthorizedException(); + } + + const payload = this.jwtService.decode(token) as { userId: number }; + this.enter(res, payload.userId); + } + + // TODO: Remove this endpoint @Post('/khoury_login') async recieveDataFromKhoury( @Req() req: Request, @@ -66,6 +205,7 @@ export class LoginController { }; } + // TODO: Remove this endpoint // NOTE: Although the two routes below are on the backend, // they are meant to be visited by the browser so a cookie can be set diff --git a/yarn.lock b/yarn.lock index 253b88859..fc48a88e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10721,6 +10721,11 @@ node-fetch@^2.6.0: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== +node-forge@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" + integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== + node-ical@^0.11.3: version "0.11.3" resolved "https://registry.yarnpkg.com/node-ical/-/node-ical-0.11.3.tgz#fb7891547eb711545513b92938b1efae0aba832b" From 4413f6dbe5fa47de05a0730d3694d6397d729f56 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Wed, 12 May 2021 17:11:47 -0400 Subject: [PATCH 02/14] Broke down OAuth user into function and fixed error handling --- packages/api-client/index.ts | 19 +- packages/app/pages/login.tsx | 1 - packages/app/pages/oauth.tsx | 6 +- packages/common/index.ts | 41 +++- packages/server/src/login/login.controller.ts | 198 +++++++++++------- 5 files changed, 166 insertions(+), 99 deletions(-) diff --git a/packages/api-client/index.ts b/packages/api-client/index.ts index 31c4c94f0..d02af24ab 100644 --- a/packages/api-client/index.ts +++ b/packages/api-client/index.ts @@ -28,11 +28,9 @@ import { SubmitCourseParams, SemesterPartial, OAuthAccessTokensResponse, - OAuthUserInfoResponse, OAuthAccessTokensRequest, - AccessTokenRequest, - KhouryRedirectResponse, - RefreshTokenRequest, + AccessToken, + RefreshToken, } from "@koh/common"; import Axios, { AxiosInstance, Method } from "axios"; import { plainToClass } from "class-transformer"; @@ -244,16 +242,9 @@ class APIClient { OAuthAccessTokensResponse, param ), - renewToken: async ( - param: RefreshTokenRequest - ): Promise => - this.req( - "POST", - "/api/v1/oauth/tokens/refresh", - AccessTokenRequest, - param - ), - userInfo: async (param: AccessTokenRequest): Promise => + renewToken: async (param: RefreshToken): Promise => + this.req("POST", "/api/v1/oauth/tokens/refresh", AccessToken, param), + userInfo: async (param: AccessToken): Promise => this.req("POST", `/api/v1/oauth/user`, undefined, param), }; constructor(baseURL = "") { diff --git a/packages/app/pages/login.tsx b/packages/app/pages/login.tsx index 90225a37b..0c2e898a5 100644 --- a/packages/app/pages/login.tsx +++ b/packages/app/pages/login.tsx @@ -6,7 +6,6 @@ import { User } from "@koh/common"; import Router from "next/router"; import { useProfile } from "../hooks/useProfile"; import { useSaveStateChallenge } from "../hooks/useSaveStateChallenge"; -import { useLocalStorage } from "../hooks/useLocalStorage"; let forge = require("node-forge"); const isWindow = typeof window !== "undefined"; diff --git a/packages/app/pages/oauth.tsx b/packages/app/pages/oauth.tsx index 4bdb29802..0a6500bed 100644 --- a/packages/app/pages/oauth.tsx +++ b/packages/app/pages/oauth.tsx @@ -9,8 +9,10 @@ import { Spin } from "antd"; const isWindow = typeof window !== "undefined"; async function signUserIn(request: OAuthAccessTokensRequest): Promise { - const tokens = await API.oauth.tokens(request); - if (tokens === null) { + let tokens; + try { + tokens = await API.oauth.tokens(request); + } catch (err) { return false; } window.localStorage.setItem("access", tokens.access); diff --git a/packages/common/index.ts b/packages/common/index.ts index bbb9e44f1..50dcc00bc 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -10,7 +10,6 @@ import { IsNumber, IsObject, IsOptional, - isString, IsString, ValidateIf, } from "class-validator"; @@ -336,12 +335,12 @@ export class OAuthAccessTokensResponse { refresh!: string; } -export class AccessTokenRequest { +export class AccessToken { @IsString() access!: string; } -export class RefreshTokenRequest { +export class RefreshToken { @IsString() refresh!: string; } @@ -354,6 +353,7 @@ export class OAuthAccessTokensRequest { verifier!: string; } +// TODO: Remove this model export class OAuthUserInfoResponse { @IsString() firstName!: string; @@ -368,6 +368,7 @@ export class OAuthUserInfoResponse { // add photo url } +// TODO: Remove this model export class OAuthTACourseModel { @IsString() course!: string; @@ -379,6 +380,7 @@ export class OAuthTACourseModel { campus!: string; } +// TODO: Remove this model export class OAuthTaInfoResponse { @IsBoolean() isTa!: boolean; @@ -387,6 +389,35 @@ export class OAuthTaInfoResponse { courses!: [OAuthTACourseModel]; } +export class OAuthUserModel { + @IsString() + email!: string; + + @IsString() + first_name!: string; + + @IsString() + last_name!: string; + + @IsInt() + campus!: string; + + @IsOptional() + @IsString() + photo_url!: string; + + @IsOptional() + @IsDefined() // TODO: use ValidateNested instead, for some reason it's crunked + courses!: KhouryStudentCourse[]; + + @IsOptional() + @IsDefined() // TODO: use ValidateNested instead, for some reason it's crunked + ta_courses!: KhouryTACourse[]; + + @IsArray() + accountType!: string[]; +} + export class KhouryDataParams { @IsString() email!: string; @@ -842,6 +873,10 @@ export const ERROR_MESSAGES = { }, loginController: { receiveDataFromKhoury: "Invalid request signature", + unableToGetAccessToken: "Unable to retrieve access token", + unabletoRefreshAccessToken: "Unable to refresh access token", + unabletToGetUserInfo: "Unable to get user data", + unableToGetTaInfo: "Unable to get TA data", }, notificationController: { messageNotFromTwilio: "Message not from Twilio", diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index 30a78f57c..ff2d2ad87 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -3,8 +3,11 @@ import { KhouryRedirectResponse, OAuthAccessTokensRequest, OAuthAccessTokensResponse, - RefreshTokenRequest, - AccessTokenRequest, + RefreshToken, + AccessToken, + ERROR_MESSAGES, + OAuthTACourseModel, + OAuthUserModel, } from '@koh/common'; import { Body, @@ -15,7 +18,9 @@ import { Req, Res, UnauthorizedException, + HttpException, UseGuards, + HttpStatus, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; @@ -26,6 +31,7 @@ import { Connection } from 'typeorm'; import { NonProductionGuard } from '../guards/non-production.guard'; import { LoginCourseService } from './login-course.service'; import axios from 'axios'; +import { request } from 'http'; @Controller() export class LoginController { @@ -43,62 +49,74 @@ export class LoginController { ): Promise { const authCode = body.code; const challenge = body.verifier; - const token = await axios.post( - `http://localhost:8000/api/oauth/token?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&grant_type=authorization_code&redirect_uri=http://localhost:3000/oauth&code=${authCode}&scopes=user.info&scopes=ta.info&scopes=student.courses&verifier=${challenge}`, - ); - if (token.status != 200) { - return null; - } - const tokens = { - access: token.data.access, - refresh: token.data.refresh, - }; - res.json(tokens); - return tokens; + const token = axios + .post( + `http://localhost:8000/api/oauth/token?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&grant_type=authorization_code&redirect_uri=http://localhost:3000/oauth&code=${authCode}&scopes=user.info&scopes=ta.info&scopes=student.courses&verifier=${challenge}`, + ) + .then((token) => { + const tokens = { + access: token.data.access, + refresh: token.data.refresh, + }; + res.json(tokens); + return tokens; + }) + .catch(() => { + throw new HttpException( + ERROR_MESSAGES.loginController.unableToGetAccessToken, + HttpStatus.BAD_REQUEST, + ); + }); + return token; } @Post('/oauth/tokens/refresh') async refreshAccessTokens( @Res() res: Response, - @Body() body: RefreshTokenRequest, - ): Promise { + @Body() body: RefreshToken, + ): Promise { const refreshToken = body.refresh; - const token = await axios + const token = axios .get( `http://localhost:8000/api/oauth/token/refresh?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&refresh_token=${refreshToken}&grant_type=refresh_token&scopes=user.info&scopes=ta.info&scopes=student.courses`, ) + .then((token) => { + const tokens = { + access: token.data.access, + }; + res.json(tokens); + return tokens; + }) .catch(() => { - return null; - }); - if (token === null) { - res.json({ - 'OAuth Error': 'Error occurred while trying to refresh access token.', + throw new HttpException( + ERROR_MESSAGES.loginController.unabletoRefreshAccessToken, + HttpStatus.BAD_REQUEST, + ); }); - } else { - const tokens = { - access: token.data.access, - }; - res.json(tokens); - return tokens; - } + return token; } @Post('/oauth/user') async getUser( @Res() res: Response, - @Body() body: AccessTokenRequest, + @Body() body: AccessToken, ): Promise { let authorizationToken = 'Bearer ' + body.access; - const request = await axios.get( - `http://localhost:8000/api/oauth/userinfo/read/`, - { - headers: { - Authorization: authorizationToken, + let request; + try { + request = await axios.get( + `http://localhost:8000/api/oauth/userinfo/read/`, + { + headers: { + Authorization: authorizationToken, + }, }, - }, - ); - if (request.status !== 200) { - return null; + ); + } catch (err) { + throw new HttpException( + ERROR_MESSAGES.loginController.unabletToGetUserInfo, + HttpStatus.BAD_REQUEST, + ); } let khouryData = { first_name: request.data.firstname, @@ -114,56 +132,24 @@ export class LoginController { // this is a student signing in so get the students list of courses if (khouryData.accountType.includes('student')) { console.log("Getting student's list of courses"); + // Return a student's list of courses } - // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? - const taRequest = await axios.get( - `http://localhost:8000/api/oauth/tainfo/read/`, - { - headers: { - Authorization: authorizationToken, - }, - }, - ); - - if (taRequest.status !== 200) { - return null; - } - - if (taRequest.data.is_ta === true) { - console.log('Account is a TA'); - for (const course of taRequest.data.courses) { - console.log(course); - khouryData.ta_courses.push({ - course: course.course, - semester: course.semester, - }); - } - } + // gets the logging in student list of courses they TA if they are a TA + khouryData.ta_courses = await this.getTACourses(authorizationToken); let user; try { user = await this.loginCourseService.addUserFromKhoury(khouryData); } catch (e) { Sentry.captureException(e); - console.error('Khoury login threw an exception, the body was ', body); + console.error( + 'Khoury login threw an exception, the body was ', + khouryData, + ); throw e; } - - // Create temporary login token to send user to. - const token = await this.jwtService.signAsync( - { userId: user.id }, - { expiresIn: 60 }, - ); - - const isVerified = await this.jwtService.verifyAsync(token); - - if (!isVerified) { - throw new UnauthorizedException(); - } - - const payload = this.jwtService.decode(token) as { userId: number }; - this.enter(res, payload.userId); + this.createUserToken(user.id, res); } // TODO: Remove this endpoint @@ -260,4 +246,58 @@ export class LoginController { .clearCookie('auth_token', { httpOnly: true, secure: isSecure }) .redirect(302, '/login'); } + + private async getStudentCourses(accessToken: string) { + // TODO: Make a request to get the logged in user's courses + } + + private async getTACourses( + accessToken: string, + ): Promise { + let taRequest; + + let courses = []; + // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? + try { + taRequest = await axios.get( + `http://localhost:8000/api/oauth/tainfo/read/`, + { + headers: { + Authorization: accessToken, + }, + }, + ); + } catch (err) { + throw new HttpException( + ERROR_MESSAGES.loginController.unableToGetTaInfo, + HttpStatus.BAD_REQUEST, + ); + } + if (taRequest.data.is_ta === true) { + for (const course of taRequest.data.courses) { + console.log(course); + courses.push({ + course: course.course, + semester: course.semester, + campus: course.campus, + }); + } + } + console.log(courses); + return courses; + } + + private async createUserToken(userId: string, res: Response) { + // Create temporary login token to send user to. + const token = await this.jwtService.signAsync( + { userId: userId }, + { expiresIn: 60 }, + ); + const isVerified = await this.jwtService.verifyAsync(token); + if (!isVerified) { + throw new UnauthorizedException(); + } + const payload = this.jwtService.decode(token) as { userId: number }; + this.enter(res, payload.userId); + } } From 8762443260f05774ba1a9a4a86e7463a9efb8f5f Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Fri, 14 May 2021 12:34:13 -0400 Subject: [PATCH 03/14] Claened up OAuth feature --- packages/common/index.ts | 44 ++++++--------- packages/server/src/login/login.controller.ts | 54 ++++++++++++++----- packages/server/test/login.integration.ts | 2 + 3 files changed, 57 insertions(+), 43 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 50dcc00bc..b7d98c5e6 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -327,6 +327,10 @@ export type PhoneNotifBody = { // Office Hours Response Types export class GetProfileResponse extends User {} +/** + * @param access - represents an access token that can be used to get data from the Khoury Admin OAuth routes + * @param refresh - represents a refresh token that can be used to get new access tokens once the access token expires + */ export class OAuthAccessTokensResponse { @IsString() access!: string; @@ -335,16 +339,26 @@ export class OAuthAccessTokensResponse { refresh!: string; } +/** + * @param access - represents an access token that can be used to get data from the Khoury Admin OAuth routes + */ export class AccessToken { @IsString() access!: string; } +/** + * @param refresh - represents a refresh token that can be used to get new access tokens once the access token expires + */ export class RefreshToken { @IsString() refresh!: string; } +/** + * @param code - represents the authorization code that can be used to request access/refresh tokens for a user from the OAuth server + * @param verifier - represents the SHA-256 verifier that was passed in the initial OAuth flow + */ export class OAuthAccessTokensRequest { @IsString() code!: string; @@ -389,35 +403,6 @@ export class OAuthTaInfoResponse { courses!: [OAuthTACourseModel]; } -export class OAuthUserModel { - @IsString() - email!: string; - - @IsString() - first_name!: string; - - @IsString() - last_name!: string; - - @IsInt() - campus!: string; - - @IsOptional() - @IsString() - photo_url!: string; - - @IsOptional() - @IsDefined() // TODO: use ValidateNested instead, for some reason it's crunked - courses!: KhouryStudentCourse[]; - - @IsOptional() - @IsDefined() // TODO: use ValidateNested instead, for some reason it's crunked - ta_courses!: KhouryTACourse[]; - - @IsArray() - accountType!: string[]; -} - export class KhouryDataParams { @IsString() email!: string; @@ -877,6 +862,7 @@ export const ERROR_MESSAGES = { unabletoRefreshAccessToken: "Unable to refresh access token", unabletToGetUserInfo: "Unable to get user data", unableToGetTaInfo: "Unable to get TA data", + officeHourUserDataError: "Unable to get a user's office hour account", }, notificationController: { messageNotFromTwilio: "Message not from Twilio", diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index ff2d2ad87..0f6d23337 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -7,7 +7,6 @@ import { AccessToken, ERROR_MESSAGES, OAuthTACourseModel, - OAuthUserModel, } from '@koh/common'; import { Body, @@ -31,7 +30,6 @@ import { Connection } from 'typeorm'; import { NonProductionGuard } from '../guards/non-production.guard'; import { LoginCourseService } from './login-course.service'; import axios from 'axios'; -import { request } from 'http'; @Controller() export class LoginController { @@ -49,6 +47,9 @@ export class LoginController { ): Promise { const authCode = body.code; const challenge = body.verifier; + const isSecure = this.configService + .get('DOMAIN') + .startsWith('https://'); const token = axios .post( `http://localhost:8000/api/oauth/token?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&grant_type=authorization_code&redirect_uri=http://localhost:3000/oauth&code=${authCode}&scopes=user.info&scopes=ta.info&scopes=student.courses&verifier=${challenge}`, @@ -58,6 +59,14 @@ export class LoginController { access: token.data.access, refresh: token.data.refresh, }; + res.cookie('oauth_access', tokens.access, { + httpOnly: true, + secure: isSecure, + }); + res.cookie('oauth_refresh', tokens.refresh, { + httpOnly: true, + secure: isSecure, + }); res.json(tokens); return tokens; }) @@ -100,7 +109,12 @@ export class LoginController { async getUser( @Res() res: Response, @Body() body: AccessToken, + @Req() req: Request, ): Promise { + console.log( + '!!!!!!!!!!!!! THE NEXT LINE TO PRINT IS THE COOKIES !!!!!!!!!!', + ); + console.log(req.cookies); let authorizationToken = 'Bearer ' + body.access; let request; try { @@ -138,18 +152,16 @@ export class LoginController { // gets the logging in student list of courses they TA if they are a TA khouryData.ta_courses = await this.getTACourses(authorizationToken); - let user; - try { - user = await this.loginCourseService.addUserFromKhoury(khouryData); - } catch (e) { - Sentry.captureException(e); - console.error( - 'Khoury login threw an exception, the body was ', - khouryData, - ); - throw e; - } - this.createUserToken(user.id, res); + this.signInToOfficeHoursUser(khouryData) + .then((id) => { + this.createUserToken(id, res); + }) + .catch(() => { + throw new HttpException( + ERROR_MESSAGES.loginController.officeHourUserDataError, + HttpStatus.BAD_REQUEST, + ); + }); } // TODO: Remove this endpoint @@ -300,4 +312,18 @@ export class LoginController { const payload = this.jwtService.decode(token) as { userId: number }; this.enter(res, payload.userId); } + + private async signInToOfficeHoursUser( + data: KhouryDataParams, + ): Promise { + let user; + try { + user = await this.loginCourseService.addUserFromKhoury(data); + } catch (e) { + Sentry.captureException(e); + console.error('Khoury login threw an exception, the body was ', data); + throw e; + } + return user.id; + } } diff --git a/packages/server/test/login.integration.ts b/packages/server/test/login.integration.ts index 45bf97abc..d04254632 100644 --- a/packages/server/test/login.integration.ts +++ b/packages/server/test/login.integration.ts @@ -531,4 +531,6 @@ describe('Login Integration', () => { expect(res.get('Set-Cookie')[0]).toContain('auth_token=;'); }); }); + + describe('POST /oauth/tokens', () => {}); }); From 7512fb9e4c6604bda7d7ebde2475caffaf4c59e1 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Tue, 18 May 2021 16:40:19 -0400 Subject: [PATCH 04/14] Started unit testing --- packages/server/src/login/login.controller.ts | 11 +++++++---- packages/server/test/login.integration.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index 0f6d23337..2a96dd9db 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -85,6 +85,9 @@ export class LoginController { @Body() body: RefreshToken, ): Promise { const refreshToken = body.refresh; + const isSecure = this.configService + .get('DOMAIN') + .startsWith('https://'); const token = axios .get( `http://localhost:8000/api/oauth/token/refresh?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&refresh_token=${refreshToken}&grant_type=refresh_token&scopes=user.info&scopes=ta.info&scopes=student.courses`, @@ -93,6 +96,10 @@ export class LoginController { const tokens = { access: token.data.access, }; + res.cookie('oauth_access', tokens.access, { + httpOnly: true, + secure: isSecure, + }); res.json(tokens); return tokens; }) @@ -111,10 +118,6 @@ export class LoginController { @Body() body: AccessToken, @Req() req: Request, ): Promise { - console.log( - '!!!!!!!!!!!!! THE NEXT LINE TO PRINT IS THE COOKIES !!!!!!!!!!', - ); - console.log(req.cookies); let authorizationToken = 'Bearer ' + body.access; let request; try { diff --git a/packages/server/test/login.integration.ts b/packages/server/test/login.integration.ts index d04254632..dc47ecac1 100644 --- a/packages/server/test/login.integration.ts +++ b/packages/server/test/login.integration.ts @@ -532,5 +532,16 @@ describe('Login Integration', () => { }); }); - describe('POST /oauth/tokens', () => {}); + describe('POST /oauth/tokens/refresh', () => { + it('returns a new valid access token if the current one is expired', async () => { + const token = await mockJWT.signAsync({ + scopes: ['user.info'], + user: 'jtavera235', + }); + const body = { + refresh: token, + }; + await supertest().post(`/oauth/tokens/refresh`).send(body).expect(200); + }); + }); }); From 1e0a560cb9b9e06f4f77bbf542003db29d2d0ce9 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Thu, 3 Jun 2021 13:40:45 -0400 Subject: [PATCH 05/14] Added courses implementation --- packages/app/pages/login.tsx | 16 +- packages/app/pages/oauth.tsx | 4 +- packages/common/index.ts | 13 ++ packages/server/src/login/login.controller.ts | 200 ++++++++++-------- 4 files changed, 138 insertions(+), 95 deletions(-) diff --git a/packages/app/pages/login.tsx b/packages/app/pages/login.tsx index 0c2e898a5..ecf724b9e 100644 --- a/packages/app/pages/login.tsx +++ b/packages/app/pages/login.tsx @@ -6,6 +6,13 @@ import { User } from "@koh/common"; import Router from "next/router"; import { useProfile } from "../hooks/useProfile"; import { useSaveStateChallenge } from "../hooks/useSaveStateChallenge"; +import { + KHOURY_ADMIN_OAUTH_URL, + OAUTH_CLIENT_ID, + OAUTH_REDIRECT_URI, + OAUTH_SCOPES, +} from "@koh/common"; + let forge = require("node-forge"); const isWindow = typeof window !== "undefined"; @@ -31,7 +38,14 @@ function openKhouryOAuthLoginPage() { md.update(codeVal); const hashedCodeChallenge: string = md.digest().toHex(); const windowReference = window.open( - "http://localhost:8000/oauth/login?response_type=code&client_id=f7af86112c35ba004b25&redirect_uri=http://localhost:3000/oauth&scopes=ta.info&scopes=user.info&scopes=student.courses&state=" + + KHOURY_ADMIN_OAUTH_URL + + "/login?response_type=code&client_id=" + + OAUTH_CLIENT_ID + + "&redirect_uri=" + + OAUTH_REDIRECT_URI + + "&" + + OAUTH_SCOPES + + "&state=" + stateVal + "&challenge=" + hashedCodeChallenge, diff --git a/packages/app/pages/oauth.tsx b/packages/app/pages/oauth.tsx index 0a6500bed..f66465fbc 100644 --- a/packages/app/pages/oauth.tsx +++ b/packages/app/pages/oauth.tsx @@ -15,8 +15,6 @@ async function signUserIn(request: OAuthAccessTokensRequest): Promise { } catch (err) { return false; } - window.localStorage.setItem("access", tokens.access); - window.localStorage.setItem("refresh", tokens.refresh); let params = { access: tokens.access, }; @@ -32,6 +30,7 @@ async function signUserIn(request: OAuthAccessTokensRequest): Promise { } export default function OAuth(): ReactElement { + console.log("Made it to the OAuth page"); const router = useRouter(); const state = router.query.state; const authCode = router.query.code; @@ -41,7 +40,6 @@ export default function OAuth(): ReactElement { if (state && authCode && isWindow) { const storedState = window.localStorage.getItem("state"); const storedChallenge = window.localStorage.getItem("challenge"); - if (storedState != state) { // if the states are not equal then a CRSF attack may be happening so do not continue with OAuth sign in and return to login // also, may want to notify khoury admin page. An error also could have happened so that is another possibility. diff --git a/packages/common/index.ts b/packages/common/index.ts index b7d98c5e6..2d4ee132b 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -17,6 +17,17 @@ import "reflect-metadata"; export const PROD_URL = "https://officehours.khoury.northeastern.edu"; export const STAGING_URL = "https://staging.khouryofficehours.com"; +export const KHOURY_ADMIN_OAUTH_API_URL = + "https://admin-alpha.khoury.northeastern.edu/api/oauth"; +export const KHOURY_ADMIN_OAUTH_URL = + "https://admin-alpha.khoury.northeastern.edu/oauth"; +export const OAUTH_CLIENT_ID = "37dc7f2ce2c6643aa393"; +export const OAUTH_CLIENT_SECRET = "ZI0TTY9TREP6VGZV"; +export const OAUTH_REDIRECT_URI = "http://localhost:3000/oauth"; +export const OAUTH_SCOPES = + "scopes=user.info&scopes=ta.info&scopes=student.info&scopes=student.courses&scopes=instructor.courses"; + +//export const OAUTH_USER_INFO_URI = ""; // Get domain. works on node and browser const domain = (): string | false => process.env.DOMAIN || @@ -862,6 +873,8 @@ export const ERROR_MESSAGES = { unabletoRefreshAccessToken: "Unable to refresh access token", unabletToGetUserInfo: "Unable to get user data", unableToGetTaInfo: "Unable to get TA data", + unableToGetInstructorCourses: "Unable to get instructor courses", + unableToGetStudentCourses: "Unable to get student courses", officeHourUserDataError: "Unable to get a user's office hour account", }, notificationController: { diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index 2a96dd9db..0a0d70500 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -7,6 +7,11 @@ import { AccessToken, ERROR_MESSAGES, OAuthTACourseModel, + KHOURY_ADMIN_OAUTH_API_URL, + OAUTH_CLIENT_ID, + OAUTH_CLIENT_SECRET, + OAUTH_REDIRECT_URI, + OAUTH_SCOPES, } from '@koh/common'; import { Body, @@ -52,7 +57,16 @@ export class LoginController { .startsWith('https://'); const token = axios .post( - `http://localhost:8000/api/oauth/token?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&grant_type=authorization_code&redirect_uri=http://localhost:3000/oauth&code=${authCode}&scopes=user.info&scopes=ta.info&scopes=student.courses&verifier=${challenge}`, + KHOURY_ADMIN_OAUTH_API_URL + + '/token?client_id=' + + OAUTH_CLIENT_ID + + '&client_secret=' + + OAUTH_CLIENT_SECRET + + '&grant_type=authorization_code&redirect_uri=' + + OAUTH_REDIRECT_URI + + `&code=${authCode}&` + + OAUTH_SCOPES + + `&verifier=${challenge}`, ) .then((token) => { const tokens = { @@ -90,7 +104,8 @@ export class LoginController { .startsWith('https://'); const token = axios .get( - `http://localhost:8000/api/oauth/token/refresh?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&refresh_token=${refreshToken}&grant_type=refresh_token&scopes=user.info&scopes=ta.info&scopes=student.courses`, + KHOURY_ADMIN_OAUTH_API_URL + + `/token/refresh?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&refresh_token=${refreshToken}&grant_type=refresh_token&scopes=user.info&scopes=ta.info&scopes=student.courses`, ) .then((token) => { const tokens = { @@ -122,7 +137,7 @@ export class LoginController { let request; try { request = await axios.get( - `http://localhost:8000/api/oauth/userinfo/read/`, + KHOURY_ADMIN_OAUTH_API_URL + `/userinfo/read/`, { headers: { Authorization: authorizationToken, @@ -130,11 +145,13 @@ export class LoginController { }, ); } catch (err) { + console.error(err); throw new HttpException( ERROR_MESSAGES.loginController.unabletToGetUserInfo, HttpStatus.BAD_REQUEST, ); } + let khouryData = { first_name: request.data.firstname, last_name: request.data.lastname, @@ -149,12 +166,19 @@ export class LoginController { // this is a student signing in so get the students list of courses if (khouryData.accountType.includes('student')) { console.log("Getting student's list of courses"); + khouryData.courses = await this.getStudentCourses(authorizationToken); + khouryData.ta_courses = await this.getTACourses(authorizationToken); + console.log('TA COURSES IS: ' + khouryData.ta_courses); + console.log('STUDENT COURSES IS: ' + khouryData.courses); // Return a student's list of courses + } else if (khouryData.accountType.includes('faculty')) { + console.log("Getting instructor's list of courses"); + khouryData.ta_courses = await this.getInstructorCourses( + authorizationToken, + ); + console.log('INSTRUCTOR COURSES IS: ' + khouryData.ta_courses); } - // gets the logging in student list of courses they TA if they are a TA - khouryData.ta_courses = await this.getTACourses(authorizationToken); - this.signInToOfficeHoursUser(khouryData) .then((id) => { this.createUserToken(id, res); @@ -167,76 +191,6 @@ export class LoginController { }); } - // TODO: Remove this endpoint - @Post('/khoury_login') - async recieveDataFromKhoury( - @Req() req: Request, - @Body() body: KhouryDataParams, - ): Promise { - if (process.env.NODE_ENV === 'production') { - // Check that request has come from Khoury - const parsedRequest = httpSignature.parseRequest(req); - const verifySignature = httpSignature.verifyHMAC( - parsedRequest, - this.configService.get('KHOURY_PRIVATE_KEY'), - ); - if (!verifySignature) { - Sentry.captureMessage('Invalid request signature: ' + parsedRequest); - throw new UnauthorizedException('Invalid request signature'); - } - } - - let user; - try { - user = await this.loginCourseService.addUserFromKhoury(body); - } catch (e) { - Sentry.captureException(e); - console.error('Khoury login threw an exception, the body was ', body); - throw e; - } - - // Create temporary login token to send user to. - const token = await this.jwtService.signAsync( - { userId: user.id }, - { expiresIn: 60 }, - ); - return { - redirect: - this.configService.get('DOMAIN') + `/api/v1/login/entry?token=${token}`, - }; - } - - // TODO: Remove this endpoint - // NOTE: Although the two routes below are on the backend, - // they are meant to be visited by the browser so a cookie can be set - - // This is the real admin entry point - @Get('/login/entry') - async enterFromKhoury( - @Res() res: Response, - @Query('token') token: string, - ): Promise { - const isVerified = await this.jwtService.verifyAsync(token); - - if (!isVerified) { - throw new UnauthorizedException(); - } - - const payload = this.jwtService.decode(token) as { userId: number }; - - this.enter(res, payload.userId); - } - - // This is for login on development only - @Get('/login/dev') - @UseGuards(NonProductionGuard) - async enterFromDev( - @Res() res: Response, - @Query('userId') userId: number, - ): Promise { - this.enter(res, userId); - } - // Set cookie and redirect to proper page private async enter(res: Response, userId: number) { // Expires in 30 days @@ -252,30 +206,45 @@ export class LoginController { .redirect(302, '/'); } - @Get('/logout') - async logout(@Res() res: Response): Promise { - const isSecure = this.configService - .get('DOMAIN') - .startsWith('https://'); - res - .clearCookie('auth_token', { httpOnly: true, secure: isSecure }) - .redirect(302, '/login'); - } - private async getStudentCourses(accessToken: string) { - // TODO: Make a request to get the logged in user's courses + let studentCourseRequest; + let courses = []; + // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? + try { + studentCourseRequest = await axios.get( + KHOURY_ADMIN_OAUTH_API_URL + `/studentcourses/read/`, + { + headers: { + Authorization: accessToken, + }, + }, + ); + } catch (err) { + console.error(err); + throw new HttpException( + ERROR_MESSAGES.loginController.unableToGetStudentCourses, + HttpStatus.BAD_REQUEST, + ); + } + for (const course of studentCourseRequest.data[0].courses) { + courses.push({ + course: course.course, + semester: course.semester, + campus: course.campus, + }); + } + return courses; } private async getTACourses( accessToken: string, ): Promise { let taRequest; - let courses = []; // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? try { taRequest = await axios.get( - `http://localhost:8000/api/oauth/tainfo/read/`, + KHOURY_ADMIN_OAUTH_API_URL + `/tainfo/read/`, { headers: { Authorization: accessToken, @@ -290,7 +259,6 @@ export class LoginController { } if (taRequest.data.is_ta === true) { for (const course of taRequest.data.courses) { - console.log(course); courses.push({ course: course.course, semester: course.semester, @@ -298,7 +266,37 @@ export class LoginController { }); } } - console.log(courses); + return courses; + } + + private async getInstructorCourses( + accessToken: string, + ): Promise { + let instructorRequest; + let courses = []; + // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? + try { + instructorRequest = await axios.get( + KHOURY_ADMIN_OAUTH_API_URL + `/instructorcourses/read/`, + { + headers: { + Authorization: accessToken, + }, + }, + ); + } catch (err) { + throw new HttpException( + ERROR_MESSAGES.loginController.unableToGetInstructorCourses, + HttpStatus.BAD_REQUEST, + ); + } + for (const course of instructorRequest.data.courses) { + courses.push({ + course: course.course, + semester: course.semester, + campus: course.campus, + }); + } return courses; } @@ -329,4 +327,24 @@ export class LoginController { } return user.id; } + + // This is for login on development only + @Get('/login/dev') + @UseGuards(NonProductionGuard) + async enterFromDev( + @Res() res: Response, + @Query('userId') userId: number, + ): Promise { + this.enter(res, userId); + } + + @Get('/logout') + async logout(@Res() res: Response): Promise { + const isSecure = this.configService + .get('DOMAIN') + .startsWith('https://'); + res + .clearCookie('auth_token', { httpOnly: true, secure: isSecure }) + .redirect(302, '/login'); + } } From 90fb128322447974e132dbd83020f3eba2881696 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Thu, 3 Jun 2021 16:25:39 -0400 Subject: [PATCH 06/14] Refactored code --- packages/server/src/login/login.controller.ts | 58 +++++-------------- 1 file changed, 13 insertions(+), 45 deletions(-) diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index 0a0d70500..d0a09b7f3 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -1,6 +1,5 @@ import { KhouryDataParams, - KhouryRedirectResponse, OAuthAccessTokensRequest, OAuthAccessTokensResponse, RefreshToken, @@ -166,15 +165,19 @@ export class LoginController { // this is a student signing in so get the students list of courses if (khouryData.accountType.includes('student')) { console.log("Getting student's list of courses"); - khouryData.courses = await this.getStudentCourses(authorizationToken); + khouryData.courses = await this.getCourses( + authorizationToken, + '/studentcourses/read/', + ); khouryData.ta_courses = await this.getTACourses(authorizationToken); console.log('TA COURSES IS: ' + khouryData.ta_courses); console.log('STUDENT COURSES IS: ' + khouryData.courses); // Return a student's list of courses } else if (khouryData.accountType.includes('faculty')) { console.log("Getting instructor's list of courses"); - khouryData.ta_courses = await this.getInstructorCourses( + khouryData.ta_courses = await this.getCourses( authorizationToken, + '/instructorcourses/read/', ); console.log('INSTRUCTOR COURSES IS: ' + khouryData.ta_courses); } @@ -206,19 +209,15 @@ export class LoginController { .redirect(302, '/'); } - private async getStudentCourses(accessToken: string) { - let studentCourseRequest; + private async getCourses(accessToken: string, url: string) { + let request; let courses = []; - // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? try { - studentCourseRequest = await axios.get( - KHOURY_ADMIN_OAUTH_API_URL + `/studentcourses/read/`, - { - headers: { - Authorization: accessToken, - }, + request = await axios.get(KHOURY_ADMIN_OAUTH_API_URL + url, { + headers: { + Authorization: accessToken, }, - ); + }); } catch (err) { console.error(err); throw new HttpException( @@ -226,7 +225,7 @@ export class LoginController { HttpStatus.BAD_REQUEST, ); } - for (const course of studentCourseRequest.data[0].courses) { + for (const course of request.data.courses) { courses.push({ course: course.course, semester: course.semester, @@ -269,37 +268,6 @@ export class LoginController { return courses; } - private async getInstructorCourses( - accessToken: string, - ): Promise { - let instructorRequest; - let courses = []; - // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? - try { - instructorRequest = await axios.get( - KHOURY_ADMIN_OAUTH_API_URL + `/instructorcourses/read/`, - { - headers: { - Authorization: accessToken, - }, - }, - ); - } catch (err) { - throw new HttpException( - ERROR_MESSAGES.loginController.unableToGetInstructorCourses, - HttpStatus.BAD_REQUEST, - ); - } - for (const course of instructorRequest.data.courses) { - courses.push({ - course: course.course, - semester: course.semester, - campus: course.campus, - }); - } - return courses; - } - private async createUserToken(userId: string, res: Response) { // Create temporary login token to send user to. const token = await this.jwtService.signAsync( From da0c068d089e2bfbfd4803e37b086578c48ae22d Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Wed, 9 Jun 2021 12:25:42 -0400 Subject: [PATCH 07/14] Added error handling for login, works with alpha server --- .../app/components/OAuth/OAuthErrorPage.tsx | 32 +++++++++ packages/app/pages/oauth.tsx | 25 ++++--- packages/common/index.ts | 4 +- .../server/src/login/login-course.service.ts | 71 +++++++++++++------ packages/server/src/login/login.controller.ts | 13 ++-- packages/server/test/login.integration.ts | 16 +---- 6 files changed, 107 insertions(+), 54 deletions(-) create mode 100644 packages/app/components/OAuth/OAuthErrorPage.tsx diff --git a/packages/app/components/OAuth/OAuthErrorPage.tsx b/packages/app/components/OAuth/OAuthErrorPage.tsx new file mode 100644 index 000000000..c29db5ba2 --- /dev/null +++ b/packages/app/components/OAuth/OAuthErrorPage.tsx @@ -0,0 +1,32 @@ +import React, { ReactElement } from "react"; +import styled from "styled-components"; +import { Button } from "antd"; +import Router from "next/router"; + +const Container = styled.div` + height: 80vh; + display: flex; + justify-content: center; + align-items: center; +`; + +const ContentContainer = styled.div` + text-align: center; +`; + +export default function OAuthErrorPage(): ReactElement { + return ( + + +

An error occurred while trying to login. Please try again.

+ +
+
+ ); +} diff --git a/packages/app/pages/oauth.tsx b/packages/app/pages/oauth.tsx index f66465fbc..547a64214 100644 --- a/packages/app/pages/oauth.tsx +++ b/packages/app/pages/oauth.tsx @@ -1,10 +1,11 @@ import { API } from "@koh/api-client"; import { useRouter } from "next/router"; -import { ReactElement } from "react"; +import { ReactElement, useState } from "react"; import { StandardPageContainer } from "../components/common/PageContainer"; import Router from "next/router"; import { OAuthAccessTokensRequest } from "@koh/common"; import { Spin } from "antd"; +import OAuthErrorPage from "../components/OAuth/OAuthErrorPage"; const isWindow = typeof window !== "undefined"; @@ -30,10 +31,10 @@ async function signUserIn(request: OAuthAccessTokensRequest): Promise { } export default function OAuth(): ReactElement { - console.log("Made it to the OAuth page"); const router = useRouter(); const state = router.query.state; const authCode = router.query.code; + let [hasError, setHasError] = useState(false); let tokensRequestBody: OAuthAccessTokensRequest; @@ -49,19 +50,23 @@ export default function OAuth(): ReactElement { code: authCode as string, verifier: storedChallenge, }; - signUserIn(tokensRequestBody).then((result) => { - if (result) { - Router.push("/nocourses"); - } else { - Router.push("/login"); - } - }); + signUserIn(tokensRequestBody) + .then((result) => { + if (result) { + Router.push("/nocourses"); + } else { + setHasError(true); + } + }) + .catch(() => { + setHasError(true); + }); } } return ( - + {hasError ? : } ); } diff --git a/packages/common/index.ts b/packages/common/index.ts index 2d4ee132b..8490921b5 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -21,8 +21,8 @@ export const KHOURY_ADMIN_OAUTH_API_URL = "https://admin-alpha.khoury.northeastern.edu/api/oauth"; export const KHOURY_ADMIN_OAUTH_URL = "https://admin-alpha.khoury.northeastern.edu/oauth"; -export const OAUTH_CLIENT_ID = "37dc7f2ce2c6643aa393"; -export const OAUTH_CLIENT_SECRET = "ZI0TTY9TREP6VGZV"; +export const OAUTH_CLIENT_ID = "08a26d0c2c7ae8c4e166"; +export const OAUTH_CLIENT_SECRET = "0WY0L7CCD6NN9PX5"; export const OAUTH_REDIRECT_URI = "http://localhost:3000/oauth"; export const OAUTH_SCOPES = "scopes=user.info&scopes=ta.info&scopes=student.info&scopes=student.courses&scopes=instructor.courses"; diff --git a/packages/server/src/login/login-course.service.ts b/packages/server/src/login/login-course.service.ts index 615236f92..783164cec 100644 --- a/packages/server/src/login/login-course.service.ts +++ b/packages/server/src/login/login-course.service.ts @@ -5,6 +5,7 @@ import { CourseSectionMappingModel } from 'login/course-section-mapping.entity'; import { UserCourseModel } from 'profile/user-course.entity'; import { UserModel } from 'profile/user.entity'; import { Connection } from 'typeorm'; +import * as Sentry from '@sentry/node'; @Injectable() export class LoginCourseService { @@ -31,31 +32,42 @@ export class LoginCourseService { const userCourses = []; for (const c of info.courses) { + console.log( + 'Mapping current course ' + c.course + ' with section ' + c.section, + ); const course: CourseModel = await this.courseSectionToCourse( c.course, c.section, ); - if (course) { + console.log('Course was mapped to a section'); const userCourse = await this.courseToUserCourse( user.id, course.id, Role.STUDENT, ); userCourses.push(userCourse); + } else { + console.log('Course was not mapped to a section'); } } if (info.ta_courses) { for (const c of info.ta_courses) { + console.log('mapping current TA course ' + c); // Query for all the courses which match the name of the generic course from Khoury - const courseMappings = ( - await CourseSectionMappingModel.find({ - where: { genericCourseName: c.course }, // TODO: Add semester support - relations: ['course'], - }) - ).filter((cm) => cm.course.enabled); - + let courseMappings: CourseSectionMappingModel[]; + try { + courseMappings = ( + await CourseSectionMappingModel.find({ + where: { genericCourseName: c.course }, // TODO: Add semester support + relations: ['course'], + }) + ).filter((cm) => cm.course.enabled); + } catch (err) { + Sentry.captureException(err); + console.error(err); + } for (const courseMapping of courseMappings) { const taCourse = await this.courseToUserCourse( user.id, @@ -66,7 +78,6 @@ export class LoginCourseService { } } } - // Delete "stale" user courses for (const previousCourse of user.courses) { if ( @@ -80,7 +91,6 @@ export class LoginCourseService { } } } - user.courses = userCourses; await user.save(); return user; @@ -90,13 +100,18 @@ export class LoginCourseService { courseName: string, courseSection: number, ): Promise { - const courseSectionModel = ( - await CourseSectionMappingModel.find({ - where: { genericCourseName: courseName, section: courseSection }, - relations: ['course'], - }) - ).find((cm) => cm.course.enabled); - + let courseSectionModel: CourseSectionMappingModel; + try { + courseSectionModel = ( + await CourseSectionMappingModel.find({ + where: { genericCourseName: courseName, section: courseSection }, + relations: ['course'], + }) + ).find((cm) => cm.course.enabled); + } catch (err) { + Sentry.captureException(err); + console.log(err); + } return courseSectionModel?.course; } @@ -111,14 +126,24 @@ export class LoginCourseService { }); if (userCourse && userCourse.override && userCourse.role === role) { userCourse.override = false; - await userCourse.save(); + try { + await userCourse.save(); + } catch (err) { + Sentry.captureException(err); + console.error(err); + } } if (!userCourse) { - userCourse = await UserCourseModel.create({ - userId, - courseId, - role, - }).save(); + try { + userCourse = await UserCourseModel.create({ + userId, + courseId, + role, + }).save(); + } catch (err) { + Sentry.captureException(err); + console.error(err); + } } return userCourse; } diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index d0a09b7f3..f32857c86 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -164,22 +164,17 @@ export class LoginController { // this is a student signing in so get the students list of courses if (khouryData.accountType.includes('student')) { - console.log("Getting student's list of courses"); khouryData.courses = await this.getCourses( authorizationToken, '/studentcourses/read/', ); khouryData.ta_courses = await this.getTACourses(authorizationToken); - console.log('TA COURSES IS: ' + khouryData.ta_courses); - console.log('STUDENT COURSES IS: ' + khouryData.courses); // Return a student's list of courses } else if (khouryData.accountType.includes('faculty')) { - console.log("Getting instructor's list of courses"); khouryData.ta_courses = await this.getCourses( authorizationToken, '/instructorcourses/read/', ); - console.log('INSTRUCTOR COURSES IS: ' + khouryData.ta_courses); } this.signInToOfficeHoursUser(khouryData) @@ -226,10 +221,15 @@ export class LoginController { ); } for (const course of request.data.courses) { + console.log('Course section is ' + course.section); + console.log('Course semester is ' + course.semester); + console.log('Course is ' + course.course); + console.log('Course campus is ' + course.campus); courses.push({ course: course.course, semester: course.semester, campus: course.campus, + section: course.section, }); } return courses; @@ -289,8 +289,9 @@ export class LoginController { try { user = await this.loginCourseService.addUserFromKhoury(data); } catch (e) { - Sentry.captureException(e); + //Sentry.captureException(e); console.error('Khoury login threw an exception, the body was ', data); + console.error(e); throw e; } return user.id; diff --git a/packages/server/test/login.integration.ts b/packages/server/test/login.integration.ts index dc47ecac1..333dc64c3 100644 --- a/packages/server/test/login.integration.ts +++ b/packages/server/test/login.integration.ts @@ -18,6 +18,8 @@ const mockJWT = { decode: (payload) => JSON.parse(payload), }; +/* + describe('Login Integration', () => { const supertest = setupIntegrationTest( LoginModule, @@ -531,17 +533,5 @@ describe('Login Integration', () => { expect(res.get('Set-Cookie')[0]).toContain('auth_token=;'); }); }); - - describe('POST /oauth/tokens/refresh', () => { - it('returns a new valid access token if the current one is expired', async () => { - const token = await mockJWT.signAsync({ - scopes: ['user.info'], - user: 'jtavera235', - }); - const body = { - refresh: token, - }; - await supertest().post(`/oauth/tokens/refresh`).send(body).expect(200); - }); - }); }); +*/ From 087b98e4ad271fc1f5be6680282dca8d60842e2e Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Wed, 16 Jun 2021 11:23:09 -0400 Subject: [PATCH 08/14] Fixed course mappings and started adding error handling to endpoints --- packages/common/index.ts | 48 ++------- packages/server/src/login/login.controller.ts | 100 ++++++++++++------ 2 files changed, 78 insertions(+), 70 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 8490921b5..0e304165f 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -17,17 +17,22 @@ import "reflect-metadata"; export const PROD_URL = "https://officehours.khoury.northeastern.edu"; export const STAGING_URL = "https://staging.khouryofficehours.com"; +// https://admin-alpha.khoury.northeastern.edu +// http://localhost:8000 export const KHOURY_ADMIN_OAUTH_API_URL = "https://admin-alpha.khoury.northeastern.edu/api/oauth"; export const KHOURY_ADMIN_OAUTH_URL = "https://admin-alpha.khoury.northeastern.edu/oauth"; -export const OAUTH_CLIENT_ID = "08a26d0c2c7ae8c4e166"; -export const OAUTH_CLIENT_SECRET = "0WY0L7CCD6NN9PX5"; +// ca9806f9bfcdd3aa446e -> Alpha +// f7af86112c35ba004b25 -> Local +export const OAUTH_CLIENT_ID = "ca9806f9bfcdd3aa446e"; +// O0584IK2L3WW8V5I -> Alpha +// ZJMPI4JXIJRSOG4D -> local +export const OAUTH_CLIENT_SECRET = "O0584IK2L3WW8V5I"; export const OAUTH_REDIRECT_URI = "http://localhost:3000/oauth"; export const OAUTH_SCOPES = "scopes=user.info&scopes=ta.info&scopes=student.info&scopes=student.courses&scopes=instructor.courses"; -//export const OAUTH_USER_INFO_URI = ""; // Get domain. works on node and browser const domain = (): string | false => process.env.DOMAIN || @@ -378,42 +383,6 @@ export class OAuthAccessTokensRequest { verifier!: string; } -// TODO: Remove this model -export class OAuthUserInfoResponse { - @IsString() - firstName!: string; - @IsString() - lastName!: string; - @IsString() - email!: string; - @IsInt() - campus!: number; - @IsArray() - accountType!: string[]; - // add photo url -} - -// TODO: Remove this model -export class OAuthTACourseModel { - @IsString() - course!: string; - - @IsString() - semester!: string; - - @IsString() - campus!: string; -} - -// TODO: Remove this model -export class OAuthTaInfoResponse { - @IsBoolean() - isTa!: boolean; - - @IsArray() - courses!: [OAuthTACourseModel]; -} - export class KhouryDataParams { @IsString() email!: string; @@ -476,6 +445,7 @@ export class KhouryTACourse { instructor!: number; } +// TODO: Get rid of this interface export interface KhouryRedirectResponse { redirect: string; } diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index f32857c86..39c60802f 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -5,7 +5,7 @@ import { RefreshToken, AccessToken, ERROR_MESSAGES, - OAuthTACourseModel, + KhouryTACourse, KHOURY_ADMIN_OAUTH_API_URL, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, @@ -83,7 +83,11 @@ export class LoginController { res.json(tokens); return tokens; }) - .catch(() => { + .catch((e) => { + console.error(e); + Sentry.captureException( + 'Error while getting Access and Refresh tokens: ' + e, + ); throw new HttpException( ERROR_MESSAGES.loginController.unableToGetAccessToken, HttpStatus.BAD_REQUEST, @@ -117,7 +121,9 @@ export class LoginController { res.json(tokens); return tokens; }) - .catch(() => { + .catch((e) => { + console.error(e); + Sentry.captureException('Error while getting Refresh token: ' + e); throw new HttpException( ERROR_MESSAGES.loginController.unabletoRefreshAccessToken, HttpStatus.BAD_REQUEST, @@ -144,7 +150,12 @@ export class LoginController { }, ); } catch (err) { - console.error(err); + console.error( + 'Error while retrieving user data from Khoury server: ' + err, + ); + Sentry.captureException( + 'Error while retrieving user data from Khoury server: ' + err, + ); throw new HttpException( ERROR_MESSAGES.loginController.unabletToGetUserInfo, HttpStatus.BAD_REQUEST, @@ -164,16 +175,20 @@ export class LoginController { // this is a student signing in so get the students list of courses if (khouryData.accountType.includes('student')) { - khouryData.courses = await this.getCourses( + khouryData.courses = await this.getCourses(authorizationToken); + // Get the courses the singing in student TA's for + khouryData.ta_courses = await this.getTACourses( authorizationToken, - '/studentcourses/read/', + `/tainfo/read/`, + true, ); - khouryData.ta_courses = await this.getTACourses(authorizationToken); - // Return a student's list of courses + + // An instructor is signing in, get an instructor's courses and map it as the khoury Data TA courses } else if (khouryData.accountType.includes('faculty')) { - khouryData.ta_courses = await this.getCourses( + khouryData.ta_courses = await this.getTACourses( authorizationToken, '/instructorcourses/read/', + false, ); } @@ -181,7 +196,9 @@ export class LoginController { .then((id) => { this.createUserToken(id, res); }) - .catch(() => { + .catch((err) => { + console.error('Error while signing user in: ' + err); + Sentry.captureException('Error while signing user in: ' + err); throw new HttpException( ERROR_MESSAGES.loginController.officeHourUserDataError, HttpStatus.BAD_REQUEST, @@ -204,32 +221,37 @@ export class LoginController { .redirect(302, '/'); } - private async getCourses(accessToken: string, url: string) { + private async getCourses(accessToken: string) { let request; let courses = []; try { - request = await axios.get(KHOURY_ADMIN_OAUTH_API_URL + url, { - headers: { - Authorization: accessToken, + request = await axios.get( + KHOURY_ADMIN_OAUTH_API_URL + '/studentcourses/read/', + { + headers: { + Authorization: accessToken, + }, }, - }); + ); } catch (err) { - console.error(err); + console.error("Error while getting a student's courses: " + err); + Sentry.captureException( + "Error while getting a student's courses: " + err, + ); throw new HttpException( ERROR_MESSAGES.loginController.unableToGetStudentCourses, HttpStatus.BAD_REQUEST, ); } for (const course of request.data.courses) { - console.log('Course section is ' + course.section); - console.log('Course semester is ' + course.semester); - console.log('Course is ' + course.course); - console.log('Course campus is ' + course.campus); courses.push({ course: course.course, semester: course.semester, campus: course.campus, section: course.section, + crn: course.crn, + title: course.title, + accelerated: course.accelerated, }); } return courses; @@ -237,27 +259,43 @@ export class LoginController { private async getTACourses( accessToken: string, - ): Promise { - let taRequest; + url: string, + isStudent: boolean, + ): Promise { + let request; let courses = []; // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? try { - taRequest = await axios.get( - KHOURY_ADMIN_OAUTH_API_URL + `/tainfo/read/`, - { - headers: { - Authorization: accessToken, - }, + request = await axios.get(KHOURY_ADMIN_OAUTH_API_URL + url, { + headers: { + Authorization: accessToken, }, - ); + }); } catch (err) { + console.error("Error while getting a TA's courses: " + err); + Sentry.captureException("Error while getting a TA's courses: " + err); throw new HttpException( ERROR_MESSAGES.loginController.unableToGetTaInfo, HttpStatus.BAD_REQUEST, ); } - if (taRequest.data.is_ta === true) { - for (const course of taRequest.data.courses) { + if (isStudent) { + /* + The 'request.data.is_ta' has to be a separate check because if the account is a student but they are not a TA we do nothing. + If we did (isStudent && request.data.is_ta == true) then the above scenario would break as the code will go into the else clause + which is meant for ONLY instructors because they do not have the .is_ta check. + */ + if (request.data.is_ta === true) { + for (const course of request.data.courses) { + courses.push({ + course: course.course, + semester: course.semester, + campus: course.campus, + }); + } + } + } else { + for (const course of request.data.courses) { courses.push({ course: course.course, semester: course.semester, From 82b21f7512cba02e7648600c32a7c19e477d5106 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Wed, 16 Jun 2021 12:33:59 -0400 Subject: [PATCH 09/14] Cleaned up code a little bit --- .../server/src/login/login-course.service.ts | 7 ---- packages/server/src/login/login.controller.ts | 39 ++++++++++++------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/server/src/login/login-course.service.ts b/packages/server/src/login/login-course.service.ts index 783164cec..1e03b8488 100644 --- a/packages/server/src/login/login-course.service.ts +++ b/packages/server/src/login/login-course.service.ts @@ -32,29 +32,22 @@ export class LoginCourseService { const userCourses = []; for (const c of info.courses) { - console.log( - 'Mapping current course ' + c.course + ' with section ' + c.section, - ); const course: CourseModel = await this.courseSectionToCourse( c.course, c.section, ); if (course) { - console.log('Course was mapped to a section'); const userCourse = await this.courseToUserCourse( user.id, course.id, Role.STUDENT, ); userCourses.push(userCourse); - } else { - console.log('Course was not mapped to a section'); } } if (info.ta_courses) { for (const c of info.ta_courses) { - console.log('mapping current TA course ' + c); // Query for all the courses which match the name of the generic course from Khoury let courseMappings: CourseSectionMappingModel[]; try { diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index 39c60802f..92d31de83 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -56,16 +56,14 @@ export class LoginController { .startsWith('https://'); const token = axios .post( - KHOURY_ADMIN_OAUTH_API_URL + - '/token?client_id=' + - OAUTH_CLIENT_ID + - '&client_secret=' + - OAUTH_CLIENT_SECRET + - '&grant_type=authorization_code&redirect_uri=' + - OAUTH_REDIRECT_URI + - `&code=${authCode}&` + - OAUTH_SCOPES + - `&verifier=${challenge}`, + `${KHOURY_ADMIN_OAUTH_API_URL}/token? + client_id=${OAUTH_CLIENT_ID} + &client_secret=${OAUTH_CLIENT_SECRET} + &grant_type=authorization_code + &redirect_uri=${OAUTH_REDIRECT_URI} + &code=${authCode} + &${OAUTH_SCOPES} + &verifier=${challenge}`, ) .then((token) => { const tokens = { @@ -105,10 +103,23 @@ export class LoginController { const isSecure = this.configService .get('DOMAIN') .startsWith('https://'); + `${KHOURY_ADMIN_OAUTH_API_URL}/token/refresh? + client_id=${OAUTH_CLIENT_ID} + &refresh_token=${refreshToken} + &client_secret=${OAUTH_CLIENT_SECRET} + &grant_type=refresh_token + &redirect_uri=${OAUTH_REDIRECT_URI} + &${OAUTH_SCOPES}`; + const token = axios .get( - KHOURY_ADMIN_OAUTH_API_URL + - `/token/refresh?client_id=f7af86112c35ba004b25&client_secret=ZJMPI4JXIJRSOG4D&refresh_token=${refreshToken}&grant_type=refresh_token&scopes=user.info&scopes=ta.info&scopes=student.courses`, + `${KHOURY_ADMIN_OAUTH_API_URL}/token/refresh? + client_id=${OAUTH_CLIENT_ID} + &refresh_token=${refreshToken} + &client_secret=${OAUTH_CLIENT_SECRET} + &grant_type=refresh_token + &redirect_uri=${OAUTH_REDIRECT_URI} + &${OAUTH_SCOPES}`, ) .then((token) => { const tokens = { @@ -327,9 +338,11 @@ export class LoginController { try { user = await this.loginCourseService.addUserFromKhoury(data); } catch (e) { - //Sentry.captureException(e); console.error('Khoury login threw an exception, the body was ', data); console.error(e); + Sentry.captureException( + 'Error while performing all the course mappings for the user: ' + e, + ); throw e; } return user.id; From 2b839f799296eb27c2dd86a22f7e41db05c90163 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Wed, 16 Jun 2021 15:38:49 -0400 Subject: [PATCH 10/14] Added error handling --- packages/common/index.ts | 2 ++ packages/server/src/login/login.controller.ts | 34 ++++++++++++++----- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index bc9dc2565..902433ace 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -875,6 +875,8 @@ export const ERROR_MESSAGES = { invalidTempJWTToken: "Error occurred while signing a JWT token", addUserFromKhoury: "Error occurred while translating account from Khoury to Office Hours", + saveUserCourseModel: "Unable to save user course model", + getUserCourseModel: "Unable to get user course model", }, notificationController: { messageNotFromTwilio: "Message not from Twilio", diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index 8fa854068..a652b998d 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -388,8 +388,16 @@ export class LoginController { @Get('self_enroll_courses') async selfEnrollEnabledAnywhere(): Promise { - const courses = await CourseModel.find(); - return { courses: courses.filter((course) => course.selfEnroll) }; + try { + const courses = await CourseModel.find(); + return { courses: courses.filter((course) => course.selfEnroll) }; + } catch (err) { + console.error(err); + throw new HttpException( + ERROR_MESSAGES.loginController.getUserCourseModel, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } @Post('create_self_enroll_override/:id') @@ -419,12 +427,20 @@ export class LoginController { ); } - await UserCourseModel.create({ - userId: user.id, - courseId: courseId, - role: Role.STUDENT, - override: true, - expires: true, - }).save(); + try { + await UserCourseModel.create({ + userId: user.id, + courseId: courseId, + role: Role.STUDENT, + override: true, + expires: true, + }).save(); + } catch (err) { + console.error(err); + throw new HttpException( + ERROR_MESSAGES.loginController.saveUserCourseModel, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } } From 3435f4ea94e0aadc0f6839b62ec70fcd1fb014a3 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Wed, 16 Jun 2021 15:53:18 -0400 Subject: [PATCH 11/14] Added code comments --- packages/server/src/login/login.controller.ts | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index a652b998d..e89c47962 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -53,6 +53,13 @@ export class LoginController { private configService: ConfigService, ) {} + /** + * Gets Access and Refresh tokens for the current user attempting to login using the passed temporary authorization code. + * + * @param res The response obkect + * @param body The request body that contains the access code that will be used to get access and refresh tokens + * @returns A pair value of Access and Refresh tokens + */ @Post('/oauth/tokens') async getAccessTokens( @Res() res: Response, @@ -103,6 +110,13 @@ export class LoginController { return token; } + /** + * Gets a new access token using the paired refresh token. + * + * @param res The response object + * @param body The refresh token being used to request a new access token + * @returns A new access token + */ @Post('/oauth/tokens/refresh') async refreshAccessTokens( @Res() res: Response, @@ -152,6 +166,13 @@ export class LoginController { return token; } + /** + * Gets a user from the Khoury server and maps the user's account/courses into the Office Hours database. + * + * @param res The request object + * @param body The Access token used to get a user's information from the Khoury server + * @param req The request object + */ @Post('/oauth/user') async getUser( @Res() res: Response, @@ -193,9 +214,10 @@ export class LoginController { photo_url: '', }; - // this is a student signing in so get the students list of courses + // This is a student signing in so get the students list of courses if (khouryData.accountType.includes('student')) { khouryData.courses = await this.getCourses(authorizationToken); + // Get the courses the singing in student TA's for khouryData.ta_courses = await this.getTACourses( authorizationToken, @@ -226,6 +248,12 @@ export class LoginController { }); } + /** + * Gets the current student list of courses they are enrolled in + * + * @param accessToken The token used to get the student's current courses + * @returns The list of a student courses + */ private async getCourses(accessToken: string) { let request; let courses = []; @@ -262,6 +290,14 @@ export class LoginController { return courses; } + /** + * Returns the TA courses a student is in or an instructors courses + * + * @param accessToken The token used to access the courses resource + * @param url The URL to make a request to + * @param isStudent Whether the user is a student or an instructor + * @returns A list of TA courses for the student or instructor + */ private async getTACourses( accessToken: string, url: string, @@ -269,7 +305,6 @@ export class LoginController { ): Promise { let request; let courses = []; - // Get the logging in user's ta courses if they are a TA. I think either an instructor or student can have this? try { request = await axios.get(KHOURY_ADMIN_OAUTH_API_URL + url, { headers: { @@ -311,6 +346,12 @@ export class LoginController { return courses; } + /** + * Creates an Office Hour JWT based on the user ID. + * + * @param userId The ID of the logging in user + * @param res The response object + */ private async createUserToken(userId: string, res: Response) { // Create temporary login token to send user to. const token = await this.jwtService.signAsync( @@ -325,6 +366,12 @@ export class LoginController { this.enter(res, payload.userId); } + /** + * Adds the user from the Khoury server to the Office Hours database + * + * @param data The data received from the Khoury server regarding the user + * @returns The ID of the user + */ private async signInToOfficeHoursUser( data: KhouryDataParams, ): Promise { From f86b5f1c2f1be4806ce7f38739d4eac8373f4f1b Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Wed, 16 Jun 2021 15:58:08 -0400 Subject: [PATCH 12/14] Updated page --- packages/app/pages/login.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/pages/login.tsx b/packages/app/pages/login.tsx index ecf724b9e..c506f9b3a 100644 --- a/packages/app/pages/login.tsx +++ b/packages/app/pages/login.tsx @@ -26,7 +26,10 @@ const Container = styled.div` const ContentContainer = styled.div` text-align: center; `; - +/** + * Opens a new window that directs a user to the Khoury OAuth login page and passes in + * the Office Hour client properties used for verification. + */ function openKhouryOAuthLoginPage() { let stateVal = ""; let codeVal = ""; From 9669887a30c694774b8ed89cf40d14ff9cc97978 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Mon, 28 Jun 2021 11:14:04 -0400 Subject: [PATCH 13/14] Finished Outh impl and re-did env var --- packages/app/hooks/useSaveStateChallenge.tsx | 6 +++ packages/common/index.ts | 11 ++-- packages/server/.env.development | 8 ++- packages/server/src/login/login.controller.ts | 23 ++------- packages/server/test/login.integration.ts | 51 +++++-------------- 5 files changed, 34 insertions(+), 65 deletions(-) diff --git a/packages/app/hooks/useSaveStateChallenge.tsx b/packages/app/hooks/useSaveStateChallenge.tsx index dd9468808..b97d74dfb 100644 --- a/packages/app/hooks/useSaveStateChallenge.tsx +++ b/packages/app/hooks/useSaveStateChallenge.tsx @@ -3,6 +3,12 @@ export interface OAuthStateChallenge { challenge: string; } +/** + * + * @param stateLength How long the state value should be + * @param hashLength How long the plaintext value of the challenge should be + * @returns The pair of the state and challenge plaintext value + */ export function useSaveStateChallenge( stateLength: number, hashLength: number diff --git a/packages/common/index.ts b/packages/common/index.ts index 902433ace..40881b04e 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -17,18 +17,13 @@ import "reflect-metadata"; export const PROD_URL = "https://officehours.khoury.northeastern.edu"; export const STAGING_URL = "https://staging.khouryofficehours.com"; -// https://admin-alpha.khoury.northeastern.edu -// http://localhost:8000 + export const KHOURY_ADMIN_OAUTH_API_URL = "https://admin-alpha.khoury.northeastern.edu/api/oauth"; export const KHOURY_ADMIN_OAUTH_URL = "https://admin-alpha.khoury.northeastern.edu/oauth"; -// ca9806f9bfcdd3aa446e -> Alpha -// f7af86112c35ba004b25 -> Local -export const OAUTH_CLIENT_ID = "ca9806f9bfcdd3aa446e"; -// O0584IK2L3WW8V5I -> Alpha -// ZJMPI4JXIJRSOG4D -> local -export const OAUTH_CLIENT_SECRET = "O0584IK2L3WW8V5I"; +export const OAUTH_CLIENT_ID = "10aac8aac0f1f711756f"; +export const OAUTH_CLIENT_SECRET = "ZSLHBL5C5CBP87QL"; export const OAUTH_REDIRECT_URI = "http://localhost:3000/oauth"; export const OAUTH_SCOPES = "scopes=user.info&scopes=ta.info&scopes=student.info&scopes=student.courses&scopes=instructor.courses"; diff --git a/packages/server/.env.development b/packages/server/.env.development index 3d2ee0f2b..d86984a04 100644 --- a/packages/server/.env.development +++ b/packages/server/.env.development @@ -25,4 +25,10 @@ Z4UMR7EOcpfdUE9Hf3m/hs+FUR45uBJeDK1HSFHD8bHKD6kv8FPGfJTotc+2xjJw oYi+1hqp1fIekaxsyQIDAQAB -----END PUBLIC KEY-----' -APPLY_PASSWORD=chungus \ No newline at end of file +APPLY_PASSWORD=chungus +KHOURY_ADMIN_OAUTH_API_URL=https://admin-alpha.khoury.northeastern.edu/api/oauth +KHOURY_ADMIN_OAUTH_URL=https://admin-alpha.khoury.northeastern.edu/oauth +OAUTH_CLIENT_ID=10aac8aac0f1f711756f +OAUTH_CLIENT_SECRET=ZSLHBL5C5CBP87QL +OAUTH_REDIRECT_URI=http://localhost:3000/oauth +OAUTH_SCOPES=scopes=user.info&scopes=ta.info&scopes=student.info&scopes=student.courses&scopes=instructor.courses diff --git a/packages/server/src/login/login.controller.ts b/packages/server/src/login/login.controller.ts index e89c47962..9f8de736c 100644 --- a/packages/server/src/login/login.controller.ts +++ b/packages/server/src/login/login.controller.ts @@ -6,13 +6,13 @@ import { AccessToken, ERROR_MESSAGES, KhouryTACourse, + GetSelfEnrollResponse, + Role, KHOURY_ADMIN_OAUTH_API_URL, OAUTH_CLIENT_ID, - OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_SCOPES, - GetSelfEnrollResponse, - Role, + OAUTH_CLIENT_SECRET, } from '@koh/common'; import { BadRequestException, @@ -72,14 +72,7 @@ export class LoginController { .startsWith('https://'); const token = axios .post( - `${KHOURY_ADMIN_OAUTH_API_URL}/token? - client_id=${OAUTH_CLIENT_ID} - &client_secret=${OAUTH_CLIENT_SECRET} - &grant_type=authorization_code - &redirect_uri=${OAUTH_REDIRECT_URI} - &code=${authCode} - &${OAUTH_SCOPES} - &verifier=${challenge}`, + `${KHOURY_ADMIN_OAUTH_API_URL}/token?client_id=${OAUTH_CLIENT_ID}&client_secret=${OAUTH_CLIENT_SECRET}&grant_type=authorization_code&redirect_uri=${OAUTH_REDIRECT_URI}&code=${authCode}&${OAUTH_SCOPES}&verifier=${challenge}`, ) .then((token) => { const tokens = { @@ -136,13 +129,7 @@ export class LoginController { const token = axios .get( - `${KHOURY_ADMIN_OAUTH_API_URL}/token/refresh? - client_id=${OAUTH_CLIENT_ID} - &refresh_token=${refreshToken} - &client_secret=${OAUTH_CLIENT_SECRET} - &grant_type=refresh_token - &redirect_uri=${OAUTH_REDIRECT_URI} - &${OAUTH_SCOPES}`, + `${KHOURY_ADMIN_OAUTH_API_URL}/token/refresh?client_id=${OAUTH_CLIENT_ID}&refresh_token=${refreshToken}&client_secret=${OAUTH_CLIENT_SECRET}&grant_type=refresh_token&redirect_uri=${OAUTH_REDIRECT_URI}&${OAUTH_SCOPES}`, ) .then((token) => { const tokens = { diff --git a/packages/server/test/login.integration.ts b/packages/server/test/login.integration.ts index e59ce0e1b..37652c277 100644 --- a/packages/server/test/login.integration.ts +++ b/packages/server/test/login.integration.ts @@ -1,7 +1,10 @@ import { Role } from '@koh/common'; import { JwtService } from '@nestjs/jwt'; import { TestingModuleBuilder } from '@nestjs/testing'; +import { LoginCourseService } from 'login/login-course.service'; +import { LoginController } from 'login/login.controller'; import { UserCourseModel } from 'profile/user-course.entity'; +import { Connection, createConnection } from 'typeorm'; import { LoginModule } from '../src/login/login.module'; import { UserModel } from '../src/profile/user.entity'; import { @@ -18,8 +21,6 @@ const mockJWT = { decode: (payload) => JSON.parse(payload), }; -/* - describe('Login Integration', () => { const supertest = setupIntegrationTest( LoginModule, @@ -27,6 +28,15 @@ describe('Login Integration', () => { t.overrideProvider(JwtService).useValue(mockJWT), ); + describe('GET /logout', () => { + it('makes sure logout endpoint is destroying cookies like a mob boss', async () => { + const res = await supertest().get(`/logout`).expect(302); + expect(res.header['location']).toBe('/login'); + expect(res.get('Set-Cookie')[0]).toContain('auth_token=;'); + }); + }); + + /* describe('POST /login/entry', () => { it('request to entry with correct jwt payload works', async () => { const token = await mockJWT.signAsync({ userId: 1 }); @@ -59,34 +69,7 @@ describe('Login Integration', () => { }); }); - describe('POST /khoury_login', () => { - it('creates user and sends back correct redirect', async () => { - const user = await UserModel.findOne({ - where: { email: 'stenzel.w@northeastern.edu' }, - }); - expect(user).toBeUndefined(); - const res = await supertest().post('/khoury_login').send({ - email: 'stenzel.w@northeastern.edu', - campus: 1, - first_name: 'Will', - last_name: 'Stenzel', - photo_url: 'sdf', - courses: [], - ta_courses: [], - }); - - // Expect that the new user has been created - const newUser = await UserModel.findOne({ - where: { email: 'stenzel.w@northeastern.edu' }, - }); - expect(newUser).toBeDefined(); - - // And that the redirect is correct - expect(res.body).toEqual({ - redirect: 'http://localhost:3000/api/v1/login/entry?token={"userId":1}', - }); - }); it('converts husky emails to northeastern emails', async () => { const user = await UserModel.findOne({ @@ -528,13 +511,5 @@ describe('Login Integration', () => { expect(ucms.every((ucm) => ucm.role === Role.PROFESSOR)).toBeTruthy(); }); }); - - describe('GET /logout', () => { - it('makes sure logout endpoint is destroying cookies like a mob boss', async () => { - const res = await supertest().get(`/logout`).expect(302); - expect(res.header['location']).toBe('/login'); - expect(res.get('Set-Cookie')[0]).toContain('auth_token=;'); - }); - }); -}); */ +}); From ea478e55d4a7f10dba4199a049853ceec18064c3 Mon Sep 17 00:00:00 2001 From: Juan Tavera Date: Mon, 28 Jun 2021 11:17:35 -0400 Subject: [PATCH 14/14] Removed dev variables --- packages/common/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/common/index.ts b/packages/common/index.ts index 40881b04e..541e798e5 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -19,14 +19,14 @@ export const PROD_URL = "https://officehours.khoury.northeastern.edu"; export const STAGING_URL = "https://staging.khouryofficehours.com"; export const KHOURY_ADMIN_OAUTH_API_URL = - "https://admin-alpha.khoury.northeastern.edu/api/oauth"; + "https://admin.khoury.northeastern.edu/api/oauth"; export const KHOURY_ADMIN_OAUTH_URL = - "https://admin-alpha.khoury.northeastern.edu/oauth"; -export const OAUTH_CLIENT_ID = "10aac8aac0f1f711756f"; -export const OAUTH_CLIENT_SECRET = "ZSLHBL5C5CBP87QL"; -export const OAUTH_REDIRECT_URI = "http://localhost:3000/oauth"; + "https://admin.khoury.northeastern.edu/oauth"; +export const OAUTH_CLIENT_ID = ""; +export const OAUTH_CLIENT_SECRET = ""; +export const OAUTH_REDIRECT_URI = ""; export const OAUTH_SCOPES = - "scopes=user.info&scopes=ta.info&scopes=student.info&scopes=student.courses&scopes=instructor.courses"; + "scopes=user.info&scopes=ta.info&scopes=student.courses&scopes=instructor.courses"; // Get domain. works on node and browser const domain = (): string | false =>