diff --git a/i18n/en.json b/i18n/en.json index e4fc825cda..578fe9a115 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 9544b2ddab..620fc97664 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -111,13 +111,14 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | *AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | +*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**resetPinCode**](doc//AuthenticationApi.md#resetpincode) | **DELETE** /auth/pin-code | *AuthenticationApi* | [**setupPinCode**](doc//AuthenticationApi.md#setuppincode) | **POST** /auth/pin-code | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | +*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*AuthenticationApi* | [**verifyPinCode**](doc//AuthenticationApi.md#verifypincode) | **POST** /auth/pin-code/verify | *DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | @@ -198,6 +199,7 @@ Class | Method | HTTP request | Description *SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions | *SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} | *SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions | +*SessionsApi* | [**lockSession**](doc//SessionsApi.md#locksession) | **POST** /sessions/{id}/lock | *SharedLinksApi* | [**addSharedLinkAssets**](doc//SharedLinksApi.md#addsharedlinkassets) | **PUT** /shared-links/{id}/assets | *SharedLinksApi* | [**createSharedLink**](doc//SharedLinksApi.md#createsharedlink) | **POST** /shared-links | *SharedLinksApi* | [**getAllSharedLinks**](doc//SharedLinksApi.md#getallsharedlinks) | **GET** /shared-links | @@ -392,6 +394,7 @@ Class | Method | HTTP request | Description - [PersonUpdateDto](doc//PersonUpdateDto.md) - [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md) - [PinCodeChangeDto](doc//PinCodeChangeDto.md) + - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md) - [PurchaseResponse](doc//PurchaseResponse.md) @@ -424,6 +427,7 @@ Class | Method | HTTP request | Description - [SessionCreateDto](doc//SessionCreateDto.md) - [SessionCreateResponseDto](doc//SessionCreateResponseDto.md) - [SessionResponseDto](doc//SessionResponseDto.md) + - [SessionUnlockDto](doc//SessionUnlockDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkEditDto](doc//SharedLinkEditDto.md) - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index d0e39e0965..8710298d7d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -189,6 +189,7 @@ part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; part 'model/person_with_faces_response_dto.dart'; part 'model/pin_code_change_dto.dart'; +part 'model/pin_code_reset_dto.dart'; part 'model/pin_code_setup_dto.dart'; part 'model/places_response_dto.dart'; part 'model/purchase_response.dart'; @@ -221,6 +222,7 @@ part 'model/server_version_response_dto.dart'; part 'model/session_create_dto.dart'; part 'model/session_create_response_dto.dart'; part 'model/session_response_dto.dart'; +part 'model/session_unlock_dto.dart'; part 'model/shared_link_create_dto.dart'; part 'model/shared_link_edit_dto.dart'; part 'model/shared_link_response_dto.dart'; diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index 446a0616ed..5482a9fc51 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -143,6 +143,39 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'POST /auth/session/lock' operation and returns the [Response]. + Future lockAuthSessionWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/session/lock'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future lockAuthSession() async { + final response = await lockAuthSessionWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. /// Parameters: /// @@ -234,13 +267,13 @@ class AuthenticationApi { /// Performs an HTTP 'DELETE /auth/pin-code' operation and returns the [Response]. /// Parameters: /// - /// * [PinCodeChangeDto] pinCodeChangeDto (required): - Future resetPinCodeWithHttpInfo(PinCodeChangeDto pinCodeChangeDto,) async { + /// * [PinCodeResetDto] pinCodeResetDto (required): + Future resetPinCodeWithHttpInfo(PinCodeResetDto pinCodeResetDto,) async { // ignore: prefer_const_declarations final apiPath = r'/auth/pin-code'; // ignore: prefer_final_locals - Object? postBody = pinCodeChangeDto; + Object? postBody = pinCodeResetDto; final queryParams = []; final headerParams = {}; @@ -262,9 +295,9 @@ class AuthenticationApi { /// Parameters: /// - /// * [PinCodeChangeDto] pinCodeChangeDto (required): - Future resetPinCode(PinCodeChangeDto pinCodeChangeDto,) async { - final response = await resetPinCodeWithHttpInfo(pinCodeChangeDto,); + /// * [PinCodeResetDto] pinCodeResetDto (required): + Future resetPinCode(PinCodeResetDto pinCodeResetDto,) async { + final response = await resetPinCodeWithHttpInfo(pinCodeResetDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -356,6 +389,45 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'POST /auth/session/unlock' operation and returns the [Response]. + /// Parameters: + /// + /// * [SessionUnlockDto] sessionUnlockDto (required): + Future unlockAuthSessionWithHttpInfo(SessionUnlockDto sessionUnlockDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/auth/session/unlock'; + + // ignore: prefer_final_locals + Object? postBody = sessionUnlockDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [SessionUnlockDto] sessionUnlockDto (required): + Future unlockAuthSession(SessionUnlockDto sessionUnlockDto,) async { + final response = await unlockAuthSessionWithHttpInfo(sessionUnlockDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'POST /auth/validateToken' operation and returns the [Response]. Future validateAccessTokenWithHttpInfo() async { // ignore: prefer_const_declarations @@ -396,43 +468,4 @@ class AuthenticationApi { } return null; } - - /// Performs an HTTP 'POST /auth/pin-code/verify' operation and returns the [Response]. - /// Parameters: - /// - /// * [PinCodeSetupDto] pinCodeSetupDto (required): - Future verifyPinCodeWithHttpInfo(PinCodeSetupDto pinCodeSetupDto,) async { - // ignore: prefer_const_declarations - final apiPath = r'/auth/pin-code/verify'; - - // ignore: prefer_final_locals - Object? postBody = pinCodeSetupDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - apiPath, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [PinCodeSetupDto] pinCodeSetupDto (required): - Future verifyPinCode(PinCodeSetupDto pinCodeSetupDto,) async { - final response = await verifyPinCodeWithHttpInfo(pinCodeSetupDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - } } diff --git a/mobile/openapi/lib/api/sessions_api.dart b/mobile/openapi/lib/api/sessions_api.dart index 9f850fb4c8..3228d31e91 100644 --- a/mobile/openapi/lib/api/sessions_api.dart +++ b/mobile/openapi/lib/api/sessions_api.dart @@ -179,4 +179,44 @@ class SessionsApi { } return null; } + + /// Performs an HTTP 'POST /sessions/{id}/lock' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future lockSessionWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/sessions/{id}/lock' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future lockSession(String id,) async { + final response = await lockSessionWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f40d09ecc3..a3b1c41ca6 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -434,6 +434,8 @@ class ApiClient { return PersonWithFacesResponseDto.fromJson(value); case 'PinCodeChangeDto': return PinCodeChangeDto.fromJson(value); + case 'PinCodeResetDto': + return PinCodeResetDto.fromJson(value); case 'PinCodeSetupDto': return PinCodeSetupDto.fromJson(value); case 'PlacesResponseDto': @@ -498,6 +500,8 @@ class ApiClient { return SessionCreateResponseDto.fromJson(value); case 'SessionResponseDto': return SessionResponseDto.fromJson(value); + case 'SessionUnlockDto': + return SessionUnlockDto.fromJson(value); case 'SharedLinkCreateDto': return SharedLinkCreateDto.fromJson(value); case 'SharedLinkEditDto': diff --git a/mobile/openapi/lib/model/auth_status_response_dto.dart b/mobile/openapi/lib/model/auth_status_response_dto.dart index 0ccd87114e..4e823506ee 100644 --- a/mobile/openapi/lib/model/auth_status_response_dto.dart +++ b/mobile/openapi/lib/model/auth_status_response_dto.dart @@ -13,38 +13,70 @@ part of openapi.api; class AuthStatusResponseDto { /// Returns a new [AuthStatusResponseDto] instance. AuthStatusResponseDto({ + this.expiresAt, required this.isElevated, required this.password, required this.pinCode, + this.pinExpiresAt, }); + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? expiresAt; + bool isElevated; bool password; bool pinCode; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? pinExpiresAt; + @override bool operator ==(Object other) => identical(this, other) || other is AuthStatusResponseDto && + other.expiresAt == expiresAt && other.isElevated == isElevated && other.password == password && - other.pinCode == pinCode; + other.pinCode == pinCode && + other.pinExpiresAt == pinExpiresAt; @override int get hashCode => // ignore: unnecessary_parenthesis + (expiresAt == null ? 0 : expiresAt!.hashCode) + (isElevated.hashCode) + (password.hashCode) + - (pinCode.hashCode); + (pinCode.hashCode) + + (pinExpiresAt == null ? 0 : pinExpiresAt!.hashCode); @override - String toString() => 'AuthStatusResponseDto[isElevated=$isElevated, password=$password, pinCode=$pinCode]'; + String toString() => 'AuthStatusResponseDto[expiresAt=$expiresAt, isElevated=$isElevated, password=$password, pinCode=$pinCode, pinExpiresAt=$pinExpiresAt]'; Map toJson() { final json = {}; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'isElevated'] = this.isElevated; json[r'password'] = this.password; json[r'pinCode'] = this.pinCode; + if (this.pinExpiresAt != null) { + json[r'pinExpiresAt'] = this.pinExpiresAt; + } else { + // json[r'pinExpiresAt'] = null; + } return json; } @@ -57,9 +89,11 @@ class AuthStatusResponseDto { final json = value.cast(); return AuthStatusResponseDto( + expiresAt: mapValueOfType(json, r'expiresAt'), isElevated: mapValueOfType(json, r'isElevated')!, password: mapValueOfType(json, r'password')!, pinCode: mapValueOfType(json, r'pinCode')!, + pinExpiresAt: mapValueOfType(json, r'pinExpiresAt'), ); } return null; diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 73ecbd5868..a85b5002bf 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -85,6 +85,7 @@ class Permission { static const sessionPeriodRead = Permission._(r'session.read'); static const sessionPeriodUpdate = Permission._(r'session.update'); static const sessionPeriodDelete = Permission._(r'session.delete'); + static const sessionPeriodLock = Permission._(r'session.lock'); static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create'); static const sharedLinkPeriodRead = Permission._(r'sharedLink.read'); static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update'); @@ -171,6 +172,7 @@ class Permission { sessionPeriodRead, sessionPeriodUpdate, sessionPeriodDelete, + sessionPeriodLock, sharedLinkPeriodCreate, sharedLinkPeriodRead, sharedLinkPeriodUpdate, @@ -292,6 +294,7 @@ class PermissionTypeTransformer { case r'session.read': return Permission.sessionPeriodRead; case r'session.update': return Permission.sessionPeriodUpdate; case r'session.delete': return Permission.sessionPeriodDelete; + case r'session.lock': return Permission.sessionPeriodLock; case r'sharedLink.create': return Permission.sharedLinkPeriodCreate; case r'sharedLink.read': return Permission.sharedLinkPeriodRead; case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate; diff --git a/mobile/openapi/lib/model/pin_code_reset_dto.dart b/mobile/openapi/lib/model/pin_code_reset_dto.dart new file mode 100644 index 0000000000..3585348675 --- /dev/null +++ b/mobile/openapi/lib/model/pin_code_reset_dto.dart @@ -0,0 +1,125 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PinCodeResetDto { + /// Returns a new [PinCodeResetDto] instance. + PinCodeResetDto({ + this.password, + this.pinCode, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? password; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? pinCode; + + @override + bool operator ==(Object other) => identical(this, other) || other is PinCodeResetDto && + other.password == password && + other.pinCode == pinCode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (password == null ? 0 : password!.hashCode) + + (pinCode == null ? 0 : pinCode!.hashCode); + + @override + String toString() => 'PinCodeResetDto[password=$password, pinCode=$pinCode]'; + + Map toJson() { + final json = {}; + if (this.password != null) { + json[r'password'] = this.password; + } else { + // json[r'password'] = null; + } + if (this.pinCode != null) { + json[r'pinCode'] = this.pinCode; + } else { + // json[r'pinCode'] = null; + } + return json; + } + + /// Returns a new [PinCodeResetDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PinCodeResetDto? fromJson(dynamic value) { + upgradeDto(value, "PinCodeResetDto"); + if (value is Map) { + final json = value.cast(); + + return PinCodeResetDto( + password: mapValueOfType(json, r'password'), + pinCode: mapValueOfType(json, r'pinCode'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PinCodeResetDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PinCodeResetDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PinCodeResetDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PinCodeResetDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index 1ef346c96a..ab1c4ca2d8 100644 --- a/mobile/openapi/lib/model/session_create_response_dto.dart +++ b/mobile/openapi/lib/model/session_create_response_dto.dart @@ -17,6 +17,7 @@ class SessionCreateResponseDto { required this.current, required this.deviceOS, required this.deviceType, + this.expiresAt, required this.id, required this.token, required this.updatedAt, @@ -30,6 +31,14 @@ class SessionCreateResponseDto { String deviceType; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? expiresAt; + String id; String token; @@ -42,6 +51,7 @@ class SessionCreateResponseDto { other.current == current && other.deviceOS == deviceOS && other.deviceType == deviceType && + other.expiresAt == expiresAt && other.id == id && other.token == token && other.updatedAt == updatedAt; @@ -53,12 +63,13 @@ class SessionCreateResponseDto { (current.hashCode) + (deviceOS.hashCode) + (deviceType.hashCode) + + (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + (token.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, token=$token, updatedAt=$updatedAt]'; + String toString() => 'SessionCreateResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, token=$token, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -66,6 +77,11 @@ class SessionCreateResponseDto { json[r'current'] = this.current; json[r'deviceOS'] = this.deviceOS; json[r'deviceType'] = this.deviceType; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'id'] = this.id; json[r'token'] = this.token; json[r'updatedAt'] = this.updatedAt; @@ -85,6 +101,7 @@ class SessionCreateResponseDto { current: mapValueOfType(json, r'current')!, deviceOS: mapValueOfType(json, r'deviceOS')!, deviceType: mapValueOfType(json, r'deviceType')!, + expiresAt: mapValueOfType(json, r'expiresAt'), id: mapValueOfType(json, r'id')!, token: mapValueOfType(json, r'token')!, updatedAt: mapValueOfType(json, r'updatedAt')!, diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index 92e2dc6067..cf9eb08a78 100644 --- a/mobile/openapi/lib/model/session_response_dto.dart +++ b/mobile/openapi/lib/model/session_response_dto.dart @@ -17,6 +17,7 @@ class SessionResponseDto { required this.current, required this.deviceOS, required this.deviceType, + this.expiresAt, required this.id, required this.updatedAt, }); @@ -29,6 +30,14 @@ class SessionResponseDto { String deviceType; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? expiresAt; + String id; String updatedAt; @@ -39,6 +48,7 @@ class SessionResponseDto { other.current == current && other.deviceOS == deviceOS && other.deviceType == deviceType && + other.expiresAt == expiresAt && other.id == id && other.updatedAt == updatedAt; @@ -49,11 +59,12 @@ class SessionResponseDto { (current.hashCode) + (deviceOS.hashCode) + (deviceType.hashCode) + + (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, updatedAt=$updatedAt]'; + String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, expiresAt=$expiresAt, id=$id, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -61,6 +72,11 @@ class SessionResponseDto { json[r'current'] = this.current; json[r'deviceOS'] = this.deviceOS; json[r'deviceType'] = this.deviceType; + if (this.expiresAt != null) { + json[r'expiresAt'] = this.expiresAt; + } else { + // json[r'expiresAt'] = null; + } json[r'id'] = this.id; json[r'updatedAt'] = this.updatedAt; return json; @@ -79,6 +95,7 @@ class SessionResponseDto { current: mapValueOfType(json, r'current')!, deviceOS: mapValueOfType(json, r'deviceOS')!, deviceType: mapValueOfType(json, r'deviceType')!, + expiresAt: mapValueOfType(json, r'expiresAt'), id: mapValueOfType(json, r'id')!, updatedAt: mapValueOfType(json, r'updatedAt')!, ); diff --git a/mobile/openapi/lib/model/session_unlock_dto.dart b/mobile/openapi/lib/model/session_unlock_dto.dart new file mode 100644 index 0000000000..4cfeb14385 --- /dev/null +++ b/mobile/openapi/lib/model/session_unlock_dto.dart @@ -0,0 +1,125 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SessionUnlockDto { + /// Returns a new [SessionUnlockDto] instance. + SessionUnlockDto({ + this.password, + this.pinCode, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? password; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? pinCode; + + @override + bool operator ==(Object other) => identical(this, other) || other is SessionUnlockDto && + other.password == password && + other.pinCode == pinCode; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (password == null ? 0 : password!.hashCode) + + (pinCode == null ? 0 : pinCode!.hashCode); + + @override + String toString() => 'SessionUnlockDto[password=$password, pinCode=$pinCode]'; + + Map toJson() { + final json = {}; + if (this.password != null) { + json[r'password'] = this.password; + } else { + // json[r'password'] = null; + } + if (this.pinCode != null) { + json[r'pinCode'] = this.pinCode; + } else { + // json[r'pinCode'] = null; + } + return json; + } + + /// Returns a new [SessionUnlockDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SessionUnlockDto? fromJson(dynamic value) { + upgradeDto(value, "SessionUnlockDto"); + if (value is Map) { + final json = value.cast(); + + return SessionUnlockDto( + password: mapValueOfType(json, r'password'), + pinCode: mapValueOfType(json, r'pinCode'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SessionUnlockDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SessionUnlockDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SessionUnlockDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SessionUnlockDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d4a1e219c9..89bdfef45e 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 de0a723ffa..1d3a04da44 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 5d3ba8be95..78c611d761 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 addcfd8fe9..3838d5af80 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 29c746aa1f..cfccd70b75 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 6efbd5f7d7..943c9ddfa0 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 8644426ab2..2f3ae5c14b 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 f109e44fa0..f15166fbf5 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 c6feb27dcc..a4d2d21274 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 c73f44c19d..402bbdcfaf 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 b265380a1f..6a9b69c2e3 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 b25007c4ea..17f69c0e52 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 ce819470c7..6c3d10cb9a 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 0000000000..5ba532d136 --- /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 9cc41c5bba..6bd5d84cb2 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 fb1a5ae042..4bc5f1ce0b 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 7bda2eeb98..e6c541a624 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 9f49cda07f..059ff00e16 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 e2fe7429f3..38697a654b 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 5b98b95e27..50db983cba 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 231deeba83..75e36c1da2 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 49b40866dd..9c41a7fe59 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 91480cd35c..ffed9d5de0 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 b0d248ebe6..89d59a3127 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;