Login form checks the db, and saves a cookie

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!)
This commit is contained in:
Emi Matchu 2022-08-17 00:58:52 -07:00
parent 68fff3e36d
commit 4d0c48ab7c
4 changed files with 180 additions and 11 deletions

View file

@ -1,3 +1,4 @@
import { gql, useMutation } from "@apollo/client";
import { import {
Box, Box,
Button, Button,
@ -18,6 +19,7 @@ import {
Tabs, Tabs,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import React from "react"; import React from "react";
import { ErrorMessage, getGraphQLErrorMessage } from "../util";
export default function LoginModal({ isOpen, onClose }) { export default function LoginModal({ isOpen, onClose }) {
return ( return (
@ -34,7 +36,7 @@ export default function LoginModal({ isOpen, onClose }) {
<TabPanels> <TabPanels>
<TabPanel> <TabPanel>
<ModalBody> <ModalBody>
<LoginForm /> <LoginForm onSuccess={() => onClose()} />
</ModalBody> </ModalBody>
</TabPanel> </TabPanel>
<TabPanel> <TabPanel>
@ -49,17 +51,47 @@ export default function LoginModal({ isOpen, onClose }) {
); );
} }
function LoginForm() { function LoginForm({ onSuccess }) {
const onSubmit = (e) => { const [username, setUsername] = React.useState("");
e.preventDefault(); const [password, setPassword] = React.useState("");
alert("TODO: Log in!");
}; const [
sendLoginMutation,
{ loading, error, data, called, reset },
] = useMutation(gql`
mutation LoginForm_Login($username: String!, $password: String!) {
login(username: $username, password: $password) {
id
username
}
}
`);
return ( return (
<form onSubmit={onSubmit}> <form
onSubmit={(e) => {
e.preventDefault();
sendLoginMutation({
variables: { username, password },
})
.then(({ data }) => {
if (data?.login != null) {
onSuccess();
}
})
.catch((e) => {}); // handled in error UI
}}
>
<FormControl> <FormControl>
<FormLabel>DTI Username</FormLabel> <FormLabel>DTI Username</FormLabel>
<Input type="text" /> <Input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value);
reset();
}}
/>
<FormHelperText> <FormHelperText>
This is separate from your Neopets.com account. This is separate from your Neopets.com account.
</FormHelperText> </FormHelperText>
@ -67,17 +99,37 @@ function LoginForm() {
<Box height="4" /> <Box height="4" />
<FormControl> <FormControl>
<FormLabel>DTI Password</FormLabel> <FormLabel>DTI Password</FormLabel>
<Input type="password" /> <Input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
reset();
}}
/>
<FormHelperText> <FormHelperText>
Careful, never enter your Neopets password on another site! Careful, never enter your Neopets password on another site!
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
{error && (
<ErrorMessage marginTop="4">
Oops, login failed: "{getGraphQLErrorMessage(error)}". Try again?
</ErrorMessage>
)}
{called && !loading && !error && data?.login == null && (
<ErrorMessage marginTop="4">
We couldn't find a match for that username and password. Try again?
</ErrorMessage>
)}
<Box marginTop="6" display="flex" alignItems="center"> <Box marginTop="6" display="flex" alignItems="center">
<Button size="sm" onClick={() => alert("TODO: Forgot password")}> <Button size="sm" onClick={() => alert("TODO: Forgot password")}>
Forgot password? Forgot password?
</Button> </Button>
<Box flex="1 0 auto" width="4" /> <Box flex="1 0 auto" width="4" />
<Button type="submit" colorScheme="green"> <Button type="submit" colorScheme="green" isLoading={loading}>
Log in Log in
</Button> </Button>
</Box> </Box>

88
src/server/auth-by-db.js Normal file
View file

@ -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;
}

View file

@ -4,6 +4,7 @@ import { getUserIdFromToken } from "./auth";
import connectToDb from "./db"; import connectToDb from "./db";
import buildLoaders from "./loaders"; import buildLoaders from "./loaders";
import { plugin as cacheControlPluginFork } from "./lib/apollo-cache-control-fork"; import { plugin as cacheControlPluginFork } from "./lib/apollo-cache-control-fork";
import { getAuthToken } from "./auth-by-db";
const rootTypeDefs = gql` const rootTypeDefs = gql`
enum CacheScope { enum CacheScope {
@ -60,7 +61,7 @@ if (process.env["NODE_ENV"] !== "test") {
const config = { const config = {
schema, schema,
context: async ({ req }) => { context: async ({ req, res }) => {
const db = await connectToDb(); const db = await connectToDb();
const auth = (req && req.headers && req.headers.authorization) || ""; const auth = (req && req.headers && req.headers.authorization) || "";
@ -71,6 +72,20 @@ const config = {
return { return {
db, db,
currentUserId, 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), ...buildLoaders(db),
}; };
}, },

View file

@ -56,6 +56,10 @@ const typeDefs = gql`
# login/logout will change the local cache key! # login/logout will change the local cache key!
currentUser: User @cacheControl(scope: PRIVATE) currentUser: User @cacheControl(scope: PRIVATE)
} }
extend type Mutation {
login(username: String!, password: String!): User
}
`; `;
const resolvers = { const resolvers = {
@ -358,6 +362,16 @@ const resolvers = {
return { id: currentUserId }; 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 }; module.exports = { typeDefs, resolvers };