diff --git a/e2e/src/api/specs/auth.e2e-spec.ts b/e2e/src/api/specs/auth.e2e-spec.ts index 28445f79d..4a6e1a773 100644 --- a/e2e/src/api/specs/auth.e2e-spec.ts +++ b/e2e/src/api/specs/auth.e2e-spec.ts @@ -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'); diff --git a/e2e/src/api/specs/session.e2e-spec.ts b/e2e/src/api/specs/session.e2e-spec.ts new file mode 100644 index 000000000..0b632f78b --- /dev/null +++ b/e2e/src/api/specs/session.e2e-spec.ts @@ -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); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 617f2d62c..004750202 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -140,7 +140,7 @@ export const utils = { 'asset_faces', 'activity', 'api_keys', - 'user_token', + 'sessions', 'users', 'system_metadata', 'system_config', diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index b296bbcb5..2181476b3 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 730307b9b..7fb4681f7 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AuthenticationApi.md b/mobile/openapi/doc/AuthenticationApi.md index 9521568e9..02fb94a09 100644 Binary files a/mobile/openapi/doc/AuthenticationApi.md and b/mobile/openapi/doc/AuthenticationApi.md differ diff --git a/mobile/openapi/doc/AuthDeviceResponseDto.md b/mobile/openapi/doc/SessionResponseDto.md similarity index 93% rename from mobile/openapi/doc/AuthDeviceResponseDto.md rename to mobile/openapi/doc/SessionResponseDto.md index 4433e3338..9d1a11cbc 100644 Binary files a/mobile/openapi/doc/AuthDeviceResponseDto.md and b/mobile/openapi/doc/SessionResponseDto.md differ diff --git a/mobile/openapi/doc/SessionsApi.md b/mobile/openapi/doc/SessionsApi.md new file mode 100644 index 000000000..d082a8cfe Binary files /dev/null and b/mobile/openapi/doc/SessionsApi.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e7320d5bb..b484d38b6 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index d1f04d600..62f8be353 100644 Binary files a/mobile/openapi/lib/api/authentication_api.dart and b/mobile/openapi/lib/api/authentication_api.dart differ diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart new file mode 100644 index 000000000..bc0fed71e Binary files /dev/null and b/mobile/openapi/lib/api/sessions_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4bbae8928..0a0cd8008 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/auth_device_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart similarity index 70% rename from mobile/openapi/lib/model/auth_device_response_dto.dart rename to mobile/openapi/lib/model/session_response_dto.dart index f1425a221..6a44fc24b 100644 Binary files a/mobile/openapi/lib/model/auth_device_response_dto.dart and b/mobile/openapi/lib/model/session_response_dto.dart differ diff --git a/mobile/openapi/test/authentication_api_test.dart b/mobile/openapi/test/authentication_api_test.dart index aa2f1879d..dea20ec9b 100644 Binary files a/mobile/openapi/test/authentication_api_test.dart and b/mobile/openapi/test/authentication_api_test.dart differ diff --git a/mobile/openapi/test/auth_device_response_dto_test.dart b/mobile/openapi/test/session_response_dto_test.dart similarity index 88% rename from mobile/openapi/test/auth_device_response_dto_test.dart rename to mobile/openapi/test/session_response_dto_test.dart index c0cccf8d6..d704b2e5e 100644 Binary files a/mobile/openapi/test/auth_device_response_dto_test.dart and b/mobile/openapi/test/session_response_dto_test.dart differ diff --git a/mobile/openapi/test/sessions_api_test.dart b/mobile/openapi/test/sessions_api_test.dart new file mode 100644 index 000000000..9fc6093c1 Binary files /dev/null and b/mobile/openapi/test/sessions_api_test.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8f53f838b..bfe3ec32c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 96b071f1f..560295c94 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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; diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 9b4e7a3bc..f4e766620 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -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 { - return this.service.getDevices(auth); - } - - @Delete('devices') - @HttpCode(HttpStatus.NO_CONTENT) - logoutAuthDevices(@Auth() auth: AuthDto): Promise { - return this.service.logoutDevices(auth); - } - - @Delete('devices/:id') - @HttpCode(HttpStatus.NO_CONTENT) - logoutAuthDevice(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.logoutDevice(auth, id); - } - @Post('validateToken') @HttpCode(HttpStatus.OK) validateAccessToken(): ValidateAccessTokenResponseDto { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index d136a52b0..5e109f1eb 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -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, diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts new file mode 100644 index 000000000..552afcdf5 --- /dev/null +++ b/server/src/controllers/session.controller.ts @@ -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 { + return this.service.getAll(auth); + } + + @Delete() + @HttpCode(HttpStatus.NO_CONTENT) + deleteAllSessions(@Auth() auth: AuthDto): Promise { + return this.service.deleteAll(auth); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } +} diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index f3f2270d0..4651c010b 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -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() diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts new file mode 100644 index 000000000..d96d7819a --- /dev/null +++ b/server/src/dtos/session.dto.ts @@ -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, +}); diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 761b47693..59aa90719 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -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, ]; diff --git a/server/src/entities/user-token.entity.ts b/server/src/entities/session.entity.ts similarity index 92% rename from server/src/entities/user-token.entity.ts rename to server/src/entities/session.entity.ts index 3c2cf2cf6..1cc9ad985 100644 --- a/server/src/entities/user-token.entity.ts +++ b/server/src/entities/session.entity.ts @@ -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; diff --git a/server/src/interfaces/session.interface.ts b/server/src/interfaces/session.interface.ts new file mode 100644 index 000000000..3e2c9574a --- /dev/null +++ b/server/src/interfaces/session.interface.ts @@ -0,0 +1,11 @@ +import { SessionEntity } from 'src/entities/session.entity'; + +export const ISessionRepository = 'ISessionRepository'; + +export interface ISessionRepository { + create(dto: Partial): Promise; + update(dto: Partial): Promise; + delete(id: string): Promise; + getByToken(token: string): Promise; + getByUserId(userId: string): Promise; +} diff --git a/server/src/interfaces/user-token.interface.ts b/server/src/interfaces/user-token.interface.ts deleted file mode 100644 index 0fcec39fd..000000000 --- a/server/src/interfaces/user-token.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { UserTokenEntity } from 'src/entities/user-token.entity'; - -export const IUserTokenRepository = 'IUserTokenRepository'; - -export interface IUserTokenRepository { - create(dto: Partial): Promise; - save(dto: Partial): Promise; - delete(id: string): Promise; - getByToken(token: string): Promise; - getAll(userId: string): Promise; -} diff --git a/server/src/migrations/1713490844785-RenameSessionsTable.ts b/server/src/migrations/1713490844785-RenameSessionsTable.ts new file mode 100644 index 000000000..b1b35e8ae --- /dev/null +++ b/server/src/migrations/1713490844785-RenameSessionsTable.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameSessionsTable1713490844785 implements MigrationInterface { + name = 'RenameSessionsTable1713490844785'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_57de40bc620f456c7311aa3a1e6" to "FK_d37db50eecdf9b8ce4eedd2f918"`); + await queryRunner.query(`ALTER TABLE "sessions" RENAME TO "user_token"`); + } +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 0e1cab6d0..3c6eca727 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -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 diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql new file mode 100644 index 000000000..e712c8a16 --- /dev/null +++ b/server/src/queries/session.repository.sql @@ -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 diff --git a/server/src/queries/user.token.repository.sql b/server/src/queries/user.token.repository.sql deleted file mode 100644 index f09238e13..000000000 --- a/server/src/queries/user.token.repository.sql +++ /dev/null @@ -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 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 469de11be..a624e8bfd 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -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) {} + constructor(private sessionRepository: Repository) {} @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, @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository, @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository, - @InjectRepository(UserTokenEntity) tokenRepository: Repository, + @InjectRepository(SessionEntity) sessionRepository: Repository, ) { 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); diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index e6466ee6b..6ab09ac74 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -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 }, ]; diff --git a/server/src/repositories/user-token.repository.ts b/server/src/repositories/session.repository.ts similarity index 54% rename from server/src/repositories/user-token.repository.ts rename to server/src/repositories/session.repository.ts index cbf3a3e3b..5e42039bc 100644 --- a/server/src/repositories/user-token.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -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) {} +export class SessionRepository implements ISessionRepository { + constructor(@InjectRepository(SessionEntity) private repository: Repository) {} @GenerateSql({ params: [DummyValue.STRING] }) - getByToken(token: string): Promise { + getByToken(token: string): Promise { return this.repository.findOne({ where: { token }, relations: { user: true } }); } - getAll(userId: string): Promise { + getByUserId(userId: string): Promise { return this.repository.find({ where: { userId, @@ -31,12 +31,12 @@ export class UserTokenRepository implements IUserTokenRepository { }); } - create(userToken: Partial): Promise { - return this.repository.save(userToken); + create(session: Partial): Promise { + return this.repository.save(session); } - save(userToken: Partial): Promise { - return this.repository.save(userToken); + update(session: Partial): Promise { + return this.repository.save(session); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index d53f31966..9d83d5261 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -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; let loggerMock: Mocked; let configMock: Mocked; - let userTokenMock: Mocked; + let sessionMock: Mocked; let shareMock: Mocked; let keyMock: Mocked; @@ -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); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7bebca598..7e81d15ce 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -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 { - 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): Promise { 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 { - 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 { - await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); - await this.userTokenRepository.delete(id); - } - - async logoutDevices(auth: AuthDto): Promise { - 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 { + private async validateSession(tokenValue: string): Promise { 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, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 6c40f8420..db3d6083e 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -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, diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts new file mode 100644 index 000000000..0b54564da --- /dev/null +++ b/server/src/services/session.service.spec.ts @@ -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; + let loggerMock: Mocked; + let sessionMock: Mocked; + + 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'); + }); + }); +}); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts new file mode 100644 index 000000000..7ee454d7b --- /dev/null +++ b/server/src/services/session.service.ts @@ -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 { + 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 { + await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id); + await this.sessionRepository.delete(id); + } + + async deleteAll(auth: AuthDto): Promise { + 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); + } + } +} diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 2e56d0001..a4753a02e 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -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({ 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({ 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({ user: { diff --git a/server/test/fixtures/user-token.stub.ts b/server/test/fixtures/session.stub.ts similarity index 72% rename from server/test/fixtures/user-token.stub.ts rename to server/test/fixtures/session.stub.ts index 2f6fcc0cd..cdf499c8d 100644 --- a/server/test/fixtures/user-token.stub.ts +++ b/server/test/fixtures/session.stub.ts @@ -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({ +export const sessionStub = { + valid: Object.freeze({ id: 'token-id', token: 'auth_token', userId: userStub.user1.id, @@ -12,7 +12,7 @@ export const userTokenStub = { deviceType: '', deviceOS: '', }), - inactiveToken: Object.freeze({ + inactive: Object.freeze({ id: 'not_active', token: 'auth_token', userId: userStub.user1.id, diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts new file mode 100644 index 000000000..1a034e79f --- /dev/null +++ b/server/test/repositories/session.repository.mock.ts @@ -0,0 +1,12 @@ +import { ISessionRepository } from 'src/interfaces/session.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newSessionRepositoryMock = (): Mocked => { + return { + create: vitest.fn(), + update: vitest.fn(), + delete: vitest.fn(), + getByToken: vitest.fn(), + getByUserId: vitest.fn(), + }; +}; diff --git a/server/test/repositories/user-token.repository.mock.ts b/server/test/repositories/user-token.repository.mock.ts deleted file mode 100644 index f34e65b7f..000000000 --- a/server/test/repositories/user-token.repository.mock.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; -import { Mocked, vitest } from 'vitest'; - -export const newUserTokenRepositoryMock = (): Mocked => { - return { - create: vitest.fn(), - save: vitest.fn(), - delete: vitest.fn(), - getByToken: vitest.fn(), - getAll: vitest.fn(), - }; -}; diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 64f17ad9e..8821ed970 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -1,7 +1,7 @@