diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index b14aedf89..27e609120 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -119,5 +119,6 @@ export const deviceDto = { isPendingSyncReset: false, deviceOS: '', deviceType: '', + appVersion: null, }, }; diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 698b9774d..7ee04c07b 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api/users_admin_api.dart b/mobile/openapi/lib/api/users_admin_api.dart index e4fc1673e..4a4301ff4 100644 Binary files a/mobile/openapi/lib/api/users_admin_api.dart and b/mobile/openapi/lib/api/users_admin_api.dart differ diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 95b9a55fb..86011eb83 100644 Binary files a/mobile/openapi/lib/model/permission.dart and b/mobile/openapi/lib/model/permission.dart differ diff --git a/mobile/openapi/lib/model/session_create_response_dto.dart b/mobile/openapi/lib/model/session_create_response_dto.dart index a4f93e8d9..e16597f3b 100644 Binary files a/mobile/openapi/lib/model/session_create_response_dto.dart and b/mobile/openapi/lib/model/session_create_response_dto.dart differ diff --git a/mobile/openapi/lib/model/session_response_dto.dart b/mobile/openapi/lib/model/session_response_dto.dart index e76e4d48b..85acb8a35 100644 Binary files a/mobile/openapi/lib/model/session_response_dto.dart and b/mobile/openapi/lib/model/session_response_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index fdfc40eb6..3b258d505 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -773,6 +773,54 @@ "description": "This endpoint is an admin-only route, and requires the `adminUser.delete` permission." } }, + "/admin/users/{id}/sessions": { + "get": { + "operationId": "getUserSessionsAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SessionResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users (admin)" + ], + "x-immich-admin-only": true, + "x-immich-permission": "adminSession.read", + "description": "This endpoint is an admin-only route, and requires the `adminSession.read` permission." + } + }, "/admin/users/{id}/statistics": { "get": { "operationId": "getUserStatisticsAdmin", @@ -13267,6 +13315,7 @@ "adminUser.read", "adminUser.update", "adminUser.delete", + "adminSession.read", "adminAuth.unlinkAll" ], "type": "string" @@ -14303,6 +14352,10 @@ }, "SessionCreateResponseDto": { "properties": { + "appVersion": { + "nullable": true, + "type": "string" + }, "createdAt": { "type": "string" }, @@ -14332,6 +14385,7 @@ } }, "required": [ + "appVersion", "createdAt", "current", "deviceOS", @@ -14345,6 +14399,10 @@ }, "SessionResponseDto": { "properties": { + "appVersion": { + "nullable": true, + "type": "string" + }, "createdAt": { "type": "string" }, @@ -14371,6 +14429,7 @@ } }, "required": [ + "appVersion", "createdAt", "current", "deviceOS", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5c952c30a..cdd004770 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -244,6 +244,17 @@ export type UserPreferencesUpdateDto = { sharedLinks?: SharedLinksUpdate; tags?: TagsUpdate; }; +export type SessionResponseDto = { + appVersion: string | null; + createdAt: string; + current: boolean; + deviceOS: string; + deviceType: string; + expiresAt?: string; + id: string; + isPendingSyncReset: boolean; + updatedAt: string; +}; export type AssetStatsResponseDto = { images: number; total: number; @@ -1192,16 +1203,6 @@ export type ServerVersionHistoryResponseDto = { id: string; version: string; }; -export type SessionResponseDto = { - createdAt: string; - current: boolean; - deviceOS: string; - deviceType: string; - expiresAt?: string; - id: string; - isPendingSyncReset: boolean; - updatedAt: string; -}; export type SessionCreateDto = { deviceOS?: string; deviceType?: string; @@ -1209,6 +1210,7 @@ export type SessionCreateDto = { duration?: number; }; export type SessionCreateResponseDto = { + appVersion: string | null; createdAt: string; current: boolean; deviceOS: string; @@ -1853,6 +1855,19 @@ export function restoreUserAdmin({ id }: { method: "POST" })); } +/** + * This endpoint is an admin-only route, and requires the `adminSession.read` permission. + */ +export function getUserSessionsAdmin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SessionResponseDto[]; + }>(`/admin/users/${encodeURIComponent(id)}/sessions`, { + ...opts + })); +} /** * This endpoint is an admin-only route, and requires the `adminUser.read` permission. */ @@ -4830,6 +4845,7 @@ export enum Permission { AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", AdminUserDelete = "adminUser.delete", + AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } export enum AssetMetadataKey { diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index d50bd174a..25a4691b7 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, import { ApiTags } from '@nestjs/swagger'; import { AssetStatsDto, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionResponseDto } from 'src/dtos/session.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserAdminCreateDto, @@ -58,6 +59,12 @@ export class UserAdminController { return this.service.delete(auth, id, dto); } + @Get(':id/sessions') + @Authenticated({ permission: Permission.AdminSessionRead, admin: true }) + getUserSessionsAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getSessions(auth, id); + } + @Get(':id/statistics') @Authenticated({ permission: Permission.AdminUserRead, admin: true }) getUserStatisticsAdmin( diff --git a/server/src/database.ts b/server/src/database.ts index f472c643e..f60c2c228 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -238,6 +238,7 @@ export type Session = { expiresAt: Date | null; deviceOS: string; deviceType: string; + appVersion: string | null; pinExpiresAt: Date | null; isPendingSyncReset: boolean; }; @@ -308,7 +309,7 @@ export const columns = { assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'], authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'], authApiKey: ['api_key.id', 'api_key.permissions'], - authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt'], + authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'], authSharedLink: [ 'shared_link.id', 'shared_link.userId', diff --git a/server/src/dtos/session.dto.ts b/server/src/dtos/session.dto.ts index 7ccc72a5f..49351eda5 100644 --- a/server/src/dtos/session.dto.ts +++ b/server/src/dtos/session.dto.ts @@ -34,6 +34,7 @@ export class SessionResponseDto { current!: boolean; deviceType!: string; deviceOS!: string; + appVersion!: string | null; isPendingSyncReset!: boolean; } @@ -47,6 +48,7 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse updatedAt: entity.updatedAt.toISOString(), expiresAt: entity.expiresAt?.toISOString(), current: currentId === entity.id, + appVersion: entity.appVersion, deviceOS: entity.deviceOS, deviceType: entity.deviceType, isPendingSyncReset: entity.isPendingSyncReset, diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 443178aa1..c5067f3e8 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -173,6 +173,7 @@ export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { const license = metadata.find( (item): item is UserMetadataItem => item.key === UserMetadataKey.License, )?.value; + return { ...mapUser(entity), storageLabel: entity.storageLabel, diff --git a/server/src/enum.ts b/server/src/enum.ts index b8e6e5209..c056091f2 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -236,6 +236,8 @@ export enum Permission { AdminUserUpdate = 'adminUser.update', AdminUserDelete = 'adminUser.delete', + AdminSessionRead = 'adminSession.read', + AdminAuthUnlinkAll = 'adminAuth.unlinkAll', } diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 8af7bf7fb..4964fefbb 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -13,7 +13,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { ApiCustomExtension, ImmichQuery, MetadataKey, Permission } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { AuthService, LoginDetails } from 'src/services/auth.service'; -import { UAParser } from 'ua-parser-js'; +import { getUserAgentDetails } from 'src/utils/request'; type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; @@ -56,13 +56,14 @@ export const FileResponse = () => export const GetLoginDetails = createParamDecorator((data, context: ExecutionContext): LoginDetails => { const request = context.switchToHttp().getRequest(); - const userAgent = UAParser(request.headers['user-agent']); + const { deviceType, deviceOS, appVersion } = getUserAgentDetails(request.headers); return { clientIp: request.ip ?? '', isSecure: request.secure, - deviceType: userAgent.browser.name || userAgent.device.type || (request.headers.devicemodel as string) || '', - deviceOS: userAgent.os.name || (request.headers.devicetype as string) || '', + deviceType, + deviceOS, + appVersion, }; }); @@ -86,7 +87,6 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const targets = [context.getHandler()]; - const options = this.reflector.getAllAndOverride(MetadataKey.AuthRoute, targets); if (!options) { return true; diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index 34d25cce8..831a16342 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -23,6 +23,7 @@ select "session"."id", "session"."updatedAt", "session"."pinExpiresAt", + "session"."appVersion", ( select to_json(obj) diff --git a/server/src/schema/migrations/1761078763279-AddAppVersionColumnToSession.ts b/server/src/schema/migrations/1761078763279-AddAppVersionColumnToSession.ts new file mode 100644 index 000000000..817578851 --- /dev/null +++ b/server/src/schema/migrations/1761078763279-AddAppVersionColumnToSession.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "session" ADD "appVersion" character varying;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "session" DROP COLUMN "appVersion";`.execute(db); +} diff --git a/server/src/schema/tables/session.table.ts b/server/src/schema/tables/session.table.ts index 706abdf88..466152d35 100644 --- a/server/src/schema/tables/session.table.ts +++ b/server/src/schema/tables/session.table.ts @@ -42,6 +42,9 @@ export class SessionTable { @Column({ default: '' }) deviceOS!: Generated; + @Column({ nullable: true }) + appVersion!: string | null; + @UpdateIdColumn({ index: true }) updateId!: Generated; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index d2b287cd5..d8d759859 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -41,6 +41,7 @@ const loginDetails = { clientIp: '127.0.0.1', deviceOS: '', deviceType: '', + appVersion: null, }; const fixtures = { @@ -243,6 +244,7 @@ describe(AuthService.name, () => { updatedAt: session.updatedAt, user: factory.authUser(), pinExpiresAt: null, + appVersion: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -408,6 +410,7 @@ describe(AuthService.name, () => { updatedAt: session.updatedAt, user: factory.authUser(), pinExpiresAt: null, + appVersion: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -435,6 +438,7 @@ describe(AuthService.name, () => { user: factory.authUser(), isPendingSyncReset: false, pinExpiresAt: null, + appVersion: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); @@ -456,6 +460,7 @@ describe(AuthService.name, () => { user: factory.authUser(), isPendingSyncReset: false, pinExpiresAt: null, + appVersion: null, }; mocks.session.getByToken.mockResolvedValue(sessionWithToken); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 535df779c..d118f1809 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -29,11 +29,13 @@ import { BaseService } from 'src/services/base.service'; import { isGranted } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; +import { getUserAgentDetails } from 'src/utils/request'; export interface LoginDetails { isSecure: boolean; clientIp: string; deviceType: string; deviceOS: string; + appVersion: string | null; } interface ClaimOptions { @@ -218,7 +220,7 @@ export class AuthService extends BaseService { } if (session) { - return this.validateSession(session); + return this.validateSession(session, headers); } if (apiKey) { @@ -463,15 +465,22 @@ export class AuthService extends BaseService { return this.cryptoRepository.compareBcrypt(inputSecret, existingHash); } - private async validateSession(tokenValue: string): Promise { + private async validateSession(tokenValue: string, headers: IncomingHttpHeaders): Promise { const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const session = await this.sessionRepository.getByToken(hashedToken); if (session?.user) { + const { appVersion, deviceOS, deviceType } = getUserAgentDetails(headers); const now = DateTime.now(); const updatedAt = DateTime.fromJSDate(session.updatedAt); const diff = now.diff(updatedAt, ['hours']); - if (diff.hours > 1) { - await this.sessionRepository.update(session.id, { id: session.id, updatedAt: new Date() }); + if (diff.hours > 1 || appVersion != session.appVersion) { + await this.sessionRepository.update(session.id, { + id: session.id, + updatedAt: new Date(), + appVersion, + deviceOS, + deviceType, + }); } // Pin check @@ -529,6 +538,7 @@ export class AuthService extends BaseService { token: tokenHashed, deviceOS: loginDetails.deviceOS, deviceType: loginDetails.deviceType, + appVersion: loginDetails.appVersion, userId: user.id, }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index a57072e49..2684dca0c 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com import { SALT_ROUNDS } from 'src/constants'; import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { UserAdminCreateDto, @@ -119,6 +120,11 @@ export class UserAdminService extends BaseService { return mapUserAdmin(user); } + async getSessions(auth: AuthDto, id: string): Promise { + const sessions = await this.sessionRepository.getByUserId(id); + return sessions.map((session) => mapSession(session)); + } + async getStatistics(auth: AuthDto, id: string, dto: AssetStatsDto): Promise { const stats = await this.assetRepository.getStatistics(id, dto); return mapStats(stats); diff --git a/server/src/utils/request.ts b/server/src/utils/request.ts index 19d3cac66..c64c98052 100644 --- a/server/src/utils/request.ts +++ b/server/src/utils/request.ts @@ -1,5 +1,22 @@ +import { IncomingHttpHeaders } from 'node:http'; +import { UAParser } from 'ua-parser-js'; + export const fromChecksum = (checksum: string): Buffer => { return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex'); }; export const fromMaybeArray = (param: T | T[]) => (Array.isArray(param) ? param[0] : param); + +const getAppVersionFromUA = (ua: string) => + ua.match(/^Immich_(?:Android|iOS)_(?.+)$/)?.groups?.appVersion ?? null; + +export const getUserAgentDetails = (headers: IncomingHttpHeaders) => { + const userAgent = UAParser(headers['user-agent']); + const appVersion = getAppVersionFromUA(headers['user-agent'] ?? ''); + + return { + deviceType: userAgent.browser.name || userAgent.device.type || (headers['devicemodel'] as string) || '', + deviceOS: userAgent.os.name || (headers['devicetype'] as string) || '', + appVersion, + }; +}; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 3f021f3eb..a8d3f9df7 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -628,7 +628,7 @@ const syncStream = () => { }; const loginDetails = () => { - return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '' }; + return { isSecure: false, clientIp: '', deviceType: '', deviceOS: '', appVersion: null }; }; const loginResponse = (): LoginResponseDto => { diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 04654552a..09e7988f8 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -135,6 +135,7 @@ const sessionFactory = (session: Partial = {}) => ({ userId: newUuid(), pinExpiresAt: newDate(), isPendingSyncReset: false, + appVersion: session.appVersion ?? null, ...session, }); diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 15d9ede21..f3156b7e7 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -18,11 +18,11 @@ import { t } from 'svelte-i18n'; interface Props { - device: SessionResponseDto; + session: SessionResponseDto; onDelete?: (() => void) | undefined; } - let { device, onDelete = undefined }: Props = $props(); + const { session, onDelete = undefined }: Props = $props(); const options: ToRelativeCalendarOptions = { unit: 'days', @@ -32,21 +32,21 @@