Merge branch 'master' into feature/63-delete-account-frontend

This commit is contained in:
Tom Russell 2019-08-23 12:42:03 +01:00 committed by GitHub
commit a35289d9b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 3910 additions and 3423 deletions

6745
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@
"leaflet": "^1.5.1", "leaflet": "^1.5.1",
"mapnik": "^4.2.1", "mapnik": "^4.2.1",
"node-fs": "^0.1.7", "node-fs": "^0.1.7",
"nodemailer": "^6.3.0",
"pg-promise": "^8.7.5", "pg-promise": "^8.7.5",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"query-string": "^6.8.2", "query-string": "^6.8.2",
@ -39,7 +40,8 @@
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/express-session": "^1.15.13", "@types/express-session": "^1.15.13",
"@types/jest": "^24.0.17", "@types/jest": "^24.0.17",
"@types/node": "^12.7.1", "@types/node": "^8.10.52",
"@types/nodemailer": "^6.2.1",
"@types/prop-types": "^15.7.1", "@types/prop-types": "^15.7.1",
"@types/react": "^16.9.1", "@types/react": "^16.9.1",
"@types/react-dom": "^16.8.5", "@types/react-dom": "^16.8.5",

View File

@ -1,49 +1,20 @@
import express from 'express'; import express from 'express';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import { authUser, createUser, getUserById, getNewUserAPIKey, deleteUser } from './services/user'; import { authUser, createUser, getUserById, getNewUserAPIKey, deleteUser, logout } from './services/user';
import { queryLocation } from './services/search'; import { queryLocation } from './services/search';
import buildingsRouter from './routes/buildingsRouter'; import buildingsRouter from './routes/buildingsRouter';
import usersRouter from './routes/usersRouter';
const server = express(); const server = express.Router();
// parse POSTed json body // parse POSTed json body
server.use(bodyParser.json()); server.use(bodyParser.json());
server.use('/buildings', buildingsRouter); server.use('/buildings', buildingsRouter);
server.use('/users', usersRouter);
// POST new user
server.post('/users', function (req, res) {
const user = req.body;
if (req.session.user_id) {
res.send({ error: 'Already signed in' });
return
}
if (user.email) {
if (user.email != user.confirm_email) {
res.send({ error: 'Email did not match confirmation.' });
return
}
} else {
user.email = null;
}
createUser(user).then(function (result) {
if (result.user_id) {
req.session.user_id = result.user_id;
res.send({ user_id: result.user_id });
} else {
req.session.user_id = undefined;
res.send({ error: result.error });
}
}).catch(function (err) {
console.error(err);
res.send(err)
});
});
// GET own user info // GET own user info
server.route('/users/me') server.route('/users/me')
@ -98,16 +69,6 @@ server.post('/logout', function (req, res) {
}); });
}); });
function logout(session) {
return new Promise((resolve, reject) => {
session.user_id = undefined;
session.destroy(err => {
if (err) return reject(err);
return resolve();
});
});
}
// POST generate API key // POST generate API key
server.post('/api/key', function (req, res) { server.post('/api/key', function (req, res) {
if (!req.session.user_id) { if (!req.session.user_id) {
@ -157,8 +118,20 @@ server.get('/search', function (req, res) {
}); });
}) })
server.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
if (err != undefined) {
console.log('Global error handler: ', err);
res.status(500).send({ error: 'Server error' });
}
});
server.use((req, res) => { server.use((req, res) => {
res.status(404).json({ error: 'Resource not found'}); res.status(404).json({ error: 'Resource not found'});
}) })
export default server;
export default server;

View File

@ -0,0 +1,104 @@
import { URL } from 'url';
import express from 'express';
import * as userService from '../services/user';
import * as passwordResetService from '../services/passwordReset';
import { TokenVerificationError } from '../services/passwordReset';
function createUser(req, res) {
const user = req.body;
if (req.session.user_id) {
res.send({ error: 'Already signed in' });
return;
}
if (user.email) {
if (user.email != user.confirm_email) {
res.send({ error: 'Email did not match confirmation.' });
return;
}
} else {
user.email = null;
}
userService.createUser(user).then(function (result) {
if (result.user_id) {
req.session.user_id = result.user_id;
res.send({ user_id: result.user_id });
} else {
req.session.user_id = undefined;
res.send({ error: result.error });
}
}).catch(function (err) {
console.error(err);
res.send(err);
});
}
function getCurrentUser(req, res) {
if (!req.session.user_id) {
res.send({ error: 'Must be logged in' });
return;
}
userService.getUserById(req.session.user_id).then(function (user) {
res.send(user);
}).catch(function (error) {
res.send(error);
});
}
function deleteCurrentUser(req, res) {
if (!req.session.user_id) {
return res.send({ error: 'Must be logged in' });
}
console.log(`Deleting user ${req.session.user_id}`);
userService.deleteUser(req.session.user_id).then(
() => userService.logout(req.session)
).then(() => {
res.send({ success: true });
}).catch(err => {
res.send({ error: err });
});
}
async function resetPassword(req: express.Request, res: express.Response) {
if(req.body == undefined || (req.body.email == undefined && req.body.token == undefined)) {
return res.send({ error: 'Expected an email address or password reset token in the request body' });
}
if(req.body.email != undefined) {
// first stage: send reset token to email address
// this relies on the API being on the same hostname as the frontend
const { origin } = new URL(req.protocol + '://' + req.headers.host);
await passwordResetService.sendPasswordResetToken(req.body.email, origin);
return res.status(202).send({ success: true });
} else if (req.body.token != undefined) {
// second stage: verify token and reset password
if (req.body.password == undefined) {
return res.send({ error: 'Expected a new password' });
}
try {
await passwordResetService.resetPassword(req.body.token, req.body.password);
} catch (err) {
if (err instanceof TokenVerificationError) {
return res.send({ error: 'Could not verify token' });
}
throw err;
}
return res.send({ success: true });
}
}
export default {
createUser,
getCurrentUser,
deleteCurrentUser,
resetPassword
};

View File

@ -0,0 +1,21 @@
import express from 'express';
import userController from '../controllers/userController';
const asyncMiddleware = fn =>
(req, res, next) => {
Promise.resolve(fn(req, res, next))
.catch(next);
};
const router = express.Router();
router.post('/', userController.createUser);
router.route('/me')
.get(userController.getCurrentUser)
.delete(userController.deleteCurrentUser);
router.put('/password', asyncMiddleware(userController.resetPassword));
export default router;

View File

@ -0,0 +1,11 @@
import * as nodemailer from 'nodemailer';
export const transporter = nodemailer.createTransport({
host: process.env.MAIL_SERVER_HOST,
port: parseInt(process.env.MAIL_SERVER_PORT),
secure: false,
auth: {
user: process.env.MAIL_SERVER_USER,
pass: process.env.MAIL_SERVER_PASSWORD
}
});

View File

@ -0,0 +1,121 @@
import url, { URL } from 'url';
import { errors } from 'pg-promise';
import nodemailer from 'nodemailer';
import db from '../../db';
import * as userService from './user';
import { transporter } from './email';
/**
* Generate a password reset token for the specified account and send the password reset link by email
* @param email the email address for which to generate a password reset token
* @param siteOrigin the origin of the website, without a path element - e.g. https://beta.colouring.london
*/
export async function sendPasswordResetToken(email: string, siteOrigin: string): Promise<void> {
const user = await userService.getUserByEmail(email);
// if no user found for email, do nothing
if (user == undefined) return;
const token = await createPasswordResetToken(user.user_id);
const message = getPasswordResetEmail(email, token, siteOrigin);
await transporter.sendMail(message);
}
export async function resetPassword(passwordResetToken: string, newPassword: string): Promise<void> {
const userId = await verifyPasswordResetToken(passwordResetToken);
if (userId != undefined) {
await updatePasswordForUser(userId, newPassword);
//TODO: expire other tokens of the user?
} else {
throw new TokenVerificationError('Password reset token could not be verified');
}
}
export class TokenVerificationError extends Error {
constructor(message: string) {
super(message);
this.name = "TokenVerificationError";
}
}
function getPasswordResetEmail(email: string, token: string, siteOrigin: string): nodemailer.SendMailOptions {
const linkUrl = new URL(siteOrigin);
linkUrl.pathname = '/password-reset.html';
linkUrl.search = `?token=${token}`;
const linkString = url.format(linkUrl);
const messageBody = `Hi there,
Someone has requested a password reset for the Colouring London account associated with this email address.
Click on the following link within the next 24 hours to reset your password:
${linkString}
`;
return {
text: messageBody,
subject: 'Reset your Colouring London password',
to: email,
from: 'no-reply@colouring.london'
};
}
async function createPasswordResetToken(userId: string) {
const { token } = await db.one(
`INSERT INTO
user_password_reset_tokens (user_id, expires_on)
VALUES
($1::uuid, now() at time zone 'utc' + INTERVAL '$2 day')
RETURNING
token
`, [userId, 1]
);
return token;
}
/**
* Verify that the password reset token is valid and expire the token
* @param token password reset token to verify and use
* @returns the UUID of the user whose token was used
*/
async function verifyPasswordResetToken(token: string): Promise<string> {
try {
// verify and deactivate the token in one operation
const usedToken = await db.one(
`UPDATE
user_password_reset_tokens
SET used = true
WHERE
token = $1::uuid
AND NOT used
AND now() at time zone 'utc' < expires_on
RETURNING *`, [token]
);
console.log('verified');
return usedToken.user_id;
} catch (err) {
if (err instanceof errors.QueryResultError) {
console.log(err.code);
if (err.code === errors.queryResultErrorCode.noData) return undefined;
}
throw err;
}
}
async function updatePasswordForUser(userId: string, newPassword: string): Promise<null> {
return db.none(
`UPDATE
users
SET
pass = crypt($1, gen_salt('bf'))
WHERE
user_id = $2::uuid
`, [newPassword, userId]);
}

View File

@ -86,6 +86,21 @@ function getUserById(id) {
}); });
} }
function getUserByEmail(email: string) {
return db.one(
`SELECT
user_id, username, email
FROM
users
WHERE
email = $1
`, [email]
).catch(function(error) {
console.error('Error:', error);
return undefined;
});
}
function getNewUserAPIKey(id) { function getNewUserAPIKey(id) {
return db.one( return db.one(
`UPDATE `UPDATE
@ -140,4 +155,23 @@ function deleteUser(id) {
}); });
} }
export { getUserById, createUser, authUser, getNewUserAPIKey, authAPIUser, deleteUser } function logout(session: Express.Session) {
return new Promise((resolve, reject) => {
session.user_id = undefined;
session.destroy(err => {
if (err) return reject(err);
return resolve();
});
});
}
export {
getUserById,
getUserByEmail,
createUser,
authUser,
getNewUserAPIKey,
authAPIUser,
deleteUser,
logout
};

View File

@ -20,6 +20,8 @@ import Welcome from './welcome';
import { parseCategoryURL } from '../parse'; import { parseCategoryURL } from '../parse';
import PrivacyPolicyPage from './privacy-policy'; import PrivacyPolicyPage from './privacy-policy';
import ContributorAgreementPage from './contributor-agreement'; import ContributorAgreementPage from './contributor-agreement';
import ForgottenPassword from './forgotten-password';
import PasswordReset from './password-reset';
/** /**
* App component * App component
@ -247,6 +249,8 @@ class App extends React.Component<any, any> { // TODO: add proper types
<Route exact path="/login.html"> <Route exact path="/login.html">
<Login user={this.state.user} login={this.login} /> <Login user={this.state.user} login={this.login} />
</Route> </Route>
<Route exact path="/forgotten-password.html" component={ForgottenPassword} />
<Route exact path="/password-reset.html" component={PasswordReset} />
<Route exact path="/sign-up.html"> <Route exact path="/sign-up.html">
<SignUp user={this.state.user} login={this.login} /> <SignUp user={this.state.user} login={this.login} />
</Route> </Route>

View File

@ -0,0 +1,84 @@
import React, { FormEvent, ChangeEvent } from 'react';
import InfoBox from './info-box';
import ErrorBox from './error-box';
interface ForgottenPasswordState {
success: boolean;
error: string;
email: string;
emailUsed: string;
}
export default class ForgottenPassword extends React.Component<{}, ForgottenPasswordState> {
constructor(props) {
super(props);
this.state = {
error: undefined,
success: undefined,
email: undefined,
emailUsed: undefined
};
}
handleChange(event: ChangeEvent<HTMLInputElement>) {
const { name, value } = event.currentTarget;
this.setState({ [name]: value } as any);
}
async handleSubmit(event: FormEvent) {
event.preventDefault();
this.setState({ error: undefined, success: undefined });
const emailSent = this.state.email;
try {
const res = await fetch('/api/users/password', {
method: 'PUT',
body: JSON.stringify({ email: emailSent }),
headers: {
'Content-Type': 'application/json'
}
});
const data = await res.json();
if (data.error != undefined) {
this.setState({ error: data.error });
} else if (data.success === true) {
this.setState({ success: true, emailUsed: emailSent});
} else {
this.setState({ error: 'Unexpected result.' });
}
} catch (err) {
this.setState({ error: 'Something went wrong.' });
}
}
render() {
return (
<article>
<section className="main-col">
<h1 className="h2">Forgotten password</h1>
<p>Please provide the e-mail address associated with your account. A password reset link will be sent to your mailbox.</p>
<ErrorBox msg={this.state.error} />
<InfoBox msg="">
{this.state.success ?
`A password reset link has been sent to ${this.state.emailUsed}. Please check your inbox.` :
null
}
</InfoBox>
<form onSubmit={e => this.handleSubmit(e)}>
<label htmlFor="email">E-mail</label>
<input name="email" id="email"
className="form-control" type="email"
placeholder="Your e-mail address" required
onChange={e => this.handleChange(e)}
/>
<div className="buttons-container">
<input type="submit" value="Request password reset" className="btn btn-primary" />
</div>
</form>
</section>
</article>
)
}
}

View File

@ -111,6 +111,8 @@ class Login extends Component<any, any> { // TODO: add proper types
<label htmlFor="show_password" className="form-check-label">Show password?</label> <label htmlFor="show_password" className="form-check-label">Show password?</label>
</div> </div>
<Link to="/forgotten-password.html">Forgotten password?</Link>
<div className="buttons-container"> <div className="buttons-container">
<input type="submit" value="Log In" className="btn btn-primary" /> <input type="submit" value="Log In" className="btn btn-primary" />
</div> </div>

View File

@ -0,0 +1,122 @@
import React, { ChangeEvent, FormEvent } from 'react';
import { RouteComponentProps, Redirect } from 'react-router';
import ErrorBox from './error-box';
import { Link } from 'react-router-dom';
interface PasswordResetState {
error: string;
success: boolean;
token: string;
password: string;
confirmPassword: string;
}
export default class PasswordReset extends React.Component<RouteComponentProps, PasswordResetState> {
static tokenMissingMessage = 'Password reset token is missing. Make sure to follow the link from a password reset email!';
constructor(props) {
super(props);
this.state = {
error: undefined,
success: undefined,
token: undefined,
password: undefined,
confirmPassword: undefined
};
}
componentDidMount() {
const queryParams = new URLSearchParams(this.props.location.search);
const token = queryParams.get('token');
if(token == undefined) {
this.setState({ error: PasswordReset.tokenMissingMessage });
} else {
this.setState({ token: token });
}
}
handleChange(event: FormEvent<HTMLInputElement>) {
const { name, value } = event.currentTarget;
this.setState({
[name]: value,
} as any);
}
async handleSubmit(event: FormEvent) {
event.preventDefault();
this.setState({ error: undefined });
if(this.state.token == undefined) {
this.setState({ error: PasswordReset.tokenMissingMessage });
return;
}
if(this.state.password !== this.state.confirmPassword) {
this.setState({ error: 'Passwords do not match' });
return;
}
const requestData = {
token: this.state.token,
password: this.state.password
};
const res = await fetch('/api/users/password', {
method: 'PUT',
body: JSON.stringify(requestData),
headers: {
'Content-Type': 'application/json'
}
});
const data = await res.json();
if (data.error != undefined) {
this.setState({ error: data.error });
} else if (data.success === true) {
this.setState({ success: true });
} else {
this.setState({ error: 'Unexpected result.' });
}
}
render() {
if (this.state.success) {
return <Redirect to="/my-account.html" />;
}
return (
<article>
<section className="main-col">
<h1 className="h2">Forgotten password</h1>
<p>Please input the new password you want to set for your account. If your password reset token does not work, go back to the <Link to="/forgotten-password.html">forgotten password page</Link> to request a new link.</p>
<ErrorBox msg={this.state.error} />
<form onSubmit={e => this.handleSubmit(e)}>
<label htmlFor="email">New password</label>
<input name="password"
className="form-control" type="password"
placeholder="New password" required
onChange={e => this.handleChange(e)}
/>
<label htmlFor="email">Confirm new password</label>
<input name="confirmPassword"
className="form-control" type="password"
placeholder="Confirm new password" required
onChange={e => this.handleChange(e)}
/>
<input name="token" id="token" type="hidden" value={this.state.token} />
<div className="buttons-container">
<input type="submit" value="Change password" className="btn btn-primary" />
</div>
</form>
</section>
</article>
)
}
}

View File

@ -21,7 +21,10 @@ module.exports = {
PGUSER: "username", PGUSER: "username",
PGPASSWORD: "longrandomsecret", PGPASSWORD: "longrandomsecret",
APP_COOKIE_SECRET: "longrandomsecret", APP_COOKIE_SECRET: "longrandomsecret",
MAIL_SERVER_HOST: "mail_hostname",
MAIL_SERVER_PORT: 587,
MAIL_SERVER_USER: "mail_username",
MAIL_SERVER_PASSWORD: "longrandompassword",
} }
} }
] ]

View File

@ -17,7 +17,11 @@ module.exports = {
PGUSER: "username", PGUSER: "username",
PGPASSWORD: "longrandomsecret", PGPASSWORD: "longrandomsecret",
APP_COOKIE_SECRET: "longrandomsecret", APP_COOKIE_SECRET: "longrandomsecret",
TILECACHE_PATH: "/path/to/tile/cache" TILECACHE_PATH: "/path/to/tile/cache",
MAIL_SERVER_HOST: "mail_hostname",
MAIL_SERVER_PORT: 587,
MAIL_SERVER_USER: "mail_username",
MAIL_SERVER_PASSWORD: "longrandompassword",
} }
} }
] ]

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS user_password_reset_tokens;

View File

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS user_password_reset_tokens (
token uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid,
expires_on timestamp NOT NULL,
used boolean NOT NULL DEFAULT false
)