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:
parent
4c9dbf91fb
commit
c7ba61a0f1
3 changed files with 62 additions and 24 deletions
|
@ -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) {
|
||||||
|
|
|
@ -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),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue