mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 00:03:42 +08:00
331 lines
8.8 KiB
TypeScript
331 lines
8.8 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:';
|
|
}
|
|
|
|
const PBKDF2_ITERATIONS = 120_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 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,
|
|
};
|
|
}
|