Kaynağa Gözat

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 <alex.tran1502@gmail.com>
Jason Rasmussen 2 yıl önce
ebeveyn
işleme
b8313abfa8
41 değiştirilmiş dosya ile 1208 ekleme ve 92 silme
  1. 17 0
      mobile/lib/modules/login/providers/authentication.provider.dart
  2. 3 0
      mobile/openapi/.openapi-generator/FILES
  3. 3 0
      mobile/openapi/README.md
  4. 20 0
      mobile/openapi/doc/AuthDeviceResponseDto.md
  5. 99 0
      mobile/openapi/doc/AuthenticationApi.md
  6. 2 2
      mobile/openapi/doc/LogoutResponseDto.md
  7. 1 0
      mobile/openapi/lib/api.dart
  8. 84 0
      mobile/openapi/lib/api/authentication_api.dart
  9. 2 0
      mobile/openapi/lib/api_client.dart
  10. 151 0
      mobile/openapi/lib/model/auth_device_response_dto.dart
  11. 52 0
      mobile/openapi/test/auth_device_response_dto_test.dart
  12. 10 0
      mobile/openapi/test/authentication_api_test.dart
  13. 19 5
      server/apps/immich/src/controllers/auth.controller.ts
  14. 4 3
      server/apps/immich/src/controllers/oauth.controller.ts
  15. 14 1
      server/apps/immich/src/decorators/auth-user.decorator.ts
  16. 4 0
      server/apps/immich/src/utils/patch-open-api.util.ts
  17. 97 4
      server/immich-openapi-specs.json
  18. 13 6
      server/libs/domain/src/auth/auth.core.ts
  19. 72 11
      server/libs/domain/src/auth/auth.service.spec.ts
  20. 17 6
      server/libs/domain/src/auth/auth.service.ts
  21. 1 0
      server/libs/domain/src/auth/index.ts
  22. 19 0
      server/libs/domain/src/auth/response-dto/auth-device-response.dto.ts
  23. 1 0
      server/libs/domain/src/auth/response-dto/index.ts
  24. 0 9
      server/libs/domain/src/auth/response-dto/logout-response.dto.ts
  25. 0 7
      server/libs/domain/src/auth/response-dto/validate-asset-token-response.dto.ts
  26. 12 5
      server/libs/domain/src/oauth/oauth.service.spec.ts
  27. 6 3
      server/libs/domain/src/oauth/oauth.service.ts
  28. 20 5
      server/libs/domain/src/user-token/user-token.core.ts
  29. 4 2
      server/libs/domain/src/user-token/user-token.repository.ts
  30. 15 2
      server/libs/domain/test/fixtures.ts
  31. 3 1
      server/libs/domain/test/user-token.repository.mock.ts
  32. 11 2
      server/libs/infra/src/entities/user-token.entity.ts
  33. 21 0
      server/libs/infra/src/migrations/1682371561743-FixNullableRelations.ts
  34. 16 0
      server/libs/infra/src/migrations/1682371791038-AddDeviceInfoToUserToken.ts
  35. 27 11
      server/libs/infra/src/repositories/user-token.repository.ts
  36. 39 2
      server/package-lock.json
  37. 7 5
      server/package.json
  38. 174 0
      web/src/api/open-api/api.ts
  39. 72 0
      web/src/lib/components/user-settings-page/device-card.svelte
  40. 71 0
      web/src/lib/components/user-settings-page/device-list.svelte
  41. 5 0
      web/src/lib/components/user-settings-page/user-settings-list.svelte

+ 17 - 0
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<AuthenticationState> {
     }
 
     // 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(

+ 3 - 0
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

+ 3 - 0
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)

+ 20 - 0
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)
+
+

+ 99 - 0
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<AuthDeviceResponseDto> getAuthDevices()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('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>**](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<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('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()
 

+ 2 - 2
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)
 

+ 1 - 0
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';

+ 84 - 0
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<Response> getAuthDevicesWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/auth/devices';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<List<AuthDeviceResponseDto>?> 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<AuthDeviceResponseDto>') as List)
+        .cast<AuthDeviceResponseDto>()
+        .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<Response> 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 = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<void> 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<Response> validateAccessTokenWithHttpInfo() async {
     // ignore: prefer_const_declarations

+ 2 - 0
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':

+ 151 - 0
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<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      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<String, dynamic>();
+
+      // 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<String>(json, r'id')!,
+        createdAt: mapValueOfType<String>(json, r'createdAt')!,
+        updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
+        current: mapValueOfType<bool>(json, r'current')!,
+        deviceType: mapValueOfType<String>(json, r'deviceType')!,
+        deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
+      );
+    }
+    return null;
+  }
+
+  static List<AuthDeviceResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AuthDeviceResponseDto>[];
+    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<String, AuthDeviceResponseDto> mapFromJson(dynamic json) {
+    final map = <String, AuthDeviceResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // 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<String, List<AuthDeviceResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AuthDeviceResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // 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 = <String>{
+    'id',
+    'createdAt',
+    'updatedAt',
+    'current',
+    'deviceType',
+    'deviceOS',
+  };
+}
+

+ 52 - 0
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
+    });
+
+
+  });
+
+}

+ 10 - 0
mobile/openapi/test/authentication_api_test.dart

@@ -27,6 +27,11 @@ void main() {
       // TODO
     });
 
+    //Future<List<AuthDeviceResponseDto>> getAuthDevices() async
+    test('test getAuthDevices', () async {
+      // TODO
+    });
+
     //Future<LoginResponseDto> 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<ValidateAccessTokenResponseDto> validateAccessToken() async
     test('test validateAccessToken', () async {
       // TODO

+ 19 - 5
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<LoginResponseDto> {
-    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<AuthDeviceResponseDto[]> {
+    return this.service.getDevices(authUser);
+  }
+
+  @Authenticated()
+  @Delete('devices/:id')
+  logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
+    return this.service.logoutDevice(authUser, id);
+  }
+
   @Authenticated()
   @Post('validateToken')
   validateAccessToken(): ValidateAccessTokenResponseDto {

+ 4 - 3
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<LoginResponseDto> {
-    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;
   }

+ 14 - 1
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 || '',
+  };
+});

+ 4 - 0
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;
+      }
     }
   }
 

+ 97 - 4
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": [

+ 13 - 6
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 { IUserTokenRepository, UserTokenCore } from '../user-token';
 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';
+
+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 };
   }
 

+ 72 - 11
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<ICryptoRepository>;
@@ -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');
+    });
+  });
 });

+ 17 - 6
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<LogoutResponseDto> {
     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<AuthDeviceResponseDto[]> {
+    const userTokens = await this.userTokenCore.getAll(authUser.id);
+    return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
+  }
+
+  async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
+    await this.userTokenCore.delete(authUser.id, deviceId);
+  }
+
   private getBearerToken(headers: IncomingHttpHeaders): string | null {
     const [type, token] = (headers.authorization || '').split(' ');
     if (type.toLowerCase() === 'bearer') {

+ 1 - 0
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';

+ 19 - 0
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,
+});

+ 1 - 0
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';

+ 0 - 9
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;
 }

+ 0 - 7
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;
 }

+ 12 - 5
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' });
     });

+ 6 - 3
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<UserResponseDto> {

+ 20 - 5
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<string> {
+  async create(user: UserEntity, loginDetails: LoginDetails): Promise<string> {
     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<void> {
-    await this.repository.delete(id);
+  async delete(userId: string, id: string): Promise<void> {
+    await this.repository.delete(userId, id);
+  }
+
+  getAll(userId: string): Promise<UserTokenEntity[]> {
+    return this.repository.getAll(userId);
   }
 }

+ 4 - 2
server/libs/domain/src/user-token/user-token.repository.ts

@@ -4,7 +4,9 @@ export const IUserTokenRepository = 'IUserTokenRepository';
 
 export interface IUserTokenRepository {
   create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
-  delete(userToken: string): Promise<void>;
+  save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
+  delete(userId: string, id: string): Promise<void>;
   deleteAll(userId: string): Promise<void>;
-  get(userToken: string): Promise<UserTokenEntity | null>;
+  getByToken(token: string): Promise<UserTokenEntity | null>;
+  getAll(userId: string): Promise<UserTokenEntity[]>;
 }

+ 15 - 2
server/libs/domain/test/fixtures.ts

@@ -391,9 +391,22 @@ export const userTokenEntityStub = {
   userToken: Object.freeze<UserTokenEntity>({
     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<UserTokenEntity>({
+    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',
   }),
 };
 

+ 3 - 1
server/libs/domain/test/user-token.repository.mock.ts

@@ -3,8 +3,10 @@ import { IUserTokenRepository } from '../src';
 export const newUserTokenRepositoryMock = (): jest.Mocked<IUserTokenRepository> => {
   return {
     create: jest.fn(),
+    save: jest.fn(),
     delete: jest.fn(),
     deleteAll: jest.fn(),
-    get: jest.fn(),
+    getByToken: jest.fn(),
+    getAll: jest.fn(),
   };
 };

+ 11 - 2
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;
 }

+ 21 - 0
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<void> {
+    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<void> {
+    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`,
+    );
+  }
+}

+ 16 - 0
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<void> {
+        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<void> {
+        await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceOS"`);
+        await queryRunner.query(`ALTER TABLE "user_token" DROP COLUMN "deviceType"`);
+    }
+
+}

+ 27 - 11
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<UserTokenEntity>,
-  ) {}
+  constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
 
-  async get(userToken: string): Promise<UserTokenEntity | null> {
-    return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } });
+  getByToken(token: string): Promise<UserTokenEntity | null> {
+    return this.repository.findOne({ where: { token }, relations: { user: true } });
   }
 
-  async create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
-    return this.userTokenRepository.save(userToken);
+  getAll(userId: string): Promise<UserTokenEntity[]> {
+    return this.repository.find({
+      where: {
+        userId,
+      },
+      relations: {
+        user: true,
+      },
+      order: {
+        updatedAt: 'desc',
+        createdAt: 'desc',
+      },
+    });
   }
 
-  async delete(id: string): Promise<void> {
-    await this.userTokenRepository.delete(id);
+  create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
+    return this.repository.save(userToken);
+  }
+
+  save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
+    return this.repository.save(userToken);
+  }
+
+  async delete(userId: string, id: string): Promise<void> {
+    await this.repository.delete({ userId, id });
   }
 
   async deleteAll(userId: string): Promise<void> {
-    await this.userTokenRepository.delete({ user: { id: userId } });
+    await this.repository.delete({ userId });
   }
 }

+ 39 - 2
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",

+ 7 - 5
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": "<rootDir>/libs/domain/test/global-setup.js"
   }
-}
+}

+ 174 - 0
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<RequestArgs> => {
+            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<RequestArgs> => {
+            // 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<Array<AuthDeviceResponseDto>>> {
+            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<void>> {
+            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<UserResponseDto> {
             return localVarFp.changePassword(changePasswordDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getAuthDevices(options?: any): AxiosPromise<Array<AuthDeviceResponseDto>> {
+            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<LogoutResponseDto> {
             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<void> {
+            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.

+ 72 - 0
web/src/lib/components/user-settings-page/device-card.svelte

@@ -0,0 +1,72 @@
+<script lang="ts">
+	import { locale } from '$lib/stores/preferences.store';
+	import { AuthDeviceResponseDto } from '@api';
+	import { DateTime, ToRelativeCalendarOptions } from 'luxon';
+	import { createEventDispatcher } from 'svelte';
+	import Android from 'svelte-material-icons/Android.svelte';
+	import Apple from 'svelte-material-icons/Apple.svelte';
+	import AppleSafari from 'svelte-material-icons/AppleSafari.svelte';
+	import GoogleChrome from 'svelte-material-icons/GoogleChrome.svelte';
+	import Help from 'svelte-material-icons/Help.svelte';
+	import Linux from 'svelte-material-icons/Linux.svelte';
+	import MicrosoftWindows from 'svelte-material-icons/MicrosoftWindows.svelte';
+	import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
+
+	export let device: AuthDeviceResponseDto;
+
+	const dispatcher = createEventDispatcher();
+
+	const options: ToRelativeCalendarOptions = {
+		unit: 'days',
+		locale: $locale
+	};
+</script>
+
+<div class="flex flex-row w-full">
+	<!-- TODO: Device Image -->
+	<div
+		class="hidden sm:flex pr-2 justify-center items-center text-immich-primary dark:text-immich-dark-primary"
+	>
+		{#if device.deviceOS === 'Android'}
+			<Android size="40" />
+		{:else if device.deviceOS === 'iOS' || device.deviceOS === 'Mac OS'}
+			<Apple size="40" />
+		{:else if device.deviceOS.indexOf('Safari') !== -1}
+			<AppleSafari size="40" />
+		{:else if device.deviceOS.indexOf('Windows') !== -1}
+			<MicrosoftWindows size="40" />
+		{:else if device.deviceOS === 'Linux'}
+			<Linux size="40" />
+		{:else if device.deviceOS === 'Chromium OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium'}
+			<GoogleChrome size="40" />
+		{:else}
+			<Help size="40" />
+		{/if}
+	</div>
+	<div class="pl-4 sm:pl-0 flex flex-row grow justify-between gap-1">
+		<div class="flex flex-col gap-1 justify-center dark:text-white">
+			<span class="text-sm">
+				{#if device.deviceType || device.deviceOS}
+					<span>{device.deviceOS || 'Unknown'} • {device.deviceType || 'Unknown'}</span>
+				{:else}
+					<span>Unknown</span>
+				{/if}
+			</span>
+			<div class="text-sm">
+				<span class="">Last seen</span>
+				<span>{DateTime.fromISO(device.updatedAt).toRelativeCalendar(options)}</span>
+			</div>
+		</div>
+		{#if !device.current}
+			<div class="text-sm flex flex-col justify-center">
+				<button
+					on:click={() => dispatcher('delete')}
+					class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700  rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
+					title="Logout"
+				>
+					<TrashCanOutline size="16" />
+				</button>
+			</div>
+		{/if}
+	</div>
+</div>

+ 71 - 0
web/src/lib/components/user-settings-page/device-list.svelte

@@ -0,0 +1,71 @@
+<script lang="ts">
+	import { api, AuthDeviceResponseDto } from '@api';
+	import { onMount } from 'svelte';
+	import { handleError } from '../../utils/handle-error';
+	import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
+	import {
+		notificationController,
+		NotificationType
+	} from '../shared-components/notification/notification';
+	import DeviceCard from './device-card.svelte';
+
+	let devices: AuthDeviceResponseDto[] = [];
+	let deleteDevice: AuthDeviceResponseDto | null = null;
+
+	const refresh = () => api.authenticationApi.getAuthDevices().then(({ data }) => (devices = data));
+
+	onMount(() => {
+		refresh();
+	});
+
+	$: currentDevice = devices.find((device) => device.current);
+	$: otherDevices = devices.filter((device) => !device.current);
+
+	const handleDelete = async () => {
+		if (!deleteDevice) {
+			return;
+		}
+
+		try {
+			await api.authenticationApi.logoutAuthDevice(deleteDevice.id);
+			notificationController.show({ message: `Logged out device`, type: NotificationType.Info });
+		} catch (error) {
+			handleError(error, 'Unable to logout device');
+		} finally {
+			await refresh();
+			deleteDevice = null;
+		}
+	};
+</script>
+
+{#if deleteDevice}
+	<ConfirmDialogue
+		prompt="Are you sure you want to logout this device?"
+		on:confirm={() => handleDelete()}
+		on:cancel={() => (deleteDevice = null)}
+	/>
+{/if}
+
+<section class="my-4">
+	{#if currentDevice}
+		<div class="mb-6">
+			<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
+				CURRENT DEVICE
+			</h3>
+			<DeviceCard device={currentDevice} />
+		</div>
+	{/if}
+	{#if otherDevices.length > 0}
+		<div>
+			<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
+				OTHER DEVICES
+			</h3>
+			{#each otherDevices as device, i}
+				<DeviceCard {device} on:delete={() => (deleteDevice = device)} />
+				{#if i !== otherDevices.length - 1}
+					<hr class="my-3" />
+				{/if}
+			{/each}
+		</div>
+	{/if}
+</section>

+ 5 - 0
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 @@
 		<OAuthSettings {user} />
 	</SettingAccordion>
 {/if}
+
+<SettingAccordion title="Authorized Devices" subtitle="View and manage your logged-in devices">
+	<DeviceList />
+</SettingAccordion>