From 538d5c81ea0f4bc1c58eaa6e1c1bd30893a1e45c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 8 Aug 2025 15:42:38 -0400 Subject: [PATCH] feat: reset oauth ids (#20798) --- i18n/en.json | 4 ++ mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/auth_admin_api.dart | 54 +++++++++++++++ mobile/openapi/lib/model/permission.dart | 3 + open-api/immich-openapi-specs.json | 31 ++++++++- open-api/typescript-sdk/src/fetch-client.ts | 12 +++- .../src/controllers/auth-admin.controller.ts | 18 +++++ server/src/controllers/index.ts | 2 + server/src/enum.ts | 2 + server/src/repositories/user.repository.ts | 4 ++ server/src/services/auth-admin.service.ts | 11 ++++ server/src/services/index.ts | 2 + .../specs/services/auth-admin.service.spec.ts | 66 +++++++++++++++++++ .../settings/auth/auth-settings.svelte | 42 ++++++++++-- 15 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 mobile/openapi/lib/api/auth_admin_api.dart create mode 100644 server/src/controllers/auth-admin.controller.ts create mode 100644 server/src/services/auth-admin.service.ts create mode 100644 server/test/medium/specs/services/auth-admin.service.spec.ts diff --git a/i18n/en.json b/i18n/en.json index bc52dbd85c..2554bb5783 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -355,6 +355,9 @@ "trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them", "trash_settings": "Trash Settings", "trash_settings_description": "Manage trash settings", + "unlink_all_oauth_accounts": "Unlink all OAuth accounts", + "unlink_all_oauth_accounts_description": "Remember to unlink all OAuth accounts before migrating to a new provider.", + "unlink_all_oauth_accounts_prompt": "Are you sure you want to unlink all OAuth accounts? This will reset the OAuth ID for each user and cannot be undone.", "user_cleanup_job": "User cleanup", "user_delete_delay": "{user}'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay_settings": "Delete delay", @@ -921,6 +924,7 @@ "paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation", "profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.", "quota_higher_than_disk_size": "You set a quota higher than the disk size", + "something_went_wrong": "Something went wrong", "unable_to_add_album_users": "Unable to add users to album", "unable_to_add_assets_to_shared_link": "Unable to add assets to shared link", "unable_to_add_comment": "Unable to add comment", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c4349ff657..2397d55c78 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -108,6 +108,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | +*AuthAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | *AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8c1fa1a80a..8ecb9cd5f5 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -34,6 +34,7 @@ part 'api/api_keys_api.dart'; part 'api/activities_api.dart'; part 'api/albums_api.dart'; part 'api/assets_api.dart'; +part 'api/auth_admin_api.dart'; part 'api/authentication_api.dart'; part 'api/deprecated_api.dart'; part 'api/download_api.dart'; diff --git a/mobile/openapi/lib/api/auth_admin_api.dart b/mobile/openapi/lib/api/auth_admin_api.dart new file mode 100644 index 0000000000..d22b449aab --- /dev/null +++ b/mobile/openapi/lib/api/auth_admin_api.dart @@ -0,0 +1,54 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AuthAdminApi { + AuthAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. + /// + /// Note: This method returns the HTTP [Response]. + Future unlinkAllOAuthAccountsAdminWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/auth/unlink-all'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. + Future unlinkAllOAuthAccountsAdmin() async { + final response = await unlinkAllOAuthAccountsAdminWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } +} diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index b0903e8f19..95b9a55fba 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -150,6 +150,7 @@ class Permission { static const adminUserPeriodRead = Permission._(r'adminUser.read'); static const adminUserPeriodUpdate = Permission._(r'adminUser.update'); static const adminUserPeriodDelete = Permission._(r'adminUser.delete'); + static const adminAuthPeriodUnlinkAll = Permission._(r'adminAuth.unlinkAll'); /// List of all possible values in this [enum][Permission]. static const values = [ @@ -280,6 +281,7 @@ class Permission { adminUserPeriodRead, adminUserPeriodUpdate, adminUserPeriodDelete, + adminAuthPeriodUnlinkAll, ]; static Permission? fromJson(dynamic value) => PermissionTypeTransformer().decode(value); @@ -445,6 +447,7 @@ class PermissionTypeTransformer { case r'adminUser.read': return Permission.adminUserPeriodRead; case r'adminUser.update': return Permission.adminUserPeriodUpdate; case r'adminUser.delete': return Permission.adminUserPeriodDelete; + case r'adminAuth.unlinkAll': return Permission.adminAuthPeriodUnlinkAll; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e8b5df9dc1..ad22aa09c8 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -214,6 +214,34 @@ "description": "This endpoint requires the `activity.delete` permission." } }, + "/admin/auth/unlink-all": { + "post": { + "operationId": "unlinkAllOAuthAccountsAdmin", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Auth (admin)" + ], + "x-immich-admin-only": true, + "x-immich-permission": "adminAuth.unlinkAll", + "description": "This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission." + } + }, "/admin/notifications": { "post": { "operationId": "createNotification", @@ -12687,7 +12715,8 @@ "adminUser.create", "adminUser.read", "adminUser.update", - "adminUser.delete" + "adminUser.delete", + "adminAuth.unlinkAll" ], "type": "string" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5011e065eb..ee5e2a769d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1646,6 +1646,15 @@ export function deleteActivity({ id }: { method: "DELETE" })); } +/** + * This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission. + */ +export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/admin/auth/unlink-all", { + ...opts, + method: "POST" + })); +} export function createNotification({ notificationCreateDto }: { notificationCreateDto: NotificationCreateDto; }, opts?: Oazapfts.RequestOpts) { @@ -4669,7 +4678,8 @@ export enum Permission { AdminUserCreate = "adminUser.create", AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", - AdminUserDelete = "adminUser.delete" + AdminUserDelete = "adminUser.delete", + AdminAuthUnlinkAll = "adminAuth.unlinkAll" } export enum AssetMediaStatus { Created = "created", diff --git a/server/src/controllers/auth-admin.controller.ts b/server/src/controllers/auth-admin.controller.ts new file mode 100644 index 0000000000..dba352783e --- /dev/null +++ b/server/src/controllers/auth-admin.controller.ts @@ -0,0 +1,18 @@ +import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { AuthAdminService } from 'src/services/auth-admin.service'; + +@ApiTags('Auth (admin)') +@Controller('admin/auth') +export class AuthAdminController { + constructor(private service: AuthAdminService) {} + @Post('unlink-all') + @Authenticated({ permission: Permission.AdminAuthUnlinkAll, admin: true }) + @HttpCode(HttpStatus.NO_CONTENT) + unlinkAllOAuthAccountsAdmin(@Auth() auth: AuthDto): Promise { + return this.service.unlinkAll(auth); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 9c39e580b6..137abf103c 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -4,6 +4,7 @@ import { APIKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; +import { AuthAdminController } from 'src/controllers/auth-admin.controller'; import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; @@ -40,6 +41,7 @@ export const controllers = [ AssetController, AssetMediaController, AuthController, + AuthAdminController, DownloadController, DuplicateController, FaceController, diff --git a/server/src/enum.ts b/server/src/enum.ts index 8c7ee85a32..02ef222883 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -235,6 +235,8 @@ export enum Permission { AdminUserRead = 'adminUser.read', AdminUserUpdate = 'adminUser.update', AdminUserDelete = 'adminUser.delete', + + AdminAuthUnlinkAll = 'adminAuth.unlinkAll', } export enum SharedLinkType { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 9d5f19b26a..a63a4cc553 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -194,6 +194,10 @@ export class UserRepository { .executeTakeFirstOrThrow(); } + async updateAll(dto: Updateable) { + await this.db.updateTable('user').set(dto).execute(); + } + restore(id: string) { return this.db .updateTable('user') diff --git a/server/src/services/auth-admin.service.ts b/server/src/services/auth-admin.service.ts new file mode 100644 index 0000000000..3648a19957 --- /dev/null +++ b/server/src/services/auth-admin.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { BaseService } from 'src/services/base.service'; + +@Injectable() +export class AuthAdminService extends BaseService { + async unlinkAll(_auth: AuthDto) { + // TODO replace '' with null + await this.userRepository.updateAll({ oauthId: '' }); + } +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 88b68d2c13..cad38ca1f4 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -5,6 +5,7 @@ import { ApiService } from 'src/services/api.service'; import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; +import { AuthAdminService } from 'src/services/auth-admin.service'; import { AuthService } from 'src/services/auth.service'; import { BackupService } from 'src/services/backup.service'; import { CliService } from 'src/services/cli.service'; @@ -49,6 +50,7 @@ export const services = [ AssetService, AuditService, AuthService, + AuthAdminService, BackupService, CliService, DatabaseService, diff --git a/server/test/medium/specs/services/auth-admin.service.spec.ts b/server/test/medium/specs/services/auth-admin.service.spec.ts new file mode 100644 index 0000000000..fa2a69f665 --- /dev/null +++ b/server/test/medium/specs/services/auth-admin.service.spec.ts @@ -0,0 +1,66 @@ +import { Kysely } from 'kysely'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { UserRepository } from 'src/repositories/user.repository'; +import { DB } from 'src/schema'; +import { AuthAdminService } from 'src/services/auth-admin.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(AuthAdminService, { + database: db || defaultDatabase, + real: [UserRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(AuthAdminService.name, () => { + describe('unlinkAll', () => { + it('should reset user.oauthId', async () => { + const { sut, ctx } = setup(); + const userRepo = ctx.get(UserRepository); + const { user } = await ctx.newUser({ oauthId: 'test-oauth-id' }); + const auth = factory.auth(); + + await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); + await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual( + expect.objectContaining({ oauthId: '' }), + ); + }); + + it('should reset a deleted user', async () => { + const { sut, ctx } = setup(); + const userRepo = ctx.get(UserRepository); + const { user } = await ctx.newUser({ oauthId: 'test-oauth-id', deletedAt: new Date() }); + const auth = factory.auth(); + + await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); + await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual( + expect.objectContaining({ oauthId: '' }), + ); + }); + + it('should reset multiple users', async () => { + const { sut, ctx } = setup(); + const userRepo = ctx.get(UserRepository); + const { user: user1 } = await ctx.newUser({ oauthId: '1' }); + const { user: user2 } = await ctx.newUser({ oauthId: '2', deletedAt: new Date() }); + const auth = factory.auth(); + + await expect(sut.unlinkAll(auth)).resolves.toBeUndefined(); + await expect(userRepo.get(user1.id, { withDeleted: true })).resolves.toEqual( + expect.objectContaining({ oauthId: '' }), + ); + await expect(userRepo.get(user2.id, { withDeleted: true })).resolves.toEqual( + expect.objectContaining({ oauthId: '' }), + ); + }); + }); +}); diff --git a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte index ce6dc26171..ef371910c5 100644 --- a/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/auth/auth-settings.svelte @@ -1,5 +1,9 @@
@@ -56,7 +82,7 @@ subtitle={$t('admin.oauth_settings_description')} >