From 07973e3488fd8e957e0c1c02fe21d5b9d8434147 Mon Sep 17 00:00:00 2001 From: Niicck Date: Fri, 8 Feb 2019 13:21:23 -0600 Subject: [PATCH] improve password reset process --- package.json | 1 + src/FloodsRoutes.js | 2 +- .../ForgotPasswordPage/ForgotPasswordPage.js | 22 ++- .../ForgotPasswordPage.scss | 44 ----- .../Dashboard/LoginPage/LoginPage.js | 18 +- .../Dashboard/ManageUsersPage/AddUserPage.js | 2 +- .../ManageUsersPage/ArchiveUserModal.js | 2 +- .../ResetPasswordPage/ResetPasswordPage.js | 170 +++++++++++++++--- .../ResetPasswordPage/ResetPasswordPage.scss | 38 ---- .../LoginPage.scss => scss/auth.scss} | 34 +++- src/services/apolloClientService.js | 19 +- src/services/gqlAuth.js | 6 +- src/services/jwtHelper.js | 40 ++--- src/stories/schema/schema.graphql | 3 + src/stories/schema/schema.js | 3 + yarn.lock | 13 ++ 16 files changed, 250 insertions(+), 167 deletions(-) delete mode 100644 src/components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.scss delete mode 100644 src/components/Dashboard/ResetPasswordPage/ResetPasswordPage.scss rename src/{components/Dashboard/LoginPage/LoginPage.scss => scss/auth.scss} (57%) diff --git a/package.json b/package.json index 4df5d565..2329708d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "node-sass-chokidar": "^0.0.3", "prettier": "1.11.1", "prop-types": "15.6.1", + "query-string": "^6.2.0", "raven-js": "^3.24.2", "react": "^16.5.2", "react-apollo": "2.0.4", diff --git a/src/FloodsRoutes.js b/src/FloodsRoutes.js index 008e2402..07b96c3d 100644 --- a/src/FloodsRoutes.js +++ b/src/FloodsRoutes.js @@ -55,7 +55,7 @@ class FloodsRoutes extends Component { component={ForgotPasswordPage} /> ( )} diff --git a/src/components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.js b/src/components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.js index ed6bb66c..451196bd 100644 --- a/src/components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.js +++ b/src/components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.js @@ -3,7 +3,7 @@ import FontAwesome from 'react-fontawesome'; import { logError } from 'services/logger'; -import 'components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.css'; +import 'scss/auth.css'; class ForgotPasswordPage extends Component { state = { @@ -22,7 +22,7 @@ class ForgotPasswordPage extends Component { e.preventDefault(); fetch(`${process.env.REACT_APP_BACKEND_URL}/email/reset`, { method: 'POST', - body: JSON.stringify({ email: this.state.email }), + body: JSON.stringify({ email: this.state.email, newUser: false }), headers: new Headers({ 'Content-Type': 'application/json', }), @@ -54,15 +54,14 @@ class ForgotPasswordPage extends Component { render() { const { emailSentSuccessfully, waiting, errorHappened } = this.state; - return ( -
+
{!emailSentSuccessfully && !waiting && ( -
+

Reset your password

{errorHappened && ( -
+
{' '} Failed to send password reset email{' '}
@@ -76,7 +75,7 @@ class ForgotPasswordPage extends Component { /> @@ -86,10 +85,15 @@ class ForgotPasswordPage extends Component { )} - {emailSentSuccessfully &&

Email Sent!

} + {emailSentSuccessfully && ( +
+

Email Sent!

+

You have 30 minutes to reset your password before your email's link expires.

+
+ )}
); } diff --git a/src/components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.scss b/src/components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.scss deleted file mode 100644 index 169504fd..00000000 --- a/src/components/Dashboard/ForgotPasswordPage/ForgotPasswordPage.scss +++ /dev/null @@ -1,44 +0,0 @@ -.ForgotPasswordPage { - height: 100%; - background: midnightblue; - input { - width: 100%; - font-size: 2rem; - margin-top: 1em; - padding-top: 0.4em; - padding-bottom: 0.4em; - padding-left: 0.4em; - } - h1 { - color: white; - text-align: center; - padding-left: 0.5em; - padding-right: 0.5em; - } - form { - width: 100%; - padding-left: 3%; - padding-right: 3%; - } -} - -.ForgotPasswordPage__waiting { - display: block !important; - color: white; - text-align: center; -} - -.ForgotPasswordPage__form-controls { - width: fit-content; - margin: 20px auto; -} - -.ForgotPasswordPage__submit { - background-color: deepskyblue; -} - -.ForgotPasswordPage__error-text { - color: red; - text-align: center; - font-size: 1.5rem; -} diff --git a/src/components/Dashboard/LoginPage/LoginPage.js b/src/components/Dashboard/LoginPage/LoginPage.js index 675a6c04..4f5f6ece 100644 --- a/src/components/Dashboard/LoginPage/LoginPage.js +++ b/src/components/Dashboard/LoginPage/LoginPage.js @@ -5,7 +5,7 @@ import gql from 'graphql-tag'; import { Link } from 'react-router-dom'; import { logError } from 'services/logger'; -import 'components/Dashboard/LoginPage/LoginPage.css'; +import 'scss/auth.css'; class LoginPage extends Component { static propTypes = { @@ -49,9 +49,14 @@ class LoginPage extends Component { render() { return ( -
-
+
+

Log in to the CTXfloods Dashboard

+ {this.state.errorHappened && ( +
+ Authentication Failed +
+ )}
- +
- {this.state.errorHappened && ( -
- Authentication Failed -
- )} Forgot Password?
); diff --git a/src/components/Dashboard/ManageUsersPage/AddUserPage.js b/src/components/Dashboard/ManageUsersPage/AddUserPage.js index 1bc9b43e..340d3f0c 100644 --- a/src/components/Dashboard/ManageUsersPage/AddUserPage.js +++ b/src/components/Dashboard/ManageUsersPage/AddUserPage.js @@ -77,7 +77,7 @@ class AddUserPage extends Component { sendEmail = user => { fetch(`${process.env.REACT_APP_BACKEND_URL}/email/reset`, { method: 'POST', - body: JSON.stringify({ email: user.email }), + body: JSON.stringify({ email: user.email, newUser: true }), headers: new Headers({ 'Content-Type': 'application/json', }), diff --git a/src/components/Dashboard/ManageUsersPage/ArchiveUserModal.js b/src/components/Dashboard/ManageUsersPage/ArchiveUserModal.js index 41b2a72c..56cfb706 100644 --- a/src/components/Dashboard/ManageUsersPage/ArchiveUserModal.js +++ b/src/components/Dashboard/ManageUsersPage/ArchiveUserModal.js @@ -68,7 +68,7 @@ class ArchiveUserModal extends Component { sendEmail = user => { fetch(`${process.env.REACT_APP_BACKEND_URL}/email/reset`, { method: 'POST', - body: JSON.stringify({ email: user.emailAddress }), + body: JSON.stringify({ email: user.emailAddress, newUser: true }), headers: new Headers({ 'Content-Type': 'application/json', }), diff --git a/src/components/Dashboard/ResetPasswordPage/ResetPasswordPage.js b/src/components/Dashboard/ResetPasswordPage/ResetPasswordPage.js index ea5afd33..eca776e1 100644 --- a/src/components/Dashboard/ResetPasswordPage/ResetPasswordPage.js +++ b/src/components/Dashboard/ResetPasswordPage/ResetPasswordPage.js @@ -1,11 +1,14 @@ import React, { Component } from 'react'; +import FontAwesome from 'react-fontawesome'; import { graphql } from 'react-apollo'; import gql from 'graphql-tag'; import { Redirect } from 'react-router-dom'; import PropTypes from 'prop-types'; +import queryString from 'query-string'; +import { isTokenReal, isTokenValid } from 'services/jwtHelper'; import { logError } from 'services/logger'; -import 'components/Dashboard/ResetPasswordPage/ResetPasswordPage.css'; +import 'scss/auth.css'; class ResetPasswordPage extends Component { static propTypes = { @@ -16,17 +19,27 @@ class ResetPasswordPage extends Component { super(props); this.state = { + waiting: false, password: '', confirmPassword: '', passwordResetSuccessfully: false, + newLinkSetSuccessfully: false, errorHappened: false, - errorMessage: "" + errorMessage: "", }; - const { resetterJwt } = props.match.params; + const { resetterJwt, email } = queryString.parse(props.location.search); + this.email = email; - if (resetterJwt) { - localStorage.setItem('jwt_user_token', resetterJwt); + if (isTokenReal(resetterJwt)) { + if (isTokenValid(resetterJwt)) { + this.tokenExpired = false; + localStorage.setItem('jwt_user_token', resetterJwt); + } else { + this.tokenExpired = true; + } + } else { + this.tokenInvalid = true; } } @@ -38,6 +51,48 @@ class ResetPasswordPage extends Component { this.setState({ confirmPassword: e.target.value }); }; + handleSendNewLink = e => { + this.setState({ waiting: true }); + e.preventDefault(); + fetch(`${process.env.REACT_APP_BACKEND_URL}/email/reset`, { + method: 'POST', + body: JSON.stringify({ email: this.email, newUser: false }), + headers: new Headers({ + 'Content-Type': 'application/json', + }), + }) + .then(res => { + if (res.status === 204) { + this.setState({ + newLinkSetSuccessfully: true, + errorHappened: false, + waiting: false, + }); + } else if (res.status === 400 || res.status === 500) { + this.setState({ + newLinkSetSuccessfully: false, + errorHappened: true, + errorMessage: "Server Error. Failed to reset password.", + waiting: false, + }); + } + }) + .catch(err => { + logError(err); + this.setState({ + newLinkSetSuccessfully: false, + errorHappened: true, + errorMessage: "Server Error. Failed to reset password.", + waiting: false, + }); + }) + } + + handleForgotPasswordLink = e => { + e.preventDefault(); + this.props.history.push('/dashboard/forgot_password'); + } + handleSubmit = e => { e.preventDefault(); const password = this.state.password.trim(); @@ -72,47 +127,106 @@ class ResetPasswordPage extends Component { }; render() { + let content; const { + waiting, passwordResetSuccessfully, + newLinkSetSuccessfully, password, confirmPassword, errorHappened, errorMessage } = this.state; - return ( -
- {!passwordResetSuccessfully && ( -
-

Reset your password

+ // Content if JWT Token is invalid + if (this.tokenInvalid) { + content = ( +
+ Error: Invalid "Reset Password" link. Please if you need to reset your password. +
+ ) + // Content if JWT Token is Expired + } else if (this.tokenExpired) { + if (!waiting && !newLinkSetSuccessfully) { + content = ( +
+

For security reasons, this "Reset Password" link has expired.

+

Please click the button below, and we will send you an email containing a fresh link.

+

You will have 30 minutes to reset your password with that new link.

{errorHappened && ( -
+
{' '} {errorMessage}{' '}
)} -
- - +
- )} - {passwordResetSuccessfully && } + ) + } else if (waiting) { + content = ( + + ) + } else if (newLinkSetSuccessfully) { + content = ( +
+

Email Sent!

+

You have 30 minutes to reset your password before your email's link expires.

+
+ ) + } + // Content for Valid JWT Token + } else { + content = ( +
+ {!passwordResetSuccessfully && ( +
+

Reset your password

+ {errorHappened && ( +
+ {' '} + {errorMessage}{' '} +
+ )} +
+ + + +
+
+ )} + {passwordResetSuccessfully && } +
+ ); + } + + return ( +
+ {content}
); } diff --git a/src/components/Dashboard/ResetPasswordPage/ResetPasswordPage.scss b/src/components/Dashboard/ResetPasswordPage/ResetPasswordPage.scss deleted file mode 100644 index 47231537..00000000 --- a/src/components/Dashboard/ResetPasswordPage/ResetPasswordPage.scss +++ /dev/null @@ -1,38 +0,0 @@ -.ResetPasswordPage { - height: 100%; - background: midnightblue; - input { - width: 100%; - font-size: 2rem; - margin-top: 1em; - padding-top: 0.4em; - padding-bottom: 0.4em; - padding-left: 0.4em; - } - h1 { - color: white; - text-align: center; - padding-left: 0.5em; - padding-right: 0.5em; - } - form { - width: 100%; - padding-left: 3%; - padding-right: 3%; - } -} - -.ResetPasswordPage__form-controls { - width: fit-content; - margin: 20px auto; -} - -.ResetPasswordPage__submit { - background-color: deepskyblue; -} - -.ResetPasswordPage__error-text { - color: red; - text-align: center; - font-size: 1.5rem; -} diff --git a/src/components/Dashboard/LoginPage/LoginPage.scss b/src/scss/auth.scss similarity index 57% rename from src/components/Dashboard/LoginPage/LoginPage.scss rename to src/scss/auth.scss index f106f4a9..e68406e0 100644 --- a/src/components/Dashboard/LoginPage/LoginPage.scss +++ b/src/scss/auth.scss @@ -1,4 +1,4 @@ -.LoginPage { +.AuthPage { height: 100%; background: midnightblue; text-align: center; @@ -10,7 +10,7 @@ padding-bottom: 0.4em; padding-left: 0.4em; } - h1 { + h1, p { color: white; text-align: center; padding-left: 0.5em; @@ -27,18 +27,40 @@ } } -.LoginPage__form-controls { +.Auth__form-controls { width: fit-content; margin: 20px auto; } -.LoginPage__submit { +.Auth__submit { background-color: deepskyblue; } -.LoginPage__error-text { +.Auth__error-text { color: red; text-align: center; font-size: 1.5rem; - margin-bottom: 10px; +} + +.Auth__waiting { + display: block !important; + color: white; + text-align: center; +} + +.button-link { + color: white; + font-size: 1.5rem; + background-color: transparent; + border: none; + cursor: pointer; + text-decoration: underline; + display: inline; + margin: 0; + padding: 0; +} + +.button-link:hover, +.button-link:focus { + text-decoration: none; } diff --git a/src/services/apolloClientService.js b/src/services/apolloClientService.js index f4cb9b52..2f0eb76a 100644 --- a/src/services/apolloClientService.js +++ b/src/services/apolloClientService.js @@ -5,7 +5,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { onError } from "apollo-link-error"; import { logError } from './logger'; -import { isTokenExpired } from './jwtHelper'; +import { isTokenValid } from './jwtHelper'; const httpLink = createHttpLink({ uri: `${process.env.REACT_APP_BACKEND_URL}/graphql`, @@ -29,12 +29,17 @@ const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) const jwtMiddleware = new ApolloLink((operation, forward) => { const token = localStorage.getItem('jwt_user_token'); - if (token !== null && token !== 'null' && !isTokenExpired(token)) { - operation.setContext({ - headers: { - authorization: `Bearer ${token}`, - }, - }); + if (token !== null && token !== 'null') { + if (!isTokenValid(token)) { + localStorage.removeItem('jwt_user_token'); + window.location.reload(); + } else { + operation.setContext({ + headers: { + authorization: `Bearer ${token}`, + }, + }); + } } return forward(operation); diff --git a/src/services/gqlAuth.js b/src/services/gqlAuth.js index ed55e353..786c75ca 100644 --- a/src/services/gqlAuth.js +++ b/src/services/gqlAuth.js @@ -1,12 +1,12 @@ import decode from 'jwt-decode'; -import { isTokenExpired } from './jwtHelper'; +import { isTokenValid } from './jwtHelper'; const auth = { isAuthenticated() { try { var token = localStorage.getItem('jwt_user_token'); if (token === null || token === 'null') return false; - return !isTokenExpired(token); + return isTokenValid(token); } catch (e) { return false; } @@ -16,7 +16,7 @@ const auth = { try { var token = localStorage.getItem('jwt_user_token'); if (token === null || token === 'null') return null; - return isTokenExpired(token) ? false : decode(token).role; + return isTokenValid(token) ? decode(token).role : false; } catch (e) { return null; } diff --git a/src/services/jwtHelper.js b/src/services/jwtHelper.js index c1b1a5dd..10f302d7 100644 --- a/src/services/jwtHelper.js +++ b/src/services/jwtHelper.js @@ -1,28 +1,28 @@ import decode from 'jwt-decode'; -import { logError } from './logger'; +import moment from 'moment'; -export function getTokenExpirationDate(token) { - const decoded = decode(token); - if (!decoded.exp) { - return null; +// Check if token is a real token and not expired. +export function isTokenValid(token) { + let decoded; + try { + decoded = decode(token); + } catch(err) { + return false + } + if (decoded.exp) { + // Check if token has expired + return (decoded.exp > moment().format('X')); + } else { + return true } - const date = new Date(0); // The 0 here is the key, which sets the date to the epoch - date.setUTCSeconds(decoded.exp); - return date; } -export function isTokenExpired(token) { - let date; +// Check if token is a valid token +export function isTokenReal(token) { try { - date = getTokenExpirationDate(token); - } catch(e) { - logError(e); - localStorage.removeItem('jwt_user_token'); - window.location.reload(); - } - const offsetSeconds = 0; - if (date === null) { - return false; + decode(token); + return true + } catch(err) { + return false } - return !(date.valueOf() > new Date().valueOf() + offsetSeconds * 1000); } diff --git a/src/stories/schema/schema.graphql b/src/stories/schema/schema.graphql index fe09d419..6a6cd1f8 100644 --- a/src/stories/schema/schema.graphql +++ b/src/stories/schema/schema.graphql @@ -2398,6 +2398,9 @@ type Query implements Node { after: Cursor ): UsersConnection! + """Stable operation to test expiration of jwt_token.""" + testJwtToken: Boolean + """Reads and enables pagination through a set of `WazeFeedIncident`.""" wazeFeed( """Only read the first `n` values of the set.""" diff --git a/src/stories/schema/schema.js b/src/stories/schema/schema.js index ca4ccfee..5da100dc 100644 --- a/src/stories/schema/schema.js +++ b/src/stories/schema/schema.js @@ -2399,6 +2399,9 @@ type Query implements Node { after: Cursor ): UsersConnection! + """Stable operation to test expiration of jwt_token.""" + testJwtToken: Boolean + """Reads and enables pagination through a set of 'WazeFeedIncident'.""" wazeFeed( """Only read the first 'n' values of the set.""" diff --git a/yarn.lock b/yarn.lock index 422b07e9..444e54d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11977,6 +11977,14 @@ query-string@^4.1.0: object-assign "^4.1.0" strict-uri-encode "^1.0.0" +query-string@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.2.0.tgz#468edeb542b7e0538f9f9b1aeb26f034f19c86e1" + integrity sha512-5wupExkIt8RYL4h/FE+WTg3JHk62e6fFPWtAZA9J5IWK1PfTfKkMS93HBUHcFpeYi9KsY5pFbh+ldvEyaz5MyA== + dependencies: + decode-uri-component "^0.2.0" + strict-uri-encode "^2.0.0" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -13875,6 +13883,11 @@ strict-uri-encode@^1.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= +strict-uri-encode@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" + integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= + string-convert@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97"