Skip to content

Commit

Permalink
Merge pull request #12 from amphineko/feat-eap-password
Browse files Browse the repository at this point in the history
EAP-PEAP-GTC/MSCHAPv2 with local SQLite password backend
  • Loading branch information
amphineko authored Mar 24, 2024
2 parents 3c813d5 + fc43c95 commit 6c462d9
Show file tree
Hide file tree
Showing 30 changed files with 1,218 additions and 37 deletions.
22 changes: 22 additions & 0 deletions packages/common/api/users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as t from "io-ts"

import { RadiusUserPasswordStatusType, RadiusUserPasswords, RadiusUserType } from "../types/users/RadiusUser"

export const CreateOrUpdateUserRequestType = t.partial({})

export type CreateOrUpdateUserRequest = t.TypeOf<typeof CreateOrUpdateUserRequestType>

export const ListUserResponseType = t.readonlyArray(RadiusUserType)

export type ListUserResponse = t.TypeOf<typeof ListUserResponseType>

export const ListUserPasswordStatusResponseType = t.readonlyArray(RadiusUserPasswordStatusType)

export const UpdateUserPasswordsRequestType: t.Type<Partial<RadiusUserPasswords>> = t.partial({
clearText: t.union([t.string, t.null]),
ntHash: t.union([t.string, t.null]),
ssha: t.union([t.string, t.null]),
ssha512: t.union([t.string, t.null]),
})

export type UpdateUserPasswordsRequest = t.TypeOf<typeof UpdateUserPasswordsRequestType>
59 changes: 59 additions & 0 deletions packages/common/types/users/RadiusUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as t from "io-ts"

import { Username, UsernameType } from "./Username"

export interface RadiusUser {
username: Username
}

interface EncodedRadiusUser {
username: string
}

export const RadiusUserType: t.Type<RadiusUser, EncodedRadiusUser> = t.type({
username: UsernameType,
})

/**
* For each password attribute,
* string = password hash for this method
* null = method is disabled
*/
export interface RadiusUserPasswords {
username: Username
clearText: string | null
ntHash: string | null
ssha512: string | null
}

interface EncodedRadiusUserPasswords {
username: string
clearText: string | null
ntHash: string | null
ssha512: string | null
}

export const RadiusUserPasswordsType: t.Type<RadiusUserPasswords, EncodedRadiusUserPasswords> = t.type({
username: UsernameType,
clearText: t.union([t.string, t.null]),
ntHash: t.union([t.string, t.null]),
ssha512: t.union([t.string, t.null]),
})

/**
* Indicates whether a user has a password set for each method
*/
export type RadiusUserPasswordStatus = {
[K in keyof RadiusUserPasswords]: K extends "username" ? Username : boolean
}

type EncodedRadiusUserPasswordStatus = {
[K in keyof RadiusUserPasswordStatus]: RadiusUserPasswords[K] extends Username ? string : boolean
}

export const RadiusUserPasswordStatusType: t.Type<RadiusUserPasswordStatus, EncodedRadiusUserPasswordStatus> = t.type({
username: UsernameType,
clearText: t.boolean,
ntHash: t.boolean,
ssha512: t.boolean,
})
24 changes: 24 additions & 0 deletions packages/common/types/users/Username.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as E from "fp-ts/lib/Either"
import * as F from "fp-ts/lib/function"
import * as t from "io-ts"

const MAX_USERNAME_LENGTH = 64

const USERNAME_REGEX = /^[a-zA-Z0-9_-]+$/

function isUsername(u: string): boolean {
return u.length > 0 && u.length <= MAX_USERNAME_LENGTH && USERNAME_REGEX.test(u)
}

export type Username = t.Branded<string, { readonly Username: unique symbol }>

export const UsernameType = new t.Type<Username, string, unknown>(
"Username",
(u): u is Username => typeof u === "string" && isUsername(u),
(u, c) =>
F.pipe(
t.string.validate(u, c),
E.chain((u) => (isUsername(u) ? t.success(u as Username) : t.failure(u, c))),
),
t.identity,
)
11 changes: 10 additions & 1 deletion packages/supervisor/src/api/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@ import { MPSKController } from "./mpsks.controller"
import { PkiController } from "./pki.controller"
import { RadiusdController } from "./radiusd.controller"
import { RlmRestController } from "./rlm_rest.controller"
import { RadiusUserController, RadiusUserPasswordController } from "./users.controller"
import { ConfigModule } from "../config"
import { PkiModule } from "../pki/module"
import { RadiusdModule } from "../radiusd/module"
import { RlmRestModule } from "../rlm_rest/module"
import { StorageModule } from "../storages/module"

@Module({
controllers: [MPSKController, PkiController, RadiusClientController, RadiusdController, RlmRestController],
controllers: [
MPSKController,
PkiController,
RadiusClientController,
RadiusdController,
RadiusUserController,
RadiusUserPasswordController,
RlmRestController,
],
imports: [ConfigModule, PkiModule, RadiusdModule, RlmRestModule, StorageModule],
providers: [ResponseInterceptor],
})
Expand Down
41 changes: 35 additions & 6 deletions packages/supervisor/src/api/rlm_rest.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import {
InternalServerErrorException,
NotFoundException,
Post,
forwardRef,
} from "@nestjs/common"
import { Client } from "@yonagi/common/types/Client"
import { NameType } from "@yonagi/common/types/Name"
import { CallingStationIdAuthentication } from "@yonagi/common/types/mpsks/MPSK"
import { RadiusUserPasswords } from "@yonagi/common/types/users/RadiusUser"
import { mapValidationLeftError } from "@yonagi/common/utils/Either"
import { getOrThrow, tryCatchF } from "@yonagi/common/utils/TaskEither"
import * as TE from "fp-ts/lib/TaskEither"
Expand All @@ -28,13 +28,19 @@ import {
RlmRestMacAuthResponse,
RlmRestMacAuthResponseType,
} from "../rlm_rest/types/macAuth"
import { AbstractMPSKStorage } from "../storages"
import {
RlmRestPasswordAuthRequestType,
RlmRestPasswordAuthResponse,
RlmRestPasswordAuthResponseType,
} from "../rlm_rest/types/passwordAuth"
import { AbstractMPSKStorage, AbstractRadiusUserPasswordStorage } from "../storages"

@Controller("/api/v1/rlm_rest")
export class RlmRestController {
constructor(
@Inject(forwardRef(() => AbstractMPSKStorage)) private readonly mpskStorage: AbstractMPSKStorage,
@Inject(forwardRef(() => DynamicClientResolver)) private readonly clientResolver: DynamicClientResolver,
@Inject(AbstractMPSKStorage) private readonly mpskStorage: AbstractMPSKStorage,
@Inject(DynamicClientResolver) private readonly clientResolver: DynamicClientResolver,
@Inject(AbstractRadiusUserPasswordStorage) private readonly passwordStorage: AbstractRadiusUserPasswordStorage,
) {}

@Post("/mac/authorize")
Expand All @@ -58,7 +64,7 @@ export class RlmRestController {
@Post("/clients/authorize")
@EncodeResponseWith(RlmRestClientAuthResponseType)
async authorizeClient(@Body() rawBody: unknown): Promise<RlmRestClientAuthResponse> {
const o = await F.pipe(
return await F.pipe(
TE.Do,
TE.bindW("request", () => TE.fromEither(validateRequestParam(rawBody, RlmRestClientAuthRequestType))),
TE.bindW("client", ({ request: { clientIpAddr } }) =>
Expand All @@ -83,6 +89,29 @@ export class RlmRestController {
TE.map(({ name, client: { secret } }) => ({ name, secret })),
getOrThrow(),
)()
return o
}

@Post("/passwords/authorize")
@EncodeResponseWith(RlmRestPasswordAuthResponseType)
async authorizePassword(@Body() rawBody: unknown): Promise<RlmRestPasswordAuthResponse> {
return await F.pipe(
TE.fromEither(validateRequestParam(rawBody, RlmRestPasswordAuthRequestType)),
tryCatchF(
({ username }) => this.passwordStorage.getByUsername(username),
(reason) => new InternalServerErrorException(reason),
),
TE.filterOrElseW(
(password): password is RadiusUserPasswords => password !== null,
() => new NotFoundException("No password found"),
),
TE.map(
({ clearText, ntHash, ssha512 }): RlmRestPasswordAuthResponse => ({
cleartext: clearText ?? undefined,
nt: ntHash ?? undefined,
ssha512: ssha512 ?? undefined,
}),
),
getOrThrow(),
)()
}
}
108 changes: 108 additions & 0 deletions packages/supervisor/src/api/users.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Inject,
Param,
Post,
UseInterceptors,
} from "@nestjs/common"
import {
CreateOrUpdateUserRequestType,
ListUserPasswordStatusResponseType,
ListUserResponse,
UpdateUserPasswordsRequestType,
} from "@yonagi/common/api/users"
import { RadiusUserPasswordStatus } from "@yonagi/common/types/users/RadiusUser"
import { UsernameType } from "@yonagi/common/types/users/Username"
import { mapValidationLeftError } from "@yonagi/common/utils/Either"
import { getOrThrow, tryCatchF } from "@yonagi/common/utils/TaskEither"
import * as E from "fp-ts/lib/Either"
import * as TE from "fp-ts/lib/TaskEither"
import * as F from "fp-ts/lib/function"

import { ResponseInterceptor } from "./api.middleware"
import { EncodeResponseWith } from "./common"
import { AbstractRadiusUserPasswordStorage, AbstractRadiusUserStorage } from "../storages"

@Controller("/api/v1/users")
@UseInterceptors(ResponseInterceptor)
export class RadiusUserController {
@Get("/")
async all(): Promise<ListUserResponse> {
return await this.storage.all()
}

@Post("/:username")
async createOrUpdate(@Param("username") unknownUsername: unknown, @Body() u: unknown): Promise<void> {
await F.pipe(
TE.fromEither(
F.pipe(
E.Do,
E.bindW("username", () => UsernameType.decode(unknownUsername)),
E.bindW("form", () => CreateOrUpdateUserRequestType.decode(u)),
mapValidationLeftError((e) => new BadRequestException(String(e))),
),
),
tryCatchF(
({ username, form }) => this.storage.createOrUpdate(username, { username, ...form }),
(reason) => new Error(String(reason)),
),
getOrThrow(),
)()
}

@Delete("/:username")
async delete(@Param("username") unknownUsername: unknown): Promise<void> {
await F.pipe(
TE.fromEither(
F.pipe(
UsernameType.decode(unknownUsername),
mapValidationLeftError((e) => new Error(String(e))),
),
),
tryCatchF(
(username) => this.storage.deleteByUsername(username),
(reason) => new Error(String(reason)),
),
getOrThrow(),
)()
}

constructor(@Inject(AbstractRadiusUserStorage) private readonly storage: AbstractRadiusUserStorage) {}
}

@Controller("/api/v1/passwords")
@UseInterceptors(ResponseInterceptor)
export class RadiusUserPasswordController {
@Get("/")
@EncodeResponseWith(ListUserPasswordStatusResponseType)
async all(): Promise<readonly RadiusUserPasswordStatus[]> {
return await this.storage.allStatus()
}

@Post("/:username")
async update(@Param("username") unknownUsername: unknown, @Body() u: unknown): Promise<void> {
await F.pipe(
TE.fromEither(
F.pipe(
E.Do,
E.bindW("username", () => UsernameType.decode(unknownUsername)),
E.bindW("form", () => UpdateUserPasswordsRequestType.decode(u)),
mapValidationLeftError((e) => new BadRequestException(String(e))),
),
),
tryCatchF(
({ username, form }) => this.storage.createOrUpdate(username, form),
(reason) => new Error(String(reason)),
),
getOrThrow(),
)()
}

constructor(
@Inject(AbstractRadiusUserPasswordStorage) private readonly storage: AbstractRadiusUserPasswordStorage,
) {}
}
12 changes: 4 additions & 8 deletions packages/supervisor/src/configs/raddb/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import { generateClients } from "./clients"
import { generateEapModule } from "./modules/eapModule"
import { generateRestModule } from "./modules/restModule"
import { generateModules } from "./modules"
import { patchAuthLogEnable } from "./radiusdConfig"
import { generateDefaultSite } from "./sites/defaultSite"
import { generateDynamicClientSite } from "./sites/dynClientSite"
import { generateSites } from "./sites"
import { RaddbGenParams } from ".."

export async function generateRaddb(params: RaddbGenParams): Promise<void> {
await Promise.all([
generateClients(params),
generateDynamicClientSite(params),
generateDefaultSite(params),
generateEapModule(params),
generateRestModule(params),
generateModules(params),
generateSites(params),
patchAuthLogEnable(params),
])
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ export async function generateEapModule({ pki, raddbPath }: RaddbGenParams): Pro
await writeFile(
configPath,
dedent(`
eap {
default_eap_type = tls
eap outer-eap {
default_eap_type = peap
type = peap
type = tls
timer_expire = 60
max_sessions = \${max_requests}
Expand Down Expand Up @@ -57,6 +60,27 @@ export async function generateEapModule({ pki, raddbPath }: RaddbGenParams): Pro
tls {
tls = tls-common
}
peap {
tls = tls-common
default_eap_type = mschapv2
virtual_server = "inner-tunnel"
copy_request_to_tunnel = yes
}
}
eap inner-eap {
default_eap_type = gtc
type = gtc
type = mschapv2
gtc {
auth_type = PAP
}
mschapv2 {
send_error = yes
}
}
`),
)
Expand Down
8 changes: 8 additions & 0 deletions packages/supervisor/src/configs/raddb/modules/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { generateEapModule } from "./eap"
import { generateMschapModule } from "./mschap"
import { generateRestModule } from "./rest"
import { RaddbGenParams } from "../.."

export async function generateModules(params: RaddbGenParams): Promise<void> {
await Promise.all([generateEapModule(params), generateMschapModule(params), generateRestModule(params)])
}
Loading

0 comments on commit 6c462d9

Please sign in to comment.