From 61c83b862a3efa4a79109149f5e201f4f6dfed29 Mon Sep 17 00:00:00 2001 From: amphineko Date: Sat, 23 Mar 2024 14:19:14 +0000 Subject: [PATCH 1/5] feat(passwords): eap-peap-gtc/mschapv2 with sqlite user passwords --- packages/common/types/users/RadiusUser.ts | 59 ++++++ packages/common/types/users/Username.ts | 24 +++ packages/supervisor/src/api/module.ts | 11 +- .../supervisor/src/api/rlm_rest.controller.ts | 41 +++- .../supervisor/src/configs/raddb/index.ts | 12 +- .../raddb/modules/{eapModule.ts => eap.ts} | 28 ++- .../src/configs/raddb/modules/index.ts | 8 + .../src/configs/raddb/modules/mschap.ts | 18 ++ .../raddb/modules/{restModule.ts => rest.ts} | 2 + .../sites/{defaultSite.ts => default.ts} | 13 +- .../{dynClientSite.ts => dynamicClients.ts} | 0 .../src/configs/raddb/sites/index.ts | 8 + .../src/configs/raddb/sites/innerTunnel.ts | 58 ++++++ .../src/rlm_rest/types/passwordAuth.ts | 75 +++++++ packages/supervisor/src/storages/index.ts | 20 ++ packages/supervisor/src/storages/module.ts | 25 ++- .../sql/migrations/1700000000003-passwords.ts | 15 ++ .../supervisor/src/storages/sql/sqlite.ts | 6 +- packages/supervisor/src/storages/sql/users.ts | 194 ++++++++++++++++++ scripts/test-eap-gtc-bad.sh | 17 ++ scripts/test-eap-gtc.sh | 17 ++ scripts/test-eap-mschapv2.sh | 17 ++ 22 files changed, 638 insertions(+), 30 deletions(-) create mode 100644 packages/common/types/users/RadiusUser.ts create mode 100644 packages/common/types/users/Username.ts rename packages/supervisor/src/configs/raddb/modules/{eapModule.ts => eap.ts} (71%) create mode 100644 packages/supervisor/src/configs/raddb/modules/index.ts create mode 100644 packages/supervisor/src/configs/raddb/modules/mschap.ts rename packages/supervisor/src/configs/raddb/modules/{restModule.ts => rest.ts} (91%) rename packages/supervisor/src/configs/raddb/sites/{defaultSite.ts => default.ts} (88%) rename packages/supervisor/src/configs/raddb/sites/{dynClientSite.ts => dynamicClients.ts} (100%) create mode 100644 packages/supervisor/src/configs/raddb/sites/index.ts create mode 100644 packages/supervisor/src/configs/raddb/sites/innerTunnel.ts create mode 100644 packages/supervisor/src/rlm_rest/types/passwordAuth.ts create mode 100644 packages/supervisor/src/storages/sql/migrations/1700000000003-passwords.ts create mode 100644 packages/supervisor/src/storages/sql/users.ts create mode 100755 scripts/test-eap-gtc-bad.sh create mode 100755 scripts/test-eap-gtc.sh create mode 100755 scripts/test-eap-mschapv2.sh diff --git a/packages/common/types/users/RadiusUser.ts b/packages/common/types/users/RadiusUser.ts new file mode 100644 index 0000000..3199708 --- /dev/null +++ b/packages/common/types/users/RadiusUser.ts @@ -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 = 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 = 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 = t.type({ + username: UsernameType, + clearText: t.boolean, + ntHash: t.boolean, + ssha512: t.boolean, +}) diff --git a/packages/common/types/users/Username.ts b/packages/common/types/users/Username.ts new file mode 100644 index 0000000..a6a4108 --- /dev/null +++ b/packages/common/types/users/Username.ts @@ -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 + +export const UsernameType = new t.Type( + "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, +) diff --git a/packages/supervisor/src/api/module.ts b/packages/supervisor/src/api/module.ts index 36d26ee..9896dff 100644 --- a/packages/supervisor/src/api/module.ts +++ b/packages/supervisor/src/api/module.ts @@ -6,6 +6,7 @@ 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" @@ -13,7 +14,15 @@ 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], }) diff --git a/packages/supervisor/src/api/rlm_rest.controller.ts b/packages/supervisor/src/api/rlm_rest.controller.ts index 3e246c0..ed5d086 100644 --- a/packages/supervisor/src/api/rlm_rest.controller.ts +++ b/packages/supervisor/src/api/rlm_rest.controller.ts @@ -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" @@ -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") @@ -58,7 +64,7 @@ export class RlmRestController { @Post("/clients/authorize") @EncodeResponseWith(RlmRestClientAuthResponseType) async authorizeClient(@Body() rawBody: unknown): Promise { - const o = await F.pipe( + return await F.pipe( TE.Do, TE.bindW("request", () => TE.fromEither(validateRequestParam(rawBody, RlmRestClientAuthRequestType))), TE.bindW("client", ({ request: { clientIpAddr } }) => @@ -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 { + 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(), + )() } } diff --git a/packages/supervisor/src/configs/raddb/index.ts b/packages/supervisor/src/configs/raddb/index.ts index 9d5efc1..b8a1b44 100644 --- a/packages/supervisor/src/configs/raddb/index.ts +++ b/packages/supervisor/src/configs/raddb/index.ts @@ -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 { await Promise.all([ generateClients(params), - generateDynamicClientSite(params), - generateDefaultSite(params), - generateEapModule(params), - generateRestModule(params), + generateModules(params), + generateSites(params), patchAuthLogEnable(params), ]) } diff --git a/packages/supervisor/src/configs/raddb/modules/eapModule.ts b/packages/supervisor/src/configs/raddb/modules/eap.ts similarity index 71% rename from packages/supervisor/src/configs/raddb/modules/eapModule.ts rename to packages/supervisor/src/configs/raddb/modules/eap.ts index ca4fdf0..267da6c 100644 --- a/packages/supervisor/src/configs/raddb/modules/eapModule.ts +++ b/packages/supervisor/src/configs/raddb/modules/eap.ts @@ -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} @@ -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 + } } `), ) diff --git a/packages/supervisor/src/configs/raddb/modules/index.ts b/packages/supervisor/src/configs/raddb/modules/index.ts new file mode 100644 index 0000000..a13438d --- /dev/null +++ b/packages/supervisor/src/configs/raddb/modules/index.ts @@ -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 { + await Promise.all([generateEapModule(params), generateMschapModule(params), generateRestModule(params)]) +} diff --git a/packages/supervisor/src/configs/raddb/modules/mschap.ts b/packages/supervisor/src/configs/raddb/modules/mschap.ts new file mode 100644 index 0000000..c84d09d --- /dev/null +++ b/packages/supervisor/src/configs/raddb/modules/mschap.ts @@ -0,0 +1,18 @@ +import { writeFile } from "fs/promises" +import * as path from "node:path" + +import { RaddbGenParams } from "../.." +import { dedent } from "../../indents" + +export async function generateMschapModule({ raddbPath }: RaddbGenParams): Promise { + await writeFile( + path.join(raddbPath, "mods-enabled", "mschap"), + dedent(` + mschap { + use_mppe = yes + require_encryption = yes + require_strong = yes + } + `), + ) +} diff --git a/packages/supervisor/src/configs/raddb/modules/restModule.ts b/packages/supervisor/src/configs/raddb/modules/rest.ts similarity index 91% rename from packages/supervisor/src/configs/raddb/modules/restModule.ts rename to packages/supervisor/src/configs/raddb/modules/rest.ts index 75f4daa..9327051 100644 --- a/packages/supervisor/src/configs/raddb/modules/restModule.ts +++ b/packages/supervisor/src/configs/raddb/modules/rest.ts @@ -26,6 +26,8 @@ export async function generateRestModule({ raddbPath }: RaddbGenParams): Promise createRestModule("dyn_clients", "clients", baseUrl), // mac auth createRestModule("mac_auth", "mac", baseUrl), + // password-based eap + createRestModule("passwords", "passwords", baseUrl), ] const configPath = path.join(raddbPath, "mods-enabled", "rest") diff --git a/packages/supervisor/src/configs/raddb/sites/defaultSite.ts b/packages/supervisor/src/configs/raddb/sites/default.ts similarity index 88% rename from packages/supervisor/src/configs/raddb/sites/defaultSite.ts rename to packages/supervisor/src/configs/raddb/sites/default.ts index d7ade0e..8626421 100644 --- a/packages/supervisor/src/configs/raddb/sites/defaultSite.ts +++ b/packages/supervisor/src/configs/raddb/sites/default.ts @@ -15,16 +15,12 @@ export async function generateDefaultSite({ pki, raddbPath }: RaddbGenParams): P let eapAuthenticate = "" if (pki) { eapAuthorize = ` - # eap - eap { - ok = return - } + outer-eap { + ok = return + } ` eapAuthenticate = ` - eap - Auth-Type EAP { - eap - } + outer-eap ` } else { logger.info("No PKI deployed, disabling EAP for default site") @@ -34,7 +30,6 @@ export async function generateDefaultSite({ pki, raddbPath }: RaddbGenParams): P defaultSitePath, dedent(` server default { - listen { ipaddr = * type = auth diff --git a/packages/supervisor/src/configs/raddb/sites/dynClientSite.ts b/packages/supervisor/src/configs/raddb/sites/dynamicClients.ts similarity index 100% rename from packages/supervisor/src/configs/raddb/sites/dynClientSite.ts rename to packages/supervisor/src/configs/raddb/sites/dynamicClients.ts diff --git a/packages/supervisor/src/configs/raddb/sites/index.ts b/packages/supervisor/src/configs/raddb/sites/index.ts new file mode 100644 index 0000000..4cc9a48 --- /dev/null +++ b/packages/supervisor/src/configs/raddb/sites/index.ts @@ -0,0 +1,8 @@ +import { generateDefaultSite } from "./default" +import { generateDynamicClientSite } from "./dynamicClients" +import { generateInnerTunnelSite } from "./innerTunnel" +import { RaddbGenParams } from "../.." + +export async function generateSites(params: RaddbGenParams): Promise { + await Promise.all([generateDefaultSite(params), generateDynamicClientSite(params), generateInnerTunnelSite(params)]) +} diff --git a/packages/supervisor/src/configs/raddb/sites/innerTunnel.ts b/packages/supervisor/src/configs/raddb/sites/innerTunnel.ts new file mode 100644 index 0000000..b448172 --- /dev/null +++ b/packages/supervisor/src/configs/raddb/sites/innerTunnel.ts @@ -0,0 +1,58 @@ +import { writeFile } from "node:fs/promises" +import * as path from "node:path" + +import pino from "pino" + +import { RaddbGenParams } from "../.." +import { dedent } from "../../indents" + +const logger = pino({ name: `${path.basename(__dirname)}/${path.basename(__filename)}` }) + +export async function generateInnerTunnelSite({ pki, raddbPath }: RaddbGenParams): Promise { + const filePath = path.join(raddbPath, "sites-enabled", "inner-tunnel") + + if (!pki) { + await writeFile(filePath, "") + logger.info("No PKI deployed, disabling EAP for inner-tunnel site") + return + } + + await writeFile( + filePath, + dedent(` + server inner-tunnel { + + listen { + ipaddr = * + type = auth + port = 18120 + } + + authorize { + filter_username + filter_password + preprocess + + rest_passwords + inner-eap { + ok = return + } + } + + authenticate { + # EAP-PEAP-GTC + Auth-Type PAP { + pap + } + + # EAP-PEAP-MSCHAPv2 + Auth-Type MS-CHAP { + mschap + } + + inner-eap + } + } + `), + ) +} diff --git a/packages/supervisor/src/rlm_rest/types/passwordAuth.ts b/packages/supervisor/src/rlm_rest/types/passwordAuth.ts new file mode 100644 index 0000000..f72fc9a --- /dev/null +++ b/packages/supervisor/src/rlm_rest/types/passwordAuth.ts @@ -0,0 +1,75 @@ +import { Username, UsernameType } from "@yonagi/common/types/users/Username" +import * as E from "fp-ts/lib/Either" +import * as F from "fp-ts/lib/function" +import * as t from "io-ts" + +import { attribute } from "./common" + +export interface RlmRestPasswordAuthRequest { + username: Username +} + +const BaseRlmRestPasswordAuthRequest = t.partial({ + username: t.string, +}) + +const EncodedRlmRestPasswordAuthRequest = t.type({ + "User-Name": attribute("Username", "string", UsernameType), +}) + +export const RlmRestPasswordAuthRequestType = new t.Type< + RlmRestPasswordAuthRequest, + t.OutputOf +>( + "RlmRestPasswordAuthRequest", + (u): u is RlmRestPasswordAuthRequest => E.isRight(BaseRlmRestPasswordAuthRequest.validate(u, [])), + (u, c) => + F.pipe( + EncodedRlmRestPasswordAuthRequest.validate(u, c), + E.map(({ "User-Name": username }) => ({ username })), + ), + () => { + throw new Error("Encoding of this type is unnecessary") + }, +) + +export interface RlmRestPasswordAuthResponse { + cleartext?: string + nt?: string + ssha?: string + ssha512?: string +} + +const BaseRlmRestPasswordAuthResponse = t.partial({ + cleartext: t.string, + nt: t.string, + ssha: t.string, + ssha512: t.string, +}) + +const EncodedRlmRestPasswordAuthResponseType = t.partial({ + "control:Cleartext-Password": attribute("Cleartext-Password", "string", t.string), + "control:LM-Password": attribute("LM-Password", "string", t.string), + "control:NT-Password": attribute("NT-Password", "string", t.string), + "control:SSHA-Password": attribute("SSHA-Password", "string", t.string), + "control:SSHA2-512-Password": attribute("SSHA2-512-Password", "string", t.string), +}) + +export const RlmRestPasswordAuthResponseType = new t.Type< + RlmRestPasswordAuthResponse, + t.OutputOf +>( + "RlmRestPasswordAuthResponse", + (u): u is RlmRestPasswordAuthResponse => E.isRight(BaseRlmRestPasswordAuthResponse.validate(u, [])), + () => { + throw new Error("Decoding of this type is unnecessary") + }, + ({ cleartext, nt, ssha, ssha512 }) => { + const response: t.TypeOf = {} + if (cleartext) response["control:Cleartext-Password"] = cleartext + if (nt) response["control:NT-Password"] = nt + if (ssha) response["control:SSHA-Password"] = ssha + if (ssha512) response["control:SSHA2-512-Password"] = ssha512 + return EncodedRlmRestPasswordAuthResponseType.encode(response) + }, +) diff --git a/packages/supervisor/src/storages/index.ts b/packages/supervisor/src/storages/index.ts index 061385d..f8f106c 100644 --- a/packages/supervisor/src/storages/index.ts +++ b/packages/supervisor/src/storages/index.ts @@ -4,6 +4,8 @@ import { CallingStationId } from "@yonagi/common/types/mpsks/CallingStationId" import { CallingStationIdAuthentication } from "@yonagi/common/types/mpsks/MPSK" import { CertificateMetadata } from "@yonagi/common/types/pki/CertificateMetadata" import { SerialNumberString } from "@yonagi/common/types/pki/SerialNumberString" +import { RadiusUser, RadiusUserPasswordStatus, RadiusUserPasswords } from "@yonagi/common/types/users/RadiusUser" +import { Username } from "@yonagi/common/types/users/Username" export abstract class AbstractClientStorage { abstract all(): Promise @@ -69,3 +71,21 @@ export abstract class AbstractCertificateStorage { abstract isClientCertificateRevokedBySerialNumber(serialNumber: SerialNumberString): Promise abstract revokeClientCertificate(serialNumber: SerialNumberString): Promise } + +export abstract class AbstractRadiusUserStorage { + abstract all(): Promise + + abstract createOrUpdate(username: Username, record: Partial): Promise + + abstract deleteByUsername(username: Username): Promise +} + +export abstract class AbstractRadiusUserPasswordStorage { + abstract allStatus(): Promise + + abstract createOrUpdate(username: Username, record: Partial): Promise + + abstract deleteByUsername(username: Username): Promise + + abstract getByUsername(username: Username): Promise +} diff --git a/packages/supervisor/src/storages/module.ts b/packages/supervisor/src/storages/module.ts index d8b8bea..3091ff9 100644 --- a/packages/supervisor/src/storages/module.ts +++ b/packages/supervisor/src/storages/module.ts @@ -1,15 +1,28 @@ import { Inject, Module, OnApplicationBootstrap, OnApplicationShutdown, forwardRef } from "@nestjs/common" import { DataSource } from "typeorm" -import { AbstractCertificateStorage, AbstractClientStorage, AbstractMPSKStorage } from "." +import { + AbstractCertificateStorage, + AbstractClientStorage, + AbstractMPSKStorage, + AbstractRadiusUserPasswordStorage, + AbstractRadiusUserStorage, +} from "." import { SqlClientStorage } from "./sql/clients" import { SqlMPSKStorage } from "./sql/mpsks" import { SqliteCertificateStorage } from "./sql/pki" import { SqliteDataSource } from "./sql/sqlite" +import { SqlRadiusUserPasswordStorage, SqlRadiusUserStorage } from "./sql/users" import { Config, ConfigModule } from "../config" @Module({ - exports: [AbstractCertificateStorage, AbstractClientStorage, AbstractMPSKStorage], + exports: [ + AbstractCertificateStorage, + AbstractClientStorage, + AbstractMPSKStorage, + AbstractRadiusUserPasswordStorage, + AbstractRadiusUserStorage, + ], imports: [ConfigModule], providers: [ { @@ -29,6 +42,14 @@ import { Config, ConfigModule } from "../config" provide: AbstractMPSKStorage, useClass: SqlMPSKStorage, }, + { + provide: AbstractRadiusUserPasswordStorage, + useClass: SqlRadiusUserPasswordStorage, + }, + { + provide: AbstractRadiusUserStorage, + useClass: SqlRadiusUserStorage, + }, ], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class diff --git a/packages/supervisor/src/storages/sql/migrations/1700000000003-passwords.ts b/packages/supervisor/src/storages/sql/migrations/1700000000003-passwords.ts new file mode 100644 index 0000000..ec3f40b --- /dev/null +++ b/packages/supervisor/src/storages/sql/migrations/1700000000003-passwords.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Passwords1700000000003 implements MigrationInterface { + name = 'Passwords1700000000003' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "user_passwords" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "username" varchar NOT NULL, "cleartext" varchar(32), "nthash" varchar(32), "ssha512" varchar(192))`); + await queryRunner.query(`INSERT INTO "user_passwords"("username", "cleartext", "nthash", "ssha512") VALUES ('test', NULL, NULL, '0x2a757bad0fa1cc04d513b3ea2122d7d3e7d134df8e17f901d88e29a489752e6cf60e7ea8ebfbc9239616f009a43ea13fac872d7c7b7831c08f26b3119058dc16905009f723ca4695398cfdb71e4b970f')`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "user_passwords"`); + } + +} diff --git a/packages/supervisor/src/storages/sql/sqlite.ts b/packages/supervisor/src/storages/sql/sqlite.ts index 2ce2b33..7657400 100644 --- a/packages/supervisor/src/storages/sql/sqlite.ts +++ b/packages/supervisor/src/storages/sql/sqlite.ts @@ -8,14 +8,16 @@ import { DataSourceOptions } from "typeorm/browser" import { entities as ClientEntities } from "./clients" import { Initial1700000000001 } from "./migrations/1700000000001-initial" import { SqlitePki1700000000002 } from "./migrations/1700000000002-sqlite-pki" +import { Passwords1700000000003 } from "./migrations/1700000000003-passwords" import { entities as MPSKEntities } from "./mpsks" import { entities as PkiEntities } from "./pki" +import { entities as UserEntities } from "./users" import { createLogger } from "../../common/logger" import { Config } from "../../config" const logger = createLogger(`${basename(__dirname)}/${basename(__filename)}`) -const entities = [...ClientEntities, ...MPSKEntities, ...PkiEntities] +const entities = [...ClientEntities, ...MPSKEntities, ...PkiEntities, ...UserEntities] class PinoTypeormLogger extends AbstractLogger { private readonly _logger: pino.Logger @@ -74,7 +76,7 @@ function createSqliteDataSourceOptions(filePath: string, enableSynchronize: bool database: filePath, entities, logger: new PinoTypeormLogger(), - migrations: [Initial1700000000001, SqlitePki1700000000002], + migrations: [Initial1700000000001, SqlitePki1700000000002, Passwords1700000000003], migrationsRun: true, migrationsTableName: "migrations", synchronize: enableSynchronize, diff --git a/packages/supervisor/src/storages/sql/users.ts b/packages/supervisor/src/storages/sql/users.ts new file mode 100644 index 0000000..4399b93 --- /dev/null +++ b/packages/supervisor/src/storages/sql/users.ts @@ -0,0 +1,194 @@ +import { Inject, Injectable } from "@nestjs/common" +import { RadiusUser, RadiusUserPasswordStatus, RadiusUserPasswords } from "@yonagi/common/types/users/RadiusUser" +import { Username, UsernameType } from "@yonagi/common/types/users/Username" +import { mapValidationLeftError } from "@yonagi/common/utils/Either" +import { getOrThrow } from "@yonagi/common/utils/TaskEither" +import * as A from "fp-ts/lib/Array" +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 * as t from "io-ts" +import { BaseEntity, Column, DataSource, Entity, EntityManager, PrimaryGeneratedColumn, Repository } from "typeorm" + +import { AbstractRadiusUserPasswordStorage, AbstractRadiusUserStorage } from ".." + +/** + * TODO(amphineko): in the future when user records are implemented, + * passwords should be deleted before users, + * and password records should have a foreign key to user records. + */ +@Entity("user_passwords") +export class RadiusUserPasswordEntity extends BaseEntity { + @PrimaryGeneratedColumn("increment") + public id!: number + + @Column({ name: "username", nullable: false }) + public username!: string + + @Column({ name: "cleartext", nullable: true, default: null, type: "varchar", length: 32 }) + public clearText!: string | null + + @Column({ name: "nthash", nullable: true, default: null, type: "varchar", length: 32 }) + public ntHash!: string | null + + @Column({ name: "ssha512", nullable: true, default: null, type: "varchar", length: 192 }) + public ssha512!: string | null + + constructor(username?: string) { + super() + if (username) { + this.username = username + } + } +} + +@Injectable() +export class SqlRadiusUserStorage extends AbstractRadiusUserStorage { + async all(): Promise { + return await F.pipe( + TE.tryCatch(() => this.repository.find({ select: ["username"] }), E.toError), + TE.flatMapEither( + F.flow( + A.map(({ username }) => UsernameType.decode(username)), + A.map(E.map((username): RadiusUser => ({ username }))), + A.sequence(E.Applicative), + mapValidationLeftError((error) => new Error(error)), + ), + ), + getOrThrow(), + )() + } + + async createOrUpdate(username: Username, record: Partial): Promise { + if (username !== record.username) { + throw new Error("Username doesn't match") + } + + await this.passwordStorage.createOrUpdate(username, { username }) + } + + async deleteByUsername(username: Username): Promise { + return await this.passwordStorage.deleteByUsername(username) + } + + private readonly repository: Repository + + constructor( + @Inject(DataSource) dataSource: DataSource, + @Inject(AbstractRadiusUserPasswordStorage) private readonly passwordStorage: AbstractRadiusUserPasswordStorage, + ) { + super() + this.repository = dataSource.manager.getRepository(RadiusUserPasswordEntity) + } +} + +const BooleanFromSql = new t.Type( + "SqliteBoolean", + (u): u is boolean => typeof u === "boolean", + (u, c) => { + if (u === 0 || u === "0") return t.success(false) + if (u === 1 || u === "1") return t.success(true) + return t.failure(u, c, "expected 0 or 1") + }, + (a) => (a ? 1 : 0), +) + +const RadiusUserPasswordStatusFromSqlType: t.Type = t.type({ + username: UsernameType, + clearText: BooleanFromSql, + ntHash: BooleanFromSql, + ssha512: BooleanFromSql, +}) + +@Injectable() +export class SqlRadiusUserPasswordStorage extends AbstractRadiusUserPasswordStorage { + async allStatus(): Promise { + return await F.pipe( + TE.tryCatch( + () => + // NOTE(amphineko): alias of each column should match the key of RadiusUserPasswordStatus + this.repository + .createQueryBuilder("password") + .select("password.username", "username") + .addSelect("password.cleartext IS NOT NULL", "clearText") + .addSelect("password.nthash IS NOT NULL", "ntHash") + .addSelect("password.ssha512 IS NOT NULL", "ssha512") + .getRawMany(), + E.toError, + ), + TE.flatMapEither( + F.flow( + A.map((row) => RadiusUserPasswordStatusFromSqlType.decode(row)), + A.sequence(E.Applicative), + mapValidationLeftError((error) => new Error(error)), + ), + ), + getOrThrow(), + )() + } + + /** + * For each password attribute, + * string = password hash for this method + * null = method is disabled + * undefined = no change + */ + async createOrUpdate(username: Username, record: Partial): Promise { + if (username !== record.username) { + throw new Error("Username doesn't match") + } + + await this.manager.transaction(async (manager) => { + await manager + .findOneBy(RadiusUserPasswordEntity, { username }) + .then((entity) => entity ?? new RadiusUserPasswordEntity(username)) + .then((entity) => { + if (record.clearText !== undefined) entity.clearText = record.clearText + if (record.ntHash !== undefined) entity.ntHash = record.ntHash + if (record.ssha512 !== undefined) entity.ssha512 = record.ssha512 + return manager.save(entity) + }) + }) + } + + async deleteByUsername(username: Username): Promise { + const { affected } = await this.manager.delete(RadiusUserPasswordEntity, { username }) + if (typeof affected !== "number") { + throw new Error("Driver doesn't support affected rows") + } + return affected > 0 + } + + async getByUsername(username: Username): Promise { + return await F.pipe( + TE.tryCatch(() => this.repository.findOneBy({ username }), E.toError), + TE.flatMap((entity) => (entity ? TE.fromEither(this.decodeEntity(entity)) : TE.right(null))), + getOrThrow(), + )() + } + + private decodeEntity({ + username, + clearText, + ntHash, + ssha512, + }: RadiusUserPasswordEntity): E.Either { + return F.pipe( + UsernameType.decode(username), + E.map((username) => ({ username, clearText, ntHash, ssha512 })), + mapValidationLeftError((error) => new Error(error)), + ) + } + + private readonly manager: EntityManager + + private readonly repository: Repository + + constructor(@Inject(DataSource) dataSource: DataSource) { + super() + this.manager = dataSource.manager + this.repository = dataSource.manager.getRepository(RadiusUserPasswordEntity) + } +} + +export const entities = [RadiusUserPasswordEntity] diff --git a/scripts/test-eap-gtc-bad.sh b/scripts/test-eap-gtc-bad.sh new file mode 100755 index 0000000..399d883 --- /dev/null +++ b/scripts/test-eap-gtc-bad.sh @@ -0,0 +1,17 @@ +set -o errexit -o nounset -o pipefail + +# run eapol_test +config=$(mktemp) +cat < "$config" +network={ + ssid="neko" + key_mgmt=WPA-EAP + eap=PEAP + identity="test" + anonymous_identity="anonymous" + password="unmatched" + phase2="auth=GTC" +} +EOF + +eapol_test -c "$config" -s $1 diff --git a/scripts/test-eap-gtc.sh b/scripts/test-eap-gtc.sh new file mode 100755 index 0000000..3651f32 --- /dev/null +++ b/scripts/test-eap-gtc.sh @@ -0,0 +1,17 @@ +set -o errexit -o nounset -o pipefail + +# run eapol_test +config=$(mktemp) +cat < "$config" +network={ + ssid="neko" + key_mgmt=WPA-EAP + eap=PEAP + identity="test" + anonymous_identity="anonymous" + password="test" + phase2="auth=GTC" +} +EOF + +eapol_test -c "$config" -s $1 diff --git a/scripts/test-eap-mschapv2.sh b/scripts/test-eap-mschapv2.sh new file mode 100755 index 0000000..94ed097 --- /dev/null +++ b/scripts/test-eap-mschapv2.sh @@ -0,0 +1,17 @@ +set -o errexit -o nounset -o pipefail + +# run eapol_test +config=$(mktemp) +cat < "$config" +network={ + ssid="neko" + key_mgmt=WPA-EAP + eap=PEAP + identity="test" + anonymous_identity="anonymous" + password="test" + phase2="auth=MSCHAPV2" +} +EOF + +eapol_test -c "$config" -s $1 From 28b21f25e0e68b72815fb937c94567ddb89455b3 Mon Sep 17 00:00:00 2001 From: "Restyled.io" Date: Sat, 23 Mar 2024 14:22:05 +0000 Subject: [PATCH 2/5] Restyled by shellcheck --- scripts/test-eap-gtc-bad.sh | 2 +- scripts/test-eap-gtc.sh | 2 +- scripts/test-eap-mschapv2.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/test-eap-gtc-bad.sh b/scripts/test-eap-gtc-bad.sh index 399d883..686f17c 100755 --- a/scripts/test-eap-gtc-bad.sh +++ b/scripts/test-eap-gtc-bad.sh @@ -14,4 +14,4 @@ network={ } EOF -eapol_test -c "$config" -s $1 +eapol_test -c "$config" -s "$1" diff --git a/scripts/test-eap-gtc.sh b/scripts/test-eap-gtc.sh index 3651f32..7c2ab02 100755 --- a/scripts/test-eap-gtc.sh +++ b/scripts/test-eap-gtc.sh @@ -14,4 +14,4 @@ network={ } EOF -eapol_test -c "$config" -s $1 +eapol_test -c "$config" -s "$1" diff --git a/scripts/test-eap-mschapv2.sh b/scripts/test-eap-mschapv2.sh index 94ed097..b60b288 100755 --- a/scripts/test-eap-mschapv2.sh +++ b/scripts/test-eap-mschapv2.sh @@ -14,4 +14,4 @@ network={ } EOF -eapol_test -c "$config" -s $1 +eapol_test -c "$config" -s "$1" From 2239e2d0a876b26ee61b24c294b109b31db4f306 Mon Sep 17 00:00:00 2001 From: "Restyled.io" Date: Sat, 23 Mar 2024 14:22:21 +0000 Subject: [PATCH 3/5] Restyled by shfmt --- scripts/test-eap-gtc-bad.sh | 2 +- scripts/test-eap-gtc.sh | 2 +- scripts/test-eap-mschapv2.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/test-eap-gtc-bad.sh b/scripts/test-eap-gtc-bad.sh index 686f17c..c8127f2 100755 --- a/scripts/test-eap-gtc-bad.sh +++ b/scripts/test-eap-gtc-bad.sh @@ -2,7 +2,7 @@ set -o errexit -o nounset -o pipefail # run eapol_test config=$(mktemp) -cat < "$config" +cat <"$config" network={ ssid="neko" key_mgmt=WPA-EAP diff --git a/scripts/test-eap-gtc.sh b/scripts/test-eap-gtc.sh index 7c2ab02..f6f44cf 100755 --- a/scripts/test-eap-gtc.sh +++ b/scripts/test-eap-gtc.sh @@ -2,7 +2,7 @@ set -o errexit -o nounset -o pipefail # run eapol_test config=$(mktemp) -cat < "$config" +cat <"$config" network={ ssid="neko" key_mgmt=WPA-EAP diff --git a/scripts/test-eap-mschapv2.sh b/scripts/test-eap-mschapv2.sh index b60b288..c0e60bd 100755 --- a/scripts/test-eap-mschapv2.sh +++ b/scripts/test-eap-mschapv2.sh @@ -2,7 +2,7 @@ set -o errexit -o nounset -o pipefail # run eapol_test config=$(mktemp) -cat < "$config" +cat <"$config" network={ ssid="neko" key_mgmt=WPA-EAP From b05c992037cd7704ad098fd17e9b4e981421c31b Mon Sep 17 00:00:00 2001 From: "Restyled.io" Date: Sat, 23 Mar 2024 14:22:22 +0000 Subject: [PATCH 4/5] Restyled by whitespace --- packages/supervisor/src/configs/raddb/modules/eap.ts | 2 +- packages/supervisor/src/configs/raddb/sites/default.ts | 6 +++--- .../supervisor/src/configs/raddb/sites/dynamicClients.ts | 4 ++-- packages/supervisor/src/configs/raddb/sites/innerTunnel.ts | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/supervisor/src/configs/raddb/modules/eap.ts b/packages/supervisor/src/configs/raddb/modules/eap.ts index 267da6c..7c03740 100644 --- a/packages/supervisor/src/configs/raddb/modules/eap.ts +++ b/packages/supervisor/src/configs/raddb/modules/eap.ts @@ -60,7 +60,7 @@ export async function generateEapModule({ pki, raddbPath }: RaddbGenParams): Pro tls { tls = tls-common } - + peap { tls = tls-common default_eap_type = mschapv2 diff --git a/packages/supervisor/src/configs/raddb/sites/default.ts b/packages/supervisor/src/configs/raddb/sites/default.ts index 8626421..221a06d 100644 --- a/packages/supervisor/src/configs/raddb/sites/default.ts +++ b/packages/supervisor/src/configs/raddb/sites/default.ts @@ -35,13 +35,13 @@ export async function generateDefaultSite({ pki, raddbPath }: RaddbGenParams): P type = auth port = 1812 } - + authorize { filter_username filter_password preprocess rewrite_calling_station_id - + if (!EAP-Message) { # non-802.1x: mac auth rest_mac_auth @@ -54,7 +54,7 @@ export async function generateDefaultSite({ pki, raddbPath }: RaddbGenParams): P ${eapAuthorize} } } - + authenticate { ${eapAuthenticate} } diff --git a/packages/supervisor/src/configs/raddb/sites/dynamicClients.ts b/packages/supervisor/src/configs/raddb/sites/dynamicClients.ts index fcbb3da..902e5d2 100644 --- a/packages/supervisor/src/configs/raddb/sites/dynamicClients.ts +++ b/packages/supervisor/src/configs/raddb/sites/dynamicClients.ts @@ -13,11 +13,11 @@ export async function generateDynamicClientSite({ raddbPath }: RaddbGenParams): client dynamic-v4 { ipaddr = 0.0.0.0 netmask = 0 - + dynamic_clients = dyn_client_server lifetime = 1 } - + server dyn_client_server { authorize { update request { diff --git a/packages/supervisor/src/configs/raddb/sites/innerTunnel.ts b/packages/supervisor/src/configs/raddb/sites/innerTunnel.ts index b448172..2554dd7 100644 --- a/packages/supervisor/src/configs/raddb/sites/innerTunnel.ts +++ b/packages/supervisor/src/configs/raddb/sites/innerTunnel.ts @@ -27,18 +27,18 @@ export async function generateInnerTunnelSite({ pki, raddbPath }: RaddbGenParams type = auth port = 18120 } - + authorize { filter_username filter_password preprocess - + rest_passwords inner-eap { ok = return } } - + authenticate { # EAP-PEAP-GTC Auth-Type PAP { From fc43c9521dd4b93b8ee0a18274742cd9b39eab6e Mon Sep 17 00:00:00 2001 From: amphineko Date: Sat, 23 Mar 2024 18:41:52 +0000 Subject: [PATCH 5/5] feat(passwords): web user creation and password update --- packages/common/api/users.ts | 22 ++ .../supervisor/src/api/users.controller.ts | 108 ++++++ packages/web/app/clientLayout.tsx | 2 + packages/web/app/users/actions.ts | 38 ++ packages/web/app/users/crypto.ts | 26 ++ packages/web/app/users/page.tsx | 357 ++++++++++++++++++ packages/web/package.json | 4 +- yarn.lock | 20 +- 8 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 packages/common/api/users.ts create mode 100644 packages/supervisor/src/api/users.controller.ts create mode 100644 packages/web/app/users/actions.ts create mode 100644 packages/web/app/users/crypto.ts create mode 100644 packages/web/app/users/page.tsx diff --git a/packages/common/api/users.ts b/packages/common/api/users.ts new file mode 100644 index 0000000..785a4a0 --- /dev/null +++ b/packages/common/api/users.ts @@ -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 + +export const ListUserResponseType = t.readonlyArray(RadiusUserType) + +export type ListUserResponse = t.TypeOf + +export const ListUserPasswordStatusResponseType = t.readonlyArray(RadiusUserPasswordStatusType) + +export const UpdateUserPasswordsRequestType: t.Type> = 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 diff --git a/packages/supervisor/src/api/users.controller.ts b/packages/supervisor/src/api/users.controller.ts new file mode 100644 index 0000000..353c484 --- /dev/null +++ b/packages/supervisor/src/api/users.controller.ts @@ -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 { + return await this.storage.all() + } + + @Post("/:username") + async createOrUpdate(@Param("username") unknownUsername: unknown, @Body() u: unknown): Promise { + 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 { + 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 { + return await this.storage.allStatus() + } + + @Post("/:username") + async update(@Param("username") unknownUsername: unknown, @Body() u: unknown): Promise { + 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, + ) {} +} diff --git a/packages/web/app/clientLayout.tsx b/packages/web/app/clientLayout.tsx index df736f9..42a1b67 100644 --- a/packages/web/app/clientLayout.tsx +++ b/packages/web/app/clientLayout.tsx @@ -1,6 +1,7 @@ "use client" import { + AccountBox, BugReport, Error, Lock, @@ -194,6 +195,7 @@ export function RootClientLayout({ children }: { children: React.ReactNode }): J "/clients": { label: "NAS Clients", icon: WifiPassword }, "/mpsks": { label: "Device MPSKs", icon: Password }, "/pki": { label: "PKI", icon: Lock }, + "/users": { label: "Users", icon: AccountBox }, "/debug": { label: "Debug", icon: BugReport, hidden: true }, }), [], diff --git a/packages/web/app/users/actions.ts b/packages/web/app/users/actions.ts new file mode 100644 index 0000000..6b3cf91 --- /dev/null +++ b/packages/web/app/users/actions.ts @@ -0,0 +1,38 @@ +"use server" + +import { + CreateOrUpdateUserRequestType, + ListUserPasswordStatusResponseType, + ListUserResponse, + ListUserResponseType, + UpdateUserPasswordsRequest, + UpdateUserPasswordsRequestType, +} from "@yonagi/common/api/users" +import { RadiusUserPasswordStatus } from "@yonagi/common/types/users/RadiusUser" +import { Username } from "@yonagi/common/types/users/Username" +import * as t from "io-ts" + +import { deleteEndpoint, getTypedEndpoint, postTypedEndpoint } from "../../lib/actions" + +export async function createUser(username: string): Promise { + await postTypedEndpoint(t.unknown, CreateOrUpdateUserRequestType, `api/v1/users/${username}`, {}) +} + +export async function deleteUser(username: string): Promise { + await deleteEndpoint(`api/v1/users/${username}`) +} + +export async function listUsers(): Promise { + return await getTypedEndpoint(ListUserResponseType, "api/v1/users") +} + +export async function listUserPasswords(): Promise { + return await getTypedEndpoint(ListUserPasswordStatusResponseType, "api/v1/passwords") +} + +export async function updateUserPasswords(username: Username, passwords: UpdateUserPasswordsRequest): Promise { + await postTypedEndpoint(t.unknown, UpdateUserPasswordsRequestType, `api/v1/passwords/${username}`, { + ...passwords, + username, + }) +} diff --git a/packages/web/app/users/crypto.ts b/packages/web/app/users/crypto.ts new file mode 100644 index 0000000..cc4ff3d --- /dev/null +++ b/packages/web/app/users/crypto.ts @@ -0,0 +1,26 @@ +"use client" + +import { md4, sha512 } from "hash-wasm" + +export async function ssha512(password: string): Promise { + const data = new TextEncoder().encode(password) + const salt = crypto.getRandomValues(new Uint8Array(16)) + + const hash = await sha512(new Uint8Array([...data, ...salt])) + return hash + Array.prototype.map.call(salt, (byte: number) => byte.toString(16).padStart(2, "0")).join("") +} + +export async function nthash(password: string): Promise { + const utf16 = new Uint16Array(password.length) + for (let i = 0; i < password.length; i++) { + utf16[i] = password.charCodeAt(i) + } + + const utf16le = new Uint8Array(utf16.length * 2) + for (let i = 0; i < utf16.length; i++) { + utf16le[i * 2] = utf16[i] & 0xff + utf16le[i * 2 + 1] = utf16[i] >> 8 + } + + return await md4(utf16le) +} diff --git a/packages/web/app/users/page.tsx b/packages/web/app/users/page.tsx new file mode 100644 index 0000000..13ad2aa --- /dev/null +++ b/packages/web/app/users/page.tsx @@ -0,0 +1,357 @@ +"use client" + +import { Delete, ExpandMore, Lock, LockOpen, Person, PersonAdd, Save, Warning } from "@mui/icons-material" +import { + Accordion, + AccordionDetails, + AccordionSummary, + Button, + Card, + CardContent, + CardHeader, + Chip, + FormControlLabel, + Grid, + Stack, + Switch, + TextField, + Tooltip, + Typography, +} from "@mui/material" +import { RadiusUser, RadiusUserPasswordStatus } from "@yonagi/common/types/users/RadiusUser" +import { Username, UsernameType } from "@yonagi/common/types/users/Username" +import { useState } from "react" +import useSWR, { useSWRConfig } from "swr" +import useSWRMutation from "swr/mutation" + +import { createUser, deleteUser, listUserPasswords, listUsers, updateUserPasswords } from "./actions" +import { nthash, ssha512 } from "./crypto" +import { useNotifications } from "../../lib/notifications" + +function PasswordHashChip({ status, method }: { status: boolean; method: "SSHA-512" | "NT-Hash" | "Clear Text" }) { + const tooltip = status ? "A password hash is stored for this method" : "No password hash is stored for this method" + + return ( + + + + ) +} + +function SupportedMethodChip({ + status, + method, + short, +}: { + status: boolean + method: "EAP-PEAP-GTC" | "EAP-PEAP-MSCHAPv2" + short: "gtc" | "mschapv2" +}) { + return ( + + : } + label={short} + size="small" + sx={{ + gap: "0.1em", + }} + variant={status ? "filled" : "outlined"} + /> + + ) +} + +function UserAccordion({ user, passwords }: { user: RadiusUser; passwords?: RadiusUserPasswordStatus }) { + const gtc = passwords?.clearText === true || passwords?.ntHash === true || passwords?.ssha512 === true + const mschapv2 = passwords?.clearText === true || passwords?.ntHash === true + + const [enableSsha512, setSsha512] = useState(passwords?.ssha512 ?? true) + const [enableNtHash, setNtHash] = useState(passwords?.ntHash ?? false) + const [enableCleartext, setCleartext] = useState(passwords?.clearText ?? false) + const [password, setPassword] = useState("") + + const { mutate } = useSWRConfig() + const { trigger: updatePasswords } = useSWRMutation( + ["passwords", "update", user.username], + async () => { + if (!password || password.length === 0) { + throw new Error("Password is empty") + } + + await updateUserPasswords(user.username, { + clearText: enableCleartext ? password : null, + ntHash: enableNtHash ? await nthash(password) : null, + ssha512: enableSsha512 ? await ssha512(password) : null, + }) + }, + { + onError: (error) => { + notifyError("Failed to update passwords", String(error)) + }, + onSuccess: () => { + notifySuccess("Passwords updated") + setPassword("") + mutate(["passwords", "list"]).catch(() => { + /**/ + }) + }, + }, + ) + + const { trigger: deleteThisUser } = useSWRMutation( + ["users", "delete", user.username], + async () => { + await deleteUser(user.username) + }, + { + onError: (error) => { + notifyError("Failed to delete user", String(error)) + }, + onSuccess: () => { + notifySuccess("User deleted") + mutate(["users", "list"]).catch(() => { + /**/ + }) + }, + }, + ) + + const { notifyError, notifySuccess } = useNotifications() + + return ( + + }> + + + + {user.username} + + {passwords && ( + + + + + )} + + + + + + + Current Password Hashes + + + + + + + + +
{ + e.preventDefault() + updatePasswords().catch(() => { + /* */ + }) + }} + > + + Update Password + { + setPassword(e.target.value) + }} + /> + + + { + setSsha512(e.target.checked) + }} + /> + } + label="SSHA-512" + /> + + + { + setNtHash(e.target.checked) + }} + /> + } + label="NT-Hash" + /> + + + { + setCleartext(e.target.checked) + }} + /> + } + label={ + + Clear Text + {enableCleartext && } + + } + /> + + + + + + + +
+
+
+
+
+ ) +} + +function AddUserForm() { + const [username, setUsername] = useState("") + + const { mutate } = useSWRConfig() + const { trigger } = useSWRMutation( + ["users", "create", username], + async () => { + if (!username || username.length === 0) { + throw new Error("Username is empty") + } + + if (!UsernameType.is(username)) { + throw new Error("Username is invalid") + } + + await createUser(username) + }, + { + onError: (error) => { + notifyError("Failed to create user", String(error)) + }, + onSuccess: () => { + notifySuccess("User created") + setUsername("") + mutate(["users", "list"]).catch(() => { + /* */ + }) + }, + }, + ) + + const { notifyError, notifySuccess } = useNotifications() + + return ( +
{ + e.preventDefault() + trigger() + .then(() => { + setUsername("") + }) + .catch(() => { + /* */ + }) + }} + > + + + + New User + + } + /> + + + { + setUsername(e.target.value as Username) + }} + variant="standard" + size="small" + /> + + + + +
+ ) +} + +export default function UserDashboardPage() { + const { data: users } = useSWR(["users", "list"], async (): Promise => { + return await listUsers() + }) + + const { data: passwords } = useSWR( + ["passwords", "list"], + async (): Promise> => { + return Object.fromEntries( + await listUserPasswords().then((passwords) => + passwords.map((password) => [password.username, password]), + ), + ) + }, + ) + + return ( + + {users?.map((user) => ( + + ))} + + + ) +} diff --git a/packages/web/package.json b/packages/web/package.json index a01f05d..5be8be7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -10,10 +10,12 @@ "@mui/material": "^5.15.1", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", + "hash-wasm": "^4.11.0", "next": "^14.0.4", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-query": "^3.39.3" + "react-query": "^3.39.3", + "swr": "^2.2.5" }, "scripts": { "dev": "next dev", diff --git a/yarn.lock b/yarn.lock index 0fd99e2..f4c78b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2032,7 +2032,7 @@ cli-highlight@^2.1.11: parse5-htmlparser2-tree-adapter "^6.0.0" yargs "^16.0.0" -client-only@0.0.1: +client-only@0.0.1, client-only@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== @@ -3199,6 +3199,11 @@ has-unicode@^2.0.1: resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== +hash-wasm@^4.11.0: + version "4.11.0" + resolved "https://registry.yarnpkg.com/hash-wasm/-/hash-wasm-4.11.0.tgz#7d1479b114c82e48498fdb1d2462a687d00386d5" + integrity sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ== + hasown@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.0.tgz#f4c513d454a57b7c7e1650778de226b11700546c" @@ -5545,6 +5550,14 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swr@^2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/swr/-/swr-2.2.5.tgz#063eea0e9939f947227d5ca760cc53696f46446b" + integrity sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg== + dependencies: + client-only "^0.0.1" + use-sync-external-store "^1.2.0" + synckit@^0.8.6: version "0.8.6" resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.6.tgz#b69b7fbce3917c2673cbdc0d87fb324db4a5b409" @@ -5864,6 +5877,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +use-sync-external-store@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== + util-deprecate@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"