feat(api): authentication

This commit is contained in:
2025-11-18 00:38:27 +02:00
parent 3f53c1e03f
commit baad24fecc
34 changed files with 910 additions and 234 deletions
+1
View File
@@ -1,2 +1,3 @@
export * from "./articles";
export * from "./sources";
export * from "./users";
+23
View File
@@ -0,0 +1,23 @@
import { and, eq, ilike } from "drizzle-orm";
import { Database } from "#db/client";
import { users } from "#db/schema";
export type User = typeof users.$inferSelect;
export async function getUserByEmail(db: Database, email: string): Promise<User | undefined> {
return db.query.users.findFirst({
where: ilike(users.email, email),
});
}
export async function getUserById(
db: Database,
params: { id: string; email?: string },
): Promise<User | undefined> {
const { id, email } = params;
return db.query.users.findFirst({
where: email ? and(eq(users.id, id), ilike(users.email, email)) : eq(users.id, id),
});
}
+11
View File
@@ -23,3 +23,14 @@ export const DEFAULT_SOURCE_IMAGE = "https://devscast.org/images/sources/";
export const DEFAULT_PUBLICATION_GRAPH_DAYS = 30;
export const DEFAULT_CATEGORY_SHARES_LIMIT = 10;
export const DEFAULT_TIMEZONE = "Africa/Lubumbashi";
export const DEFAULT_ACCESS_TOKEN_COOKIE = "basango.access_token";
export const DEFAULT_REFRESH_TOKEN_COOKIE = "basango.refresh_token";
export const DEFAULT_ENCRYPTION_ALGORITHM = "aes-256-gcm";
export const DEFAULT_IV_LENGTH = 16;
export const DEFAULT_AUTH_TAG_LENGTH = 16;
export const DEFAULT_BCRYPT_SALT_ROUNDS = 12;
export const DEFAULT_TOKEN_AUDIENCE = "basango_dashboard";
export const DEFAULT_TOKEN_ISSUER = "basango_api";
export const DEFAULT_ACCESS_TOKEN_TTL = "15m";
export const DEFAULT_REFRESH_TOKEN_TTL = "7d";
+18
View File
@@ -0,0 +1,18 @@
import { z } from "@hono/zod-openapi";
export const loginSchema = z.object({
email: z.email().openapi({
description: "Email address used to authenticate the user.",
example: "user@example.com",
}),
password: z.string().min(8).openapi({
description: "Account password.",
example: "••••••••",
}),
});
export const refreshSessionSchema = z.object({
refreshToken: z.string().min(1).openapi({
description: "Refresh token returned when logging in.",
}),
});
+1
View File
@@ -1,4 +1,5 @@
export * from "./articles";
export * from "./auth";
export * from "./shared";
export * from "./sources";
export * from "./users";
+6 -1
View File
@@ -1,6 +1,11 @@
{
"dependencies": {
"@devscast/config": "catalog:"
"@basango/domain": "workspace:*",
"@devscast/config": "catalog:",
"bcrypt": "^6.0.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0"
},
"main": "src/index.ts",
"name": "@basango/encryption",
+24 -10
View File
@@ -1,13 +1,16 @@
import crypto from "node:crypto";
import {
DEFAULT_AUTH_TAG_LENGTH,
DEFAULT_BCRYPT_SALT_ROUNDS,
DEFAULT_ENCRYPTION_ALGORITHM,
DEFAULT_IV_LENGTH,
} from "@basango/domain/constants";
import { createEnvAccessor } from "@devscast/config";
import * as bcrypt from "bcrypt";
export const env = createEnvAccessor(["BASANGO_ENCRYPTION_KEY"] as const);
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
function getKey(): Buffer {
const key = env("BASANGO_ENCRYPTION_KEY");
@@ -24,8 +27,8 @@ function getKey(): Buffer {
*/
export function encrypt(text: string): string {
const key = getKey();
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const iv = crypto.randomBytes(DEFAULT_IV_LENGTH);
const cipher = crypto.createCipheriv(DEFAULT_ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(text, "utf8", "hex");
encrypted += cipher.final("hex");
@@ -50,11 +53,14 @@ export function decrypt(encryptedPayload: string): string {
const dataBuffer = Buffer.from(encryptedPayload, "base64");
// Extract IV, auth tag, and encrypted data
const iv = dataBuffer.subarray(0, IV_LENGTH);
const authTag = dataBuffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const encryptedText = dataBuffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const iv = dataBuffer.subarray(0, DEFAULT_IV_LENGTH);
const authTag = dataBuffer.subarray(
DEFAULT_IV_LENGTH,
DEFAULT_IV_LENGTH + DEFAULT_AUTH_TAG_LENGTH,
);
const encryptedText = dataBuffer.subarray(DEFAULT_IV_LENGTH + DEFAULT_AUTH_TAG_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
const decipher = crypto.createDecipheriv(DEFAULT_ENCRYPTION_ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText.toString("hex"), "hex", "utf8");
@@ -74,3 +80,11 @@ export function md5(str: string): string {
export function generateRandomBytes(size: number): string {
return crypto.randomBytes(size).toString("hex");
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, DEFAULT_BCRYPT_SALT_ROUNDS);
}
export async function verifyPassword(password: string, hashed: string): Promise<boolean> {
return bcrypt.compare(password, hashed);
}
+6
View File
@@ -1,4 +1,10 @@
{
"compilerOptions": {
"paths": {
"#domain/*": ["../../domain/src/*"],
"#encryption/*": ["./src/*"]
}
},
"exclude": ["node_modules"],
"extends": "@basango/tsconfig/base.json",
"include": ["src/**/*"]