Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Session manager #1133

Merged
merged 14 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion common/secret/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function getSecrets(user: User): Promise<object[]> {
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`);
Expand Down
29 changes: 20 additions & 9 deletions common/secret/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,23 +25,33 @@ 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) {
export async function getSecretByToken(token: string): Promise<secret> {
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<number> {
const secret = await getSecretByToken(token);
if (!secret) {
throw new Error(`Secret not found`);
}

await prisma.secret.delete({ where: { id: secret.id } });

logger.info(`Token Secret(${secret.id}) was revoked`);
return secret.id;
}

// 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<string> {
export async function createToken(
user: User,
expiresAt: Date | null = null,
description: string | null = null,
deviceId: string | null = null
): Promise<string> {
const token = uuid();
const hash = hashToken(token);

Expand All @@ -53,6 +63,7 @@ export async function createToken(user: User, expiresAt: Date | null = null, des
expiresAt,
lastUsed: null,
description,
deviceId,
},
});

Expand Down Expand Up @@ -107,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 | never> {
export async function loginToken(token: string, deviceId: string): Promise<User | never> {
const secret = await prisma.secret.findFirst({
where: {
secret: hashToken(token),
Expand All @@ -129,10 +140,10 @@ export async function loginToken(token: string): Promise<User | never> {
// 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()}`);
}

Expand All @@ -149,7 +160,7 @@ export async function loginToken(token: string): Promise<User | never> {
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})`);
}

Expand Down
21 changes: 21 additions & 0 deletions common/user/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +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[];
deviceId: string | undefined;
}

export const UNAUTHENTICATED_USER = {
Expand All @@ -25,6 +26,7 @@ export const UNAUTHENTICATED_USER = {
roles: [Role.UNAUTHENTICATED],
lastLogin: new Date(),
active: false,
deviceId: undefined,
};

/* As we only have one backend, and there is probably no need to scale in the near future,
Expand Down Expand Up @@ -69,3 +71,22 @@ 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 deleteSessionsByDevice(deviceId: string) {
realmayus marked this conversation as resolved.
Show resolved Hide resolved
if (!deviceId) {
return; // do nothing if deviceId is undefined
realmayus marked this conversation as resolved.
Show resolved Hide resolved
}
const sessionsToDelete = [];
for await (const [sessionToken, user] of userSessions.iterator() as AsyncIterable<[string, GraphQLUser]>) {
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 DeviceId(${deviceId})`);
}
}
24 changes: 12 additions & 12 deletions graphql/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ export { GraphQLUser, toPublicToken, UNAUTHENTICATED_USER, getUserForSession } f

const logger = getLogger('GraphQL Authentication');

export async function updateSessionUser(context: GraphQLContext, user: User) {
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);
await loginAsUser(user, context, deviceId);
}
}

Expand Down Expand Up @@ -113,12 +113,11 @@ function ensureSession(context: GraphQLContext) {
}
}

export async function loginAsUser(user: User, context: GraphQLContext) {
export async function loginAsUser(user: User, context: GraphQLContext, deviceId: string) {
ensureSession(context);

const roles = await evaluateUserRoles(user);

context.user = { ...user, roles };
context.user = { ...user, deviceId, roles };

await userSessions.set(context.sessionToken, context.user);
logger.info(`[${context.sessionToken}] User(${user.userID}) successfully logged in`);
Expand Down Expand Up @@ -155,7 +154,7 @@ export class AuthenticationResolver {
throw new AuthenticationError('Invalid email or password');
}

await loginAsUser(userForScreener(screener), context);
await loginAsUser(userForScreener(screener), context, undefined);

return true;
}
Expand All @@ -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) {
realmayus marked this conversation as resolved.
Show resolved Hide resolved
email = validateEmail(email);

ensureSession(context);

try {
const user = await loginPassword(email, password);
await loginAsUser(user, context);
await loginAsUser(user, context, deviceId);

if (user.studentId) {
await actionTaken(user, 'student_login', {});
Expand All @@ -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 = await loginToken(token);
await loginAsUser(user, context);
const user = await loginToken(token, deviceId);
await loginAsUser(user, context, deviceId);
if (user.studentId) {
await actionTaken(user, 'student_login', {});
} else if (user.pupilId) {
Expand All @@ -212,7 +211,8 @@ export class AuthenticationResolver {
@Authorized(Role.USER)
@Mutation((returns) => Boolean)
async loginRefresh(@Ctx() context: GraphQLContext) {
await updateSessionUser(context, getSessionUser(context));
const sessionUser = getSessionUser(context);
await updateSessionUser(context, sessionUser, sessionUser.deviceId);
return true;
}

Expand Down
1 change: 1 addition & 0 deletions graphql/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export default async function injectContext({ req, res }: { req: Request; res: R
roles: [Role.ADMIN, Role.UNAUTHENTICATED],
lastLogin: new Date(),
active: true,
deviceId: undefined,
};
context.sessionID = 'ADMIN';

Expand Down
10 changes: 5 additions & 5 deletions graphql/me/mutation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Role } from '../authorizations';
import { Arg, Authorized, Ctx, Field, InputType, Int, Mutation, Resolver } from 'type-graphql';
import { GraphQLContext } from '../context';
import { getSessionPupil, getSessionStudent, isSessionPupil, isSessionStudent, loginAsUser, updateSessionUser } from '../authentication';
import { getSessionPupil, getSessionStudent, getSessionUser, isSessionPupil, isSessionStudent, loginAsUser, updateSessionUser } from '../authentication';
import { activatePupil, deactivatePupil } from '../../common/pupil/activation';
import { pupil_registrationsource_enum as RegistrationSource } from '@prisma/client';
import { MaxLength, ValidateNested } from 'class-validator';
Expand Down Expand Up @@ -87,7 +87,7 @@ export class MutateMeResolver {
logger.info(`Student(${student.id}, firstname = ${student.firstname}, lastname = ${student.lastname}) registered`);

if (!byAdmin) {
await loginAsUser(userForStudent(student), context);
await loginAsUser(userForStudent(student), context, undefined);
}

return student;
Expand Down Expand Up @@ -116,7 +116,7 @@ export class MutateMeResolver {
logger.info(`Pupil(${pupil.id}, firstname = ${pupil.firstname}, lastname = ${pupil.lastname}) registered`);

if (!byAdmin) {
await loginAsUser(userForPupil(pupil), context);
await loginAsUser(userForPupil(pupil), context, undefined);
}

return pupil;
Expand Down Expand Up @@ -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));
await updateSessionUser(context, userForStudent(student), getSessionUser(context).deviceId);

// After successful screening and re authentication, the user will receive the INSTRUCTOR role

Expand All @@ -241,7 +241,7 @@ export class MutateMeResolver {
await becomeTutor(student, data);

// User gets the WANNABE_TUTOR role
await updateSessionUser(context, userForStudent(student));
await updateSessionUser(context, userForStudent(student), getSessionUser(context).deviceId);

// After successful screening and re authentication, the user will receive the TUTOR role

Expand Down
4 changes: 2 additions & 2 deletions graphql/pupil/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ensureNoNull, getPupil } from '../util';
import * as Notification from '../../common/notification';
import { createPupilMatchRequest, deletePupilMatchRequest } from '../../common/match/request';
import { GraphQLContext } from '../context';
import { getSessionPupil, getSessionScreener, isAdmin, isElevated, isScreener, updateSessionUser } from '../authentication';
import { getSessionPupil, getSessionScreener, getSessionUser, isAdmin, isElevated, isScreener, updateSessionUser } from '../authentication';
import { Subject } from '../types/subject';
import {
Prisma,
Expand Down Expand Up @@ -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));
await updateSessionUser(context, userForPupil(res), getSessionUser(context).deviceId);

logger.info(`Pupil(${pupil.id}) updated their account with ${JSON.stringify(update)}`);
return res;
Expand Down
37 changes: 27 additions & 10 deletions graphql/secret/mutation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,15 +10,17 @@ import { getLogger } from '../../common/logger/logger';
import { UserInputError } from 'apollo-server-express';
import { validateEmail } from '../validators';
import { GraphQLString } from 'graphql';
import { deleteSessionsByDevice } from '../../common/user/session';
import { prisma } from '../../common/prisma';

const logger = getLogger('MutateSecretResolver');

@Resolver((of) => Secret)
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)
Expand All @@ -28,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;
}
Expand All @@ -50,22 +52,37 @@ export class MutateSecretResolver {

@Mutation((returns) => Boolean)
@Authorized(Role.USER, Role.ADMIN)
async tokenRevoke(@Ctx() context: GraphQLContext, @Arg('id', { nullable: true }) id?: number, @Arg('token', { nullable: true }) token?: string) {
async tokenRevoke(
@Ctx() context: GraphQLContext,
@Arg('invalidateSessions') invalidateSessions: boolean,
@Arg('id', { nullable: true }) id?: number,
@Arg('token', { nullable: true }) token?: string
) {
let tokenId = id;
let deviceId = undefined;
if (id) {
deviceId = (await prisma.secret.findUnique({ where: { id: tokenId } })).deviceId;
realmayus marked this conversation as resolved.
Show resolved Hide resolved
} 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);
} else {
await revokeToken(getSessionUser(context), id);
}
return true;
} else if (token) {
tokenId = await revokeTokenByToken(token);
}

if (token) {
await revokeTokenByToken(token);
return true;
if (invalidateSessions) {
await deleteSessionsByDevice(deviceId);
}

throw new UserInputError(`Either the id or the token must be passed`);
return true;
}

@Mutation((returns) => Boolean)
Expand Down
4 changes: 2 additions & 2 deletions graphql/student/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Role } from '../authorizations';
import { ensureNoNull, getStudent } from '../util';
import { deactivateStudent, reactivateStudent } from '../../common/student/activation';
import { canStudentRequestMatch, createStudentMatchRequest, deleteStudentMatchRequest } from '../../common/match/request';
import { getSessionScreener, getSessionStudent, isElevated, updateSessionUser } from '../authentication';
import { getSessionScreener, getSessionStudent, getSessionUser, isElevated, updateSessionUser } from '../authentication';
import { GraphQLContext } from '../context';
import { Arg, Authorized, Ctx, Field, InputType, Int, Mutation, ObjectType, Resolver } from 'type-graphql';
import { prisma } from '../../common/prisma';
Expand Down Expand Up @@ -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));
await updateSessionUser(context, userForStudent(res), getSessionUser(context).deviceId);

logger.info(`Student(${student.id}) updated their account with ${JSON.stringify(update)}`);
return res;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "secret" ADD COLUMN "deviceId" VARCHAR;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
realmayus marked this conversation as resolved.
Show resolved Hide resolved
}

// DEPRECATED: Used by our old ORM to track migrations, to be removed once Prisma Migrations work reliably
Expand Down
Loading