mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-04 00:56:01 +00:00
add bcrypt and refactored auth resolve (#147)
* reworked auth architecture with improved password handling and claims Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly. * fix strategy forms handling, add register route and hidden fields Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components. * refactored auth handling to support bcrypt, extracted user pool * update email regex to allow '+' and '_' characters * update test stub password for AppAuth spec * update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool * rework strategies to extend a base class instead of interface * added simple bcrypt test
This commit is contained in:
@@ -1,151 +1,123 @@
|
||||
import type { Authenticator, Strategy } from "auth";
|
||||
import { tbValidator as tb } from "core";
|
||||
import { type Static, StringEnum, parse } from "core/utils";
|
||||
import { hash } from "core/utils";
|
||||
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
|
||||
import { $console, tbValidator as tb } from "core";
|
||||
import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
import { type StrategyActions, createStrategyAction } from "../Authenticator";
|
||||
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { Strategy } from "./Strategy";
|
||||
|
||||
const { Type } = tbbox;
|
||||
|
||||
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
||||
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
||||
|
||||
const schema = Type.Object({
|
||||
hashing: StringEnum(["plain", "sha256"] as const, { default: "sha256" }),
|
||||
const schema = StrictObject({
|
||||
hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }),
|
||||
rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
|
||||
});
|
||||
|
||||
export type PasswordStrategyOptions = Static<typeof schema>;
|
||||
|
||||
export class PasswordStrategy implements Strategy {
|
||||
private options: PasswordStrategyOptions;
|
||||
export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
constructor(config: Partial<PasswordStrategyOptions> = {}) {
|
||||
super(config as any, "password", "password", "form");
|
||||
|
||||
constructor(options: Partial<PasswordStrategyOptions> = {}) {
|
||||
this.options = parse(schema, options);
|
||||
}
|
||||
|
||||
async hash(password: string) {
|
||||
switch (this.options.hashing) {
|
||||
case "sha256":
|
||||
return hash.sha256(password);
|
||||
default:
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
||||
async login(input: LoginSchema) {
|
||||
if (!("email" in input) || !("password" in input)) {
|
||||
throw new Error("Invalid input: Email and password must be provided");
|
||||
}
|
||||
|
||||
const hashedPassword = await this.hash(input.password);
|
||||
return { ...input, password: hashedPassword };
|
||||
}
|
||||
|
||||
async register(input: RegisterSchema) {
|
||||
if (!input.email || !input.password) {
|
||||
throw new Error("Invalid input: Email and password must be provided");
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
password: await this.hash(input.password),
|
||||
};
|
||||
}
|
||||
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
|
||||
return hono
|
||||
.post(
|
||||
"/login",
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
redirect: Type.Optional(Type.String()),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = await authenticator.getBody(c);
|
||||
const { redirect } = c.req.valid("query");
|
||||
|
||||
try {
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve(
|
||||
"login",
|
||||
this,
|
||||
payload.password,
|
||||
payload,
|
||||
);
|
||||
|
||||
return await authenticator.respond(c, data, redirect);
|
||||
} catch (e) {
|
||||
return await authenticator.respond(c, e);
|
||||
}
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/register",
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
redirect: Type.Optional(Type.String()),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = await authenticator.getBody(c);
|
||||
const { redirect } = c.req.valid("query");
|
||||
|
||||
const payload = await this.register(body);
|
||||
const data = await authenticator.resolve(
|
||||
"register",
|
||||
this,
|
||||
payload.password,
|
||||
payload,
|
||||
);
|
||||
|
||||
return await authenticator.respond(c, data, redirect);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getActions(): StrategyActions {
|
||||
return {
|
||||
create: createStrategyAction(
|
||||
Type.Object({
|
||||
email: Type.String({
|
||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
|
||||
}),
|
||||
password: Type.String({
|
||||
minLength: 8, // @todo: this should be configurable
|
||||
}),
|
||||
}),
|
||||
async ({ password, ...input }) => {
|
||||
return {
|
||||
...input,
|
||||
strategy_value: await this.hash(password),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => {
|
||||
return {
|
||||
...input,
|
||||
strategy_value: await this.hash(password),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return schema;
|
||||
}
|
||||
|
||||
getType() {
|
||||
return "password";
|
||||
private getPayloadSchema() {
|
||||
return Type.Object({
|
||||
email: Type.String({
|
||||
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
|
||||
}),
|
||||
password: Type.String({
|
||||
minLength: 8, // @todo: this should be configurable
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return "form" as const;
|
||||
async hash(password: string) {
|
||||
switch (this.config.hashing) {
|
||||
case "sha256":
|
||||
return hash.sha256(password);
|
||||
case "bcrypt": {
|
||||
const salt = await bcryptGenSalt(this.config.rounds ?? 4);
|
||||
return bcryptHash(password, salt);
|
||||
}
|
||||
default:
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
||||
getName() {
|
||||
return "password" as const;
|
||||
async compare(actual: string, compare: string): Promise<boolean> {
|
||||
switch (this.config.hashing) {
|
||||
case "sha256": {
|
||||
const compareHashed = await this.hash(compare);
|
||||
return actual === compareHashed;
|
||||
}
|
||||
case "bcrypt":
|
||||
return await bcryptCompare(compare, actual);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
return secrets ? this.options : undefined;
|
||||
verify(password: string) {
|
||||
return async (user: User) => {
|
||||
const compare = await this.compare(user?.strategy_value!, password);
|
||||
if (compare !== true) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const redirectQuerySchema = Type.Object({
|
||||
redirect: Type.Optional(Type.String()),
|
||||
});
|
||||
const payloadSchema = this.getPayloadSchema();
|
||||
|
||||
hono.post("/login", tb("query", redirectQuerySchema), async (c) => {
|
||||
const body = parse(payloadSchema, await authenticator.getBody(c), {
|
||||
onError: (errors) => {
|
||||
$console.error("Invalid login payload", [...errors]);
|
||||
throw new InvalidCredentialsException();
|
||||
},
|
||||
});
|
||||
const { redirect } = c.req.valid("query");
|
||||
|
||||
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
|
||||
redirect,
|
||||
});
|
||||
});
|
||||
|
||||
hono.post("/register", tb("query", redirectQuerySchema), async (c) => {
|
||||
const { redirect } = c.req.valid("query");
|
||||
const { password, email, ...body } = parse(payloadSchema, await authenticator.getBody(c), {
|
||||
onError: (errors) => {
|
||||
$console.error("Invalid register payload", [...errors]);
|
||||
throw new InvalidCredentialsException();
|
||||
},
|
||||
});
|
||||
|
||||
const profile = {
|
||||
...body,
|
||||
email,
|
||||
strategy_value: await this.hash(password),
|
||||
};
|
||||
|
||||
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
|
||||
redirect,
|
||||
});
|
||||
});
|
||||
|
||||
return hono;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user