From dc88e2413b3be254b265d2874174eaac39d628ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Mon, 29 Aug 2022 13:18:05 +0200 Subject: [PATCH] feat(auth): retain user agent, api version and received at on revoked sessions --- .../1661771230400-revoked_session_data.ts | 15 +++++++++++++ ...essionUserAgentLoggingEventHandler.spec.ts | 9 +++++++- ...bledSessionUserAgentLoggingEventHandler.ts | 7 +++++- .../auth/src/Domain/Session/RevokedSession.ts | 21 ++++++++++++++++++ .../RevokedSessionRepositoryInterface.ts | 2 ++ .../src/Domain/Session/SessionService.spec.ts | 6 +++++ .../auth/src/Domain/Session/SessionService.ts | 9 +++++--- .../MySQLRevokedSessionRepository.spec.ts | 22 ++++++++++++++++++- .../MySQL/MySQLRevokedSessionRepository.ts | 11 ++++++++++ 9 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 packages/auth/migrations/1661771230400-revoked_session_data.ts diff --git a/packages/auth/migrations/1661771230400-revoked_session_data.ts b/packages/auth/migrations/1661771230400-revoked_session_data.ts new file mode 100644 index 000000000..1daac4e0f --- /dev/null +++ b/packages/auth/migrations/1661771230400-revoked_session_data.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class revokedSessionData1661771230400 implements MigrationInterface { + name = 'revokedSessionData1661771230400' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `revoked_sessions` ADD `received_at` datetime NULL') + await queryRunner.query('ALTER TABLE `revoked_sessions` ADD `user_agent` text NULL') + await queryRunner.query('ALTER TABLE `revoked_sessions` ADD `api_version` varchar(255) NULL') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.spec.ts b/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.spec.ts index f24619a5b..c7ce0b103 100644 --- a/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.spec.ts +++ b/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.spec.ts @@ -4,17 +4,23 @@ import { UserDisabledSessionUserAgentLoggingEvent } from '@standardnotes/domain- import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' import { UserDisabledSessionUserAgentLoggingEventHandler } from './UserDisabledSessionUserAgentLoggingEventHandler' +import { RevokedSessionRepositoryInterface } from '../Session/RevokedSessionRepositoryInterface' describe('UserDisabledSessionUserAgentLoggingEventHandler', () => { let sessionRepository: SessionRepositoryInterface + let revokedSessionRepository: RevokedSessionRepositoryInterface let event: UserDisabledSessionUserAgentLoggingEvent - const createHandler = () => new UserDisabledSessionUserAgentLoggingEventHandler(sessionRepository) + const createHandler = () => + new UserDisabledSessionUserAgentLoggingEventHandler(sessionRepository, revokedSessionRepository) beforeEach(() => { sessionRepository = {} as jest.Mocked sessionRepository.clearUserAgentByUserUuid = jest.fn() + revokedSessionRepository = {} as jest.Mocked + revokedSessionRepository.clearUserAgentByUserUuid = jest.fn() + event = {} as jest.Mocked event.payload = { userUuid: '1-2-3', @@ -26,5 +32,6 @@ describe('UserDisabledSessionUserAgentLoggingEventHandler', () => { await createHandler().handle(event) expect(sessionRepository.clearUserAgentByUserUuid).toHaveBeenCalledWith('1-2-3') + expect(revokedSessionRepository.clearUserAgentByUserUuid).toHaveBeenCalledWith('1-2-3') }) }) diff --git a/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.ts b/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.ts index ebc5a0959..865cf168f 100644 --- a/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.ts +++ b/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.ts @@ -2,13 +2,18 @@ import { DomainEventHandlerInterface, UserDisabledSessionUserAgentLoggingEvent } import { inject, injectable } from 'inversify' import TYPES from '../../Bootstrap/Types' +import { RevokedSessionRepositoryInterface } from '../Session/RevokedSessionRepositoryInterface' import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' @injectable() export class UserDisabledSessionUserAgentLoggingEventHandler implements DomainEventHandlerInterface { - constructor(@inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface) {} + constructor( + @inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface, + @inject(TYPES.SessionRepository) private revokedSessionRepository: RevokedSessionRepositoryInterface, + ) {} async handle(event: UserDisabledSessionUserAgentLoggingEvent): Promise { await this.sessionRepository.clearUserAgentByUserUuid(event.payload.userUuid) + await this.revokedSessionRepository.clearUserAgentByUserUuid(event.payload.userUuid) } } diff --git a/packages/auth/src/Domain/Session/RevokedSession.ts b/packages/auth/src/Domain/Session/RevokedSession.ts index 900493a65..306ff6985 100644 --- a/packages/auth/src/Domain/Session/RevokedSession.ts +++ b/packages/auth/src/Domain/Session/RevokedSession.ts @@ -36,4 +36,25 @@ export class RevokedSession { ) @JoinColumn({ name: 'user_uuid', referencedColumnName: 'uuid' }) declare user: Promise + + @Column({ + name: 'received_at', + type: 'datetime', + nullable: true, + }) + declare receivedAt: Date + + @Column({ + name: 'user_agent', + type: 'text', + nullable: true, + }) + declare userAgent: string | null + + @Column({ + name: 'api_version', + length: 255, + nullable: true, + }) + declare apiVersion: string } diff --git a/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts b/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts index 563bcb4de..d9fc1db0e 100644 --- a/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts +++ b/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts @@ -1,3 +1,4 @@ +import { Uuid } from '@standardnotes/common' import { RevokedSession } from './RevokedSession' export interface RevokedSessionRepositoryInterface { @@ -5,4 +6,5 @@ export interface RevokedSessionRepositoryInterface { findAllByUserUuid(userUuid: string): Promise> save(revokedSession: RevokedSession): Promise remove(revokedSession: RevokedSession): Promise + clearUserAgentByUserUuid(userUuid: Uuid): Promise } diff --git a/packages/auth/src/Domain/Session/SessionService.spec.ts b/packages/auth/src/Domain/Session/SessionService.spec.ts index debf0fa40..4140af480 100644 --- a/packages/auth/src/Domain/Session/SessionService.spec.ts +++ b/packages/auth/src/Domain/Session/SessionService.spec.ts @@ -2,6 +2,7 @@ import 'reflect-metadata' import * as winston from 'winston' import { TimerInterface } from '@standardnotes/time' +import { ApiVersion } from '../Api/ApiVersion' import { Session } from './Session' import { SessionRepositoryInterface } from './SessionRepositoryInterface' import { SessionService } from './SessionService' @@ -47,6 +48,7 @@ describe('SessionService', () => { session.uuid = '2e1e43' session.userUuid = '1-2-3' session.userAgent = 'Chrome' + session.apiVersion = ApiVersion.v20200115 session.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' session.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' @@ -80,6 +82,7 @@ describe('SessionService', () => { timer = {} as jest.Mocked timer.convertStringDateToMilliseconds = jest.fn().mockReturnValue(123) + timer.getUTCDate = jest.fn().mockReturnValue(new Date(1)) deviceDetector = {} as jest.Mocked deviceDetector.setUA = jest.fn().mockReturnThis() @@ -111,6 +114,7 @@ describe('SessionService', () => { expect(revokedSessionRepository.save).toHaveBeenCalledWith({ uuid: '2e1e43', received: true, + receivedAt: new Date(1), }) }) @@ -476,6 +480,8 @@ describe('SessionService', () => { expect(revokedSessionRepository.save).toHaveBeenCalledWith({ uuid: '2e1e43', userUuid: '1-2-3', + userAgent: 'Chrome', + apiVersion: '20200115', createdAt: expect.any(Date), }) }) diff --git a/packages/auth/src/Domain/Session/SessionService.ts b/packages/auth/src/Domain/Session/SessionService.ts index cd737482d..a392846c2 100644 --- a/packages/auth/src/Domain/Session/SessionService.ts +++ b/packages/auth/src/Domain/Session/SessionService.ts @@ -203,6 +203,7 @@ export class SessionService implements SessionServiceInterface { async markRevokedSessionAsReceived(revokedSession: RevokedSession): Promise { revokedSession.received = true + revokedSession.receivedAt = this.timer.getUTCDate() return this.revokedSessionRepository.save(revokedSession) } @@ -224,7 +225,9 @@ export class SessionService implements SessionServiceInterface { const revokedSession = new RevokedSession() revokedSession.uuid = session.uuid revokedSession.userUuid = session.userUuid - revokedSession.createdAt = dayjs.utc().toDate() + revokedSession.createdAt = this.timer.getUTCDate() + revokedSession.apiVersion = session.apiVersion + revokedSession.userAgent = session.userAgent return this.revokedSessionRepository.save(revokedSession) } @@ -246,8 +249,8 @@ export class SessionService implements SessionServiceInterface { } session.userUuid = dto.user.uuid session.apiVersion = dto.apiVersion - session.createdAt = dayjs.utc().toDate() - session.updatedAt = dayjs.utc().toDate() + session.createdAt = this.timer.getUTCDate() + session.updatedAt = this.timer.getUTCDate() session.readonlyAccess = dto.readonlyAccess return session diff --git a/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.spec.ts index 14b4a73c0..9b0d3d5ab 100644 --- a/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.spec.ts +++ b/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.spec.ts @@ -1,6 +1,6 @@ import 'reflect-metadata' -import { Repository, SelectQueryBuilder } from 'typeorm' +import { Repository, SelectQueryBuilder, UpdateQueryBuilder } from 'typeorm' import { RevokedSession } from '../../Domain/Session/RevokedSession' @@ -9,12 +9,14 @@ import { MySQLRevokedSessionRepository } from './MySQLRevokedSessionRepository' describe('MySQLRevokedSessionRepository', () => { let ormRepository: Repository let queryBuilder: SelectQueryBuilder + let updateQueryBuilder: UpdateQueryBuilder let session: RevokedSession const createRepository = () => new MySQLRevokedSessionRepository(ormRepository) beforeEach(() => { queryBuilder = {} as jest.Mocked> + updateQueryBuilder = {} as jest.Mocked> session = {} as jest.Mocked @@ -36,6 +38,24 @@ describe('MySQLRevokedSessionRepository', () => { expect(ormRepository.remove).toHaveBeenCalledWith(session) }) + it('should clear user agent data on all user sessions', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder) + + updateQueryBuilder.update = jest.fn().mockReturnThis() + updateQueryBuilder.set = jest.fn().mockReturnThis() + updateQueryBuilder.where = jest.fn().mockReturnThis() + updateQueryBuilder.execute = jest.fn() + + await createRepository().clearUserAgentByUserUuid('1-2-3') + + expect(updateQueryBuilder.update).toHaveBeenCalled() + expect(updateQueryBuilder.set).toHaveBeenCalledWith({ + userAgent: null, + }) + expect(updateQueryBuilder.where).toHaveBeenCalledWith('user_uuid = :userUuid', { userUuid: '1-2-3' }) + expect(updateQueryBuilder.execute).toHaveBeenCalled() + }) + it('should find one session by id', async () => { queryBuilder.where = jest.fn().mockReturnThis() queryBuilder.getOne = jest.fn().mockReturnValue(session) diff --git a/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.ts b/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.ts index 83b1a508c..9b0352c04 100644 --- a/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.ts +++ b/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.ts @@ -20,6 +20,17 @@ export class MySQLRevokedSessionRepository implements RevokedSessionRepositoryIn return this.ormRepository.remove(revokedSession) } + async clearUserAgentByUserUuid(userUuid: string): Promise { + await this.ormRepository + .createQueryBuilder('revoked_session') + .update() + .set({ + userAgent: null, + }) + .where('user_uuid = :userUuid', { userUuid }) + .execute() + } + async findAllByUserUuid(userUuid: string): Promise { return this.ormRepository .createQueryBuilder('revoked_session')