forked from OpenNeo/impress-2020
It doesn't actually create the account, but it does some field validation and the form reacts to it!
190 lines
7.1 KiB
JavaScript
190 lines
7.1 KiB
JavaScript
import { createHmac } from "crypto";
|
|
import { normalizeRow } from "./util";
|
|
|
|
// https://stackoverflow.com/a/201378/107415
|
|
const EMAIL_PATTERN = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/;
|
|
|
|
export async function getAuthToken({ username, password, ipAddress }, db) {
|
|
// For legacy reasons (and I guess decent security reasons too!), auth info
|
|
// is stored in a users table in a separate database. First, look up the user
|
|
// in that database, and get their encrypted auth info.
|
|
const [rowsFromOpenneoId] = await db.query(
|
|
`
|
|
SELECT id, encrypted_password, password_salt FROM openneo_id.users
|
|
WHERE name = ?;
|
|
`,
|
|
[username]
|
|
);
|
|
if (rowsFromOpenneoId.length === 0) {
|
|
console.debug(
|
|
`[getAuthToken] Failed: No user named ${JSON.stringify(username)}.`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Then, use the password encrpytion function to validate the password the
|
|
// user is trying to log in with.
|
|
const { id, encryptedPassword, passwordSalt } = normalizeRow(
|
|
rowsFromOpenneoId[0]
|
|
);
|
|
const encryptedProvidedPassword = encryptPassword(password, passwordSalt);
|
|
|
|
if (encryptedProvidedPassword !== encryptedPassword) {
|
|
console.debug(
|
|
`[getAuthToken] Failed: Encrypted input password ` +
|
|
`${JSON.stringify(encryptedProvidedPassword)} ` +
|
|
`did not match for user ${JSON.stringify(username)}.`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Then, look up this user's ID in the main Dress to Impress database.
|
|
// (For silly legacy reasons, it can be - and in our current database is
|
|
// always! - different than the ID in the Openneo ID database.)
|
|
const [rowsFromOpenneoImpress] = await db.query(
|
|
`
|
|
SELECT id FROM openneo_impress.users WHERE remote_id = ?;
|
|
`,
|
|
[id]
|
|
);
|
|
if (rowsFromOpenneoImpress.length === 0) {
|
|
// TODO: Auto-create the impress row in this case? will it ever happen tho?
|
|
throw new Error(
|
|
`Syncing error: user exists in openneo_id, but not openneo_impress.`
|
|
);
|
|
}
|
|
const { id: impressId } = normalizeRow(rowsFromOpenneoImpress[0]);
|
|
|
|
// One more thing: Update the user record to keep track of this login.
|
|
await db.query(
|
|
`
|
|
UPDATE openneo_id.users
|
|
SET last_sign_in_at = current_sign_in_at,
|
|
current_sign_in_at = CURRENT_TIMESTAMP(),
|
|
last_sign_in_ip = current_sign_in_ip,
|
|
current_sign_in_ip = ?,
|
|
sign_in_count = sign_in_count + 1,
|
|
updated_at = CURRENT_TIMESTAMP()
|
|
WHERE id = ? LIMIT 1;
|
|
`,
|
|
[ipAddress, id]
|
|
);
|
|
|
|
// Finally, create and return the auth token itself. The caller will handle
|
|
// setting it to a cookie etc.
|
|
const authToken = createAuthToken(impressId);
|
|
console.debug(`[getAuthToken] Succeeded: ${JSON.stringify(authToken)}`);
|
|
return authToken;
|
|
}
|
|
|
|
function createAuthToken(impressId) {
|
|
// This contains a `userId` field, a `createdAt` field, and a signature of
|
|
// the object with every field but the `signature` field. The signature also
|
|
// uses HMAC-SHA256 (which doesn't at all need to be in sync with the
|
|
// password hashing, but it's a good algorithm so we chose it again), and the
|
|
// key this time is a secret global value called `DTI_AUTH_TOKEN_SECRET`.
|
|
// This proves that the auth token was generated by the app, because only the
|
|
// app knows the secret.
|
|
const unsignedAuthToken = {
|
|
userId: impressId,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
const signature = computeSignatureForAuthToken(unsignedAuthToken);
|
|
return { ...unsignedAuthToken, signature };
|
|
}
|
|
|
|
function encryptPassword(password, passwordSalt) {
|
|
// Use HMAC-SHA256 to encrypt the password. The random salt for this user,
|
|
// saved in the database, is the HMAC "key". (That way, if our database
|
|
// leaks, each user's password would need to be cracked individually, instead
|
|
// of being susceptible to attacks where you match our database against a
|
|
// database of SHA256 hashes for common passwords.)
|
|
const passwordHmac = createHmac("sha256", passwordSalt);
|
|
passwordHmac.update(password);
|
|
return passwordHmac.digest("hex");
|
|
}
|
|
|
|
export async function getUserIdFromToken(authToken) {
|
|
// Check the auth token's signature, to make sure we're the ones who created
|
|
// it. (The signature depends on the DTI_AUTH_TOKEN_SECRET, so we should be
|
|
// the only ones who can generate accurate signatures.)
|
|
const { signature, ...unsignedAuthToken } = authToken;
|
|
const actualSignature = computeSignatureForAuthToken(unsignedAuthToken);
|
|
if (signature !== actualSignature) {
|
|
console.warn(
|
|
`[getUserIdFromToken] Signature ${signature} did not match auth ` +
|
|
`token. Rejecting.`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Then, check that the cookie was created within the past week. If not,
|
|
// treat it as expired; we'll have the user log in again, as a general
|
|
// security practice.
|
|
const oneWeekAgo = new Date();
|
|
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
|
if (authToken.createdAt < oneWeekAgo) {
|
|
console.warn(
|
|
`[getUserIdFromToken] Auth token expired, was created at ` +
|
|
`${authToken.createdAt}. Rejecting.`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Okay, it passed validation: this is a real auth token generated by us, and
|
|
// it hasn't expired. Now we can safely trust it: return its own userId!
|
|
return authToken.userId;
|
|
}
|
|
|
|
function computeSignatureForAuthToken(unsignedAuthToken) {
|
|
if (process.env["DTI_AUTH_TOKEN_SECRET"] == null) {
|
|
throw new Error(
|
|
`The DTI_AUTH_TOKEN_SECRET environment variable is missing. ` +
|
|
`The server admin should create a random secret, and save it in the ` +
|
|
`.env file.`
|
|
);
|
|
}
|
|
const authTokenHmac = createHmac(
|
|
"sha256",
|
|
process.env["DTI_AUTH_TOKEN_SECRET"]
|
|
);
|
|
authTokenHmac.update(JSON.stringify(unsignedAuthToken));
|
|
return authTokenHmac.digest("hex");
|
|
}
|
|
|
|
export async function createAccount(
|
|
{ username, password, email, _ /* ipAddress */ },
|
|
__ /* db */
|
|
) {
|
|
const errors = [];
|
|
if (!username) {
|
|
errors.push({ type: "USERNAME_IS_REQUIRED" });
|
|
}
|
|
if (!password) {
|
|
errors.push({ type: "PASSWORD_IS_REQUIRED" });
|
|
}
|
|
if (!email) {
|
|
errors.push({ type: "EMAIL_IS_REQUIRED" });
|
|
}
|
|
if (email && !email?.match(EMAIL_PATTERN)) {
|
|
errors.push({ type: "EMAIL_MUST_BE_VALID" });
|
|
}
|
|
|
|
// TODO: Add an error for non-unique username.
|
|
|
|
if (errors.length > 0) {
|
|
return { errors, authToken: null };
|
|
}
|
|
|
|
throw new Error(`TODO: Actually create the account!`);
|
|
|
|
// await db.query(`
|
|
// INSERT INTO openneo_id.users
|
|
// (name, encrypted_password, email, password_salt, sign_in_count,
|
|
// current_sign_in_at, current_sign_in_ip, created_at, updated_at)
|
|
// VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP(), ?,
|
|
// CURRENT_TIMESTAMP(), CURRENT_TIMESTAMP());
|
|
// `, [username, encryptedPassword, email, passwordSalt, ipAddress]);
|
|
|
|
// return { errors: [], authToken: createAuthToken(6) };
|
|
}
|