1
0
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:
Jason Rasmussen 2023-10-30 11:48:38 -04:00 committed by GitHub
parent ce04e9e07a
commit 603b056512
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 76 additions and 20 deletions

View File

@ -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));

View File

@ -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');
}); });
}); });

View File

@ -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);
} }
} }

View File

@ -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>;

View File

@ -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[]>;
} }

View File

@ -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({

View File

@ -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 });
} }
} }

View File

@ -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)

View File

@ -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}`)

View File

@ -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',
}; };

View File

@ -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(),