From ea1e756cde62456e028483190465e8db7b078210 Mon Sep 17 00:00:00 2001 From: realmayus Date: Tue, 8 Oct 2024 18:59:55 +0200 Subject: [PATCH] use deviceId instead of secretId, store deviceId in secret --- common/secret/index.ts | 2 +- common/secret/token.ts | 28 +++++++++++++------ common/user/session.ts | 14 +++++----- graphql/authentication.ts | 23 ++++++++------- graphql/context.ts | 2 +- graphql/me/fields.ts | 5 ---- graphql/me/mutation.ts | 4 +-- graphql/pupil/mutations.ts | 2 +- graphql/secret/mutation.ts | 25 +++++++++++------ graphql/student/mutations.ts | 2 +- .../migration.sql | 2 ++ prisma/schema.prisma | 1 + 12 files changed, 63 insertions(+), 47 deletions(-) create mode 100644 prisma/migrations/20241008145908_track_device_id_in_secret/migration.sql diff --git a/common/secret/index.ts b/common/secret/index.ts index f796dd8d5..78cdc0181 100644 --- a/common/secret/index.ts +++ b/common/secret/index.ts @@ -15,7 +15,7 @@ export async function getSecrets(user: User): Promise { userId: user.userID, OR: [{ expiresAt: null }, { expiresAt: { gte: new Date() } }], }, - select: { createdAt: true, expiresAt: true, id: true, lastUsed: true, type: true, userId: true, description: true }, + select: { createdAt: true, expiresAt: true, id: true, lastUsed: true, type: true, userId: true, description: true, deviceId: true }, }); logger.info(`User(${user.userID}) retrieved ${result.length} secrets`); diff --git a/common/secret/token.ts b/common/secret/token.ts index a47c07e92..bf98adbd3 100644 --- a/common/secret/token.ts +++ b/common/secret/token.ts @@ -8,7 +8,7 @@ import { isDev, isTest, USER_APP_DOMAIN } from '../util/environment'; import { validateEmail } from '../../graphql/validators'; import { Email } from '../notification/types'; import { isEmailAvailable } from '../user/email'; -import { secret_type_enum as SecretType } from '@prisma/client'; +import { secret, secret_type_enum as SecretType } from '@prisma/client'; import { createSecretEmailToken } from './emailToken'; import moment from 'moment'; import { updateUser } from '../user/update'; @@ -25,12 +25,16 @@ export async function revokeToken(user: User | null, id: number) { logger.info(`User(${user?.userID}) revoked token Secret(${id})`); } -// One can revoke any token that is known - i.e. one can also revoke a token if the token was leaked -export async function revokeTokenByToken(token: string): Promise { +export async function getSecretByToken(token: string): Promise { const hash = hashToken(token); - const secret = await prisma.secret.findFirst({ + return await prisma.secret.findFirst({ where: { secret: hash, type: { in: [SecretType.EMAIL_TOKEN, SecretType.TOKEN] } }, }); +} + +// One can revoke any token that is known - i.e. one can also revoke a token if the token was leaked +export async function revokeTokenByToken(token: string): Promise { + const secret = await getSecretByToken(token); if (!secret) { throw new Error(`Secret not found`); } @@ -42,7 +46,12 @@ export async function revokeTokenByToken(token: string): Promise { } // The token returned by this function MAY NEVER be persisted and may only be sent to the user -export async function createToken(user: User, expiresAt: Date | null = null, description: string | null = null): Promise { +export async function createToken( + user: User, + expiresAt: Date | null = null, + description: string | null = null, + deviceId: string | null = null +): Promise { const token = uuid(); const hash = hashToken(token); @@ -54,6 +63,7 @@ export async function createToken(user: User, expiresAt: Date | null = null, des expiresAt, lastUsed: null, description, + deviceId, }, }); @@ -108,7 +118,7 @@ export async function requestToken( await Notification.actionTaken(user, action, { token, redirectTo: redirectTo ?? '', overrideReceiverEmail: newEmail as Email }); } -export async function loginToken(token: string): Promise<{ user: User; secretID: number } | never> { +export async function loginToken(token: string, deviceId: string): Promise<{ user: User; secretID: number } | never> { const secret = await prisma.secret.findFirst({ where: { secret: hashToken(token), @@ -130,10 +140,10 @@ export async function loginToken(token: string): Promise<{ user: User; secretID: // but only expire it soon to not reduce the possibility that eavesdroppers use the token const inOneHour = new Date(); inOneHour.setHours(inOneHour.getHours() + 1); - await prisma.secret.update({ where: { id: secret.id }, data: { expiresAt: inOneHour, lastUsed: new Date() } }); + await prisma.secret.update({ where: { id: secret.id }, data: { expiresAt: inOneHour, lastUsed: new Date(), deviceId } }); logger.info(`User(${user.userID}) logged in with email token Secret(${secret.id}), token will be revoked in one hour`); } else { - await prisma.secret.update({ data: { lastUsed: new Date() }, where: { id: secret.id } }); + await prisma.secret.update({ data: { lastUsed: new Date(), deviceId }, where: { id: secret.id } }); logger.info(`User(${user.userID}) logged in with email token Secret(${secret.id}) it will expire at ${secret.expiresAt.toISOString()}`); } @@ -150,7 +160,7 @@ export async function loginToken(token: string): Promise<{ user: User; secretID: logger.info(`User(${user.userID}) changed their email to ${newEmail} via email token login`); } } else { - await prisma.secret.update({ data: { lastUsed: new Date() }, where: { id: secret.id } }); + await prisma.secret.update({ data: { lastUsed: new Date(), deviceId }, where: { id: secret.id } }); logger.info(`User(${user.userID}) logged in with persistent token Secret(${secret.id})`); } diff --git a/common/user/session.ts b/common/user/session.ts index 1bbab6001..7921ea54c 100644 --- a/common/user/session.ts +++ b/common/user/session.ts @@ -13,7 +13,7 @@ const logger = getLogger('Session'); // As it is persisted in the session, it should only contain commonly accessed fields that are rarely changed export interface GraphQLUser extends User { roles: Role[]; - secretID: number | undefined; // the ID of the secret that was used to create this sessionToken + deviceId: string | undefined; } export const UNAUTHENTICATED_USER = { @@ -26,7 +26,7 @@ export const UNAUTHENTICATED_USER = { roles: [Role.UNAUTHENTICATED], lastLogin: new Date(), active: false, - secretID: undefined, + deviceId: undefined, }; /* As we only have one backend, and there is probably no need to scale in the near future, @@ -74,19 +74,19 @@ export async function updateSessionRolesOfUser(userID: string) { // O(n) // Currently used in session manager to log out all sessions created by a specific device token -export async function deleteSessionsBySecret(secretID: number) { - if (!secretID) { - return; // do nothing if secretID is undefined (can happen through loginLegacy) +export async function deleteSessionsByDevice(deviceId: string) { + if (!deviceId) { + return; // do nothing if deviceId is undefined } const sessionsToDelete = []; for await (const [sessionToken, user] of userSessions.iterator() as AsyncIterable<[string, GraphQLUser]>) { - if (user.secretID === secretID) { + if (user.deviceId === deviceId) { sessionsToDelete.push(sessionToken); } } for (const sessionToken of sessionsToDelete) { await userSessions.delete(sessionToken); - logger.info(`Deleted Session(${sessionToken}) as it was created by Secret(${secretID})`); + logger.info(`Deleted Session(${sessionToken}) as it was created by DeviceId(${deviceId})`); } } diff --git a/graphql/authentication.ts b/graphql/authentication.ts index d68b10eeb..37c88c7bb 100644 --- a/graphql/authentication.ts +++ b/graphql/authentication.ts @@ -21,10 +21,10 @@ export { GraphQLUser, toPublicToken, UNAUTHENTICATED_USER, getUserForSession } f const logger = getLogger('GraphQL Authentication'); -export async function updateSessionUser(context: GraphQLContext, user: User, secretID: number) { +export async function updateSessionUser(context: GraphQLContext, user: User, deviceId: string) { // Only update the session user if the user updated was the user associated to the session (and e.g. not a screener or admin) if (context.user.userID === user.userID) { - await loginAsUser(user, context, secretID); + await loginAsUser(user, context, deviceId); } } @@ -113,12 +113,11 @@ function ensureSession(context: GraphQLContext) { } } -export async function loginAsUser(user: User, context: GraphQLContext, secretID: number) { +export async function loginAsUser(user: User, context: GraphQLContext, deviceId: string) { ensureSession(context); - const roles = await evaluateUserRoles(user); - context.user = { ...user, secretID, roles }; + context.user = { ...user, deviceId, roles }; await userSessions.set(context.sessionToken, context.user); logger.info(`[${context.sessionToken}] User(${user.userID}) successfully logged in`); @@ -170,14 +169,14 @@ export class AuthenticationResolver { @Authorized(Role.UNAUTHENTICATED) @Mutation((returns) => Boolean) - async loginPassword(@Ctx() context: GraphQLContext, @Arg('email') email: string, @Arg('password') password: string) { + async loginPassword(@Ctx() context: GraphQLContext, @Arg('email') email: string, @Arg('password') password: string, @Arg('deviceId') deviceId: string) { email = validateEmail(email); ensureSession(context); try { - const { user, secretID } = await loginPassword(email, password); - await loginAsUser(user, context, secretID); + const { user } = await loginPassword(email, password); + await loginAsUser(user, context, deviceId); if (user.studentId) { await actionTaken(user, 'student_login', {}); @@ -193,10 +192,10 @@ export class AuthenticationResolver { @Authorized(Role.UNAUTHENTICATED) @Mutation((returns) => Boolean) - async loginToken(@Ctx() context: GraphQLContext, @Arg('token') token: string) { + async loginToken(@Ctx() context: GraphQLContext, @Arg('token') token: string, @Arg('deviceId') deviceId: string) { try { - const { user, secretID } = await loginToken(token); - await loginAsUser(user, context, secretID); + const { user } = await loginToken(token, deviceId); + await loginAsUser(user, context, deviceId); if (user.studentId) { await actionTaken(user, 'student_login', {}); } else if (user.pupilId) { @@ -213,7 +212,7 @@ export class AuthenticationResolver { @Mutation((returns) => Boolean) async loginRefresh(@Ctx() context: GraphQLContext) { const sessionUser = getSessionUser(context); - await updateSessionUser(context, sessionUser, sessionUser.secretID); + await updateSessionUser(context, sessionUser, sessionUser.deviceId); return true; } diff --git a/graphql/context.ts b/graphql/context.ts index 3266148ee..b001604f7 100644 --- a/graphql/context.ts +++ b/graphql/context.ts @@ -76,7 +76,7 @@ export default async function injectContext({ req, res }: { req: Request; res: R roles: [Role.ADMIN, Role.UNAUTHENTICATED], lastLogin: new Date(), active: true, - secretID: undefined, + deviceId: undefined, }; context.sessionID = 'ADMIN'; diff --git a/graphql/me/fields.ts b/graphql/me/fields.ts index 6002d13ed..c4d712083 100644 --- a/graphql/me/fields.ts +++ b/graphql/me/fields.ts @@ -17,9 +17,4 @@ export class FieldMeResolver { myRoles(@Ctx() context: GraphQLContext): string[] { return context.user?.roles ?? []; } - - @FieldResolver((type) => Number, { nullable: true }) - myCurrentSecretID(@Ctx() context: GraphQLContext): number { - return context.user.secretID; - } } diff --git a/graphql/me/mutation.ts b/graphql/me/mutation.ts index a5ee6a6b7..c820b6cae 100644 --- a/graphql/me/mutation.ts +++ b/graphql/me/mutation.ts @@ -222,7 +222,7 @@ export class MutateMeResolver { logger.info(`Student(${student.id}) requested to become an instructor`); // User gets the WANNABE_INSTRUCTOR role - await updateSessionUser(context, userForStudent(student), getSessionUser(context).secretID); + await updateSessionUser(context, userForStudent(student), getSessionUser(context).deviceId); // After successful screening and re authentication, the user will receive the INSTRUCTOR role @@ -241,7 +241,7 @@ export class MutateMeResolver { await becomeTutor(student, data); // User gets the WANNABE_TUTOR role - await updateSessionUser(context, userForStudent(student), getSessionUser(context).secretID); + await updateSessionUser(context, userForStudent(student), getSessionUser(context).deviceId); // After successful screening and re authentication, the user will receive the TUTOR role diff --git a/graphql/pupil/mutations.ts b/graphql/pupil/mutations.ts index e0f95613c..5eb8fea17 100644 --- a/graphql/pupil/mutations.ts +++ b/graphql/pupil/mutations.ts @@ -193,7 +193,7 @@ export async function updatePupil( } // The email, firstname or lastname might have changed, so it is a good idea to refresh the session - await updateSessionUser(context, userForPupil(res), getSessionUser(context).secretID); + await updateSessionUser(context, userForPupil(res), getSessionUser(context).deviceId); logger.info(`Pupil(${pupil.id}) updated their account with ${JSON.stringify(update)}`); return res; diff --git a/graphql/secret/mutation.ts b/graphql/secret/mutation.ts index 4f03f5604..3d448b722 100644 --- a/graphql/secret/mutation.ts +++ b/graphql/secret/mutation.ts @@ -1,6 +1,6 @@ import { Secret } from '../generated'; import { Resolver, Mutation, Arg, Authorized, Ctx } from 'type-graphql'; -import { createPassword, createToken, requestToken, revokeToken, revokeTokenByToken } from '../../common/secret'; +import { createPassword, createToken, getSecretByToken, requestToken, revokeToken, revokeTokenByToken } from '../../common/secret'; import { GraphQLContext } from '../context'; import { getSessionUser, isAdmin } from '../authentication'; import { Role } from '../authorizations'; @@ -10,7 +10,8 @@ import { getLogger } from '../../common/logger/logger'; import { UserInputError } from 'apollo-server-express'; import { validateEmail } from '../validators'; import { GraphQLString } from 'graphql'; -import { deleteSessionsBySecret } from '../../common/user/session'; +import { deleteSessionsByDevice } from '../../common/user/session'; +import { prisma } from '../../common/prisma'; const logger = getLogger('MutateSecretResolver'); @@ -18,8 +19,8 @@ const logger = getLogger('MutateSecretResolver'); export class MutateSecretResolver { @Mutation((returns) => String) @Authorized(Role.USER) - async tokenCreate(@Ctx() context: GraphQLContext, @Arg('description', { nullable: true }) description: string | null) { - return await createToken(getSessionUser(context), /* expiresAt */ null, description); + async tokenCreate(@Ctx() context: GraphQLContext, @Arg('description', { nullable: true }) description: string | null, @Arg('deviceId') deviceId: string) { + return await createToken(getSessionUser(context), /* expiresAt */ null, description, deviceId); } @Mutation((returns) => String) @@ -29,7 +30,7 @@ export class MutateSecretResolver { inOneWeek.setDate(inOneWeek.getDate() + 7); const user = await getUser(userId); - const token = await createToken(user, /* expiresAt */ inOneWeek, `Support ${description ?? 'Week Access'}`); + const token = await createToken(user, /* expiresAt */ inOneWeek, `Support ${description ?? 'Week Access'}`, null); logger.info(`Admin/trusted screener created a login token for User(${userId})`); return token; } @@ -58,6 +59,15 @@ export class MutateSecretResolver { @Arg('token', { nullable: true }) token?: string ) { let tokenId = id; + let deviceId = undefined; + if (id) { + deviceId = (await prisma.secret.findUnique({ where: { id: tokenId } })).deviceId; + } else if (token) { + deviceId = (await getSecretByToken(token)).deviceId; + } else { + throw new UserInputError(`Either the id or the token must be passed`); + } + if (id) { if (isAdmin(context)) { await revokeToken(null, id); @@ -66,11 +76,10 @@ export class MutateSecretResolver { } } else if (token) { tokenId = await revokeTokenByToken(token); - } else { - throw new UserInputError(`Either the id or the token must be passed`); } + if (invalidateSessions) { - await deleteSessionsBySecret(tokenId); + await deleteSessionsByDevice(deviceId); } return true; diff --git a/graphql/student/mutations.ts b/graphql/student/mutations.ts index 4c0b9c8f4..bd4a91713 100644 --- a/graphql/student/mutations.ts +++ b/graphql/student/mutations.ts @@ -221,7 +221,7 @@ export async function updateStudent( }); // The email, firstname or lastname might have changed, so it is a good idea to refresh the session - await updateSessionUser(context, userForStudent(res), getSessionUser(context).secretID); + await updateSessionUser(context, userForStudent(res), getSessionUser(context).deviceId); logger.info(`Student(${student.id}) updated their account with ${JSON.stringify(update)}`); return res; diff --git a/prisma/migrations/20241008145908_track_device_id_in_secret/migration.sql b/prisma/migrations/20241008145908_track_device_id_in_secret/migration.sql new file mode 100644 index 000000000..bd4cbc8af --- /dev/null +++ b/prisma/migrations/20241008145908_track_device_id_in_secret/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "secret" ADD COLUMN "deviceId" VARCHAR; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a17be699f..cc1c1a136 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -929,6 +929,7 @@ model secret { // For EMAIL_TOKENs, contains the email the token is sent to. Can be used to confirm additional email addresses // (i.e. during email address change) description String? @db.VarChar + deviceId String? @db.VarChar // the permanent device identifier this secret was created by } // DEPRECATED: Used by our old ORM to track migrations, to be removed once Prisma Migrations work reliably