Files

340 lines
9.1 KiB
TypeScript

import {
normalizePermissions,
normalizeRole,
type Permission,
type Role,
} from '@/lib/auth/permissions';
export type LoginMode = 'none' | 'legacy_password' | 'managed';
export interface SeedAccountInput {
username: string;
password: string;
name: string;
role: Role;
customPermissions: Permission[];
}
export interface StoredAccountRecord {
id: string;
username: string;
name: string;
role: Role;
customPermissions: Permission[];
passwordHash: string;
passwordSalt: string;
createdAt: number;
updatedAt: number;
}
export interface SessionPayload {
accountId: string;
profileId: string;
username?: string;
name: string;
role: Role;
customPermissions?: Permission[];
mode: 'managed' | 'legacy';
iat: number;
}
export interface SessionCookieProtocolRequest {
headers: {
get(name: string): string | null;
};
nextUrl: {
protocol: string;
};
}
export function resolveLoginMode({
managedAccountCount,
managedAuthEnabled,
managedAuthForced,
legacyAuthConfigured,
}: {
managedAccountCount: number;
managedAuthEnabled: boolean;
managedAuthForced: boolean;
legacyAuthConfigured: boolean;
}): LoginMode {
if (managedAccountCount > 0 || (managedAuthForced && managedAuthEnabled)) {
return 'managed';
}
return legacyAuthConfigured ? 'legacy_password' : 'none';
}
export function shouldUseSecureSessionCookie(request?: SessionCookieProtocolRequest): boolean {
if (process.env.NODE_ENV !== 'production') {
return false;
}
if (!request) {
return true;
}
const forwardedProtocol = request.headers
.get('x-forwarded-proto')
?.split(',')[0]
?.trim()
.toLowerCase();
if (forwardedProtocol) {
return forwardedProtocol === 'https';
}
return request.nextUrl.protocol === 'https:';
}
// Cloudflare Workers rejects PBKDF2 iteration counts above 100,000.
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_KEY_BYTES = 32;
const SESSION_TOKEN_VERSION = 'v1';
function bytesToBinary(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return binary;
}
function binaryToBytes(binary: string): Uint8Array {
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
export function encodeBase64Url(bytes: Uint8Array): string {
return btoa(bytesToBinary(bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function decodeBase64Url(value: string): Uint8Array {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
return binaryToBytes(atob(`${normalized}${padding}`));
}
function encodeText(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
function decodeText(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
async function importPbkdf2Key(password: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', toArrayBuffer(encodeText(password)), 'PBKDF2', false, ['deriveBits']);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
toArrayBuffer(encodeText(secret)),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify']
);
}
export function createRandomToken(byteLength = 16): string {
const bytes = new Uint8Array(byteLength);
crypto.getRandomValues(bytes);
return encodeBase64Url(bytes);
}
export async function hashPassword(password: string, salt?: string): Promise<{ hash: string; salt: string }> {
const effectiveSalt = salt || createRandomToken();
const key = await importPbkdf2Key(password);
const bits = await crypto.subtle.deriveBits(
{
name: 'PBKDF2',
hash: 'SHA-256',
iterations: PBKDF2_ITERATIONS,
salt: toArrayBuffer(encodeText(effectiveSalt)),
},
key,
PBKDF2_KEY_BYTES * 8
);
return {
hash: encodeBase64Url(new Uint8Array(bits)),
salt: effectiveSalt,
};
}
export async function verifyPassword(password: string, salt: string, expectedHash: string): Promise<boolean> {
const actual = await hashPassword(password, salt);
return actual.hash === expectedHash;
}
export function isBootstrapAdminCredential(
username: string,
password: string,
adminPassword: string
): boolean {
return normalizeUsername(username) === 'admin' && !!adminPassword && password === adminPassword;
}
export async function signSessionPayload(payload: SessionPayload, secret: string): Promise<string> {
const payloadBytes = encodeText(JSON.stringify(payload));
const encodedPayload = encodeBase64Url(payloadBytes);
const message = `${SESSION_TOKEN_VERSION}.${encodedPayload}`;
const key = await importHmacKey(secret);
const signature = await crypto.subtle.sign('HMAC', key, toArrayBuffer(encodeText(message)));
return `${message}.${encodeBase64Url(new Uint8Array(signature))}`;
}
export async function verifySessionToken(token: string, secret: string): Promise<SessionPayload | null> {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [version, encodedPayload, encodedSignature] = parts;
if (version !== SESSION_TOKEN_VERSION) return null;
const key = await importHmacKey(secret);
const valid = await crypto.subtle.verify(
'HMAC',
key,
toArrayBuffer(decodeBase64Url(encodedSignature)),
toArrayBuffer(encodeText(`${version}.${encodedPayload}`))
);
if (!valid) return null;
try {
const payload = JSON.parse(decodeText(decodeBase64Url(encodedPayload)));
if (!payload || typeof payload !== 'object') return null;
if (!payload.accountId || !payload.profileId || !payload.name || !payload.role || !payload.mode || !payload.iat) {
return null;
}
return {
accountId: String(payload.accountId),
profileId: String(payload.profileId),
username: payload.username ? String(payload.username) : undefined,
name: String(payload.name),
role: normalizeRole(payload.role),
customPermissions: normalizePermissions(payload.customPermissions),
mode: payload.mode === 'managed' ? 'managed' : 'legacy',
iat: Number(payload.iat),
};
} catch {
return null;
}
}
export function normalizeUsername(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
}
export function ensureUniqueUsername(
preferredValue: string,
existingUsernames: ReadonlySet<string>,
fallbackValue: string
): string {
const fallbackBase = normalizeUsername(fallbackValue) || 'user';
const preferredBase = normalizeUsername(preferredValue) || fallbackBase;
if (!existingUsernames.has(preferredBase)) {
return preferredBase;
}
let suffix = 2;
while (existingUsernames.has(`${preferredBase}-${suffix}`)) {
suffix += 1;
}
return `${preferredBase}-${suffix}`;
}
export function parseBootstrapAccounts(rawAccounts: string): SeedAccountInput[] {
if (!rawAccounts.trim()) return [];
const usernames = new Set<string>();
const seeds: SeedAccountInput[] = [];
rawAccounts
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.forEach((entry, index) => {
const parts = entry.split(':').map((part) => part.trim());
if (parts.length < 2) return;
let username = '';
let password = '';
let name = '';
let rolePart = '';
let permissionsPart = '';
if (parts.length === 2) {
[password, name] = parts;
} else if (parts.length === 3) {
if (parts[2] === 'viewer' || parts[2] === 'admin' || parts[2] === 'super_admin') {
[password, name, rolePart] = parts;
} else {
[username, password, name] = parts;
}
} else if (parts.length === 4 && (parts[2] === 'viewer' || parts[2] === 'admin' || parts[2] === 'super_admin')) {
[password, name, rolePart, permissionsPart] = parts;
} else {
[username, password, name, rolePart, permissionsPart] = parts;
}
if (!password || !name) return;
const normalizedUsername = ensureUniqueUsername(
username || name,
usernames,
`user-${index + 1}`
);
usernames.add(normalizedUsername);
seeds.push({
username: normalizedUsername,
password,
name,
role: normalizeRole(rolePart),
customPermissions: normalizePermissions(permissionsPart ? permissionsPart.split('|') : []),
});
});
return seeds;
}
export async function createStoredAccount(
input: SeedAccountInput,
now = Date.now()
): Promise<StoredAccountRecord> {
const password = await hashPassword(input.password);
return {
id: crypto.randomUUID(),
username: input.username,
name: input.name,
role: input.role,
customPermissions: input.customPermissions,
passwordHash: password.hash,
passwordSalt: password.salt,
createdAt: now,
updatedAt: now,
};
}