feat: shared link login (#25678)

This commit is contained in:
Jason Rasmussen 2026-02-12 12:08:38 -05:00 committed by GitHub
parent 81c93101a0
commit 72cef8b94b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 235 additions and 48 deletions

View file

@ -43,10 +43,10 @@ export const errorDto = {
message: 'Invalid share key', message: 'Invalid share key',
correlationId: expect.any(String), correlationId: expect.any(String),
}, },
invalidSharePassword: { passwordRequired: {
error: 'Unauthorized', error: 'Unauthorized',
statusCode: 401, statusCode: 401,
message: 'Invalid password', message: 'Password required',
correlationId: expect.any(String), correlationId: expect.any(String),
}, },
badRequest: (message: any = null) => ({ badRequest: (message: any = null) => ({

View file

@ -239,7 +239,7 @@ describe('/shared-links', () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key }); const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key });
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.invalidSharePassword); expect(body).toEqual(errorDto.passwordRequired);
}); });
it('should get data for correct password protected link', async () => { it('should get data for correct password protected link', async () => {

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -11239,6 +11239,78 @@
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
"/shared-links/login": {
"post": {
"description": "Login to a password protected shared link",
"operationId": "sharedLinkLogin",
"parameters": [
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkLoginDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharedLinkResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Shared link login",
"tags": [
"Shared links"
],
"x-immich-history": [
{
"version": "v2.6.0",
"state": "Added"
},
{
"version": "v2.6.0",
"state": "Beta"
}
],
"x-immich-state": "Beta"
}
},
"/shared-links/me": { "/shared-links/me": {
"get": { "get": {
"description": "Retrieve the current shared link associated with authentication method.", "description": "Retrieve the current shared link associated with authentication method.",
@ -21686,6 +21758,19 @@
}, },
"type": "object" "type": "object"
}, },
"SharedLinkLoginDto": {
"properties": {
"password": {
"description": "Shared link password",
"example": "password",
"type": "string"
}
},
"required": [
"password"
],
"type": "object"
},
"SharedLinkResponseDto": { "SharedLinkResponseDto": {
"properties": { "properties": {
"album": { "album": {
@ -21744,9 +21829,25 @@
"type": "string" "type": "string"
}, },
"token": { "token": {
"deprecated": true,
"description": "Access token", "description": "Access token",
"nullable": true, "nullable": true,
"type": "string" "type": "string",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Deprecated"
}
],
"x-immich-state": "Deprecated"
}, },
"type": { "type": {
"allOf": [ "allOf": [

View file

@ -2287,6 +2287,10 @@ export type SharedLinkCreateDto = {
/** Shared link type */ /** Shared link type */
"type": SharedLinkType; "type": SharedLinkType;
}; };
export type SharedLinkLoginDto = {
/** Shared link password */
password: string;
};
export type SharedLinkEditDto = { export type SharedLinkEditDto = {
/** Allow downloads */ /** Allow downloads */
allowDownload?: boolean; allowDownload?: boolean;
@ -5861,6 +5865,26 @@ export function createSharedLink({ sharedLinkCreateDto }: {
body: sharedLinkCreateDto body: sharedLinkCreateDto
}))); })));
} }
/**
* Shared link login
*/
export function sharedLinkLogin({ key, slug, sharedLinkLoginDto }: {
key?: string;
slug?: string;
sharedLinkLoginDto: SharedLinkLoginDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: SharedLinkResponseDto;
}>(`/shared-links/login${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.json({
...opts,
method: "POST",
body: sharedLinkLoginDto
})));
}
/** /**
* Retrieve current shared link * Retrieve current shared link
*/ */

View file

@ -22,21 +22,39 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { import {
SharedLinkCreateDto, SharedLinkCreateDto,
SharedLinkEditDto, SharedLinkEditDto,
SharedLinkLoginDto,
SharedLinkPasswordDto, SharedLinkPasswordDto,
SharedLinkResponseDto, SharedLinkResponseDto,
SharedLinkSearchDto, SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto'; } from 'src/dtos/shared-link.dto';
import { ApiTag, ImmichCookie, Permission } from 'src/enum'; import { ApiTag, ImmichCookie, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service'; import { LoginDetails } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import { respondWithCookie } from 'src/utils/response'; import { respondWithCookie } from 'src/utils/response';
import { UUIDParamDto } from 'src/validation'; import { UUIDParamDto } from 'src/validation';
const getAuthTokens = (cookies: Record<string, string> | undefined) => {
return cookies?.[ImmichCookie.SharedLinkToken]?.split(',') || [];
};
const merge = (cookies: Record<string, string> | undefined, token: string) => {
const authTokens = getAuthTokens(cookies);
if (!authTokens.includes(token)) {
authTokens.push(token);
}
return authTokens.join(',');
};
@ApiTags(ApiTag.SharedLinks) @ApiTags(ApiTag.SharedLinks)
@Controller('shared-links') @Controller('shared-links')
export class SharedLinkController { export class SharedLinkController {
constructor(private service: SharedLinkService) {} constructor(
private service: SharedLinkService,
private logger: LoggingRepository,
) {}
@Get() @Get()
@Authenticated({ permission: Permission.SharedLinkRead }) @Authenticated({ permission: Permission.SharedLinkRead })
@ -49,6 +67,28 @@ export class SharedLinkController {
return this.service.getAll(auth, dto); return this.service.getAll(auth, dto);
} }
@Post('login')
@Authenticated({ sharedLink: true })
@Endpoint({
summary: 'Shared link login',
description: 'Login to a password protected shared link',
history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'),
})
async sharedLinkLogin(
@Auth() auth: AuthDto,
@Body() dto: SharedLinkLoginDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> {
const { sharedLink, token } = await this.service.login(auth, dto);
return respondWithCookie(res, sharedLink, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.SharedLinkToken, value: merge(req.cookies, token) }],
});
}
@Get('me') @Get('me')
@Authenticated({ sharedLink: true }) @Authenticated({ sharedLink: true })
@Endpoint({ @Endpoint({
@ -59,19 +99,19 @@ export class SharedLinkController {
async getMySharedLink( async getMySharedLink(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Query() dto: SharedLinkPasswordDto, @Query() dto: SharedLinkPasswordDto,
@Req() request: Request, @Req() req: Request,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> { ): Promise<SharedLinkResponseDto> {
const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken]; if (dto.password) {
if (sharedLinkToken) { this.logger.deprecate(
dto.token = sharedLinkToken; 'Passing shared link password via query parameters is deprecated and will be removed in the next major release. Please use POST /shared-links/login instead.',
);
return this.sharedLinkLogin(auth, { password: dto.password }, req, res, loginDetails);
} }
const body = await this.service.getMine(auth, dto);
return respondWithCookie(res, body, { return this.service.getMine(auth, getAuthTokens(req.cookies));
isSecure: loginDetails.isSecure,
values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [],
});
} }
@Get(':id') @Get(':id')

View file

@ -1,11 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
import { SharedLink } from 'src/database'; import { SharedLink } from 'src/database';
import { HistoryBuilder } from 'src/decorators'; import { HistoryBuilder, Property } from 'src/decorators';
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkType } from 'src/enum'; import { SharedLinkType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
export class SharedLinkSearchDto { export class SharedLinkSearchDto {
@ValidateUUID({ optional: true, description: 'Filter by album ID' }) @ValidateUUID({ optional: true, description: 'Filter by album ID' })
@ -94,6 +94,11 @@ export class SharedLinkEditDto {
changeExpiryTime?: boolean; changeExpiryTime?: boolean;
} }
export class SharedLinkLoginDto {
@ValidateString({ description: 'Shared link password', example: 'password' })
password!: string;
}
export class SharedLinkPasswordDto { export class SharedLinkPasswordDto {
@ApiPropertyOptional({ example: 'password', description: 'Link password' }) @ApiPropertyOptional({ example: 'password', description: 'Link password' })
@IsString() @IsString()
@ -112,7 +117,10 @@ export class SharedLinkResponseDto {
description!: string | null; description!: string | null;
@ApiProperty({ description: 'Has password' }) @ApiProperty({ description: 'Has password' })
password!: string | null; password!: string | null;
@ApiPropertyOptional({ description: 'Access token' }) @Property({
description: 'Access token',
history: new HistoryBuilder().added('v1').stable('v2').deprecated('v2.6.0'),
})
token?: string | null; token?: string | null;
@ApiProperty({ description: 'Owner user ID' }) @ApiProperty({ description: 'Owner user ID' })
userId!: string; userId!: string;

View file

@ -35,14 +35,14 @@ describe(SharedLinkService.name, () => {
describe('getMine', () => { describe('getMine', () => {
it('should only work for a public user', async () => { it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException); await expect(sut.getMine(authStub.admin, [])).rejects.toBeInstanceOf(ForbiddenException);
expect(mocks.sharedLink.get).not.toHaveBeenCalled(); expect(mocks.sharedLink.get).not.toHaveBeenCalled();
}); });
it('should return the shared link for the public user', async () => { it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink; const authDto = authStub.adminSharedLink;
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid); await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
@ -55,21 +55,22 @@ describe(SharedLinkService.name, () => {
}, },
}); });
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
const response = await sut.getMine(authDto, {}); const response = await sut.getMine(authDto, []);
expect(response.assets[0]).toMatchObject({ hasMetadata: false }); expect(response.assets[0]).toMatchObject({ hasMetadata: false });
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
it('should throw an error for an invalid password protected shared link', async () => { it('should throw an error for a request without a shared link auth token', async () => {
const authDto = authStub.adminSharedLink; const authDto = authStub.adminSharedLink;
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired);
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.getMine(authDto, [])).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
}); });
it('should allow a correct password on a password protected shared link', async () => { it('should accept a valid shared link auth token', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined(); mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token');
await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined();
expect(mocks.sharedLink.get).toHaveBeenCalledWith( expect(mocks.sharedLink.get).toHaveBeenCalledWith(
authStub.adminSharedLink.user.id, authStub.adminSharedLink.user.id,
authStub.adminSharedLink.sharedLink?.id, authStub.adminSharedLink.sharedLink?.id,

View file

@ -1,6 +1,5 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { PostgresError } from 'postgres'; import { PostgresError } from 'postgres';
import { SharedLink } from 'src/database';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@ -8,7 +7,7 @@ import {
mapSharedLink, mapSharedLink,
SharedLinkCreateDto, SharedLinkCreateDto,
SharedLinkEditDto, SharedLinkEditDto,
SharedLinkPasswordDto, SharedLinkLoginDto,
SharedLinkResponseDto, SharedLinkResponseDto,
SharedLinkSearchDto, SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto'; } from 'src/dtos/shared-link.dto';
@ -24,18 +23,41 @@ export class SharedLinkService extends BaseService {
.then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false }))); .then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
} }
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> { async login(auth: AuthDto, dto: SharedLinkLoginDto) {
if (!auth.sharedLink) { if (!auth.sharedLink) {
throw new ForbiddenException(); throw new ForbiddenException();
} }
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id); const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }); const { id, password } = sharedLink;
if (sharedLink.password) {
response.token = this.validateAndRefreshToken(sharedLink, dto); if (!password) {
throw new BadRequestException('Shared link is not password protected');
} }
return response; if (password !== dto.password) {
throw new UnauthorizedException('Invalid password');
}
return {
sharedLink: mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }),
token: this.asToken({ id, password }),
};
}
async getMine(auth: AuthDto, authTokens: string[]) {
if (!auth.sharedLink) {
throw new ForbiddenException();
}
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
const { id, password } = sharedLink;
if (password && !authTokens.includes(this.asToken({ id, password }))) {
throw new UnauthorizedException('Password required');
}
return mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
} }
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> { async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
@ -213,16 +235,7 @@ export class SharedLinkService extends BaseService {
}; };
} }
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string { private asToken(sharedLink: { id: string; password: string }) {
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`); return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
const sharedLinkTokens = dto.token?.split(',') || [];
if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) {
throw new UnauthorizedException('Invalid password');
}
if (!sharedLinkTokens.includes(token)) {
sharedLinkTokens.push(token);
}
return sharedLinkTokens.join(',');
} }
} }

View file

@ -90,7 +90,7 @@ describe(SharedLinkService.name, () => {
assetIds: assets.map(({ asset }) => asset.id), assetIds: assets.map(({ asset }) => asset.id),
}); });
await expect(sut.getMine({ user, sharedLink }, {})).resolves.toMatchObject({ await expect(sut.getMine({ user, sharedLink }, [])).resolves.toMatchObject({
assets: assets.map(({ asset }) => expect.objectContaining({ id: asset.id })), assets: assets.map(({ asset }) => expect.objectContaining({ id: asset.id })),
}); });
}); });
@ -114,7 +114,7 @@ describe(SharedLinkService.name, () => {
assetIds: [asset.id], assetIds: [asset.id],
}); });
await expect(sut.getMine({ user, sharedLink }, {})).resolves.toMatchObject({ await expect(sut.getMine({ user, sharedLink }, [])).resolves.toMatchObject({
assets: [expect.objectContaining({ id: asset.id })], assets: [expect.objectContaining({ id: asset.id })],
}); });
@ -122,6 +122,6 @@ describe(SharedLinkService.name, () => {
assetIds: [asset.id], assetIds: [asset.id],
}); });
await expect(sut.getMine({ user, sharedLink }, {})).resolves.toHaveProperty('assets', []); await expect(sut.getMine({ user, sharedLink }, [])).resolves.toHaveProperty('assets', []);
}); });
}); });

View file

@ -8,7 +8,7 @@
import { setSharedLink } from '$lib/utils'; import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { sharedLinkLogin, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { Button, Logo, PasswordInput } from '@immich/ui'; import { Button, Logo, PasswordInput } from '@immich/ui';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -39,7 +39,7 @@
const handlePasswordSubmit = async () => { const handlePasswordSubmit = async () => {
try { try {
sharedLink = await getMySharedLink({ password, key, slug }); sharedLink = await sharedLinkLogin({ key, slug, sharedLinkLoginDto: { password } });
setSharedLink(sharedLink); setSharedLink(sharedLink);
passwordRequired = false; passwordRequired = false;
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich'; title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';

View file

@ -49,7 +49,7 @@ export const loadSharedLink = async ({
}, },
}; };
} catch (error) { } catch (error) {
if (isHttpError(error) && error.data.message === 'Invalid password') { if (isHttpError(error) && error.data.message === 'Password required') {
return { return {
...common, ...common,
passwordRequired: true, passwordRequired: true,