1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-01 15:11:21 +01:00

fix(server): don't publicly reveal user count (#4409)

* fix: don't reveal user count publicly

* fix: mobile and user controller

* fix: update other frontend endpoints

* fix: revert openapi change

* chore: open api

* fix: initialize

* openapi

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jonathan Jogenfors 2023-10-11 04:37:13 +02:00 committed by GitHub
parent 09bf1c9175
commit 41befc0948
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 101 additions and 15 deletions

View file

@ -2582,6 +2582,12 @@ export interface SearchResponseDto {
* @interface ServerConfigDto * @interface ServerConfigDto
*/ */
export interface ServerConfigDto { export interface ServerConfigDto {
/**
*
* @type {boolean}
* @memberof ServerConfigDto
*/
'isInitialized': boolean;
/** /**
* *
* @type {string} * @type {string}
@ -15081,6 +15087,15 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
const localVarHeaderParameter = {} as any; const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any; const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (admin !== undefined) { if (admin !== undefined) {
localVarQueryParameter['admin'] = admin; localVarQueryParameter['admin'] = admin;
} }

View file

@ -34,6 +34,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", mapTileUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
oauthButtonText: "", oauthButtonText: "",
trashDays: 30, trashDays: 30,
isInitialized: false,
), ),
isVersionMismatch: false, isVersionMismatch: false,
versionMismatchErrorMessage: "", versionMismatchErrorMessage: "",

View file

@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
## Properties ## Properties
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**isInitialized** | **bool** | |
**loginPageMessage** | **String** | | **loginPageMessage** | **String** | |
**mapTileUrl** | **String** | | **mapTileUrl** | **String** | |
**oauthButtonText** | **String** | | **oauthButtonText** | **String** | |

View file

@ -410,6 +410,20 @@ Name | Type | Description | Notes
### Example ### Example
```dart ```dart
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = UserApi(); final api_instance = UserApi();
final admin = true; // bool | final admin = true; // bool |
@ -434,7 +448,7 @@ Name | Type | Description | Notes
### Authorization ### Authorization
No authorization required [cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers ### HTTP request headers

View file

@ -13,12 +13,15 @@ part of openapi.api;
class ServerConfigDto { class ServerConfigDto {
/// Returns a new [ServerConfigDto] instance. /// Returns a new [ServerConfigDto] instance.
ServerConfigDto({ ServerConfigDto({
required this.isInitialized,
required this.loginPageMessage, required this.loginPageMessage,
required this.mapTileUrl, required this.mapTileUrl,
required this.oauthButtonText, required this.oauthButtonText,
required this.trashDays, required this.trashDays,
}); });
bool isInitialized;
String loginPageMessage; String loginPageMessage;
String mapTileUrl; String mapTileUrl;
@ -29,6 +32,7 @@ class ServerConfigDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto && bool operator ==(Object other) => identical(this, other) || other is ServerConfigDto &&
other.isInitialized == isInitialized &&
other.loginPageMessage == loginPageMessage && other.loginPageMessage == loginPageMessage &&
other.mapTileUrl == mapTileUrl && other.mapTileUrl == mapTileUrl &&
other.oauthButtonText == oauthButtonText && other.oauthButtonText == oauthButtonText &&
@ -37,16 +41,18 @@ class ServerConfigDto {
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(isInitialized.hashCode) +
(loginPageMessage.hashCode) + (loginPageMessage.hashCode) +
(mapTileUrl.hashCode) + (mapTileUrl.hashCode) +
(oauthButtonText.hashCode) + (oauthButtonText.hashCode) +
(trashDays.hashCode); (trashDays.hashCode);
@override @override
String toString() => 'ServerConfigDto[loginPageMessage=$loginPageMessage, mapTileUrl=$mapTileUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays]'; String toString() => 'ServerConfigDto[isInitialized=$isInitialized, loginPageMessage=$loginPageMessage, mapTileUrl=$mapTileUrl, oauthButtonText=$oauthButtonText, trashDays=$trashDays]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'isInitialized'] = this.isInitialized;
json[r'loginPageMessage'] = this.loginPageMessage; json[r'loginPageMessage'] = this.loginPageMessage;
json[r'mapTileUrl'] = this.mapTileUrl; json[r'mapTileUrl'] = this.mapTileUrl;
json[r'oauthButtonText'] = this.oauthButtonText; json[r'oauthButtonText'] = this.oauthButtonText;
@ -62,6 +68,7 @@ class ServerConfigDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return ServerConfigDto( return ServerConfigDto(
isInitialized: mapValueOfType<bool>(json, r'isInitialized')!,
loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!, loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
mapTileUrl: mapValueOfType<String>(json, r'mapTileUrl')!, mapTileUrl: mapValueOfType<String>(json, r'mapTileUrl')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!, oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
@ -113,6 +120,7 @@ class ServerConfigDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'isInitialized',
'loginPageMessage', 'loginPageMessage',
'mapTileUrl', 'mapTileUrl',
'oauthButtonText', 'oauthButtonText',

View file

@ -16,6 +16,11 @@ void main() {
// final instance = ServerConfigDto(); // final instance = ServerConfigDto();
group('test ServerConfigDto', () { group('test ServerConfigDto', () {
// bool isInitialized
test('to test the property `isInitialized`', () async {
// TODO
});
// String loginPageMessage // String loginPageMessage
test('to test the property `loginPageMessage`', () async { test('to test the property `loginPageMessage`', () async {
// TODO // TODO

View file

@ -5004,6 +5004,17 @@
"description": "" "description": ""
} }
}, },
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [ "tags": [
"User" "User"
] ]
@ -7340,6 +7351,9 @@
}, },
"ServerConfigDto": { "ServerConfigDto": {
"properties": { "properties": {
"isInitialized": {
"type": "boolean"
},
"loginPageMessage": { "loginPageMessage": {
"type": "string" "type": "string"
}, },
@ -7357,7 +7371,8 @@
"trashDays", "trashDays",
"oauthButtonText", "oauthButtonText",
"loginPageMessage", "loginPageMessage",
"mapTileUrl" "mapTileUrl",
"isInitialized"
], ],
"type": "object" "type": "object"
}, },

View file

@ -18,6 +18,7 @@ export const IUserRepository = 'IUserRepository';
export interface IUserRepository { export interface IUserRepository {
get(id: string, withDeleted?: boolean): Promise<UserEntity | null>; get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
getAdmin(): Promise<UserEntity | null>; getAdmin(): Promise<UserEntity | null>;
hasAdmin(): Promise<boolean>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>; getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
getByStorageLabel(storageLabel: string): Promise<UserEntity | null>; getByStorageLabel(storageLabel: string): Promise<UserEntity | null>;
getByOAuthId(oauthId: string): Promise<UserEntity | null>; getByOAuthId(oauthId: string): Promise<UserEntity | null>;

View file

@ -85,6 +85,7 @@ export class ServerConfigDto {
mapTileUrl!: string; mapTileUrl!: string;
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
trashDays!: number; trashDays!: number;
isInitialized!: boolean;
} }
export class ServerFeaturesDto implements FeatureFlags { export class ServerFeaturesDto implements FeatureFlags {

View file

@ -74,11 +74,14 @@ export class ServerInfoService {
// TODO move to system config // TODO move to system config
const loginPageMessage = process.env.PUBLIC_LOGIN_PAGE_MESSAGE || ''; const loginPageMessage = process.env.PUBLIC_LOGIN_PAGE_MESSAGE || '';
const isInitialized = await this.userRepository.hasAdmin();
return { return {
loginPageMessage, loginPageMessage,
mapTileUrl: config.map.tileUrl, mapTileUrl: config.map.tileUrl,
trashDays: config.trash.days, trashDays: config.trash.days,
oauthButtonText: config.oauth.buttonText, oauthButtonText: config.oauth.buttonText,
isInitialized,
}; };
} }

View file

@ -26,7 +26,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { AdminRoute, AuthUser, Authenticated, PublicRoute } from '../app.guard'; import { AdminRoute, AuthUser, Authenticated } from '../app.guard';
import { FileUploadInterceptor, Route } from '../app.interceptor'; import { FileUploadInterceptor, Route } from '../app.interceptor';
import { UseValidation } from '../app.utils'; import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ -59,7 +59,7 @@ export class UserController {
return this.service.create(createUserDto); return this.service.create(createUserDto);
} }
@PublicRoute() @AdminRoute()
@Get('count') @Get('count')
getUserCount(@Query() dto: CountDto): Promise<UserCountResponseDto> { getUserCount(@Query() dto: CountDto): Promise<UserCountResponseDto> {
return this.service.getCount(dto); return this.service.getCount(dto);

View file

@ -16,6 +16,10 @@ export class UserRepository implements IUserRepository {
return this.userRepository.findOne({ where: { isAdmin: true } }); return this.userRepository.findOne({ where: { isAdmin: true } });
} }
async hasAdmin(): Promise<boolean> {
return this.userRepository.exist({ where: { isAdmin: true } });
}
async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> { async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> {
let builder = this.userRepository.createQueryBuilder('user').where({ email }); let builder = this.userRepository.createQueryBuilder('user').where({ email });

View file

@ -102,6 +102,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
oauthButtonText: 'Login with OAuth', oauthButtonText: 'Login with OAuth',
mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
trashDays: 30, trashDays: 30,
isInitialized: true,
}); });
}); });
}); });

View file

@ -311,10 +311,10 @@ describe(`${UserController.name}`, () => {
}); });
describe('GET /user/count', () => { describe('GET /user/count', () => {
it('should not require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).get(`/user/count`); const { status, body } = await request(server).get(`/user/count`);
expect(status).toBe(200); expect(status).toBe(401);
expect(body).toEqual({ userCount: 1 }); expect(body).toEqual(errorStub.unauthorized);
}); });
it('should start with just the admin', async () => { it('should start with just the admin', async () => {

View file

@ -14,5 +14,6 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
delete: jest.fn(), delete: jest.fn(),
getDeletedUsers: jest.fn(), getDeletedUsers: jest.fn(),
restore: jest.fn(), restore: jest.fn(),
hasAdmin: jest.fn(),
}; };
}; };

View file

@ -2582,6 +2582,12 @@ export interface SearchResponseDto {
* @interface ServerConfigDto * @interface ServerConfigDto
*/ */
export interface ServerConfigDto { export interface ServerConfigDto {
/**
*
* @type {boolean}
* @memberof ServerConfigDto
*/
'isInitialized': boolean;
/** /**
* *
* @type {string} * @type {string}
@ -15081,6 +15087,15 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
const localVarHeaderParameter = {} as any; const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any; const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (admin !== undefined) { if (admin !== undefined) {
localVarQueryParameter['admin'] = admin; localVarQueryParameter['admin'] = admin;
} }

View file

@ -27,6 +27,7 @@ export const serverConfig = writable<ServerConfig>({
mapTileUrl: '', mapTileUrl: '',
loginPageMessage: '', loginPageMessage: '',
trashDays: 30, trashDays: 30,
isInitialized: false,
}); });
export const loadConfig = async () => { export const loadConfig = async () => {

View file

@ -10,10 +10,10 @@ export const load = (async ({ parent, locals: { api } }) => {
throw redirect(302, AppRoute.PHOTOS); throw redirect(302, AppRoute.PHOTOS);
} }
const { data } = await api.userApi.getUserCount({ admin: true }); const { data } = await api.serverInfoApi.getServerConfig();
if (data.userCount > 0) { if (data.isInitialized) {
// Redirect to login page if an admin is already registered. // Redirect to login page if there exists an admin account (i.e. server is initialized)
throw redirect(302, AppRoute.AUTH_LOGIN); throw redirect(302, AppRoute.AUTH_LOGIN);
} }

View file

@ -3,8 +3,8 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api } }) => { export const load = (async ({ locals: { api } }) => {
const { data } = await api.userApi.getUserCount({ admin: true }); const { data } = await api.serverInfoApi.getServerConfig();
if (data.userCount === 0) { if (!data.isInitialized) {
// Admin not registered // Admin not registered
throw redirect(302, AppRoute.AUTH_REGISTER); throw redirect(302, AppRoute.AUTH_REGISTER);
} }

View file

@ -3,8 +3,8 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api } }) => { export const load = (async ({ locals: { api } }) => {
const { data } = await api.userApi.getUserCount({ admin: true }); const { data } = await api.serverInfoApi.getServerConfig();
if (data.userCount != 0) { if (data.isInitialized) {
// Admin has been registered, redirect to login // Admin has been registered, redirect to login
throw redirect(302, AppRoute.AUTH_LOGIN); throw redirect(302, AppRoute.AUTH_LOGIN);
} }