fix(auth): move tracing sessions to session creation instead of cross service token creation

This commit is contained in:
Karol Sójko 2022-12-20 21:21:43 +01:00
parent 780358368b
commit 5255cfbb25
No known key found for this signature in database
GPG key ID: A50543BF560BDEB0
4 changed files with 164 additions and 196 deletions

View file

@ -15,6 +15,10 @@ import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { LogSessionUserAgentOption } from '@standardnotes/settings' import { LogSessionUserAgentOption } from '@standardnotes/settings'
import { Setting } from '../Setting/Setting' import { Setting } from '../Setting/Setting'
import { CryptoNode } from '@standardnotes/sncrypto-node' import { CryptoNode } from '@standardnotes/sncrypto-node'
import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
import { TraceSession } from '../UseCase/TraceSession/TraceSession'
import { UserSubscription } from '../Subscription/UserSubscription'
import { Result } from '@standardnotes/domain-core'
describe('SessionService', () => { describe('SessionService', () => {
let sessionRepository: SessionRepositoryInterface let sessionRepository: SessionRepositoryInterface
@ -28,6 +32,8 @@ describe('SessionService', () => {
let timer: TimerInterface let timer: TimerInterface
let logger: winston.Logger let logger: winston.Logger
let cryptoNode: CryptoNode let cryptoNode: CryptoNode
let traceSession: TraceSession
let userSubscriptionRepository: UserSubscriptionRepositoryInterface
const createService = () => const createService = () =>
new SessionService( new SessionService(
@ -41,6 +47,8 @@ describe('SessionService', () => {
234, 234,
settingService, settingService,
cryptoNode, cryptoNode,
traceSession,
userSubscriptionRepository,
) )
beforeEach(() => { beforeEach(() => {
@ -106,6 +114,14 @@ describe('SessionService', () => {
cryptoNode = {} as jest.Mocked<CryptoNode> cryptoNode = {} as jest.Mocked<CryptoNode>
cryptoNode.generateRandomKey = jest.fn().mockReturnValue('foo bar') cryptoNode.generateRandomKey = jest.fn().mockReturnValue('foo bar')
cryptoNode.base64URLEncode = jest.fn().mockReturnValue('foobar') cryptoNode.base64URLEncode = jest.fn().mockReturnValue('foobar')
traceSession = {} as jest.Mocked<TraceSession>
traceSession.execute = jest.fn()
userSubscriptionRepository = {} as jest.Mocked<UserSubscriptionRepositoryInterface>
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue({
planName: 'PRO_PLAN',
} as jest.Mocked<UserSubscription>)
}) })
it('should mark a revoked session as received', async () => { it('should mark a revoked session as received', async () => {
@ -204,6 +220,129 @@ describe('SessionService', () => {
}) })
}) })
it('should trace a session', async () => {
const user = {} as jest.Mocked<User>
user.uuid = '123'
user.email = 'test@test.te'
await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
readonlyAccess: false,
})
expect(traceSession.execute).toHaveBeenCalledWith({
userUuid: '123',
username: 'test@test.te',
subscriptionPlanName: 'PRO_PLAN',
})
})
it('should trace a session without a subscription', async () => {
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null)
const user = {} as jest.Mocked<User>
user.uuid = '123'
user.email = 'test@test.te'
await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
readonlyAccess: false,
})
expect(traceSession.execute).toHaveBeenCalledWith({
userUuid: '123',
username: 'test@test.te',
subscriptionPlanName: null,
})
})
it('should create a session if tracing session throws an error', async () => {
traceSession.execute = jest.fn().mockRejectedValue(new Error('foo bar'))
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null)
const user = {} as jest.Mocked<User>
user.uuid = '123'
user.email = 'test@test.te'
const sessionPayload = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
readonlyAccess: false,
})
expect(traceSession.execute).toHaveBeenCalledWith({
userUuid: '123',
username: 'test@test.te',
subscriptionPlanName: null,
})
expect(sessionPayload).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
refresh_token: expect.any(String),
readonly_access: false,
})
})
it('should create a session if tracing session throws an error', async () => {
traceSession.execute = jest.fn().mockRejectedValue(new Error('foo bar'))
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null)
const user = {} as jest.Mocked<User>
user.uuid = '123'
user.email = 'test@test.te'
const sessionPayload = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
readonlyAccess: false,
})
expect(traceSession.execute).toHaveBeenCalledWith({
userUuid: '123',
username: 'test@test.te',
subscriptionPlanName: null,
})
expect(sessionPayload).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
refresh_token: expect.any(String),
readonly_access: false,
})
})
it('should create a session if tracing session fails', async () => {
traceSession.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null)
const user = {} as jest.Mocked<User>
user.uuid = '123'
user.email = 'test@test.te'
const sessionPayload = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
readonlyAccess: false,
})
expect(traceSession.execute).toHaveBeenCalledWith({
userUuid: '123',
username: 'test@test.te',
subscriptionPlanName: null,
})
expect(sessionPayload).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
refresh_token: expect.any(String),
readonly_access: false,
})
})
it('should create new ephemeral session for a user', async () => { it('should create new ephemeral session for a user', async () => {
const user = {} as jest.Mocked<User> const user = {} as jest.Mocked<User>
user.uuid = '123' user.uuid = '123'

View file

@ -1,10 +1,14 @@
import * as crypto from 'crypto' import * as crypto from 'crypto'
import * as winston from 'winston'
import * as dayjs from 'dayjs' import * as dayjs from 'dayjs'
import { UAParser } from 'ua-parser-js' import { UAParser } from 'ua-parser-js'
import { inject, injectable } from 'inversify' import { inject, injectable } from 'inversify'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { TimerInterface } from '@standardnotes/time' import { TimerInterface } from '@standardnotes/time'
import { Logger } from 'winston'
import { LogSessionUserAgentOption, SettingName } from '@standardnotes/settings'
import { SessionBody } from '@standardnotes/responses'
import { Uuid } from '@standardnotes/common'
import { CryptoNode } from '@standardnotes/sncrypto-node'
import TYPES from '../../Bootstrap/Types' import TYPES from '../../Bootstrap/Types'
import { Session } from './Session' import { Session } from './Session'
@ -16,10 +20,8 @@ import { EphemeralSession } from './EphemeralSession'
import { RevokedSession } from './RevokedSession' import { RevokedSession } from './RevokedSession'
import { RevokedSessionRepositoryInterface } from './RevokedSessionRepositoryInterface' import { RevokedSessionRepositoryInterface } from './RevokedSessionRepositoryInterface'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface' import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { LogSessionUserAgentOption, SettingName } from '@standardnotes/settings' import { TraceSession } from '../UseCase/TraceSession/TraceSession'
import { SessionBody } from '@standardnotes/responses' import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface'
import { Uuid } from '@standardnotes/common'
import { CryptoNode } from '@standardnotes/sncrypto-node'
@injectable() @injectable()
export class SessionService implements SessionServiceInterface { export class SessionService implements SessionServiceInterface {
@ -31,11 +33,13 @@ export class SessionService implements SessionServiceInterface {
@inject(TYPES.RevokedSessionRepository) private revokedSessionRepository: RevokedSessionRepositoryInterface, @inject(TYPES.RevokedSessionRepository) private revokedSessionRepository: RevokedSessionRepositoryInterface,
@inject(TYPES.DeviceDetector) private deviceDetector: UAParser, @inject(TYPES.DeviceDetector) private deviceDetector: UAParser,
@inject(TYPES.Timer) private timer: TimerInterface, @inject(TYPES.Timer) private timer: TimerInterface,
@inject(TYPES.Logger) private logger: winston.Logger, @inject(TYPES.Logger) private logger: Logger,
@inject(TYPES.ACCESS_TOKEN_AGE) private accessTokenAge: number, @inject(TYPES.ACCESS_TOKEN_AGE) private accessTokenAge: number,
@inject(TYPES.REFRESH_TOKEN_AGE) private refreshTokenAge: number, @inject(TYPES.REFRESH_TOKEN_AGE) private refreshTokenAge: number,
@inject(TYPES.SettingService) private settingService: SettingServiceInterface, @inject(TYPES.SettingService) private settingService: SettingServiceInterface,
@inject(TYPES.CryptoNode) private cryptoNode: CryptoNode, @inject(TYPES.CryptoNode) private cryptoNode: CryptoNode,
@inject(TYPES.TraceSession) private traceSession: TraceSession,
@inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface,
) {} ) {}
async createNewSessionForUser(dto: { async createNewSessionForUser(dto: {
@ -53,6 +57,20 @@ export class SessionService implements SessionServiceInterface {
await this.sessionRepository.save(session) await this.sessionRepository.save(session)
try {
const userSubscription = await this.userSubscriptionRepository.findOneByUserUuid(dto.user.uuid)
const traceSessionResult = await this.traceSession.execute({
userUuid: dto.user.uuid,
username: dto.user.email,
subscriptionPlanName: userSubscription ? userSubscription.planName : null,
})
if (traceSessionResult.isFailed()) {
this.logger.error(traceSessionResult.getError())
}
} catch (error) {
this.logger.error(`Could not trace session while creating cross service token.: ${(error as Error).message}`)
}
return sessionPayload return sessionPayload
} }

View file

@ -8,10 +8,6 @@ import { Role } from '../../Role/Role'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { CreateCrossServiceToken } from './CreateCrossServiceToken' import { CreateCrossServiceToken } from './CreateCrossServiceToken'
import { RoleToSubscriptionMapInterface } from '../../Role/RoleToSubscriptionMapInterface'
import { TraceSession } from '../TraceSession/TraceSession'
import { Logger } from 'winston'
import { Result, RoleName, SubscriptionPlanName } from '@standardnotes/domain-core'
describe('CreateCrossServiceToken', () => { describe('CreateCrossServiceToken', () => {
let userProjector: ProjectorInterface<User> let userProjector: ProjectorInterface<User>
@ -19,9 +15,6 @@ describe('CreateCrossServiceToken', () => {
let roleProjector: ProjectorInterface<Role> let roleProjector: ProjectorInterface<Role>
let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData> let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>
let userRepository: UserRepositoryInterface let userRepository: UserRepositoryInterface
let roleToSubscriptionMap: RoleToSubscriptionMapInterface
let traceSession: TraceSession
let logger: Logger
const jwtTTL = 60 const jwtTTL = 60
let session: Session let session: Session
@ -29,17 +22,7 @@ describe('CreateCrossServiceToken', () => {
let role: Role let role: Role
const createUseCase = () => const createUseCase = () =>
new CreateCrossServiceToken( new CreateCrossServiceToken(userProjector, sessionProjector, roleProjector, tokenEncoder, userRepository, jwtTTL)
userProjector,
sessionProjector,
roleProjector,
tokenEncoder,
userRepository,
jwtTTL,
roleToSubscriptionMap,
traceSession,
logger,
)
beforeEach(() => { beforeEach(() => {
session = {} as jest.Mocked<Session> session = {} as jest.Mocked<Session>
@ -65,19 +48,6 @@ describe('CreateCrossServiceToken', () => {
userRepository = {} as jest.Mocked<UserRepositoryInterface> userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue(user) userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
roleToSubscriptionMap = {} as jest.Mocked<RoleToSubscriptionMapInterface>
roleToSubscriptionMap.filterSubscriptionRoles = jest.fn().mockReturnValue([RoleName.NAMES.PlusUser])
roleToSubscriptionMap.getSubscriptionNameForRoleName = jest
.fn()
.mockReturnValue(SubscriptionPlanName.NAMES.PlusPlan)
traceSession = {} as jest.Mocked<TraceSession>
traceSession.execute = jest.fn()
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
logger.debug = jest.fn()
}) })
it('should create a cross service token for user', async () => { it('should create a cross service token for user', async () => {
@ -86,11 +56,6 @@ describe('CreateCrossServiceToken', () => {
session, session,
}) })
expect(traceSession.execute).toHaveBeenCalledWith({
userUuid: '1-2-3',
username: 'test@test.te',
subscriptionPlanName: 'PLUS_PLAN',
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith( expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{ {
roles: [ roles: [
@ -169,126 +134,4 @@ describe('CreateCrossServiceToken', () => {
expect(caughtError).not.toBeNull() expect(caughtError).not.toBeNull()
}) })
it('should trace session without a subscription role', async () => {
roleToSubscriptionMap.filterSubscriptionRoles = jest.fn().mockReturnValue([])
await createUseCase().execute({
user,
session,
})
expect(traceSession.execute).toHaveBeenCalledWith({
userUuid: '1-2-3',
username: 'test@test.te',
subscriptionPlanName: null,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
email: 'test@test.te',
uuid: '1-2-3',
},
},
60,
)
})
it('should trace session without a subscription', async () => {
roleToSubscriptionMap.getSubscriptionNameForRoleName = jest.fn().mockReturnValue(undefined)
await createUseCase().execute({
user,
session,
})
expect(traceSession.execute).toHaveBeenCalledWith({
userUuid: '1-2-3',
username: 'test@test.te',
subscriptionPlanName: null,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
email: 'test@test.te',
uuid: '1-2-3',
},
},
60,
)
})
it('should create token if tracing session throws an error', async () => {
traceSession.execute = jest.fn().mockRejectedValue(new Error('test'))
await createUseCase().execute({
user,
session,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
email: 'test@test.te',
uuid: '1-2-3',
},
},
60,
)
})
it('should create token if tracing session fails', async () => {
traceSession.execute = jest.fn().mockReturnValue(Result.fail('Ooops'))
await createUseCase().execute({
user,
session,
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
user: {
email: 'test@test.te',
uuid: '1-2-3',
},
},
60,
)
})
}) })

View file

@ -1,16 +1,13 @@
import { RoleName } from '@standardnotes/common' import { RoleName } from '@standardnotes/common'
import { TokenEncoderInterface, CrossServiceTokenData } from '@standardnotes/security' import { TokenEncoderInterface, CrossServiceTokenData } from '@standardnotes/security'
import { inject, injectable } from 'inversify' import { inject, injectable } from 'inversify'
import { Logger } from 'winston'
import TYPES from '../../../Bootstrap/Types' import TYPES from '../../../Bootstrap/Types'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface' import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
import { Role } from '../../Role/Role' import { Role } from '../../Role/Role'
import { RoleToSubscriptionMapInterface } from '../../Role/RoleToSubscriptionMapInterface'
import { Session } from '../../Session/Session' import { Session } from '../../Session/Session'
import { User } from '../../User/User' import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { TraceSession } from '../TraceSession/TraceSession'
import { UseCaseInterface } from '../UseCaseInterface' import { UseCaseInterface } from '../UseCaseInterface'
import { CreateCrossServiceTokenDTO } from './CreateCrossServiceTokenDTO' import { CreateCrossServiceTokenDTO } from './CreateCrossServiceTokenDTO'
@ -25,9 +22,6 @@ export class CreateCrossServiceToken implements UseCaseInterface {
@inject(TYPES.CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>, @inject(TYPES.CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>,
@inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number, @inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number,
@inject(TYPES.RoleToSubscriptionMap) private roleToSubscriptionMap: RoleToSubscriptionMapInterface,
@inject(TYPES.TraceSession) private traceSession: TraceSession,
@inject(TYPES.Logger) private logger: Logger,
) {} ) {}
async execute(dto: CreateCrossServiceTokenDTO): Promise<CreateCrossServiceTokenResponse> { async execute(dto: CreateCrossServiceTokenDTO): Promise<CreateCrossServiceTokenResponse> {
@ -51,19 +45,6 @@ export class CreateCrossServiceToken implements UseCaseInterface {
authTokenData.session = this.projectSession(dto.session) authTokenData.session = this.projectSession(dto.session)
} }
try {
const traceSessionResult = await this.traceSession.execute({
userUuid: user.uuid,
username: user.email,
subscriptionPlanName: this.getSubscriptionNameFromRoles(roles),
})
if (traceSessionResult.isFailed()) {
this.logger.error(traceSessionResult.getError())
}
} catch (error) {
this.logger.debug(`Could not trace session while creating cross service token.: ${(error as Error).message}`)
}
return { return {
token: this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL), token: this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL),
} }
@ -100,17 +81,4 @@ export class CreateCrossServiceToken implements UseCaseInterface {
private projectRoles(roles: Array<Role>): Array<{ uuid: string; name: RoleName }> { private projectRoles(roles: Array<Role>): Array<{ uuid: string; name: RoleName }> {
return roles.map((role) => <{ uuid: string; name: RoleName }>this.roleProjector.projectSimple(role)) return roles.map((role) => <{ uuid: string; name: RoleName }>this.roleProjector.projectSimple(role))
} }
private getSubscriptionNameFromRoles(roles: Array<Role>): string | null {
const nonSubscriptionRoles = this.roleToSubscriptionMap.filterSubscriptionRoles(roles)
if (nonSubscriptionRoles.length === 0) {
return null
}
const subscriptionName = this.roleToSubscriptionMap.getSubscriptionNameForRoleName(
nonSubscriptionRoles[0].name as RoleName,
)
return subscriptionName === undefined ? null : subscriptionName
}
} }