mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 01:29:04 +03:00
feat: shared link login (#25678)
This commit is contained in:
@@ -22,21 +22,39 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkEditDto,
|
||||
SharedLinkLoginDto,
|
||||
SharedLinkPasswordDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkSearchDto,
|
||||
} from 'src/dtos/shared-link.dto';
|
||||
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
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)
|
||||
@Controller('shared-links')
|
||||
export class SharedLinkController {
|
||||
constructor(private service: SharedLinkService) {}
|
||||
constructor(
|
||||
private service: SharedLinkService,
|
||||
private logger: LoggingRepository,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.SharedLinkRead })
|
||||
@@ -49,6 +67,28 @@ export class SharedLinkController {
|
||||
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')
|
||||
@Authenticated({ sharedLink: true })
|
||||
@Endpoint({
|
||||
@@ -59,19 +99,19 @@ export class SharedLinkController {
|
||||
async getMySharedLink(
|
||||
@Auth() auth: AuthDto,
|
||||
@Query() dto: SharedLinkPasswordDto,
|
||||
@Req() request: Request,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<SharedLinkResponseDto> {
|
||||
const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken];
|
||||
if (sharedLinkToken) {
|
||||
dto.token = sharedLinkToken;
|
||||
if (dto.password) {
|
||||
this.logger.deprecate(
|
||||
'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, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [],
|
||||
});
|
||||
|
||||
return this.service.getMine(auth, getAuthTokens(req.cookies));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
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 { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
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 {
|
||||
@ValidateUUID({ optional: true, description: 'Filter by album ID' })
|
||||
@@ -94,6 +94,11 @@ export class SharedLinkEditDto {
|
||||
changeExpiryTime?: boolean;
|
||||
}
|
||||
|
||||
export class SharedLinkLoginDto {
|
||||
@ValidateString({ description: 'Shared link password', example: 'password' })
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class SharedLinkPasswordDto {
|
||||
@ApiPropertyOptional({ example: 'password', description: 'Link password' })
|
||||
@IsString()
|
||||
@@ -112,7 +117,10 @@ export class SharedLinkResponseDto {
|
||||
description!: string | null;
|
||||
@ApiProperty({ description: 'Has password' })
|
||||
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;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
userId!: string;
|
||||
|
||||
@@ -35,14 +35,14 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
describe('getMine', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('should return the shared link for the public user', async () => {
|
||||
const authDto = authStub.adminSharedLink;
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -55,21 +55,22 @@ describe(SharedLinkService.name, () => {
|
||||
},
|
||||
});
|
||||
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(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;
|
||||
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);
|
||||
});
|
||||
|
||||
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' });
|
||||
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(
|
||||
authStub.adminSharedLink.user.id,
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PostgresError } from 'postgres';
|
||||
import { SharedLink } from 'src/database';
|
||||
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
mapSharedLink,
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkEditDto,
|
||||
SharedLinkPasswordDto,
|
||||
SharedLinkLoginDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkSearchDto,
|
||||
} from 'src/dtos/shared-link.dto';
|
||||
@@ -24,18 +23,41 @@ export class SharedLinkService extends BaseService {
|
||||
.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) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
|
||||
const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
|
||||
if (sharedLink.password) {
|
||||
response.token = this.validateAndRefreshToken(sharedLink, dto);
|
||||
const { id, password } = sharedLink;
|
||||
|
||||
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> {
|
||||
@@ -213,16 +235,7 @@ export class SharedLinkService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
|
||||
const token = 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(',');
|
||||
private asToken(sharedLink: { id: string; password: string }) {
|
||||
return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user