diff --git a/eslint.config.js b/eslint.config.js index d5ae4fa3..c6a4d5b3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -249,6 +249,8 @@ export default eslintTs.config( "@typescript-eslint/no-misused-promises": "off", // Disabled for performance "@typescript-eslint/no-unsafe-assignment": "off", + // Disabled for performance + "@typescript-eslint/no-base-to-string": "off", "@typescript-eslint/require-await": "error", "@typescript-eslint/return-await": "error", "@typescript-eslint/consistent-type-imports": [ diff --git a/packages/mobile/src/navigation/root/Modals/SplashLoginBackgrounds.tsx b/packages/mobile/src/navigation/root/Modals/SplashLoginBackgrounds.tsx index 4b4c9b9f..a9edfc7b 100644 --- a/packages/mobile/src/navigation/root/Modals/SplashLoginBackgrounds.tsx +++ b/packages/mobile/src/navigation/root/Modals/SplashLoginBackgrounds.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react-refresh/only-export-components */ import random from "lodash/random"; import type { ImageSourcePropType } from "react-native"; diff --git a/packages/mobile/src/theme/components.ts b/packages/mobile/src/theme/components.ts index a3a8deab..0ac552da 100644 --- a/packages/mobile/src/theme/components.ts +++ b/packages/mobile/src/theme/components.ts @@ -1,4 +1,6 @@ +/* eslint-disable */ import type { ComponentTheme, Theme } from "native-base"; +// @ts-expect-error Broken module import originalComponentThemes from "native-base/components"; const { diff --git a/packages/portal/gql/graphql-env.d.ts b/packages/portal/gql/graphql-env.d.ts index 3208e551..1b5c1fe0 100644 --- a/packages/portal/gql/graphql-env.d.ts +++ b/packages/portal/gql/graphql-env.d.ts @@ -190,7 +190,7 @@ export type introspection_types = { 'String': unknown; 'TeamLegacyStatus': { name: 'TeamLegacyStatus'; enumValues: 'DemoTeam' | 'NewTeam' | 'ReturningTeam'; }; 'TeamNode': { kind: 'OBJECT'; name: 'TeamNode'; fields: { 'committeeIdentifier': { name: 'committeeIdentifier'; type: { kind: 'ENUM'; name: 'CommitteeIdentifier'; ofType: null; } }; 'createdAt': { name: 'createdAt'; type: { kind: 'SCALAR'; name: 'LuxonDateTime'; ofType: null; } }; 'fundraisingEntries': { name: 'fundraisingEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ListFundraisingEntriesResponse'; ofType: null; }; } }; 'fundraisingTotalAmount': { name: 'fundraisingTotalAmount'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GlobalId'; ofType: null; }; } }; 'legacyStatus': { name: 'legacyStatus'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamLegacyStatus'; ofType: null; }; } }; 'marathon': { name: 'marathon'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MarathonNode'; ofType: null; }; } }; 'members': { name: 'members'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'MembershipNode'; ofType: null; }; }; }; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'pointEntries': { name: 'pointEntries'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'PointEntryNode'; ofType: null; }; }; }; } }; 'solicitationCode': { name: 'solicitationCode'; type: { kind: 'OBJECT'; name: 'SolicitationCodeNode'; ofType: null; } }; 'totalPoints': { name: 'totalPoints'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'type': { name: 'type'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamType'; ofType: null; }; } }; 'updatedAt': { name: 'updatedAt'; type: { kind: 'SCALAR'; name: 'LuxonDateTime'; ofType: null; } }; }; }; - 'TeamResolverFilterFields': { name: 'TeamResolverFilterFields'; enumValues: 'legacyStatus' | 'marathonYear' | 'name' | 'type'; }; + 'TeamResolverFilterFields': { name: 'TeamResolverFilterFields'; enumValues: 'legacyStatus' | 'marathonYear' | 'name' | 'totalPoints' | 'type'; }; 'TeamResolverFilterGroup': { kind: 'INPUT_OBJECT'; name: 'TeamResolverFilterGroup'; isOneOf: false; inputFields: [{ name: 'children'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'TeamResolverFilterGroup'; ofType: null; }; }; }; }; defaultValue: "[]" }, { name: 'filters'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'TeamResolverFilterItem'; ofType: null; }; }; }; }; defaultValue: "[]" }, { name: 'operator'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'FilterGroupOperator'; ofType: null; }; }; defaultValue: null }]; }; 'TeamResolverFilterItem': { kind: 'INPUT_OBJECT'; name: 'TeamResolverFilterItem'; isOneOf: false; inputFields: [{ name: 'field'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamResolverFilterFields'; ofType: null; }; }; defaultValue: null }, { name: 'filter'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'INPUT_OBJECT'; name: 'SomeFilter'; ofType: null; }; }; defaultValue: null }]; }; 'TeamResolverSearchFilter': { kind: 'INPUT_OBJECT'; name: 'TeamResolverSearchFilter'; isOneOf: false; inputFields: [{ name: 'fields'; type: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'ENUM'; name: 'TeamResolverFilterFields'; ofType: null; }; }; }; defaultValue: null }, { name: 'query'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'NonEmptyString'; ofType: null; }; }; defaultValue: null }]; }; diff --git a/packages/portal/src/config/refine/resources.tsx b/packages/portal/src/config/refine/resources.tsx index 266e57f5..1d34d17d 100644 --- a/packages/portal/src/config/refine/resources.tsx +++ b/packages/portal/src/config/refine/resources.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-base-to-string */ import { BellOutlined, CalendarOutlined, diff --git a/packages/portal/src/elements/components/sider/index.tsx b/packages/portal/src/elements/components/sider/index.tsx index fa766e3d..58ba94f4 100644 --- a/packages/portal/src/elements/components/sider/index.tsx +++ b/packages/portal/src/elements/components/sider/index.tsx @@ -97,7 +97,6 @@ export const Sider: React.FC< }, label: ( <> - {/* eslint-disable-next-line @typescript-eslint/no-base-to-string */} {meta?.label} {!menuCollapsed && isSelected && (
diff --git a/packages/server/prisma/migrations/20250116143217_/migration.sql b/packages/server/prisma/migrations/20250116143217_/migration.sql new file mode 100644 index 00000000..58267c2f --- /dev/null +++ b/packages/server/prisma/migrations/20250116143217_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Session" ALTER COLUMN "personId" DROP NOT NULL; diff --git a/packages/server/prisma/schema/person.prisma b/packages/server/prisma/schema/person.prisma index 54f3c83e..33a185ed 100644 --- a/packages/server/prisma/schema/person.prisma +++ b/packages/server/prisma/schema/person.prisma @@ -69,8 +69,8 @@ model Session { ip String? expiresAt DateTime - personId Int - person Person @relation(fields: [personId], references: [id], onDelete: Cascade) + personId Int? + person Person? @relation(fields: [personId], references: [id], onDelete: Cascade) } enum AuthSource { diff --git a/packages/server/src/jobs/garbageCollectLogins.ts b/packages/server/src/jobs/garbageCollectLogins.ts index bbf487e4..5365abac 100644 --- a/packages/server/src/jobs/garbageCollectLogins.ts +++ b/packages/server/src/jobs/garbageCollectLogins.ts @@ -4,6 +4,7 @@ import { Cron } from "croner"; import { logger } from "#logging/standardLogging.js"; import { JobStateRepository } from "#repositories/JobState.js"; import { LoginFlowRepository } from "#repositories/LoginFlowSession.js"; +import { SessionRepository } from "#repositories/Session.js"; const jobStateRepository = Container.get(JobStateRepository); @@ -25,6 +26,10 @@ export const garbageCollectLoginFlowSessions = new Cron( logger.info("Garbage collecting old login flows"); const loginFlowSessionRepository = Container.get(LoginFlowRepository); await loginFlowSessionRepository.gcOldLoginFlows(); + const sessionRepository = Container.get(SessionRepository); + await sessionRepository + .gcOldSessions() + .promise.then((res) => res.unwrap()); await jobStateRepository.logCompletedJob(garbageCollectLoginFlowSessions); } catch (error) { diff --git a/packages/server/src/lib/auth/context.ts b/packages/server/src/lib/auth/context.ts index 7e8bda38..52d90cc5 100644 --- a/packages/server/src/lib/auth/context.ts +++ b/packages/server/src/lib/auth/context.ts @@ -1,7 +1,7 @@ import type { ContextFunction } from "@apollo/server"; import type { ExpressContextFunctionArgument } from "@apollo/server/express4"; import { Container } from "@freshgum/typedi"; -import { CommitteeRole } from "@prisma/client"; +import { CommitteeRole, type Person, type Session } from "@prisma/client"; import type { AppAbility, AuthorizationContext, @@ -25,11 +25,10 @@ import { superAdminLinkbluesToken } from "#lib/typediTokens.js"; import { personModelToResource } from "#repositories/person/personModelToResource.js"; import { PersonRepository } from "#repositories/person/PersonRepository.js"; -import { parseUserJwt } from "./index.js"; - export interface GraphQLContext extends AuthorizationContext { serverUrl: URL; ability: AppAbility; + session: Session | null; } type UserContext = Partial< @@ -39,28 +38,15 @@ type UserContext = Partial< > >; async function getUserInfo( - userId: string + person: Person ): Promise> { const outputContext: UserContext = {}; const personRepository = Container.get(PersonRepository); - const person = await personRepository.findPersonAndTeamsByUnique({ - uuid: userId, - }); - - if (person.isErr()) { - if (person.error.tag === ErrorCode.NotFound) { - // Short-circuit if the user is not found - return Ok(outputContext); - } - return person; - } // If we found a user, set the authenticated user // Convert the user to a resource and set it on the context - const personResource = await personModelToResource( - person.value, - personRepository - ).promise; + const personResource = await personModelToResource(person, personRepository) + .promise; if (personResource.isErr()) { return personResource; } @@ -70,7 +56,7 @@ async function getUserInfo( // Set the teams the user is on let teamMemberships = await personRepository.findMembershipsOfPerson( { - id: person.value.id, + id: person.id, }, {}, undefined, @@ -96,7 +82,7 @@ async function getUserInfo( // Set the effective committee roles the user has const effectiveCommitteeRoles = await personRepository.getEffectiveCommitteeRolesOfPerson({ - id: person.value.id, + id: person.id, }); if (effectiveCommitteeRoles.isErr()) { return effectiveCommitteeRoles; @@ -128,15 +114,18 @@ export const authenticate: ContextFunction< token = authorizationHeader.substring("Bearer ".length); } } - let userId: string | undefined; + let person: Person | null = null; let authSource: AuthSource = AuthSource.None; if (token) { - ({ userId, authSource } = parseUserJwt(token)); + ({ person, authSource } = req.session ?? { + authSource: AuthSource.None, + person: null, + }); } let userContext: UserContext | undefined = undefined; - if (userId) { - const userInfo = await getUserInfo(userId); + if (person) { + const userInfo = await getUserInfo(person); if (userInfo.isErr()) { logger.error(`Failed to get user info: ${userInfo.error.toString()}`); } else { @@ -163,36 +152,30 @@ export const authenticate: ContextFunction< logger.trace( `graphqlContextFunction Masquerading as ${parsedId.value.id}` ); - // We need to reset the dbRole to the default one in case the masquerade user is not a committee member - const masqueradeUserInfo = await getUserInfo(parsedId.value.id); - if (masqueradeUserInfo.isErr()) { + const person = await req + .getService(PersonRepository) + .findPersonByUnique({ uuid: parsedId.value.id }); + if (person.isErr()) { logger.error( - `Failed to get masquerade user info: ${masqueradeUserInfo.error.toString()}` + `Failed to get masquerade user: ${person.error.toString()}` ); + } else if (person.value.isNone()) { + logger.error(`Failed to get masquerade user: not found`); } else { - userContext = masqueradeUserInfo.value; + const userInfo = await getUserInfo(person.value.value); + if (userInfo.isErr()) { + logger.error( + `Failed to get masquerade user info: ${userInfo.error.toString()}` + ); + } else { + userContext = userInfo.value; + } } superAdmin = false; } } - if ( - superAdmin && - (!userContext?.effectiveCommitteeRoles || - userContext.effectiveCommitteeRoles.length === 0) - ) { - userContext = { - ...userContext, - effectiveCommitteeRoles: [ - { - identifier: CommitteeIdentifier.techCommittee, - role: CommitteeRole.Chair, - }, - ], - }; - } - const authorizationContext: AuthorizationContext = { authenticatedUser: userContext?.authenticatedUser ?? null, effectiveCommitteeRoles: userContext?.effectiveCommitteeRoles ?? [], @@ -215,6 +198,7 @@ export const authenticate: ContextFunction< teamMemberships: authorizationContext.teamMemberships, userId: userContext?.authenticatedUser?.id.id ?? null, }), + session: req.session, }; }; diff --git a/packages/server/src/lib/auth/index.ts b/packages/server/src/lib/auth/index.ts deleted file mode 100644 index 734598de..00000000 --- a/packages/server/src/lib/auth/index.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { Container } from "@freshgum/typedi"; -import type { JwtPayload, UserData } from "@ukdanceblue/common"; -import { AuthSource } from "@ukdanceblue/common"; -import type { Request } from "express"; -import jsonwebtoken from "jsonwebtoken"; - -import { jwtSecretToken } from "#lib/typediTokens.js"; - -const jwtSecret = Container.get(jwtSecretToken); -const jwtIssuer = "https://app.danceblue.org"; - -/** - * @param payload The payload to check - * @return Whether the payload is a valid JWT payload - */ -export function isValidJwtPayload(payload: unknown): payload is JwtPayload { - if (typeof payload !== "object" || payload === null) { - return false; - } - const { sub, auth_source } = payload as Record; - if (sub !== undefined && typeof sub !== "string") { - return false; - } - if ( - ![...Object.values(AuthSource), "UkyLinkblue"].includes( - auth_source as AuthSource - ) - ) { - return false; - } - return true; -} - -/** - * Mints a JWT for the given user data - * - * @param user The user data to mint a JWT for - * @param source The source of the user's authorization - * @return The JWT, containing the user's authorization data - */ -export function makeUserJwt(user: UserData): string { - const payload: JwtPayload = { - auth_source: - // Handle legacy "UkyLinkblue" auth source, can remove eventually - (user.authSource as string) === "UkyLinkblue" - ? AuthSource.LinkBlue - : user.authSource, - }; - - if (user.userId) { - payload.sub = user.userId; - } - - return jsonwebtoken.sign(payload, jwtSecret, { - issuer: jwtIssuer, - // TODO: Set expiration - }); -} - -/** - * Parses a JWT into user data - * - * @param token The JWT to parse - * @return The user data contained in the JWT - */ -export function parseUserJwt(token: string): UserData { - if (!jwtSecret) { - throw new Error("JWT_SECRET is not set"); - } - - const payload = jsonwebtoken.verify(token, jwtSecret, { - issuer: jwtIssuer, - }); - - if (!isValidJwtPayload(payload)) { - throw new Error("Invalid JWT payload"); - } - - const userData: UserData = { - authSource: payload.auth_source, - }; - - if (payload.sub) { - userData.userId = payload.sub; - } - - return userData; -} - -/** - * Parses a JWT from a request - * - * @param req The request to parse the JWT from - * @return The JWT, or undefined if no JWT was found and any error messages - */ -export function tokenFromRequest( - req: Request -): [string | undefined, "invalid-header" | "not-bearer" | "unknown" | null] { - try { - // Prefer cookie - let jsonWebToken: string | undefined = undefined; - const cookies = req.cookies as unknown; - if ( - typeof cookies === "object" && - cookies && - typeof (cookies as { token: unknown }).token === "string" - ) { - jsonWebToken = (cookies as { token: string }).token; - } - - // Fallback to header - const authHeader = req.headers.authorization; - if (authHeader) { - const headerParts = authHeader.split(" "); - if (headerParts.length !== 2) { - return [undefined, "invalid-header"]; - } - const authType = headerParts[0]; - jsonWebToken = headerParts[1]; - - if (authType !== "Bearer") { - return [undefined, "not-bearer"]; - } - } - - return [jsonWebToken, null]; - } catch { - return [undefined, "unknown"]; - } -} - -/** - * Checks the token from a request and returns an message about it's status - * - * @param req The request to check the token from - * @return A message about the status of the token - */ -export function checkTokenFromRequest( - req: Request -): [ - code: undefined | "invalid_request" | "invalid_token" | "no_auth", - description: undefined | string, -] { - const [token, error] = tokenFromRequest(req); - if (error) { - let description: string | undefined = undefined; - switch (error) { - case "invalid-header": { - description = "Invalid Authorization header"; - break; - } - case "not-bearer": { - description = "Authorization header must be a Bearer token"; - break; - } - default: { - break; - } - } - return ["invalid_request", description]; - } - - if (!token) { - return ["no_auth", undefined]; - } - - try { - parseUserJwt(token); - } catch (jwtParseError) { - if (jwtParseError instanceof jsonwebtoken.TokenExpiredError) { - return ["invalid_token", "Token expired"]; - } - if (jwtParseError instanceof jsonwebtoken.NotBeforeError) { - return ["invalid_token", "Token not yet valid"]; - } - if (jwtParseError instanceof jsonwebtoken.JsonWebTokenError) { - return ["invalid_token", "Invalid token"]; - } - return ["invalid_token", "Invalid token"]; - } - - return [undefined, undefined]; -} - -/** - * @deprecated Use the export from @ukdanceblue/common instead - */ -export { defaultAuthorization } from "@ukdanceblue/common"; diff --git a/packages/server/src/repositories/Session.ts b/packages/server/src/repositories/Session.ts index 2f8783d2..e31d6e51 100644 --- a/packages/server/src/repositories/Session.ts +++ b/packages/server/src/repositories/Session.ts @@ -1,5 +1,5 @@ import { type Container, Service } from "@freshgum/typedi"; -import { PrismaClient, Session } from "@prisma/client"; +import { type Person, PrismaClient, Session } from "@prisma/client"; import { AuthSource } from "@ukdanceblue/common"; import { ConcreteError, @@ -9,6 +9,7 @@ import { UnauthenticatedError, } from "@ukdanceblue/common/error"; import { type CookieOptions, Handler } from "express"; +import express from "express"; import jsonwebtoken from "jsonwebtoken"; import { DateTime, Duration } from "luxon"; import { AsyncResult, Err, Ok } from "ts-results-es"; @@ -28,20 +29,22 @@ import { } from "./person/PersonRepository.js"; import { type AsyncRepositoryResult, type RepositoryError } from "./shared.js"; +export const SESSION_LENGTH = Duration.fromObject({ day: 1 }); +const JWT_ISSUER = "https://danceblue.org"; +export const SESSION_COOKIE_NAME = "ukdanceblue_session"; + +export type SessionValue = Session & { person: Person | null }; + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { - session: Session | null; + session: SessionValue | null; getService: typeof Container.get; } } } -export const SESSION_LENGTH = Duration.fromObject({ day: 1 }); -const JWT_ISSUER = "https://danceblue.org"; -const SESSION_COOKIE_NAME = "ukdanceblue_session"; - @Service([prismaToken, jwtSecretToken, isDevelopmentToken, PersonRepository]) export class SessionRepository extends buildDefaultRepository("Session", {}) { constructor( @@ -72,11 +75,11 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) { ip, userAgent, }: { - user: UniquePersonParam; - userAgent: string; - ip: string; + user: UniquePersonParam | null; authSource: AuthSource; - }): AsyncRepositoryResult { + ip?: string; + userAgent?: string; + }): AsyncRepositoryResult { if (authSource === AuthSource.None) { return Err( new InvariantError( @@ -90,9 +93,12 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) { authSource, ip, userAgent, - person: { connect: this.personRepository.uniqueToWhere(user) }, + person: user + ? { connect: this.personRepository.uniqueToWhere(user) } + : undefined, expiresAt: DateTime.now().plus(SESSION_LENGTH).toJSDate(), }, + include: { person: true }, }) ); } @@ -100,7 +106,7 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) { verifySession( token: string, { ip, userAgent }: { ip?: string; userAgent?: string } - ): AsyncRepositoryResult { + ): AsyncRepositoryResult { return new AsyncResult( new Promise((resolve) => { verify( @@ -125,6 +131,7 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) { return this.handleQueryError( this.prisma.session.findUnique({ where: { uuid: decoded }, + include: { person: true }, }) ); }) @@ -169,19 +176,38 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) { ); } - refreshSession(session: Session): AsyncRepositoryResult { + refreshSession(session: Session): AsyncRepositoryResult { return this.handleQueryError( this.prisma.session.update({ where: { uuid: session.uuid }, data: { expiresAt: DateTime.now().plus(SESSION_LENGTH).toJSDate(), }, + include: { person: true }, }) ); } + deleteSession(session: Session): AsyncRepositoryResult { + return this.handleQueryError( + this.prisma.session.delete({ where: { uuid: session.uuid } }) + ).map(() => undefined); + } + + gcOldSessions(): AsyncRepositoryResult { + return this.handleQueryError( + this.prisma.session.deleteMany({ + where: { expiresAt: { lte: new Date() } }, + }) + ).map(() => undefined); + } + get expressMiddleware(): Handler { - return async (req, res, next) => { + return async ( + req: express.Request, + res: express.Response, + next: express.NextFunction + ) => { let token = (req.cookies as Partial>)[ SESSION_COOKIE_NAME ] @@ -213,10 +239,9 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) { userAgent: req.headers["user-agent"], }) .andThen( - (session): AsyncRepositoryResult => { - if (!session) { - return Err(new UnauthenticatedError()).toAsyncResult(); - } + ( + session + ): AsyncRepositoryResult => { req.session = session; return this.refreshSession(session); } @@ -246,4 +271,25 @@ export class SessionRepository extends buildDefaultRepository("Session", {}) { } }; } + + doExpressRedirect( + req: express.Request, + res: express.Response, + jwt: string, + redirectTo: string, + returning: (string | object | undefined)[] + ) { + if (returning.includes("token")) { + redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`; + } + if (returning.includes("cookie")) { + res.cookie(SESSION_COOKIE_NAME, jwt, { + httpOnly: true, + sameSite: req.secure ? "none" : "lax", + secure: req.secure, + expires: DateTime.now().plus(SESSION_LENGTH).toJSDate(), + }); + } + return res.redirect(redirectTo); + } } diff --git a/packages/server/src/resolvers/MarathonHourResolver.ts b/packages/server/src/resolvers/MarathonHourResolver.ts index 6d056812..97651e69 100644 --- a/packages/server/src/resolvers/MarathonHourResolver.ts +++ b/packages/server/src/resolvers/MarathonHourResolver.ts @@ -93,7 +93,9 @@ export class MarathonHourResolver @Arg("marathonUuid", () => GlobalIdScalar) marathonUuid: GlobalId ) { const marathonHour = await this.marathonHourRepository.createMarathonHour({ - ...input, + durationInfo: input.durationInfo, + title: input.title, + details: input.details, marathon: { uuid: marathonUuid.id }, shownStartingAt: input.shownStartingAt.toJSDate(), }); @@ -109,7 +111,9 @@ export class MarathonHourResolver const marathonHour = await this.marathonHourRepository.updateMarathonHour( { uuid: id }, { - ...input, + details: input.details, + durationInfo: input.durationInfo, + title: input.title, shownStartingAt: input.shownStartingAt.toJSDate(), } ); diff --git a/packages/server/src/resolvers/MarathonResolver.ts b/packages/server/src/resolvers/MarathonResolver.ts index 8a650a1c..b792fe6b 100644 --- a/packages/server/src/resolvers/MarathonResolver.ts +++ b/packages/server/src/resolvers/MarathonResolver.ts @@ -168,7 +168,7 @@ export class MarathonResolver async createMarathon(@Arg("input") input: CreateMarathonInput) { return new AsyncResult( this.marathonRepository.createMarathon({ - ...input, + year: input.year, startDate: input.startDate?.toJSDate() ?? null, endDate: input.endDate?.toJSDate() ?? null, }) @@ -189,7 +189,7 @@ export class MarathonResolver const marathon = await this.marathonRepository.updateMarathon( { uuid: id }, { - ...input, + year: input.year, startDate: input.startDate?.toJSDate() ?? null, endDate: input.endDate?.toJSDate() ?? null, } diff --git a/packages/server/src/routes/api/auth/anonymous.ts b/packages/server/src/routes/api/auth/anonymous.ts index fba76fe9..2382c8a5 100644 --- a/packages/server/src/routes/api/auth/anonymous.ts +++ b/packages/server/src/routes/api/auth/anonymous.ts @@ -1,50 +1,46 @@ import { AuthSource } from "@ukdanceblue/common"; import type { NextFunction, Request, Response } from "express"; -import { DateTime } from "luxon"; -import { makeUserJwt } from "#auth/index.js"; +import { SessionRepository } from "#repositories/Session.js"; -export const anonymousLogin = ( +export const anonymousLogin = async ( req: Request, res: Response, next: NextFunction -): void => { +): Promise => { try { let redirectTo = "/"; const queryRedirectTo = Array.isArray(req.query.redirectTo) ? req.query.redirectTo[0] : req.query.redirectTo; + const returning = Array.isArray(req.query.returning) + ? req.query.returning + : [req.query.returning]; if (queryRedirectTo && (queryRedirectTo as string).length > 0) { redirectTo = queryRedirectTo as string; } else { return void res.status(400).send("Missing redirectTo query parameter"); } - let setCookie = false; - let sendToken = false; - const returning = Array.isArray(req.query.returning) - ? req.query.returning - : [req.query.returning]; - if (returning.includes("cookie")) { - setCookie = true; - } - if (returning.includes("token")) { - sendToken = true; - } - - const jwt = makeUserJwt({ - authSource: AuthSource.Anonymous, - }); - if (setCookie) { - res.cookie("token", jwt, { - httpOnly: true, - sameSite: "lax", - expires: DateTime.utc().plus({ weeks: 2 }).toJSDate(), - }); - } - if (sendToken) { - redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`; + const sessionRepository = req.getService(SessionRepository); + const jwt = await sessionRepository + .newSession({ + user: null, + authSource: AuthSource.Demo, + ip: req.ip, + userAgent: req.headers["user-agent"], + }) + .andThen((session) => sessionRepository.signSession(session)).promise; + if (jwt.isErr()) { + next(jwt.error); + } else { + return sessionRepository.doExpressRedirect( + req, + res, + jwt.value, + redirectTo, + returning + ); } - return res.redirect(redirectTo); } catch (error) { res.clearCookie("token"); next(error); diff --git a/packages/server/src/routes/api/auth/demo.ts b/packages/server/src/routes/api/auth/demo.ts index 13aa36e7..17636af3 100644 --- a/packages/server/src/routes/api/auth/demo.ts +++ b/packages/server/src/routes/api/auth/demo.ts @@ -1,9 +1,8 @@ import { AuthSource } from "@ukdanceblue/common"; import type { NextFunction, Request, Response } from "express"; -import { DateTime } from "luxon"; -import { makeUserJwt } from "#auth/index.js"; import { getOrMakeDemoUser } from "#lib/demo.js"; +import { SessionRepository } from "#repositories/Session.js"; export const demoLogin = async ( req: Request, @@ -21,17 +20,9 @@ export const demoLogin = async ( res.status(400); return void res.send("Missing redirectTo query parameter"); } - let setCookie = false; - let sendToken = false; const returning = Array.isArray(req.query.returning) ? req.query.returning : [req.query.returning]; - if (returning.includes("cookie")) { - setCookie = true; - } - if (returning.includes("token")) { - sendToken = true; - } const person = await getOrMakeDemoUser(); if (person.isErr()) { @@ -41,21 +32,28 @@ export const demoLogin = async ( ); } - const jwt = makeUserJwt({ - userId: person.value.uuid, - authSource: AuthSource.Demo, - }); - if (setCookie) { - res.cookie("token", jwt, { - httpOnly: true, - sameSite: "lax", - expires: DateTime.utc().plus({ weeks: 2 }).toJSDate(), - }); - } - if (sendToken) { - redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`; + const sessionRepository = req.getService(SessionRepository); + const jwt = await sessionRepository + .newSession({ + user: { + id: person.value.id, + }, + authSource: AuthSource.Demo, + ip: req.ip, + userAgent: req.headers["user-agent"], + }) + .andThen((session) => sessionRepository.signSession(session)).promise; + if (jwt.isErr()) { + next(jwt.error); + } else { + return sessionRepository.doExpressRedirect( + req, + res, + jwt.value, + redirectTo, + returning + ); } - return res.redirect(redirectTo); } catch (error) { res.clearCookie("token"); next(error); diff --git a/packages/server/src/routes/api/auth/index.ts b/packages/server/src/routes/api/auth/index.ts index 4bbb3f4c..4f9f755f 100644 --- a/packages/server/src/routes/api/auth/index.ts +++ b/packages/server/src/routes/api/auth/index.ts @@ -1,6 +1,10 @@ import { Service } from "@freshgum/typedi"; import { RequestHandler, urlencoded } from "express"; +import { + SESSION_COOKIE_NAME, + SessionRepository, +} from "#repositories/Session.js"; import { RouterService } from "#routes/RouteService.js"; import { anonymousLogin } from "./anonymous.js"; @@ -8,13 +12,12 @@ import { demoLogin } from "./demo.js"; import { login } from "./login.js"; import { oidcCallback } from "./oidcCallback.js"; -// TODO: Replace custom OAuth2 + middleware implementation with Passport.js and oauth2orize -// https://www.passportjs.org -// https://github.com/jaredhanson/oauth2orize - -const logout: RequestHandler = (req, res, next) => { +const logout: RequestHandler = async (req, res, next) => { + if (req.session) { + await req.getService(SessionRepository).deleteSession(req.session).promise; + } try { - res.clearCookie("token"); + res.clearCookie(SESSION_COOKIE_NAME); let redirectTo = "/"; const queryRedirectTo = Array.isArray(req.query.redirectTo) diff --git a/packages/server/src/routes/api/auth/login.ts b/packages/server/src/routes/api/auth/login.ts index 673bbac7..5670c602 100644 --- a/packages/server/src/routes/api/auth/login.ts +++ b/packages/server/src/routes/api/auth/login.ts @@ -1,19 +1,17 @@ -import { Container } from "@freshgum/typedi"; import { AuthSource } from "@ukdanceblue/common"; import { ErrorCode } from "@ukdanceblue/common/error"; import type { NextFunction, Request, Response } from "express"; -import { DateTime } from "luxon"; import { buildAuthorizationUrl, calculatePKCECodeChallenge, } from "openid-client"; import { AsyncResult } from "ts-results-es"; -import { makeUserJwt } from "#auth/index.js"; import { getHostUrl } from "#lib/host.js"; import { LoginFlowRepository } from "#repositories/LoginFlowSession.js"; import { personModelToResource } from "#repositories/person/personModelToResource.js"; import { PersonRepository } from "#repositories/person/PersonRepository.js"; +import { SessionRepository } from "#repositories/Session.js"; import { oidcConfiguration } from "./oidcClient.js"; @@ -35,10 +33,10 @@ export const login = async ( next: NextFunction ): Promise => { try { - const loginFlowSessionRepository = Container.get(LoginFlowRepository); + const loginFlowSessionRepository = req.getService(LoginFlowRepository); - const queryRedirectTo = getStringQueryParameter(req, "redirectTo"); - if (!queryRedirectTo) { + const redirectTo = getStringQueryParameter(req, "redirectTo"); + if (!redirectTo) { return void res.status(400).send("Missing redirectTo query parameter"); } @@ -54,7 +52,8 @@ export const login = async ( return void res.status(405).send("Method Not Allowed"); } - const personRepository = Container.get(PersonRepository); + const personRepository = req.getService(PersonRepository); + const sessionRepository = req.getService(SessionRepository); const person = await new AsyncResult( personRepository.passwordLogin(email, password) ).andThen((person) => personModelToResource(person, personRepository)) @@ -66,28 +65,32 @@ export const login = async ( ? void res.status(401).send("Invalid email or password") : void res.sendStatus(500); } else { - const jwt = makeUserJwt({ - authSource: AuthSource.Password, - userId: person.value.id.id, - }); - let redirectTo = queryRedirectTo; - if (returning.includes("token")) { - redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`; + const jwt = await sessionRepository + .newSession({ + user: { + uuid: person.value.id.id, + }, + authSource: AuthSource.Password, + ip: req.ip, + userAgent: req.headers["user-agent"], + }) + .andThen((session) => sessionRepository.signSession(session)).promise; + if (jwt.isErr()) { + next(jwt.error); + } else { + return sessionRepository.doExpressRedirect( + req, + res, + jwt.value, + redirectTo, + returning + ); } - if (returning.includes("cookie")) { - res.cookie("token", jwt, { - httpOnly: true, - sameSite: req.secure ? "none" : "lax", - secure: req.secure, - expires: DateTime.now().plus({ days: 7 }).toJSDate(), - }); - } - return res.redirect(redirectTo); } } else { // OIDC login const session = await loginFlowSessionRepository.startLoginFlow({ - redirectToAfterLogin: queryRedirectTo, + redirectToAfterLogin: redirectTo, setCookie: returning.includes("cookie"), sendToken: returning.includes("token"), }); diff --git a/packages/server/src/routes/api/auth/oidcCallback.ts b/packages/server/src/routes/api/auth/oidcCallback.ts index 139d2927..845c9445 100644 --- a/packages/server/src/routes/api/auth/oidcCallback.ts +++ b/packages/server/src/routes/api/auth/oidcCallback.ts @@ -1,16 +1,18 @@ -import { Container } from "@freshgum/typedi"; -import { AuthSource } from "@ukdanceblue/common"; +import { AuthSource, type PersonNode } from "@ukdanceblue/common"; +import { + type ConcreteError, + InvalidArgumentError, +} from "@ukdanceblue/common/error"; import type { NextFunction, Request, Response } from "express"; import jsonwebtoken from "jsonwebtoken"; -import { DateTime } from "luxon"; -import { authorizationCodeGrant } from "openid-client"; +import { authorizationCodeGrant, type JsonValue } from "openid-client"; +import { Err, type Result } from "ts-results-es"; -import { makeUserJwt } from "#auth/index.js"; import { getHostUrl } from "#lib/host.js"; -import { logger } from "#lib/logging/standardLogging.js"; import { LoginFlowRepository } from "#repositories/LoginFlowSession.js"; import { personModelToResource } from "#repositories/person/personModelToResource.js"; import { PersonRepository } from "#repositories/person/PersonRepository.js"; +import { SessionRepository } from "#repositories/Session.js"; import { oidcConfiguration } from "./oidcClient.js"; @@ -21,8 +23,9 @@ export const oidcCallback = async ( ): Promise => { let sessionDeleted = true; - const personRepository = Container.get(PersonRepository); - const loginFlowSessionRepository = Container.get(LoginFlowRepository); + const personRepository = req.getService(PersonRepository); + const loginFlowRepository = req.getService(LoginFlowRepository); + const sessionRepository = req.getService(SessionRepository); let flowSessionId; try { @@ -30,22 +33,20 @@ export const oidcCallback = async ( // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access flowSessionId = req.body.state; } else { - throw new Error("Missing state parameter"); + return void res.status(400).json({ message: "Missing state parameter" }); } - if (!flowSessionId) { - return void res.status(400).send("Missing state parameter"); + return void res.status(400).json({ message: "Missing state parameter" }); } sessionDeleted = false; - const session = - await loginFlowSessionRepository.findLoginFlowSessionByUnique({ - uuid: flowSessionId, - }); - if (!session?.codeVerifier) { + const loginFlow = await loginFlowRepository.findLoginFlowSessionByUnique({ + uuid: flowSessionId, + }); + if (!loginFlow?.codeVerifier) { throw new Error( - `No ${session == null ? "session" : "codeVerifier"} found` + `No ${loginFlow == null ? "session" : "codeVerifier"} found` ); } const query = new URLSearchParams( @@ -60,7 +61,7 @@ export const oidcCallback = async ( let tokenSet; try { tokenSet = await authorizationCodeGrant(oidcConfiguration, currentUrl, { - pkceCodeVerifier: session.codeVerifier, + pkceCodeVerifier: loginFlow.codeVerifier, expectedState: flowSessionId, idTokenExpected: true, }); @@ -75,7 +76,7 @@ export const oidcCallback = async ( throw error; } // Destroy the session - await loginFlowSessionRepository.completeLoginFlow({ + await loginFlowRepository.completeLoginFlow({ uuid: flowSessionId, }); sessionDeleted = true; @@ -93,123 +94,134 @@ export const oidcCallback = async ( if (!decodedJwt) { throw new Error("Error decoding JWT"); } - const { - given_name: firstName, - family_name: lastName, - upn: userPrincipalName, - } = decodedJwt; - let linkblue: null | string = null; - if ( - typeof userPrincipalName === "string" && - userPrincipalName.endsWith("@uky.edu") - ) { - linkblue = userPrincipalName.replace(/@uky\.edu$/, "").toLowerCase(); - } - if (typeof objectId !== "string") { - return void res.status(500).send("Missing OID"); - } - const findPersonForLoginResult = await personRepository.findPersonForLogin( - [[AuthSource.LinkBlue, objectId]], - { email: String(email), linkblue } - ); - - if (findPersonForLoginResult.isErr()) { - return void res - .status(500) - .send( - findPersonForLoginResult.error.expose - ? findPersonForLoginResult.error.message - : "Error finding person" - ); - } - const { currentPerson } = findPersonForLoginResult.value; - - if ( - !currentPerson.authIdPairs.some( - ({ source, value }) => - source === AuthSource.LinkBlue && value === objectId - ) - ) { - currentPerson.authIdPairs = [ - ...currentPerson.authIdPairs, - { - personId: currentPerson.id, - source: AuthSource.LinkBlue, - value: objectId, - }, - ]; - } - if (email && currentPerson.email !== email && typeof email === "string") { - currentPerson.email = email; - } - if (typeof firstName === "string" && typeof lastName === "string") { - const name = `${firstName} ${lastName}`; - if (currentPerson.name !== name) { - currentPerson.name = name; - } - } - if (linkblue && currentPerson.linkblue !== linkblue) { - currentPerson.linkblue = linkblue.toLowerCase(); - } - const updatedPerson = await personRepository.updatePerson( - { id: currentPerson.id }, - { - name: currentPerson.name, - email: currentPerson.email, - linkblue: currentPerson.linkblue?.toLowerCase(), - authIds: currentPerson.authIdPairs.map((a) => ({ - source: a.source, - value: a.value, - })), - } + const personNodeResult = await getPersonFromAzureJwt( + decodedJwt, + objectId, + email, + personRepository ); - if (updatedPerson.isErr()) { - logger.error("Failed to update database entry", { - error: updatedPerson.error, - }); - return void res.status(500).send("Failed to update database entry"); - } + if (personNodeResult.isErr()) { + next(personNodeResult.error); + } else { + const jwt = await sessionRepository + .newSession({ + user: { + uuid: personNodeResult.value.id.id, + }, + authSource: AuthSource.LinkBlue, + ip: req.ip, + userAgent: req.headers["user-agent"], + }) + .andThen((session) => sessionRepository.signSession(session)).promise; - const personNode = await personModelToResource( - updatedPerson.value, - personRepository - ).promise; - if (personNode.isErr()) { - return void res - .status(500) - .send( - personNode.error.expose - ? personNode.error.message - : "Error creating person node" + if (jwt.isErr()) { + next(jwt.error); + } else { + return sessionRepository.doExpressRedirect( + req, + res, + jwt.value, + loginFlow.redirectToAfterLogin, + [ + loginFlow.sendToken ? "token" : undefined, + loginFlow.setCookie ? "cookie" : undefined, + ] ); + } } - const jwt = makeUserJwt({ - userId: personNode.value.id.id, - authSource: AuthSource.LinkBlue, - }); - let redirectTo = session.redirectToAfterLogin; - if (session.sendToken) { - redirectTo = `${redirectTo}?token=${encodeURIComponent(jwt)}`; - } - if (session.setCookie) { - res.cookie("token", jwt, { - httpOnly: true, - sameSite: req.secure ? "none" : "lax", - secure: req.secure, - expires: DateTime.now().plus({ days: 7 }).toJSDate(), - }); - } - return res.redirect(redirectTo); } catch (error) { res.clearCookie("token"); next(error); } finally { if (!sessionDeleted) { - await loginFlowSessionRepository.completeLoginFlow({ + await loginFlowRepository.completeLoginFlow({ uuid: flowSessionId, }); } } }; + +async function getPersonFromAzureJwt( + decodedJwt: jsonwebtoken.JwtPayload, + objectId: JsonValue | undefined, + email: JsonValue | undefined, + personRepository: PersonRepository +): Promise> { + const { + given_name: firstName, + family_name: lastName, + upn: userPrincipalName, + } = decodedJwt; + let linkblue: null | string = null; + if ( + typeof userPrincipalName === "string" && + userPrincipalName.endsWith("@uky.edu") + ) { + linkblue = userPrincipalName.replace(/@uky\.edu$/, "").toLowerCase(); + } + if (typeof objectId !== "string") { + return Err(new InvalidArgumentError("Missing OID in JWT")); + } + const findPersonForLoginResult = await personRepository.findPersonForLogin( + [[AuthSource.LinkBlue, objectId]], + { email: String(email), linkblue } + ); + + if (findPersonForLoginResult.isErr()) { + return findPersonForLoginResult; + } + const { currentPerson } = findPersonForLoginResult.value; + + if ( + !currentPerson.authIdPairs.some( + ({ source, value }) => + source === AuthSource.LinkBlue && value === objectId + ) + ) { + currentPerson.authIdPairs = [ + ...currentPerson.authIdPairs, + { + personId: currentPerson.id, + source: AuthSource.LinkBlue, + value: objectId, + }, + ]; + } + if (email && currentPerson.email !== email && typeof email === "string") { + currentPerson.email = email; + } + if (typeof firstName === "string" && typeof lastName === "string") { + const name = `${firstName} ${lastName}`; + if (currentPerson.name !== name) { + currentPerson.name = name; + } + } + if (linkblue && currentPerson.linkblue !== linkblue) { + currentPerson.linkblue = linkblue.toLowerCase(); + } + + const updatedPerson = await personRepository.updatePerson( + { id: currentPerson.id }, + { + name: currentPerson.name, + email: currentPerson.email, + linkblue: currentPerson.linkblue?.toLowerCase(), + authIds: currentPerson.authIdPairs.map((a) => ({ + source: a.source, + value: a.value, + })), + } + ); + + if (updatedPerson.isErr()) { + return updatedPerson; + } + + const personNode = await personModelToResource( + updatedPerson.value, + personRepository + ).promise; + return personNode; +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 9d87e554..f6b7827b 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -15,12 +15,7 @@ import { setupExpressErrorHandler } from "@sentry/node"; import { ConcreteError, ErrorCode } from "@ukdanceblue/common/error"; import cookieParser from "cookie-parser"; import cors from "cors"; -import type { - Application as ExpressApplication, - NextFunction, - Request, - Response, -} from "express"; +import type { Application as ExpressApplication } from "express"; import express from "express"; import type { GraphQLContext } from "#auth/context.js"; @@ -31,7 +26,6 @@ import { loggingLevelToken, } from "#lib/typediTokens.js"; import { logger } from "#logging/logger.js"; -import { SessionRepository } from "#repositories/Session.js"; import { mountPortal } from "./portal.js"; @@ -79,33 +73,6 @@ export async function createServer() { next(); }); - setupExpressErrorHandler(app, { - shouldHandleError(error) { - if ( - error instanceof ConcreteError && - [ - ErrorCode.AccessControlError, - ErrorCode.AuthorizationRuleFailed, - ErrorCode.NotFound, - ErrorCode.Unauthenticated, - ].includes(error.tag) - ) { - return false; - } - return true; - }, - }); - - if (loggingLevel === "trace") { - app.use((err: unknown, req: Request, _: unknown, next: NextFunction) => { - logger.error("Koa app error", err, { - method: req.method, - url: req.url, - }); - next(err); - }); - } - const httpServer = http.createServer(app); const apolloServerPlugins = [ @@ -171,7 +138,11 @@ export async function startServer( apolloServer: ApolloServer, app: ExpressApplication ) { + const { formatError } = await import("#lib/formatError.js"); + const { SessionRepository } = await import("#repositories/Session.js"); + const cookieSecret = Container.get(cookieSecretToken); + const isDev = Container.get(isDevelopmentToken); await apolloServer.start(); @@ -219,20 +190,7 @@ export async function startServer( Container.get(fileRouter).mount(apiRouter); Container.get(uploadRouter).mount(apiRouter); - app.use( - "/api", - apiRouter, - (err: unknown, _: Request, res: Response, next: NextFunction) => { - if (typeof err === "object" && err !== null && "message" in err) { - const { message, ...errorData } = err; - logger.error(String(message), { error: errorData }); - res.status(500).send(String(message)); - } else { - logger.error("Unknown Error in API", { error: err }); - next(err); - } - } - ); + app.use("/api", apiRouter); if (process.env.SSR) { logger.warning( @@ -276,4 +234,50 @@ export async function startServer( }); } } + + setupExpressErrorHandler(app, { + shouldHandleError(error) { + if ( + error instanceof ConcreteError && + [ + ErrorCode.AccessControlError, + ErrorCode.AuthorizationRuleFailed, + ErrorCode.NotFound, + ErrorCode.Unauthenticated, + ].includes(error.tag) + ) { + return false; + } + return true; + }, + }); + + app.use( + ( + err: unknown, + _r: express.Request, + res: express.Response, + _n: express.NextFunction + ) => { + const formatted = formatError( + err instanceof Error + ? err + : err instanceof ConcreteError + ? err.graphQlError + : new Error(String(err)), + err, + isDev + ); + if ( + formatted.extensions && + "code" in formatted.extensions && + formatted.extensions.code === ErrorCode.Unauthenticated.description + ) { + res.status(401).json(formatted); + } else { + logger.error("Unhandled error in Express", { error: formatted }); + res.status(500).json(formatted); + } + } + ); }