Skip to content

Prevent login if user is disabled #3509

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

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
9 changes: 7 additions & 2 deletions src/common/exceptions/unauthenticated.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
30 changes: 22 additions & 8 deletions src/core/authentication/authentication.gel.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ID> {
Expand Down Expand Up @@ -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 },
}));
},
);
}
3 changes: 3 additions & 0 deletions src/core/authentication/authentication.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,6 +39,8 @@ import { SessionManager } from './session/session.manager';
splitDb(AuthenticationRepository, AuthenticationGelRepository),
JwtService,
CryptoService,

DisablingUserLogsThemOutHandler,
],
exports: [Identity],
})
Expand Down
56 changes: 38 additions & 18 deletions src/core/authentication/authentication.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 [
Expand Down
18 changes: 16 additions & 2 deletions src/core/authentication/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -73,11 +74,14 @@ export class AuthenticationService {
}

async login(input: LoginInput): Promise<ID> {
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,
Expand All @@ -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,
Expand Down Expand Up @@ -152,3 +160,9 @@ export class AuthenticationService {
await this.repo.removeAllEmailTokensForEmail(emailToken.email);
}
}

export class UserDisabledException extends AuthenticationException {
constructor() {
super('User is disabled');
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
45 changes: 43 additions & 2 deletions test/authentication.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import { graphql } from '~/graphql';
import {
createSession,
createTestApp,
CurrentUserDoc,
fragments,
generateRegisterInput,
login,
LoginDoc,
logout,
registerUser,
type TestApp,
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 10 additions & 11 deletions test/utility/create-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
`);
2 changes: 1 addition & 1 deletion test/utility/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export async function login(app: TestApp, input: InputOf<typeof LoginDoc>) {
app.graphql.email = input.email;
return res;
}
const LoginDoc = graphql(`
export const LoginDoc = graphql(`
mutation login($input: LoginInput!) {
login(input: $input) {
user {
Expand Down