Skip to content

Commit

Permalink
use deviceId instead of secretId, store deviceId in secret
Browse files Browse the repository at this point in the history
  • Loading branch information
realmayus committed Oct 8, 2024
1 parent 681ed34 commit ea1e756
Show file tree
Hide file tree
Showing 12 changed files with 63 additions and 47 deletions.
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
28 changes: 19 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,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<number> {
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`);
}
Expand All @@ -42,7 +46,12 @@ export async function revokeTokenByToken(token: string): Promise<number> {
}

// 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 @@ -54,6 +63,7 @@ export async function createToken(user: User, expiresAt: Date | null = null, des
expiresAt,
lastUsed: null,
description,
deviceId,
},
});

Expand Down Expand Up @@ -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),
Expand All @@ -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()}`);
}

Expand All @@ -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})`);
}

Expand Down
14 changes: 7 additions & 7 deletions common/user/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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})`);
}
}
23 changes: 11 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, 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);
}
}

Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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', {});
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, 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) {
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion graphql/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
5 changes: 0 additions & 5 deletions graphql/me/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
4 changes: 2 additions & 2 deletions graphql/me/mutation.ts
Original file line number Diff line number Diff line change
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), getSessionUser(context).secretID);
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), getSessionUser(context).secretID);
await updateSessionUser(context, userForStudent(student), getSessionUser(context).deviceId);

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

Expand Down
2 changes: 1 addition & 1 deletion graphql/pupil/mutations.ts
Original file line number Diff line number Diff line change
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), 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;
Expand Down
25 changes: 17 additions & 8 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,16 +10,17 @@ 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');

@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 @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion graphql/student/mutations.ts
Original file line number Diff line number Diff line change
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), 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;
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
}

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

0 comments on commit ea1e756

Please sign in to comment.