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:
parent
68fff3e36d
commit
4d0c48ab7c
4 changed files with 180 additions and 11 deletions
|
@ -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 }) {
|
|||
<TabPanels>
|
||||
<TabPanel>
|
||||
<ModalBody>
|
||||
<LoginForm />
|
||||
<LoginForm onSuccess={() => onClose()} />
|
||||
</ModalBody>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
|
@ -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 (
|
||||
<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>
|
||||
<FormLabel>DTI Username</FormLabel>
|
||||
<Input type="text" />
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
reset();
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
This is separate from your Neopets.com account.
|
||||
</FormHelperText>
|
||||
|
@ -67,17 +99,37 @@ function LoginForm() {
|
|||
<Box height="4" />
|
||||
<FormControl>
|
||||
<FormLabel>DTI Password</FormLabel>
|
||||
<Input type="password" />
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
reset();
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Careful, never enter your Neopets password on another site!
|
||||
</FormHelperText>
|
||||
</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">
|
||||
<Button size="sm" onClick={() => alert("TODO: Forgot password")}>
|
||||
Forgot password?
|
||||
</Button>
|
||||
<Box flex="1 0 auto" width="4" />
|
||||
<Button type="submit" colorScheme="green">
|
||||
<Button type="submit" colorScheme="green" isLoading={loading}>
|
||||
Log in
|
||||
</Button>
|
||||
</Box>
|
||||
|
|
88
src/server/auth-by-db.js
Normal file
88
src/server/auth-by-db.js
Normal 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;
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
|
|
|
@ -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 };
|
||||
|
|
Loading…
Reference in a new issue