@withstudiocms/auth-kit
Version:
Utilities for managing authentication
92 lines (91 loc) • 3.22 kB
JavaScript
import { sha1 } from "@oslojs/crypto/sha1";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { Effect, Platform } from "@withstudiocms/effect";
import { PasswordError, usePasswordError } from "../errors.js";
import { CheckIfUnsafe } from "./unsafeCheck.js";
const constantTimeEqual = (a, b) => {
const len = Math.max(a.length, b.length);
let result = a.length ^ b.length;
for (let i = 0; i < len; i++) {
const ca = i < a.length ? a.charCodeAt(i) : 0;
const cb = i < b.length ? b.charCodeAt(i) : 0;
result |= ca ^ cb;
}
return result === 0;
};
const PASS_GEN1_0_PREFIX = "gen1.0";
const buildSecurePassword = Effect.fn(
({ generation, hash, salt }) => Effect.succeed(`${generation}:${salt}:${hash}`)
);
const breakSecurePassword = Effect.fn(
(hash) => usePasswordError(() => {
const parts = hash.split(":");
if (parts.length !== 3) {
throw new PasswordError({
cause: 'Invalid secure password format. Expected "gen1.0:salt:hash".'
});
}
const [generation, salt, hashValue] = parts;
if (generation !== PASS_GEN1_0_PREFIX) {
throw new PasswordError({
cause: "Legacy password hashes are not supported. Please reset any legacy passwords."
});
}
if (!salt || !hashValue) {
throw new PasswordError({
cause: "Invalid secure password format: missing salt or hash."
});
}
return { generation, salt, hash: hashValue };
})
);
const verifyPasswordLength = Effect.fn(
(pass) => usePasswordError(() => {
if (pass.length < 6 || pass.length > 255) {
return "Password must be between 6 and 255 characters long.";
}
return void 0;
})
);
const verifySafe = (pass) => Effect.gen(function* () {
const check = yield* CheckIfUnsafe;
const isUnsafe = yield* check.password(pass);
if (isUnsafe) {
return "Password must not be a commonly known unsafe password (admin, root, etc.)";
}
return void 0;
}).pipe(Effect.provide(CheckIfUnsafe.Default));
const checkPwnedDB = (pass) => Effect.gen(function* () {
const http = yield* Platform.HttpClient.HttpClient;
const encodedData = new TextEncoder().encode(pass);
const sha1Hash = sha1(encodedData);
const hashHex = encodeHexLowerCase(sha1Hash);
const hashPrefix = hashHex.slice(0, 5);
const response = yield* http.get(`https://api.pwnedpasswords.com/range/${hashPrefix}`).pipe(
Effect.catchTags({
RequestError: () => Effect.succeed({ text: Effect.succeed(""), status: 500 }),
ResponseError: () => Effect.succeed({ text: Effect.succeed(""), status: 500 })
})
);
if (response.status >= 400) {
return void 0;
}
const data = yield* response.text;
const lines = data.split("\n");
for (const line of lines) {
const hashSuffix = line.slice(0, 35).toLowerCase();
if (hashHex === hashPrefix + hashSuffix) {
return 'Password must not be in the <a href="https://haveibeenpwned.com/Passwords" target="_blank">pwned password database</a>.';
}
}
return void 0;
}).pipe(Effect.provide(Platform.FetchHttpClient.layer));
export {
PASS_GEN1_0_PREFIX,
breakSecurePassword,
buildSecurePassword,
checkPwnedDB,
constantTimeEqual,
verifyPasswordLength,
verifySafe
};