impress/src/server/auth-by-db.js
Matchu 2dbfaf1557 Support actual login via db?? :0
Yeah cool the login button seems to. work now? And subsequent requests serve user data correctly based on that, and let you edit stuff.

I also tested the following attacks:
- Using the wrong password indeed fails! lol basic one
- Changing the userId or createdAt fields in the cookie causes the auth token to be rejected for an invalid signature.

Tbh that's all that comes to mind… like, you either attack us by tricking the login itself into giving you a token when it shouldn't, or you attack us by tricking the subsequent requests into accepting a token when it shouldn't. Seems like we're covered? 😳🤞

Still need to add logout, but yeah, this is… looking surprisingly feature-parity with our Auth0 integration already lmao. Maybe it'll be ready to launch sooner than expected?
2022-08-17 15:24:17 -07:00

127 lines
4.9 KiB
JavaScript

import { createHmac } from "crypto";
import { normalizeRow } from "./util";
export async function getAuthToken({ username, password }, 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 HMAC-SHA256 algorithm to validate the password the user is
// trying to log in with. 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 { id, encryptedPassword, passwordSalt } = normalizeRow(
rowsFromOpenneoId[0]
);
const passwordHmac = createHmac("sha256", passwordSalt);
passwordHmac.update(password);
const encryptedProvidedPassword = passwordHmac.digest("hex");
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]);
// Finally, create the auth token object. 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);
const authToken = { ...unsignedAuthToken, signature };
// Login success! Return the auth token. The caller will handle setting it to
// a cookie etc.
console.debug(`[getAuthToken] Succeeded: ${JSON.stringify(authToken)}`);
return authToken;
}
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");
}