feat: add session creation endpoint (#18295)

This commit is contained in:
Brandon Wees 2025-05-15 13:34:33 -05:00 committed by GitHub
parent 585997d46f
commit 6117329057
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 241 additions and 50 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5618,6 +5618,46 @@
"tags": [
"Sessions"
]
},
"post": {
"operationId": "createSession",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionCreateResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/sessions/{id}": {
@ -11052,6 +11092,7 @@
"person.statistics",
"person.merge",
"person.reassign",
"session.create",
"session.read",
"session.update",
"session.delete",
@ -12038,6 +12079,57 @@
],
"type": "object"
},
"SessionCreateDto": {
"properties": {
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"duration": {
"description": "session duration, in seconds",
"minimum": 1,
"type": "number"
}
},
"type": "object"
},
"SessionCreateResponseDto": {
"properties": {
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"id": {
"type": "string"
},
"token": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"createdAt",
"current",
"deviceOS",
"deviceType",
"id",
"token",
"updatedAt"
],
"type": "object"
},
"SessionResponseDto": {
"properties": {
"createdAt": {

View file

@ -1078,6 +1078,21 @@ export type SessionResponseDto = {
id: string;
updatedAt: string;
};
export type SessionCreateDto = {
deviceOS?: string;
deviceType?: string;
/** session duration, in seconds */
duration?: number;
};
export type SessionCreateResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
id: string;
token: string;
updatedAt: string;
};
export type SharedLinkResponseDto = {
album?: AlbumResponseDto;
allowDownload: boolean;
@ -2917,6 +2932,18 @@ export function getSessions(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function createSession({ sessionCreateDto }: {
sessionCreateDto: SessionCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: SessionCreateResponseDto;
}>("/sessions", oazapfts.json({
...opts,
method: "POST",
body: sessionCreateDto
})));
}
export function deleteSession({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
@ -3678,6 +3705,7 @@ export enum Permission {
PersonStatistics = "person.statistics",
PersonMerge = "person.merge",
PersonReassign = "person.reassign",
SessionCreate = "session.create",
SessionRead = "session.read",
SessionUpdate = "session.update",
SessionDelete = "session.delete",

View file

@ -1,7 +1,7 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto';
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto } from 'src/dtos/session.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SessionService } from 'src/services/session.service';
@ -12,6 +12,12 @@ import { UUIDParamDto } from 'src/validation';
export class SessionController {
constructor(private service: SessionService) {}
@Post()
@Authenticated({ permission: Permission.SESSION_CREATE })
createSession(@Auth() auth: AuthDto, @Body() dto: SessionCreateDto): Promise<SessionCreateResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.SESSION_READ })
getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> {

2
server/src/db.d.ts vendored
View file

@ -343,6 +343,8 @@ export interface Sessions {
deviceOS: Generated<string>;
deviceType: Generated<string>;
id: Generated<string>;
parentId: string | null;
expiredAt: Date | null;
token: string;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;

View file

@ -1,4 +1,24 @@
import { IsInt, IsPositive, IsString } from 'class-validator';
import { Session } from 'src/database';
import { Optional } from 'src/validation';
export class SessionCreateDto {
/**
* session duration, in seconds
*/
@IsInt()
@IsPositive()
@Optional()
duration?: number;
@IsString()
@Optional()
deviceType?: string;
@IsString()
@Optional()
deviceOS?: string;
}
export class SessionResponseDto {
id!: string;
@ -9,6 +29,10 @@ export class SessionResponseDto {
deviceOS!: string;
}
export class SessionCreateResponseDto extends SessionResponseDto {
token!: string;
}
export const mapSession = (entity: Session, currentId?: string): SessionResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),

View file

@ -144,6 +144,7 @@ export enum Permission {
PERSON_MERGE = 'person.merge',
PERSON_REASSIGN = 'person.reassign',
SESSION_CREATE = 'session.create',
SESSION_READ = 'session.read',
SESSION_UPDATE = 'session.update',
SESSION_DELETE = 'session.delete',

View file

@ -36,6 +36,10 @@ from
"sessions"
where
"sessions"."token" = $1
and (
"sessions"."expiredAt" is null
or "sessions"."expiredAt" > $2
)
-- SessionRepository.getByUserId
select

View file

@ -54,7 +54,7 @@ export class CryptoRepository {
});
}
newPassword(bytes: number) {
randomBytesAsText(bytes: number) {
return randomBytes(bytes).toString('base64').replaceAll(/\W/g, '');
}
}

View file

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, Sessions } from 'src/db';
@ -13,6 +14,19 @@ export type SessionSearchOptions = { updatedBefore: Date };
export class SessionRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
cleanup() {
return this.db
.deleteFrom('sessions')
.where((eb) =>
eb.or([
eb('updatedAt', '<=', DateTime.now().minus({ days: 90 }).toJSDate()),
eb.and([eb('expiredAt', 'is not', null), eb('expiredAt', '<=', DateTime.now().toJSDate())]),
]),
)
.returning(['id', 'deviceOS', 'deviceType'])
.execute();
}
@GenerateSql({ params: [{ updatedBefore: DummyValue.DATE }] })
search(options: SessionSearchOptions) {
return this.db
@ -37,6 +51,9 @@ export class SessionRepository {
).as('user'),
])
.where('sessions.token', '=', token)
.where((eb) =>
eb.or([eb('sessions.expiredAt', 'is', null), eb('sessions.expiredAt', '>', DateTime.now().toJSDate())]),
)
.executeTakeFirst();
}

View file

@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "sessions" ADD "expiredAt" timestamp with time zone;`.execute(db);
await sql`ALTER TABLE "sessions" ADD "parentId" uuid;`.execute(db);
await sql`ALTER TABLE "sessions" ADD CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8" FOREIGN KEY ("parentId") REFERENCES "sessions" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "IDX_afbbabbd7daf5b91de4dca84de" ON "sessions" ("parentId")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP INDEX "IDX_afbbabbd7daf5b91de4dca84de";`.execute(db);
await sql`ALTER TABLE "sessions" DROP CONSTRAINT "FK_afbbabbd7daf5b91de4dca84de8";`.execute(db);
await sql`ALTER TABLE "sessions" DROP COLUMN "expiredAt";`.execute(db);
await sql`ALTER TABLE "sessions" DROP COLUMN "parentId";`.execute(db);
}

View file

@ -25,9 +25,15 @@ export class SessionTable {
@UpdateDateColumn()
updatedAt!: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
expiredAt!: Date | null;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@ForeignKeyColumn(() => SessionTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', nullable: true })
parentId!: string | null;
@Column({ default: '' })
deviceType!: string;

View file

@ -18,7 +18,7 @@ describe(ApiKeyService.name, () => {
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] });
const key = 'super-secret';
mocks.crypto.newPassword.mockReturnValue(key);
mocks.crypto.randomBytesAsText.mockReturnValue(key);
mocks.apiKey.create.mockResolvedValue(apiKey);
await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions });
@ -29,7 +29,7 @@ describe(ApiKeyService.name, () => {
permissions: apiKey.permissions,
userId: apiKey.userId,
});
expect(mocks.crypto.newPassword).toHaveBeenCalled();
expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled();
});
@ -38,7 +38,7 @@ describe(ApiKeyService.name, () => {
const apiKey = factory.apiKey({ userId: auth.user.id });
const key = 'super-secret';
mocks.crypto.newPassword.mockReturnValue(key);
mocks.crypto.randomBytesAsText.mockReturnValue(key);
mocks.apiKey.create.mockResolvedValue(apiKey);
await sut.create(auth, { permissions: [Permission.ALL] });
@ -49,7 +49,7 @@ describe(ApiKeyService.name, () => {
permissions: [Permission.ALL],
userId: auth.user.id,
});
expect(mocks.crypto.newPassword).toHaveBeenCalled();
expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled();
});

View file

@ -9,20 +9,21 @@ import { isGranted } from 'src/utils/access';
@Injectable()
export class ApiKeyService extends BaseService {
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
throw new BadRequestException('Cannot grant permissions you do not have');
}
const entity = await this.apiKeyRepository.create({
key: this.cryptoRepository.hashSha256(secret),
key: tokenHashed,
name: dto.name || 'API Key',
userId: auth.user.id,
permissions: dto.permissions,
});
return { secret, apiKey: this.map(entity) };
return { secret: token, apiKey: this.map(entity) };
}
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {

View file

@ -492,17 +492,17 @@ export class AuthService extends BaseService {
}
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
const key = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.hashSha256(key);
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
await this.sessionRepository.create({
token,
token: tokenHashed,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
userId: user.id,
});
return mapLoginResponse(user, key);
return mapLoginResponse(user, token);
}
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {

View file

@ -17,7 +17,7 @@ export class CliService extends BaseService {
}
const providedPassword = await ask(mapUserAdmin(admin));
const password = providedPassword || this.cryptoRepository.newPassword(24);
const password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
await this.userRepository.update(admin.id, { password: hashedPassword });

View file

@ -17,30 +17,9 @@ describe('SessionService', () => {
});
describe('handleCleanup', () => {
it('should return skipped if nothing is to be deleted', async () => {
mocks.session.search.mockResolvedValue([]);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED);
expect(mocks.session.search).toHaveBeenCalled();
});
it('should delete sessions', async () => {
mocks.session.search.mockResolvedValue([
{
createdAt: new Date('1970-01-01T00:00:00.00Z'),
updatedAt: new Date('1970-01-02T00:00:00.00Z'),
deviceOS: '',
deviceType: '',
id: '123',
token: '420',
userId: '42',
updateId: 'uuid-v7',
pinExpiresAt: null,
},
]);
mocks.session.delete.mockResolvedValue();
it('should clean sessions', async () => {
mocks.session.cleanup.mockResolvedValue([]);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS);
expect(mocks.session.delete).toHaveBeenCalledWith('123');
});
});

View file

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { SessionCreateDto, SessionCreateResponseDto, SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
@ -10,16 +10,8 @@ import { BaseService } from 'src/services/base.service';
export class SessionService extends BaseService {
@OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK })
async handleCleanup(): Promise<JobStatus> {
const sessions = await this.sessionRepository.search({
updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(),
});
if (sessions.length === 0) {
return JobStatus.SKIPPED;
}
const sessions = await this.sessionRepository.cleanup();
for (const session of sessions) {
await this.sessionRepository.delete(session.id);
this.logger.verbose(`Deleted expired session token: ${session.deviceOS}/${session.deviceType}`);
}
@ -28,6 +20,25 @@ export class SessionService extends BaseService {
return JobStatus.SUCCESS;
}
async create(auth: AuthDto, dto: SessionCreateDto): Promise<SessionCreateResponseDto> {
if (!auth.session) {
throw new BadRequestException('This endpoint can only be used with a session token');
}
const token = this.cryptoRepository.randomBytesAsText(32);
const tokenHashed = this.cryptoRepository.hashSha256(token);
const session = await this.sessionRepository.create({
parentId: auth.session.id,
userId: auth.user.id,
expiredAt: dto.duration ? DateTime.now().plus({ seconds: dto.duration }).toJSDate() : null,
deviceType: dto.deviceType,
deviceOS: dto.deviceOS,
token: tokenHashed,
});
return { ...mapSession(session), token };
}
async getAll(auth: AuthDto): Promise<SessionResponseDto[]> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
return sessions.map((session) => mapSession(session, auth.session?.id));

View file

@ -12,6 +12,6 @@ export const newCryptoRepositoryMock = (): Mocked<RepositoryInterface<CryptoRepo
verifySha256: vitest.fn().mockImplementation(() => true),
hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)),
hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`),
newPassword: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')),
randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')),
};
};

View file

@ -126,6 +126,8 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
deviceOS: 'android',
deviceType: 'mobile',
token: 'abc123',
parentId: null,
expiredAt: null,
userId: newUuid(),
pinExpiresAt: newDate(),
...session,

View file

@ -7,6 +7,7 @@
mdiAndroid,
mdiApple,
mdiAppleSafari,
mdiCast,
mdiGoogleChrome,
mdiHelp,
mdiLinux,
@ -46,6 +47,8 @@
<Icon path={mdiUbuntu} size="40" />
{:else if device.deviceOS === 'Chrome OS' || device.deviceType === 'Chrome' || device.deviceType === 'Chromium' || device.deviceType === 'Mobile Chrome'}
<Icon path={mdiGoogleChrome} size="40" />
{:else if device.deviceOS === 'Google Cast'}
<Icon path={mdiCast} size="40" />
{:else}
<Icon path={mdiHelp} size="40" />
{/if}