fix: shared check for server setup availability (#30311)
* fix: shared check for server setup availability * chore: add medium test * feat: require @Authenticated decorator everywhere * fix: lints
This commit is contained in:
@@ -45,7 +45,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
|
|||||||
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
|
||||||
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
|
||||||
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
|
||||||
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
|
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` and `/admin/database-backups/start-restore` endpoints | `true` | server | api |
|
||||||
|
|
||||||
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
|
||||||
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ export const errorDto = {
|
|||||||
incorrectLogin: {
|
incorrectLogin: {
|
||||||
message: 'Incorrect email or password',
|
message: 'Incorrect email or password',
|
||||||
},
|
},
|
||||||
alreadyHasAdmin: {
|
|
||||||
message: 'The server already has an admin',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signupResponseDto = {
|
export const signupResponseDto = {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe('/admin/database-backups', () => {
|
|||||||
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
|
expect(body).toEqual(errorDto.badRequest('Admin setup is not available'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Header } from '@nestjs/common';
|
import { Controller, Get, Header } from '@nestjs/common';
|
||||||
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
import { ApiExcludeEndpoint } from '@nestjs/swagger';
|
||||||
|
import { Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { SystemConfigService } from 'src/services/system-config.service';
|
import { SystemConfigService } from 'src/services/system-config.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -8,6 +9,7 @@ export class AppController {
|
|||||||
|
|
||||||
@ApiExcludeEndpoint()
|
@ApiExcludeEndpoint()
|
||||||
@Get('.well-known/immich')
|
@Get('.well-known/immich')
|
||||||
|
@Authenticated({ public: true })
|
||||||
getImmichWellKnown() {
|
getImmichWellKnown() {
|
||||||
return {
|
return {
|
||||||
api: {
|
api: {
|
||||||
@@ -18,6 +20,7 @@ export class AppController {
|
|||||||
|
|
||||||
@ApiExcludeEndpoint()
|
@ApiExcludeEndpoint()
|
||||||
@Get('custom.css')
|
@Get('custom.css')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Header('Content-Type', 'text/css')
|
@Header('Content-Type', 'text/css')
|
||||||
getCustomCss() {
|
getCustomCss() {
|
||||||
return this.service.getCustomCss();
|
return this.service.getCustomCss();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { AuthController } from 'src/controllers/auth.controller';
|
import { AuthController } from 'src/controllers/auth.controller';
|
||||||
import { LoginResponseDto } from 'src/dtos/auth.dto';
|
import { LoginResponseDto } from 'src/dtos/auth.dto';
|
||||||
import { AuthService } from 'src/services/auth.service';
|
import { AuthService } from 'src/services/auth.service';
|
||||||
@@ -76,6 +77,18 @@ describe(AuthController.name, () => {
|
|||||||
.send({ name: 'admin', password: 'password', email: 'admin@local' });
|
.send({ name: 'admin', password: 'password', email: 'admin@local' });
|
||||||
expect(status).toEqual(201);
|
expect(status).toEqual(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not sign up an admin when setup is unavailable', async () => {
|
||||||
|
ctx.requireSetupAvailable.mockRejectedValue(new BadRequestException('Admin setup is not available'));
|
||||||
|
|
||||||
|
const { status, body } = await request(ctx.getHttpServer())
|
||||||
|
.post('/auth/admin-sign-up')
|
||||||
|
.send({ name, email, password });
|
||||||
|
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('Admin setup is not available'));
|
||||||
|
expect(service.adminSignUp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /auth/login', () => {
|
describe('POST /auth/login', () => {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class AuthController {
|
|||||||
description: 'Login with username and password and receive a session token.',
|
description: 'Login with username and password and receive a session token.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||||
})
|
})
|
||||||
|
@Authenticated({ public: true })
|
||||||
async login(
|
async login(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Body() loginCredential: LoginCredentialDto,
|
@Body() loginCredential: LoginCredentialDto,
|
||||||
@@ -55,6 +56,7 @@ export class AuthController {
|
|||||||
description: 'Create the first admin user in the system.',
|
description: 'Create the first admin user in the system.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||||
})
|
})
|
||||||
|
@Authenticated({ public: true, setup: true })
|
||||||
signUpAdmin(@Body() dto: SignUpDto): Promise<UserAdminResponseDto> {
|
signUpAdmin(@Body() dto: SignUpDto): Promise<UserAdminResponseDto> {
|
||||||
return this.service.adminSignUp(dto);
|
return this.service.adminSignUp(dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||||
|
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||||
|
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { errorDto } from 'test/medium/responses';
|
||||||
|
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||||
|
|
||||||
|
describe(DatabaseBackupController.name, () => {
|
||||||
|
let ctx: ControllerContext;
|
||||||
|
const service = automock(DatabaseBackupService, { args: [{ setContext: () => {} }], strict: false });
|
||||||
|
const maintenanceService = mockBaseService(MaintenanceService);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ctx = await controllerSetup(DatabaseBackupController, [
|
||||||
|
{ provide: DatabaseBackupService, useValue: service },
|
||||||
|
{ provide: MaintenanceService, useValue: maintenanceService },
|
||||||
|
]);
|
||||||
|
return () => ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service.resetAllMocks();
|
||||||
|
maintenanceService.resetAllMocks();
|
||||||
|
ctx.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /admin/database-backups', () => {
|
||||||
|
it('should be an authenticated route', async () => {
|
||||||
|
await request(ctx.getHttpServer()).get('/admin/database-backups').send();
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /admin/database-backups/start-restore', () => {
|
||||||
|
it('should not be an authenticated route', async () => {
|
||||||
|
maintenanceService.startRestoreFlow.mockResolvedValue({ jwt: 'jwt' });
|
||||||
|
|
||||||
|
await request(ctx.getHttpServer()).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
|
expect(ctx.authenticate).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.requireSetupAvailable).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not start a restore when setup is unavailable', async () => {
|
||||||
|
ctx.requireSetupAvailable.mockRejectedValue(new BadRequestException('Admin setup is not available'));
|
||||||
|
|
||||||
|
const { status, body } = await request(ctx.getHttpServer()).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
|
expect(status).toEqual(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('Admin setup is not available'));
|
||||||
|
expect(maintenanceService.startRestoreFlow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -71,6 +71,7 @@ export class DatabaseBackupController {
|
|||||||
description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)',
|
description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)',
|
||||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||||
})
|
})
|
||||||
|
@Authenticated({ public: true, setup: true })
|
||||||
async startDatabaseRestoreFlow(
|
async startDatabaseRestoreFlow(
|
||||||
@GetLoginDetails() loginDetails: LoginDetails,
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
|||||||
@@ -53,12 +53,10 @@ describe('controllers', () => {
|
|||||||
expect(new Set(reachableByNonAdmins)).toEqual(UNAUTHENTICATED_ADMIN_ROUTES);
|
expect(new Set(reachableByNonAdmins)).toEqual(UNAUTHENTICATED_ADMIN_ROUTES);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not authenticate the bootstrap routes under admin/', () => {
|
it('should declare authentication on every route', () => {
|
||||||
const authenticated = routes
|
const undeclared = routes.filter((route) => route.auth === undefined).map((route) => route.label);
|
||||||
.filter((route) => UNAUTHENTICATED_ADMIN_ROUTES.has(route.id) && route.auth !== undefined)
|
|
||||||
.map((route) => route.label);
|
|
||||||
|
|
||||||
expect(authenticated).toEqual([]);
|
expect(undeclared).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require admin access for routes with an admin permission', () => {
|
it('should require admin access for routes with an admin permission', () => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export class MaintenanceController {
|
|||||||
description: 'Fetch information about the currently running maintenance action.',
|
description: 'Fetch information about the currently running maintenance action.',
|
||||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||||
})
|
})
|
||||||
|
@Authenticated({ public: true })
|
||||||
getMaintenanceStatus(): MaintenanceStatusResponseDto {
|
getMaintenanceStatus(): MaintenanceStatusResponseDto {
|
||||||
return this.service.getMaintenanceStatus();
|
return this.service.getMaintenanceStatus();
|
||||||
}
|
}
|
||||||
@@ -48,6 +49,7 @@ export class MaintenanceController {
|
|||||||
description: 'Login with maintenance token or cookie to receive current information and perform further actions.',
|
description: 'Login with maintenance token or cookie to receive current information and perform further actions.',
|
||||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||||
})
|
})
|
||||||
|
@Authenticated({ public: true })
|
||||||
maintenanceLogin(@Body() _dto: MaintenanceLoginDto): MaintenanceAuthDto {
|
maintenanceLogin(@Body() _dto: MaintenanceLoginDto): MaintenanceAuthDto {
|
||||||
throw new BadRequestException('Not in maintenance mode');
|
throw new BadRequestException('Not in maintenance mode');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export class OAuthController {
|
|||||||
constructor(private service: AuthService) {}
|
constructor(private service: AuthService) {}
|
||||||
|
|
||||||
@Get('mobile-redirect')
|
@Get('mobile-redirect')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Redirect()
|
@Redirect()
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Redirect OAuth to mobile',
|
summary: 'Redirect OAuth to mobile',
|
||||||
@@ -37,6 +38,7 @@ export class OAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('authorize')
|
@Post('authorize')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Start OAuth',
|
summary: 'Start OAuth',
|
||||||
description: 'Initiate the OAuth authorization process.',
|
description: 'Initiate the OAuth authorization process.',
|
||||||
@@ -62,6 +64,7 @@ export class OAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('callback')
|
@Post('callback')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Finish OAuth',
|
summary: 'Finish OAuth',
|
||||||
description: 'Complete the OAuth authorization process by exchanging the authorization code for a session token.',
|
description: 'Complete the OAuth authorization process by exchanging the authorization code for a session token.',
|
||||||
@@ -115,6 +118,7 @@ export class OAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('backchannel-logout')
|
@Post('backchannel-logout')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiConsumes('application/x-www-form-urlencoded')
|
@ApiConsumes('application/x-www-form-urlencoded')
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export class ServerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('ping')
|
@Get('ping')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Ping',
|
summary: 'Ping',
|
||||||
description: 'Pong',
|
description: 'Pong',
|
||||||
@@ -74,6 +75,7 @@ export class ServerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('version')
|
@Get('version')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get server version',
|
summary: 'Get server version',
|
||||||
description: 'Retrieve the current server version in semantic versioning (semver) format.',
|
description: 'Retrieve the current server version in semantic versioning (semver) format.',
|
||||||
@@ -84,6 +86,7 @@ export class ServerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('version-history')
|
@Get('version-history')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get version history',
|
summary: 'Get version history',
|
||||||
description: 'Retrieve a list of past versions the server has been on.',
|
description: 'Retrieve a list of past versions the server has been on.',
|
||||||
@@ -94,6 +97,7 @@ export class ServerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('features')
|
@Get('features')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get features',
|
summary: 'Get features',
|
||||||
description: 'Retrieve available features supported by this server.',
|
description: 'Retrieve available features supported by this server.',
|
||||||
@@ -104,6 +108,7 @@ export class ServerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('config')
|
@Get('config')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get config',
|
summary: 'Get config',
|
||||||
description: 'Retrieve the current server configuration.',
|
description: 'Retrieve the current server configuration.',
|
||||||
@@ -125,6 +130,7 @@ export class ServerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('media-types')
|
@Get('media-types')
|
||||||
|
@Authenticated({ public: true })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Get supported media types',
|
summary: 'Get supported media types',
|
||||||
description: 'Retrieve all media types supported by the server.',
|
description: 'Retrieve all media types supported by the server.',
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Authenticated, AuthGuard } from 'src/middleware/auth.guard';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { AuthService } from 'src/services/auth.service';
|
||||||
|
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||||
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
|
class TestController {
|
||||||
|
@Authenticated({ public: true, setup: true })
|
||||||
|
setupRoute() {}
|
||||||
|
|
||||||
|
@Authenticated({ public: true })
|
||||||
|
publicRoute() {}
|
||||||
|
|
||||||
|
undecoratedRoute() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextFor = (handler: () => void) =>
|
||||||
|
({
|
||||||
|
getHandler: () => handler,
|
||||||
|
switchToHttp: () => ({ getRequest: () => ({ headers: {}, query: {}, path: '/' }) }),
|
||||||
|
}) as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
describe(AuthGuard.name, () => {
|
||||||
|
let sut: AuthGuard;
|
||||||
|
let authService: AuthService;
|
||||||
|
let mocks: ServiceMocks;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ sut: authService, mocks } = newTestService(AuthService));
|
||||||
|
sut = new AuthGuard(mocks.logger as unknown as LoggingRepository, new Reflector(), authService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setup routes', () => {
|
||||||
|
it('should allow access while the server is awaiting its first admin', async () => {
|
||||||
|
mocks.user.hasAdmin.mockResolvedValue(false);
|
||||||
|
const authenticate = vitest.spyOn(authService, 'authenticate');
|
||||||
|
|
||||||
|
await expect(sut.canActivate(contextFor(TestController.prototype.setupRoute))).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(authenticate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when setup is disabled', async () => {
|
||||||
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ setup: { allow: false } }));
|
||||||
|
mocks.user.hasAdmin.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(sut.canActivate(contextFor(TestController.prototype.setupRoute))).rejects.toThrowError(
|
||||||
|
'Admin setup is not available',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when the server already has an admin', async () => {
|
||||||
|
mocks.user.hasAdmin.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(sut.canActivate(contextFor(TestController.prototype.setupRoute))).rejects.toThrowError(
|
||||||
|
'Admin setup is not available',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('public routes', () => {
|
||||||
|
it('should not require setup availability', async () => {
|
||||||
|
mocks.user.hasAdmin.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(sut.canActivate(contextFor(TestController.prototype.publicRoute))).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('undecorated routes', () => {
|
||||||
|
it('should be rejected', async () => {
|
||||||
|
const authenticate = vitest.spyOn(authService, 'authenticate');
|
||||||
|
|
||||||
|
await expect(sut.canActivate(contextFor(TestController.prototype.undecoratedRoute))).rejects.toThrowError(
|
||||||
|
'does not declare @Authenticated()',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(authenticate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,23 +17,26 @@ import { getUserAgentDetails } from 'src/utils/request';
|
|||||||
|
|
||||||
type AdminRoute = { admin?: true };
|
type AdminRoute = { admin?: true };
|
||||||
type SharedLinkRoute = { sharedLink?: true };
|
type SharedLinkRoute = { sharedLink?: true };
|
||||||
export type AuthenticatedOptions = { permission?: Permission | false } & (AdminRoute | SharedLinkRoute);
|
type AuthorizedRoute = { permission?: Permission | false; public?: never; setup?: never } & (
|
||||||
|
AdminRoute | SharedLinkRoute
|
||||||
|
);
|
||||||
|
type PublicRoute = { public: true; setup?: true; permission?: never; admin?: never; sharedLink?: never };
|
||||||
|
export type AuthenticatedOptions = AuthorizedRoute | PublicRoute;
|
||||||
|
|
||||||
type ReflectorTarget = Parameters<Reflector['get']>[1];
|
type ReflectorTarget = Parameters<Reflector['get']>[1];
|
||||||
|
|
||||||
/** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */
|
/** Resolves the `@Authenticated()` options of a route handler, with the defaults applied. */
|
||||||
export const getAuthenticatedOptions = (reflector: Reflector, target: ReflectorTarget) => {
|
export const getAuthenticatedOptions = (reflector: Reflector, target: ReflectorTarget) => {
|
||||||
const options = reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, [target]);
|
const options = reflector.getAllAndOverride<AuthenticatedOptions | undefined>(MetadataKey.AuthRoute, [target]);
|
||||||
return options && { sharedLink: false, admin: false, ...options };
|
return options && { sharedLink: false, admin: false, public: false, setup: false, ...options };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
|
export const Authenticated = (options: AuthenticatedOptions = {}): MethodDecorator => {
|
||||||
const decorators: MethodDecorator[] = [
|
const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)];
|
||||||
ApiBearerAuth(),
|
|
||||||
ApiCookieAuth(),
|
if (!options.public) {
|
||||||
ApiSecurity(MetadataKey.ApiKeySecurity),
|
decorators.push(ApiBearerAuth(), ApiCookieAuth(), ApiSecurity(MetadataKey.ApiKeySecurity));
|
||||||
SetMetadata(MetadataKey.AuthRoute, options),
|
}
|
||||||
];
|
|
||||||
|
|
||||||
if ((options as AdminRoute).admin) {
|
if ((options as AdminRoute).admin) {
|
||||||
decorators.push(ApiExtension(ApiCustomExtension.AdminOnly, true));
|
decorators.push(ApiExtension(ApiCustomExtension.AdminOnly, true));
|
||||||
@@ -96,6 +99,14 @@ export class AuthGuard implements CanActivate {
|
|||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const options = getAuthenticatedOptions(this.reflector, context.getHandler());
|
const options = getAuthenticatedOptions(this.reflector, context.getHandler());
|
||||||
if (!options) {
|
if (!options) {
|
||||||
|
throw new Error(`Route ${context.getHandler().name} does not declare @Authenticated()`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.setup) {
|
||||||
|
await this.authService.requireSetupAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.public) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -307,16 +307,7 @@ describe(AuthService.name, () => {
|
|||||||
describe('adminSignUp', () => {
|
describe('adminSignUp', () => {
|
||||||
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
|
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
|
||||||
|
|
||||||
it('should only allow one admin', async () => {
|
|
||||||
mocks.user.getAdmin.mockResolvedValue({} as UserAdmin);
|
|
||||||
|
|
||||||
await expect(sut.adminSignUp(dto)).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
|
|
||||||
expect(mocks.user.getAdmin).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should sign up the admin', async () => {
|
it('should sign up the admin', async () => {
|
||||||
mocks.user.getAdmin.mockResolvedValue(void 0);
|
|
||||||
mocks.user.create.mockResolvedValue({
|
mocks.user.create.mockResolvedValue({
|
||||||
...userStub.admin,
|
...userStub.admin,
|
||||||
...dto,
|
...dto,
|
||||||
@@ -334,7 +325,6 @@ describe(AuthService.name, () => {
|
|||||||
name: 'immich admin',
|
name: 'immich admin',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.user.getAdmin).toHaveBeenCalled();
|
|
||||||
expect(mocks.user.create).toHaveBeenCalled();
|
expect(mocks.user.create).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -197,16 +197,6 @@ export class AuthService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
|
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
|
||||||
const { setup } = this.configRepository.getEnv();
|
|
||||||
if (!setup.allow) {
|
|
||||||
throw new BadRequestException('Admin setup is disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUser = await this.userRepository.getAdmin();
|
|
||||||
if (adminUser) {
|
|
||||||
throw new BadRequestException('The server already has an admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
const admin = await this.createUser({
|
const admin = await this.createUser({
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
|
|||||||
@@ -280,6 +280,17 @@ export class BaseService {
|
|||||||
return checkAccess(this.accessRepository, request);
|
return checkAccess(this.accessRepository, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async isSetupAvailable(): Promise<boolean> {
|
||||||
|
const { setup } = this.configRepository.getEnv();
|
||||||
|
return setup.allow && !(await this.userRepository.hasAdmin());
|
||||||
|
}
|
||||||
|
|
||||||
|
async requireSetupAvailable(): Promise<void> {
|
||||||
|
if (!(await this.isSetupAvailable())) {
|
||||||
|
throw new BadRequestException('Admin setup is not available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
|
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserAdmin> {
|
||||||
const exists = await this.userRepository.getByEmail(dto.email);
|
const exists = await this.userRepository.getByEmail(dto.email);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
|
|||||||
@@ -134,6 +134,22 @@ describe(MaintenanceService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('startRestoreFlow', () => {
|
||||||
|
it('should start maintenance mode and return a jwt', async () => {
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
||||||
|
|
||||||
|
await expect(sut.startRestoreFlow()).resolves.toMatchObject({ jwt: expect.any(String) });
|
||||||
|
|
||||||
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: expect.stringMatching(/^\w{128}$/),
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.SelectDatabaseRestore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createLoginUrl', () => {
|
describe('createLoginUrl', () => {
|
||||||
it('should fail outside of maintenance mode without secret', async () => {
|
it('should fail outside of maintenance mode without secret', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import {
|
import {
|
||||||
MaintenanceAuthDto,
|
MaintenanceAuthDto,
|
||||||
@@ -59,11 +59,6 @@ export class MaintenanceService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async startRestoreFlow(): Promise<{ jwt: string }> {
|
async startRestoreFlow(): Promise<{ jwt: string }> {
|
||||||
const adminUser = await this.userRepository.getAdmin();
|
|
||||||
if (adminUser) {
|
|
||||||
throw new BadRequestException('The server already has an admin');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.startMaintenance(
|
return this.startMaintenance(
|
||||||
{
|
{
|
||||||
action: MaintenanceAction.SelectDatabaseRestore,
|
action: MaintenanceAction.SelectDatabaseRestore,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
import { ServerService } from 'src/services/server.service';
|
import { ServerService } from 'src/services/server.service';
|
||||||
|
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe(ServerService.name, () => {
|
describe(ServerService.name, () => {
|
||||||
@@ -161,7 +162,7 @@ describe(ServerService.name, () => {
|
|||||||
oauthButtonText: 'Login with OAuth',
|
oauthButtonText: 'Login with OAuth',
|
||||||
trashDays: 30,
|
trashDays: 30,
|
||||||
userDeleteDelay: 7,
|
userDeleteDelay: 7,
|
||||||
isInitialized: undefined,
|
isInitialized: false,
|
||||||
isOnboarded: false,
|
isOnboarded: false,
|
||||||
externalDomain: '',
|
externalDomain: '',
|
||||||
publicUsers: true,
|
publicUsers: true,
|
||||||
@@ -172,6 +173,19 @@ describe(ServerService.name, () => {
|
|||||||
});
|
});
|
||||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be initialized once an admin exists', async () => {
|
||||||
|
mocks.user.hasAdmin.mockResolvedValue(true);
|
||||||
|
|
||||||
|
await expect(sut.getSystemConfig()).resolves.toMatchObject({ isInitialized: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be initialized when setup is disabled', async () => {
|
||||||
|
mocks.config.getEnv.mockReturnValue(mockEnvData({ setup: { allow: false } }));
|
||||||
|
mocks.user.hasAdmin.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(sut.getSystemConfig()).resolves.toMatchObject({ isInitialized: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getStats', () => {
|
describe('getStats', () => {
|
||||||
|
|||||||
@@ -111,9 +111,8 @@ export class ServerService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSystemConfig(): Promise<ServerConfigDto> {
|
async getSystemConfig(): Promise<ServerConfigDto> {
|
||||||
const { setup } = this.configRepository.getEnv();
|
|
||||||
const config = await this.getConfig({ withCache: false });
|
const config = await this.getConfig({ withCache: false });
|
||||||
const isInitialized = !setup.allow || (await this.userRepository.hasAdmin());
|
const isInitialized = !(await this.isSetupAvailable());
|
||||||
const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding);
|
const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -35,7 +35,4 @@ export const errorDto = {
|
|||||||
incorrectLogin: {
|
incorrectLogin: {
|
||||||
message: 'Incorrect email or password',
|
message: 'Incorrect email or password',
|
||||||
},
|
},
|
||||||
alreadyHasAdmin: {
|
|
||||||
message: 'The server already has an admin',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,16 +57,6 @@ describe(AuthService.name, () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow a second admin to sign up', async () => {
|
|
||||||
const { sut, ctx } = setup();
|
|
||||||
await ctx.newUser({ isAdmin: true });
|
|
||||||
const dto = { name: 'Admin', email: 'admin@immich.cloud', password: 'password' };
|
|
||||||
|
|
||||||
const response = sut.adminSignUp(dto);
|
|
||||||
await expect(response).rejects.toThrow(BadRequestException);
|
|
||||||
await expect(response).rejects.toThrow('The server already has an admin');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
|
|||||||
+13
-6
@@ -89,6 +89,7 @@ import { assert, Mock, Mocked, vitest } from 'vitest';
|
|||||||
|
|
||||||
export type ControllerContext = {
|
export type ControllerContext = {
|
||||||
authenticate: Mock;
|
authenticate: Mock;
|
||||||
|
requireSetupAvailable: Mock;
|
||||||
getHttpServer: () => any;
|
getHttpServer: () => any;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
@@ -124,7 +125,7 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow
|
|||||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
{ provide: LoggingRepository, useValue: LoggingRepository.create() },
|
{ provide: LoggingRepository, useValue: LoggingRepository.create() },
|
||||||
{ provide: ClsService, useValue: { getId: vi.fn() } },
|
{ provide: ClsService, useValue: { getId: vi.fn() } },
|
||||||
{ provide: AuthService, useValue: { authenticate: vi.fn() } },
|
{ provide: AuthService, useValue: { authenticate: vi.fn(), requireSetupAvailable: vi.fn() } },
|
||||||
...providers,
|
...providers,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -137,13 +138,17 @@ export const controllerSetup = async (controller: new (...args: any[]) => unknow
|
|||||||
await app.init();
|
await app.init();
|
||||||
|
|
||||||
// allow the AuthController to override the AuthService itself
|
// allow the AuthController to override the AuthService itself
|
||||||
const authenticate = app.get<Mocked<AuthService>>(AuthService).authenticate as Mock;
|
const resolvedAuthService = app.get<Mocked<AuthService>>(AuthService);
|
||||||
|
const authenticate = resolvedAuthService.authenticate as Mock;
|
||||||
|
const requireSetupAvailable = resolvedAuthService.requireSetupAvailable as Mock;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authenticate,
|
authenticate,
|
||||||
|
requireSetupAvailable,
|
||||||
getHttpServer: () => app.getHttpServer(),
|
getHttpServer: () => app.getHttpServer(),
|
||||||
reset: () => {
|
reset: () => {
|
||||||
authenticate.mockReset();
|
authenticate.mockReset();
|
||||||
|
requireSetupAvailable.mockReset();
|
||||||
},
|
},
|
||||||
close: async () => {
|
close: async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
@@ -184,10 +189,12 @@ export const automock = <T>(
|
|||||||
const mocks: Mock[] = [];
|
const mocks: Mock[] = [];
|
||||||
|
|
||||||
const instance = new Dependency(...args);
|
const instance = new Dependency(...args);
|
||||||
const propertyNames = new Set([
|
const propertyNames = new Set(Object.getOwnPropertyNames(instance));
|
||||||
...Object.getOwnPropertyNames(Dependency.prototype),
|
for (let proto = Dependency.prototype; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
|
||||||
...Object.getOwnPropertyNames(instance),
|
for (const property of Object.getOwnPropertyNames(proto)) {
|
||||||
]);
|
propertyNames.add(property);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const property of propertyNames) {
|
for (const property of propertyNames) {
|
||||||
if (property === 'constructor') {
|
if (property === 'constructor') {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
Reference in New Issue
Block a user