Skip to content

Commit 61be479

Browse files
authored
Merge pull request #3509 from SeedCompany/disabled-user-no-login
2 parents 996453f + 930dba2 commit 61be479

9 files changed

+153
-44
lines changed

src/common/exceptions/unauthenticated.exception.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ import { HttpStatus } from '@nestjs/common';
22
import { ClientException } from './exception';
33

44
/**
5-
* We cannot identify the requester.
5+
* Any authentication-related problem
66
*/
7-
export class UnauthenticatedException extends ClientException {
7+
export abstract class AuthenticationException extends ClientException {
88
readonly status = HttpStatus.UNAUTHORIZED;
9+
}
910

11+
/**
12+
* We cannot identify the requester.
13+
*/
14+
export class UnauthenticatedException extends AuthenticationException {
1015
constructor(message?: string, previous?: Error) {
1116
super(message ?? `Not authenticated`, previous);
1217
}

src/core/authentication/authentication.gel.repository.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,17 @@ export class AuthenticationGelRepository
6464
},
6565
);
6666

67-
async getPasswordHash({ email }: LoginInput) {
68-
return await this.db.run(this.getPasswordHashQuery, { email });
67+
async getInfoForLogin({ email }: LoginInput) {
68+
return await this.db.run(this.getInfoForLoginQuery, { email });
6969
}
70-
private readonly getPasswordHashQuery = e.params(
70+
private readonly getInfoForLoginQuery = e.params(
7171
{ email: e.str },
72-
({ email }) => {
73-
const identity = e.select(e.Auth.Identity, (identity) => ({
72+
({ email }) =>
73+
e.select(e.Auth.Identity, (identity) => ({
7474
filter_single: e.op(identity.user.email, '=', email),
75-
}));
76-
return identity.passwordHash;
77-
},
75+
passwordHash: true,
76+
status: identity.user.status,
77+
})),
7878
);
7979

8080
async connectSessionToUser(input: LoginInput, session: Session): Promise<ID> {
@@ -280,4 +280,18 @@ export class AuthenticationGelRepository
280280
set: { user: null },
281281
})),
282282
);
283+
284+
async deactivateAllSessions(user: ID<'User'>) {
285+
await this.db.run(this.deactivateAllSessionsQuery, { user });
286+
}
287+
private readonly deactivateAllSessionsQuery = e.params(
288+
{ user: e.uuid },
289+
($) => {
290+
const user = e.cast(e.User, $.user);
291+
return e.update(e.Auth.Session, (s) => ({
292+
filter: e.op(s.user, '=', user),
293+
set: { user: null },
294+
}));
295+
},
296+
);
283297
}

src/core/authentication/authentication.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AuthenticationGelRepository } from './authentication.gel.repository';
66
import { AuthenticationRepository } from './authentication.repository';
77
import { AuthenticationService } from './authentication.service';
88
import { CryptoService } from './crypto.service';
9+
import { DisablingUserLogsThemOutHandler } from './handlers/disabling-user-logs-them-out.handler';
910
import { Identity } from './identity.service';
1011
import { JwtService } from './jwt.service';
1112
import { LoginResolver } from './resolvers/login.resolver';
@@ -38,6 +39,8 @@ import { SessionManager } from './session/session.manager';
3839
splitDb(AuthenticationRepository, AuthenticationGelRepository),
3940
JwtService,
4041
CryptoService,
42+
43+
DisablingUserLogsThemOutHandler,
4144
],
4245
exports: [Identity],
4346
})

src/core/authentication/authentication.repository.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
22
import { node, relation } from 'cypher-query-builder';
33
import { DateTime } from 'luxon';
44
import { type ID, type Role, ServerException } from '~/common';
5+
import { type UserStatus } from '../../components/user/dto';
56
import { DatabaseService, DbTraceLayer, OnIndex } from '../database';
67
import {
78
ACTIVE,
@@ -97,27 +98,34 @@ export class AuthenticationRepository {
9798
.run();
9899
}
99100

100-
async getPasswordHash(input: LoginInput) {
101+
async getInfoForLogin(input: LoginInput) {
101102
const result = await this.db
102103
.query()
103-
.raw(
104-
`
105-
MATCH
106-
(:EmailAddress {value: $email})
107-
<-[:email {active: true}]-
108-
(user:User)
109-
-[:password {active: true}]->
110-
(password:Property)
111-
RETURN
112-
password.value as pash
113-
`,
114-
{
115-
email: input.email,
116-
},
117-
)
118-
.asResult<{ pash: string }>()
104+
.match([
105+
[
106+
node('email', 'EmailAddress', {
107+
value: input.email,
108+
}),
109+
relation('in', '', 'email', ACTIVE),
110+
node('user', 'User'),
111+
],
112+
[
113+
node('user'),
114+
relation('out', '', 'password', ACTIVE),
115+
node('password', 'Property'),
116+
],
117+
[
118+
node('user'),
119+
relation('out', '', 'status', ACTIVE),
120+
node('status', 'Property'),
121+
],
122+
])
123+
.return<{ passwordHash: string; status: UserStatus }>([
124+
'password.value as passwordHash',
125+
'status.value as status',
126+
])
119127
.first();
120-
return result?.pash ?? null;
128+
return result ?? null;
121129
}
122130

123131
async connectSessionToUser(input: LoginInput, session: Session) {
@@ -352,6 +360,18 @@ export class AuthenticationRepository {
352360
.run();
353361
}
354362

363+
async deactivateAllSessions(user: ID<'User'>) {
364+
await this.db
365+
.query()
366+
.match([
367+
node('user', 'User', { id: user }),
368+
relation('out', 'oldRel', 'token', ACTIVE),
369+
node('token', 'Token'),
370+
])
371+
.setValues({ 'oldRel.active': false })
372+
.run();
373+
}
374+
355375
@OnIndex()
356376
private createIndexes() {
357377
return [

src/core/authentication/authentication.service.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
22
import { ModuleRef } from '@nestjs/core';
33
import { EmailService } from '@seedcompany/nestjs-email';
44
import {
5+
AuthenticationException,
56
DuplicateException,
67
type ID,
78
InputException,
@@ -73,11 +74,14 @@ export class AuthenticationService {
7374
}
7475

7576
async login(input: LoginInput): Promise<ID> {
76-
const hash = await this.repo.getPasswordHash(input);
77+
const info = await this.repo.getInfoForLogin(input);
7778

78-
if (!(await this.crypto.verify(hash, input.password))) {
79+
if (!(await this.crypto.verify(info?.passwordHash, input.password))) {
7980
throw new UnauthenticatedException('Invalid credentials');
8081
}
82+
if (info!.status === 'Disabled') {
83+
throw new UserDisabledException();
84+
}
8185

8286
const userId = await this.repo.connectSessionToUser(
8387
input,
@@ -98,6 +102,10 @@ export class AuthenticationService {
98102
refresh && (await this.sessionManager.refreshCurrentSession());
99103
}
100104

105+
async logoutByUser(user: ID<'User'>) {
106+
await this.repo.deactivateAllSessions(user);
107+
}
108+
101109
async changePassword(
102110
oldPassword: string,
103111
newPassword: string,
@@ -152,3 +160,9 @@ export class AuthenticationService {
152160
await this.repo.removeAllEmailTokensForEmail(emailToken.email);
153161
}
154162
}
163+
164+
export class UserDisabledException extends AuthenticationException {
165+
constructor() {
166+
super('User is disabled');
167+
}
168+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { EventsHandler } from '~/core';
2+
import { UserUpdatedEvent } from '../../../components/user/events/user-updated.event';
3+
import { AuthenticationService } from '../authentication.service';
4+
5+
@EventsHandler(UserUpdatedEvent)
6+
export class DisablingUserLogsThemOutHandler {
7+
constructor(private readonly auth: AuthenticationService) {}
8+
async handle({ input, updated: user }: UserUpdatedEvent) {
9+
if (input.status === 'Disabled') {
10+
await this.auth.logoutByUser(user.id);
11+
}
12+
}
13+
}

test/authentication.e2e-spec.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { graphql } from '~/graphql';
77
import {
88
createSession,
99
createTestApp,
10+
CurrentUserDoc,
1011
fragments,
1112
generateRegisterInput,
1213
login,
14+
LoginDoc,
1315
logout,
1416
registerUser,
1517
type TestApp,
@@ -115,11 +117,50 @@ describe('Authentication e2e', () => {
115117
expect(actual.phone.value).toBe(fakeUser.phone);
116118
expect(actual.timezone.value?.name).toBe(fakeUser.timezone);
117119
expect(actual.about.value).toBe(fakeUser.about);
120+
});
121+
122+
it('disabled users are logged out & cannot login', async () => {
123+
const input = await generateRegisterInput();
124+
const user = await registerUser(app, input);
125+
126+
// confirm they're logged in
127+
const before = await app.graphql.query(CurrentUserDoc);
128+
expect(before.session.user).toBeTruthy();
118129

119-
return true;
130+
await app.graphql.query(
131+
graphql(
132+
`
133+
mutation DisableUser($id: ID!) {
134+
updateUser(input: { user: { id: $id, status: Disabled } }) {
135+
__typename
136+
}
137+
}
138+
`,
139+
),
140+
{
141+
id: user.id,
142+
},
143+
);
144+
145+
// Confirm mutation logged them out
146+
const after = await app.graphql.query(CurrentUserDoc);
147+
expect(after.session.user).toBeNull();
148+
149+
// Confirm they can't log back in
150+
await app.graphql
151+
.query(LoginDoc, {
152+
input: {
153+
email: input.email,
154+
password: input.password,
155+
},
156+
})
157+
.expectError({
158+
message: 'User is disabled',
159+
code: ['UserDisabled', 'Authentication', 'Client'],
160+
});
120161
});
121162

122-
it('should return true after password changed', async () => {
163+
it('Password changed', async () => {
123164
const fakeUser = await generateRegisterInput();
124165

125166
const user = await registerUser(app, fakeUser);

test/utility/create-session.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,17 @@ export async function createSession(app: TestApp) {
1818
}
1919

2020
export async function getUserFromSession(app: TestApp) {
21-
const result = await app.graphql.query(
22-
graphql(`
23-
query SessionUser {
24-
session {
25-
user {
26-
id
27-
}
28-
}
29-
}
30-
`),
31-
);
21+
const result = await app.graphql.query(CurrentUserDoc);
3222
const user = result.session.user;
3323
expect(user).toBeTruthy();
3424
return user!;
3525
}
26+
export const CurrentUserDoc = graphql(`
27+
query SessionUser {
28+
session {
29+
user {
30+
id
31+
}
32+
}
33+
}
34+
`);

test/utility/login.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export async function login(app: TestApp, input: InputOf<typeof LoginDoc>) {
88
app.graphql.email = input.email;
99
return res;
1010
}
11-
const LoginDoc = graphql(`
11+
export const LoginDoc = graphql(`
1212
mutation login($input: LoginInput!) {
1313
login(input: $input) {
1414
user {

0 commit comments

Comments
 (0)