Skip to content

Commit

Permalink
feat(passwords): web user creation and password update
Browse files Browse the repository at this point in the history
  • Loading branch information
amphineko committed Mar 24, 2024
1 parent fc83ab9 commit fc43c95
Show file tree
Hide file tree
Showing 8 changed files with 575 additions and 2 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>
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,
) {}
}
2 changes: 2 additions & 0 deletions packages/web/app/clientLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

import {
AccountBox,
BugReport,
Error,
Lock,
Expand Down Expand Up @@ -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 },
}),
[],
Expand Down
38 changes: 38 additions & 0 deletions packages/web/app/users/actions.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await postTypedEndpoint(t.unknown, CreateOrUpdateUserRequestType, `api/v1/users/${username}`, {})
}

export async function deleteUser(username: string): Promise<void> {
await deleteEndpoint(`api/v1/users/${username}`)
}

export async function listUsers(): Promise<ListUserResponse> {
return await getTypedEndpoint(ListUserResponseType, "api/v1/users")
}

export async function listUserPasswords(): Promise<readonly RadiusUserPasswordStatus[]> {
return await getTypedEndpoint(ListUserPasswordStatusResponseType, "api/v1/passwords")
}

export async function updateUserPasswords(username: Username, passwords: UpdateUserPasswordsRequest): Promise<void> {
await postTypedEndpoint(t.unknown, UpdateUserPasswordsRequestType, `api/v1/passwords/${username}`, {
...passwords,
username,
})
}
26 changes: 26 additions & 0 deletions packages/web/app/users/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"use client"

import { md4, sha512 } from "hash-wasm"

export async function ssha512(password: string): Promise<string> {
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<string> {
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)
}
Loading

0 comments on commit fc43c95

Please sign in to comment.