Track sign-ins & IP addresses

Oh right, these are some logging-ish things that Classic DTI would perform! It's easy enough for us to keep the fields up-to-date too, so let's do it!
This commit is contained in:
Emi Matchu 2022-09-12 15:24:58 -07:00
parent 4c9dbf91fb
commit c7ba61a0f1
3 changed files with 62 additions and 24 deletions

View file

@ -1,7 +1,8 @@
import { createHmac } from "crypto"; import { createHmac } from "crypto";
import { normalizeRow } from "./util"; import { normalizeRow } from "./util";
export async function getAuthToken({ username, password }, db) {
export async function getAuthToken({ username, password, ipAddress }, db) {
// For legacy reasons (and I guess decent security reasons too!), auth info // 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 // is stored in a users table in a separate database. First, look up the user
// in that database, and get their encrypted auth info. // in that database, and get their encrypted auth info.
@ -19,18 +20,12 @@ export async function getAuthToken({ username, password }, db) {
return null; return null;
} }
// Then, use the HMAC-SHA256 algorithm to validate the password the user is // Then, use the password encrpytion function to validate the password the
// trying to log in with. The random salt for this user, saved in the // user is trying to log in with.
// 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( const { id, encryptedPassword, passwordSalt } = normalizeRow(
rowsFromOpenneoId[0] rowsFromOpenneoId[0]
); );
const passwordHmac = createHmac("sha256", passwordSalt); const encryptedProvidedPassword = encryptPassword(password, passwordSalt);
passwordHmac.update(password);
const encryptedProvidedPassword = passwordHmac.digest("hex");
if (encryptedProvidedPassword !== encryptedPassword) { if (encryptedProvidedPassword !== encryptedPassword) {
console.debug( console.debug(
@ -58,24 +53,53 @@ export async function getAuthToken({ username, password }, db) {
} }
const { id: impressId } = normalizeRow(rowsFromOpenneoImpress[0]); const { id: impressId } = normalizeRow(rowsFromOpenneoImpress[0]);
// Finally, create the auth token object. This contains a `userId` field, a // One more thing: Update the user record to keep track of this login.
// `createdAt` field, and a signature of the object with every field but the await db.query(
// `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 UPDATE openneo_id.users
// algorithm so we chose it again), and the key this time is a secret global SET last_sign_in_at = current_sign_in_at,
// value called `DTI_AUTH_TOKEN_SECRET`. This proves that the auth token was current_sign_in_at = CURRENT_TIMESTAMP(),
// generated by the app, because only the app knows the secret. 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 = { const unsignedAuthToken = {
userId: impressId, userId: impressId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
const signature = computeSignatureForAuthToken(unsignedAuthToken); const signature = computeSignatureForAuthToken(unsignedAuthToken);
const authToken = { ...unsignedAuthToken, signature }; return { ...unsignedAuthToken, signature };
}
// Login success! Return the auth token. The caller will handle setting it to function encryptPassword(password, passwordSalt) {
// a cookie etc. // Use HMAC-SHA256 to encrypt the password. The random salt for this user,
console.debug(`[getAuthToken] Succeeded: ${JSON.stringify(authToken)}`); // saved in the database, is the HMAC "key". (That way, if our database
return authToken; // 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) { export async function getUserIdFromToken(authToken) {

View file

@ -80,6 +80,12 @@ const config = {
currentUserId = null; currentUserId = null;
} }
// In production, the server is behind a few proxy layers (both nginx and
// the CDN), so this IP header will be managed by them and can be trusted.
// (Can be null if something is set up a bit different, e.g. in local
// development.)
const ipAddress = req?.headers?.["x-forwarded-for"] || null;
return { return {
db, db,
currentUserId, currentUserId,
@ -100,6 +106,7 @@ const config = {
res.setHeader("Set-Cookie", `DTIAuthToken=; Max-Age=-1`); res.setHeader("Set-Cookie", `DTIAuthToken=; Max-Age=-1`);
} }
}, },
ipAddress,
...buildLoaders(db), ...buildLoaders(db),
}; };
}, },

View file

@ -366,8 +366,15 @@ const resolvers = {
}, },
Mutation: { Mutation: {
login: async (_, { username, password }, { setAuthToken, db }) => { login: async (
const authToken = await getAuthToken({ username, password }, db); _,
{ username, password },
{ setAuthToken, db, ipAddress }
) => {
const authToken = await getAuthToken(
{ username, password, ipAddress },
db
);
if (authToken == null) { if (authToken == null) {
return null; return null;
} }