Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions libs/contract/api/controllers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const AUTH_ROUTES = {
LOGIN: 'login',
REGISTER: 'register',
GET_STATUS: 'status',
CLOUDFLARE_ACCESS: 'cloudflare-access',

OAUTH2: {
TELEGRAM_CALLBACK: 'oauth2/tg/callback',
Expand Down
1 change: 1 addition & 0 deletions libs/contract/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const REST_API = {
LOGIN: `${ROOT}/${CONTROLLERS.AUTH_CONTROLLER}/${CONTROLLERS.AUTH_ROUTES.LOGIN}`,
REGISTER: `${ROOT}/${CONTROLLERS.AUTH_CONTROLLER}/${CONTROLLERS.AUTH_ROUTES.REGISTER}`,
GET_STATUS: `${ROOT}/${CONTROLLERS.AUTH_CONTROLLER}/${CONTROLLERS.AUTH_ROUTES.GET_STATUS}`,
CLOUDFLARE_ACCESS: `${ROOT}/${CONTROLLERS.AUTH_CONTROLLER}/${CONTROLLERS.AUTH_ROUTES.CLOUDFLARE_ACCESS}`,

OAUTH2: {
TELEGRAM_CALLBACK: `${ROOT}/${CONTROLLERS.AUTH_CONTROLLER}/${CONTROLLERS.AUTH_ROUTES.OAUTH2.TELEGRAM_CALLBACK}`,
Expand Down
27 changes: 27 additions & 0 deletions libs/contract/commands/auth/cloudflare-access.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from 'zod';

import { getEndpointDetails } from '../../constants';
import { AUTH_ROUTES, REST_API } from '../../api';

export namespace CloudflareAccessCommand {
export const url = REST_API.AUTH.CLOUDFLARE_ACCESS;
export const TSQ_url = url;

export const endpointDetails = getEndpointDetails(
AUTH_ROUTES.CLOUDFLARE_ACCESS,
'post',
'Login with Cloudflare Access',
);

export const RequestSchema = z.object({});

export type Request = z.infer<typeof RequestSchema>;

export const ResponseSchema = z.object({
response: z.object({
accessToken: z.string(),
}),
});

export type Response = z.infer<typeof ResponseSchema>;
}
3 changes: 3 additions & 0 deletions libs/contract/commands/auth/get-status.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export namespace GetStatusCommand {
password: z.object({
enabled: z.boolean(),
}),
cloudflareAccess: z.object({
enabled: z.boolean(),
}),
}),
),
branding: z.object({
Expand Down
1 change: 1 addition & 0 deletions libs/contract/commands/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './cloudflare-access.command';
export * from './get-status.command';
export * from './login.command';
export * from './oauth2';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from 'zod';

import {
BrandingSettingsSchema,
CloudflareAccessSettingsSchema,
Oauth2SettingsSchema,
PasskeySettingsSchema,
PasswordAuthSettingsSchema,
Expand All @@ -24,6 +25,7 @@ export namespace UpdateRemnawaveSettingsCommand {
passkeySettings: PasskeySettingsSchema.optional(),
oauth2Settings: Oauth2SettingsSchema.optional(),
passwordSettings: PasswordAuthSettingsSchema.optional(),
cloudflareAccessSettings: CloudflareAccessSettingsSchema.optional(),
brandingSettings: BrandingSettingsSchema.optional(),
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import z from 'zod';

export const CloudflareAccessSettingsSchema = z.object({
enabled: z.boolean(),
teamDomain: z.nullable(z.string()),
audience: z.nullable(z.string()),
emailAllowlistEnabled: z.boolean(),
allowedEmails: z.array(z.string()),
allowedDomains: z.array(z.string()),
});

export type TCloudflareAccessSettings = z.infer<typeof CloudflareAccessSettingsSchema>;
1 change: 1 addition & 0 deletions libs/contract/models/remnawave-settings/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './branding-settings.schema';
export * from './cloudflare-access-settings.schema';
export * from './oauth2-settings.schema';
export * from './passkey-settings.schema';
export * from './password-auth-settings.schema';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';

import { CloudflareAccessSettingsSchema } from './cloudflare-access-settings.schema';
import { PasswordAuthSettingsSchema } from './password-auth-settings.schema';
import { BrandingSettingsSchema } from './branding-settings.schema';
import { PasskeySettingsSchema } from './passkey-settings.schema';
Expand All @@ -9,6 +10,7 @@ export const RemnawaveSettingsSchema = z.object({
passkeySettings: z.nullable(PasskeySettingsSchema),
oauth2Settings: z.nullable(Oauth2SettingsSchema),
passwordSettings: z.nullable(PasswordAuthSettingsSchema),
cloudflareAccessSettings: z.nullable(CloudflareAccessSettingsSchema),
brandingSettings: z.nullable(BrandingSettingsSchema),
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "remnawave_settings" ADD COLUMN "cloudflare_access_settings" JSONB;
6 changes: 4 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ model RemnawaveSettings {
/// [Oauth2Settings]
oauth2Settings Json? @map("oauth2_settings")
/// [PasswordAuthSettings]
passwordSettings Json? @map("password_settings")
passwordSettings Json? @map("password_settings")
/// [CloudflareAccessSettings]
cloudflareAccessSettings Json? @map("cloudflare_access_settings")
/// [BrandingSettings]
brandingSettings Json? @map("branding_settings")
brandingSettings Json? @map("branding_settings")

@@map("remnawave_settings")
}
Expand Down
29 changes: 29 additions & 0 deletions prisma/seed/seeders/3_seed-remnawave-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { PrismaClient, RemnawaveSettings } from '@prisma/client';
import consola from 'consola';

import {
CloudflareAccessSettingsSchema,
Oauth2SettingsSchema,
PasskeySettingsSchema,
TBrandingSettings,
TCloudflareAccessSettings,
TOauth2Settings,
TPasswordAuthSettings,
TRemnawavePasskeySettings,
Expand Down Expand Up @@ -71,10 +73,20 @@ export async function seedRemnawaveSettings(prisma: PrismaClient) {
enabled: true,
};

const DEFAULT_CLOUDFLARE_ACCESS_SETTINGS: TCloudflareAccessSettings = {
enabled: false,
teamDomain: null,
audience: null,
emailAllowlistEnabled: true,
allowedEmails: [],
allowedDomains: [],
};

const settingsMapping = {
passkeySettings: DEFAULT_PASSKEY_SETTINGS,
oauth2Settings: DEFAULT_OAUTH2_SETTINGS,
passwordSettings: DEFAULT_PASSWORD_AUTH_SETTINGS,
cloudflareAccessSettings: DEFAULT_CLOUDFLARE_ACCESS_SETTINGS,
};

const DEFAULT_BRANDING_SETTINGS: TBrandingSettings = {
Expand Down Expand Up @@ -120,6 +132,22 @@ export async function seedRemnawaveSettings(prisma: PrismaClient) {
continue;
}
}

if (key === 'cloudflareAccessSettings') {
if (
!CloudflareAccessSettingsSchema.safeParse(
existingConfig.cloudflareAccessSettings,
).success
) {
consola.warn(`${key} is not valid! Falling back to default...`);
await prisma.remnawaveSettings.update({
where: { id: existingConfig.id },
data: { [key]: DEFAULT_CLOUDFLARE_ACCESS_SETTINGS },
});
consola.success(`${key} updated to default`);
continue;
}
}
}
}
}
Expand All @@ -129,6 +157,7 @@ export async function seedRemnawaveSettings(prisma: PrismaClient) {
passkeySettings: DEFAULT_PASSKEY_SETTINGS,
oauth2Settings: DEFAULT_OAUTH2_SETTINGS,
passwordSettings: DEFAULT_PASSWORD_AUTH_SETTINGS,
cloudflareAccessSettings: DEFAULT_CLOUDFLARE_ACCESS_SETTINGS,
brandingSettings: DEFAULT_BRANDING_SETTINGS,
});

Expand Down
28 changes: 27 additions & 1 deletion src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Body, Controller, HttpStatus, UseFilters } from '@nestjs/common';
import { Body, Controller, Headers, HttpStatus, UseFilters } from '@nestjs/common';

import { GetRemnawaveSettings } from '@common/decorators/get-remnawave-settings';
import { HttpExceptionFilter } from '@common/exception/http-exception.filter';
Expand All @@ -18,6 +18,7 @@ import {
RegisterCommand,
OAuth2AuthorizeCommand,
OAuth2CallbackCommand,
CloudflareAccessCommand,
GetPasskeyAuthenticationOptionsCommand,
VerifyPasskeyAuthenticationCommand,
} from '@libs/contracts/commands';
Expand All @@ -32,6 +33,7 @@ import {
LoginResponseDto,
RegisterRequestDto,
RegisterResponseDto,
CloudflareAccessResponseDto,
OAuth2AuthorizeResponseDto,
OAuth2CallbackResponseDto,
OAuth2CallbackRequestDto,
Expand Down Expand Up @@ -120,6 +122,30 @@ export class AuthController {
};
}

@ApiResponse({
type: CloudflareAccessResponseDto,
description: 'JWT access token after successful Cloudflare Access authentication',
})
@ApiUnauthorizedResponse({
description: 'Unauthorized - Invalid Cloudflare Access assertion',
})
@Endpoint({
command: CloudflareAccessCommand,
httpCode: HttpStatus.OK,
})
async cloudflareAccessLogin(
@Headers('cf-access-jwt-assertion') assertion: string | undefined,
@IpAddress() ip: string,
@UserAgent() userAgent: string,
): Promise<CloudflareAccessResponseDto> {
const result = await this.authService.cloudflareAccessLogin(assertion, ip, userAgent);

const data = errorHandler(result);
return {
response: new AuthResponseModel(data),
};
}

@ApiResponse({
type: OAuth2AuthorizeResponseDto,
description: 'OAuth2 authorization URL',
Expand Down
3 changes: 2 additions & 1 deletion src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { JwtModule } from '@nestjs/jwt';
import { getJWTConfig } from '@common/config/jwt/jwt.config';

import { InjectRemnawaveSettingsMiddleware } from './middlewares/inject-remnawave-settings';
import { CloudflareAccessService } from './services/cloudflare-access.service';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies';
Expand All @@ -14,7 +15,7 @@ import { COMMANDS } from './commands';
@Module({
imports: [CqrsModule, JwtModule.registerAsync(getJWTConfig()), HttpModule],
controllers: [AuthController],
providers: [JwtStrategy, AuthService, ...COMMANDS],
providers: [JwtStrategy, CloudflareAccessService, AuthService, ...COMMANDS],
})
export class AuthModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
Expand Down
93 changes: 93 additions & 0 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
OAuth2CallbackResponseModel,
GetStatusResponseModel,
} from './model';
import { CloudflareAccessService } from './services/cloudflare-access.service';
import { VerifyPasskeyAuthenticationRequestDto } from './dtos';
import { ILogin, IRegister } from './interfaces';

Expand All @@ -68,6 +69,7 @@ export class AuthService {
private readonly commandBus: CommandBus,
private readonly eventEmitter: EventEmitter2,
private readonly httpService: HttpService,
private readonly cloudflareAccessService: CloudflareAccessService,
) {
this.jwtSecret = this.configService.getOrThrow<string>('JWT_AUTH_SECRET');
this.jwtLifetime = this.configService.getOrThrow<number>('JWT_AUTH_LIFETIME');
Expand Down Expand Up @@ -305,6 +307,9 @@ export class AuthService {
password: {
enabled: remnawaveSettings.passwordSettings.enabled,
},
cloudflareAccess: {
enabled: remnawaveSettings.cloudflareAccessSettings?.enabled ?? false,
},
},
branding: remnawaveSettings.brandingSettings,
}),
Expand Down Expand Up @@ -538,6 +543,94 @@ export class AuthService {
}
}

public async cloudflareAccessLogin(
assertion: string | undefined,
ip: string,
userAgent: string,
): Promise<TResult<{ accessToken: string }>> {
try {
const statusResponse = await this.getStatus();

if (!statusResponse.isOk) {
return fail(ERRORS.GET_AUTH_STATUS_ERROR);
}

if (
!statusResponse.response.isLoginAllowed ||
!statusResponse.response.authentication?.cloudflareAccess.enabled
) {
await this.emitFailedLoginAttempt(
'Unknown',
'Cloudflare Access assertion',
ip,
userAgent,
'Cloudflare Access authentication is disabled or login is not allowed.',
);
return fail(ERRORS.FORBIDDEN);
}

const firstAdmin = await this.getFirstAdmin();
if (!firstAdmin.isOk) {
await this.emitFailedLoginAttempt(
'Unknown',
'Cloudflare Access assertion',
ip,
userAgent,
'Superadmin not found.',
);
return fail(ERRORS.FORBIDDEN);
}

const remnawaveSettings = await this.queryBus.execute(
new GetCachedRemnawaveSettingsQuery(),
);

const assertionValidationResult = await this.cloudflareAccessService.validateAssertion(
assertion,
remnawaveSettings.cloudflareAccessSettings ?? {
allowedDomains: [],
allowedEmails: [],
audience: null,
emailAllowlistEnabled: true,
enabled: false,
teamDomain: null,
},
);

if (!assertionValidationResult.isOk) {
await this.emitFailedLoginAttempt(
'Unknown',
'Cloudflare Access assertion',
ip,
userAgent,
'Cloudflare Access assertion validation failed.',
);
return fail(ERRORS.FORBIDDEN);
}

const accessToken = this.jwtService.sign(
{
username: firstAdmin.response.username,
uuid: firstAdmin.response.uuid,
role: ROLE.ADMIN,
},
{ expiresIn: `${this.jwtLifetime}h` },
);

await this.emitLoginSuccess(
assertionValidationResult.response.email,
ip,
userAgent,
'Logged via Cloudflare Access.',
);

return ok({ accessToken });
} catch (error) {
this.logger.error(`Cloudflare Access login error: ${error}`);
return fail(ERRORS.LOGIN_ERROR);
}
}

private async processOAuth2Callback(
provider: TOAuth2ProvidersKeys,
code: string,
Expand Down
7 changes: 7 additions & 0 deletions src/modules/auth/dtos/cloudflare-access.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createZodDto } from 'nestjs-zod';

import { CloudflareAccessCommand } from '@libs/contracts/commands';

export class CloudflareAccessResponseDto extends createZodDto(
CloudflareAccessCommand.ResponseSchema,
) {}
1 change: 1 addition & 0 deletions src/modules/auth/dtos/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './cloudflare-access.dto';
export * from './get-status.dto';
export * from './login.dto';
export * from './oauth2';
Expand Down
Loading