Browse Source

feat(web,server): logout all devices (#2415)

* feat: logout all devices

* chore: regenerate openapi

* chore: add test

* chore: logout vs log out
Jason Rasmussen 2 years ago
parent
commit
a808b9403e

+ 1 - 0
mobile/openapi/README.md

@@ -120,6 +120,7 @@ Class | Method | HTTP request | Description
 *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* | [**logoutAuthDevices**](doc//AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices | 
 *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
 *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | 
 *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | 

+ 51 - 0
mobile/openapi/doc/AuthenticationApi.md

@@ -15,6 +15,7 @@ Method | HTTP request | Description
 [**login**](AuthenticationApi.md#login) | **POST** /auth/login | 
 [**logout**](AuthenticationApi.md#logout) | **POST** /auth/logout | 
 [**logoutAuthDevice**](AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} | 
+[**logoutAuthDevices**](AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices | 
 [**validateAccessToken**](AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | 
 
 
@@ -311,6 +312,56 @@ void (empty response body)
 
 [[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)
 
+# **logoutAuthDevices**
+> logoutAuthDevices()
+
+
+
+### 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 API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').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 {
+    api_instance.logoutAuthDevices();
+} catch (e) {
+    print('Exception when calling AuthenticationApi->logoutAuthDevices: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [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()
 

+ 33 - 0
mobile/openapi/lib/api/authentication_api.dart

@@ -282,6 +282,39 @@ class AuthenticationApi {
     }
   }
 
+  /// Performs an HTTP 'DELETE /auth/devices' operation and returns the [Response].
+  Future<Response> logoutAuthDevicesWithHttpInfo() 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,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<void> logoutAuthDevices() async {
+    final response = await logoutAuthDevicesWithHttpInfo();
+    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

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

@@ -47,6 +47,11 @@ void main() {
       // TODO
     });
 
+    //Future logoutAuthDevices() async
+    test('test logoutAuthDevices', () async {
+      // TODO
+    });
+
     //Future<ValidateAccessTokenResponseDto> validateAccessToken() async
     test('test validateAccessToken', () async {
       // TODO

+ 6 - 0
server/apps/immich/src/controllers/auth.controller.ts

@@ -52,6 +52,12 @@ export class AuthController {
     return this.service.getDevices(authUser);
   }
 
+  @Authenticated()
+  @Delete('devices')
+  logoutAuthDevices(@GetAuthUser() authUser: AuthUserDto): Promise<void> {
+    return this.service.logoutDevices(authUser);
+  }
+
   @Authenticated()
   @Delete('devices/:id')
   logoutAuthDevice(@GetAuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {

+ 23 - 0
server/immich-openapi-specs.json

@@ -393,6 +393,29 @@
             "api_key": []
           }
         ]
+      },
+      "delete": {
+        "operationId": "logoutAuthDevices",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "tags": [
+          "Authentication"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ]
       }
     },
     "/auth/devices/{id}": {

+ 12 - 0
server/libs/domain/src/auth/auth.service.spec.ts

@@ -357,6 +357,18 @@ describe('AuthService', () => {
     });
   });
 
+  describe('logoutDevices', () => {
+    it('should logout all devices', async () => {
+      userTokenMock.getAll.mockResolvedValue([userTokenEntityStub.inactiveToken, userTokenEntityStub.userToken]);
+
+      await sut.logoutDevices(authStub.user1);
+
+      expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
+      expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'not_active');
+      expect(userTokenMock.delete).not.toHaveBeenCalledWith(authStub.user1.id, 'token-id');
+    });
+  });
+
   describe('logoutDevice', () => {
     it('should logout the device', async () => {
       await sut.logoutDevice(authStub.user1, 'token-1');

+ 10 - 0
server/libs/domain/src/auth/auth.service.ts

@@ -163,6 +163,16 @@ export class AuthService {
     await this.userTokenCore.delete(authUser.id, deviceId);
   }
 
+  async logoutDevices(authUser: AuthUserDto): Promise<void> {
+    const devices = await this.userTokenCore.getAll(authUser.id);
+    for (const device of devices) {
+      if (device.id === authUser.accessTokenId) {
+        continue;
+      }
+      await this.userTokenCore.delete(authUser.id, device.id);
+    }
+  }
+
   private getBearerToken(headers: IncomingHttpHeaders): string | null {
     const [type, token] = (headers.authorization || '').split(' ');
     if (type.toLowerCase() === 'bearer') {

+ 65 - 0
web/src/api/open-api/api.ts

@@ -6299,6 +6299,44 @@ 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 {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        logoutAuthDevices: 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: 'DELETE', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // 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};
@@ -6414,6 +6452,15 @@ export const AuthenticationApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.logoutAuthDevice(id, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async logoutAuthDevices(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.logoutAuthDevices(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -6485,6 +6532,14 @@ export const AuthenticationApiFactory = function (configuration?: Configuration,
         logoutAuthDevice(id: string, options?: any): AxiosPromise<void> {
             return localVarFp.logoutAuthDevice(id, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        logoutAuthDevices(options?: any): AxiosPromise<void> {
+            return localVarFp.logoutAuthDevices(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -6567,6 +6622,16 @@ export class AuthenticationApi extends BaseAPI {
         return AuthenticationApiFp(this.configuration).logoutAuthDevice(id, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AuthenticationApi
+     */
+    public logoutAuthDevices(options?: AxiosRequestConfig) {
+        return AuthenticationApiFp(this.configuration).logoutAuthDevices(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {*} [options] Override http request option.

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

@@ -62,7 +62,7 @@
 				<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"
+					title="Log out"
 				>
 					<TrashCanOutline size="16" />
 				</button>

+ 34 - 3
web/src/lib/components/user-settings-page/device-list.svelte

@@ -2,6 +2,7 @@
 	import { api, AuthDeviceResponseDto } from '@api';
 	import { onMount } from 'svelte';
 	import { handleError } from '../../utils/handle-error';
+	import Button from '../elements/buttons/button.svelte';
 	import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
 	import {
 		notificationController,
@@ -11,6 +12,7 @@
 
 	let devices: AuthDeviceResponseDto[] = [];
 	let deleteDevice: AuthDeviceResponseDto | null = null;
+	let deleteAll = false;
 
 	const refresh = () => api.authenticationApi.getAuthDevices().then(({ data }) => (devices = data));
 
@@ -30,22 +32,45 @@
 			await api.authenticationApi.logoutAuthDevice(deleteDevice.id);
 			notificationController.show({ message: `Logged out device`, type: NotificationType.Info });
 		} catch (error) {
-			handleError(error, 'Unable to logout device');
+			handleError(error, 'Unable to log out device');
 		} finally {
 			await refresh();
 			deleteDevice = null;
 		}
 	};
+
+	const handleDeleteAll = async () => {
+		try {
+			await api.authenticationApi.logoutAuthDevices();
+			notificationController.show({
+				message: `Logged out all devices`,
+				type: NotificationType.Info
+			});
+		} catch (error) {
+			handleError(error, 'Unable to log out all devices');
+		} finally {
+			await refresh();
+			deleteAll = false;
+		}
+	};
 </script>
 
 {#if deleteDevice}
 	<ConfirmDialogue
-		prompt="Are you sure you want to logout this device?"
+		prompt="Are you sure you want to log out this device?"
 		on:confirm={() => handleDelete()}
 		on:cancel={() => (deleteDevice = null)}
 	/>
 {/if}
 
+{#if deleteAll}
+	<ConfirmDialogue
+		prompt="Are you sure you want to log out all devices?"
+		on:confirm={() => handleDeleteAll()}
+		on:cancel={() => (deleteAll = false)}
+	/>
+{/if}
+
 <section class="my-4">
 	{#if currentDevice}
 		<div class="mb-6">
@@ -56,7 +81,7 @@
 		</div>
 	{/if}
 	{#if otherDevices.length > 0}
-		<div>
+		<div class="mb-6">
 			<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
 				OTHER DEVICES
 			</h3>
@@ -67,5 +92,11 @@
 				{/if}
 			{/each}
 		</div>
+		<h3 class="font-medium text-xs mb-2 text-immich-primary dark:text-immich-dark-primary">
+			LOG OUT ALL DEVICES
+		</h3>
+		<div class="flex justify-end">
+			<Button color="red" size="sm" on:click={() => (deleteAll = true)}>Log Out All Devices</Button>
+		</div>
 	{/if}
 </section>