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 { 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
// is stored in a users table in a separate database. First, look up the user
// in that database, and get their encrypted auth info.
@ -19,18 +20,12 @@ export async function getAuthToken({ username, password }, db) {
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.)
// 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 passwordHmac = createHmac("sha256", passwordSalt);
passwordHmac.update(password);
const encryptedProvidedPassword = passwordHmac.digest("hex");
const encryptedProvidedPassword = encryptPassword(password, passwordSalt);
if (encryptedProvidedPassword !== encryptedPassword) {
console.debug(
@ -58,24 +53,53 @@ export async function getAuthToken({ username, password }, db) {
}
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.
// 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);
const authToken = { ...unsignedAuthToken, signature };
return { ...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;
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) {

View file

@ -80,6 +80,12 @@ const config = {
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 {
db,
currentUserId,
@ -100,6 +106,7 @@ const config = {
res.setHeader("Set-Cookie", `DTIAuthToken=; Max-Age=-1`);
}
},
ipAddress,
...buildLoaders(db),
};
},

View file

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