diff --git a/src/common/exceptions/unauthenticated.exception.ts b/src/common/exceptions/unauthenticated.exception.ts index 668d63867e..b3d366e54d 100644 --- a/src/common/exceptions/unauthenticated.exception.ts +++ b/src/common/exceptions/unauthenticated.exception.ts @@ -2,11 +2,16 @@ import { HttpStatus } from '@nestjs/common'; import { ClientException } from './exception'; /** - * We cannot identify the requester. + * Any authentication-related problem */ -export class UnauthenticatedException extends ClientException { +export abstract class AuthenticationException extends ClientException { readonly status = HttpStatus.UNAUTHORIZED; +} +/** + * We cannot identify the requester. + */ +export class UnauthenticatedException extends AuthenticationException { constructor(message?: string, previous?: Error) { super(message ?? `Not authenticated`, previous); } diff --git a/src/core/authentication/authentication.gel.repository.ts b/src/core/authentication/authentication.gel.repository.ts index eacf6c6ec7..444e7f744b 100644 --- a/src/core/authentication/authentication.gel.repository.ts +++ b/src/core/authentication/authentication.gel.repository.ts @@ -64,17 +64,17 @@ export class AuthenticationGelRepository }, ); - async getPasswordHash({ email }: LoginInput) { - return await this.db.run(this.getPasswordHashQuery, { email }); + async getInfoForLogin({ email }: LoginInput) { + return await this.db.run(this.getInfoForLoginQuery, { email }); } - private readonly getPasswordHashQuery = e.params( + private readonly getInfoForLoginQuery = e.params( { email: e.str }, - ({ email }) => { - const identity = e.select(e.Auth.Identity, (identity) => ({ + ({ email }) => + e.select(e.Auth.Identity, (identity) => ({ filter_single: e.op(identity.user.email, '=', email), - })); - return identity.passwordHash; - }, + passwordHash: true, + status: identity.user.status, + })), ); async connectSessionToUser(input: LoginInput, session: Session): Promise { @@ -280,4 +280,18 @@ export class AuthenticationGelRepository set: { user: null }, })), ); + + async deactivateAllSessions(user: ID<'User'>) { + await this.db.run(this.deactivateAllSessionsQuery, { user }); + } + private readonly deactivateAllSessionsQuery = e.params( + { user: e.uuid }, + ($) => { + const user = e.cast(e.User, $.user); + return e.update(e.Auth.Session, (s) => ({ + filter: e.op(s.user, '=', user), + set: { user: null }, + })); + }, + ); } diff --git a/src/core/authentication/authentication.module.ts b/src/core/authentication/authentication.module.ts index d399f787f1..6c5a657e23 100644 --- a/src/core/authentication/authentication.module.ts +++ b/src/core/authentication/authentication.module.ts @@ -6,6 +6,7 @@ import { AuthenticationGelRepository } from './authentication.gel.repository'; import { AuthenticationRepository } from './authentication.repository'; import { AuthenticationService } from './authentication.service'; import { CryptoService } from './crypto.service'; +import { DisablingUserLogsThemOutHandler } from './handlers/disabling-user-logs-them-out.handler'; import { Identity } from './identity.service'; import { JwtService } from './jwt.service'; import { LoginResolver } from './resolvers/login.resolver'; @@ -38,6 +39,8 @@ import { SessionManager } from './session/session.manager'; splitDb(AuthenticationRepository, AuthenticationGelRepository), JwtService, CryptoService, + + DisablingUserLogsThemOutHandler, ], exports: [Identity], }) diff --git a/src/core/authentication/authentication.repository.ts b/src/core/authentication/authentication.repository.ts index 31ed34e5eb..aca12e9773 100644 --- a/src/core/authentication/authentication.repository.ts +++ b/src/core/authentication/authentication.repository.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { node, relation } from 'cypher-query-builder'; import { DateTime } from 'luxon'; import { type ID, type Role, ServerException } from '~/common'; +import { type UserStatus } from '../../components/user/dto'; import { DatabaseService, DbTraceLayer, OnIndex } from '../database'; import { ACTIVE, @@ -97,27 +98,34 @@ export class AuthenticationRepository { .run(); } - async getPasswordHash(input: LoginInput) { + async getInfoForLogin(input: LoginInput) { const result = await this.db .query() - .raw( - ` - MATCH - (:EmailAddress {value: $email}) - <-[:email {active: true}]- - (user:User) - -[:password {active: true}]-> - (password:Property) - RETURN - password.value as pash - `, - { - email: input.email, - }, - ) - .asResult<{ pash: string }>() + .match([ + [ + node('email', 'EmailAddress', { + value: input.email, + }), + relation('in', '', 'email', ACTIVE), + node('user', 'User'), + ], + [ + node('user'), + relation('out', '', 'password', ACTIVE), + node('password', 'Property'), + ], + [ + node('user'), + relation('out', '', 'status', ACTIVE), + node('status', 'Property'), + ], + ]) + .return<{ passwordHash: string; status: UserStatus }>([ + 'password.value as passwordHash', + 'status.value as status', + ]) .first(); - return result?.pash ?? null; + return result ?? null; } async connectSessionToUser(input: LoginInput, session: Session) { @@ -352,6 +360,18 @@ export class AuthenticationRepository { .run(); } + async deactivateAllSessions(user: ID<'User'>) { + await this.db + .query() + .match([ + node('user', 'User', { id: user }), + relation('out', 'oldRel', 'token', ACTIVE), + node('token', 'Token'), + ]) + .setValues({ 'oldRel.active': false }) + .run(); + } + @OnIndex() private createIndexes() { return [ diff --git a/src/core/authentication/authentication.service.ts b/src/core/authentication/authentication.service.ts index 0d50896bdd..9334381a40 100644 --- a/src/core/authentication/authentication.service.ts +++ b/src/core/authentication/authentication.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { EmailService } from '@seedcompany/nestjs-email'; import { + AuthenticationException, DuplicateException, type ID, InputException, @@ -73,11 +74,14 @@ export class AuthenticationService { } async login(input: LoginInput): Promise { - const hash = await this.repo.getPasswordHash(input); + const info = await this.repo.getInfoForLogin(input); - if (!(await this.crypto.verify(hash, input.password))) { + if (!(await this.crypto.verify(info?.passwordHash, input.password))) { throw new UnauthenticatedException('Invalid credentials'); } + if (info!.status === 'Disabled') { + throw new UserDisabledException(); + } const userId = await this.repo.connectSessionToUser( input, @@ -98,6 +102,10 @@ export class AuthenticationService { refresh && (await this.sessionManager.refreshCurrentSession()); } + async logoutByUser(user: ID<'User'>) { + await this.repo.deactivateAllSessions(user); + } + async changePassword( oldPassword: string, newPassword: string, @@ -152,3 +160,9 @@ export class AuthenticationService { await this.repo.removeAllEmailTokensForEmail(emailToken.email); } } + +export class UserDisabledException extends AuthenticationException { + constructor() { + super('User is disabled'); + } +} diff --git a/src/core/authentication/handlers/disabling-user-logs-them-out.handler.ts b/src/core/authentication/handlers/disabling-user-logs-them-out.handler.ts new file mode 100644 index 0000000000..9e70a013b8 --- /dev/null +++ b/src/core/authentication/handlers/disabling-user-logs-them-out.handler.ts @@ -0,0 +1,13 @@ +import { EventsHandler } from '~/core'; +import { UserUpdatedEvent } from '../../../components/user/events/user-updated.event'; +import { AuthenticationService } from '../authentication.service'; + +@EventsHandler(UserUpdatedEvent) +export class DisablingUserLogsThemOutHandler { + constructor(private readonly auth: AuthenticationService) {} + async handle({ input, updated: user }: UserUpdatedEvent) { + if (input.status === 'Disabled') { + await this.auth.logoutByUser(user.id); + } + } +} diff --git a/test/authentication.e2e-spec.ts b/test/authentication.e2e-spec.ts index 3a5c806bf0..f9142edbb1 100644 --- a/test/authentication.e2e-spec.ts +++ b/test/authentication.e2e-spec.ts @@ -7,9 +7,11 @@ import { graphql } from '~/graphql'; import { createSession, createTestApp, + CurrentUserDoc, fragments, generateRegisterInput, login, + LoginDoc, logout, registerUser, type TestApp, @@ -115,11 +117,50 @@ describe('Authentication e2e', () => { expect(actual.phone.value).toBe(fakeUser.phone); expect(actual.timezone.value?.name).toBe(fakeUser.timezone); expect(actual.about.value).toBe(fakeUser.about); + }); + + it('disabled users are logged out & cannot login', async () => { + const input = await generateRegisterInput(); + const user = await registerUser(app, input); + + // confirm they're logged in + const before = await app.graphql.query(CurrentUserDoc); + expect(before.session.user).toBeTruthy(); - return true; + await app.graphql.query( + graphql( + ` + mutation DisableUser($id: ID!) { + updateUser(input: { user: { id: $id, status: Disabled } }) { + __typename + } + } + `, + ), + { + id: user.id, + }, + ); + + // Confirm mutation logged them out + const after = await app.graphql.query(CurrentUserDoc); + expect(after.session.user).toBeNull(); + + // Confirm they can't log back in + await app.graphql + .query(LoginDoc, { + input: { + email: input.email, + password: input.password, + }, + }) + .expectError({ + message: 'User is disabled', + code: ['UserDisabled', 'Authentication', 'Client'], + }); }); - it('should return true after password changed', async () => { + it('Password changed', async () => { const fakeUser = await generateRegisterInput(); const user = await registerUser(app, fakeUser); diff --git a/test/utility/create-session.ts b/test/utility/create-session.ts index f458d27290..b326137d51 100644 --- a/test/utility/create-session.ts +++ b/test/utility/create-session.ts @@ -18,18 +18,17 @@ export async function createSession(app: TestApp) { } export async function getUserFromSession(app: TestApp) { - const result = await app.graphql.query( - graphql(` - query SessionUser { - session { - user { - id - } - } - } - `), - ); + const result = await app.graphql.query(CurrentUserDoc); const user = result.session.user; expect(user).toBeTruthy(); return user!; } +export const CurrentUserDoc = graphql(` + query SessionUser { + session { + user { + id + } + } + } +`); diff --git a/test/utility/login.ts b/test/utility/login.ts index 90e153ef1e..dd646c8da7 100644 --- a/test/utility/login.ts +++ b/test/utility/login.ts @@ -8,7 +8,7 @@ export async function login(app: TestApp, input: InputOf) { app.graphql.email = input.email; return res; } -const LoginDoc = graphql(` +export const LoginDoc = graphql(` mutation login($input: LoginInput!) { login(input: $input) { user {