From c1150fe7e3cbf8fa2c2a7e41613dbeffac1cae9c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 15 May 2025 18:08:31 -0400 Subject: [PATCH] feat: lock auth session (#18322) --- i18n/en.json | 1 + mobile/openapi/README.md | Bin 35337 -> 35659 bytes mobile/openapi/lib/api.dart | Bin 12639 -> 12715 bytes .../openapi/lib/api/authentication_api.dart | Bin 14267 -> 15181 bytes mobile/openapi/lib/api/sessions_api.dart | Bin 5527 -> 6586 bytes mobile/openapi/lib/api_client.dart | Bin 32110 -> 32276 bytes .../lib/model/auth_status_response_dto.dart | Bin 3445 -> 4877 bytes mobile/openapi/lib/model/permission.dart | Bin 15989 -> 16145 bytes .../openapi/lib/model/pin_code_reset_dto.dart | Bin 0 -> 3938 bytes .../model/session_create_response_dto.dart | Bin 4414 -> 5109 bytes .../lib/model/session_response_dto.dart | Bin 4089 -> 4784 bytes .../openapi/lib/model/session_unlock_dto.dart | Bin 0 -> 3957 bytes open-api/immich-openapi-specs.json | 105 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 45 ++++++-- server/src/controllers/auth.controller.ts | 17 ++- server/src/controllers/session.controller.ts | 7 ++ server/src/database.ts | 1 + server/src/db.d.ts | 2 +- server/src/dtos/auth.dto.ts | 4 + server/src/dtos/session.dto.ts | 2 + server/src/enum.ts | 1 + server/src/queries/access.repository.sql | 9 ++ server/src/queries/session.repository.sql | 23 +++- server/src/repositories/access.repository.ts | 21 ++++ server/src/repositories/session.repository.ts | 22 ++-- .../migrations/1747338664832-SessionRename.ts | 9 ++ server/src/schema/tables/session.table.ts | 2 +- server/src/services/auth.service.spec.ts | 4 +- server/src/services/auth.service.ts | 40 ++++--- server/src/services/session.service.ts | 7 +- server/src/utils/access.ts | 7 ++ .../repositories/access.repository.mock.ts | 4 + server/test/small.factory.ts | 2 +- .../[[assetId=id]]/+page.svelte | 19 +++- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 8 +- web/src/routes/auth/pin-prompt/+page.svelte | 15 +-- web/src/routes/auth/pin-prompt/+page.ts | 5 +- 37 files changed, 310 insertions(+), 72 deletions(-) create mode 100644 mobile/openapi/lib/model/pin_code_reset_dto.dart create mode 100644 mobile/openapi/lib/model/session_unlock_dto.dart create mode 100644 server/src/schema/migrations/1747338664832-SessionRename.ts diff --git a/i18n/en.json b/i18n/en.json index e4fc825cd..578fe9a11 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1142,6 +1142,7 @@ "location_picker_latitude_hint": "Enter your latitude here", "location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_hint": "Enter your longitude here", + "lock": "Lock", "locked_folder": "Locked Folder", "log_out": "Log out", "log_out_all_devices": "Log Out All Devices", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9544b2ddab131dc86d4e5a32ea73fcc3a85489ff..620fc976643ed91992eac6d6534be3ac0ad7bd0f 100644 GIT binary patch delta 255 zcmeC2!gP8X(*_3#-sEh@(vpne)Z*gI{JhBvB@}=ni9nHJn24k>50tH+lb@WuSy4ih zNw72z#75OA2Ngxr%?HyB(KA_5LUyv3ROV(a>D%&??^p`5fJ~YkV-jj3}A(*_5L%{wHxm?k?&XHHg=kYFoIEy_%*oSY%93S$aMDY5HAgf}mdejq>j bhK} delta 21 dcmZ3Td_QT!e5uVxrSw@R=O}P*R#j*d1ORI02si)$ diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index 446a0616ed8cf1c1c0866334abe900b597658bd1..5482a9fc51b650c4d6c41a3dbed887d52ffd2b0f 100644 GIT binary patch delta 571 zcmdm;f3|Exr4UbXYH@L9ex80#escEYIw7seGLpi)$=QyjB^kjGsmW2ivD`?iHYe!q zWE3gO&rGpX0O>*~o$O`LNQBqRinU|KYfo_Td zsv$rhq1!FMY{!eDX!CxP)r>?qN8SL#DTJ&)sHmxpp$WzPC@z^SptNuE9Nkt{rdlol D|902Y delta 156 zcmX?GwmW}ArO;+ZF)5zObGQvBcZ+dN4pCCrtjD>Tak7faKA@oaWN}rI$uGDaCTFTD zO!nm2Iho&(d$W()Ij+seOj}te2gs;Q_EFH>tm2gsEAq=?i1(@kmb=(3;Ow%LM=%F*lz8 diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 9f850fb4c88dcd2b87d80bb5ff4545b55cae77be..3228d31e91defb2a787ed55c116d56601782c999 100644 GIT binary patch delta 83 zcmbQPy~}vRRME*-`6VYGVC3V{ug*-V)z8UK&Yt{$yO;&ch-VU=oX3;E3Krgcj>n2| ZvIfhx$p`pUA@Z;J%pnX7{y#tjN?tU;;8sU?#SI!bV7<~ir5q(b|<}-CDSLQj3c-^YcRUa`Kb2 VCl~rla>DtO1;f-f&kek&4FG=q9bo_f delta 23 fcmbR8hwS1iB zpDWMjK>jx5!J{J30M4z0hJN&Vy~Qtvf|w!%GNwLGv=G8bh7`qTP`*rr3!lvhW1>JO z1$To)2sRef#E1tI(p?BDqzU)o4lSQmKk{_ z&hQr+@mWaSW$}>=6n7~CbKOPSB?KD$iX-L?biEF1(=z{-!diY5Z%1145XDf^pOwLU zkv5$T!+U)vF`A2#=Ep$?+_GBNOyg}R4`ls3qHPiRC$0f0| zTfo~$zt;x0>=i0!*DOwJ_2W7B&|bYA&|JV;c$c#Puapd4M0EmS`Hgf~|4=fRbXQ`SNhOK-c0G)lnb%)8fG&R2zf|rOC+%a(!e`k% p^wc}iH^X2QXB%|CompbT^u)5$aO<0c6~q5qzXD@j+`cy4`U5j#=*j>9 delta 64 zcmeBG`zp0zGUMdsjKNH`nv>g@Z6;f=mQ23D8Z|kDEpYNpHuuR$=Q<=MTIBd7i4G20df3WfA`OezSXL zABQQZ4}Izb=5BX>?R?+N?CtOO_V?-i?^mO}Ur)|YF5X|A9MkE?&nF2TkLY}KMQ5Yq zkEefbL5wBe<-+>m&x4=d^!QaxwXrgt7@JO1ArGk1m6LfY3ptnCC(XN>m&W>y8lqT; zrAy1or&j(|DGPNi=J+jz#eXlYgK_INt7k?#FSJip4u@)@qz&qBqq9P3>C(dJDa8Ct z==9C&G@A+QdOf)FWF}-vsYR&<_5sjkyB^U#&VM0&Wwj$g zS}thZ5xXW8g3IIp@}12-+aU5Y8DyEBCvIBlwHI^reu(Nz%q6<5w_1 zrA5@%FUieJRpc}iw~}t8)DY5px+)5BQ^+m4R63LJDan;GL$)&EmC?lD)y8up9h*k% zSyAN@XUpY8RE4KoQB;!pl~Z~ejBM9C2ivO%b`zSJg}jwE2_jRer4?T0wRvYMix4to zljfE4G!aEXHzNB+$SdMz+el{}GWlg0vd7u}0IUJCC6VV*Y(oHu`-uYv2hhhhQpVY1 zSYQ~xm)^qS5j9BspYg!&|03g&=po_n40vM!5fHgCrr=rxd5opuus^!_Dl=3l*lTxb zcu2A0Gg0&z(4^V<{yjbsK71}j?rCUKGwGBIV*Q42?1M>9NA#KwR`F~N8pFHvW390cs;v-LTk?-G z8aa+Ch!6dc#GOux#!W!OADX9x%4i%*@!@(W7(Wx`FVR*)`4UZAWrr~T$r%mfvA<1} z76pC95)J8*^xgg2a40pQ0OA^xt(cC+3U?)x^@AhcZ}@dUb5Z`m18+3B!VIcy9`>2a3AKp08>e&$R=*F1Psp1d6oF~Hp*vZbXN^UBr5f)BD>Yk9+`>Nklxpynx9X+zL%dfjX> zXEp-SoINH5Qtf8gwbE{iP0r!pr!w|Ks-$BFa+KCIeh z1K@z)lyuEuIG6E2&jK0j41!n-mS+G(n#NJ_n_P0c5BiahYaAOqAD{}=RySo>xb1}` znRty@C_lpzmkwY0I~eRmDJazqmG6$Y2=gZbjDbdVL7ufnAT#Ju0UfeD?`-5nLN6;& z*l06mv!{QSTVna^ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index 1ef346c96a4a2fe7d519e34aeb556fe4fac2b148..ab1c4ca2d8cdd0efeecb78d3185f20e0723f4edb 100644 GIT binary patch delta 674 zcmZ`%Pfy!0997E(YBx=qgg7zjwHhjwfO0O15aJ6A!42);#=j7QXGgYEKnV3i2}8E{-B>Huo5f@>7_2^28J&@F*RZM1B}dA}gb|VQB(<$_DcML;2ggFi1eX$QMzKgl%$(2) ziI6li=bCJ=hNsG$6BsxpET2e+F_SrAGq*yWRW98Ml5W_oBlNeB>Ps4v*{UdKq{uKw zeCBU;ZhVGVd+T4hbx4?$7_s;QPKV&Zn_-?A?mD9#uMP9#P%A4q88FVLyafq-^8buj z{lV)!>`M#%1?Oq8$If`ieavbc!O}|3$z4E@f-B_zpM9~|yd|dk4=)S{3e(Ylx z!#7%|`19d;wWdYi>(}!B<3?j55^Vl^^al=|sA{5*8y_Y-=T@sz)2o%epNOh)=JzE8 p55!xL^p|9+^VKa@hL|&cyL?*yw$!Sfi%F~VsowSa<=@uP$}55w;@$uN delta 47 zcmV+~0MP&SC%z)E4g!-R0->`u1UCV*kp%+*lgI|rv#4&nd+ diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 92e2dc60676afdb86bbf9207d21da557c5eaccd7..cf9eb08a780d66dc6b549f71927a5a210b205eee 100644 GIT binary patch delta 671 zcmZ`%O->sz7**0jY3Q;_m%IcJL;})v&_GcyPy{=~!Wnb6%v z$|1V#8F~j6NGz}?!vrLlg*Ec~@%x^Aw{o+7bKm&jQek)KG82Yu@u$%)TFt9Tzu#{G ze#JLQ%o3D#G{CXvA~QN8Tyo$fQP6HH`@SP3 zTale5*u$Ro5Jw{K7w`M-jygQ-9Uju#FIS_3U(YViKEJ&>JEimYAI}mx9nr<;iaw1_ z-=F`v12L9-lMCyIKM#I-)#Fz+)yB$nVr)85g*>84S5D@sEaY5jpEU1kUK;DSYKUSX zmM$$TpIZ4>r7YC7nB%t;7XQ7p4#ut9uAUj~ywE;XIUK5qk~XNjt-FHylbMhyr52?c;O}*>mleV}x|Gf-qrd3FWVff@&|Qz{5DEA!ePy*H zL0T?o+?Bf~6~fEp01DmRK3gI3Gvx*)+$@aElh%^b?@TUv<$fQb1ds8TelMhOlC<$i z`V|aNX%V>fOL8+)6*2f&{RpIHIC@M+)$|*e!Mt19+quo^m`w7j=LVlAr2_jRer4?T0wRvYM zix4uLjpmi}G!aEXHzKEnFA1KdE`lF0KYwj}_>{m21>1L$KL zDdX%hE-;MWOK)NEgc>CN&v;^pNm32E4I=2#DMmQ*b$gJjT*+*dN_|l^Kc@ z?6vzeJf>LmnJD@UXwvL_{~n(RAa{$7Uc7*5iUO;$gY^~g9f=rR5O^d`r9Dlh2X)jp2RzvDR1z)mDhB zE%}X)3$U0Kq#Xjw5V$B=| zxpR&~>&8HuKwyt2))>%TCy{ZXq!!B6{jeVF;GmTgjLI-&ZIz)8UCI@jV(5C6rI^n% zlQWOw!bZ8Bgct%FYIelf8ceHlYDF$FQ~M#ngm$}Z+{N(Yie-Mp`Gc}X?2QS+>}v5V zhzv5}{07tdag4IDI^|to{osiO8jc;&T$F$C*c(l*Fo|lT$9<-ALM<}x$Jrf}BiK*t z)}&ae)XZuH<-FkCihJi zV-Br!Jm$GdyT-@YYZ%xhH&+e=?J_WTpxlM=XYoBC5%$u!PG^dYgfmFLGZkTCz$0qIY`;S=IzqV}*dGu&(mvrk&yziaR~u-Q#R} zfFM@wvcYh`Z%Vr6G@Q+NqNlM8h6X{dh0GI}B2nY0cq^CO_=A4rmkTpBnAx8Ds?R)C95)B7i_X(*OqFB zs}?B^@f9Uk_%~mo1gbgQFa%)cFO?g~6Vbsfmw})Um!k{*f~e6CV-n3Le#ZXBx~u!n M@g3xF(?S3E8=WNvs{jB1 literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d4a1e219c..89bdfef45 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2377,7 +2377,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PinCodeChangeDto" + "$ref": "#/components/schemas/PinCodeResetDto" } } }, @@ -2470,15 +2470,40 @@ ] } }, - "/auth/pin-code/verify": { + "/auth/session/lock": { "post": { - "operationId": "verifyPinCode", + "operationId": "lockAuthSession", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Authentication" + ] + } + }, + "/auth/session/unlock": { + "post": { + "operationId": "unlockAuthSession", "parameters": [], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PinCodeSetupDto" + "$ref": "#/components/schemas/SessionUnlockDto" } } }, @@ -5695,6 +5720,41 @@ ] } }, + "/sessions/{id}/lock": { + "post": { + "operationId": "lockSession", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Sessions" + ] + } + }, "/shared-links": { "get": { "operationId": "getAllSharedLinks", @@ -9327,6 +9387,9 @@ }, "AuthStatusResponseDto": { "properties": { + "expiresAt": { + "type": "string" + }, "isElevated": { "type": "boolean" }, @@ -9335,6 +9398,9 @@ }, "pinCode": { "type": "boolean" + }, + "pinExpiresAt": { + "type": "string" } }, "required": [ @@ -11096,6 +11162,7 @@ "session.read", "session.update", "session.delete", + "session.lock", "sharedLink.create", "sharedLink.read", "sharedLink.update", @@ -11297,6 +11364,18 @@ ], "type": "object" }, + "PinCodeResetDto": { + "properties": { + "password": { + "type": "string" + }, + "pinCode": { + "example": "123456", + "type": "string" + } + }, + "type": "object" + }, "PinCodeSetupDto": { "properties": { "pinCode": { @@ -12109,6 +12188,9 @@ "deviceType": { "type": "string" }, + "expiresAt": { + "type": "string" + }, "id": { "type": "string" }, @@ -12144,6 +12226,9 @@ "deviceType": { "type": "string" }, + "expiresAt": { + "type": "string" + }, "id": { "type": "string" }, @@ -12161,6 +12246,18 @@ ], "type": "object" }, + "SessionUnlockDto": { + "properties": { + "password": { + "type": "string" + }, + "pinCode": { + "example": "123456", + "type": "string" + } + }, + "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 de0a723ff..1d3a04da4 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -512,18 +512,28 @@ export type LogoutResponseDto = { redirectUri: string; successful: boolean; }; -export type PinCodeChangeDto = { - newPinCode: string; +export type PinCodeResetDto = { password?: string; pinCode?: string; }; export type PinCodeSetupDto = { pinCode: string; }; +export type PinCodeChangeDto = { + newPinCode: string; + password?: string; + pinCode?: string; +}; +export type SessionUnlockDto = { + password?: string; + pinCode?: string; +}; export type AuthStatusResponseDto = { + expiresAt?: string; isElevated: boolean; password: boolean; pinCode: boolean; + pinExpiresAt?: string; }; export type ValidateAccessTokenResponseDto = { authStatus: boolean; @@ -1075,6 +1085,7 @@ export type SessionResponseDto = { current: boolean; deviceOS: string; deviceType: string; + expiresAt?: string; id: string; updatedAt: string; }; @@ -1089,6 +1100,7 @@ export type SessionCreateResponseDto = { current: boolean; deviceOS: string; deviceType: string; + expiresAt?: string; id: string; token: string; updatedAt: string; @@ -2066,13 +2078,13 @@ export function logout(opts?: Oazapfts.RequestOpts) { method: "POST" })); } -export function resetPinCode({ pinCodeChangeDto }: { - pinCodeChangeDto: PinCodeChangeDto; +export function resetPinCode({ pinCodeResetDto }: { + pinCodeResetDto: PinCodeResetDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({ ...opts, method: "DELETE", - body: pinCodeChangeDto + body: pinCodeResetDto }))); } export function setupPinCode({ pinCodeSetupDto }: { @@ -2093,13 +2105,19 @@ export function changePinCode({ pinCodeChangeDto }: { body: pinCodeChangeDto }))); } -export function verifyPinCode({ pinCodeSetupDto }: { - pinCodeSetupDto: PinCodeSetupDto; +export function lockAuthSession(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/auth/session/lock", { + ...opts, + method: "POST" + })); +} +export function unlockAuthSession({ sessionUnlockDto }: { + sessionUnlockDto: SessionUnlockDto; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/auth/pin-code/verify", oazapfts.json({ + return oazapfts.ok(oazapfts.fetchText("/auth/session/unlock", oazapfts.json({ ...opts, method: "POST", - body: pinCodeSetupDto + body: sessionUnlockDto }))); } export function getAuthStatus(opts?: Oazapfts.RequestOpts) { @@ -2952,6 +2970,14 @@ export function deleteSession({ id }: { method: "DELETE" })); } +export function lockSession({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}/lock`, { + ...opts, + method: "POST" + })); +} export function getAllSharedLinks({ albumId }: { albumId?: string; }, opts?: Oazapfts.RequestOpts) { @@ -3709,6 +3735,7 @@ export enum Permission { SessionRead = "session.read", SessionUpdate = "session.update", SessionDelete = "session.delete", + SessionLock = "session.lock", SharedLinkCreate = "sharedLink.create", SharedLinkRead = "sharedLink.read", SharedLinkUpdate = "sharedLink.update", diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 5d3ba8be9..78c611d76 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -9,7 +9,9 @@ import { LoginResponseDto, LogoutResponseDto, PinCodeChangeDto, + PinCodeResetDto, PinCodeSetupDto, + SessionUnlockDto, SignUpDto, ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; @@ -98,14 +100,21 @@ export class AuthController { @Delete('pin-code') @Authenticated() - async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise { + async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise { return this.service.resetPinCode(auth, dto); } - @Post('pin-code/verify') + @Post('session/unlock') @HttpCode(HttpStatus.OK) @Authenticated() - async verifyPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise { - return this.service.verifyPinCode(auth, dto); + async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise { + return this.service.unlockSession(auth, dto); + } + + @Post('session/lock') + @HttpCode(HttpStatus.OK) + @Authenticated() + async lockAuthSession(@Auth() auth: AuthDto): Promise { + return this.service.lockSession(auth); } } diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index addcfd8fe..3838d5af8 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -37,4 +37,11 @@ export class SessionController { deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } + + @Post(':id/lock') + @Authenticated({ permission: Permission.SESSION_LOCK }) + @HttpCode(HttpStatus.NO_CONTENT) + lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.lock(auth, id); + } } diff --git a/server/src/database.ts b/server/src/database.ts index 29c746aa1..cfccd70b7 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -232,6 +232,7 @@ export type Session = { id: string; createdAt: Date; updatedAt: Date; + expiresAt: Date | null; deviceOS: string; deviceType: string; pinExpiresAt: Date | null; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 6efbd5f7d..943c9ddfa 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -344,7 +344,7 @@ export interface Sessions { deviceType: Generated; id: Generated; parentId: string | null; - expiredAt: Date | null; + expiresAt: Date | null; token: string; updatedAt: Generated; updateId: Generated; diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 8644426ab..2f3ae5c14 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -93,6 +93,8 @@ export class PinCodeResetDto { password?: string; } +export class SessionUnlockDto extends PinCodeResetDto {} + export class PinCodeChangeDto extends PinCodeResetDto { @PinCode() newPinCode!: string; @@ -139,4 +141,6 @@ export class AuthStatusResponseDto { pinCode!: boolean; password!: boolean; isElevated!: boolean; + expiresAt?: string; + pinExpiresAt?: string; } diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index f109e44fa..f15166fbf 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -24,6 +24,7 @@ export class SessionResponseDto { id!: string; createdAt!: string; updatedAt!: string; + expiresAt?: string; current!: boolean; deviceType!: string; deviceOS!: string; @@ -37,6 +38,7 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse id: entity.id, createdAt: entity.createdAt.toISOString(), updatedAt: entity.updatedAt.toISOString(), + expiresAt: entity.expiresAt?.toISOString(), current: currentId === entity.id, deviceOS: entity.deviceOS, deviceType: entity.deviceType, diff --git a/server/src/enum.ts b/server/src/enum.ts index c6feb27dc..a4d2d2127 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -148,6 +148,7 @@ export enum Permission { SESSION_READ = 'session.read', SESSION_UPDATE = 'session.update', SESSION_DELETE = 'session.delete', + SESSION_LOCK = 'session.lock', SHARED_LINK_CREATE = 'sharedLink.create', SHARED_LINK_READ = 'sharedLink.read', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index c73f44c19..402bbdcfa 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -199,6 +199,15 @@ where "partners"."sharedById" in ($1) and "partners"."sharedWithId" = $2 +-- AccessRepository.session.checkOwnerAccess +select + "sessions"."id" +from + "sessions" +where + "sessions"."id" in ($1) + and "sessions"."userId" = $2 + -- AccessRepository.stack.checkOwnerAccess select "stacks"."id" diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index b265380a1..6a9b69c2e 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -1,12 +1,14 @@ -- NOTE: This file is auto generated by ./sql-generator --- SessionRepository.search +-- SessionRepository.get select - * + "id", + "expiresAt", + "pinExpiresAt" from "sessions" where - "sessions"."updatedAt" <= $1 + "id" = $1 -- SessionRepository.getByToken select @@ -37,8 +39,8 @@ from where "sessions"."token" = $1 and ( - "sessions"."expiredAt" is null - or "sessions"."expiredAt" > $2 + "sessions"."expiresAt" is null + or "sessions"."expiresAt" > $2 ) -- SessionRepository.getByUserId @@ -50,6 +52,10 @@ from and "users"."deletedAt" is null where "sessions"."userId" = $1 + and ( + "sessions"."expiresAt" is null + or "sessions"."expiresAt" > $2 + ) order by "sessions"."updatedAt" desc, "sessions"."createdAt" desc @@ -58,3 +64,10 @@ order by delete from "sessions" where "id" = $1::uuid + +-- SessionRepository.lockAll +update "sessions" +set + "pinExpiresAt" = $1 +where + "userId" = $2 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index b25007c4e..17f69c0e5 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -306,6 +306,25 @@ class NotificationAccess { } } +class SessionAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, sessionIds: Set) { + if (sessionIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('sessions') + .select('sessions.id') + .where('sessions.id', 'in', [...sessionIds]) + .where('sessions.userId', '=', userId) + .execute() + .then((sessions) => new Set(sessions.map((session) => session.id))); + } +} class StackAccess { constructor(private db: Kysely) {} @@ -456,6 +475,7 @@ export class AccessRepository { notification: NotificationAccess; person: PersonAccess; partner: PartnerAccess; + session: SessionAccess; stack: StackAccess; tag: TagAccess; timeline: TimelineAccess; @@ -469,6 +489,7 @@ export class AccessRepository { this.notification = new NotificationAccess(db); this.person = new PersonAccess(db); this.partner = new PartnerAccess(db); + this.session = new SessionAccess(db); this.stack = new StackAccess(db); this.tag = new TagAccess(db); this.timeline = new TimelineAccess(db); diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index ce819470c..6c3d10cb9 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -20,20 +20,20 @@ export class SessionRepository { .where((eb) => eb.or([ eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()), - eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]), + eb.and([eb('expiresAt', 'is not', null), eb('expiresAt', '<=', DateTime.now().toJSDate())]), ]), ) .returning(['id', 'deviceOS', 'deviceType']) .execute(); } - @GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] }) - search(options: SessionSearchOptions) { + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { return this.db .selectFrom('sessions') - .selectAll() - .where('sessions.updatedAt', '<=', options.updatedBefore) - .execute(); + .select(['id', 'expiresAt', 'pinExpiresAt']) + .where('id', '=', id) + .executeTakeFirst(); } @GenerateSql({ params: [DummyValue.STRING] }) @@ -52,7 +52,7 @@ export class SessionRepository { ]) .where('sessions.token', '=', token) .where((eb) => - eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]), + eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]), ) .executeTakeFirst(); } @@ -64,6 +64,9 @@ export class SessionRepository { .innerJoin('users', (join) => join.onRef('users.id', '=', 'sessions.userId').on('users.deletedAt', 'is', null)) .selectAll('sessions') .where('sessions.userId', '=', userId) + .where((eb) => + eb.or([eb('sessions.expiresAt', 'is', null), eb('sessions.expiresAt', '>', DateTime.now().toJSDate())]), + ) .orderBy('sessions.updatedAt', 'desc') .orderBy('sessions.createdAt', 'desc') .execute(); @@ -86,4 +89,9 @@ export class SessionRepository { async delete(id: string) { await this.db.deleteFrom('sessions').where('id', '=', asUuid(id)).execute(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async lockAll(userId: string) { + await this.db.updateTable('sessions').set({ pinExpiresAt: null }).where('userId', '=', userId).execute(); + } } diff --git a/server/src/schema/migrations/1747338664832-SessionRename.ts b/server/src/schema/migrations/1747338664832-SessionRename.ts new file mode 100644 index 000000000..5ba532d13 --- /dev/null +++ b/server/src/schema/migrations/1747338664832-SessionRename.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" RENAME "expiredAt" TO "expiresAt";`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "sessions" RENAME "expiresAt" TO "expiredAt";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 9cc41c5bb..6bd5d84cb 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -26,7 +26,7 @@ export class SessionTable { updatedAt!: Date; @Column({ type: 'timestamp with time zone', nullable: true }) - expiredAt!: Date | null; + expiresAt!: Date | null; @ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) userId!: string; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index fb1a5ae04..4bc5f1ce0 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -924,13 +924,13 @@ describe(AuthService.name, () => { const user = factory.userAdmin(); mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' }); mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b); - mocks.session.getByUserId.mockResolvedValue([currentSession]); + mocks.session.lockAll.mockResolvedValue(void 0); mocks.session.update.mockResolvedValue(currentSession); await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' }); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null }); - expect(mocks.session.update).toHaveBeenCalledWith(currentSession.id, { pinExpiresAt: null }); + expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id); }); it('should throw if the PIN code does not match', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 7bda2eeb9..e6c541a62 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -18,6 +18,7 @@ import { PinCodeChangeDto, PinCodeResetDto, PinCodeSetupDto, + SessionUnlockDto, SignUpDto, mapLoginResponse, } from 'src/dtos/auth.dto'; @@ -123,24 +124,21 @@ export class AuthService extends BaseService { async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) { const user = await this.userRepository.getForPinCode(auth.user.id); - this.resetPinChecks(user, dto); + this.validatePinCode(user, dto); await this.userRepository.update(auth.user.id, { pinCode: null }); - const sessions = await this.sessionRepository.getByUserId(auth.user.id); - for (const session of sessions) { - await this.sessionRepository.update(session.id, { pinExpiresAt: null }); - } + await this.sessionRepository.lockAll(auth.user.id); } async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) { const user = await this.userRepository.getForPinCode(auth.user.id); - this.resetPinChecks(user, dto); + this.validatePinCode(user, dto); const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS); await this.userRepository.update(auth.user.id, { pinCode: hashed }); } - private resetPinChecks( + private validatePinCode( user: { pinCode: string | null; password: string | null }, dto: { pinCode?: string; password?: string }, ) { @@ -474,23 +472,27 @@ export class AuthService extends BaseService { throw new UnauthorizedException('Invalid user token'); } - async verifyPinCode(auth: AuthDto, dto: PinCodeSetupDto): Promise { - const user = await this.userRepository.getForPinCode(auth.user.id); - if (!user) { - throw new UnauthorizedException(); - } - - this.resetPinChecks(user, { pinCode: dto.pinCode }); - + async unlockSession(auth: AuthDto, dto: SessionUnlockDto): Promise { if (!auth.session) { - throw new BadRequestException('Session is missing'); + throw new BadRequestException('This endpoint can only be used with a session token'); } + const user = await this.userRepository.getForPinCode(auth.user.id); + this.validatePinCode(user, { pinCode: dto.pinCode }); + await this.sessionRepository.update(auth.session.id, { - pinExpiresAt: new Date(DateTime.now().plus({ minutes: 15 }).toJSDate()), + pinExpiresAt: DateTime.now().plus({ minutes: 15 }).toJSDate(), }); } + async lockSession(auth: AuthDto): Promise { + if (!auth.session) { + throw new BadRequestException('This endpoint can only be used with a session token'); + } + + await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null }); + } + private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) { const token = this.cryptoRepository.randomBytesAsText(32); const tokenHashed = this.cryptoRepository.hashSha256(token); @@ -526,10 +528,14 @@ export class AuthService extends BaseService { throw new UnauthorizedException(); } + const session = auth.session ? await this.sessionRepository.get(auth.session.id) : undefined; + return { pinCode: !!user.pinCode, password: !!user.password, isElevated: !!auth.session?.hasElevatedPermission, + expiresAt: session?.expiresAt?.toISOString(), + pinExpiresAt: session?.pinExpiresAt?.toISOString(), }; } } diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 9f49cda07..059ff00e1 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -30,7 +30,7 @@ export class SessionService extends BaseService { const session = await this.sessionRepository.create({ parentId: auth.session.id, userId: auth.user.id, - expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, + expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null, deviceType: dto.deviceType, deviceOS: dto.deviceOS, token: tokenHashed, @@ -49,6 +49,11 @@ export class SessionService extends BaseService { await this.sessionRepository.delete(id); } + async lock(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.SESSION_LOCK, ids: [id] }); + await this.sessionRepository.update(id, { pinExpiresAt: null }); + } + async deleteAll(auth: AuthDto): Promise { const sessions = await this.sessionRepository.getByUserId(auth.user.id); for (const session of sessions) { diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index e2fe7429f..38697a654 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -280,6 +280,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.partner.checkUpdateAccess(auth.user.id, ids); } + case Permission.SESSION_READ: + case Permission.SESSION_UPDATE: + case Permission.SESSION_DELETE: + case Permission.SESSION_LOCK: { + return access.session.checkOwnerAccess(auth.user.id, ids); + } + case Permission.STACK_READ: { return access.stack.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 5b98b95e2..50db983cb 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -50,6 +50,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkUpdateAccess: vitest.fn().mockResolvedValue(new Set()), }, + session: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, + stack: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 231deeba8..75e36c1da 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -127,7 +127,7 @@ const sessionFactory = (session: Partial = {}) => ({ deviceType: 'mobile', token: 'abc123', parentId: null, - expiredAt: null, + expiresAt: null, userId: newUuid(), pinExpiresAt: newDate(), ...session, diff --git a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte index 49b40866d..9c41a7fe5 100644 --- a/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/locked/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,4 +1,5 @@ @@ -62,6 +69,12 @@ {/if} + {#snippet buttons()} + + {/snippet} + { await authenticate(url); + const { isElevated, pinCode } = await getAuthStatus(); - if (!isElevated || !pinCode) { - const continuePath = encodeURIComponent(url.pathname); - const redirectPath = `${AppRoute.AUTH_PIN_PROMPT}?continue=${continuePath}`; - - redirect(302, redirectPath); + redirect(302, `${AppRoute.AUTH_PIN_PROMPT}?continue=${encodeURIComponent(url.pathname + url.search)}`); } + const asset = await getAssetInfoFromParam(params); const $t = await getFormatter(); diff --git a/web/src/routes/auth/pin-prompt/+page.svelte b/web/src/routes/auth/pin-prompt/+page.svelte index 91480cd35..ffed9d5de 100644 --- a/web/src/routes/auth/pin-prompt/+page.svelte +++ b/web/src/routes/auth/pin-prompt/+page.svelte @@ -3,9 +3,8 @@ import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte'; import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte'; import PincodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte'; - import { AppRoute } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; - import { verifyPinCode } from '@immich/sdk'; + import { unlockAuthSession } from '@immich/sdk'; import { Icon } from '@immich/ui'; import { mdiLockOpenVariantOutline, mdiLockOutline, mdiLockSmart } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -23,17 +22,15 @@ let hasPinCode = $derived(data.hasPinCode); let pinCode = $state(''); - const onPinFilled = async (code: string, withDelay = false) => { + const handleUnlockSession = async (code: string) => { try { - await verifyPinCode({ pinCodeSetupDto: { pinCode: code } }); + await unlockAuthSession({ sessionUnlockDto: { pinCode: code } }); isVerified = true; - if (withDelay) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - } + await new Promise((resolve) => setTimeout(resolve, 1000)); - void goto(data.continuePath ?? AppRoute.LOCKED); + await goto(data.continueUrl); } catch (error) { handleError(error, $t('wrong_pin_code')); isBadPinCode = true; @@ -64,7 +61,7 @@ bind:value={pinCode} tabindexStart={1} pinLength={6} - onFilled={(pinCode) => onPinFilled(pinCode, true)} + onFilled={handleUnlockSession} /> diff --git a/web/src/routes/auth/pin-prompt/+page.ts b/web/src/routes/auth/pin-prompt/+page.ts index b0d248ebe..89d59a312 100644 --- a/web/src/routes/auth/pin-prompt/+page.ts +++ b/web/src/routes/auth/pin-prompt/+page.ts @@ -1,3 +1,4 @@ +import { AppRoute } from '$lib/constants'; import { authenticate } from '$lib/utils/auth'; import { getFormatter } from '$lib/utils/i18n'; import { getAuthStatus } from '@immich/sdk'; @@ -8,8 +9,6 @@ export const load = (async ({ url }) => { const { pinCode } = await getAuthStatus(); - const continuePath = url.searchParams.get('continue'); - const $t = await getFormatter(); return { @@ -17,6 +16,6 @@ export const load = (async ({ url }) => { title: $t('pin_verification'), }, hasPinCode: !!pinCode, - continuePath, + continueUrl: url.searchParams.get('continue') || AppRoute.LOCKED, }; }) satisfies PageLoad;