diff --git a/src/server/auth-by-db.js b/src/server/auth-by-db.js index dfcb958..0e77829 100644 --- a/src/server/auth-by-db.js +++ b/src/server/auth-by-db.js @@ -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) { diff --git a/src/server/index.js b/src/server/index.js index b5619d7..7b902e5 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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), }; }, diff --git a/src/server/types/User.js b/src/server/types/User.js index 3b67bcc..12bc280 100644 --- a/src/server/types/User.js +++ b/src/server/types/User.js @@ -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; }