mirror of
https://github.com/immich-app/immich.git
synced 2025-12-24 01:11:32 +03:00
refactor(server): sessions (#8915)
* refactor: auth device => sessions * chore: open api
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, Res } from '@nestjs/common';
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, IMMICH_IS_AUTHENTICATED } from 'src/constants';
|
||||
import {
|
||||
AuthDeviceResponseDto,
|
||||
AuthDto,
|
||||
ChangePasswordDto,
|
||||
LoginCredentialDto,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
|
||||
import { AuthService, LoginDetails } from 'src/services/auth.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
@@ -41,23 +39,6 @@ export class AuthController {
|
||||
return this.service.adminSignUp(dto);
|
||||
}
|
||||
|
||||
@Get('devices')
|
||||
getAuthDevices(@Auth() auth: AuthDto): Promise<AuthDeviceResponseDto[]> {
|
||||
return this.service.getDevices(auth);
|
||||
}
|
||||
|
||||
@Delete('devices')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
logoutAuthDevices(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.service.logoutDevices(auth);
|
||||
}
|
||||
|
||||
@Delete('devices/:id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
logoutAuthDevice(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.logoutDevice(auth, id);
|
||||
}
|
||||
|
||||
@Post('validateToken')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
validateAccessToken(): ValidateAccessTokenResponseDto {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { PartnerController } from 'src/controllers/partner.controller';
|
||||
import { PersonController } from 'src/controllers/person.controller';
|
||||
import { SearchController } from 'src/controllers/search.controller';
|
||||
import { ServerInfoController } from 'src/controllers/server-info.controller';
|
||||
import { SessionController } from 'src/controllers/session.controller';
|
||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||
import { SyncController } from 'src/controllers/sync.controller';
|
||||
import { SystemConfigController } from 'src/controllers/system-config.controller';
|
||||
@@ -43,6 +44,7 @@ export const controllers = [
|
||||
PartnerController,
|
||||
SearchController,
|
||||
ServerInfoController,
|
||||
SessionController,
|
||||
SharedLinkController,
|
||||
SyncController,
|
||||
SystemConfigController,
|
||||
|
||||
31
server/src/controllers/session.controller.ts
Normal file
31
server/src/controllers/session.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto } from 'src/dtos/session.dto';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { SessionService } from 'src/services/session.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Sessions')
|
||||
@Controller('sessions')
|
||||
@Authenticated()
|
||||
export class SessionController {
|
||||
constructor(private service: SessionService) {}
|
||||
|
||||
@Get()
|
||||
getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> {
|
||||
return this.service.getAll(auth);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteAllSessions(@Auth() auth: AuthDto): Promise<void> {
|
||||
return this.service.deleteAll(auth);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { APIKeyEntity } from 'src/entities/api-key.entity';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { UserTokenEntity } from 'src/entities/user-token.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
|
||||
export class AuthDto {
|
||||
@@ -11,7 +11,7 @@ export class AuthDto {
|
||||
|
||||
apiKey?: APIKeyEntity;
|
||||
sharedLink?: SharedLinkEntity;
|
||||
userToken?: UserTokenEntity;
|
||||
session?: SessionEntity;
|
||||
}
|
||||
|
||||
export class LoginCredentialDto {
|
||||
@@ -78,24 +78,6 @@ export class ValidateAccessTokenResponseDto {
|
||||
authStatus!: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
|
||||
19
server/src/dtos/session.dto.ts
Normal file
19
server/src/dtos/session.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
|
||||
export class SessionResponseDto {
|
||||
id!: string;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
current!: boolean;
|
||||
deviceType!: string;
|
||||
deviceOS!: string;
|
||||
}
|
||||
|
||||
export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({
|
||||
id: entity.id,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
current: currentId === entity.id,
|
||||
deviceOS: entity.deviceOS,
|
||||
deviceType: entity.deviceType,
|
||||
});
|
||||
@@ -13,13 +13,13 @@ import { MemoryEntity } from 'src/entities/memory.entity';
|
||||
import { MoveEntity } from 'src/entities/move.entity';
|
||||
import { PartnerEntity } from 'src/entities/partner.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
||||
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
||||
import { SystemConfigEntity } from 'src/entities/system-config.entity';
|
||||
import { SystemMetadataEntity } from 'src/entities/system-metadata.entity';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { UserTokenEntity } from 'src/entities/user-token.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
|
||||
export const entities = [
|
||||
@@ -44,6 +44,6 @@ export const entities = [
|
||||
SystemMetadataEntity,
|
||||
TagEntity,
|
||||
UserEntity,
|
||||
UserTokenEntity,
|
||||
SessionEntity,
|
||||
LibraryEntity,
|
||||
];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('user_token')
|
||||
export class UserTokenEntity {
|
||||
@Entity('sessions')
|
||||
export class SessionEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
11
server/src/interfaces/session.interface.ts
Normal file
11
server/src/interfaces/session.interface.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
|
||||
export const ISessionRepository = 'ISessionRepository';
|
||||
|
||||
export interface ISessionRepository {
|
||||
create(dto: Partial<SessionEntity>): Promise<SessionEntity>;
|
||||
update(dto: Partial<SessionEntity>): Promise<SessionEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
getByToken(token: string): Promise<SessionEntity | null>;
|
||||
getByUserId(userId: string): Promise<SessionEntity[]>;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { UserTokenEntity } from 'src/entities/user-token.entity';
|
||||
|
||||
export const IUserTokenRepository = 'IUserTokenRepository';
|
||||
|
||||
export interface IUserTokenRepository {
|
||||
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||
save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
getByToken(token: string): Promise<UserTokenEntity | null>;
|
||||
getAll(userId: string): Promise<UserTokenEntity[]>;
|
||||
}
|
||||
15
server/src/migrations/1713490844785-RenameSessionsTable.ts
Normal file
15
server/src/migrations/1713490844785-RenameSessionsTable.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RenameSessionsTable1713490844785 implements MigrationInterface {
|
||||
name = 'RenameSessionsTable1713490844785';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user_token" RENAME TO "sessions"`);
|
||||
await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" to "FK_57de40bc620f456c7311aa3a1e6"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_57de40bc620f456c7311aa3a1e6" to "FK_d37db50eecdf9b8ce4eedd2f918"`);
|
||||
await queryRunner.query(`ALTER TABLE "sessions" RENAME TO "user_token"`);
|
||||
}
|
||||
}
|
||||
@@ -173,13 +173,13 @@ WHERE
|
||||
|
||||
-- AccessRepository.authDevice.checkOwnerAccess
|
||||
SELECT
|
||||
"UserTokenEntity"."id" AS "UserTokenEntity_id"
|
||||
"SessionEntity"."id" AS "SessionEntity_id"
|
||||
FROM
|
||||
"user_token" "UserTokenEntity"
|
||||
"sessions" "SessionEntity"
|
||||
WHERE
|
||||
(
|
||||
("UserTokenEntity"."userId" = $1)
|
||||
AND ("UserTokenEntity"."id" IN ($2))
|
||||
("SessionEntity"."userId" = $1)
|
||||
AND ("SessionEntity"."id" IN ($2))
|
||||
)
|
||||
|
||||
-- AccessRepository.library.checkOwnerAccess
|
||||
|
||||
48
server/src/queries/session.repository.sql
Normal file
48
server/src/queries/session.repository.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- SessionRepository.getByToken
|
||||
SELECT DISTINCT
|
||||
"distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id"
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
"SessionEntity"."id" AS "SessionEntity_id",
|
||||
"SessionEntity"."userId" AS "SessionEntity_userId",
|
||||
"SessionEntity"."createdAt" AS "SessionEntity_createdAt",
|
||||
"SessionEntity"."updatedAt" AS "SessionEntity_updatedAt",
|
||||
"SessionEntity"."deviceType" AS "SessionEntity_deviceType",
|
||||
"SessionEntity"."deviceOS" AS "SessionEntity_deviceOS",
|
||||
"SessionEntity__SessionEntity_user"."id" AS "SessionEntity__SessionEntity_user_id",
|
||||
"SessionEntity__SessionEntity_user"."name" AS "SessionEntity__SessionEntity_user_name",
|
||||
"SessionEntity__SessionEntity_user"."avatarColor" AS "SessionEntity__SessionEntity_user_avatarColor",
|
||||
"SessionEntity__SessionEntity_user"."isAdmin" AS "SessionEntity__SessionEntity_user_isAdmin",
|
||||
"SessionEntity__SessionEntity_user"."email" AS "SessionEntity__SessionEntity_user_email",
|
||||
"SessionEntity__SessionEntity_user"."storageLabel" AS "SessionEntity__SessionEntity_user_storageLabel",
|
||||
"SessionEntity__SessionEntity_user"."oauthId" AS "SessionEntity__SessionEntity_user_oauthId",
|
||||
"SessionEntity__SessionEntity_user"."profileImagePath" AS "SessionEntity__SessionEntity_user_profileImagePath",
|
||||
"SessionEntity__SessionEntity_user"."shouldChangePassword" AS "SessionEntity__SessionEntity_user_shouldChangePassword",
|
||||
"SessionEntity__SessionEntity_user"."createdAt" AS "SessionEntity__SessionEntity_user_createdAt",
|
||||
"SessionEntity__SessionEntity_user"."deletedAt" AS "SessionEntity__SessionEntity_user_deletedAt",
|
||||
"SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status",
|
||||
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
|
||||
"SessionEntity__SessionEntity_user"."memoriesEnabled" AS "SessionEntity__SessionEntity_user_memoriesEnabled",
|
||||
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes",
|
||||
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes"
|
||||
FROM
|
||||
"sessions" "SessionEntity"
|
||||
LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId"
|
||||
AND (
|
||||
"SessionEntity__SessionEntity_user"."deletedAt" IS NULL
|
||||
)
|
||||
WHERE
|
||||
(("SessionEntity"."token" = $1))
|
||||
) "distinctAlias"
|
||||
ORDER BY
|
||||
"SessionEntity_id" ASC
|
||||
LIMIT
|
||||
1
|
||||
|
||||
-- SessionRepository.delete
|
||||
DELETE FROM "sessions"
|
||||
WHERE
|
||||
"id" = $1
|
||||
@@ -1,48 +0,0 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- UserTokenRepository.getByToken
|
||||
SELECT DISTINCT
|
||||
"distinctAlias"."UserTokenEntity_id" AS "ids_UserTokenEntity_id"
|
||||
FROM
|
||||
(
|
||||
SELECT
|
||||
"UserTokenEntity"."id" AS "UserTokenEntity_id",
|
||||
"UserTokenEntity"."userId" AS "UserTokenEntity_userId",
|
||||
"UserTokenEntity"."createdAt" AS "UserTokenEntity_createdAt",
|
||||
"UserTokenEntity"."updatedAt" AS "UserTokenEntity_updatedAt",
|
||||
"UserTokenEntity"."deviceType" AS "UserTokenEntity_deviceType",
|
||||
"UserTokenEntity"."deviceOS" AS "UserTokenEntity_deviceOS",
|
||||
"UserTokenEntity__UserTokenEntity_user"."id" AS "UserTokenEntity__UserTokenEntity_user_id",
|
||||
"UserTokenEntity__UserTokenEntity_user"."name" AS "UserTokenEntity__UserTokenEntity_user_name",
|
||||
"UserTokenEntity__UserTokenEntity_user"."avatarColor" AS "UserTokenEntity__UserTokenEntity_user_avatarColor",
|
||||
"UserTokenEntity__UserTokenEntity_user"."isAdmin" AS "UserTokenEntity__UserTokenEntity_user_isAdmin",
|
||||
"UserTokenEntity__UserTokenEntity_user"."email" AS "UserTokenEntity__UserTokenEntity_user_email",
|
||||
"UserTokenEntity__UserTokenEntity_user"."storageLabel" AS "UserTokenEntity__UserTokenEntity_user_storageLabel",
|
||||
"UserTokenEntity__UserTokenEntity_user"."oauthId" AS "UserTokenEntity__UserTokenEntity_user_oauthId",
|
||||
"UserTokenEntity__UserTokenEntity_user"."profileImagePath" AS "UserTokenEntity__UserTokenEntity_user_profileImagePath",
|
||||
"UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword",
|
||||
"UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt",
|
||||
"UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt",
|
||||
"UserTokenEntity__UserTokenEntity_user"."status" AS "UserTokenEntity__UserTokenEntity_user_status",
|
||||
"UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt",
|
||||
"UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled",
|
||||
"UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes",
|
||||
"UserTokenEntity__UserTokenEntity_user"."quotaUsageInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaUsageInBytes"
|
||||
FROM
|
||||
"user_token" "UserTokenEntity"
|
||||
LEFT JOIN "users" "UserTokenEntity__UserTokenEntity_user" ON "UserTokenEntity__UserTokenEntity_user"."id" = "UserTokenEntity"."userId"
|
||||
AND (
|
||||
"UserTokenEntity__UserTokenEntity_user"."deletedAt" IS NULL
|
||||
)
|
||||
WHERE
|
||||
(("UserTokenEntity"."token" = $1))
|
||||
) "distinctAlias"
|
||||
ORDER BY
|
||||
"UserTokenEntity_id" ASC
|
||||
LIMIT
|
||||
1
|
||||
|
||||
-- UserTokenRepository.delete
|
||||
DELETE FROM "user_token"
|
||||
WHERE
|
||||
"id" = $1
|
||||
@@ -9,8 +9,8 @@ import { LibraryEntity } from 'src/entities/library.entity';
|
||||
import { MemoryEntity } from 'src/entities/memory.entity';
|
||||
import { PartnerEntity } from 'src/entities/partner.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { UserTokenEntity } from 'src/entities/user-token.entity';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Brackets, In, Repository } from 'typeorm';
|
||||
@@ -286,7 +286,7 @@ class AssetAccess implements IAssetAccess {
|
||||
}
|
||||
|
||||
class AuthDeviceAccess implements IAuthDeviceAccess {
|
||||
constructor(private tokenRepository: Repository<UserTokenEntity>) {}
|
||||
constructor(private sessionRepository: Repository<SessionEntity>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
@@ -295,7 +295,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.tokenRepository
|
||||
return this.sessionRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
@@ -457,12 +457,12 @@ export class AccessRepository implements IAccessRepository {
|
||||
@InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>,
|
||||
@InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>,
|
||||
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
|
||||
@InjectRepository(UserTokenEntity) tokenRepository: Repository<UserTokenEntity>,
|
||||
@InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
|
||||
) {
|
||||
this.activity = new ActivityAccess(activityRepository, albumRepository);
|
||||
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
|
||||
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
|
||||
this.authDevice = new AuthDeviceAccess(tokenRepository);
|
||||
this.authDevice = new AuthDeviceAccess(sessionRepository);
|
||||
this.library = new LibraryAccess(libraryRepository);
|
||||
this.memory = new MemoryAccess(memoryRepository);
|
||||
this.person = new PersonAccess(assetFaceRepository, personRepository);
|
||||
|
||||
@@ -22,12 +22,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
@@ -53,12 +53,12 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { SearchRepository } from 'src/repositories/search.repository';
|
||||
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
||||
import { SessionRepository } from 'src/repositories/session.repository';
|
||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemConfigRepository } from 'src/repositories/system-config.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { UserTokenRepository } from 'src/repositories/user-token.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
|
||||
export const repositories = [
|
||||
@@ -86,11 +86,11 @@ export const repositories = [
|
||||
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
||||
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
||||
{ provide: ISearchRepository, useClass: SearchRepository },
|
||||
{ provide: ISessionRepository, useClass: SessionRepository },
|
||||
{ provide: IStorageRepository, useClass: StorageRepository },
|
||||
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
|
||||
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
|
||||
{ provide: ITagRepository, useClass: TagRepository },
|
||||
{ provide: IMediaRepository, useClass: MediaRepository },
|
||||
{ provide: IUserRepository, useClass: UserRepository },
|
||||
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
|
||||
];
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { UserTokenEntity } from 'src/entities/user-token.entity';
|
||||
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class UserTokenRepository implements IUserTokenRepository {
|
||||
constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {}
|
||||
export class SessionRepository implements ISessionRepository {
|
||||
constructor(@InjectRepository(SessionEntity) private repository: Repository<SessionEntity>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByToken(token: string): Promise<UserTokenEntity | null> {
|
||||
getByToken(token: string): Promise<SessionEntity | null> {
|
||||
return this.repository.findOne({ where: { token }, relations: { user: true } });
|
||||
}
|
||||
|
||||
getAll(userId: string): Promise<UserTokenEntity[]> {
|
||||
getByUserId(userId: string): Promise<SessionEntity[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
userId,
|
||||
@@ -31,12 +31,12 @@ export class UserTokenRepository implements IUserTokenRepository {
|
||||
});
|
||||
}
|
||||
|
||||
create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
||||
return this.repository.save(userToken);
|
||||
create(session: Partial<SessionEntity>): Promise<SessionEntity> {
|
||||
return this.repository.save(session);
|
||||
}
|
||||
|
||||
save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> {
|
||||
return this.repository.save(userToken);
|
||||
update(session: Partial<SessionEntity>): Promise<SessionEntity> {
|
||||
return this.repository.save(session);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
@@ -9,25 +9,25 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { keyStub } from 'test/fixtures/api-key.stub';
|
||||
import { authStub, loginResponseStub } from 'test/fixtures/auth.stub';
|
||||
import { sessionStub } from 'test/fixtures/session.stub';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userTokenStub } from 'test/fixtures/user-token.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
|
||||
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
||||
import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
|
||||
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
|
||||
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
|
||||
import { newUserTokenRepositoryMock } from 'test/repositories/user-token.repository.mock';
|
||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
import { Mock, Mocked, vitest } from 'vitest';
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('AuthService', () => {
|
||||
let libraryMock: Mocked<ILibraryRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let configMock: Mocked<ISystemConfigRepository>;
|
||||
let userTokenMock: Mocked<IUserTokenRepository>;
|
||||
let sessionMock: Mocked<ISessionRepository>;
|
||||
let shareMock: Mocked<ISharedLinkRepository>;
|
||||
let keyMock: Mocked<IKeyRepository>;
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('AuthService', () => {
|
||||
libraryMock = newLibraryRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
userTokenMock = newUserTokenRepositoryMock();
|
||||
sessionMock = newSessionRepositoryMock();
|
||||
shareMock = newSharedLinkRepositoryMock();
|
||||
keyMock = newKeyRepositoryMock();
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('AuthService', () => {
|
||||
libraryMock,
|
||||
loggerMock,
|
||||
userMock,
|
||||
userTokenMock,
|
||||
sessionMock,
|
||||
shareMock,
|
||||
keyMock,
|
||||
);
|
||||
@@ -139,14 +139,14 @@ describe('AuthService', () => {
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
userMock.getByEmail.mockResolvedValue(userStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
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(userStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
await expect(
|
||||
sut.login(fixtures.login, {
|
||||
clientIp: '127.0.0.1',
|
||||
@@ -231,14 +231,14 @@ describe('AuthService', () => {
|
||||
});
|
||||
|
||||
it('should delete the access token', async () => {
|
||||
const auth = { user: { id: '123' }, userToken: { id: 'token123' } } as AuthDto;
|
||||
const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto;
|
||||
|
||||
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: '/auth/login?autoLaunch=0',
|
||||
});
|
||||
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith('token123');
|
||||
expect(sessionMock.delete).toHaveBeenCalledWith('token123');
|
||||
});
|
||||
|
||||
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
|
||||
@@ -282,11 +282,11 @@ describe('AuthService', () => {
|
||||
|
||||
it('should validate using authorization header', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
|
||||
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
|
||||
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({
|
||||
user: userStub.user1,
|
||||
userToken: userTokenStub.userToken,
|
||||
session: sessionStub.valid,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -336,37 +336,29 @@ describe('AuthService', () => {
|
||||
|
||||
describe('validate - user token', () => {
|
||||
it('should throw if no token is found', async () => {
|
||||
userTokenMock.getByToken.mockResolvedValue(null);
|
||||
sessionMock.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.getByToken.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
|
||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual({
|
||||
user: userStub.user1,
|
||||
userToken: userTokenStub.userToken,
|
||||
session: sessionStub.valid,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update when access time exceeds an hour', async () => {
|
||||
userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken);
|
||||
userTokenMock.save.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.getByToken.mockResolvedValue(sessionStub.inactive);
|
||||
sessionMock.update.mockResolvedValue(sessionStub.valid);
|
||||
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
|
||||
await expect(sut.validate(headers, {})).resolves.toEqual({
|
||||
user: userStub.user1,
|
||||
userToken: userTokenStub.userToken,
|
||||
});
|
||||
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
|
||||
id: 'not_active',
|
||||
token: 'auth_token',
|
||||
userId: 'user-id',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: expect.any(Date),
|
||||
deviceOS: 'Android',
|
||||
deviceType: 'Mobile',
|
||||
session: sessionStub.valid,
|
||||
});
|
||||
expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -386,55 +378,6 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDevices', () => {
|
||||
it('should get the devices', async () => {
|
||||
userTokenMock.getAll.mockResolvedValue([userTokenStub.userToken, userTokenStub.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.user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutDevices', () => {
|
||||
it('should logout all devices', async () => {
|
||||
userTokenMock.getAll.mockResolvedValue([userTokenStub.inactiveToken, userTokenStub.userToken]);
|
||||
|
||||
await sut.logoutDevices(authStub.user1);
|
||||
|
||||
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith('not_active');
|
||||
expect(userTokenMock.delete).not.toHaveBeenCalledWith('token-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutDevice', () => {
|
||||
it('should logout the device', async () => {
|
||||
accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
|
||||
|
||||
await sut.logoutDevice(authStub.user1, 'token-1');
|
||||
|
||||
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1']));
|
||||
expect(userTokenMock.delete).toHaveBeenCalledWith('token-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMobileRedirect', () => {
|
||||
it('should pass along the query params', () => {
|
||||
expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
|
||||
@@ -463,7 +406,7 @@ describe('AuthService', () => {
|
||||
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
|
||||
userMock.getByEmail.mockResolvedValue(userStub.user1);
|
||||
userMock.update.mockResolvedValue(userStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
@@ -478,7 +421,7 @@ describe('AuthService', () => {
|
||||
userMock.getByEmail.mockResolvedValue(null);
|
||||
userMock.getAdmin.mockResolvedValue(userStub.user1);
|
||||
userMock.create.mockResolvedValue(userStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
|
||||
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
|
||||
loginResponseStub.user1oauth,
|
||||
@@ -491,7 +434,7 @@ describe('AuthService', () => {
|
||||
it('should use the mobile redirect override', async () => {
|
||||
configMock.load.mockResolvedValue(systemConfigStub.override);
|
||||
userMock.getByOAuthId.mockResolvedValue(userStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
|
||||
await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails);
|
||||
|
||||
@@ -501,7 +444,7 @@ describe('AuthService', () => {
|
||||
it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
|
||||
configMock.load.mockResolvedValue(systemConfigStub.override);
|
||||
userMock.getByOAuthId.mockResolvedValue(userStub.user1);
|
||||
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
|
||||
sessionMock.create.mockResolvedValue(sessionStub.valid);
|
||||
|
||||
await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);
|
||||
|
||||
|
||||
@@ -19,11 +19,10 @@ import {
|
||||
LOGIN_URL,
|
||||
MOBILE_REDIRECT,
|
||||
} from 'src/constants';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
import { AccessCore } from 'src/cores/access.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { UserCore } from 'src/cores/user.core';
|
||||
import {
|
||||
AuthDeviceResponseDto,
|
||||
AuthDto,
|
||||
ChangePasswordDto,
|
||||
LoginCredentialDto,
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
OAuthConfigDto,
|
||||
SignUpDto,
|
||||
mapLoginResponse,
|
||||
mapUserToken,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { SystemConfig } from 'src/entities/system-config.entity';
|
||||
@@ -44,9 +42,9 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
|
||||
@@ -85,7 +83,7 @@ export class AuthService {
|
||||
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
|
||||
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
|
||||
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
|
||||
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
|
||||
) {
|
||||
@@ -120,8 +118,8 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||
if (auth.userToken) {
|
||||
await this.userTokenRepository.delete(auth.userToken.id);
|
||||
if (auth.session) {
|
||||
await this.sessionRepository.delete(auth.session.id);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -164,8 +162,9 @@ export class AuthService {
|
||||
|
||||
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
|
||||
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
|
||||
const userToken = (headers['x-immich-user-token'] ||
|
||||
params.userToken ||
|
||||
const session = (headers['x-immich-user-token'] ||
|
||||
headers['x-immich-session-token'] ||
|
||||
params.sessionKey ||
|
||||
this.getBearerToken(headers) ||
|
||||
this.getCookieToken(headers)) as string;
|
||||
const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string;
|
||||
@@ -174,8 +173,8 @@ export class AuthService {
|
||||
return this.validateSharedLink(shareKey);
|
||||
}
|
||||
|
||||
if (userToken) {
|
||||
return this.validateUserToken(userToken);
|
||||
if (session) {
|
||||
return this.validateSession(session);
|
||||
}
|
||||
|
||||
if (apiKey) {
|
||||
@@ -185,26 +184,6 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Authentication required');
|
||||
}
|
||||
|
||||
async getDevices(auth: AuthDto): Promise<AuthDeviceResponseDto[]> {
|
||||
const userTokens = await this.userTokenRepository.getAll(auth.user.id);
|
||||
return userTokens.map((userToken) => mapUserToken(userToken, auth.userToken?.id));
|
||||
}
|
||||
|
||||
async logoutDevice(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id);
|
||||
await this.userTokenRepository.delete(id);
|
||||
}
|
||||
|
||||
async logoutDevices(auth: AuthDto): Promise<void> {
|
||||
const devices = await this.userTokenRepository.getAll(auth.user.id);
|
||||
for (const device of devices) {
|
||||
if (device.id === auth.userToken?.id) {
|
||||
continue;
|
||||
}
|
||||
await this.userTokenRepository.delete(device.id);
|
||||
}
|
||||
}
|
||||
|
||||
getMobileRedirect(url: string) {
|
||||
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
|
||||
}
|
||||
@@ -408,19 +387,19 @@ export class AuthService {
|
||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
||||
}
|
||||
|
||||
private async validateUserToken(tokenValue: string): Promise<AuthDto> {
|
||||
private async validateSession(tokenValue: string): Promise<AuthDto> {
|
||||
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
|
||||
let userToken = await this.userTokenRepository.getByToken(hashedToken);
|
||||
let session = await this.sessionRepository.getByToken(hashedToken);
|
||||
|
||||
if (userToken?.user) {
|
||||
if (session?.user) {
|
||||
const now = DateTime.now();
|
||||
const updatedAt = DateTime.fromJSDate(userToken.updatedAt);
|
||||
const updatedAt = DateTime.fromJSDate(session.updatedAt);
|
||||
const diff = now.diff(updatedAt, ['hours']);
|
||||
if (diff.hours > 1) {
|
||||
userToken = await this.userTokenRepository.save({ ...userToken, updatedAt: new Date() });
|
||||
session = await this.sessionRepository.update({ id: session.id, updatedAt: new Date() });
|
||||
}
|
||||
|
||||
return { user: userToken.user, userToken };
|
||||
return { user: session.user, session: session };
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Invalid user token');
|
||||
@@ -430,7 +409,7 @@ export class AuthService {
|
||||
const key = this.cryptoRepository.newPassword(32);
|
||||
const token = this.cryptoRepository.hashSha256(key);
|
||||
|
||||
await this.userTokenRepository.create({
|
||||
await this.sessionRepository.create({
|
||||
token,
|
||||
user,
|
||||
deviceOS: loginDetails.deviceOS,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PartnerService } from 'src/services/partner.service';
|
||||
import { PersonService } from 'src/services/person.service';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { ServerInfoService } from 'src/services/server-info.service';
|
||||
import { SessionService } from 'src/services/session.service';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import { SmartInfoService } from 'src/services/smart-info.service';
|
||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||
@@ -50,6 +51,7 @@ export const services = [
|
||||
PersonService,
|
||||
SearchService,
|
||||
ServerInfoService,
|
||||
SessionService,
|
||||
SharedLinkService,
|
||||
SmartInfoService,
|
||||
StorageService,
|
||||
|
||||
77
server/src/services/session.service.spec.ts
Normal file
77
server/src/services/session.service.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
import { SessionService } from 'src/services/session.service';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { sessionStub } from 'test/fixtures/session.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe('SessionService', () => {
|
||||
let sut: SessionService;
|
||||
let accessMock: Mocked<IAccessRepositoryMock>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let sessionMock: Mocked<ISessionRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
sessionMock = newSessionRepositoryMock();
|
||||
|
||||
sut = new SessionService(accessMock, loggerMock, sessionMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should get the devices', async () => {
|
||||
sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]);
|
||||
await expect(sut.getAll(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(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutDevices', () => {
|
||||
it('should logout all devices', async () => {
|
||||
sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid]);
|
||||
|
||||
await sut.deleteAll(authStub.user1);
|
||||
|
||||
expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id);
|
||||
expect(sessionMock.delete).toHaveBeenCalledWith('not_active');
|
||||
expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutDevice', () => {
|
||||
it('should logout the device', async () => {
|
||||
accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
|
||||
|
||||
await sut.delete(authStub.user1, 'token-1');
|
||||
|
||||
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1']));
|
||||
expect(sessionMock.delete).toHaveBeenCalledWith('token-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
41
server/src/services/session.service.ts
Normal file
41
server/src/services/session.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private access: AccessCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
|
||||
) {
|
||||
this.logger.setContext(SessionService.name);
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
async getAll(auth: AuthDto): Promise<SessionResponseDto[]> {
|
||||
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
|
||||
return sessions.map((session) => mapSession(session, auth.session?.id));
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id);
|
||||
await this.sessionRepository.delete(id);
|
||||
}
|
||||
|
||||
async deleteAll(auth: AuthDto): Promise<void> {
|
||||
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
|
||||
for (const session of sessions) {
|
||||
if (session.id === auth.session?.id) {
|
||||
continue;
|
||||
}
|
||||
await this.sessionRepository.delete(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user