From 4d0c48ab7c665aa1f7cc889376d1934a33840d83 Mon Sep 17 00:00:00 2001 From: Matchu Date: Wed, 17 Aug 2022 00:58:52 -0700 Subject: [PATCH] Login form checks the db, and saves a cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Okay so one of the trickiest parts of login is done! 🤞 and now we need to make it actually show up in the UI. (and also pressure-test the security a bit, I've only really checked the happy path!) --- src/app/components/LoginModal.js | 72 ++++++++++++++++++++++---- src/server/auth-by-db.js | 88 ++++++++++++++++++++++++++++++++ src/server/index.js | 17 +++++- src/server/types/User.js | 14 +++++ 4 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 src/server/auth-by-db.js diff --git a/src/app/components/LoginModal.js b/src/app/components/LoginModal.js index 85d9c8c..65cf34e 100644 --- a/src/app/components/LoginModal.js +++ b/src/app/components/LoginModal.js @@ -1,3 +1,4 @@ +import { gql, useMutation } from "@apollo/client"; import { Box, Button, @@ -18,6 +19,7 @@ import { Tabs, } from "@chakra-ui/react"; import React from "react"; +import { ErrorMessage, getGraphQLErrorMessage } from "../util"; export default function LoginModal({ isOpen, onClose }) { return ( @@ -34,7 +36,7 @@ export default function LoginModal({ isOpen, onClose }) { - + onClose()} /> @@ -49,17 +51,47 @@ export default function LoginModal({ isOpen, onClose }) { ); } -function LoginForm() { - const onSubmit = (e) => { - e.preventDefault(); - alert("TODO: Log in!"); - }; +function LoginForm({ onSuccess }) { + const [username, setUsername] = React.useState(""); + const [password, setPassword] = React.useState(""); + + const [ + sendLoginMutation, + { loading, error, data, called, reset }, + ] = useMutation(gql` + mutation LoginForm_Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + id + username + } + } + `); return ( -
+ { + e.preventDefault(); + sendLoginMutation({ + variables: { username, password }, + }) + .then(({ data }) => { + if (data?.login != null) { + onSuccess(); + } + }) + .catch((e) => {}); // handled in error UI + }} + > DTI Username - + { + setUsername(e.target.value); + reset(); + }} + /> This is separate from your Neopets.com account. @@ -67,17 +99,37 @@ function LoginForm() { DTI Password - + { + setPassword(e.target.value); + reset(); + }} + /> Careful, never enter your Neopets password on another site! + + {error && ( + + Oops, login failed: "{getGraphQLErrorMessage(error)}". Try again? + + )} + + {called && !loading && !error && data?.login == null && ( + + We couldn't find a match for that username and password. Try again? + + )} + - diff --git a/src/server/auth-by-db.js b/src/server/auth-by-db.js new file mode 100644 index 0000000..47eaf87 --- /dev/null +++ b/src/server/auth-by-db.js @@ -0,0 +1,88 @@ +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; + } + + // Finally, 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. + 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 unsignedAuthToken = { userId: impressId }; + const authTokenHmac = createHmac( + "sha256", + process.env["DTI_AUTH_TOKEN_SECRET"] + ); + authTokenHmac.update(JSON.stringify(unsignedAuthToken)); + const signature = authTokenHmac.digest("hex"); + 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; +} diff --git a/src/server/index.js b/src/server/index.js index 32e9b0e..286ad48 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -4,6 +4,7 @@ import { getUserIdFromToken } from "./auth"; import connectToDb from "./db"; import buildLoaders from "./loaders"; import { plugin as cacheControlPluginFork } from "./lib/apollo-cache-control-fork"; +import { getAuthToken } from "./auth-by-db"; const rootTypeDefs = gql` enum CacheScope { @@ -60,7 +61,7 @@ if (process.env["NODE_ENV"] !== "test") { const config = { schema, - context: async ({ req }) => { + context: async ({ req, res }) => { const db = await connectToDb(); const auth = (req && req.headers && req.headers.authorization) || ""; @@ -71,6 +72,20 @@ const config = { return { db, currentUserId, + login: async (params) => { + const authToken = await getAuthToken(params, db); + if (authToken == null) { + return null; + } + const oneWeekFromNow = new Date(); + oneWeekFromNow.setDate(oneWeekFromNow.getDate() + 7); + res.setHeader( + "Set-Cookie", + `DTIAuthToken=${encodeURIComponent(JSON.stringify(authToken))}; ` + + `Max-Age=${60 * 60 * 24 * 7}; Secure; HttpOnly; SameSite=Strict` + ); + return authToken; + }, ...buildLoaders(db), }; }, diff --git a/src/server/types/User.js b/src/server/types/User.js index b34d19c..5b06c9d 100644 --- a/src/server/types/User.js +++ b/src/server/types/User.js @@ -56,6 +56,10 @@ const typeDefs = gql` # login/logout will change the local cache key! currentUser: User @cacheControl(scope: PRIVATE) } + + extend type Mutation { + login(username: String!, password: String!): User + } `; const resolvers = { @@ -358,6 +362,16 @@ const resolvers = { return { id: currentUserId }; }, }, + + Mutation: { + login: async (_, { username, password }, { login }) => { + const loginToken = await login({ username, password }); + if (loginToken == null) { + return null; + } + return { id: loginToken.userId }; + }, + }, }; module.exports = { typeDefs, resolvers };