1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

refactor(server): sessions (#8915)

* refactor: auth device => sessions

* chore: open api
This commit is contained in:
Jason Rasmussen 2024-04-19 06:47:29 -04:00 committed by GitHub
parent e72e41a7aa
commit 4478e524f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 595 additions and 506 deletions

View File

@ -1,7 +1,7 @@
import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto } from 'src/fixtures';
import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
@ -118,67 +118,6 @@ describe('/auth/*', () => {
});
});
describe('GET /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/auth/devices');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get a list of authorized devices', async () => {
const { status, body } = await request(app)
.get('/auth/devices')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceDto.current]);
});
});
describe('DELETE /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/auth/devices`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout all devices (except the current one)', async () => {
for (let i = 0; i < 5; i++) {
await login({ loginCredentialDto: loginDto.admin });
}
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6);
const { status } = await request(app).delete(`/auth/devices`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1);
});
it('should throw an error for a non-existent device id', async () => {
const { status, body } = await request(app)
.delete(`/auth/devices/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
const [device] = await getAuthDevices({
headers: asBearerAuth(admin.accessToken),
});
const { status } = await request(app)
.delete(`/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const response = await request(app)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.body).toEqual(errorDto.invalidToken);
expect(response.status).toBe(401);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(app).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');

View File

@ -0,0 +1,75 @@
import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import { deviceDto, errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
describe('/sessions', () => {
let admin: LoginResponseDto;
beforeEach(async () => {
await utils.resetDatabase();
await signUpAdmin({ signUpDto: signupDto.admin });
admin = await login({ loginCredentialDto: loginDto.admin });
});
describe('GET /sessions', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/sessions');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get a list of authorized devices', async () => {
const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceDto.current]);
});
});
describe('DELETE /sessions', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/sessions`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout all devices (except the current one)', async () => {
for (let i = 0; i < 5; i++) {
await login({ loginCredentialDto: loginDto.admin });
}
await expect(getSessions({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6);
const { status } = await request(app).delete(`/sessions`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await expect(getSessions({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1);
});
it('should throw an error for a non-existent device id', async () => {
const { status, body } = await request(app)
.delete(`/sessions/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
const [device] = await getSessions({
headers: asBearerAuth(admin.accessToken),
});
const { status } = await request(app)
.delete(`/sessions/${device.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const response = await request(app)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.body).toEqual(errorDto.invalidToken);
expect(response.status).toBe(401);
});
});
});

View File

@ -140,7 +140,7 @@ export const utils = {
'asset_faces',
'activity',
'api_keys',
'user_token',
'sessions',
'users',
'system_metadata',
'system_config',

View File

@ -41,7 +41,6 @@ doc/AssetTypeEnum.md
doc/AudioCodec.md
doc/AuditApi.md
doc/AuditDeletesResponseDto.md
doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md
doc/BulkIdResponseDto.md
doc/BulkIdsDto.md
@ -142,6 +141,8 @@ doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md
doc/ServerThemeDto.md
doc/ServerVersionResponseDto.md
doc/SessionResponseDto.md
doc/SessionsApi.md
doc/SharedLinkApi.md
doc/SharedLinkCreateDto.md
doc/SharedLinkEditDto.md
@ -219,6 +220,7 @@ lib/api/partner_api.dart
lib/api/person_api.dart
lib/api/search_api.dart
lib/api/server_info_api.dart
lib/api/sessions_api.dart
lib/api/shared_link_api.dart
lib/api/sync_api.dart
lib/api/system_config_api.dart
@ -267,7 +269,6 @@ lib/model/asset_stats_response_dto.dart
lib/model/asset_type_enum.dart
lib/model/audio_codec.dart
lib/model/audit_deletes_response_dto.dart
lib/model/auth_device_response_dto.dart
lib/model/bulk_id_response_dto.dart
lib/model/bulk_ids_dto.dart
lib/model/change_password_dto.dart
@ -357,6 +358,7 @@ lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart
lib/model/server_theme_dto.dart
lib/model/server_version_response_dto.dart
lib/model/session_response_dto.dart
lib/model/shared_link_create_dto.dart
lib/model/shared_link_edit_dto.dart
lib/model/shared_link_response_dto.dart
@ -448,7 +450,6 @@ test/asset_type_enum_test.dart
test/audio_codec_test.dart
test/audit_api_test.dart
test/audit_deletes_response_dto_test.dart
test/auth_device_response_dto_test.dart
test/authentication_api_test.dart
test/bulk_id_response_dto_test.dart
test/bulk_ids_dto_test.dart
@ -549,6 +550,8 @@ test/server_ping_response_test.dart
test/server_stats_response_dto_test.dart
test/server_theme_dto_test.dart
test/server_version_response_dto_test.dart
test/session_response_dto_test.dart
test/sessions_api_test.dart
test/shared_link_api_test.dart
test/shared_link_create_dto_test.dart
test/shared_link_edit_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SessionsApi.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/api/sessions_api.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/test/sessions_api_test.dart generated Normal file

Binary file not shown.

View File

@ -2530,99 +2530,6 @@
]
}
},
"/auth/devices": {
"delete": {
"operationId": "logoutAuthDevices",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
},
"get": {
"operationId": "getAuthDevices",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AuthDeviceResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/devices/{id}": {
"delete": {
"operationId": "logoutAuthDevice",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/login": {
"post": {
"operationId": "login",
@ -5184,6 +5091,99 @@
]
}
},
"/sessions": {
"delete": {
"operationId": "deleteAllSessions",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
},
"get": {
"operationId": "getSessions",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/SessionResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/sessions/{id}": {
"delete": {
"operationId": "deleteSession",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/shared-link": {
"get": {
"operationId": "getAllSharedLinks",
@ -7892,37 +7892,6 @@
],
"type": "object"
},
"AuthDeviceResponseDto": {
"properties": {
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"id": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"createdAt",
"current",
"deviceOS",
"deviceType",
"id",
"updatedAt"
],
"type": "object"
},
"BulkIdResponseDto": {
"properties": {
"error": {
@ -10049,6 +10018,37 @@
],
"type": "object"
},
"SessionResponseDto": {
"properties": {
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"id": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"createdAt",
"current",
"deviceOS",
"deviceType",
"id",
"updatedAt"
],
"type": "object"
},
"SharedLinkCreateDto": {
"properties": {
"albumId": {

View File

@ -346,14 +346,6 @@ export type ChangePasswordDto = {
newPassword: string;
password: string;
};
export type AuthDeviceResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
id: string;
updatedAt: string;
};
export type LoginCredentialDto = {
email: string;
password: string;
@ -791,6 +783,14 @@ export type ServerVersionResponseDto = {
minor: number;
patch: number;
};
export type SessionResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
id: string;
updatedAt: string;
};
export type SharedLinkResponseDto = {
album?: AlbumResponseDto;
allowDownload: boolean;
@ -1703,28 +1703,6 @@ export function changePassword({ changePasswordDto }: {
body: changePasswordDto
})));
}
export function logoutAuthDevices(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/devices", {
...opts,
method: "DELETE"
}));
}
export function getAuthDevices(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AuthDeviceResponseDto[];
}>("/auth/devices", {
...opts
}));
}
export function logoutAuthDevice({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/auth/devices/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
export function login({ loginCredentialDto }: {
loginCredentialDto: LoginCredentialDto;
}, opts?: Oazapfts.RequestOpts) {
@ -2413,6 +2391,28 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function deleteAllSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/sessions", {
...opts,
method: "DELETE"
}));
}
export function getSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SessionResponseDto[];
}>("/sessions", {
...opts
}));
}
export function deleteSession({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;

View File

@ -1,9 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, Res } from '@nestjs/common';
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, IMMICH_IS_AUTHENTICATED } from 'src/constants';
import {
AuthDeviceResponseDto,
AuthDto,
ChangePasswordDto,
LoginCredentialDto,
@ -15,7 +14,6 @@ import {
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Authentication')
@Controller('auth')
@ -41,23 +39,6 @@ export class AuthController {
return this.service.adminSignUp(dto);
}
@Get('devices')
getAuthDevices(@Auth() auth: AuthDto): Promise<AuthDeviceResponseDto[]> {
return this.service.getDevices(auth);
}
@Delete('devices')
@HttpCode(HttpStatus.NO_CONTENT)
logoutAuthDevices(@Auth() auth: AuthDto): Promise<void> {
return this.service.logoutDevices(auth);
}
@Delete('devices/:id')
@HttpCode(HttpStatus.NO_CONTENT)
logoutAuthDevice(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.logoutDevice(auth, id);
}
@Post('validateToken')
@HttpCode(HttpStatus.OK)
validateAccessToken(): ValidateAccessTokenResponseDto {

View File

@ -16,6 +16,7 @@ import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
import { SearchController } from 'src/controllers/search.controller';
import { ServerInfoController } from 'src/controllers/server-info.controller';
import { SessionController } from 'src/controllers/session.controller';
import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { SyncController } from 'src/controllers/sync.controller';
import { SystemConfigController } from 'src/controllers/system-config.controller';
@ -43,6 +44,7 @@ export const controllers = [
PartnerController,
SearchController,
ServerInfoController,
SessionController,
SharedLinkController,
SyncController,
SystemConfigController,

View File

@ -0,0 +1,31 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SessionService } from 'src/services/session.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Sessions')
@Controller('sessions')
@Authenticated()
export class SessionController {
constructor(private service: SessionService) {}
@Get()
getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> {
return this.service.getAll(auth);
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
deleteAllSessions(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteAll(auth);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}

View File

@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { UserEntity } from 'src/entities/user.entity';
export class AuthDto {
@ -11,7 +11,7 @@ export class AuthDto {
apiKey?: APIKeyEntity;
sharedLink?: SharedLinkEntity;
userToken?: UserTokenEntity;
session?: SessionEntity;
}
export class LoginCredentialDto {
@ -78,24 +78,6 @@ export class ValidateAccessTokenResponseDto {
authStatus!: boolean;
}
export class AuthDeviceResponseDto {
id!: string;
createdAt!: string;
updatedAt!: string;
current!: boolean;
deviceType!: string;
deviceOS!: string;
}
export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
current: currentId === entity.id,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,
});
export class OAuthCallbackDto {
@IsNotEmpty()
@IsString()

View File

@ -0,0 +1,19 @@
import { SessionEntity } from 'src/entities/session.entity';
export class SessionResponseDto {
id!: string;
createdAt!: string;
updatedAt!: string;
current!: boolean;
deviceType!: string;
deviceOS!: string;
}
export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
current: currentId === entity.id,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,
});

View File

@ -13,13 +13,13 @@ import { MemoryEntity } from 'src/entities/memory.entity';
import { MoveEntity } from 'src/entities/move.entity';
import { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { SystemConfigEntity } from 'src/entities/system-config.entity';
import { SystemMetadataEntity } from 'src/entities/system-metadata.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { UserEntity } from 'src/entities/user.entity';
export const entities = [
@ -44,6 +44,6 @@ export const entities = [
SystemMetadataEntity,
TagEntity,
UserEntity,
UserTokenEntity,
SessionEntity,
LibraryEntity,
];

View File

@ -1,8 +1,8 @@
import { UserEntity } from 'src/entities/user.entity';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('user_token')
export class UserTokenEntity {
@Entity('sessions')
export class SessionEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;

View File

@ -0,0 +1,11 @@
import { SessionEntity } from 'src/entities/session.entity';
export const ISessionRepository = 'ISessionRepository';
export interface ISessionRepository {
create(dto: Partial<SessionEntity>): Promise<SessionEntity>;
update(dto: Partial<SessionEntity>): Promise<SessionEntity>;
delete(id: string): Promise<void>;
getByToken(token: string): Promise<SessionEntity | null>;
getByUserId(userId: string): Promise<SessionEntity[]>;
}

View File

@ -1,11 +0,0 @@
import { UserTokenEntity } from 'src/entities/user-token.entity';
export const IUserTokenRepository = 'IUserTokenRepository';
export interface IUserTokenRepository {
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
delete(id: string): Promise<void>;
getByToken(token: string): Promise<UserTokenEntity | null>;
getAll(userId: string): Promise<UserTokenEntity[]>;
}

View File

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameSessionsTable1713490844785 implements MigrationInterface {
name = 'RenameSessionsTable1713490844785';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_token" RENAME TO "sessions"`);
await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" to "FK_57de40bc620f456c7311aa3a1e6"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_57de40bc620f456c7311aa3a1e6" to "FK_d37db50eecdf9b8ce4eedd2f918"`);
await queryRunner.query(`ALTER TABLE "sessions" RENAME TO "user_token"`);
}
}

View File

@ -173,13 +173,13 @@ WHERE
-- AccessRepository.authDevice.checkOwnerAccess
SELECT
"UserTokenEntity"."id" AS "UserTokenEntity_id"
"SessionEntity"."id" AS "SessionEntity_id"
FROM
"user_token" "UserTokenEntity"
"sessions" "SessionEntity"
WHERE
(
("UserTokenEntity"."userId" = $1)
AND ("UserTokenEntity"."id" IN ($2))
("SessionEntity"."userId" = $1)
AND ("SessionEntity"."id" IN ($2))
)
-- AccessRepository.library.checkOwnerAccess

View File

@ -0,0 +1,48 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SessionRepository.getByToken
SELECT DISTINCT
"distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id"
FROM
(
SELECT
"SessionEntity"."id" AS "SessionEntity_id",
"SessionEntity"."userId" AS "SessionEntity_userId",
"SessionEntity"."createdAt" AS "SessionEntity_createdAt",
"SessionEntity"."updatedAt" AS "SessionEntity_updatedAt",
"SessionEntity"."deviceType" AS "SessionEntity_deviceType",
"SessionEntity"."deviceOS" AS "SessionEntity_deviceOS",
"SessionEntity__SessionEntity_user"."id" AS "SessionEntity__SessionEntity_user_id",
"SessionEntity__SessionEntity_user"."name" AS "SessionEntity__SessionEntity_user_name",
"SessionEntity__SessionEntity_user"."avatarColor" AS "SessionEntity__SessionEntity_user_avatarColor",
"SessionEntity__SessionEntity_user"."isAdmin" AS "SessionEntity__SessionEntity_user_isAdmin",
"SessionEntity__SessionEntity_user"."email" AS "SessionEntity__SessionEntity_user_email",
"SessionEntity__SessionEntity_user"."storageLabel" AS "SessionEntity__SessionEntity_user_storageLabel",
"SessionEntity__SessionEntity_user"."oauthId" AS "SessionEntity__SessionEntity_user_oauthId",
"SessionEntity__SessionEntity_user"."profileImagePath" AS "SessionEntity__SessionEntity_user_profileImagePath",
"SessionEntity__SessionEntity_user"."shouldChangePassword" AS "SessionEntity__SessionEntity_user_shouldChangePassword",
"SessionEntity__SessionEntity_user"."createdAt" AS "SessionEntity__SessionEntity_user_createdAt",
"SessionEntity__SessionEntity_user"."deletedAt" AS "SessionEntity__SessionEntity_user_deletedAt",
"SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status",
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
"SessionEntity__SessionEntity_user"."memoriesEnabled" AS "SessionEntity__SessionEntity_user_memoriesEnabled",
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes",
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes"
FROM
"sessions" "SessionEntity"
LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId"
AND (
"SessionEntity__SessionEntity_user"."deletedAt" IS NULL
)
WHERE
(("SessionEntity"."token" = $1))
) "distinctAlias"
ORDER BY
"SessionEntity_id" ASC
LIMIT
1
-- SessionRepository.delete
DELETE FROM "sessions"
WHERE
"id" = $1

View File

@ -1,48 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- UserTokenRepository.getByToken
SELECT DISTINCT
"distinctAlias"."UserTokenEntity_id" AS "ids_UserTokenEntity_id"
FROM
(
SELECT
"UserTokenEntity"."id" AS "UserTokenEntity_id",
"UserTokenEntity"."userId" AS "UserTokenEntity_userId",
"UserTokenEntity"."createdAt" AS "UserTokenEntity_createdAt",
"UserTokenEntity"."updatedAt" AS "UserTokenEntity_updatedAt",
"UserTokenEntity"."deviceType" AS "UserTokenEntity_deviceType",
"UserTokenEntity"."deviceOS" AS "UserTokenEntity_deviceOS",
"UserTokenEntity__UserTokenEntity_user"."id" AS "UserTokenEntity__UserTokenEntity_user_id",
"UserTokenEntity__UserTokenEntity_user"."name" AS "UserTokenEntity__UserTokenEntity_user_name",
"UserTokenEntity__UserTokenEntity_user"."avatarColor" AS "UserTokenEntity__UserTokenEntity_user_avatarColor",
"UserTokenEntity__UserTokenEntity_user"."isAdmin" AS "UserTokenEntity__UserTokenEntity_user_isAdmin",
"UserTokenEntity__UserTokenEntity_user"."email" AS "UserTokenEntity__UserTokenEntity_user_email",
"UserTokenEntity__UserTokenEntity_user"."storageLabel" AS "UserTokenEntity__UserTokenEntity_user_storageLabel",
"UserTokenEntity__UserTokenEntity_user"."oauthId" AS "UserTokenEntity__UserTokenEntity_user_oauthId",
"UserTokenEntity__UserTokenEntity_user"."profileImagePath" AS "UserTokenEntity__UserTokenEntity_user_profileImagePath",
"UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword",
"UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt",
"UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt",
"UserTokenEntity__UserTokenEntity_user"."status" AS "UserTokenEntity__UserTokenEntity_user_status",
"UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt",
"UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled",
"UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes",
"UserTokenEntity__UserTokenEntity_user"."quotaUsageInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaUsageInBytes"
FROM
"user_token" "UserTokenEntity"
LEFT JOIN "users" "UserTokenEntity__UserTokenEntity_user" ON "UserTokenEntity__UserTokenEntity_user"."id" = "UserTokenEntity"."userId"
AND (
"UserTokenEntity__UserTokenEntity_user"."deletedAt" IS NULL
)
WHERE
(("UserTokenEntity"."token" = $1))
) "distinctAlias"
ORDER BY
"UserTokenEntity_id" ASC
LIMIT
1
-- UserTokenRepository.delete
DELETE FROM "user_token"
WHERE
"id" = $1

View File

@ -9,8 +9,8 @@ import { LibraryEntity } from 'src/entities/library.entity';
import { MemoryEntity } from 'src/entities/memory.entity';
import { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Brackets, In, Repository } from 'typeorm';
@ -286,7 +286,7 @@ class AssetAccess implements IAssetAccess {
}
class AuthDeviceAccess implements IAuthDeviceAccess {
constructor(private tokenRepository: Repository<UserTokenEntity>) {}
constructor(private sessionRepository: Repository<SessionEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
@ -295,7 +295,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
return new Set();
}
return this.tokenRepository
return this.sessionRepository
.find({
select: { id: true },
where: {
@ -457,12 +457,12 @@ export class AccessRepository implements IAccessRepository {
@InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
@InjectRepository(UserTokenEntity) tokenRepository: Repository<UserTokenEntity>,
@InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
) {
this.activity = new ActivityAccess(activityRepository, albumRepository);
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
this.authDevice = new AuthDeviceAccess(tokenRepository);
this.authDevice = new AuthDeviceAccess(sessionRepository);
this.library = new LibraryAccess(libraryRepository);
this.memory = new MemoryAccess(memoryRepository);
this.person = new PersonAccess(assetFaceRepository, personRepository);

View File

@ -22,12 +22,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
@ -53,12 +53,12 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemConfigRepository } from 'src/repositories/system-config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { UserTokenRepository } from 'src/repositories/user-token.repository';
import { UserRepository } from 'src/repositories/user.repository';
export const repositories = [
@ -86,11 +86,11 @@ export const repositories = [
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISearchRepository, useClass: SearchRepository },
{ provide: ISessionRepository, useClass: SessionRepository },
{ provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: IMediaRepository, useClass: MediaRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
];

View File

@ -1,22 +1,22 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { SessionEntity } from 'src/entities/session.entity';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class UserTokenRepository implements IUserTokenRepository {
constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
export class SessionRepository implements ISessionRepository {
constructor(@InjectRepository(SessionEntity) private repository: Repository<SessionEntity>) {}
@GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string): Promise<UserTokenEntity | null> {
getByToken(token: string): Promise<SessionEntity | null> {
return this.repository.findOne({ where: { token }, relations: { user: true } });
}
getAll(userId: string): Promise<UserTokenEntity[]> {
getByUserId(userId: string): Promise<SessionEntity[]> {
return this.repository.find({
where: {
userId,
@ -31,12 +31,12 @@ export class UserTokenRepository implements IUserTokenRepository {
});
}
create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.repository.save(userToken);
create(session: Partial<SessionEntity>): Promise<SessionEntity> {
return this.repository.save(session);
}
save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
return this.repository.save(userToken);
update(session: Partial<SessionEntity>): Promise<SessionEntity> {
return this.repository.save(session);
}
@GenerateSql({ params: [DummyValue.UUID] })

View File

@ -9,25 +9,25 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthService } from 'src/services/auth.service';
import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub, loginResponseStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userTokenStub } from 'test/fixtures/user-token.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
import { newUserTokenRepositoryMock } from 'test/repositories/user-token.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mock, Mocked, vitest } from 'vitest';
@ -65,7 +65,7 @@ describe('AuthService', () => {
let libraryMock: Mocked<ILibraryRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let configMock: Mocked<ISystemConfigRepository>;
let userTokenMock: Mocked<IUserTokenRepository>;
let sessionMock: Mocked<ISessionRepository>;
let shareMock: Mocked<ISharedLinkRepository>;
let keyMock: Mocked<IKeyRepository>;
@ -98,7 +98,7 @@ describe('AuthService', () => {
libraryMock = newLibraryRepositoryMock();
loggerMock = newLoggerRepositoryMock();
configMock = newSystemConfigRepositoryMock();
userTokenMock = newUserTokenRepositoryMock();
sessionMock = newSessionRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
@ -109,7 +109,7 @@ describe('AuthService', () => {
libraryMock,
loggerMock,
userMock,
userTokenMock,
sessionMock,
shareMock,
keyMock,
);
@ -139,14 +139,14 @@ describe('AuthService', () => {
it('should successfully log the user in', async () => {
userMock.getByEmail.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should generate the cookie headers (insecure)', async () => {
userMock.getByEmail.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(
sut.login(fixtures.login, {
clientIp: '127.0.0.1',
@ -231,14 +231,14 @@ describe('AuthService', () => {
});
it('should delete the access token', async () => {
const auth = { user: { id: '123' }, userToken: { id: 'token123' } } as AuthDto;
const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto;
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true,
redirectUri: '/auth/login?autoLaunch=0',
});
expect(userTokenMock.delete).toHaveBeenCalledWith('token123');
expect(sessionMock.delete).toHaveBeenCalledWith('token123');
});
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
@ -282,11 +282,11 @@ describe('AuthService', () => {
it('should validate using authorization header', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken);
sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
session: sessionStub.valid,
});
});
});
@ -336,37 +336,29 @@ describe('AuthService', () => {
describe('validate - user token', () => {
it('should throw if no token is found', async () => {
userTokenMock.getByToken.mockResolvedValue(null);
sessionMock.getByToken.mockResolvedValue(null);
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should return an auth dto', async () => {
userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken);
sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
session: sessionStub.valid,
});
});
it('should update when access time exceeds an hour', async () => {
userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken);
userTokenMock.save.mockResolvedValue(userTokenStub.userToken);
sessionMock.getByToken.mockResolvedValue(sessionStub.inactive);
sessionMock.update.mockResolvedValue(sessionStub.valid);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
});
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
id: 'not_active',
token: 'auth_token',
userId: 'user-id',
createdAt: new Date('2021-01-01'),
updatedAt: expect.any(Date),
deviceOS: 'Android',
deviceType: 'Mobile',
session: sessionStub.valid,
});
expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
});
});
@ -386,55 +378,6 @@ describe('AuthService', () => {
});
});
describe('getDevices', () => {
it('should get the devices', async () => {
userTokenMock.getAll.mockResolvedValue([userTokenStub.userToken, userTokenStub.inactiveToken]);
await expect(sut.getDevices(authStub.user1)).resolves.toEqual([
{
createdAt: '2021-01-01T00:00:00.000Z',
current: true,
deviceOS: '',
deviceType: '',
id: 'token-id',
updatedAt: expect.any(String),
},
{
createdAt: '2021-01-01T00:00:00.000Z',
current: false,
deviceOS: 'Android',
deviceType: 'Mobile',
id: 'not_active',
updatedAt: expect.any(String),
},
]);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
describe('logoutDevices', () => {
it('should logout all devices', async () => {
userTokenMock.getAll.mockResolvedValue([userTokenStub.inactiveToken, userTokenStub.userToken]);
await sut.logoutDevices(authStub.user1);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
expect(userTokenMock.delete).toHaveBeenCalledWith('not_active');
expect(userTokenMock.delete).not.toHaveBeenCalledWith('token-id');
});
});
describe('logoutDevice', () => {
it('should logout the device', async () => {
accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
await sut.logoutDevice(authStub.user1, 'token-1');
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1']));
expect(userTokenMock.delete).toHaveBeenCalledWith('token-1');
});
});
describe('getMobileRedirect', () => {
it('should pass along the query params', () => {
expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
@ -463,7 +406,7 @@ describe('AuthService', () => {
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
@ -478,7 +421,7 @@ describe('AuthService', () => {
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
@ -491,7 +434,7 @@ describe('AuthService', () => {
it('should use the mobile redirect override', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails);
@ -501,7 +444,7 @@ describe('AuthService', () => {
it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);

View File

@ -19,11 +19,10 @@ import {
LOGIN_URL,
MOBILE_REDIRECT,
} from 'src/constants';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AccessCore } from 'src/cores/access.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import {
AuthDeviceResponseDto,
AuthDto,
ChangePasswordDto,
LoginCredentialDto,
@ -34,7 +33,6 @@ import {
OAuthConfigDto,
SignUpDto,
mapLoginResponse,
mapUserToken,
} from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { SystemConfig } from 'src/entities/system-config.entity';
@ -44,9 +42,9 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { HumanReadableSize } from 'src/utils/bytes';
@ -85,7 +83,7 @@ export class AuthService {
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
) {
@ -120,8 +118,8 @@ export class AuthService {
}
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
if (auth.userToken) {
await this.userTokenRepository.delete(auth.userToken.id);
if (auth.session) {
await this.sessionRepository.delete(auth.session.id);
}
return {
@ -164,8 +162,9 @@ export class AuthService {
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 ||
const session = (headers['x-immich-user-token'] ||
headers['x-immich-session-token'] ||
params.sessionKey ||
this.getBearerToken(headers) ||
this.getCookieToken(headers)) as string;
const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string;
@ -174,8 +173,8 @@ export class AuthService {
return this.validateSharedLink(shareKey);
}
if (userToken) {
return this.validateUserToken(userToken);
if (session) {
return this.validateSession(session);
}
if (apiKey) {
@ -185,26 +184,6 @@ export class AuthService {
throw new UnauthorizedException('Authentication required');
}
async getDevices(auth: AuthDto): Promise<AuthDeviceResponseDto[]> {
const userTokens = await this.userTokenRepository.getAll(auth.user.id);
return userTokens.map((userToken) => mapUserToken(userToken, auth.userToken?.id));
}
async logoutDevice(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id);
await this.userTokenRepository.delete(id);
}
async logoutDevices(auth: AuthDto): Promise<void> {
const devices = await this.userTokenRepository.getAll(auth.user.id);
for (const device of devices) {
if (device.id === auth.userToken?.id) {
continue;
}
await this.userTokenRepository.delete(device.id);
}
}
getMobileRedirect(url: string) {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
@ -408,19 +387,19 @@ export class AuthService {
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
}
private async validateUserToken(tokenValue: string): Promise<AuthDto> {
private async validateSession(tokenValue: string): Promise<AuthDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
let userToken = await this.userTokenRepository.getByToken(hashedToken);
let session = await this.sessionRepository.getByToken(hashedToken);
if (userToken?.user) {
if (session?.user) {
const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(userToken.updatedAt);
const updatedAt = DateTime.fromJSDate(session.updatedAt);
const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) {
userToken = await this.userTokenRepository.save({ ...userToken, updatedAt: new Date() });
session = await this.sessionRepository.update({ id: session.id, updatedAt: new Date() });
}
return { user: userToken.user, userToken };
return { user: session.user, session: session };
}
throw new UnauthorizedException('Invalid user token');
@ -430,7 +409,7 @@ export class AuthService {
const key = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.hashSha256(key);
await this.userTokenRepository.create({
await this.sessionRepository.create({
token,
user,
deviceOS: loginDetails.deviceOS,

View File

@ -18,6 +18,7 @@ import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { SearchService } from 'src/services/search.service';
import { ServerInfoService } from 'src/services/server-info.service';
import { SessionService } from 'src/services/session.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service';
@ -50,6 +51,7 @@ export const services = [
PersonService,
SearchService,
ServerInfoService,
SessionService,
SharedLinkService,
SmartInfoService,
StorageService,

View File

@ -0,0 +1,77 @@
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { SessionService } from 'src/services/session.service';
import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { Mocked } from 'vitest';
describe('SessionService', () => {
let sut: SessionService;
let accessMock: Mocked<IAccessRepositoryMock>;
let loggerMock: Mocked<ILoggerRepository>;
let sessionMock: Mocked<ISessionRepository>;
beforeEach(() => {
accessMock = newAccessRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sessionMock = newSessionRepositoryMock();
sut = new SessionService(accessMock, loggerMock, sessionMock);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
describe('getAll', () => {
it('should get the devices', async () => {
sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([
{
createdAt: '2021-01-01T00:00:00.000Z',
current: true,
deviceOS: '',
deviceType: '',
id: 'token-id',
updatedAt: expect.any(String),
},
{
createdAt: '2021-01-01T00:00:00.000Z',
current: false,
deviceOS: 'Android',
deviceType: 'Mobile',
id: 'not_active',
updatedAt: expect.any(String),
},
]);
expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
describe('logoutDevices', () => {
it('should logout all devices', async () => {
sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid]);
await sut.deleteAll(authStub.user1);
expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id);
expect(sessionMock.delete).toHaveBeenCalledWith('not_active');
expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id');
});
});
describe('logoutDevice', () => {
it('should logout the device', async () => {
accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
await sut.delete(authStub.user1, 'token-1');
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1']));
expect(sessionMock.delete).toHaveBeenCalledWith('token-1');
});
});
});

View File

@ -0,0 +1,41 @@
import { Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
@Injectable()
export class SessionService {
private access: AccessCore;
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
) {
this.logger.setContext(SessionService.name);
this.access = AccessCore.create(accessRepository);
}
async getAll(auth: AuthDto): Promise<SessionResponseDto[]> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
return sessions.map((session) => mapSession(session, auth.session?.id));
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id);
await this.sessionRepository.delete(id);
}
async deleteAll(auth: AuthDto): Promise<void> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
for (const session of sessions) {
if (session.id === auth.session?.id) {
continue;
}
await this.sessionRepository.delete(session.id);
}
}
}

View File

@ -1,6 +1,6 @@
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { UserEntity } from 'src/entities/user.entity';
export const adminSignupStub = {
@ -35,9 +35,9 @@ export const authStub = {
email: 'immich@test.com',
isAdmin: false,
} as UserEntity,
userToken: {
session: {
id: 'token-id',
} as UserTokenEntity,
} as SessionEntity,
}),
user2: Object.freeze<AuthDto>({
user: {
@ -45,9 +45,9 @@ export const authStub = {
email: 'user2@immich.app',
isAdmin: false,
} as UserEntity,
userToken: {
session: {
id: 'token-id',
} as UserTokenEntity,
} as SessionEntity,
}),
external1: Object.freeze<AuthDto>({
user: {
@ -55,9 +55,9 @@ export const authStub = {
email: 'immich@test.com',
isAdmin: false,
} as UserEntity,
userToken: {
session: {
id: 'token-id',
} as UserTokenEntity,
} as SessionEntity,
}),
adminSharedLink: Object.freeze<AuthDto>({
user: {

View File

@ -1,8 +1,8 @@
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { userStub } from 'test/fixtures/user.stub';
export const userTokenStub = {
userToken: Object.freeze<UserTokenEntity>({
export const sessionStub = {
valid: Object.freeze<SessionEntity>({
id: 'token-id',
token: 'auth_token',
userId: userStub.user1.id,
@ -12,7 +12,7 @@ export const userTokenStub = {
deviceType: '',
deviceOS: '',
}),
inactiveToken: Object.freeze<UserTokenEntity>({
inactive: Object.freeze<SessionEntity>({
id: 'not_active',
token: 'auth_token',
userId: userStub.user1.id,

View File

@ -0,0 +1,12 @@
import { ISessionRepository } from 'src/interfaces/session.interface';
import { Mocked, vitest } from 'vitest';
export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => {
return {
create: vitest.fn(),
update: vitest.fn(),
delete: vitest.fn(),
getByToken: vitest.fn(),
getByUserId: vitest.fn(),
};
};

View File

@ -1,12 +0,0 @@
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { Mocked, vitest } from 'vitest';
export const newUserTokenRepositoryMock = (): Mocked<IUserTokenRepository> => {
return {
create: vitest.fn(),
save: vitest.fn(),
delete: vitest.fn(),
getByToken: vitest.fn(),
getAll: vitest.fn(),
};
};

View File

@ -1,7 +1,7 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { locale } from '$lib/stores/preferences.store';
import type { AuthDeviceResponseDto } from '@immich/sdk';
import type { SessionResponseDto } from '@immich/sdk';
import {
mdiAndroid,
mdiApple,
@ -15,7 +15,7 @@
import { DateTime, type ToRelativeCalendarOptions } from 'luxon';
import { createEventDispatcher } from 'svelte';
export let device: AuthDeviceResponseDto;
export let device: SessionResponseDto;
const dispatcher = createEventDispatcher<{
delete: void;

View File

@ -1,16 +1,16 @@
<script lang="ts">
import { getAuthDevices, logoutAuthDevice, logoutAuthDevices, type AuthDeviceResponseDto } from '@immich/sdk';
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
import { handleError } from '../../utils/handle-error';
import Button from '../elements/buttons/button.svelte';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import DeviceCard from './device-card.svelte';
export let devices: AuthDeviceResponseDto[];
let deleteDevice: AuthDeviceResponseDto | null = null;
export let devices: SessionResponseDto[];
let deleteDevice: SessionResponseDto | null = null;
let deleteAll = false;
const refresh = () => getAuthDevices().then((_devices) => (devices = _devices));
const refresh = () => getSessions().then((_devices) => (devices = _devices));
$: currentDevice = devices.find((device) => device.current);
$: otherDevices = devices.filter((device) => !device.current);
@ -21,7 +21,7 @@
}
try {
await logoutAuthDevice({ id: deleteDevice.id });
await deleteSession({ id: deleteDevice.id });
notificationController.show({ message: `Logged out device`, type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to log out device');
@ -33,7 +33,7 @@
const handleDeleteAll = async () => {
try {
await logoutAuthDevices();
await deleteAllSessions();
notificationController.show({
message: `Logged out all devices`,
type: NotificationType.Info,

View File

@ -4,7 +4,8 @@
import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { oauth } from '$lib/utils';
import { type ApiKeyResponseDto, type AuthDeviceResponseDto } from '@immich/sdk';
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
import AppSettings from './app-settings.svelte';
import ChangePasswordSettings from './change-password-settings.svelte';
@ -14,10 +15,9 @@
import PartnerSettings from './partner-settings.svelte';
import UserAPIKeyList from './user-api-key-list.svelte';
import UserProfileSettings from './user-profile-settings.svelte';
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
export let keys: ApiKeyResponseDto[] = [];
export let devices: AuthDeviceResponseDto[] = [];
export let sessions: SessionResponseDto[] = [];
let oauthOpen =
oauth.isCallback(window.location) ||
@ -38,7 +38,7 @@
</SettingAccordion>
<SettingAccordion key="authorized-devices" title="Authorized Devices" subtitle="Manage your logged-in devices">
<DeviceList bind:devices />
<DeviceList bind:devices={sessions} />
</SettingAccordion>
<SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories">

View File

@ -19,7 +19,7 @@
</svelte:fragment>
<section class="mx-4 flex place-content-center">
<div class="w-full max-w-3xl">
<UserSettingsList keys={data.keys} devices={data.devices} />
<UserSettingsList keys={data.keys} sessions={data.sessions} />
</div>
</section>
</UserPageLayout>

View File

@ -1,16 +1,16 @@
import { authenticate } from '$lib/utils/auth';
import { getApiKeys, getAuthDevices } from '@immich/sdk';
import { getApiKeys, getSessions } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate();
const keys = await getApiKeys();
const devices = await getAuthDevices();
const sessions = await getSessions();
return {
keys,
devices,
sessions,
meta: {
title: 'Settings',
},