feat(auth): invalidate other sessions for user if the email or password are changed (#684)

* feat(auth): invalidate other sessions for user if the email or password are changed

* fix(auth): handling credentials change in a legacy protocol scenario

* fix(auth): leave only the newly created session when changing credentials
This commit is contained in:
Karol Sójko
2023-08-07 10:02:47 +02:00
committed by GitHub
parent 8e47491e3c
commit f39d3aca5b
35 changed files with 488 additions and 335 deletions

View File

@@ -38,7 +38,7 @@ import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyP
import { UpdateUser } from '../Domain/UseCase/UpdateUser'
import { RedisEphemeralSessionRepository } from '../Infra/Redis/RedisEphemeralSessionRepository'
import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser'
import { DeletePreviousSessionsForUser } from '../Domain/UseCase/DeletePreviousSessionsForUser'
import { DeleteOtherSessionsForUser } from '../Domain/UseCase/DeleteOtherSessionsForUser'
import { DeleteSessionForUser } from '../Domain/UseCase/DeleteSessionForUser'
import { Register } from '../Domain/UseCase/Register'
import { LockRepository } from '../Infra/Redis/LockRepository'
@@ -827,9 +827,7 @@ export class ContainerConfigLoader {
container.bind<UpdateUser>(TYPES.Auth_UpdateUser).to(UpdateUser)
container.bind<Register>(TYPES.Auth_Register).to(Register)
container.bind<GetActiveSessionsForUser>(TYPES.Auth_GetActiveSessionsForUser).to(GetActiveSessionsForUser)
container
.bind<DeletePreviousSessionsForUser>(TYPES.Auth_DeletePreviousSessionsForUser)
.to(DeletePreviousSessionsForUser)
container.bind<DeleteOtherSessionsForUser>(TYPES.Auth_DeleteOtherSessionsForUser).to(DeleteOtherSessionsForUser)
container.bind<DeleteSessionForUser>(TYPES.Auth_DeleteSessionForUser).to(DeleteSessionForUser)
container.bind<ChangeCredentials>(TYPES.Auth_ChangeCredentials).to(ChangeCredentials)
container.bind<GetSettings>(TYPES.Auth_GetSettings).to(GetSettings)
@@ -1178,7 +1176,7 @@ export class ContainerConfigLoader {
.toConstantValue(
new BaseSessionController(
container.get(TYPES.Auth_DeleteSessionForUser),
container.get(TYPES.Auth_DeletePreviousSessionsForUser),
container.get(TYPES.Auth_DeleteOtherSessionsForUser),
container.get(TYPES.Auth_RefreshSessionToken),
container.get(TYPES.Auth_ControllerContainer),
),

View File

@@ -113,7 +113,7 @@ const TYPES = {
Auth_UpdateUser: Symbol.for('Auth_UpdateUser'),
Auth_Register: Symbol.for('Auth_Register'),
Auth_GetActiveSessionsForUser: Symbol.for('Auth_GetActiveSessionsForUser'),
Auth_DeletePreviousSessionsForUser: Symbol.for('Auth_DeletePreviousSessionsForUser'),
Auth_DeleteOtherSessionsForUser: Symbol.for('Auth_DeleteOtherSessionsForUser'),
Auth_DeleteSessionForUser: Symbol.for('Auth_DeleteSessionForUser'),
Auth_ChangeCredentials: Symbol.for('Auth_ChangePassword'),
Auth_GetSettings: Symbol.for('Auth_GetSettings'),

View File

@@ -30,7 +30,7 @@ describe('AuthResponseFactory20161215', () => {
})
it('should create a 20161215 auth response', async () => {
const response = await createFactory().createResponse({
const result = await createFactory().createResponse({
user,
apiVersion: '20161215',
userAgent: 'Google Chrome',
@@ -38,7 +38,7 @@ describe('AuthResponseFactory20161215', () => {
readonlyAccess: false,
})
expect(response).toEqual({
expect(result.response).toEqual({
user: { foo: 'bar' },
token: 'foobar',
})

View File

@@ -11,6 +11,7 @@ import { User } from '../User/User'
import { AuthResponse20161215 } from './AuthResponse20161215'
import { AuthResponse20200115 } from './AuthResponse20200115'
import { AuthResponseFactoryInterface } from './AuthResponseFactoryInterface'
import { Session } from '../Session/Session'
@injectable()
export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface {
@@ -26,7 +27,7 @@ export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface
userAgent: string
ephemeralSession: boolean
readonlyAccess: boolean
}): Promise<AuthResponse20161215 | AuthResponse20200115> {
}): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }> {
this.logger.debug(`Creating JWT auth response for user ${dto.user.uuid}`)
const data: SessionTokenData = {
@@ -39,12 +40,14 @@ export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface
this.logger.debug(`Created JWT token for user ${dto.user.uuid}: ${token}`)
return {
user: this.userProjector.projectSimple(dto.user) as {
uuid: string
email: string
protocolVersion: ProtocolVersion
response: {
user: this.userProjector.projectSimple(dto.user) as {
uuid: string
email: string
protocolVersion: ProtocolVersion
},
token,
},
token,
}
}
}

View File

@@ -29,7 +29,7 @@ describe('AuthResponseFactory20190520', () => {
})
it('should create a 20161215 auth response', async () => {
const response = await createFactory().createResponse({
const result = await createFactory().createResponse({
user,
apiVersion: '20161215',
userAgent: 'Google Chrome',
@@ -37,7 +37,7 @@ describe('AuthResponseFactory20190520', () => {
readonlyAccess: false,
})
expect(response).toEqual({
expect(result.response).toEqual({
user: { foo: 'bar' },
token: 'foobar',
})

View File

@@ -11,6 +11,7 @@ import { User } from '../User/User'
import { AuthResponseFactory20200115 } from './AuthResponseFactory20200115'
import { DomainEventPublisherInterface } from '@standardnotes/domain-events'
import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface'
import { Session } from '../Session/Session'
describe('AuthResponseFactory20200115', () => {
let sessionService: SessionServiceInterface
@@ -48,8 +49,12 @@ describe('AuthResponseFactory20200115', () => {
}
sessionService = {} as jest.Mocked<SessionServiceInterface>
sessionService.createNewSessionForUser = jest.fn().mockReturnValue(sessionPayload)
sessionService.createNewEphemeralSessionForUser = jest.fn().mockReturnValue(sessionPayload)
sessionService.createNewSessionForUser = jest
.fn()
.mockReturnValue({ sessionHttpRepresentation: sessionPayload, session: {} as jest.Mocked<Session> })
sessionService.createNewEphemeralSessionForUser = jest
.fn()
.mockReturnValue({ sessionHttpRepresentation: sessionPayload, session: {} as jest.Mocked<Session> })
keyParamsFactory = {} as jest.Mocked<KeyParamsFactoryInterface>
keyParamsFactory.create = jest.fn().mockReturnValue({
@@ -76,7 +81,7 @@ describe('AuthResponseFactory20200115', () => {
it('should create a 20161215 auth response if user does not support sessions', async () => {
user.supportsSessions = jest.fn().mockReturnValue(false)
const response = await createFactory().createResponse({
const result = await createFactory().createResponse({
user,
apiVersion: '20161215',
userAgent: 'Google Chrome',
@@ -84,7 +89,7 @@ describe('AuthResponseFactory20200115', () => {
readonlyAccess: false,
})
expect(response).toEqual({
expect(result.response).toEqual({
user: { foo: 'bar' },
token: expect.any(String),
})
@@ -93,7 +98,7 @@ describe('AuthResponseFactory20200115', () => {
it('should create a 20200115 auth response', async () => {
user.supportsSessions = jest.fn().mockReturnValue(true)
const response = await createFactory().createResponse({
const result = await createFactory().createResponse({
user,
apiVersion: '20200115',
userAgent: 'Google Chrome',
@@ -101,7 +106,7 @@ describe('AuthResponseFactory20200115', () => {
readonlyAccess: false,
})
expect(response).toEqual({
expect(result.response).toEqual({
key_params: {
key1: 'value1',
key2: 'value2',
@@ -124,7 +129,7 @@ describe('AuthResponseFactory20200115', () => {
domainEventPublisher.publish = jest.fn().mockRejectedValue(new Error('test'))
user.supportsSessions = jest.fn().mockReturnValue(true)
const response = await createFactory().createResponse({
const result = await createFactory().createResponse({
user,
apiVersion: '20200115',
userAgent: 'Google Chrome',
@@ -132,7 +137,7 @@ describe('AuthResponseFactory20200115', () => {
readonlyAccess: false,
})
expect(response).toEqual({
expect(result.response).toEqual({
key_params: {
key1: 'value1',
key2: 'value2',
@@ -153,7 +158,7 @@ describe('AuthResponseFactory20200115', () => {
it('should create a 20200115 auth response with an ephemeral session', async () => {
user.supportsSessions = jest.fn().mockReturnValue(true)
const response = await createFactory().createResponse({
const result = await createFactory().createResponse({
user,
apiVersion: '20200115',
userAgent: 'Google Chrome',
@@ -161,7 +166,7 @@ describe('AuthResponseFactory20200115', () => {
readonlyAccess: false,
})
expect(response).toEqual({
expect(result.response).toEqual({
key_params: {
key1: 'value1',
key2: 'value2',
@@ -183,11 +188,14 @@ describe('AuthResponseFactory20200115', () => {
user.supportsSessions = jest.fn().mockReturnValue(true)
sessionService.createNewSessionForUser = jest.fn().mockReturnValue({
...sessionPayload,
readonly_access: true,
sessionHttpRepresentation: {
...sessionPayload,
readonly_access: true,
},
session: {} as jest.Mocked<Session>,
})
const response = await createFactory().createResponse({
const result = await createFactory().createResponse({
user,
apiVersion: '20200115',
userAgent: 'Google Chrome',
@@ -195,7 +203,7 @@ describe('AuthResponseFactory20200115', () => {
readonlyAccess: true,
})
expect(response).toEqual({
expect(result.response).toEqual({
key_params: {
key1: 'value1',
key2: 'value2',

View File

@@ -19,6 +19,7 @@ import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterfac
import { AuthResponse20161215 } from './AuthResponse20161215'
import { AuthResponse20200115 } from './AuthResponse20200115'
import { Session } from '../Session/Session'
@injectable()
export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 {
@@ -40,21 +41,28 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 {
userAgent: string
ephemeralSession: boolean
readonlyAccess: boolean
}): Promise<AuthResponse20161215 | AuthResponse20200115> {
}): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }> {
if (!dto.user.supportsSessions()) {
this.logger.debug(`User ${dto.user.uuid} does not support sessions. Falling back to JWT auth response`)
return super.createResponse(dto)
}
const sessionPayload = await this.createSession(dto)
const sessionCreationResult = await this.createSession(dto)
this.logger.debug('Created session payload for user %s: %O', dto.user.uuid, sessionPayload)
this.logger.debug(
'Created session payload for user %s: %O',
dto.user.uuid,
sessionCreationResult.sessionHttpRepresentation,
)
return {
session: sessionPayload,
key_params: this.keyParamsFactory.create(dto.user, true),
user: this.userProjector.projectSimple(dto.user) as SimpleUserProjection,
response: {
session: sessionCreationResult.sessionHttpRepresentation,
key_params: this.keyParamsFactory.create(dto.user, true),
user: this.userProjector.projectSimple(dto.user) as SimpleUserProjection,
},
session: sessionCreationResult.session,
}
}
@@ -64,12 +72,12 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 {
userAgent: string
ephemeralSession: boolean
readonlyAccess: boolean
}): Promise<SessionBody> {
}): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> {
if (dto.ephemeralSession) {
return this.sessionService.createNewEphemeralSessionForUser(dto)
}
const session = this.sessionService.createNewSessionForUser(dto)
const sessionCreationResult = await this.sessionService.createNewSessionForUser(dto)
try {
await this.domainEventPublisher.publish(
@@ -79,6 +87,6 @@ export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 {
this.logger.error(`Failed to publish session created event: ${(error as Error).message}`)
}
return session
return sessionCreationResult
}
}

View File

@@ -1,3 +1,4 @@
import { Session } from '../Session/Session'
import { User } from '../User/User'
import { AuthResponse20161215 } from './AuthResponse20161215'
import { AuthResponse20200115 } from './AuthResponse20200115'
@@ -9,5 +10,5 @@ export interface AuthResponseFactoryInterface {
userAgent: string
ephemeralSession: boolean
readonlyAccess: boolean
}): Promise<AuthResponse20161215 | AuthResponse20200115>
}): Promise<{ response: AuthResponse20161215 | AuthResponse20200115; session?: Session }>
}

View File

@@ -1,3 +1,5 @@
import { Uuid } from '@standardnotes/domain-core'
import { Session } from './Session'
export interface SessionRepositoryInterface {
@@ -5,7 +7,7 @@ export interface SessionRepositoryInterface {
findOneByUuidAndUserUuid(uuid: string, userUuid: string): Promise<Session | null>
findAllByRefreshExpirationAndUserUuid(userUuid: string): Promise<Array<Session>>
findAllByUserUuid(userUuid: string): Promise<Array<Session>>
deleteAllByUserUuid(userUuid: string, currentSessionUuid: string): Promise<void>
deleteAllByUserUuidExceptOne(dto: { userUuid: Uuid; currentSessionUuid: Uuid }): Promise<void>
deleteOneByUuid(uuid: string): Promise<void>
updateHashedTokens(uuid: string, hashedAccessToken: string, hashedRefreshToken: string): Promise<void>
updatedTokenExpirationDates(uuid: string, accessExpiration: Date, refreshExpiration: Date): Promise<void>

View File

@@ -154,7 +154,7 @@ describe('SessionService', () => {
const user = {} as jest.Mocked<User>
user.uuid = '123'
const sessionPayload = await createService().createNewSessionForUser({
const result = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
@@ -176,7 +176,7 @@ describe('SessionService', () => {
readonlyAccess: false,
})
expect(sessionPayload).toEqual({
expect(result.sessionHttpRepresentation).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
@@ -190,7 +190,7 @@ describe('SessionService', () => {
user.email = 'demo@standardnotes.com'
user.uuid = '123'
const sessionPayload = await createService().createNewSessionForUser({
const result = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
@@ -212,7 +212,7 @@ describe('SessionService', () => {
readonlyAccess: true,
})
expect(sessionPayload).toEqual({
expect(result.sessionHttpRepresentation).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
@@ -229,7 +229,7 @@ describe('SessionService', () => {
value: LogSessionUserAgentOption.Disabled,
} as jest.Mocked<Setting>)
const sessionPayload = await createService().createNewSessionForUser({
const result = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
@@ -250,7 +250,7 @@ describe('SessionService', () => {
readonlyAccess: false,
})
expect(sessionPayload).toEqual({
expect(result.sessionHttpRepresentation).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
@@ -305,7 +305,7 @@ describe('SessionService', () => {
user.uuid = '123'
user.email = 'test@test.te'
const sessionPayload = await createService().createNewSessionForUser({
const result = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
@@ -317,7 +317,7 @@ describe('SessionService', () => {
username: 'test@test.te',
subscriptionPlanName: null,
})
expect(sessionPayload).toEqual({
expect(result.sessionHttpRepresentation).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
@@ -333,7 +333,7 @@ describe('SessionService', () => {
user.uuid = '123'
user.email = 'test@test.te'
const sessionPayload = await createService().createNewSessionForUser({
const result = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
@@ -345,7 +345,7 @@ describe('SessionService', () => {
username: 'test@test.te',
subscriptionPlanName: null,
})
expect(sessionPayload).toEqual({
expect(result.sessionHttpRepresentation).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
@@ -361,7 +361,7 @@ describe('SessionService', () => {
user.uuid = '123'
user.email = 'test@test.te'
const sessionPayload = await createService().createNewSessionForUser({
const result = await createService().createNewSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
@@ -373,7 +373,7 @@ describe('SessionService', () => {
username: 'test@test.te',
subscriptionPlanName: null,
})
expect(sessionPayload).toEqual({
expect(result.sessionHttpRepresentation).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,
@@ -386,7 +386,7 @@ describe('SessionService', () => {
const user = {} as jest.Mocked<User>
user.uuid = '123'
const sessionPayload = await createService().createNewEphemeralSessionForUser({
const result = await createService().createNewEphemeralSessionForUser({
user,
apiVersion: '003',
userAgent: 'Google Chrome',
@@ -408,7 +408,7 @@ describe('SessionService', () => {
readonlyAccess: false,
})
expect(sessionPayload).toEqual({
expect(result.sessionHttpRepresentation).toEqual({
access_expiration: 123,
access_token: expect.any(String),
refresh_expiration: 123,

View File

@@ -49,7 +49,7 @@ export class SessionService implements SessionServiceInterface {
apiVersion: string
userAgent: string
readonlyAccess: boolean
}): Promise<SessionBody> {
}): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> {
const session = await this.createSession({
ephemeral: false,
...dto,
@@ -73,7 +73,10 @@ export class SessionService implements SessionServiceInterface {
this.logger.error(`Could not trace session while creating cross service token.: ${(error as Error).message}`)
}
return sessionPayload
return {
sessionHttpRepresentation: sessionPayload,
session,
}
}
async createNewEphemeralSessionForUser(dto: {
@@ -81,7 +84,7 @@ export class SessionService implements SessionServiceInterface {
apiVersion: string
userAgent: string
readonlyAccess: boolean
}): Promise<SessionBody> {
}): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }> {
const ephemeralSession = await this.createSession({
ephemeral: true,
...dto,
@@ -91,7 +94,10 @@ export class SessionService implements SessionServiceInterface {
await this.ephemeralSessionRepository.save(ephemeralSession)
return sessionPayload
return {
sessionHttpRepresentation: sessionPayload,
session: ephemeralSession,
}
}
async refreshTokens(session: Session): Promise<SessionBody> {

View File

@@ -9,13 +9,13 @@ export interface SessionServiceInterface {
apiVersion: string
userAgent: string
readonlyAccess: boolean
}): Promise<SessionBody>
}): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }>
createNewEphemeralSessionForUser(dto: {
user: User
apiVersion: string
userAgent: string
readonlyAccess: boolean
}): Promise<SessionBody>
}): Promise<{ sessionHttpRepresentation: SessionBody; session: Session }>
refreshTokens(session: Session): Promise<SessionBody>
getSessionFromToken(token: string): Promise<Session | undefined>
getRevokedSessionFromToken(token: string): Promise<RevokedSession | null>

View File

@@ -11,7 +11,10 @@ import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { ChangeCredentials } from './ChangeCredentials'
import { Username } from '@standardnotes/domain-core'
import { Result, Username } from '@standardnotes/domain-core'
import { DeleteOtherSessionsForUser } from '../DeleteOtherSessionsForUser'
import { ApiVersion } from '../../Api/ApiVersion'
import { Session } from '../../Session/Session'
describe('ChangeCredentials', () => {
let userRepository: UserRepositoryInterface
@@ -21,13 +24,23 @@ describe('ChangeCredentials', () => {
let domainEventFactory: DomainEventFactoryInterface
let timer: TimerInterface
let user: User
let deleteOtherSessionsForUser: DeleteOtherSessionsForUser
const createUseCase = () =>
new ChangeCredentials(userRepository, authResponseFactoryResolver, domainEventPublisher, domainEventFactory, timer)
new ChangeCredentials(
userRepository,
authResponseFactoryResolver,
domainEventPublisher,
domainEventFactory,
timer,
deleteOtherSessionsForUser,
)
beforeEach(() => {
authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
authResponseFactory.createResponse = jest
.fn()
.mockReturnValue({ response: { foo: 'bar' }, session: { uuid: '1-2-3' } as jest.Mocked<Session> })
authResponseFactoryResolver = {} as jest.Mocked<AuthResponseFactoryResolverInterface>
authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory)
@@ -49,27 +62,25 @@ describe('ChangeCredentials', () => {
timer = {} as jest.Mocked<TimerInterface>
timer.getUTCDate = jest.fn().mockReturnValue(new Date(1))
deleteOtherSessionsForUser = {} as jest.Mocked<DeleteOtherSessionsForUser>
deleteOtherSessionsForUser.execute = jest.fn().mockReturnValue(Result.ok())
})
it('should change password', async () => {
expect(
await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: '20190520',
currentPassword: 'qweqwe123123',
newPassword: 'test234',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
}),
).toEqual({
success: true,
authResponse: {
foo: 'bar',
},
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'qweqwe123123',
newPassword: 'test234',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
})
expect(result.isFailed()).toBeFalsy()
expect(userRepository.save).toHaveBeenCalledWith({
encryptedPassword: expect.any(String),
pwNonce: 'asdzxc',
@@ -81,29 +92,24 @@ describe('ChangeCredentials', () => {
})
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
expect(deleteOtherSessionsForUser.execute).toHaveBeenCalled()
})
it('should change email', async () => {
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValueOnce(user).mockReturnValueOnce(null)
expect(
await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: '20190520',
currentPassword: 'qweqwe123123',
newPassword: 'test234',
newEmail: 'new@test.te',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
}),
).toEqual({
success: true,
authResponse: {
foo: 'bar',
},
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'qweqwe123123',
newPassword: 'test234',
newEmail: 'new@test.te',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
})
expect(result.isFailed()).toBeFalsy()
expect(userRepository.save).toHaveBeenCalledWith({
encryptedPassword: expect.any(String),
@@ -116,6 +122,7 @@ describe('ChangeCredentials', () => {
})
expect(domainEventFactory.createUserEmailChangedEvent).toHaveBeenCalledWith('1-2-3', 'test@test.te', 'new@test.te')
expect(domainEventPublisher.publish).toHaveBeenCalled()
expect(deleteOtherSessionsForUser.execute).toHaveBeenCalled()
})
it('should not change email if already taken', async () => {
@@ -124,22 +131,19 @@ describe('ChangeCredentials', () => {
.mockReturnValueOnce(user)
.mockReturnValueOnce({} as jest.Mocked<User>)
expect(
await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: '20190520',
currentPassword: 'qweqwe123123',
newPassword: 'test234',
newEmail: 'new@test.te',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
}),
).toEqual({
success: false,
errorMessage: 'The email you entered is already taken. Please try again.',
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'qweqwe123123',
newPassword: 'test234',
newEmail: 'new@test.te',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('The email you entered is already taken. Please try again.')
expect(userRepository.save).not.toHaveBeenCalled()
expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
@@ -147,22 +151,19 @@ describe('ChangeCredentials', () => {
})
it('should not change email if the new email is invalid', async () => {
expect(
await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: '20190520',
currentPassword: 'qweqwe123123',
newPassword: 'test234',
newEmail: '',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
}),
).toEqual({
success: false,
errorMessage: 'Username cannot be empty',
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'qweqwe123123',
newPassword: 'test234',
newEmail: '',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Username cannot be empty')
expect(userRepository.save).not.toHaveBeenCalled()
expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
@@ -172,63 +173,52 @@ describe('ChangeCredentials', () => {
it('should not change email if the user is not found', async () => {
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(null)
expect(
await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: '20190520',
currentPassword: 'qweqwe123123',
newPassword: 'test234',
newEmail: '',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
}),
).toEqual({
success: false,
errorMessage: 'User not found.',
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'qweqwe123123',
newPassword: 'test234',
newEmail: '',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('User not found.')
expect(userRepository.save).not.toHaveBeenCalled()
expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
})
it('should not change password if current password is incorrect', async () => {
expect(
await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: '20190520',
currentPassword: 'test123',
newPassword: 'test234',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
}),
).toEqual({
success: false,
errorMessage: 'The current password you entered is incorrect. Please try again.',
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'test123',
newPassword: 'test234',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('The current password you entered is incorrect. Please try again.')
expect(userRepository.save).not.toHaveBeenCalled()
})
it('should update protocol version while changing password', async () => {
expect(
await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: '20190520',
currentPassword: 'qweqwe123123',
newPassword: 'test234',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
protocolVersion: '004',
}),
).toEqual({
success: true,
authResponse: {
foo: 'bar',
},
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'qweqwe123123',
newPassword: 'test234',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
protocolVersion: '004',
})
expect(result.isFailed()).toBeFalsy()
expect(userRepository.save).toHaveBeenCalledWith({
encryptedPassword: expect.any(String),
@@ -239,4 +229,63 @@ describe('ChangeCredentials', () => {
updatedAt: new Date(1),
})
})
it('should not delete other sessions for user if neither passoword nor email are changed', async () => {
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValueOnce(user)
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'qweqwe123123',
newPassword: 'qweqwe123123',
newEmail: undefined,
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
})
expect(result.isFailed()).toBeFalsy()
expect(userRepository.save).toHaveBeenCalledWith({
encryptedPassword: expect.any(String),
email: 'test@test.te',
uuid: '1-2-3',
pwNonce: 'asdzxc',
kpCreated: '123',
kpOrigination: 'password-change',
updatedAt: new Date(1),
})
expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled()
expect(domainEventPublisher.publish).not.toHaveBeenCalled()
expect(deleteOtherSessionsForUser.execute).not.toHaveBeenCalled()
})
it('should not delete other sessions for user if the caller does not support sessions', async () => {
authResponseFactory.createResponse = jest.fn().mockReturnValue({ response: { foo: 'bar' } })
const result = await createUseCase().execute({
username: Username.create('test@test.te').getValue(),
apiVersion: ApiVersion.v20200115,
currentPassword: 'qweqwe123123',
newPassword: 'test234',
pwNonce: 'asdzxc',
updatedWithUserAgent: 'Google Chrome',
kpCreated: '123',
kpOrigination: 'password-change',
})
expect(result.isFailed()).toBeFalsy()
expect(userRepository.save).toHaveBeenCalledWith({
encryptedPassword: expect.any(String),
pwNonce: 'asdzxc',
kpCreated: '123',
email: 'test@test.te',
uuid: '1-2-3',
kpOrigination: 'password-change',
updatedAt: new Date(1),
})
expect(deleteOtherSessionsForUser.execute).not.toHaveBeenCalled()
})
})

View File

@@ -1,20 +1,22 @@
import * as bcrypt from 'bcryptjs'
import { inject, injectable } from 'inversify'
import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { Result, UseCaseInterface, Username } from '@standardnotes/domain-core'
import TYPES from '../../../Bootstrap/Types'
import { AuthResponseFactoryResolverInterface } from '../../Auth/AuthResponseFactoryResolverInterface'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { ChangeCredentialsDTO } from './ChangeCredentialsDTO'
import { ChangeCredentialsResponse } from './ChangeCredentialsResponse'
import { UseCaseInterface } from '../UseCaseInterface'
import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface'
import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events'
import { TimerInterface } from '@standardnotes/time'
import { Username } from '@standardnotes/domain-core'
import { DeleteOtherSessionsForUser } from '../DeleteOtherSessionsForUser'
import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215'
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
import { Session } from '../../Session/Session'
@injectable()
export class ChangeCredentials implements UseCaseInterface {
export class ChangeCredentials implements UseCaseInterface<AuthResponse20161215 | AuthResponse20200115> {
constructor(
@inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.Auth_AuthResponseFactoryResolver)
@@ -22,22 +24,18 @@ export class ChangeCredentials implements UseCaseInterface {
@inject(TYPES.Auth_DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface,
@inject(TYPES.Auth_DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface,
@inject(TYPES.Auth_Timer) private timer: TimerInterface,
@inject(TYPES.Auth_DeleteOtherSessionsForUser)
private deleteOtherSessionsForUserUseCase: DeleteOtherSessionsForUser,
) {}
async execute(dto: ChangeCredentialsDTO): Promise<ChangeCredentialsResponse> {
async execute(dto: ChangeCredentialsDTO): Promise<Result<AuthResponse20161215 | AuthResponse20200115>> {
const user = await this.userRepository.findOneByUsernameOrEmail(dto.username)
if (!user) {
return {
success: false,
errorMessage: 'User not found.',
}
return Result.fail('User not found.')
}
if (!(await bcrypt.compare(dto.currentPassword, user.encryptedPassword))) {
return {
success: false,
errorMessage: 'The current password you entered is incorrect. Please try again.',
}
return Result.fail('The current password you entered is incorrect. Please try again.')
}
user.encryptedPassword = await bcrypt.hash(dto.newPassword, User.PASSWORD_HASH_COST)
@@ -46,19 +44,13 @@ export class ChangeCredentials implements UseCaseInterface {
if (dto.newEmail !== undefined) {
const newUsernameOrError = Username.create(dto.newEmail)
if (newUsernameOrError.isFailed()) {
return {
success: false,
errorMessage: newUsernameOrError.getError(),
}
return Result.fail(newUsernameOrError.getError())
}
const newUsername = newUsernameOrError.getValue()
const existingUser = await this.userRepository.findOneByUsernameOrEmail(newUsername)
if (existingUser !== null) {
return {
success: false,
errorMessage: 'The email you entered is already taken. Please try again.',
}
return Result.fail('The email you entered is already taken. Please try again.')
}
userEmailChangedEvent = this.domainEventFactory.createUserEmailChangedEvent(
@@ -90,15 +82,35 @@ export class ChangeCredentials implements UseCaseInterface {
const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion)
return {
success: true,
authResponse: await authResponseFactory.createResponse({
user: updatedUser,
apiVersion: dto.apiVersion,
userAgent: dto.updatedWithUserAgent,
ephemeralSession: false,
readonlyAccess: false,
}),
const authResponse = await authResponseFactory.createResponse({
user: updatedUser,
apiVersion: dto.apiVersion,
userAgent: dto.updatedWithUserAgent,
ephemeralSession: false,
readonlyAccess: false,
})
if (authResponse.session) {
await this.deleteOtherSessionsForUserIfNeeded(user.uuid, authResponse.session, dto)
}
return Result.ok(authResponse.response)
}
private async deleteOtherSessionsForUserIfNeeded(
userUuid: string,
session: Session,
dto: ChangeCredentialsDTO,
): Promise<void> {
const passwordHasChanged = dto.newPassword !== dto.currentPassword
const userEmailChanged = dto.newEmail !== undefined
if (passwordHasChanged || userEmailChanged) {
await this.deleteOtherSessionsForUserUseCase.execute({
userUuid,
currentSessionUuid: session.uuid,
markAsRevoked: false,
})
}
}
}

View File

@@ -1,8 +0,0 @@
import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215'
import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115'
export type ChangeCredentialsResponse = {
success: boolean
authResponse?: AuthResponse20161215 | AuthResponse20200115
errorMessage?: string
}

View File

@@ -0,0 +1,82 @@
import 'reflect-metadata'
import { Session } from '../Session/Session'
import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
import { SessionServiceInterface } from '../Session/SessionServiceInterface'
import { DeleteOtherSessionsForUser } from './DeleteOtherSessionsForUser'
describe('DeleteOtherSessionsForUser', () => {
let sessionRepository: SessionRepositoryInterface
let sessionService: SessionServiceInterface
let session: Session
let currentSession: Session
const createUseCase = () => new DeleteOtherSessionsForUser(sessionRepository, sessionService)
beforeEach(() => {
session = {} as jest.Mocked<Session>
session.uuid = '00000000-0000-0000-0000-000000000000'
currentSession = {} as jest.Mocked<Session>
currentSession.uuid = '00000000-0000-0000-0000-000000000001'
sessionRepository = {} as jest.Mocked<SessionRepositoryInterface>
sessionRepository.deleteAllByUserUuidExceptOne = jest.fn()
sessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([session, currentSession])
sessionService = {} as jest.Mocked<SessionServiceInterface>
sessionService.createRevokedSession = jest.fn()
})
it('should delete all sessions except current for a given user', async () => {
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
currentSessionUuid: '00000000-0000-0000-0000-000000000001',
markAsRevoked: true,
})
expect(result.isFailed()).toBeFalsy()
expect(sessionRepository.deleteAllByUserUuidExceptOne).toHaveBeenCalled()
expect(sessionService.createRevokedSession).toHaveBeenCalledWith(session)
expect(sessionService.createRevokedSession).not.toHaveBeenCalledWith(currentSession)
})
it('should delete all sessions except current for a given user without marking as revoked', async () => {
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
currentSessionUuid: '00000000-0000-0000-0000-000000000001',
markAsRevoked: false,
})
expect(result.isFailed()).toBeFalsy()
expect(sessionRepository.deleteAllByUserUuidExceptOne).toHaveBeenCalled()
expect(sessionService.createRevokedSession).not.toHaveBeenCalled()
})
it('should not delete any sessions if the user uuid is invalid', async () => {
const result = await createUseCase().execute({
userUuid: 'invalid',
currentSessionUuid: '00000000-0000-0000-0000-000000000001',
markAsRevoked: true,
})
expect(result.isFailed()).toBeTruthy()
expect(sessionRepository.deleteAllByUserUuidExceptOne).not.toHaveBeenCalled()
expect(sessionService.createRevokedSession).not.toHaveBeenCalled()
})
it('should not delete any sessions if the current session uuid is invalid', async () => {
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
currentSessionUuid: 'invalid',
markAsRevoked: true,
})
expect(result.isFailed()).toBeTruthy()
expect(sessionRepository.deleteAllByUserUuidExceptOne).not.toHaveBeenCalled()
expect(sessionService.createRevokedSession).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,46 @@
import { inject, injectable } from 'inversify'
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import TYPES from '../../Bootstrap/Types'
import { Session } from '../Session/Session'
import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
import { SessionServiceInterface } from '../Session/SessionServiceInterface'
import { DeleteOtherSessionsForUserDTO } from './DeleteOtherSessionsForUserDTO'
@injectable()
export class DeleteOtherSessionsForUser implements UseCaseInterface<void> {
constructor(
@inject(TYPES.Auth_SessionRepository) private sessionRepository: SessionRepositoryInterface,
@inject(TYPES.Auth_SessionService) private sessionService: SessionServiceInterface,
) {}
async execute(dto: DeleteOtherSessionsForUserDTO): Promise<Result<void>> {
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(userUuidOrError.getError())
}
const userUuid = userUuidOrError.getValue()
const currentSessionUuidOrError = Uuid.create(dto.currentSessionUuid)
if (currentSessionUuidOrError.isFailed()) {
return Result.fail(currentSessionUuidOrError.getError())
}
const currentSessionUuid = currentSessionUuidOrError.getValue()
const sessions = await this.sessionRepository.findAllByUserUuid(dto.userUuid)
if (dto.markAsRevoked) {
await Promise.all(
sessions.map(async (session: Session) => {
if (session.uuid !== currentSessionUuid.value) {
await this.sessionService.createRevokedSession(session)
}
}),
)
}
await this.sessionRepository.deleteAllByUserUuidExceptOne({ userUuid, currentSessionUuid })
return Result.ok()
}
}

View File

@@ -0,0 +1,5 @@
export type DeleteOtherSessionsForUserDTO = {
userUuid: string
currentSessionUuid: string
markAsRevoked: boolean
}

View File

@@ -1,39 +0,0 @@
import 'reflect-metadata'
import { Session } from '../Session/Session'
import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
import { SessionServiceInterface } from '../Session/SessionServiceInterface'
import { DeletePreviousSessionsForUser } from './DeletePreviousSessionsForUser'
describe('DeletePreviousSessionsForUser', () => {
let sessionRepository: SessionRepositoryInterface
let sessionService: SessionServiceInterface
let session: Session
let currentSession: Session
const createUseCase = () => new DeletePreviousSessionsForUser(sessionRepository, sessionService)
beforeEach(() => {
session = {} as jest.Mocked<Session>
session.uuid = '1-2-3'
currentSession = {} as jest.Mocked<Session>
currentSession.uuid = '2-3-4'
sessionRepository = {} as jest.Mocked<SessionRepositoryInterface>
sessionRepository.deleteAllByUserUuid = jest.fn()
sessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([session, currentSession])
sessionService = {} as jest.Mocked<SessionServiceInterface>
sessionService.createRevokedSession = jest.fn()
})
it('should delete all sessions except current for a given user', async () => {
expect(await createUseCase().execute({ userUuid: '1-2-3', currentSessionUuid: '2-3-4' })).toEqual({ success: true })
expect(sessionRepository.deleteAllByUserUuid).toHaveBeenCalledWith('1-2-3', '2-3-4')
expect(sessionService.createRevokedSession).toHaveBeenCalledWith(session)
expect(sessionService.createRevokedSession).not.toHaveBeenCalledWith(currentSession)
})
})

View File

@@ -1,32 +0,0 @@
import { inject, injectable } from 'inversify'
import TYPES from '../../Bootstrap/Types'
import { Session } from '../Session/Session'
import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface'
import { SessionServiceInterface } from '../Session/SessionServiceInterface'
import { DeletePreviousSessionsForUserDTO } from './DeletePreviousSessionsForUserDTO'
import { DeletePreviousSessionsForUserResponse } from './DeletePreviousSessionsForUserResponse'
import { UseCaseInterface } from './UseCaseInterface'
@injectable()
export class DeletePreviousSessionsForUser implements UseCaseInterface {
constructor(
@inject(TYPES.Auth_SessionRepository) private sessionRepository: SessionRepositoryInterface,
@inject(TYPES.Auth_SessionService) private sessionService: SessionServiceInterface,
) {}
async execute(dto: DeletePreviousSessionsForUserDTO): Promise<DeletePreviousSessionsForUserResponse> {
const sessions = await this.sessionRepository.findAllByUserUuid(dto.userUuid)
await Promise.all(
sessions.map(async (session: Session) => {
if (session.uuid !== dto.currentSessionUuid) {
await this.sessionService.createRevokedSession(session)
}
}),
)
await this.sessionRepository.deleteAllByUserUuid(dto.userUuid, dto.currentSessionUuid)
return { success: true }
}
}

View File

@@ -1,4 +0,0 @@
export type DeletePreviousSessionsForUserDTO = {
userUuid: string
currentSessionUuid: string
}

View File

@@ -1,3 +0,0 @@
export type DeletePreviousSessionsForUserResponse = {
success: boolean
}

View File

@@ -10,6 +10,7 @@ import { UserRepositoryInterface } from '../User/UserRepositoryInterface'
import { Register } from './Register'
import { SettingServiceInterface } from '../Setting/SettingServiceInterface'
import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115'
import { Session } from '../Session/Session'
describe('Register', () => {
let userRepository: UserRepositoryInterface
@@ -32,7 +33,9 @@ describe('Register', () => {
roleRepository.findOneByName = jest.fn().mockReturnValue(null)
authResponseFactory = {} as jest.Mocked<AuthResponseFactory20200115>
authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
authResponseFactory.createResponse = jest
.fn()
.mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked<Session> })
crypter = {} as jest.Mocked<CrypterInterface>
crypter.generateEncryptedUserServerKey = jest.fn().mockReturnValue('test')

View File

@@ -83,15 +83,17 @@ export class Register implements UseCaseInterface {
await this.settingService.applyDefaultSettingsUponRegistration(user)
const result = await this.authResponseFactory20200115.createResponse({
user,
apiVersion,
userAgent: dto.updatedWithUserAgent,
ephemeralSession,
readonlyAccess: false,
})
return {
success: true,
authResponse: (await this.authResponseFactory20200115.createResponse({
user,
apiVersion,
userAgent: dto.updatedWithUserAgent,
ephemeralSession,
readonlyAccess: false,
})) as AuthResponse20200115,
authResponse: result.response as AuthResponse20200115,
}
}
}

View File

@@ -13,6 +13,7 @@ import { SignIn } from './SignIn'
import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface'
import { CrypterInterface } from '../Encryption/CrypterInterface'
import { ProtocolVersion } from '@standardnotes/common'
import { Session } from '../Session/Session'
describe('SignIn', () => {
let user: User
@@ -50,7 +51,9 @@ describe('SignIn', () => {
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(user)
authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
authResponseFactory.createResponse = jest
.fn()
.mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked<Session> })
authResponseFactoryResolver = {} as jest.Mocked<AuthResponseFactoryResolverInterface>
authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory)

View File

@@ -95,15 +95,17 @@ export class SignIn implements UseCaseInterface {
await this.sendSignInEmailNotification(user, dto.userAgent)
const result = await authResponseFactory.createResponse({
user,
apiVersion: dto.apiVersion,
userAgent: dto.userAgent,
ephemeralSession: dto.ephemeralSession,
readonlyAccess: false,
})
return {
success: true,
authResponse: await authResponseFactory.createResponse({
user,
apiVersion: dto.apiVersion,
userAgent: dto.userAgent,
ephemeralSession: dto.ephemeralSession,
readonlyAccess: false,
}),
authResponse: result.response,
}
}

View File

@@ -124,7 +124,7 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<AuthResponse202
await this.clearLoginAttempts.execute({ email: username.value })
return Result.ok(authResponse as AuthResponse20200115)
return Result.ok(authResponse.response as AuthResponse20200115)
}
private async validateCodeVerifier(codeVerifier: string): Promise<boolean> {

View File

@@ -8,6 +8,7 @@ import { AuthResponseFactoryInterface } from '../Auth/AuthResponseFactoryInterfa
import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface'
import { UpdateUser } from './UpdateUser'
import { Session } from '../Session/Session'
describe('UpdateUser', () => {
let userRepository: UserRepositoryInterface
@@ -24,7 +25,9 @@ describe('UpdateUser', () => {
userRepository.findOneByUsernameOrEmail = jest.fn().mockReturnValue(undefined)
authResponseFactory = {} as jest.Mocked<AuthResponseFactoryInterface>
authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' })
authResponseFactory.createResponse = jest
.fn()
.mockReturnValue({ response: { foo: 'bar' }, session: {} as jest.Mocked<Session> })
authResponseFactoryResolver = {} as jest.Mocked<AuthResponseFactoryResolverInterface>
authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory)

View File

@@ -23,15 +23,17 @@ export class UpdateUser implements UseCaseInterface {
const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion)
const result = await authResponseFactory.createResponse({
user: updatedUser,
apiVersion: dto.apiVersion,
userAgent: dto.updatedWithUserAgent,
ephemeralSession: false,
readonlyAccess: false,
})
return {
success: true,
authResponse: await authResponseFactory.createResponse({
user: updatedUser,
apiVersion: dto.apiVersion,
userAgent: dto.updatedWithUserAgent,
ephemeralSession: false,
readonlyAccess: false,
}),
authResponse: result.response,
}
}
}

View File

@@ -4,26 +4,26 @@ import * as express from 'express'
import { AnnotatedSessionController } from './AnnotatedSessionController'
import { results } from 'inversify-express-utils'
import { DeletePreviousSessionsForUser } from '../../Domain/UseCase/DeletePreviousSessionsForUser'
import { DeleteOtherSessionsForUser } from '../../Domain/UseCase/DeleteOtherSessionsForUser'
import { DeleteSessionForUser } from '../../Domain/UseCase/DeleteSessionForUser'
import { RefreshSessionToken } from '../../Domain/UseCase/RefreshSessionToken'
describe('AnnotatedSessionController', () => {
let deleteSessionForUser: DeleteSessionForUser
let deletePreviousSessionsForUser: DeletePreviousSessionsForUser
let deleteOtherSessionsForUser: DeleteOtherSessionsForUser
let refreshSessionToken: RefreshSessionToken
let request: express.Request
let response: express.Response
const createController = () =>
new AnnotatedSessionController(deleteSessionForUser, deletePreviousSessionsForUser, refreshSessionToken)
new AnnotatedSessionController(deleteSessionForUser, deleteOtherSessionsForUser, refreshSessionToken)
beforeEach(() => {
deleteSessionForUser = {} as jest.Mocked<DeleteSessionForUser>
deleteSessionForUser.execute = jest.fn().mockReturnValue({ success: true })
deletePreviousSessionsForUser = {} as jest.Mocked<DeletePreviousSessionsForUser>
deletePreviousSessionsForUser.execute = jest.fn()
deleteOtherSessionsForUser = {} as jest.Mocked<DeleteOtherSessionsForUser>
deleteOtherSessionsForUser.execute = jest.fn()
refreshSessionToken = {} as jest.Mocked<RefreshSessionToken>
refreshSessionToken.execute = jest.fn()
@@ -196,9 +196,10 @@ describe('AnnotatedSessionController', () => {
const httpResult = <results.JsonResult>await createController().deleteAllSessions(request, response)
const result = await httpResult.executeAsync()
expect(deletePreviousSessionsForUser.execute).toHaveBeenCalledWith({
expect(deleteOtherSessionsForUser.execute).toHaveBeenCalledWith({
userUuid: '123',
currentSessionUuid: '234',
markAsRevoked: true,
})
expect(result.statusCode).toEqual(204)
@@ -218,7 +219,7 @@ describe('AnnotatedSessionController', () => {
const httpResponse = <results.JsonResult>await createController().deleteAllSessions(request, response)
const result = await httpResponse.executeAsync()
expect(deletePreviousSessionsForUser.execute).not.toHaveBeenCalled()
expect(deleteOtherSessionsForUser.execute).not.toHaveBeenCalled()
expect(result.statusCode).toEqual(401)
})

View File

@@ -8,7 +8,7 @@ import {
results,
} from 'inversify-express-utils'
import TYPES from '../../Bootstrap/Types'
import { DeletePreviousSessionsForUser } from '../../Domain/UseCase/DeletePreviousSessionsForUser'
import { DeleteOtherSessionsForUser } from '../../Domain/UseCase/DeleteOtherSessionsForUser'
import { DeleteSessionForUser } from '../../Domain/UseCase/DeleteSessionForUser'
import { RefreshSessionToken } from '../../Domain/UseCase/RefreshSessionToken'
import { BaseSessionController } from './Base/BaseSessionController'
@@ -17,11 +17,11 @@ import { BaseSessionController } from './Base/BaseSessionController'
export class AnnotatedSessionController extends BaseSessionController {
constructor(
@inject(TYPES.Auth_DeleteSessionForUser) override deleteSessionForUser: DeleteSessionForUser,
@inject(TYPES.Auth_DeletePreviousSessionsForUser)
override deletePreviousSessionsForUser: DeletePreviousSessionsForUser,
@inject(TYPES.Auth_DeleteOtherSessionsForUser)
override deleteOtherSessionsForUser: DeleteOtherSessionsForUser,
@inject(TYPES.Auth_RefreshSessionToken) override refreshSessionToken: RefreshSessionToken,
) {
super(deleteSessionForUser, deletePreviousSessionsForUser, refreshSessionToken)
super(deleteSessionForUser, deleteOtherSessionsForUser, refreshSessionToken)
}
@httpDelete('/', TYPES.Auth_RequiredCrossServiceTokenMiddleware, TYPES.Auth_SessionMiddleware)

View File

@@ -332,7 +332,7 @@ describe('AnnotatedUsersController', () => {
request.headers['user-agent'] = 'Google Chrome'
response.locals.user = user
changeCredentials.execute = jest.fn().mockReturnValue({ success: true, authResponse: { foo: 'bar' } })
changeCredentials.execute = jest.fn().mockReturnValue(Result.ok({ foo: 'bar' }))
const httpResponse = <results.JsonResult>await createController().changeCredentials(request, response)
const result = await httpResponse.executeAsync()
@@ -346,6 +346,7 @@ describe('AnnotatedUsersController', () => {
kpOrigination: 'change-password',
pwNonce: 'asdzxc',
protocolVersion: '004',
newEmail: undefined,
username: Username.create('test@test.te').getValue(),
})
@@ -385,7 +386,7 @@ describe('AnnotatedUsersController', () => {
request.headers['user-agent'] = 'Google Chrome'
response.locals.user = user
changeCredentials.execute = jest.fn().mockReturnValue({ success: false, errorMessage: 'Something bad happened' })
changeCredentials.execute = jest.fn().mockReturnValue(Result.fail('Something bad happened'))
const httpResponse = <results.JsonResult>await createController().changeCredentials(request, response)
const result = await httpResponse.executeAsync()

View File

@@ -3,14 +3,14 @@ import { Request, Response } from 'express'
import { BaseHttpController, results } from 'inversify-express-utils'
import { ErrorTag } from '@standardnotes/responses'
import { DeletePreviousSessionsForUser } from '../../../Domain/UseCase/DeletePreviousSessionsForUser'
import { DeleteOtherSessionsForUser } from '../../../Domain/UseCase/DeleteOtherSessionsForUser'
import { DeleteSessionForUser } from '../../../Domain/UseCase/DeleteSessionForUser'
import { RefreshSessionToken } from '../../../Domain/UseCase/RefreshSessionToken'
export class BaseSessionController extends BaseHttpController {
constructor(
protected deleteSessionForUser: DeleteSessionForUser,
protected deletePreviousSessionsForUser: DeletePreviousSessionsForUser,
protected deleteOtherSessionsForUser: DeleteOtherSessionsForUser,
protected refreshSessionToken: RefreshSessionToken,
private controllerContainer?: ControllerContainerInterface,
) {
@@ -106,9 +106,10 @@ export class BaseSessionController extends BaseHttpController {
)
}
await this.deletePreviousSessionsForUser.execute({
await this.deleteOtherSessionsForUser.execute({
userUuid: response.locals.user.uuid,
currentSessionUuid: response.locals.session.uuid,
markAsRevoked: true,
})
response.setHeader('x-invalidate-cache', response.locals.user.uuid)

View File

@@ -228,13 +228,13 @@ export class BaseUsersController extends BaseHttpController {
protocolVersion: request.body.version,
})
if (!changeCredentialsResult.success) {
if (changeCredentialsResult.isFailed()) {
await this.increaseLoginAttempts.execute({ email: response.locals.user.email })
return this.json(
{
error: {
message: changeCredentialsResult.errorMessage,
message: changeCredentialsResult.getError(),
},
},
401,
@@ -245,6 +245,6 @@ export class BaseUsersController extends BaseHttpController {
response.setHeader('x-invalidate-cache', response.locals.user.uuid)
return this.json(changeCredentialsResult.authResponse)
return this.json(changeCredentialsResult.getValue())
}
}

View File

@@ -7,6 +7,7 @@ import TYPES from '../../Bootstrap/Types'
import { Session } from '../../Domain/Session/Session'
import { SessionRepositoryInterface } from '../../Domain/Session/SessionRepositoryInterface'
import { Uuid } from '@standardnotes/domain-core'
@injectable()
export class TypeORMSessionRepository implements SessionRepositoryInterface {
@@ -100,13 +101,13 @@ export class TypeORMSessionRepository implements SessionRepositoryInterface {
.getMany()
}
async deleteAllByUserUuid(userUuid: string, currentSessionUuid: string): Promise<void> {
async deleteAllByUserUuidExceptOne(dto: { userUuid: Uuid; currentSessionUuid: Uuid }): Promise<void> {
await this.ormRepository
.createQueryBuilder('session')
.delete()
.where('user_uuid = :user_uuid AND uuid != :current_session_uuid', {
user_uuid: userUuid,
current_session_uuid: currentSessionUuid,
user_uuid: dto.userUuid.value,
current_session_uuid: dto.currentSessionUuid.value,
})
.execute()
}