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 | 3 + mobile/openapi/doc/AuthDeviceResponseDto.md | 20 ++ mobile/openapi/doc/AuthenticationApi.md | 99 ++++++++++ mobile/openapi/doc/LogoutResponseDto.md | 4 +- mobile/openapi/lib/api.dart | 1 + .../openapi/lib/api/authentication_api.dart | 84 +++++++++ mobile/openapi/lib/api_client.dart | 2 + .../lib/model/auth_device_response_dto.dart | 151 +++++++++++++++ .../test/auth_device_response_dto_test.dart | 52 ++++++ .../openapi/test/authentication_api_test.dart | 10 + .../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, 1209 insertions(+), 93 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 b2979df859..4317d4bc59 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 ecc9db7814..a648b5ed16 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 68ea2ee350..bcd53f9342 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -111,8 +111,10 @@ Class | Method | HTTP request | Description *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | +*AuthenticationApi* | [**getAuthDevices**](doc//AuthenticationApi.md#getauthdevices) | **GET** /auth/devices | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | +*AuthenticationApi* | [**logoutAuthDevice**](doc//AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info | *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | @@ -174,6 +176,7 @@ Class | Method | HTTP request | Description - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) + - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md) - [ChangePasswordDto](doc//ChangePasswordDto.md) - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md) - [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md) diff --git a/mobile/openapi/doc/AuthDeviceResponseDto.md b/mobile/openapi/doc/AuthDeviceResponseDto.md new file mode 100644 index 0000000000..261e767d92 --- /dev/null +++ b/mobile/openapi/doc/AuthDeviceResponseDto.md @@ -0,0 +1,20 @@ +# openapi.model.AuthDeviceResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**id** | **String** | | +**createdAt** | **String** | | +**updatedAt** | **String** | | +**current** | **bool** | | +**deviceType** | **String** | | +**deviceOS** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/AuthenticationApi.md b/mobile/openapi/doc/AuthenticationApi.md index 5b529cadb1..b515698fd0 100644 --- a/mobile/openapi/doc/AuthenticationApi.md +++ b/mobile/openapi/doc/AuthenticationApi.md @@ -11,8 +11,10 @@ Method | HTTP request | Description ------------- | ------------- | ------------- [**adminSignUp**](AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | [**changePassword**](AuthenticationApi.md#changepassword) | **POST** /auth/change-password | +[**getAuthDevices**](AuthenticationApi.md#getauthdevices) | **GET** /auth/devices | [**login**](AuthenticationApi.md#login) | **POST** /auth/login | [**logout**](AuthenticationApi.md#logout) | **POST** /auth/logout | +[**logoutAuthDevice**](AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} | [**validateAccessToken**](AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | @@ -108,6 +110,53 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getAuthDevices** +> List getAuthDevices() + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AuthenticationApi(); + +try { + final result = api_instance.getAuthDevices(); + print(result); +} catch (e) { + print('Exception when calling AuthenticationApi->getAuthDevices: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**List**](AuthDeviceResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **login** > LoginResponseDto login(loginCredentialDto) @@ -196,6 +245,56 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **logoutAuthDevice** +> logoutAuthDevice(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AuthenticationApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + api_instance.logoutAuthDevice(id); +} catch (e) { + print('Exception when calling AuthenticationApi->logoutAuthDevice: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: Not defined + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **validateAccessToken** > ValidateAccessTokenResponseDto validateAccessToken() diff --git a/mobile/openapi/doc/LogoutResponseDto.md b/mobile/openapi/doc/LogoutResponseDto.md index f1b22a889d..9d17baf2ce 100644 --- a/mobile/openapi/doc/LogoutResponseDto.md +++ b/mobile/openapi/doc/LogoutResponseDto.md @@ -8,8 +8,8 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**successful** | **bool** | | [readonly] -**redirectUri** | **String** | | [readonly] +**successful** | **bool** | | +**redirectUri** | **String** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index c30576da81..72f05c660c 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -59,6 +59,7 @@ part 'model/asset_count_by_user_id_response_dto.dart'; part 'model/asset_file_upload_response_dto.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_type_enum.dart'; +part 'model/auth_device_response_dto.dart'; part 'model/change_password_dto.dart'; part 'model/check_duplicate_asset_dto.dart'; part 'model/check_duplicate_asset_response_dto.dart'; diff --git a/mobile/openapi/lib/api/authentication_api.dart b/mobile/openapi/lib/api/authentication_api.dart index 39888550dc..f8ba2cdb16 100644 --- a/mobile/openapi/lib/api/authentication_api.dart +++ b/mobile/openapi/lib/api/authentication_api.dart @@ -110,6 +110,50 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'GET /auth/devices' operation and returns the [Response]. + Future getAuthDevicesWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/auth/devices'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getAuthDevices() async { + final response = await getAuthDevicesWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } + /// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. /// Parameters: /// @@ -198,6 +242,46 @@ class AuthenticationApi { return null; } + /// Performs an HTTP 'DELETE /auth/devices/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future logoutAuthDeviceWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/auth/devices/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future logoutAuthDevice(String id,) async { + final response = await logoutAuthDeviceWithHttpInfo(id,); + 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 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index d58945a77a..abdbafde30 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -215,6 +215,8 @@ class ApiClient { return AssetResponseDto.fromJson(value); case 'AssetTypeEnum': return AssetTypeEnumTypeTransformer().decode(value); + case 'AuthDeviceResponseDto': + return AuthDeviceResponseDto.fromJson(value); case 'ChangePasswordDto': return ChangePasswordDto.fromJson(value); case 'CheckDuplicateAssetDto': 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 0000000000..534f6c2933 --- /dev/null +++ b/mobile/openapi/lib/model/auth_device_response_dto.dart @@ -0,0 +1,151 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 AuthDeviceResponseDto { + /// Returns a new [AuthDeviceResponseDto] instance. + AuthDeviceResponseDto({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.current, + required this.deviceType, + required this.deviceOS, + }); + + String id; + + String createdAt; + + String updatedAt; + + bool current; + + String deviceType; + + String deviceOS; + + @override + bool operator ==(Object other) => identical(this, other) || other is AuthDeviceResponseDto && + other.id == id && + other.createdAt == createdAt && + other.updatedAt == updatedAt && + other.current == current && + other.deviceType == deviceType && + other.deviceOS == deviceOS; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (id.hashCode) + + (createdAt.hashCode) + + (updatedAt.hashCode) + + (current.hashCode) + + (deviceType.hashCode) + + (deviceOS.hashCode); + + @override + String toString() => 'AuthDeviceResponseDto[id=$id, createdAt=$createdAt, updatedAt=$updatedAt, current=$current, deviceType=$deviceType, deviceOS=$deviceOS]'; + + Map toJson() { + final json = {}; + json[r'id'] = this.id; + json[r'createdAt'] = this.createdAt; + json[r'updatedAt'] = this.updatedAt; + json[r'current'] = this.current; + json[r'deviceType'] = this.deviceType; + json[r'deviceOS'] = this.deviceOS; + return json; + } + + /// Returns a new [AuthDeviceResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AuthDeviceResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "AuthDeviceResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "AuthDeviceResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return AuthDeviceResponseDto( + id: mapValueOfType(json, r'id')!, + createdAt: mapValueOfType(json, r'createdAt')!, + updatedAt: mapValueOfType(json, r'updatedAt')!, + current: mapValueOfType(json, r'current')!, + deviceType: mapValueOfType(json, r'deviceType')!, + deviceOS: mapValueOfType(json, r'deviceOS')!, + ); + } + 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 = AuthDeviceResponseDto.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 = AuthDeviceResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AuthDeviceResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AuthDeviceResponseDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'id', + 'createdAt', + 'updatedAt', + 'current', + 'deviceType', + 'deviceOS', + }; +} + 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 0000000000..615b34b1d7 --- /dev/null +++ b/mobile/openapi/test/auth_device_response_dto_test.dart @@ -0,0 +1,52 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for AuthDeviceResponseDto +void main() { + // final instance = AuthDeviceResponseDto(); + + group('test AuthDeviceResponseDto', () { + // String id + test('to test the property `id`', () async { + // TODO + }); + + // String createdAt + test('to test the property `createdAt`', () async { + // TODO + }); + + // String updatedAt + test('to test the property `updatedAt`', () async { + // TODO + }); + + // bool current + test('to test the property `current`', () async { + // TODO + }); + + // String deviceType + test('to test the property `deviceType`', () async { + // TODO + }); + + // String deviceOS + test('to test the property `deviceOS`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/authentication_api_test.dart b/mobile/openapi/test/authentication_api_test.dart index f855d32390..9c5ae81b67 100644 --- a/mobile/openapi/test/authentication_api_test.dart +++ b/mobile/openapi/test/authentication_api_test.dart @@ -27,6 +27,11 @@ void main() { // TODO }); + //Future> getAuthDevices() async + test('test getAuthDevices', () async { + // TODO + }); + //Future login(LoginCredentialDto loginCredentialDto) async test('test login', () async { // TODO @@ -37,6 +42,11 @@ void main() { // TODO }); + //Future logoutAuthDevice(String id) async + test('test logoutAuthDevice', () async { + // TODO + }); + //Future validateAccessToken() async test('test validateAccessToken', () async { // TODO diff --git a/server/apps/immich/src/controllers/auth.controller.ts b/server/apps/immich/src/controllers/auth.controller.ts index 7107a8bebf..6ea39fd9d5 100644 --- a/server/apps/immich/src/controllers/auth.controller.ts +++ b/server/apps/immich/src/controllers/auth.controller.ts @@ -1,5 +1,6 @@ import { AdminSignupResponseDto, + AuthDeviceResponseDto, AuthService, AuthType, AuthUserDto, @@ -7,18 +8,20 @@ import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, LoginCredentialDto, + LoginDetails, LoginResponseDto, LogoutResponseDto, SignUpDto, UserResponseDto, ValidateAccessTokenResponseDto, } from '@app/domain'; -import { Body, Controller, Ip, Post, Req, Res } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Req, Res } from '@nestjs/common'; import { ApiBadRequestResponse, 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'; +import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Authentication') @Controller('auth') @@ -29,11 +32,10 @@ export class AuthController { @Post('login') async login( @Body() loginCredential: LoginCredentialDto, - @Ip() clientIp: string, - @Req() req: Request, @Res({ passthrough: true }) res: Response, + @GetLoginDetails() loginDetails: LoginDetails, ): Promise { - 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 0423234379..0a65e36417 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 0ccde28db5..fd4aefa4c9 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 4ff6c0257d..1b8c7882bd 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 536014021d..57b2bb9408 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 7731d46de9..de75a07eb3 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 e819650103..19b12acca9 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 83602130c3..8229580a62 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 d3aa704ba1..24cc5a995c 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 0000000000..986f743c0b --- /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 1ef1ef8b04..4bbfc7139f 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 9ada897ef9..16816264e6 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 9d4d770de2..4fdb2971d6 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 504d91307c..18c45416bd 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 4001b75700..ff95ebac34 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 d430034f47..061738cd5e 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 30285aa5cb..0b2f86400c 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 cc47da52e2..d728d84f8f 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 7f8e449659..b3b9bdb685 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 3418f2c823..c5abad55ac 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 0000000000..42c34f9399 --- /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 0000000000..bb60e452ef --- /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 6a1802397f..e76c5df13b 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 ee549a5a2d..f0d037cd84 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 e1eb1525a6..b6e3645cec 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 14abb08953..be8877a279 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 0000000000..b9a259cdfd --- /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 0000000000..1752da82e6 --- /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 0c71ea7c27..d148aaa24e 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} + + + +