mirror of
https://github.com/immich-app/immich.git
synced 2024-12-26 10:50:29 +02:00
refactor(server): auth delete device (#4720)
* refactor(server): auth delete device * fix: person e2e
This commit is contained in:
parent
ce04e9e07a
commit
603b056512
@ -21,6 +21,8 @@ export enum Permission {
|
|||||||
ALBUM_SHARE = 'album.share',
|
ALBUM_SHARE = 'album.share',
|
||||||
ALBUM_DOWNLOAD = 'album.download',
|
ALBUM_DOWNLOAD = 'album.download',
|
||||||
|
|
||||||
|
AUTH_DEVICE_DELETE = 'authDevice.delete',
|
||||||
|
|
||||||
ARCHIVE_READ = 'archive.read',
|
ARCHIVE_READ = 'archive.read',
|
||||||
|
|
||||||
TIMELINE_READ = 'timeline.read',
|
TIMELINE_READ = 'timeline.read',
|
||||||
@ -196,6 +198,9 @@ export class AccessCore {
|
|||||||
case Permission.ARCHIVE_READ:
|
case Permission.ARCHIVE_READ:
|
||||||
return authUser.id === id;
|
return authUser.id === id;
|
||||||
|
|
||||||
|
case Permission.AUTH_DEVICE_DELETE:
|
||||||
|
return this.repository.authDevice.hasOwnerAccess(authUser.id, id);
|
||||||
|
|
||||||
case Permission.TIMELINE_READ:
|
case Permission.TIMELINE_READ:
|
||||||
return authUser.id === id || (await this.repository.timeline.hasPartnerAccess(authUser.id, id));
|
return authUser.id === id || (await this.repository.timeline.hasPartnerAccess(authUser.id, id));
|
||||||
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { UserEntity } from '@app/infra/entities';
|
import { UserEntity } from '@app/infra/entities';
|
||||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
IAccessRepositoryMock,
|
||||||
authStub,
|
authStub,
|
||||||
keyStub,
|
keyStub,
|
||||||
loginResponseStub,
|
loginResponseStub,
|
||||||
|
newAccessRepositoryMock,
|
||||||
newCryptoRepositoryMock,
|
newCryptoRepositoryMock,
|
||||||
newKeyRepositoryMock,
|
newKeyRepositoryMock,
|
||||||
newLibraryRepositoryMock,
|
newLibraryRepositoryMock,
|
||||||
@ -52,6 +54,7 @@ const fixtures = {
|
|||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let sut: AuthService;
|
let sut: AuthService;
|
||||||
|
let accessMock: jest.Mocked<IAccessRepositoryMock>;
|
||||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||||
let userMock: jest.Mocked<IUserRepository>;
|
let userMock: jest.Mocked<IUserRepository>;
|
||||||
let libraryMock: jest.Mocked<ILibraryRepository>;
|
let libraryMock: jest.Mocked<ILibraryRepository>;
|
||||||
@ -84,6 +87,7 @@ describe('AuthService', () => {
|
|||||||
}),
|
}),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
accessMock = newAccessRepositoryMock();
|
||||||
cryptoMock = newCryptoRepositoryMock();
|
cryptoMock = newCryptoRepositoryMock();
|
||||||
userMock = newUserRepositoryMock();
|
userMock = newUserRepositoryMock();
|
||||||
libraryMock = newLibraryRepositoryMock();
|
libraryMock = newLibraryRepositoryMock();
|
||||||
@ -92,7 +96,7 @@ describe('AuthService', () => {
|
|||||||
shareMock = newSharedLinkRepositoryMock();
|
shareMock = newSharedLinkRepositoryMock();
|
||||||
keyMock = newKeyRepositoryMock();
|
keyMock = newKeyRepositoryMock();
|
||||||
|
|
||||||
sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, libraryMock, shareMock, keyMock);
|
sut = new AuthService(accessMock, cryptoMock, configMock, libraryMock, userMock, userTokenMock, shareMock, keyMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@ -218,7 +222,7 @@ describe('AuthService', () => {
|
|||||||
redirectUri: '/auth/login?autoLaunch=0',
|
redirectUri: '/auth/login?autoLaunch=0',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123');
|
expect(userTokenMock.delete).toHaveBeenCalledWith('token123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
|
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
|
||||||
@ -384,16 +388,19 @@ describe('AuthService', () => {
|
|||||||
await sut.logoutDevices(authStub.user1);
|
await sut.logoutDevices(authStub.user1);
|
||||||
|
|
||||||
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
|
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
|
||||||
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'not_active');
|
expect(userTokenMock.delete).toHaveBeenCalledWith('not_active');
|
||||||
expect(userTokenMock.delete).not.toHaveBeenCalledWith(authStub.user1.id, 'token-id');
|
expect(userTokenMock.delete).not.toHaveBeenCalledWith('token-id');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('logoutDevice', () => {
|
describe('logoutDevice', () => {
|
||||||
it('should logout the device', async () => {
|
it('should logout the device', async () => {
|
||||||
|
accessMock.authDevice.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await sut.logoutDevice(authStub.user1, 'token-1');
|
await sut.logoutDevice(authStub.user1, 'token-1');
|
||||||
|
|
||||||
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
|
expect(accessMock.authDevice.hasOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
|
||||||
|
expect(userTokenMock.delete).toHaveBeenCalledWith('token-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -11,7 +11,9 @@ import cookieParser from 'cookie';
|
|||||||
import { IncomingHttpHeaders } from 'http';
|
import { IncomingHttpHeaders } from 'http';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
|
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
|
||||||
|
import { AccessCore, Permission } from '../access';
|
||||||
import {
|
import {
|
||||||
|
IAccessRepository,
|
||||||
ICryptoRepository,
|
ICryptoRepository,
|
||||||
IKeyRepository,
|
IKeyRepository,
|
||||||
ILibraryRepository,
|
ILibraryRepository,
|
||||||
@ -61,19 +63,22 @@ interface OAuthProfile extends UserinfoResponse {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private userCore: UserCore;
|
private access: AccessCore;
|
||||||
private configCore: SystemConfigCore;
|
private configCore: SystemConfigCore;
|
||||||
private logger = new Logger(AuthService.name);
|
private logger = new Logger(AuthService.name);
|
||||||
|
private userCore: UserCore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
|
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
|
||||||
@Inject(IUserRepository) userRepository: IUserRepository,
|
@Inject(IUserRepository) userRepository: IUserRepository,
|
||||||
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
|
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
|
||||||
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
|
|
||||||
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
|
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
|
||||||
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
|
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
|
||||||
) {
|
) {
|
||||||
|
this.access = AccessCore.create(accessRepository);
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
|
this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository);
|
||||||
|
|
||||||
@ -104,7 +109,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
|
async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||||
if (authUser.accessTokenId) {
|
if (authUser.accessTokenId) {
|
||||||
await this.userTokenRepository.delete(authUser.id, authUser.accessTokenId);
|
await this.userTokenRepository.delete(authUser.accessTokenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -175,8 +180,9 @@ export class AuthService {
|
|||||||
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
|
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
|
async logoutDevice(authUser: AuthUserDto, id: string): Promise<void> {
|
||||||
await this.userTokenRepository.delete(authUser.id, deviceId);
|
await this.access.requirePermission(authUser, Permission.AUTH_DEVICE_DELETE, id);
|
||||||
|
await this.userTokenRepository.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logoutDevices(authUser: AuthUserDto): Promise<void> {
|
async logoutDevices(authUser: AuthUserDto): Promise<void> {
|
||||||
@ -185,7 +191,7 @@ export class AuthService {
|
|||||||
if (device.id === authUser.accessTokenId) {
|
if (device.id === authUser.accessTokenId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.userTokenRepository.delete(authUser.id, device.id);
|
await this.userTokenRepository.delete(device.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,6 +8,10 @@ export interface IAccessRepository {
|
|||||||
hasSharedLinkAccess(sharedLinkId: string, assetId: string): Promise<boolean>;
|
hasSharedLinkAccess(sharedLinkId: string, assetId: string): Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
authDevice: {
|
||||||
|
hasOwnerAccess(userId: string, deviceId: string): Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
album: {
|
album: {
|
||||||
hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
|
hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
|
||||||
hasSharedAlbumAccess(userId: string, albumId: string): Promise<boolean>;
|
hasSharedAlbumAccess(userId: string, albumId: string): Promise<boolean>;
|
||||||
|
@ -5,7 +5,7 @@ export const IUserTokenRepository = 'IUserTokenRepository';
|
|||||||
export interface IUserTokenRepository {
|
export interface IUserTokenRepository {
|
||||||
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||||
save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||||
delete(userId: string, id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
getByToken(token: string): Promise<UserTokenEntity | null>;
|
getByToken(token: string): Promise<UserTokenEntity | null>;
|
||||||
getAll(userId: string): Promise<UserTokenEntity[]>;
|
getAll(userId: string): Promise<UserTokenEntity[]>;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,25 @@
|
|||||||
import { IAccessRepository } from '@app/domain';
|
import { IAccessRepository } from '@app/domain';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AlbumEntity, AssetEntity, LibraryEntity, PartnerEntity, PersonEntity, SharedLinkEntity } from '../entities';
|
import {
|
||||||
|
AlbumEntity,
|
||||||
|
AssetEntity,
|
||||||
|
LibraryEntity,
|
||||||
|
PartnerEntity,
|
||||||
|
PersonEntity,
|
||||||
|
SharedLinkEntity,
|
||||||
|
UserTokenEntity,
|
||||||
|
} from '../entities';
|
||||||
|
|
||||||
export class AccessRepository implements IAccessRepository {
|
export class AccessRepository implements IAccessRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||||
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
|
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
|
||||||
|
@InjectRepository(LibraryEntity) private libraryRepository: Repository<LibraryEntity>,
|
||||||
@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
|
@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
|
||||||
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
||||||
@InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
|
@InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
|
||||||
@InjectRepository(LibraryEntity) private libraryRepository: Repository<LibraryEntity>,
|
@InjectRepository(UserTokenEntity) private tokenRepository: Repository<UserTokenEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
library = {
|
library = {
|
||||||
@ -148,6 +157,17 @@ export class AccessRepository implements IAccessRepository {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
authDevice = {
|
||||||
|
hasOwnerAccess: (userId: string, deviceId: string): Promise<boolean> => {
|
||||||
|
return this.tokenRepository.exist({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
id: deviceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
album = {
|
album = {
|
||||||
hasOwnerAccess: (userId: string, albumId: string): Promise<boolean> => {
|
hasOwnerAccess: (userId: string, albumId: string): Promise<boolean> => {
|
||||||
return this.albumRepository.exist({
|
return this.albumRepository.exist({
|
||||||
|
@ -35,7 +35,7 @@ export class UserTokenRepository implements IUserTokenRepository {
|
|||||||
return this.repository.save(userToken);
|
return this.repository.save(userToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(userId: string, id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
await this.repository.delete({ userId, id });
|
await this.repository.delete({ id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -189,6 +189,14 @@ describe(`${AuthController.name} (e2e)`, () => {
|
|||||||
expect(body).toEqual(errorStub.unauthorized);
|
expect(body).toEqual(errorStub.unauthorized);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should throw an error for a non-existent device id', async () => {
|
||||||
|
const { status, body } = await request(server)
|
||||||
|
.delete(`/auth/devices/${uuidStub.notFound}`)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`);
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorStub.badRequest('Not found or no authDevice.delete access'));
|
||||||
|
});
|
||||||
|
|
||||||
it('should logout a device', async () => {
|
it('should logout a device', async () => {
|
||||||
const [device] = await api.authApi.getAuthDevices(server, accessToken);
|
const [device] = await api.authApi.getAuthDevices(server, accessToken);
|
||||||
const { status } = await request(server)
|
const { status } = await request(server)
|
||||||
|
@ -139,10 +139,10 @@ describe(`${PersonController.name}`, () => {
|
|||||||
|
|
||||||
it('should not accept invalid birth dates', async () => {
|
it('should not accept invalid birth dates', async () => {
|
||||||
for (const { birthDate, response } of [
|
for (const { birthDate, response } of [
|
||||||
{ birthDate: false, response: ['id must be a UUID'] },
|
{ birthDate: false, response: 'Not found or no person.write access' },
|
||||||
{ birthDate: 'false', response: ['birthDate must be a Date instance'] },
|
{ birthDate: 'false', response: ['birthDate must be a Date instance'] },
|
||||||
{ birthDate: '123567', response: ['id must be a UUID'] },
|
{ birthDate: '123567', response: 'Not found or no person.write access' },
|
||||||
{ birthDate: 123456, response: ['id must be a UUID'] },
|
{ birthDate: 123567, response: 'Not found or no person.write access' },
|
||||||
]) {
|
]) {
|
||||||
const { status, body } = await request(server)
|
const { status, body } = await request(server)
|
||||||
.put(`/person/${uuidStub.notFound}`)
|
.put(`/person/${uuidStub.notFound}`)
|
||||||
|
3
server/test/fixtures/uuid.stub.ts
vendored
3
server/test/fixtures/uuid.stub.ts
vendored
@ -1,4 +1,5 @@
|
|||||||
export const uuidStub = {
|
export const uuidStub = {
|
||||||
invalid: 'invalid-uuid',
|
invalid: 'invalid-uuid',
|
||||||
notFound: '00000000-0000-0000-0000-000000000000',
|
// valid uuid v4
|
||||||
|
notFound: '00000000-0000-4000-a000-000000000000',
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@ import { AccessCore, IAccessRepository } from '@app/domain';
|
|||||||
export interface IAccessRepositoryMock {
|
export interface IAccessRepositoryMock {
|
||||||
asset: jest.Mocked<IAccessRepository['asset']>;
|
asset: jest.Mocked<IAccessRepository['asset']>;
|
||||||
album: jest.Mocked<IAccessRepository['album']>;
|
album: jest.Mocked<IAccessRepository['album']>;
|
||||||
|
authDevice: jest.Mocked<IAccessRepository['authDevice']>;
|
||||||
library: jest.Mocked<IAccessRepository['library']>;
|
library: jest.Mocked<IAccessRepository['library']>;
|
||||||
timeline: jest.Mocked<IAccessRepository['timeline']>;
|
timeline: jest.Mocked<IAccessRepository['timeline']>;
|
||||||
person: jest.Mocked<IAccessRepository['person']>;
|
person: jest.Mocked<IAccessRepository['person']>;
|
||||||
@ -27,6 +28,10 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
|
|||||||
hasSharedLinkAccess: jest.fn(),
|
hasSharedLinkAccess: jest.fn(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
authDevice: {
|
||||||
|
hasOwnerAccess: jest.fn(),
|
||||||
|
},
|
||||||
|
|
||||||
library: {
|
library: {
|
||||||
hasOwnerAccess: jest.fn(),
|
hasOwnerAccess: jest.fn(),
|
||||||
hasPartnerAccess: jest.fn(),
|
hasPartnerAccess: jest.fn(),
|
||||||
|
Loading…
Reference in New Issue
Block a user