From b8313abfa8b4bb1d370aafc24d5269ce1babbf95 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 25 Apr 2023 22:19:23 -0400 Subject: [PATCH] feat(web,server): manage authorized devices (#2329) * feat: manage authorized devices * chore: open api * get header from mobile app * write header from mobile app * styling * fix unit test * feat: use relative time * feat: update access time * fix: tests * chore: confirm wording * chore: bump test coverage thresholds * feat: add some icons * chore: icon tweaks --------- Co-authored-by: Alex Tran --- .../providers/authentication.provider.dart | 17 ++ mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 15347 -> 15641 bytes mobile/openapi/doc/AuthDeviceResponseDto.md | Bin 0 -> 574 bytes mobile/openapi/doc/AuthenticationApi.md | Bin 7280 -> 10523 bytes mobile/openapi/doc/LogoutResponseDto.md | Bin 472 -> 450 bytes mobile/openapi/lib/api.dart | Bin 5155 -> 5199 bytes .../openapi/lib/api/authentication_api.dart | Bin 8162 -> 10709 bytes mobile/openapi/lib/api_client.dart | Bin 16923 -> 17017 bytes .../lib/model/auth_device_response_dto.dart | Bin 0 -> 4680 bytes .../test/auth_device_response_dto_test.dart | Bin 0 -> 1084 bytes .../openapi/test/authentication_api_test.dart | Bin 1160 -> 1406 bytes .../immich/src/controllers/auth.controller.ts | 24 ++- .../src/controllers/oauth.controller.ts | 7 +- .../src/decorators/auth-user.decorator.ts | 15 +- .../immich/src/utils/patch-open-api.util.ts | 4 + server/immich-openapi-specs.json | 101 +++++++++- server/libs/domain/src/auth/auth.core.ts | 21 ++- .../libs/domain/src/auth/auth.service.spec.ts | 83 +++++++-- server/libs/domain/src/auth/auth.service.ts | 23 ++- server/libs/domain/src/auth/index.ts | 1 + .../response-dto/auth-device-response.dto.ts | 19 ++ .../domain/src/auth/response-dto/index.ts | 1 + .../auth/response-dto/logout-response.dto.ts | 9 - .../validate-asset-token-response.dto.ts | 7 - .../domain/src/oauth/oauth.service.spec.ts | 17 +- server/libs/domain/src/oauth/oauth.service.ts | 9 +- .../domain/src/user-token/user-token.core.ts | 25 ++- .../src/user-token/user-token.repository.ts | 6 +- server/libs/domain/test/fixtures.ts | 17 +- .../domain/test/user-token.repository.mock.ts | 4 +- .../infra/src/entities/user-token.entity.ts | 13 +- .../1682371561743-FixNullableRelations.ts | 21 +++ .../1682371791038-AddDeviceInfoToUserToken.ts | 16 ++ .../src/repositories/user-token.repository.ts | 38 ++-- server/package-lock.json | 41 ++++- server/package.json | 12 +- web/src/api/open-api/api.ts | 174 ++++++++++++++++++ .../user-settings-page/device-card.svelte | 72 ++++++++ .../user-settings-page/device-list.svelte | 71 +++++++ .../user-settings-list.svelte | 5 + 41 files changed, 785 insertions(+), 91 deletions(-) create mode 100644 mobile/openapi/doc/AuthDeviceResponseDto.md create mode 100644 mobile/openapi/lib/model/auth_device_response_dto.dart create mode 100644 mobile/openapi/test/auth_device_response_dto_test.dart create mode 100644 server/libs/domain/src/auth/response-dto/auth-device-response.dto.ts create mode 100644 server/libs/infra/src/migrations/1682371561743-FixNullableRelations.ts create mode 100644 server/libs/infra/src/migrations/1682371791038-AddDeviceInfoToUserToken.ts create mode 100644 web/src/lib/components/user-settings-page/device-card.svelte create mode 100644 web/src/lib/components/user-settings-page/device-list.svelte diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index b2979df85..4317d4bc5 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -49,6 +50,22 @@ class AuthenticationNotifier extends StateNotifier { } // Make sign-in request + DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin(); + + if (Platform.isIOS) { + var iosInfo = await deviceInfoPlugin.iosInfo; + _apiService.authenticationApi.apiClient + .addDefaultHeader('deviceModel', iosInfo.utsname.machine ?? ''); + _apiService.authenticationApi.apiClient + .addDefaultHeader('deviceType', 'iOS'); + } else { + var androidInfo = await deviceInfoPlugin.androidInfo; + _apiService.authenticationApi.apiClient + .addDefaultHeader('deviceModel', androidInfo.model); + _apiService.authenticationApi.apiClient + .addDefaultHeader('deviceType', 'Android'); + } + try { var loginResponse = await _apiService.authenticationApi.login( LoginCredentialDto( diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ecc9db781..a648b5ed1 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -23,6 +23,7 @@ doc/AssetCountByUserIdResponseDto.md doc/AssetFileUploadResponseDto.md doc/AssetResponseDto.md doc/AssetTypeEnum.md +doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md doc/ChangePasswordDto.md doc/CheckDuplicateAssetDto.md @@ -145,6 +146,7 @@ lib/model/asset_count_by_user_id_response_dto.dart lib/model/asset_file_upload_response_dto.dart lib/model/asset_response_dto.dart lib/model/asset_type_enum.dart +lib/model/auth_device_response_dto.dart lib/model/change_password_dto.dart lib/model/check_duplicate_asset_dto.dart lib/model/check_duplicate_asset_response_dto.dart @@ -238,6 +240,7 @@ test/asset_count_by_user_id_response_dto_test.dart test/asset_file_upload_response_dto_test.dart test/asset_response_dto_test.dart test/asset_type_enum_test.dart +test/auth_device_response_dto_test.dart test/authentication_api_test.dart test/change_password_dto_test.dart test/check_duplicate_asset_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 68ea2ee3500316f31988f408b6f10759365d7fd8..bcd53f93426fa9bb95fa89a50350578f9ff3aa59 100644 GIT binary patch delta 197 zcmexdKC^0rf+SyhYKdcMNrp>mS!Qx-@nlB{Idz~=B2Xv=BBZHMqoAeb?i!+{rJxTI z*M~|>PLNcXtSq@pASXXPzZA(7kRgT;L4-LFV_aN)Tti&ZP13KB6UeYo6Ydp)tu@;X=e0YOei6#xJL delta 21 dcmbPP^|^e5g5=~0l6yCwlyVf>?4o;>9{_MZ2~hw5 diff --git a/mobile/openapi/doc/AuthDeviceResponseDto.md b/mobile/openapi/doc/AuthDeviceResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..261e767d920fc765205b11a6b22cec08a78e2b34 GIT binary patch literal 574 zcma)(!Ab)$5Qgu0iohOf1KHm7w6Z-2wwBggDQrx}Hn^J!$*dsw@Fv@et5|C;;h*{D zpJb-s=)oy(%MJ~+W!2N6hF7a$4bh{EP!lp%6#O2Q;dB5#fZQwnqV~X=rZFlIvz^C4 zd~%9kpKNE@Is=oNj1Eq#kdlEcM7C5N@OS?B=JQoU)WLeP=#p9@B|?r)3XYVNmoyW? z8X*!U#D;=(`#~g~GCF`FFcqBxdT;&)bRPn^&-y3Cc4TjcA?A;r^FI;~o8R+nyS+^# z=7fBaH^W;SX}7y7%h`Hf)r)!AnZls%yYvK=M<$;!$YD8g%D<|s)!jd9)2bvmsgv2sbsmHcQL2JZwY|A4RW|v|7yZ@G46Qpq_^LycFa@4`RA^&eh=tLg%;}_Dh z51E6Q|2lg;El*DU9dQ!M6#MTN-d8WR)ehn(T*pUc7I=FFc17eBy$u8yAN*} wxe?+eX))ckgJ3c_cUTYH;a0)4agPtA6o<^s%)ZS4lVAvfxs-hN-PGsQ7rgStjQ{`u delta 26 icmbOo^uc1oWX8!a7~3~rV7kt=xlOo{bF-}SKUM&#J`0Zk diff --git a/mobile/openapi/doc/LogoutResponseDto.md b/mobile/openapi/doc/LogoutResponseDto.md index f1b22a889d5d1058a0f4d94b043ebff18d2c2caf..9d17baf2ce2163a9db6faeb3ffc3c77f9489de77 100644 GIT binary patch delta 14 Wcmcb?e296%&&e!|%9D*4mjM7Qk_Ax! delta 35 icmX@ae1mzyPp;^q)WnqhyqwC|i9b}3c$1|WmjeI}8V+p$ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index c30576da81d5c35edf7107827ad80f27fe7aa5ae..72f05c660c8fb4ca024120ff15c77fa4732d5c74 100644 GIT binary patch delta 27 jcmZ3iab9DCB|BGQX-P(WN@`hV^5h4+N}Da&U-19{lEMmP delta 12 TcmX@Fu~=h+CHv-7_E$UrA%6tO diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index 39888550dcbcdc75fcff07e691f958b5f3fbaad1..f8ba2cdb167a546c32bb127666b12f5eafe98c8b 100644 GIT binary patch delta 563 zcmaE4e>Hf+B0fQP*ANB$#L|)s{gl+Q%;ePK$qJ&@eCeqrjzBRNs8FJ4Bs)}k^8ryw zMkaUH$qFo!C+CZEXlE9e*dWvfr4|?D=M|^Al;qpl+bN)#GeKMp&FIa0#9uRtq-EwM z<|q_F^*ZIJR4Uj`ZsbuEu;x+#gQC=u(xN;Cjme4ZiuK6qbQIKmaJxWVQz5Zf0i-|^ zYLJ40UUDK%7i%CXE6E4x)6lf$0&A@WlOT6YHjpyhyq0ezx1@`!k86l4YVhe-XQtEw z!%%B7ACm%SNl|8AdaOcb%49`;WtIF=q)-jdEXnXFDJk&GOUu^?1}g-r)0vzn>Z1(} zYp_}BP?zf!r55BQCZ{^)qToBv< delta 14 Vcmey_!Z^Eyal?E0&AtjcN&qsJ1c diff --git a/mobile/openapi/lib/model/auth_device_response_dto.dart b/mobile/openapi/lib/model/auth_device_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..534f6c2933980884658ddf3ec99a7373251d9241 GIT binary patch literal 4680 zcmds4S#R4$5PsLM7zai~qYB;RsceQGv0wAamNE+y6) zk2hwpT9WBT!gQKsH=lTcHPkCEk6?O=o>5N}tV$@C6VLhb#r^{ESY@9+HTkh(SOc_< z#$cNqA|-Bo*cJ+rlTduv7IL{Tc5Hoij*6Kfncma39m~k(QD`7-qSkQBHLRFg{nnHm z6J>%8<4jXr^MWhIle2kaPBWJHM zTwm(O{zyzOj0b0KA7WO^8#pE!Cp_tb2D zJvQ!sWbLokI~3X=-ihKAbn{L%HM(noU@2-=!6zia6!xU=A70uXH4+Os7UlRF1s!Va zFhfjHCXjYSL!ePKkak2}{87*mcEn;vqGY5E3TN|OQR-w1j|8?RXd*li5dAm^1FK80 zTS->!5E9^eHj!5rm;vo({0pG)5#Xn9_Ug zlUN9+jH36mT6L0R?=X!Y9M-G_3_&woT80po(%*ceXoES$i$-&Jeqv-?3JEX98j2M! z{^lj%-GF=-rO+F=;hP2%f5T~S)J^@2a=B~n1)0OR3IZqmR=8%`7EOs7mt)15LI;s| z;M~9k&81Gl+>mSD-}mG$x-etKDoN1!m?;u-sx)J%j~7*p-~`5(?m_~n2v3FqSNB9)frC>>?dwlIa-x*E?)MN^meWz4T)TA@OYHx+~AxUU~T z=bQRLa!cP~(VjM48=})5!ulsiY}!+{m>t!l=+@bmoXx*eAJHzJ?@ISzQtygG|r)xD7;EJ&RF=8oo>RQZ4YU*NyWI(d*RUJ`42kq&XRm@-u2@i{ z%8f5+N>73-AJPw()>unTAM^_KjS^$Z7FN-~>(m0I2R&a||GiabR1NcmvEw75tVva4 z-zx5eULlDFi{26k>+#IWqN=-4;FS(_=$)BNVtWmA33b*ZLv~79#2@UJ34Gruqm6zi zeeJyeTR%~?LhgD!H3ke5r9PnDH(0`gQ zX4ideCUe&e%I`@x0LSUQ!8Cd~u$y@tm#rhj?dc`#Ta8!ftvDy(x0{i`*AT?Ne*vI- B+Ij#0 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/auth_device_response_dto_test.dart b/mobile/openapi/test/auth_device_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..615b34b1d786e4a9826adde2433f2404f96b0c51 GIT binary patch literal 1084 zcmbV~Pfz185XJBL6vL@SDg-#KKuB9DWrYMpqS?cqj5nU9)~2=@Pe9d*@6N>KpG2e< z4skrvd+#@sOrB?X4%6RNdGKp?GrOHuvjQ$IuVxb{O1LR2xGsy!i$8lJ>y=s2Vt#s7**?CBQjoUr+yr%dZ{U9*``Mr1zrEs4W9ad zP6wOIbJ$NkI{PGHjGXt!tDuMWbp-h&Fzt9*;DfF47Gsb`v0$G)cxzy#tsC#d{|rDZ z3+r?XHce6^oDVz4`)8Tx8}eNkk9y3oZZv_H=oCI@vaSJZ#0#lAG5d4@ULpjNNr~5R zXU*NltK-_$skz=F4OV4QCi~N-hmXNEp=LBw-kSfW{0^oIW=G7M@=KV%-nUS9M6yQP lC)wz}1bF!mCM`s@4!D)_F8w~JobQM!=et29E-k@R_6%f>Pk{gc literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/authentication_api_test.dart b/mobile/openapi/test/authentication_api_test.dart index f855d323904c9d2748bff38fe98d39229f8815c3..9c5ae81b676a9a3c5ea4a9bfd04ed0669aaea279 100644 GIT binary patch delta 134 zcmeC+{KvI{i&-nPxWvY>v?RkNwJb9^H7K>XAV04-)uklg&Q2jcwFDtmJlTLnf)7o| smdR)`C-Y}%g`E8K{8A*<8o?z+nR)37nJJSES;Pg { - const { response, cookie } = await this.service.login(loginCredential, clientIp, req.secure); + const { response, cookie } = await this.service.login(loginCredential, loginDetails); res.header('Set-Cookie', cookie); return response; } @@ -44,6 +46,18 @@ export class AuthController { return this.service.adminSignUp(signUpCredential); } + @Authenticated() + @Get('devices') + getAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.service.getDevices(authUser); + } + + @Authenticated() + @Delete('devices/:id') + logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.logoutDevice(authUser, id); + } + @Authenticated() @Post('validateToken') validateAccessToken(): ValidateAccessTokenResponseDto { diff --git a/server/apps/immich/src/controllers/oauth.controller.ts b/server/apps/immich/src/controllers/oauth.controller.ts index 042323437..0a65e3641 100644 --- a/server/apps/immich/src/controllers/oauth.controller.ts +++ b/server/apps/immich/src/controllers/oauth.controller.ts @@ -1,5 +1,6 @@ import { AuthUserDto, + LoginDetails, LoginResponseDto, OAuthCallbackDto, OAuthConfigDto, @@ -10,7 +11,7 @@ import { import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { GetAuthUser } from '../decorators/auth-user.decorator'; +import { GetAuthUser, GetLoginDetails } from '../decorators/auth-user.decorator'; import { Authenticated } from '../decorators/authenticated.decorator'; import { UseValidation } from '../decorators/use-validation.decorator'; @@ -38,9 +39,9 @@ export class OAuthController { async callback( @Res({ passthrough: true }) res: Response, @Body() dto: OAuthCallbackDto, - @Req() req: Request, + @GetLoginDetails() loginDetails: LoginDetails, ): Promise { - const { response, cookie } = await this.service.login(dto, req.secure); + const { response, cookie } = await this.service.login(dto, loginDetails); res.header('Set-Cookie', cookie); return response; } diff --git a/server/apps/immich/src/decorators/auth-user.decorator.ts b/server/apps/immich/src/decorators/auth-user.decorator.ts index 0ccde28db..fd4aefa4c 100644 --- a/server/apps/immich/src/decorators/auth-user.decorator.ts +++ b/server/apps/immich/src/decorators/auth-user.decorator.ts @@ -1,7 +1,20 @@ export { AuthUserDto } from '@app/domain'; -import { AuthUserDto } from '@app/domain'; +import { AuthUserDto, LoginDetails } from '@app/domain'; import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { UAParser } from 'ua-parser-js'; export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => { return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user; }); + +export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => { + const req = ctx.switchToHttp().getRequest(); + const userAgent = UAParser(req.headers['user-agent']); + + return { + clientIp: req.clientIp, + isSecure: req.secure, + deviceType: userAgent.browser.name || userAgent.device.type || req.headers.devicemodel || '', + deviceOS: userAgent.os.name || req.headers.devicetype || '', + }; +}); diff --git a/server/apps/immich/src/utils/patch-open-api.util.ts b/server/apps/immich/src/utils/patch-open-api.util.ts index 4ff6c0257..1b8c7882b 100644 --- a/server/apps/immich/src/utils/patch-open-api.util.ts +++ b/server/apps/immich/src/utils/patch-open-api.util.ts @@ -21,6 +21,10 @@ export function patchOpenAPI(document: OpenAPIObject) { if (operation.summary === '') { delete operation.summary; } + + if (operation.description === '') { + delete operation.description; + } } } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 536014021..57b2bb940 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -339,6 +339,70 @@ ] } }, + "/auth/devices": { + "get": { + "operationId": "getAuthDevices", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthDeviceResponseDto" + } + } + } + } + } + }, + "tags": [ + "Authentication" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, + "/auth/devices/{id}": { + "delete": { + "operationId": "logoutAuthDevice", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Authentication" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, "/auth/validateToken": { "post": { "operationId": "validateAccessToken", @@ -3986,6 +4050,37 @@ "createdAt" ] }, + "AuthDeviceResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "deviceType": { + "type": "string" + }, + "deviceOS": { + "type": "string" + } + }, + "required": [ + "id", + "createdAt", + "updatedAt", + "current", + "deviceType", + "deviceOS" + ] + }, "ValidateAccessTokenResponseDto": { "type": "object", "properties": { @@ -4018,12 +4113,10 @@ "type": "object", "properties": { "successful": { - "type": "boolean", - "readOnly": true + "type": "boolean" }, "redirectUri": { - "type": "string", - "readOnly": true + "type": "string" } }, "required": [ diff --git a/server/libs/domain/src/auth/auth.core.ts b/server/libs/domain/src/auth/auth.core.ts index 7731d46de..de75a07eb 100644 --- a/server/libs/domain/src/auth/auth.core.ts +++ b/server/libs/domain/src/auth/auth.core.ts @@ -1,10 +1,17 @@ import { SystemConfig, UserEntity } from '@app/infra/entities'; +import { ICryptoRepository } from '../crypto/crypto.repository'; import { ISystemConfigRepository } from '../system-config'; import { SystemConfigCore } from '../system-config/system-config.core'; -import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant'; -import { ICryptoRepository } from '../crypto/crypto.repository'; -import { LoginResponseDto, mapLoginResponse } from './response-dto'; import { IUserTokenRepository, UserTokenCore } from '../user-token'; +import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant'; +import { LoginResponseDto, mapLoginResponse } from './response-dto'; + +export interface LoginDetails { + isSecure: boolean; + clientIp: string; + deviceType: string; + deviceOS: string; +} export class AuthCore { private userTokenCore: UserTokenCore; @@ -23,7 +30,7 @@ export class AuthCore { return this.config.passwordLogin.enabled; } - public getCookies(loginResponse: LoginResponseDto, authType: AuthType, isSecure: boolean) { + getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) { const maxAge = 400 * 24 * 3600; // 400 days let authTypeCookie = ''; @@ -39,10 +46,10 @@ export class AuthCore { return [accessTokenCookie, authTypeCookie]; } - public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) { - const accessToken = await this.userTokenCore.createToken(user); + async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { + const accessToken = await this.userTokenCore.create(user, loginDetails); const response = mapLoginResponse(user, accessToken); - const cookie = this.getCookies(response, authType, isSecure); + const cookie = this.getCookies(response, authType, loginDetails); return { response, cookie }; } diff --git a/server/libs/domain/src/auth/auth.service.spec.ts b/server/libs/domain/src/auth/auth.service.spec.ts index e81965010..19b12acca 100644 --- a/server/libs/domain/src/auth/auth.service.spec.ts +++ b/server/libs/domain/src/auth/auth.service.spec.ts @@ -32,6 +32,12 @@ import { AuthUserDto, SignUpDto } from './dto'; const email = 'test@immich.com'; const sub = 'my-auth-user-sub'; +const loginDetails = { + isSecure: true, + clientIp: '127.0.0.1', + deviceOS: '', + deviceType: '', +}; const fixtures = { login: { @@ -40,8 +46,6 @@ const fixtures = { }, }; -const CLIENT_IP = '127.0.0.1'; - describe('AuthService', () => { let sut: AuthService; let cryptoMock: jest.Mocked; @@ -96,32 +100,39 @@ describe('AuthService', () => { it('should throw an error if password login is disabled', async () => { sut = create(systemConfigStub.disabled); - await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(UnauthorizedException); + await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); }); it('should check the user exists', async () => { userMock.getByEmail.mockResolvedValue(null); - await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); it('should check the user has a password', async () => { userMock.getByEmail.mockResolvedValue({} as UserEntity); - await expect(sut.login(fixtures.login, CLIENT_IP, true)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(BadRequestException); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); it('should successfully log the user in', async () => { userMock.getByEmail.mockResolvedValue(userEntityStub.user1); userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password); + 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(userEntityStub.user1); userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure); + await expect( + sut.login(fixtures.login, { + clientIp: '127.0.0.1', + isSecure: false, + deviceOS: '', + deviceType: '', + }), + ).resolves.toEqual(loginResponseStub.user1insecure); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); }); @@ -205,7 +216,7 @@ describe('AuthService', () => { redirectUri: '/auth/login?autoLaunch=0', }); - expect(userTokenMock.delete).toHaveBeenCalledWith('token123'); + expect(userTokenMock.delete).toHaveBeenCalledWith('123', 'token123'); }); }); @@ -240,7 +251,7 @@ describe('AuthService', () => { it('should validate using authorization header', async () => { userMock.get.mockResolvedValue(userEntityStub.user1); - userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken); + userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken); const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual(userEntityStub.user1); }); @@ -276,16 +287,32 @@ describe('AuthService', () => { describe('validate - user token', () => { it('should throw if no token is found', async () => { - userTokenMock.get.mockResolvedValue(null); + userTokenMock.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.get.mockResolvedValue(userTokenEntityStub.userToken); + userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.userToken); const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1); }); + + it('should update when access time exceeds an hour', async () => { + userTokenMock.getByToken.mockResolvedValue(userTokenEntityStub.inactiveToken); + userTokenMock.save.mockResolvedValue(userTokenEntityStub.userToken); + const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; + await expect(sut.validate(headers, {})).resolves.toEqual(userEntityStub.user1); + expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({ + id: 'not_active', + token: 'auth_token', + userId: 'immich_id', + createdAt: new Date('2021-01-01'), + updatedAt: expect.any(Date), + deviceOS: 'Android', + deviceType: 'Mobile', + }); + }); }); describe('validate - api key', () => { @@ -303,4 +330,38 @@ describe('AuthService', () => { expect(keyMock.getKey).toHaveBeenCalledWith('auth_token (hashed)'); }); }); + + describe('getDevices', () => { + it('should get the devices', async () => { + userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.userToken, userTokenEntityStub.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.id); + }); + }); + + describe('logoutDevice', () => { + it('should logout the device', async () => { + await sut.logoutDevice(authStub.user1, 'token-1'); + + expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1'); + }); + }); }); diff --git a/server/libs/domain/src/auth/auth.service.ts b/server/libs/domain/src/auth/auth.service.ts index 83602130c..8229580a6 100644 --- a/server/libs/domain/src/auth/auth.service.ts +++ b/server/libs/domain/src/auth/auth.service.ts @@ -12,7 +12,7 @@ import { OAuthCore } from '../oauth/oauth.core'; import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; import { IUserRepository, UserCore } from '../user'; import { AuthType, IMMICH_ACCESS_COOKIE } from './auth.constant'; -import { AuthCore } from './auth.core'; +import { AuthCore, LoginDetails } from './auth.core'; import { ICryptoRepository } from '../crypto/crypto.repository'; import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto'; import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto'; @@ -21,6 +21,7 @@ import cookieParser from 'cookie'; import { ISharedLinkRepository, ShareCore } from '../share'; import { APIKeyCore } from '../api-key/api-key.core'; import { IKeyRepository } from '../api-key'; +import { AuthDeviceResponseDto, mapUserToken } from './response-dto'; @Injectable() export class AuthService { @@ -53,8 +54,7 @@ export class AuthService { public async login( loginCredential: LoginCredentialDto, - clientIp: string, - isSecure: boolean, + loginDetails: LoginDetails, ): Promise<{ response: LoginResponseDto; cookie: string[] }> { if (!this.authCore.isPasswordLoginEnabled()) { throw new UnauthorizedException('Password login has been disabled'); @@ -69,16 +69,18 @@ export class AuthService { } if (!user) { - this.logger.warn(`Failed login attempt for user ${loginCredential.email} from ip address ${clientIp}`); + this.logger.warn( + `Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`, + ); throw new BadRequestException('Incorrect email or password'); } - return this.authCore.createLoginResponse(user, AuthType.PASSWORD, isSecure); + return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails); } public async logout(authUser: AuthUserDto, authType: AuthType): Promise { if (authUser.accessTokenId) { - await this.userTokenCore.deleteToken(authUser.accessTokenId); + await this.userTokenCore.delete(authUser.id, authUser.accessTokenId); } if (authType === AuthType.OAUTH) { @@ -152,6 +154,15 @@ export class AuthService { throw new UnauthorizedException('Authentication required'); } + async getDevices(authUser: AuthUserDto): Promise { + const userTokens = await this.userTokenCore.getAll(authUser.id); + return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId)); + } + + async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise { + await this.userTokenCore.delete(authUser.id, deviceId); + } + private getBearerToken(headers: IncomingHttpHeaders): string | null { const [type, token] = (headers.authorization || '').split(' '); if (type.toLowerCase() === 'bearer') { diff --git a/server/libs/domain/src/auth/index.ts b/server/libs/domain/src/auth/index.ts index d3aa704ba..24cc5a995 100644 --- a/server/libs/domain/src/auth/index.ts +++ b/server/libs/domain/src/auth/index.ts @@ -1,4 +1,5 @@ export * from './auth.constant'; +export * from './auth.core'; export * from './auth.service'; export * from './dto'; export * from './response-dto'; diff --git a/server/libs/domain/src/auth/response-dto/auth-device-response.dto.ts b/server/libs/domain/src/auth/response-dto/auth-device-response.dto.ts new file mode 100644 index 000000000..986f743c0 --- /dev/null +++ b/server/libs/domain/src/auth/response-dto/auth-device-response.dto.ts @@ -0,0 +1,19 @@ +import { UserTokenEntity } from '@app/infra/entities'; + +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, +}); diff --git a/server/libs/domain/src/auth/response-dto/index.ts b/server/libs/domain/src/auth/response-dto/index.ts index 1ef1ef8b0..4bbfc7139 100644 --- a/server/libs/domain/src/auth/response-dto/index.ts +++ b/server/libs/domain/src/auth/response-dto/index.ts @@ -1,4 +1,5 @@ export * from './admin-signup-response.dto'; +export * from './auth-device-response.dto'; export * from './login-response.dto'; export * from './logout-response.dto'; export * from './validate-asset-token-response.dto'; diff --git a/server/libs/domain/src/auth/response-dto/logout-response.dto.ts b/server/libs/domain/src/auth/response-dto/logout-response.dto.ts index 9ada897ef..16816264e 100644 --- a/server/libs/domain/src/auth/response-dto/logout-response.dto.ts +++ b/server/libs/domain/src/auth/response-dto/logout-response.dto.ts @@ -1,13 +1,4 @@ -import { ApiResponseProperty } from '@nestjs/swagger'; - export class LogoutResponseDto { - constructor(successful: boolean) { - this.successful = successful; - } - - @ApiResponseProperty() successful!: boolean; - - @ApiResponseProperty() redirectUri!: string; } diff --git a/server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts b/server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts index 9d4d770de..4fdb2971d 100644 --- a/server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts +++ b/server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts @@ -1,10 +1,3 @@ -import { ApiProperty } from '@nestjs/swagger'; - export class ValidateAccessTokenResponseDto { - constructor(authStatus: boolean) { - this.authStatus = authStatus; - } - - @ApiProperty({ type: 'boolean' }) authStatus!: boolean; } diff --git a/server/libs/domain/src/oauth/oauth.service.spec.ts b/server/libs/domain/src/oauth/oauth.service.spec.ts index 504d91307..18c45416b 100644 --- a/server/libs/domain/src/oauth/oauth.service.spec.ts +++ b/server/libs/domain/src/oauth/oauth.service.spec.ts @@ -17,9 +17,16 @@ import { ISystemConfigRepository } from '../system-config'; import { IUserRepository } from '../user'; import { IUserTokenRepository } from '../user-token'; import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock'; +import { LoginDetails } from '../auth'; const email = 'user@immich.com'; const sub = 'my-auth-user-sub'; +const loginDetails: LoginDetails = { + isSecure: true, + clientIp: '127.0.0.1', + deviceOS: '', + deviceType: '', +}; describe('OAuthService', () => { let sut: OAuthService; @@ -95,13 +102,13 @@ describe('OAuthService', () => { describe('login', () => { it('should throw an error if OAuth is not enabled', async () => { - await expect(sut.login({ url: '' }, true)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException); }); it('should not allow auto registering', async () => { sut = create(systemConfigStub.noAutoRegister); userMock.getByEmail.mockResolvedValue(null); - await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).rejects.toBeInstanceOf( + await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( BadRequestException, ); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); @@ -113,7 +120,7 @@ describe('OAuthService', () => { userMock.update.mockResolvedValue(userEntityStub.user1); userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual( + await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, ); @@ -129,7 +136,7 @@ describe('OAuthService', () => { userMock.create.mockResolvedValue(userEntityStub.user1); userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual( + await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( loginResponseStub.user1oauth, ); @@ -143,7 +150,7 @@ describe('OAuthService', () => { userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1); userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); - await sut.login({ url: `app.immich:/?code=abc123` }, true); + await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails); expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' }); }); diff --git a/server/libs/domain/src/oauth/oauth.service.ts b/server/libs/domain/src/oauth/oauth.service.ts index 4001b7570..ff95ebac3 100644 --- a/server/libs/domain/src/oauth/oauth.service.ts +++ b/server/libs/domain/src/oauth/oauth.service.ts @@ -1,7 +1,7 @@ import { SystemConfig } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; import { AuthType, AuthUserDto, LoginResponseDto } from '../auth'; -import { AuthCore } from '../auth/auth.core'; +import { AuthCore, LoginDetails } from '../auth/auth.core'; import { ICryptoRepository } from '../crypto'; import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; import { IUserRepository, UserCore, UserResponseDto } from '../user'; @@ -39,7 +39,10 @@ export class OAuthService { return this.oauthCore.generateConfig(dto); } - async login(dto: OAuthCallbackDto, isSecure: boolean): Promise<{ response: LoginResponseDto; cookie: string[] }> { + async login( + dto: OAuthCallbackDto, + loginDetails: LoginDetails, + ): Promise<{ response: LoginResponseDto; cookie: string[] }> { const profile = await this.oauthCore.callback(dto.url); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); @@ -66,7 +69,7 @@ export class OAuthService { user = await this.userCore.createUser(this.oauthCore.asUser(profile)); } - return this.authCore.createLoginResponse(user, AuthType.OAUTH, isSecure); + return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails); } public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise { diff --git a/server/libs/domain/src/user-token/user-token.core.ts b/server/libs/domain/src/user-token/user-token.core.ts index d430034f4..061738cd5 100644 --- a/server/libs/domain/src/user-token/user-token.core.ts +++ b/server/libs/domain/src/user-token/user-token.core.ts @@ -1,5 +1,7 @@ -import { UserEntity } from '@app/infra/entities'; +import { UserEntity, UserTokenEntity } from '@app/infra/entities'; import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { DateTime } from 'luxon'; +import { LoginDetails } from '../auth'; import { ICryptoRepository } from '../crypto'; import { IUserTokenRepository } from './user-token.repository'; @@ -9,9 +11,16 @@ export class UserTokenCore { async validate(tokenValue: string) { const hashedToken = this.crypto.hashSha256(tokenValue); - const token = await this.repository.get(hashedToken); + let token = await this.repository.getByToken(hashedToken); if (token?.user) { + const now = DateTime.now(); + const updatedAt = DateTime.fromJSDate(token.updatedAt); + const diff = now.diff(updatedAt, ['hours']); + if (diff.hours > 1) { + token = await this.repository.save({ ...token, updatedAt: new Date() }); + } + return { ...token.user, isPublicUser: false, @@ -25,18 +34,24 @@ export class UserTokenCore { throw new UnauthorizedException('Invalid user token'); } - public async createToken(user: UserEntity): Promise { + async create(user: UserEntity, loginDetails: LoginDetails): Promise { const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, ''); const token = this.crypto.hashSha256(key); await this.repository.create({ token, user, + deviceOS: loginDetails.deviceOS, + deviceType: loginDetails.deviceType, }); return key; } - public async deleteToken(id: string): Promise { - await this.repository.delete(id); + async delete(userId: string, id: string): Promise { + await this.repository.delete(userId, id); + } + + getAll(userId: string): Promise { + return this.repository.getAll(userId); } } diff --git a/server/libs/domain/src/user-token/user-token.repository.ts b/server/libs/domain/src/user-token/user-token.repository.ts index 30285aa5c..0b2f86400 100644 --- a/server/libs/domain/src/user-token/user-token.repository.ts +++ b/server/libs/domain/src/user-token/user-token.repository.ts @@ -4,7 +4,9 @@ export const IUserTokenRepository = 'IUserTokenRepository'; export interface IUserTokenRepository { create(dto: Partial): Promise; - delete(userToken: string): Promise; + save(dto: Partial): Promise; + delete(userId: string, id: string): Promise; deleteAll(userId: string): Promise; - get(userToken: string): Promise; + getByToken(token: string): Promise; + getAll(userId: string): Promise; } diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index cc47da52e..d728d84f8 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -391,9 +391,22 @@ export const userTokenEntityStub = { userToken: Object.freeze({ id: 'token-id', token: 'auth_token', + userId: userEntityStub.user1.id, user: userEntityStub.user1, - createdAt: '2021-01-01', - updatedAt: '2021-01-01', + createdAt: new Date('2021-01-01'), + updatedAt: new Date(), + deviceType: '', + deviceOS: '', + }), + inactiveToken: Object.freeze({ + id: 'not_active', + token: 'auth_token', + userId: userEntityStub.user1.id, + user: userEntityStub.user1, + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + deviceType: 'Mobile', + deviceOS: 'Android', }), }; diff --git a/server/libs/domain/test/user-token.repository.mock.ts b/server/libs/domain/test/user-token.repository.mock.ts index 7f8e44965..b3b9bdb68 100644 --- a/server/libs/domain/test/user-token.repository.mock.ts +++ b/server/libs/domain/test/user-token.repository.mock.ts @@ -3,8 +3,10 @@ import { IUserTokenRepository } from '../src'; export const newUserTokenRepositoryMock = (): jest.Mocked => { return { create: jest.fn(), + save: jest.fn(), delete: jest.fn(), deleteAll: jest.fn(), - get: jest.fn(), + getByToken: jest.fn(), + getAll: jest.fn(), }; }; diff --git a/server/libs/infra/src/entities/user-token.entity.ts b/server/libs/infra/src/entities/user-token.entity.ts index 3418f2c82..c5abad55a 100644 --- a/server/libs/infra/src/entities/user-token.entity.ts +++ b/server/libs/infra/src/entities/user-token.entity.ts @@ -9,12 +9,21 @@ export class UserTokenEntity { @Column({ select: false }) token!: string; + @Column() + userId!: string; + @ManyToOne(() => UserEntity) user!: UserEntity; @CreateDateColumn({ type: 'timestamptz' }) - createdAt!: string; + createdAt!: Date; @UpdateDateColumn({ type: 'timestamptz' }) - updatedAt!: string; + updatedAt!: Date; + + @Column({ default: '' }) + deviceType!: string; + + @Column({ default: '' }) + deviceOS!: string; } diff --git a/server/libs/infra/src/migrations/1682371561743-FixNullableRelations.ts b/server/libs/infra/src/migrations/1682371561743-FixNullableRelations.ts new file mode 100644 index 000000000..42c34f939 --- /dev/null +++ b/server/libs/infra/src/migrations/1682371561743-FixNullableRelations.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FixNullableRelations1682371561743 implements MigrationInterface { + name = 'FixNullableRelations1682371561743'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`); + await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" SET NOT NULL`); + await queryRunner.query( + `ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`); + await queryRunner.query(`ALTER TABLE "user_token" ALTER COLUMN "userId" DROP NOT NULL`); + await queryRunner.query( + `ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/server/libs/infra/src/migrations/1682371791038-AddDeviceInfoToUserToken.ts b/server/libs/infra/src/migrations/1682371791038-AddDeviceInfoToUserToken.ts new file mode 100644 index 000000000..bb60e452e --- /dev/null +++ b/server/libs/infra/src/migrations/1682371791038-AddDeviceInfoToUserToken.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddDeviceInfoToUserToken1682371791038 implements MigrationInterface { + name = 'AddDeviceInfoToUserToken1682371791038' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceType" character varying NOT NULL DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "user_token" ADD "deviceOS" character varying NOT NULL DEFAULT ''`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceOS"`); + await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceType"`); + } + +} diff --git a/server/libs/infra/src/repositories/user-token.repository.ts b/server/libs/infra/src/repositories/user-token.repository.ts index 6a1802397..e76c5df13 100644 --- a/server/libs/infra/src/repositories/user-token.repository.ts +++ b/server/libs/infra/src/repositories/user-token.repository.ts @@ -6,24 +6,40 @@ import { IUserTokenRepository } from '@app/domain/user-token'; @Injectable() export class UserTokenRepository implements IUserTokenRepository { - constructor( - @InjectRepository(UserTokenEntity) - private userTokenRepository: Repository, - ) {} + constructor(@InjectRepository(UserTokenEntity) private repository: Repository) {} - async get(userToken: string): Promise { - return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } }); + getByToken(token: string): Promise { + return this.repository.findOne({ where: { token }, relations: { user: true } }); } - async create(userToken: Partial): Promise { - return this.userTokenRepository.save(userToken); + getAll(userId: string): Promise { + return this.repository.find({ + where: { + userId, + }, + relations: { + user: true, + }, + order: { + updatedAt: 'desc', + createdAt: 'desc', + }, + }); } - async delete(id: string): Promise { - await this.userTokenRepository.delete(id); + create(userToken: Partial): Promise { + return this.repository.save(userToken); + } + + save(userToken: Partial): Promise { + return this.repository.save(userToken); + } + + async delete(userId: string, id: string): Promise { + await this.repository.delete({ userId, id }); } async deleteAll(userId: string): Promise { - await this.userTokenRepository.delete({ user: { id: userId } }); + await this.repository.delete({ userId }); } } diff --git a/server/package-lock.json b/server/package-lock.json index ee549a5a2..f0d037cd8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "immich", - "version": "1.53.0", + "version": "1.54.1", "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.20.13", @@ -48,7 +48,8 @@ "sanitize-filename": "^1.6.3", "sharp": "^0.28.0", "typeorm": "^0.3.11", - "typesense": "^1.5.3" + "typesense": "^1.5.3", + "ua-parser-js": "^1.0.35" }, "bin": { "immich": "bin/cli.sh" @@ -73,6 +74,7 @@ "@types/node": "^16.0.0", "@types/sharp": "^0.30.2", "@types/supertest": "^2.0.11", + "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/parser": "^5.48.1", "dotenv": "^14.2.0", @@ -2852,6 +2854,12 @@ "@types/node": "*" } }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.7.14", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz", @@ -11207,6 +11215,24 @@ "@babel/runtime": "^7.17.2" } }, + "node_modules/ua-parser-js": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", @@ -13872,6 +13898,12 @@ "@types/node": "*" } }, + "@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", + "dev": true + }, "@types/validator": { "version": "13.7.14", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.14.tgz", @@ -20132,6 +20164,11 @@ "loglevel": "^1.8.0" } }, + "ua-parser-js": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==" + }, "uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", diff --git a/server/package.json b/server/package.json index e1eb1525a..b6e3645ce 100644 --- a/server/package.json +++ b/server/package.json @@ -79,7 +79,8 @@ "sanitize-filename": "^1.6.3", "sharp": "^0.28.0", "typeorm": "^0.3.11", - "typesense": "^1.5.3" + "typesense": "^1.5.3", + "ua-parser-js": "^1.0.35" }, "devDependencies": { "@nestjs/cli": "^9.1.8", @@ -101,6 +102,7 @@ "@types/node": "^16.0.0", "@types/sharp": "^0.30.2", "@types/supertest": "^2.0.11", + "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/parser": "^5.48.1", "dotenv": "^14.2.0", @@ -139,9 +141,9 @@ "coverageThreshold": { "./libs/domain/": { "branches": 80, - "functions": 85, - "lines": 90, - "statements": 90 + "functions": 88, + "lines": 94, + "statements": 94 } }, "setupFilesAfterEnv": [ @@ -158,4 +160,4 @@ }, "globalSetup": "/libs/domain/test/global-setup.js" } -} \ No newline at end of file +} diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 14abb0895..be8877a27 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -585,6 +585,49 @@ export const AssetTypeEnum = { export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum]; +/** + * + * @export + * @interface AuthDeviceResponseDto + */ +export interface AuthDeviceResponseDto { + /** + * + * @type {string} + * @memberof AuthDeviceResponseDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof AuthDeviceResponseDto + */ + 'createdAt': string; + /** + * + * @type {string} + * @memberof AuthDeviceResponseDto + */ + 'updatedAt': string; + /** + * + * @type {boolean} + * @memberof AuthDeviceResponseDto + */ + 'current': boolean; + /** + * + * @type {string} + * @memberof AuthDeviceResponseDto + */ + 'deviceType': string; + /** + * + * @type {string} + * @memberof AuthDeviceResponseDto + */ + 'deviceOS': string; +} /** * * @export @@ -5951,6 +5994,41 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf options: localVarRequestOptions, }; }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuthDevices: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/auth/devices`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {LoginCredentialDto} loginCredentialDto @@ -6012,6 +6090,45 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logoutAuthDevice: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('logoutAuthDevice', 'id', id) + const localVarPath = `/auth/devices/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -6086,6 +6203,15 @@ export const AuthenticationApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.changePassword(changePasswordDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAuthDevices(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAuthDevices(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {LoginCredentialDto} loginCredentialDto @@ -6105,6 +6231,16 @@ export const AuthenticationApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.logout(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async logoutAuthDevice(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.logoutAuthDevice(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {*} [options] Override http request option. @@ -6142,6 +6278,14 @@ export const AuthenticationApiFactory = function (configuration?: Configuration, changePassword(changePasswordDto: ChangePasswordDto, options?: any): AxiosPromise { return localVarFp.changePassword(changePasswordDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuthDevices(options?: any): AxiosPromise> { + return localVarFp.getAuthDevices(options).then((request) => request(axios, basePath)); + }, /** * * @param {LoginCredentialDto} loginCredentialDto @@ -6159,6 +6303,15 @@ export const AuthenticationApiFactory = function (configuration?: Configuration, logout(options?: any): AxiosPromise { return localVarFp.logout(options).then((request) => request(axios, basePath)); }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logoutAuthDevice(id: string, options?: any): AxiosPromise { + return localVarFp.logoutAuthDevice(id, options).then((request) => request(axios, basePath)); + }, /** * * @param {*} [options] Override http request option. @@ -6199,6 +6352,16 @@ export class AuthenticationApi extends BaseAPI { return AuthenticationApiFp(this.configuration).changePassword(changePasswordDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthenticationApi + */ + public getAuthDevices(options?: AxiosRequestConfig) { + return AuthenticationApiFp(this.configuration).getAuthDevices(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {LoginCredentialDto} loginCredentialDto @@ -6220,6 +6383,17 @@ export class AuthenticationApi extends BaseAPI { return AuthenticationApiFp(this.configuration).logout(options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthenticationApi + */ + public logoutAuthDevice(id: string, options?: AxiosRequestConfig) { + return AuthenticationApiFp(this.configuration).logoutAuthDevice(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {*} [options] Override http request option. diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte new file mode 100644 index 000000000..b9a259cdf --- /dev/null +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -0,0 +1,72 @@ + + +
+ + +
+
+ + {#if device.deviceType || device.deviceOS} + {device.deviceOS || 'Unknown'} • {device.deviceType || 'Unknown'} + {:else} + Unknown + {/if} + +
+ Last seen + {DateTime.fromISO(device.updatedAt).toRelativeCalendar(options)} +
+
+ {#if !device.current} +
+ +
+ {/if} +
+
diff --git a/web/src/lib/components/user-settings-page/device-list.svelte b/web/src/lib/components/user-settings-page/device-list.svelte new file mode 100644 index 000000000..1752da82e --- /dev/null +++ b/web/src/lib/components/user-settings-page/device-list.svelte @@ -0,0 +1,71 @@ + + +{#if deleteDevice} + handleDelete()} + on:cancel={() => (deleteDevice = null)} + /> +{/if} + +
+ {#if currentDevice} +
+

+ CURRENT DEVICE +

+ +
+ {/if} + {#if otherDevices.length > 0} +
+

+ OTHER DEVICES +

+ {#each otherDevices as device, i} + (deleteDevice = device)} /> + {#if i !== otherDevices.length - 1} +
+ {/if} + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 0c71ea7c2..d148aaa24 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -6,6 +6,7 @@ import ChangePasswordSettings from './change-password-settings.svelte'; import OAuthSettings from './oauth-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; + import DeviceList from './device-list.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; export let user: UserResponseDto; @@ -46,3 +47,7 @@ {/if} + + + +