Phase1
This commit is contained in:
@@ -10,6 +10,8 @@ export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
emailVerified: boolean;
|
||||
roles: UserRole[];
|
||||
}
|
||||
|
||||
@@ -27,3 +29,35 @@ export interface AuthTokenPayload {
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export { hashPassword, verifyPassword } from './password';
|
||||
export {
|
||||
generateSecureToken,
|
||||
hashToken,
|
||||
verifyTokenHash,
|
||||
} from './tokens';
|
||||
export {
|
||||
generateUsernameFromEmail,
|
||||
sanitizeUsername,
|
||||
validateUsername,
|
||||
MIN_USERNAME_LENGTH,
|
||||
MAX_USERNAME_LENGTH,
|
||||
type UsernameValidationResult,
|
||||
} from './username';
|
||||
export {
|
||||
generateTotpSecret,
|
||||
verifyTotpCode,
|
||||
buildOtpAuthUrl,
|
||||
} from './totp';
|
||||
export {
|
||||
generateRecoveryCodes,
|
||||
hashRecoveryCode,
|
||||
verifyRecoveryCode,
|
||||
} from './recovery-codes';
|
||||
export {
|
||||
SESSION_COOKIE_NAME,
|
||||
SESSION_DURATION_MS,
|
||||
SESSION_ABSOLUTE_MAX_DURATION_MS,
|
||||
SESSION_REMEMBER_ME_DURATION_MS,
|
||||
createSessionExpiry,
|
||||
} from './session';
|
||||
|
||||
19
packages/auth/src/password.test.ts
Normal file
19
packages/auth/src/password.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hashPassword, verifyPassword } from "./password";
|
||||
|
||||
describe("password", () => {
|
||||
it("hashes and verifies a password roundtrip", async () => {
|
||||
const plain = "super-secure-password-123!";
|
||||
const hashed = await hashPassword(plain);
|
||||
|
||||
expect(hashed).not.toBe(plain);
|
||||
expect(await verifyPassword(plain, hashed)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an incorrect password", async () => {
|
||||
const hashed = await hashPassword("correct-password");
|
||||
|
||||
expect(await verifyPassword("wrong-password", hashed)).toBe(false);
|
||||
});
|
||||
});
|
||||
16
packages/auth/src/password.ts
Normal file
16
packages/auth/src/password.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
plain: string,
|
||||
hashed: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await verify(hashed, plain);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
28
packages/auth/src/recovery-codes.ts
Normal file
28
packages/auth/src/recovery-codes.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import { hashToken, verifyTokenHash } from "./tokens";
|
||||
|
||||
const RECOVERY_CODE_SEGMENT_LENGTH = 4;
|
||||
|
||||
function formatRecoveryCode(bytes: Buffer): string {
|
||||
const hex = bytes.toString("hex").toUpperCase();
|
||||
return `${hex.slice(0, RECOVERY_CODE_SEGMENT_LENGTH)}-${hex.slice(RECOVERY_CODE_SEGMENT_LENGTH)}`;
|
||||
}
|
||||
|
||||
export function generateRecoveryCodes(count = 10): string[] {
|
||||
return Array.from({ length: count }, () =>
|
||||
formatRecoveryCode(randomBytes(RECOVERY_CODE_SEGMENT_LENGTH)),
|
||||
);
|
||||
}
|
||||
|
||||
export function hashRecoveryCode(code: string): string {
|
||||
return hashToken(normalizeRecoveryCode(code));
|
||||
}
|
||||
|
||||
export function verifyRecoveryCode(code: string, hashed: string): boolean {
|
||||
return verifyTokenHash(normalizeRecoveryCode(code), hashed);
|
||||
}
|
||||
|
||||
function normalizeRecoveryCode(code: string): string {
|
||||
return code.trim().toUpperCase().replace(/\s+/g, "");
|
||||
}
|
||||
9
packages/auth/src/session.ts
Normal file
9
packages/auth/src/session.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const SESSION_COOKIE_NAME = 'hgc_session';
|
||||
|
||||
export const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const SESSION_ABSOLUTE_MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const SESSION_REMEMBER_ME_DURATION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function createSessionExpiry(from: Date = new Date()): Date {
|
||||
return new Date(from.getTime() + SESSION_DURATION_MS);
|
||||
}
|
||||
25
packages/auth/src/tokens.test.ts
Normal file
25
packages/auth/src/tokens.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
generateSecureToken,
|
||||
hashToken,
|
||||
verifyTokenHash,
|
||||
} from "./tokens";
|
||||
|
||||
describe("tokens", () => {
|
||||
it("produces consistent hashes for the same token", () => {
|
||||
const token = generateSecureToken();
|
||||
const firstHash = hashToken(token);
|
||||
const secondHash = hashToken(token);
|
||||
|
||||
expect(firstHash).toBe(secondHash);
|
||||
expect(verifyTokenHash(token, firstHash)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects tokens that do not match the hash", () => {
|
||||
const token = generateSecureToken();
|
||||
const hashed = hashToken(token);
|
||||
|
||||
expect(verifyTokenHash("different-token", hashed)).toBe(false);
|
||||
});
|
||||
});
|
||||
21
packages/auth/src/tokens.ts
Normal file
21
packages/auth/src/tokens.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
export function generateSecureToken(bytes = 32): string {
|
||||
return randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export function verifyTokenHash(token: string, hashed: string): boolean {
|
||||
const computed = hashToken(token);
|
||||
const computedBuffer = Buffer.from(computed, 'hex');
|
||||
const hashedBuffer = Buffer.from(hashed, 'hex');
|
||||
|
||||
if (computedBuffer.length !== hashedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(computedBuffer, hashedBuffer);
|
||||
}
|
||||
27
packages/auth/src/totp.test.ts
Normal file
27
packages/auth/src/totp.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { authenticator } from "otplib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildOtpAuthUrl,
|
||||
generateTotpSecret,
|
||||
verifyTotpCode,
|
||||
} from "./totp";
|
||||
|
||||
describe("totp", () => {
|
||||
it("generates a secret and verifies a valid code", () => {
|
||||
const secret = generateTotpSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
expect(secret.length).toBeGreaterThan(0);
|
||||
expect(verifyTotpCode(secret, code)).toBe(true);
|
||||
});
|
||||
|
||||
it("builds an otpauth URL", () => {
|
||||
const secret = generateTotpSecret();
|
||||
const url = buildOtpAuthUrl("HexaHost", "user@example.com", secret);
|
||||
|
||||
expect(url).toContain("otpauth://totp/");
|
||||
expect(url).toContain("issuer=HexaHost");
|
||||
expect(url).toContain(encodeURIComponent("user@example.com"));
|
||||
});
|
||||
});
|
||||
21
packages/auth/src/totp.ts
Normal file
21
packages/auth/src/totp.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
export function generateTotpSecret(): string {
|
||||
return authenticator.generateSecret();
|
||||
}
|
||||
|
||||
export function verifyTotpCode(secret: string, code: string): boolean {
|
||||
try {
|
||||
return authenticator.verify({ token: code.replace(/\s+/gu, ''), secret });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOtpAuthUrl(
|
||||
issuer: string,
|
||||
accountName: string,
|
||||
secret: string,
|
||||
): string {
|
||||
return authenticator.keyuri(accountName, issuer, secret);
|
||||
}
|
||||
109
packages/auth/src/username.ts
Normal file
109
packages/auth/src/username.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export const MIN_USERNAME_LENGTH = 3;
|
||||
export const MAX_USERNAME_LENGTH = 32;
|
||||
|
||||
const USERNAME_PATTERN = /^[a-z][a-z0-9_]*[a-z0-9]$|^[a-z]$/;
|
||||
|
||||
const RESERVED_USERNAMES = new Set([
|
||||
"admin",
|
||||
"administrator",
|
||||
"api",
|
||||
"auth",
|
||||
"billing",
|
||||
"dashboard",
|
||||
"help",
|
||||
"hexahost",
|
||||
"login",
|
||||
"logout",
|
||||
"moderator",
|
||||
"null",
|
||||
"register",
|
||||
"root",
|
||||
"settings",
|
||||
"support",
|
||||
"system",
|
||||
"undefined",
|
||||
"user",
|
||||
"www",
|
||||
]);
|
||||
|
||||
export interface UsernameValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function sanitizeUsername(input: string): string {
|
||||
const normalized = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!/^[a-z]/.test(normalized)) {
|
||||
return `u_${normalized}`.slice(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
return normalized.slice(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
export function generateUsernameFromEmail(email: string): string {
|
||||
const localPart = email.trim().toLowerCase().split("@")[0] ?? "";
|
||||
let username = sanitizeUsername(localPart);
|
||||
|
||||
if (!username) {
|
||||
username = "user";
|
||||
}
|
||||
|
||||
if (username.length < MIN_USERNAME_LENGTH) {
|
||||
username = username.padEnd(MIN_USERNAME_LENGTH, "0");
|
||||
}
|
||||
|
||||
if (username.length > MAX_USERNAME_LENGTH) {
|
||||
username = username.slice(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
if (!/^[a-z]/.test(username)) {
|
||||
username = `u${username}`.slice(0, MAX_USERNAME_LENGTH);
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): UsernameValidationResult {
|
||||
const normalized = username.trim().toLowerCase();
|
||||
|
||||
if (normalized.length < MIN_USERNAME_LENGTH) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Username must be at least ${MIN_USERNAME_LENGTH} characters.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.length > MAX_USERNAME_LENGTH) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Username must be at most ${MAX_USERNAME_LENGTH} characters.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!USERNAME_PATTERN.test(normalized)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Username must start with a letter, contain only lowercase letters, numbers, and underscores, and not end with an underscore.",
|
||||
};
|
||||
}
|
||||
|
||||
if (RESERVED_USERNAMES.has(normalized)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "This username is reserved.",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
Reference in New Issue
Block a user