Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): trusted header authentication #8778

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 23 additions & 7 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.3.2",
"ipaddr.js": "^2.1.0",
"joi": "^17.10.0",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
Expand Down
2 changes: 1 addition & 1 deletion server/src/middleware/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export class AuthGuard implements CanActivate {

const request = context.switchToHttp().getRequest<AuthRequest>();

const authDto = await this.authService.validate(request.headers, request.query as Record<string, string>);
const authDto = await this.authService.validate(request.headers, request.query as Record<string, string>, request.socket.remoteAddress);
if (authDto.sharedLink && !isSharedRoute) {
this.logger.warn(`Denied access to non-shared route: ${request.path}`);
return false;
Expand Down
4 changes: 2 additions & 2 deletions server/src/repositories/event.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
constructor(
private authService: AuthService,
private eventEmitter: EventEmitter2,
) {}
) { }

afterInit(server: Server) {
this.logger.log('Initialized websocket server');
Expand All @@ -53,7 +53,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
async handleConnection(client: Socket) {
try {
this.logger.log(`Websocket Connect: ${client.id}`);
const auth = await this.authService.validate(client.request.headers, {});
const auth = await this.authService.validate(client.request.headers, {}, client.request.socket.remoteAddress);
await client.join(auth.user.id);
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
} catch (error: Error | any) {
Expand Down
42 changes: 42 additions & 0 deletions server/src/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,48 @@ describe('AuthService', () => {
});
});


describe.only('validate - trusted header', () => {
const originalVariable = process.env.IMMICH_TRUSTED_REMOTE_NETWORKS;
beforeAll(() => {
process.env.IMMICH_TRUSTED_REMOTE_NETWORKS = "10.3.3.0/24";
});
afterAll(() => {
if (originalVariable) {
process.env.IMMICH_TRUSTED_REMOTE_NETWORKS = originalVariable;
} else {
delete process.env.IMMICH_TRUSTED_REMOTE_NETWORKS;
}
});

it('should throw an error if remote IP is not in trusted networks', async () => {
const headers: IncomingHttpHeaders = { 'remote-email': userStub.user1.email };
await expect(sut.validate(headers, {}, "10.2.65.2")).rejects.toBeInstanceOf(UnauthorizedException);
});

it('should throw an error if remote email not found', async () => {
const headers: IncomingHttpHeaders = { 'remote-email': "non-exist-email" };
await expect(sut.validate(headers, {}, "10.3.3.2")).rejects.toBeInstanceOf(UnauthorizedException);
});

it('should validate using trusted header', async () => {
userMock.getByEmail.mockResolvedValue(userStub.user1);
const headers: IncomingHttpHeaders = { 'remote-email': userStub.user1.email };
await expect(sut.validate(headers, {}, "10.3.3.2")).resolves.toEqual({
user: userStub.user1,
});
});

it('should validate with multiple trusted networks with IPv6', async () => {
process.env.IMMICH_TRUSTED_REMOTE_NETWORKS = "10.3.3.0/24, fd00:3::/32, 172.3.4.0/24";
userMock.getByEmail.mockResolvedValue(userStub.user1);
const headers: IncomingHttpHeaders = { 'remote-email': userStub.user1.email };
await expect(sut.validate(headers, {}, "fd00:3::9")).resolves.toEqual({
user: userStub.user1,
});
});
});

describe('getDevices', () => {
it('should get the devices', async () => {
userTokenMock.getAll.mockResolvedValue([userTokenStub.userToken, userTokenStub.inactiveToken]);
Expand Down
65 changes: 58 additions & 7 deletions server/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import cookieParser from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import ipaddr from 'ipaddr.js';
import {
AuthType,
IMMICH_ACCESS_COOKIE,
Expand Down Expand Up @@ -72,6 +73,11 @@ interface ClaimOptions<T> {
isValid: (value: unknown) => boolean;
}

const getHeader = (headers: IncomingHttpHeaders, key: string): string | undefined => {
const h = headers[key];
return Array.isArray(h) ? h[0] : h;
}

@Injectable()
export class AuthService {
private access: AccessCore;
Expand Down Expand Up @@ -161,25 +167,30 @@ export class AuthService {
return mapUser(admin);
}

async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
const userToken = (headers['x-immich-user-token'] ||
params.userToken ||
this.getBearerToken(headers) ||
this.getCookieToken(headers)) as string;
const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string;
async validate(headers: IncomingHttpHeaders, params: Record<string, string>, remoteIpAddress?: string): Promise<AuthDto> {
const shareKey = getHeader(headers, 'x-immich-share-key') ?? params.key;

if (shareKey) {
return this.validateSharedLink(shareKey);
}
const userToken = getHeader(headers, 'x-immich-user-token') ??
params.userToken ??
this.getBearerToken(headers) ??
this.getCookieToken(headers);

if (userToken) {
return this.validateUserToken(userToken);
}

const apiKey = getHeader(headers, IMMICH_API_KEY_HEADER) ?? params.apiKey;

if (apiKey) {
return this.validateApiKey(apiKey);
}
const remoteUser = getHeader(headers, 'remote-email');
if (remoteUser) {
return this.validateTrustedHeader(remoteUser, remoteIpAddress);
}

throw new UnauthorizedException('Authentication required');
}
Expand Down Expand Up @@ -425,6 +436,46 @@ export class AuthService {
throw new UnauthorizedException('Invalid user token');
}

private async validateTrustedHeader(email: string, remoteIpAddress: string | undefined): Promise<AuthDto> {
if (!process.env.IMMICH_TRUSTED_REMOTE_NETWORKS) {
this.logger.error('Trusted remote networks are not provided in environment variable when trying to validate trusted header');
throw new UnauthorizedException('Invalid trusted header');
}
if (!remoteIpAddress) {
this.logger.error('Remote IP address is not provided when trying to validate trusted header');
throw new UnauthorizedException('Invalid trusted header');
}
if (!ipaddr.isValid(remoteIpAddress)) {
this.logger.error(`Remote IP address '${remoteIpAddress}' is invalid when trying to validate trusted header`);
throw new UnauthorizedException('Invalid trusted header');
}
const cidrList = process.env.IMMICH_TRUSTED_REMOTE_NETWORKS.split(',').map((cidr) => cidr.trim());
const parsedCidrList = (() => {
try {
return cidrList.map((cidr) => ipaddr.parseCIDR(cidr));
}
catch {
this.logger.error(`Trusted remote networks ${JSON.stringify(cidrList)}, set by environment variable 'IMMICH_TRUSTED_REMOTE_NETWORKS' to '${process.env.IMMICH_TRUSTED_REMOTE_NETWORKS}' is invalid when trying to validate trusted header`);
throw new UnauthorizedException('Invalid trusted header');
}
})();
const parsedRemoteIp = ipaddr.process(remoteIpAddress);

// eslint-disable-next-line unicorn/prefer-regexp-test
if (!parsedCidrList.some((cidr) => { try { return (parsedRemoteIp.match as any)(cidr) } catch { return false } })) {
this.logger.warn(`Remote IP address '${remoteIpAddress}' is not in the trusted network '${process.env.IMMICH_TRUSTED_REMOTE_NETWORKS}' when trying to validate trusted header`);
throw new UnauthorizedException('Invalid trusted header');
}

const user = await this.userRepository.getByEmail(email);
if (user) {
return { user };
}

this.logger.warn(`User by email '${email}' not found when trying to validate trusted header`);
throw new UnauthorizedException('Invalid trusted header');
}

private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
const key = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.hashSha256(key);
Expand Down
25 changes: 25 additions & 0 deletions web/src/routes/auth/login/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { env } from '$env/dynamic/private';

export const load = (async ({ cookies }) => {
if (env.IMMICH_TRUSTED_REMOTE_NETWORKS) {
[
['immich_auth_type', 'trusted-header-auth'],
['immich_is_authenticated', 'true']
].forEach(([key, value]) => {
cookies.set(
key, value,
{
path: '/',
httpOnly: false,
sameSite: 'lax',
secure: false,
maxAge: 60 * 60 * 24 * 30
},
)
});
redirect(302, AppRoute.PHOTOS);
}
}) satisfies PageServerLoad;