mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: session permissions
This commit is contained in:
@@ -203,6 +203,7 @@ export type Album = Selectable<AlbumTable> & {
|
||||
export type AuthSession = {
|
||||
id: string;
|
||||
hasElevatedPermission: boolean;
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
export type Partner = {
|
||||
@@ -240,6 +241,7 @@ export type Session = {
|
||||
deviceType: string;
|
||||
pinExpiresAt: Date | null;
|
||||
isPendingSyncReset: boolean;
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId'>;
|
||||
@@ -308,7 +310,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.permissions'],
|
||||
authSharedLink: [
|
||||
'shared_link.id',
|
||||
'shared_link.userId',
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||
import { ImmichCookie, UserMetadataKey } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { Optional, PinCode, toEmail } from 'src/validation';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser } from 'src/database';
|
||||
import { ImmichCookie, Permission } from 'src/enum';
|
||||
import { Optional, PinCode, toEmail, ValidateEnum } from 'src/validation';
|
||||
|
||||
export type CookieResponse = {
|
||||
isSecure: boolean;
|
||||
@@ -41,23 +40,8 @@ export class LoginResponseDto {
|
||||
isAdmin!: boolean;
|
||||
shouldChangePassword!: boolean;
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto {
|
||||
const onboardingMetadata = entity.metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.Onboarding> => item.key === UserMetadataKey.Onboarding,
|
||||
)?.value;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
userId: entity.id,
|
||||
userEmail: entity.email,
|
||||
name: entity.name,
|
||||
isAdmin: entity.isAdmin,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isOnboarded: onboardingMetadata?.isOnboarded ?? false,
|
||||
};
|
||||
@ValidateEnum({ enum: Permission, name: 'Permission', each: true })
|
||||
permissions!: Permission[];
|
||||
}
|
||||
|
||||
export class LogoutResponseDto {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Equals, IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { Session } from 'src/database';
|
||||
import { Optional, ValidateBoolean } from 'src/validation';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
|
||||
|
||||
export class SessionCreateDto {
|
||||
/**
|
||||
@@ -35,6 +36,8 @@ export class SessionResponseDto {
|
||||
deviceType!: string;
|
||||
deviceOS!: string;
|
||||
isPendingSyncReset!: boolean;
|
||||
@ValidateEnum({ enum: Permission, name: 'Permission', each: true })
|
||||
permissions!: Permission[];
|
||||
}
|
||||
|
||||
export class SessionCreateResponseDto extends SessionResponseDto {
|
||||
@@ -50,4 +53,5 @@ export const mapSession = (entity: Session, currentId?: string): SessionResponse
|
||||
deviceOS: entity.deviceOS,
|
||||
deviceType: entity.deviceType,
|
||||
isPendingSyncReset: entity.isPendingSyncReset,
|
||||
permissions: entity.permissions,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
select
|
||||
"id",
|
||||
"expiresAt",
|
||||
"pinExpiresAt"
|
||||
"pinExpiresAt",
|
||||
"permissions"
|
||||
from
|
||||
"session"
|
||||
where
|
||||
@@ -23,6 +24,7 @@ select
|
||||
"session"."id",
|
||||
"session"."updatedAt",
|
||||
"session"."pinExpiresAt",
|
||||
"session"."permissions",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
|
||||
@@ -32,7 +32,7 @@ export class SessionRepository {
|
||||
get(id: string) {
|
||||
return this.db
|
||||
.selectFrom('session')
|
||||
.select(['id', 'expiresAt', 'pinExpiresAt'])
|
||||
.select(['id', 'expiresAt', 'pinExpiresAt', 'permissions'])
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" ADD "permissions" character varying[];`.execute(db);
|
||||
await sql`UPDATE "session" SET "permissions" = ARRAY['all'];`.execute(db);
|
||||
await sql`ALTER TABLE "session" ALTER COLUMN "permissions" SET NOT NULL`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" DROP COLUMN "permissions";`.execute(db);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { Permission } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
@@ -50,4 +51,7 @@ export class SessionTable {
|
||||
|
||||
@Column({ type: 'timestamp with time zone', nullable: true })
|
||||
pinExpiresAt!: Timestamp | null;
|
||||
|
||||
@Column({ array: true, type: 'character varying' })
|
||||
permissions!: Permission[];
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ const oauthResponse = ({
|
||||
isAdmin: false,
|
||||
isOnboarded: false,
|
||||
shouldChangePassword: false,
|
||||
permissions: [Permission.All],
|
||||
});
|
||||
|
||||
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
|
||||
@@ -104,6 +105,7 @@ describe(AuthService.name, () => {
|
||||
isAdmin: user.isAdmin,
|
||||
isOnboarded: false,
|
||||
shouldChangePassword: user.shouldChangePassword,
|
||||
permissions: [Permission.All],
|
||||
});
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AuthStatusResponseDto,
|
||||
ChangePasswordDto,
|
||||
LoginCredentialDto,
|
||||
LoginResponseDto,
|
||||
LogoutResponseDto,
|
||||
OAuthCallbackDto,
|
||||
OAuthConfigDto,
|
||||
@@ -20,12 +21,21 @@ import {
|
||||
PinCodeSetupDto,
|
||||
SessionUnlockDto,
|
||||
SignUpDto,
|
||||
mapLoginResponse,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission, StorageFolder } from 'src/enum';
|
||||
import {
|
||||
AuthType,
|
||||
ImmichCookie,
|
||||
ImmichHeader,
|
||||
ImmichQuery,
|
||||
JobName,
|
||||
Permission,
|
||||
StorageFolder,
|
||||
UserMetadataKey,
|
||||
} from 'src/enum';
|
||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { isGranted } from 'src/utils/access';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -75,7 +85,7 @@ export class AuthService extends BaseService {
|
||||
throw new UnauthorizedException('Incorrect email or password');
|
||||
}
|
||||
|
||||
return this.createLoginResponse(user, details);
|
||||
return this.createLoginResponse(user, details, [Permission.All]);
|
||||
}
|
||||
|
||||
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||
@@ -177,6 +187,7 @@ export class AuthService extends BaseService {
|
||||
const authDto = await this.validate({ headers, queryParams });
|
||||
const { adminRoute, sharedLinkRoute, uri } = metadata;
|
||||
const requestedPermission = metadata.permission ?? Permission.All;
|
||||
const currentPermissions = authDto.apiKey?.permissions || authDto.session?.permissions;
|
||||
|
||||
if (!authDto.user.isAdmin && adminRoute) {
|
||||
this.logger.warn(`Denied access to admin only route: ${uri}`);
|
||||
@@ -189,9 +200,9 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
if (
|
||||
authDto.apiKey &&
|
||||
requestedPermission !== false &&
|
||||
!isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions })
|
||||
currentPermissions &&
|
||||
!isGranted({ requested: [requestedPermission], current: currentPermissions })
|
||||
) {
|
||||
throw new ForbiddenException(`Missing required permission: ${requestedPermission}`);
|
||||
}
|
||||
@@ -322,7 +333,7 @@ export class AuthService extends BaseService {
|
||||
await this.syncProfilePicture(user, profile.picture);
|
||||
}
|
||||
|
||||
return this.createLoginResponse(user, loginDetails);
|
||||
return this.createLoginResponse(user, loginDetails, [Permission.All]);
|
||||
}
|
||||
|
||||
private async syncProfilePicture(user: UserAdmin, url: string) {
|
||||
@@ -492,6 +503,7 @@ export class AuthService extends BaseService {
|
||||
user: session.user,
|
||||
session: {
|
||||
id: session.id,
|
||||
permissions: session.permissions,
|
||||
hasElevatedPermission,
|
||||
},
|
||||
};
|
||||
@@ -521,18 +533,31 @@ export class AuthService extends BaseService {
|
||||
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
|
||||
}
|
||||
|
||||
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
|
||||
private async createLoginResponse(
|
||||
user: UserAdmin,
|
||||
{ deviceOS, deviceType }: LoginDetails,
|
||||
permissions: Permission[],
|
||||
): Promise<LoginResponseDto> {
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const tokenHashed = this.cryptoRepository.hashSha256(token);
|
||||
|
||||
await this.sessionRepository.create({
|
||||
token: tokenHashed,
|
||||
deviceOS: loginDetails.deviceOS,
|
||||
deviceType: loginDetails.deviceType,
|
||||
userId: user.id,
|
||||
});
|
||||
await this.sessionRepository.create({ token: tokenHashed, deviceOS, deviceType, userId: user.id, permissions });
|
||||
|
||||
return mapLoginResponse(user, token);
|
||||
const onboardingMetadata = user.metadata.find(
|
||||
(item): item is UserMetadataItem<UserMetadataKey.Onboarding> => item.key === UserMetadataKey.Onboarding,
|
||||
)?.value;
|
||||
|
||||
return {
|
||||
accessToken: token,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
name: user.name,
|
||||
isAdmin: user.isAdmin,
|
||||
profileImagePath: user.profileImagePath,
|
||||
shouldChangePassword: user.shouldChangePassword,
|
||||
isOnboarded: onboardingMetadata?.isOnboarded ?? false,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {
|
||||
|
||||
@@ -31,15 +31,21 @@ export class SessionService extends BaseService {
|
||||
throw new BadRequestException('This endpoint can only be used with a session token');
|
||||
}
|
||||
|
||||
const parent = await this.sessionRepository.get(auth.session.id);
|
||||
if (!parent) {
|
||||
throw new BadRequestException('Session not found');
|
||||
}
|
||||
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const tokenHashed = this.cryptoRepository.hashSha256(token);
|
||||
const session = await this.sessionRepository.create({
|
||||
parentId: auth.session.id,
|
||||
parentId: parent.id,
|
||||
userId: auth.user.id,
|
||||
expiresAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
|
||||
deviceType: dto.deviceType,
|
||||
deviceOS: dto.deviceOS,
|
||||
token: tokenHashed,
|
||||
permissions: parent.permissions,
|
||||
});
|
||||
|
||||
return { ...mapSession(session), token };
|
||||
|
||||
@@ -135,6 +135,7 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
|
||||
userId: newUuid(),
|
||||
pinExpiresAt: newDate(),
|
||||
isPendingSyncReset: false,
|
||||
permissions: [Permission.All],
|
||||
...session,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user