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")
|
||||
}
|
||||
|
||||
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 {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
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 { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
||||
import {
|
||||
ConsumeDeviceAuthorizationDto,
|
||||
} from './dto/device-authorization.dto';
|
||||
import { ProvisioningService } 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')
|
||||
listSshKeys(
|
||||
@Headers('x-lms-gateway-token') token: string | undefined,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
GoneException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { createHash } from 'crypto';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
||||
import { ConsumeDeviceAuthorizationDto } from './dto/device-authorization.dto';
|
||||
|
||||
export type ProvisioningResponse = {
|
||||
installTailscale: boolean;
|
||||
@@ -75,6 +79,141 @@ export class ProvisioningService {
|
||||
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(
|
||||
provisioningDto: ProvisioningInput,
|
||||
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||
@@ -273,6 +412,19 @@ export class ProvisioningService {
|
||||
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) {
|
||||
const normalized = value?.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) return null;
|
||||
|
||||
Reference in New Issue
Block a user