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 {
|
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
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 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),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
Loading…
Reference in a new issue