modify provisioning
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
CREATE TABLE "device_authorizations" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"device_code_hash" TEXT NOT NULL,
|
||||||
|
"user_code" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||||
|
"auth_user_id" UUID,
|
||||||
|
"account_email" TEXT,
|
||||||
|
"auth_user_name" TEXT,
|
||||||
|
"auth_user_role" TEXT,
|
||||||
|
"auth_user_roles" JSONB,
|
||||||
|
"device" JSONB,
|
||||||
|
"expires_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"approved_at" TIMESTAMPTZ(3),
|
||||||
|
"consumed_at" TIMESTAMPTZ(3),
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "device_authorizations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "device_authorizations_device_code_hash_key" ON "device_authorizations"("device_code_hash");
|
||||||
|
CREATE UNIQUE INDEX "device_authorizations_user_code_key" ON "device_authorizations"("user_code");
|
||||||
|
CREATE INDEX "device_authorizations_status_expires_at_idx" ON "device_authorizations"("status", "expires_at");
|
||||||
@@ -227,6 +227,27 @@ model ProvisionedDevice {
|
|||||||
@@map("provisioned_devices")
|
@@map("provisioned_devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DeviceAuthorization {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
deviceCodeHash String @unique @map("device_code_hash")
|
||||||
|
userCode String @unique @map("user_code")
|
||||||
|
status String @default("PENDING")
|
||||||
|
authUserId String? @map("auth_user_id") @db.Uuid
|
||||||
|
accountEmail String? @map("account_email")
|
||||||
|
authUserName String? @map("auth_user_name")
|
||||||
|
authUserRole String? @map("auth_user_role")
|
||||||
|
authUserRoles Json? @map("auth_user_roles")
|
||||||
|
device Json?
|
||||||
|
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||||
|
approvedAt DateTime? @map("approved_at") @db.Timestamptz(3)
|
||||||
|
consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@index([status, expiresAt])
|
||||||
|
@@map("device_authorizations")
|
||||||
|
}
|
||||||
|
|
||||||
model LauncherSshKey {
|
model LauncherSshKey {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
accountEmail String @map("account_email")
|
accountEmail String @map("account_email")
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
|
|
||||||
|
import { ProvisioningDeviceDto } from './create-provisioning.dto';
|
||||||
|
|
||||||
|
export class CreateDeviceAuthorizationDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
hostname?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
os?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
arch?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
kernel?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
distro?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
usageType?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sshUser?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
workspaceRoot?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
publicSshKey?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ProvisioningDeviceDto)
|
||||||
|
@IsOptional()
|
||||||
|
device?: ProvisioningDeviceDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApproveDeviceAuthorizationDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userCode!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsumeDeviceAuthorizationDto extends CreateDeviceAuthorizationDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
account!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userCode!: string;
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
import { Body, Controller, Get, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||||
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
||||||
|
import {
|
||||||
|
ConsumeDeviceAuthorizationDto,
|
||||||
|
} from './dto/device-authorization.dto';
|
||||||
import { ProvisioningService } from './provisioning.service';
|
import { ProvisioningService } from './provisioning.service';
|
||||||
import type { ProvisioningResponse } from './provisioning.service';
|
import type { ProvisioningResponse } from './provisioning.service';
|
||||||
|
|
||||||
@@ -31,6 +34,38 @@ export class ProvisioningController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('device-authorizations')
|
||||||
|
createDeviceAuthorization(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
) {
|
||||||
|
return this.provisioningService.createDeviceAuthorization(
|
||||||
|
this.getBearerToken(authorization),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('device-authorizations/current')
|
||||||
|
currentDeviceAuthorization(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
) {
|
||||||
|
return this.provisioningService.getCurrentDeviceAuthorization(
|
||||||
|
this.getBearerToken(authorization),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('device-authorizations/current/cancel')
|
||||||
|
cancelCurrentDeviceAuthorization(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
) {
|
||||||
|
return this.provisioningService.cancelCurrentDeviceAuthorization(
|
||||||
|
this.getBearerToken(authorization),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('device-authorizations/consume')
|
||||||
|
consumeDeviceAuthorization(@Body() body: ConsumeDeviceAuthorizationDto) {
|
||||||
|
return this.provisioningService.consumeDeviceAuthorization(body);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('ssh-keys/lookup')
|
@Post('ssh-keys/lookup')
|
||||||
listSshKeys(
|
listSshKeys(
|
||||||
@Headers('x-lms-gateway-token') token: string | undefined,
|
@Headers('x-lms-gateway-token') token: string | undefined,
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
BadGatewayException,
|
BadGatewayException,
|
||||||
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
GoneException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { createHash } from 'crypto';
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||||
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
||||||
|
import { ConsumeDeviceAuthorizationDto } from './dto/device-authorization.dto';
|
||||||
|
|
||||||
export type ProvisioningResponse = {
|
export type ProvisioningResponse = {
|
||||||
installTailscale: boolean;
|
installTailscale: boolean;
|
||||||
@@ -75,6 +79,141 @@ export class ProvisioningService {
|
|||||||
return this.createProvisioningForUser(provisioningDto, authUser);
|
return this.createProvisioningForUser(provisioningDto, authUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createDeviceAuthorization(accessToken: string | undefined) {
|
||||||
|
const authUser = await this.authenticateAccessToken(accessToken);
|
||||||
|
const expiresInSeconds = Math.max(
|
||||||
|
60,
|
||||||
|
this.configService.get<number>('DEVICE_AUTHORIZATION_TTL_SECONDS', 300),
|
||||||
|
);
|
||||||
|
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const userCode = this.createUserCode();
|
||||||
|
try {
|
||||||
|
await this.prisma.deviceAuthorization.create({
|
||||||
|
data: {
|
||||||
|
deviceCodeHash: this.hashDeviceCode(randomBytes(32).toString('base64url')),
|
||||||
|
userCode,
|
||||||
|
status: 'APPROVED',
|
||||||
|
authUserId: authUser.id,
|
||||||
|
accountEmail: authUser.email,
|
||||||
|
authUserName: authUser.name ?? null,
|
||||||
|
authUserRole: authUser.role ?? null,
|
||||||
|
authUserRoles: authUser.roles ? this.cleanJson(authUser.roles) : undefined,
|
||||||
|
expiresAt,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: userCode,
|
||||||
|
expiresAt,
|
||||||
|
expiresIn: expiresInSeconds,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ConflictException('Unable to issue a device authorization code');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrentDeviceAuthorization(accessToken: string | undefined) {
|
||||||
|
const authUser = await this.authenticateAccessToken(accessToken);
|
||||||
|
const now = new Date();
|
||||||
|
const authorization = await this.prisma.deviceAuthorization.findFirst({
|
||||||
|
where: {
|
||||||
|
authUserId: authUser.id,
|
||||||
|
accountEmail: authUser.email,
|
||||||
|
status: 'APPROVED',
|
||||||
|
device: { equals: Prisma.DbNull },
|
||||||
|
expiresAt: { gt: now },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { userCode: true, expiresAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return authorization
|
||||||
|
? { code: authorization.userCode, expiresAt: authorization.expiresAt }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelCurrentDeviceAuthorization(accessToken: string | undefined) {
|
||||||
|
const authUser = await this.authenticateAccessToken(accessToken);
|
||||||
|
const result = await this.prisma.deviceAuthorization.updateMany({
|
||||||
|
where: {
|
||||||
|
authUserId: authUser.id,
|
||||||
|
accountEmail: authUser.email,
|
||||||
|
status: 'APPROVED',
|
||||||
|
device: { equals: Prisma.DbNull },
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { cancelled: result.count > 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumeDeviceAuthorization(body: ConsumeDeviceAuthorizationDto) {
|
||||||
|
const userCode = this.normalizeUserCode(body.userCode);
|
||||||
|
const authorization = await this.prisma.deviceAuthorization.findUnique({
|
||||||
|
where: { userCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authorization) {
|
||||||
|
throw new NotFoundException('Device authorization request was not found');
|
||||||
|
}
|
||||||
|
if (authorization.expiresAt <= new Date()) {
|
||||||
|
if (authorization.status === 'PENDING' || authorization.status === 'APPROVED') {
|
||||||
|
await this.prisma.deviceAuthorization.update({
|
||||||
|
where: { id: authorization.id },
|
||||||
|
data: { status: 'EXPIRED' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new GoneException('Device authorization request has expired');
|
||||||
|
}
|
||||||
|
if (authorization.status !== 'APPROVED' || !authorization.authUserId || !authorization.accountEmail) {
|
||||||
|
throw new ConflictException('Device authorization request is no longer available');
|
||||||
|
}
|
||||||
|
if (authorization.accountEmail.toLowerCase() !== body.account.trim().toLowerCase()) {
|
||||||
|
throw new UnauthorizedException('Account does not match the registration code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimed = await this.prisma.deviceAuthorization.updateMany({
|
||||||
|
where: { id: authorization.id, status: 'APPROVED' },
|
||||||
|
data: { status: 'CONSUMING' },
|
||||||
|
});
|
||||||
|
if (claimed.count !== 1) {
|
||||||
|
return { status: 'PENDING' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser: NonNullable<AuthLoginResponse['user']> = {
|
||||||
|
id: authorization.authUserId,
|
||||||
|
email: authorization.accountEmail,
|
||||||
|
name: authorization.authUserName,
|
||||||
|
role: authorization.authUserRole ?? undefined,
|
||||||
|
roles: Array.isArray(authorization.authUserRoles)
|
||||||
|
? authorization.authUserRoles.filter((role): role is string => typeof role === 'string')
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const provisioning = await this.createProvisioningForUser(body, authUser);
|
||||||
|
await this.prisma.deviceAuthorization.update({
|
||||||
|
where: { id: authorization.id },
|
||||||
|
data: { status: 'CONSUMED', consumedAt: new Date() },
|
||||||
|
});
|
||||||
|
return provisioning;
|
||||||
|
} catch (error) {
|
||||||
|
await this.prisma.deviceAuthorization.update({
|
||||||
|
where: { id: authorization.id },
|
||||||
|
data: { status: 'APPROVED' },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async createProvisioningForUser(
|
private async createProvisioningForUser(
|
||||||
provisioningDto: ProvisioningInput,
|
provisioningDto: ProvisioningInput,
|
||||||
authUser: NonNullable<AuthLoginResponse['user']>,
|
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||||
@@ -273,6 +412,19 @@ export class ProvisioningService {
|
|||||||
return normalized || null;
|
return normalized || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hashDeviceCode(value: string) {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private createUserCode() {
|
||||||
|
const value = randomBytes(4).readUInt32BE() % 1_000_000;
|
||||||
|
return String(value).padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeUserCode(value: string) {
|
||||||
|
return value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
private normalizePublicSshKey(value?: string | null) {
|
private normalizePublicSshKey(value?: string | null) {
|
||||||
const normalized = value?.trim().replace(/\s+/g, ' ');
|
const normalized = value?.trim().replace(/\s+/g, ' ');
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user