All files / server/utils auth.ts

100% Statements 131/131
86% Branches 43/50
100% Functions 10/10
100% Lines 131/131

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142    1x 1x 8x 8x 8x 8x 1x 24x 24x 24x 24x 24x 24x   1x 1x 1x 1x 1x 1x   4x 4x 4x 4x 2x 2x 2x 2x 2x 2x 2x 2x 2x 4x 2x 2x 4x 2x 2x   10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x   3x 3x 3x 3x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x   10x 10x 10x 10x 10x 8x 10x 10x 8x 8x 10x 8x 7x 10x 6x 6x 6x 6x 10x 10x 10x 10x 10x 6x 6x 6x 10x 1x 1x 10x   6x 6x 6x 6x 5x 6x 4x 4x   4x 4x 4x 4x 4x 4x 4x 2x 2x   1x 1x 1x  
import type {H3Event} from "h3";
 
const encoder = new TextEncoder();
const b64 = (bytes: Uint8Array) =>
    btoa(String.fromCharCode(...bytes))
        .replace(/=/g, "")
        .replace(/\+/g, "-")
        .replace(/\//g, "_");
const fromB64 = (value: string) =>
    Uint8Array.from(
        atob(value.replace(/-/g, "+")
            .replace(/_/g, "/")
            .padEnd(Math.ceil(value.length / 4) * 4, "=")),
        c => c.charCodeAt(0)
    );
 
export async function hashPassword(password: string) {
    const salt = crypto.getRandomValues(new Uint8Array(16));
    const key = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
    const bits = await crypto.subtle.deriveBits({name: "PBKDF2", hash: "SHA-256", salt, iterations: 210000}, key, 256);
    return `${b64(salt)}.${b64(new Uint8Array(bits))}`;
}
 
export async function verifyPassword(password: string, stored: string) {
    const [salt, expected] = stored.split(".");
    if (!salt || !expected)
        return false;
    const key = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
    const bits = new Uint8Array(await crypto.subtle.deriveBits({
        name: "PBKDF2",
        hash: "SHA-256",
        salt: fromB64(salt),
        iterations: 210000
    }, key, 256));
    const target = fromB64(expected);
    if (bits.length !== target.length)
        return false;
    let diff = 0;
    for (let i = 0; i < bits.length; i++)
        diff |= bits[i]! ^ target[i]!;
    return diff === 0;
}
 
async function jwtKey(secret: string) {
    return crypto.subtle.importKey(
        "raw",
        encoder.encode(secret),
        {
            name: "HMAC",
            hash: "SHA-256"
        },
        false,
        ["sign", "verify"]
    );
}
 
export async function signSession(event: H3Event, user: { id: number; email: string; role: string }) {
    const secret = useRuntimeConfig(event).jwtSecret;
    if (!secret)
        throw createError({
            statusCode: 503,
            statusMessage: "Authentification non configurée"
        });
    const header = b64(encoder.encode(JSON.stringify({
        alg: "HS256",
        typ: "JWT"
    })));
    const payload = b64(encoder.encode(JSON.stringify({
        sub: String(user.id),
        email: user.email,
        role: user.role,
        iat: Math.floor(Date.now() / 1000),
        exp: Math.floor(Date.now() / 1000) + 604800
    })));
    const signature = b64(new Uint8Array(await crypto.subtle.sign("HMAC", await jwtKey(secret), encoder.encode(`${header}.${payload}`))));
    setCookie(
        event,
        "angel_session",
        `${header}.${payload}.${signature}`,
        {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: "lax",
            path: "/",
            maxAge: 604800
        });
}
 
export async function sessionUser(event: H3Event) {
    try {
        const token = getCookie(event, "angel_session");
        if (!token)
            return null;
        const [h, p, s] = token.split(".");
        if (!h || !p || !s)
            return null;
        const secret = useRuntimeConfig(event).jwtSecret;
        if (!secret)
            return null;
        const valid = await crypto.subtle.verify("HMAC", await jwtKey(secret), fromB64(s), encoder.encode(`${h}.${p}`));
        if (!valid)
            return null;
        const header = JSON.parse(new TextDecoder().decode(fromB64(h)));
        const claims = JSON.parse(new TextDecoder().decode(fromB64(p)));
        const now = Math.floor(Date.now() / 1000);
        if (header.alg !== "HS256" || header.typ !== "JWT")
            return null;
        if (!/^\d+$/.test(String(claims.sub || "")) || !Number.isInteger(claims.iat) || !Number.isInteger(claims.exp))
            return null;
        if (claims.iat > now + 60 || claims.exp < now || claims.exp - claims.iat > 604800)
            return null;
        const db = database(event);
        await ready(db);
        return await db.prepare("SELECT id,email,first_name,last_name,role,active,must_change_password,created_at FROM users WHERE id=? AND active=1").bind(Number(claims.sub)).first<any>();
    } catch {
        return null;
    }
}
 
export async function requireUser(event: H3Event) {
    const user = await sessionUser(event);
    if (!user)
        throw createError({statusCode: 401, statusMessage: "Connexion requise"});
    if (user.must_change_password)
        throw createError({statusCode: 428, statusMessage: "Changement de mot de passe requis"});
    return user;
}
 
export async function requireAdmin(event: H3Event) {
    const user = await requireUser(event);
    if (!["admin", "demo"].includes(user.role))
        throw createError({statusCode: 403, statusMessage: "Droits administrateur requis"});
    const method = String(event.method || "GET").toUpperCase();
    if (user.role === "demo" && !["GET", "HEAD"].includes(method))
        throw createError({statusCode: 403, statusMessage: "Le compte de démonstration dispose d’un accès en lecture seule"});
    return user;
}
 
export function clearAuthSession(event: H3Event) {
    deleteCookie(event, "angel_session", {path: "/"});
}