feat(api): authentication
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from "./articles";
|
||||
export * from "./sources";
|
||||
export * from "./users";
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
export * from "./articles";
|
||||
export * from "./auth";
|
||||
export * from "./shared";
|
||||
export * from "./sources";
|
||||
export * from "./users";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"#domain/*": ["../../domain/src/*"],
|
||||
"#encryption/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"extends": "@basango/tsconfig/base.json",
|
||||
"include": ["src/**/*"]
|
||||
|
||||
Reference in New Issue
Block a user