diff --git a/README.md b/README.md index 3a265574..a8f62c54 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,20 @@ Run npm install in fact-bounty-client folder. export ELASTIC_SEARCH_URL="" export ELASTIC_SEARCH_USERNAME="" export ELASTIC_SEARCH_PASSWORD="" + + export TZ="Asia/Colombo" + + export MAIL_USERNAME="" + export MAIL_PASSWORD="" + export FACTBOUNTY_ADMIN="" + export MAIL_PORT="587" + export MAIL_USE_TLS="true" + export MAIL_SERVER="smtp.gmail.com" + + export ADMIN_USERNAME = "admin" + export ADMIN_PASSWORD = "password" + export ADMIN_EMAIL = "email@email.com" + export TZ=“Asia/Colombo” ``` @@ -107,12 +121,12 @@ Run npm install in fact-bounty-client folder. ### How to install Elasticsearch and start elasticsearch server -* #### Elasticsearch v6.7.0 can be installed as follows: +* #### Elasticsearch v7.6.0 can be installed as follows: ``` - (venv)$ wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.7.0.deb - (venv)$ wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.7.0.deb.sha512 - (venv)$ shasum -a 512 -c elasticsearch-6.7.0.deb.sha512 - (venv)$ sudo dpkg -i elasticsearch-6.7.0.deb + (venv)$ wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.6.0.deb + (venv)$ wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.6.0.deb.sha512 + (venv)$ shasum -a 512 -c elasticsearch-7.6.0.deb.sha512 + (venv)$ sudo dpkg -i elasticsearch-7.6.0.deb ``` @@ -202,6 +216,10 @@ And use [localhost:3000](https://) to browse. * #### Once build completes, run `docker-compose up` +## Setting up an OAuth Daemon + +[How to setup OAuth Daemon](OAuthdSetup.md) + # How to Contribute - First fork the repository and clone it. @@ -211,3 +229,4 @@ And use [localhost:3000](https://) to browse. ## License [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fscorelab%2Ffact-Bounty.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fscorelab%2Ffact-Bounty?ref=badge_large) + diff --git a/fact-bounty-client/src/AppRouter.js b/fact-bounty-client/src/AppRouter.js index dbbff8c5..f2fc5fab 100755 --- a/fact-bounty-client/src/AppRouter.js +++ b/fact-bounty-client/src/AppRouter.js @@ -13,7 +13,8 @@ import Dashboard from './pages/Dashboard' import Posts from './pages/Posts' import PostDetailView from './pages/PostDetailView' import Tweets from './pages/Tweets' - +import ForgotPassword from './pages/ForgotPassword' +import ResetPassword from './pages/ResetPassword' class AppRouter extends Component { render() { return ( @@ -23,6 +24,12 @@ class AppRouter extends Component { + + diff --git a/fact-bounty-client/src/pages/ForgotPassword/ForgotPassword.jsx b/fact-bounty-client/src/pages/ForgotPassword/ForgotPassword.jsx new file mode 100644 index 00000000..52c89977 --- /dev/null +++ b/fact-bounty-client/src/pages/ForgotPassword/ForgotPassword.jsx @@ -0,0 +1,255 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import classnames from 'classnames' +import compose from 'recompose/compose' +import Avatar from '@material-ui/core/Avatar' +import Button from '@material-ui/core/Button' +import CssBaseline from '@material-ui/core/CssBaseline' +import FormControl from '@material-ui/core/FormControl' +import Input from '@material-ui/core/Input' +import InputLabel from '@material-ui/core/InputLabel' +import LockOutlinedIcon from '@material-ui/icons/LockOutlined' +import Paper from '@material-ui/core/Paper' +import Typography from '@material-ui/core/Typography' +import withStyles from '@material-ui/core/styles/withStyles' +import Toast from '../../components/Toast' +import { updateError } from '../../redux/actions/errorActions' +import { updateSuccess } from '../../redux/actions/successActions' +import { + forgotPassword, + authVerificationToken +} from '../../redux/actions/authActions' +import styles from './ForgotPassword.style' + +class ForgotPassword extends Component { + constructor() { + super() + this.state = { + email: '', + verificationToken: '', + errors: {}, + success: {}, + emailValid: false, + verificationTokenValid: false, + formValid: false, + openToast: false + } + } + + componentDidMount() { + // If logged in and user navigates to Register page, should redirect them to dashboard + this.props.updateError({}) + this.props.updateSuccess({}) + if (this.props.auth.isAuthenticated) { + this.props.history.push('/dashboard') + } + } + + static getDerivedStateFromProps(props, state) { + if (props.errors) { + const errors = props.errors + let openToast = false + if (errors.fetch) { + openToast = true + } + return { errors, openToast } + } + if (props.success) { + const success = props.success + let openToast = false + if (success.fetch) { + openToast = true + } + return { success, openToast } + } + return null + } + + onChange = e => { + let { id, value } = e.target + this.setState({ [id]: value }, () => { + this.validateField(id, value) + }) + } + + validateField = (fieldname, value) => { + let { emailValid, verificationTokenValid, errors } = this.state + + emailValid = value.match(/^([\w.%+-]+)@([\w-]+\.)+([\w]{2,})$/i) + verificationTokenValid = !!this.state.verificationToken + switch (fieldname) { + case 'email': + errors.email = emailValid ? '' : 'Invalid E-mail' + break + default: + break + } + this.setState( + { + errors, + emailValid, + verificationTokenValid + }, + this.validateForm + ) + } + + validateForm = () => { + this.setState({ + formValid: this.state.emailValid || this.state.verificationToken !== '' + }) + } + closeToast = () => { + // Remove error from store + this.props.updateError({}) + this.props.updateSuccess({}) + this.setState({ openToast: false }) + } + + onSubmit = e => { + e.preventDefault() + // Remove error from store + this.props.updateSuccess({}) + this.props.updateError({}) + + const { email } = this.state + if (!this.props.success.message) { + this.props.forgotPassword({ email: email }) + } else { + this.props.authVerificationToken( + { + verification_token: this.state.verificationToken + }, + this.props.history + ) + } + } + + render() { + const { errors, openToast } = this.state + var formInput, buttonName + if (!this.props.success.message) { + formInput = ( + + Email Address + + + {errors.email} + + + ) + buttonName = 'Send verification code' + } else { + formInput = ( + + + Verification token + + + + ) + buttonName = 'Verify token' + } + return ( +
+ + + {errors.fetch ? ( + + ) : null} + + + + + Forgot Password + + +
+ + {typeof this.props.errors === 'object' + ? this.props.errors.message + : null} + + + {typeof this.props.success === 'object' + ? this.props.success.message + : null} + + {formInput} + +
+
+
+ ) + } +} + +ForgotPassword.propTypes = { + forgotPassword: PropTypes.func.isRequired, + authVerificationToken: PropTypes.func.isRequired, + auth: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + success: PropTypes.object.isRequired, + history: PropTypes.object, + classes: PropTypes.object, + updateError: PropTypes.func.isRequired, + updateSuccess: PropTypes.func.isRequired +} + +const mapStateToProps = state => ({ + auth: state.auth, + errors: state.errors, + success: state.success +}) + +export default compose( + withStyles(styles, { + name: 'ForgotPassword' + }), + connect(mapStateToProps, { + forgotPassword, + authVerificationToken, + updateError, + updateSuccess + }) +)(ForgotPassword) diff --git a/fact-bounty-client/src/pages/ForgotPassword/ForgotPassword.style.js b/fact-bounty-client/src/pages/ForgotPassword/ForgotPassword.style.js new file mode 100644 index 00000000..4af949a6 --- /dev/null +++ b/fact-bounty-client/src/pages/ForgotPassword/ForgotPassword.style.js @@ -0,0 +1,32 @@ +export default theme => ({ + main: { + width: 'auto', + display: 'block', // Fix IE 11 issue. + marginLeft: theme.spacing.unit * 3, + marginRight: theme.spacing.unit * 3, + [theme.breakpoints.up(400 + theme.spacing.unit * 3 * 2)]: { + width: 400, + marginLeft: 'auto', + marginRight: 'auto' + } + }, + paper: { + marginTop: theme.spacing.unit * 12, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: `${theme.spacing.unit * 2}px ${theme.spacing.unit * 3}px ${theme + .spacing.unit * 3}px` + }, + avatar: { + margin: theme.spacing.unit, + backgroundColor: theme.palette.secondary.main + }, + form: { + width: '100%', // Fix IE 11 issue. + marginTop: theme.spacing.unit + }, + submit: { + marginTop: theme.spacing.unit * 3 + } +}) diff --git a/fact-bounty-client/src/pages/ForgotPassword/index.js b/fact-bounty-client/src/pages/ForgotPassword/index.js new file mode 100644 index 00000000..7a92c0ce --- /dev/null +++ b/fact-bounty-client/src/pages/ForgotPassword/index.js @@ -0,0 +1,2 @@ +import ForgotPassword from './ForgotPassword' +export default ForgotPassword diff --git a/fact-bounty-client/src/pages/Login/Login.jsx b/fact-bounty-client/src/pages/Login/Login.jsx index b067d9e6..1efed1b4 100644 --- a/fact-bounty-client/src/pages/Login/Login.jsx +++ b/fact-bounty-client/src/pages/Login/Login.jsx @@ -228,7 +228,13 @@ class Login extends Component { > Login - +

+ {' '} +
+ + Forgot password ? + +

{ + let { id, value } = e.target + this.setState({ [id]: value }, () => { + this.validateField(id, value) + }) + } + + handleClickShowPassword = () => { + this.setState(state => ({ showPassword: !state.showPassword })) + } + + handleClickShowPassword2 = () => { + this.setState(state => ({ showPassword2: !state.showPassword2 })) + } + + validateField = (fieldName, value) => { + let { passwordValid, password2Valid, errors } = this.state + + switch (fieldName) { + case 'password': + passwordValid = value.length >= 8 + errors.password = passwordValid ? '' : 'Too short!' + password2Valid = value === this.state.password2 + if (password2Valid && passwordValid) { + errors.password2 = null + } + break + case 'password2': + password2Valid = value === this.state.password + errors.password2 = password2Valid ? '' : "Password don't match" + break + default: + break + } + this.setState( + { + errors, + passwordValid, + password2Valid + }, + this.validateForm + ) + } + + validateForm = () => { + this.setState({ + formValid: this.state.passwordValid && this.state.password2Valid + }) + } + closeToast = () => { + // Remove error from store + this.props.updateError({}) + this.setState({ openToast: false }) + } + + onSubmit = e => { + e.preventDefault() + // Remove error from store + const { password, password2, verificationToken } = this.state + + if (password === password2) { + const data = { + password, + password2, + verificationToken + } + this.props.resetPassword(data, this.props.history) + } else { + const passwordError = 'Password dont match' + const errors = { + password2: password !== password2 ? passwordError : '' + } + this.props.updateError(errors) + } + } + + render() { + const { errors, openToast } = this.state + return ( +
+ + + {errors.fetch ? ( + + ) : null} + + + + + Reset Password + + +
+ + {typeof this.props.errors === 'object' + ? this.props.errors.message + : null} + + + + New Password + + + {this.state.showPassword ? ( + + ) : ( + + )} + + + } + /> + + {errors.password} + + + + + Confirm New Password + + + {this.state.showPassword2 ? ( + + ) : ( + + )} + + + } + /> + + {errors.password2} + + + + +
+
+
+ ) + } +} + +Register.propTypes = { + resetPassword: PropTypes.func.isRequired, + auth: PropTypes.object.isRequired, + errors: PropTypes.object.isRequired, + history: PropTypes.object, + classes: PropTypes.object, + updateError: PropTypes.func.isRequired, + match: PropTypes.object.isRequired +} + +const mapStateToProps = state => ({ + auth: state.auth, + errors: state.errors +}) + +export default compose( + withStyles(styles, { + name: 'Register' + }), + connect(mapStateToProps, { resetPassword, updateError }) +)(Register) diff --git a/fact-bounty-client/src/pages/ResetPassword/ResetPassword.style.js b/fact-bounty-client/src/pages/ResetPassword/ResetPassword.style.js new file mode 100644 index 00000000..4af949a6 --- /dev/null +++ b/fact-bounty-client/src/pages/ResetPassword/ResetPassword.style.js @@ -0,0 +1,32 @@ +export default theme => ({ + main: { + width: 'auto', + display: 'block', // Fix IE 11 issue. + marginLeft: theme.spacing.unit * 3, + marginRight: theme.spacing.unit * 3, + [theme.breakpoints.up(400 + theme.spacing.unit * 3 * 2)]: { + width: 400, + marginLeft: 'auto', + marginRight: 'auto' + } + }, + paper: { + marginTop: theme.spacing.unit * 12, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: `${theme.spacing.unit * 2}px ${theme.spacing.unit * 3}px ${theme + .spacing.unit * 3}px` + }, + avatar: { + margin: theme.spacing.unit, + backgroundColor: theme.palette.secondary.main + }, + form: { + width: '100%', // Fix IE 11 issue. + marginTop: theme.spacing.unit + }, + submit: { + marginTop: theme.spacing.unit * 3 + } +}) diff --git a/fact-bounty-client/src/pages/ResetPassword/index.js b/fact-bounty-client/src/pages/ResetPassword/index.js new file mode 100644 index 00000000..6b2df239 --- /dev/null +++ b/fact-bounty-client/src/pages/ResetPassword/index.js @@ -0,0 +1,2 @@ +import ResetPassword from './ResetPassword' +export default ResetPassword diff --git a/fact-bounty-client/src/redux/actions/actionTypes.js b/fact-bounty-client/src/redux/actions/actionTypes.js index 5364673b..ea705304 100644 --- a/fact-bounty-client/src/redux/actions/actionTypes.js +++ b/fact-bounty-client/src/redux/actions/actionTypes.js @@ -2,6 +2,8 @@ export const GET_ERRORS = 'GET_ERRORS' export const UPDATE_ERRORS = 'UPDATE_ERRORS' export const USER_LOADING = 'USER_LOADING' export const SET_CURRENT_USER = 'SET_CURRENT_USER' +export const GET_SUCCESS = 'GET_SUCCESS' +export const UPDATE_SUCCESS = 'UPDATE_SUCCESS' export const LOADING_POSTS = 'LOADING_POSTS' export const FETCH_POSTS = 'FETCH_POSTS' diff --git a/fact-bounty-client/src/redux/actions/authActions.js b/fact-bounty-client/src/redux/actions/authActions.js index f6adc61c..462b61cb 100644 --- a/fact-bounty-client/src/redux/actions/authActions.js +++ b/fact-bounty-client/src/redux/actions/authActions.js @@ -1,6 +1,11 @@ import jwt_decode from 'jwt-decode' import { setAuthToken, saveAllTokens } from '../../helpers/AuthTokenHelper' -import { SET_CURRENT_USER, USER_LOADING, GET_ERRORS } from './actionTypes' +import { + SET_CURRENT_USER, + USER_LOADING, + GET_ERRORS, + GET_SUCCESS +} from './actionTypes' import AuthService from '../../services/AuthService' // Set logged in user @@ -134,3 +139,91 @@ export const logoutUser = () => dispatch => { console.error(err) }) } + +export const forgotPassword = userData => dispatch => { + AuthService.forgotPassword(userData) + .then(res => { + if (res.status === 202) { + dispatch({ + type: GET_ERRORS, + payload: { message: res.data.message } + }) + } + if (res.status === 200) { + dispatch({ + type: GET_SUCCESS, + payload: { message: res.data.message } + }) + } + }) + .catch(err => { + if (err && err.response) { + let payload = err.response.data + if (typeof payload === 'string') { + payload = { fetch: err.response.data } + } + dispatch({ + type: GET_ERRORS, + payload + }) + } + }) +} + +export const authVerificationToken = (userData, history) => dispatch => { + AuthService.authVerificationToken(userData) + .then(res => { + if (res.status === 202) { + dispatch({ + type: GET_ERRORS, + payload: { message: res.data.message } + }) + } + if (res.status === 200) { + history.push('/resetpassword/' + userData.verification_token) + } + }) + .catch(err => { + if (err && err.response) { + let payload = err.response.data + if (typeof payload === 'string') { + payload = { fetch: err.response.data } + } + dispatch({ + type: GET_ERRORS, + payload + }) + } + }) +} + +export const resetPassword = (userData, history) => dispatch => { + AuthService.resetPassword(userData) + .then(res => { + if (res.status === 202) { + dispatch({ + type: GET_ERRORS, + payload: { message: res.data.message } + }) + } + if (res.status === 200) { + dispatch({ + type: GET_SUCCESS, + payload: { message: res.data.message } + }) + history.push('/login') + } + }) + .catch(err => { + if (err && err.response) { + let payload = err.response.data + if (typeof payload === 'string') { + payload = { fetch: err.response.data } + } + dispatch({ + type: GET_ERRORS, + payload + }) + } + }) +} diff --git a/fact-bounty-client/src/redux/actions/successActions.js b/fact-bounty-client/src/redux/actions/successActions.js new file mode 100644 index 00000000..8f40ed74 --- /dev/null +++ b/fact-bounty-client/src/redux/actions/successActions.js @@ -0,0 +1,4 @@ +import { UPDATE_SUCCESS } from './actionTypes' + +export const updateSuccess = payload => dispatch => + dispatch({ type: UPDATE_SUCCESS, payload }) diff --git a/fact-bounty-client/src/redux/reducers/index.js b/fact-bounty-client/src/redux/reducers/index.js index ef358a13..ea3e30d4 100644 --- a/fact-bounty-client/src/redux/reducers/index.js +++ b/fact-bounty-client/src/redux/reducers/index.js @@ -4,10 +4,12 @@ import errorReducer from './errorReducers' import postReducer from './postReducers' import contactUsReducer from './contactUsReducers' import twitterReducer from './twitterReducers' +import successReducers from './successReducers' export default combineReducers({ auth: authReducer, errors: errorReducer, + success: successReducers, posts: postReducer, contactUs: contactUsReducer, tweets: twitterReducer diff --git a/fact-bounty-client/src/redux/reducers/successReducers.js b/fact-bounty-client/src/redux/reducers/successReducers.js new file mode 100644 index 00000000..b3fda27e --- /dev/null +++ b/fact-bounty-client/src/redux/reducers/successReducers.js @@ -0,0 +1,16 @@ +import { GET_SUCCESS, UPDATE_SUCCESS } from '../actions/actionTypes' + +const initialState = {} + +export default function(state = initialState, action) { + switch (action.type) { + case GET_SUCCESS: + return action.payload + + case UPDATE_SUCCESS: + return action.payload + + default: + return state + } +} diff --git a/fact-bounty-client/src/services/AuthService.js b/fact-bounty-client/src/services/AuthService.js index 460b2912..3ecd4f4d 100644 --- a/fact-bounty-client/src/services/AuthService.js +++ b/fact-bounty-client/src/services/AuthService.js @@ -17,7 +17,30 @@ const loginUser = userData => { const registerUser = userData => { return ApiBuilder.API.post(`/api/users/register`, userData) } - +/** + * + * POST : forgotPassword + * + */ +const forgotPassword = userData => { + return ApiBuilder.API.post(`/api/users/forgot_password`, userData) +} +/** + * + * POST : authVerificationToken + * + */ +const authVerificationToken = userData => { + return ApiBuilder.API.post(`/api/users/auth_verification_token`, userData) +} +/** + * + * POST : resetPassword + * + */ +const resetPassword = userData => { + return ApiBuilder.API.post(`/api/users/reset_password`, userData) +} /** * * POST : OauthUser @@ -56,6 +79,9 @@ const revokeRefreshToken = () => { export default { loginUser, registerUser, + forgotPassword, + authVerificationToken, + resetPassword, OauthUser, tokenRefresh, revokeAccessToken, diff --git a/fact-bounty-flask/api/app.py b/fact-bounty-flask/api/app.py index f8c360d2..ffd4f2d6 100644 --- a/fact-bounty-flask/api/app.py +++ b/fact-bounty-flask/api/app.py @@ -15,8 +15,8 @@ def create_app(config_name): + # create and configure the app try: - # create and configure the app app = Flask( __name__, static_folder="../build/static", @@ -82,6 +82,7 @@ def register_commands(app): app.cli.add_command(commands.clean) app.cli.add_command(commands.urls) app.cli.add_command(commands.deploy) + app.cli.add_command(commands.create_admin) def register_shellcontext(app): diff --git a/fact-bounty-flask/api/commands.py b/fact-bounty-flask/api/commands.py index e99267ad..1f9d12e5 100644 --- a/fact-bounty-flask/api/commands.py +++ b/fact-bounty-flask/api/commands.py @@ -6,16 +6,39 @@ import coverage import sys import click -from flask import current_app +from flask import current_app, make_response, jsonify from flask.cli import with_appcontext from flask_migrate import upgrade from werkzeug.exceptions import MethodNotAllowed, NotFound +from .user import model +import getpass +import re COV = None if os.environ.get("FLASK_COVERAGE"): COV = coverage.coverage(branch=True, include="./*") COV.start() +""" +regex to check valid email is entered +""" +regex = r"^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$" + +""" +function for +for validating an Email +""" + + +def check(email): + """ + pass the regualar expression + and the string in search() method + """ + if re.search(regex, email): + return True + else: + return False @click.command() @@ -168,3 +191,32 @@ def deploy(): migrate database to latest revision """ upgrade() + + +@click.command(name="create_admin") +@with_appcontext +def create_admin(): + """ + create an admin user + """ + admin_username = input("Enter the admin username: ") + admin_password = getpass.getpass(prompt="Enter the admin password: ") + admin_role = "admin" + valid = False + while not valid: + admin_email = input("Enter the admin email: ") + valid = check(admin_email) + if valid: + break + else: + pass + try: + user = model.User( + name=admin_username, + email=admin_email, + password=admin_password, + role=admin_role, + ) + user.save() + except Exception as e: + return {"Error occured": str(e)} diff --git a/fact-bounty-flask/api/data-dev.sqlite b/fact-bounty-flask/api/data-dev.sqlite new file mode 100644 index 00000000..08de9227 Binary files /dev/null and b/fact-bounty-flask/api/data-dev.sqlite differ diff --git a/fact-bounty-flask/api/helpers.py b/fact-bounty-flask/api/helpers.py index 96c0d0d3..9778d726 100644 --- a/fact-bounty-flask/api/helpers.py +++ b/fact-bounty-flask/api/helpers.py @@ -11,8 +11,11 @@ def send_async_email(app, msg): def send_email(to, subject, body): app = current_app._get_current_object() - msg = Message(app.config['FACTBOUNTY_MAIL_SUBJECT_PREFIX'] + subject, - sender=app.config['FACTBOUNTY_MAIL_SENDER'], recipients=[to]) + msg = Message( + app.config["FACTBOUNTY_MAIL_SUBJECT_PREFIX"] + subject, + sender=app.config["FACTBOUNTY_MAIL_SENDER"], + recipients=[to], + ) msg.body = body thr = Thread(target=send_async_email, args=[app, msg]) thr.start() diff --git a/fact-bounty-flask/api/stories/controller.py b/fact-bounty-flask/api/stories/controller.py index 5855eb05..05bbd45f 100644 --- a/fact-bounty-flask/api/stories/controller.py +++ b/fact-bounty-flask/api/stories/controller.py @@ -4,41 +4,49 @@ from elasticsearch.helpers import scan from .model import Vote, Comment from flasgger import swag_from +from ..user import model class AllStories(MethodView): """ - Retrieve stories + Retrieve stories only for admin :return: JSON object with all stories and HTTP status code 200. """ + @jwt_required @swag_from("../../docs/stories/get_all.yml") def get(self): + user_id = get_jwt_identity() + user = model.User.find_by_user_id(user_id) - es_index = current_app.config["ES_INDEX"] - es = current_app.elasticsearch + if user.role == "admin": + es_index = current_app.config["ES_INDEX"] + es = current_app.elasticsearch - doc = { - "sort": [{"date": {"order": "desc"}}], - "query": {"match_all": {}}, - } - stories = {} - try: - for story in scan(es, doc, index=es_index, doc_type="story"): - PID = story["_id"] - source = story["_source"] - stories[PID] = source - except Exception as e: - # An error occured, so return a string message containing error - response = {"message": str(e)} - return make_response(jsonify(response)), 500 + doc = { + "sort": [{"date": {"order": "desc"}}], + "query": {"match_all": {}}, + } + stories = {} + try: + for story in scan(es, doc, index=es_index, doc_type="story"): + PID = story["_id"] + source = story["_source"] + stories[PID] = source + except Exception as e: + # An error occured, so return a string message containing error + response = {"message": str(e)} + return make_response(jsonify(response)), 500 - response = { - "message": "Stories successfully fetched", - "stories": stories, - } - return make_response(jsonify(response)), 200 + response = { + "message": "Stories successfully fetched", + "stories": stories, + } + return make_response(jsonify(response)), 200 + else: + response = {"message": "only for admins"} + return make_response(jsonify(response)), 400 class GetRange(MethodView): diff --git a/fact-bounty-flask/api/user/controller.py b/fact-bounty-flask/api/user/controller.py index 0f1485e6..2bd75087 100644 --- a/fact-bounty-flask/api/user/controller.py +++ b/fact-bounty-flask/api/user/controller.py @@ -1,5 +1,5 @@ from flask.views import MethodView -from flask import make_response, request, jsonify +from flask import make_response, request, jsonify, current_app from flask_jwt_extended import ( create_access_token, create_refresh_token, @@ -10,11 +10,15 @@ ) from .model import User, RevokedToken from flasgger import swag_from +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText class Register(MethodView): """This class registers a new user.""" + @swag_from("../../docs/users/register.yml") def post(self): """Handle POST request for this view. Url --> /api/users/register""" # getting JSON data from request @@ -50,7 +54,8 @@ def post(self): try: user = User(email=email, password=password, name=name) user.save() - except Exception: + except Exception as err: + print("Error occured: ", err) response = {"message": "Something went wrong!!"} return make_response(jsonify(response)), 500 @@ -72,7 +77,8 @@ def post(self): try: email = data["email"] password = data["password"] - except Exception: + except Exception as err: + print("Error occured: ", err) response = {"message": "Please provide all the required fields."} return make_response(jsonify(response)), 404 @@ -109,10 +115,134 @@ def post(self): return make_response(jsonify(response)), 200 +class ForgotPassword(MethodView): + """This class sends a reset password mail.""" + + def post(self): + """Handle POST request for this view. Url --> /api/users/forgot_password""" + # getting JSON data from request + post_data = request.get_json(silent=True) + + try: + email = post_data["email"] + except Exception: + response = {"message": "Please provide email."} + return make_response(jsonify(response)), 404 + + # Querying the database with requested email + user = User.find_by_email(email) + if user: + verification_token = user.verification_token + # Body of the email + mail_content = ( + """Hi, + + You are receiving this because you have requested to reset password for your account. + + Here is you verification token: """ + + verification_token + ) + # The email addresses and password + sender_address = current_app.config["MAIL_USERNAME"] + sender_pass = current_app.config["MAIL_PASSWORD"] + receiver_address = user.email + # Setup the MIME + message = MIMEMultipart() + message["From"] = sender_address + message["To"] = receiver_address + message["Subject"] = "Password reset" + # The body and the attachments for the mail + message.attach(MIMEText(mail_content, "plain")) + # Create SMTP session for sending the mail + session = smtplib.SMTP( + current_app.config["MAIL_SERVER"], + current_app.config["MAIL_PORT"], + ) # use gmail with port + session.starttls() # enable security + session.login( + sender_address, sender_pass + ) # login with mail_id and password + text = message.as_string() + session.sendmail(sender_address, receiver_address, text) + session.quit() + print("Password reset email sent successfully") + + response = { + "message": "Verification token has been sent to you on your registered email" + } + + # return a response notifying the user that a password reset mail has + # been sent to registered email + return make_response(jsonify(response)), 200 + else: + response = {"message": "Email not registered"} + return make_response(jsonify(response)), 202 + + +class AuthVerificationToken(MethodView): + """This class verifies token which is being used to reset the password""" + + def post(self): + data = request.get_json(silent=True) + try: + verification_token = data["verification_token"] + except Exception: + response = {"message": "Please provide verification token."} + return make_response(jsonify(response)), 404 + + # since token is unique, find user by its verification token + # if it exist it means input token is correct + user = User.find_by_verification_token(verification_token) + + if user: + response = {"message": "Auth success"} + return make_response(jsonify(response)), 200 + else: + response = {"message": "Incorrect, please check again"} + return make_response(jsonify(response)), 202 + + +class ResetPassword(MethodView): + """This class is used to change the password""" + + def post(self): + + data = request.get_json(silent=True) + try: + verification_token = data["verificationToken"] + new_password = data["password"] + new_password_confirm = data["password2"] + except Exception: + response = {"message": "Please provide all required fields."} + return make_response(jsonify(response)), 202 + + # find user by token + user = User.find_by_verification_token(verification_token) + + # if token is correct + if user: + # new password and confirm has to be equal + if new_password != new_password_confirm: + response = { + "message": "New password and confirm password does not match" + } + return make_response(jsonify(response)), 202 + # overwrite old password with new password + else: + user.password = user.generate_password_hash(new_password) + user.save() + response = {"message": "Password successfully changed"} + return make_response(jsonify(response)), 200 + else: + response = {"message": "Unauthorized"} + return make_response(jsonify(response)), 401 + + class Auth(MethodView): """This class-based view handles user register and access token generation \ via 3rd sources like facebook, google""" + @swag_from("../../docs/users/oauth.yml") def post(self): # Querying the database with requested email data = request.get_json(silent=True) @@ -172,6 +302,7 @@ def post(self): class LogoutAccess(MethodView): @jwt_required + @swag_from("../../docs/users/logout_access.yml") def post(self): jti = get_raw_jwt()["jti"] try: @@ -188,6 +319,7 @@ def post(self): class LogoutRefresh(MethodView): @jwt_refresh_token_required + @swag_from("../../docs/users/logout_refresh.yml") def post(self): jti = get_raw_jwt()["jti"] try: @@ -204,6 +336,7 @@ def post(self): class TokenRefresh(MethodView): @jwt_refresh_token_required + @swag_from("../../docs/users/token_refresh.yml") def post(self): current_user = get_jwt_identity() access_token = create_access_token(identity=current_user, fresh=False) @@ -215,11 +348,53 @@ def post(self): return make_response(jsonify(response)), 200 +class Profile(MethodView): + """This class-based view handles retrieving and updating the current \ + user's information""" + + @staticmethod + @jwt_required + def get(self): + current_user = get_jwt_identity() + + response = jsonify(current_user) + return make_response(response), 200 + + @jwt_required + def post(self): + try: + post_data = request.get_json(silent=True) + name = post_data["name"] + email = post_data["email"] + except Exception: + response = {"message": "Some user details are missing."} + return make_response(jsonify(response)), 404 + + # Querying the database to get the user to update + user = User.find_by_email(email) + + if user: + user = user.update(email=email, name=name) + user.save() + response = {"message": "User has been updated."} + return make_response(jsonify(response)), 204 + + else: + response = {"message": "User not found. Please check your request."} + return make_response(jsonify(response)), 404 + + userController = { "register": Register.as_view("register"), "login": Login.as_view("login"), + "forgot_password": ForgotPassword.as_view("forgot_password"), + "auth_verification_token": AuthVerificationToken.as_view( + "auth_verification_token" + ), + "reset_password": ResetPassword.as_view("reset_password"), "auth": Auth.as_view("auth"), "logout_access": LogoutAccess.as_view("logout_access"), "logout_refresh": LogoutRefresh.as_view("logout_refresh"), "token_refresh": TokenRefresh.as_view("token_refresh"), + "profile": Profile.as_view("profile") } diff --git a/fact-bounty-flask/api/user/model.py b/fact-bounty-flask/api/user/model.py old mode 100644 new mode 100755 index 8fb28dc7..8361befb --- a/fact-bounty-flask/api/user/model.py +++ b/fact-bounty-flask/api/user/model.py @@ -1,6 +1,7 @@ from datetime import datetime from flask import current_app from flask_bcrypt import Bcrypt +from uuid import uuid4 # from itsdangerous import ( # TimedJSONWebSignatureSerializer as Serializer, @@ -18,20 +19,24 @@ class User(Model): id = Column(db.Integer, primary_key=True) name = Column(db.String(80), nullable=False) password = Column(db.String(128)) + verification_token = Column(db.String(128), nullable=False, unique=True) email = Column(db.String(100), nullable=False, unique=True) date = Column(db.DateTime, default=datetime.now()) votes = db.relationship("Vote", backref=db.backref("user")) type = Column(db.String(50), default="remote") + role = Column(db.String(10), default="user") - def __init__(self, name, email, password, _type="remote"): + def __init__(self, name, email, password, role="user", _type="remote"): """ Initializes the user instance """ self.name = name self.email = email + self.verification_token = User.generate_token() if password: - self.password = Bcrypt().generate_password_hash(password).decode() + self.password = User.generate_password_hash(password) self.type = _type + self.role = role def __repr__(self): """ @@ -45,13 +50,42 @@ def to_json(self): :return: user JSON object """ - user_json = {"name": self.name, "email": self.email, "date": self.date} + user_json = { + "name": self.name, + "email": self.email, + "date": self.date, + "role": self.role, + } return user_json @classmethod def find_by_email(cls, email): return cls.query.filter_by(email=email).first() + @classmethod + def find_by_user_id(cls, id): + return cls.query.filter_by(id=id).first() + + @classmethod + def find_by_verification_token(cls, verification_token): + return cls.query.filter_by( + verification_token=verification_token + ).first() + + @staticmethod + def generate_token(): + """ + Returns a random token + """ + return uuid4().hex + + @staticmethod + def generate_password_hash(password): + """ + Returns hash of password + """ + return Bcrypt().generate_password_hash(password).decode() + def verify_password(self, password): """ Verify the password @@ -71,7 +105,7 @@ def save(self): class RevokedToken(Model): """ - This model holds information about revoked tokens, users who have looged out + This model holds information about revoked tokens, users who have logged out """ __tablename__ = "revoked_tokens" diff --git a/fact-bounty-flask/api/user/views.py b/fact-bounty-flask/api/user/views.py index ea9213da..20ddefc3 100644 --- a/fact-bounty-flask/api/user/views.py +++ b/fact-bounty-flask/api/user/views.py @@ -10,6 +10,23 @@ userprint.add_url_rule( "/register", view_func=userController["register"], methods=["POST"] ) +userprint.add_url_rule( + "/forgot_password", + view_func=userController["forgot_password"], + methods=["POST"], +) + +userprint.add_url_rule( + "/auth_verification_token", + view_func=userController["auth_verification_token"], + methods=["POST"], +) + +userprint.add_url_rule( + "/reset_password", + view_func=userController["reset_password"], + methods=["POST"], +) userprint.add_url_rule( "/oauth", view_func=userController["auth"], methods=["POST"] @@ -32,3 +49,9 @@ view_func=userController["token_refresh"], methods=["POST"], ) + +userprint.add_url_rule( + "/profile", + view_func=userController["profile"], + methods=["GET", "POST"] +) diff --git a/fact-bounty-flask/docs/users/login.yml b/fact-bounty-flask/docs/users/login.yml index ba76f27b..f3faca2b 100644 --- a/fact-bounty-flask/docs/users/login.yml +++ b/fact-bounty-flask/docs/users/login.yml @@ -1,7 +1,7 @@ User login --- tags: -- Auth +- User consumes: - "application/json" parameters: diff --git a/fact-bounty-flask/docs/users/logout_access.yml b/fact-bounty-flask/docs/users/logout_access.yml new file mode 100644 index 00000000..47d991c3 --- /dev/null +++ b/fact-bounty-flask/docs/users/logout_access.yml @@ -0,0 +1,17 @@ +User logout +--- +tags: +- User +produces: + - application/json +parameters: +- in: header + name: Authorization + description: an authorization header + required: true + type: string +responses: + 200: + description: Access token has been revoked. + 500: + description: Something went wrong! diff --git a/fact-bounty-flask/docs/users/logout_refresh.yml b/fact-bounty-flask/docs/users/logout_refresh.yml new file mode 100644 index 00000000..e5c4e3b5 --- /dev/null +++ b/fact-bounty-flask/docs/users/logout_refresh.yml @@ -0,0 +1,17 @@ +User logout +--- +tags: +- User +produces: + - application/json +parameters: +- in: header + name: Authorization + description: an authorization header + required: true + type: string +responses: + 200: + description: Refresh token has been revoked. + 500: + description: Something went wrong! diff --git a/fact-bounty-flask/docs/users/oauth.yml b/fact-bounty-flask/docs/users/oauth.yml new file mode 100644 index 00000000..b88f2bee --- /dev/null +++ b/fact-bounty-flask/docs/users/oauth.yml @@ -0,0 +1,69 @@ +User registration via OAuth like facebook, google, etc. +--- +tags: + - User +consumes: + - "application/json" +produces: + - "application/json" +parameters: +- in: body + name: body + schema: + type: object + properties: + name: + type: string + example: xyz + email: + type: string + example: xyz@gmail.com + type: + type: string + example: admin +responses: + 201: + description: (for new user) JSON object containing success message, access token and refresh token. + schema: + type: object + properties: + message: + type: string + example: You logged in successfully. + access_token: + type: string + example: hash-generated-by-jwt + refresh_token: + type: string + example: hash-generated-by-jwt + 202: + description: (for existing user) JSON object containing success message, access token and refresh token. + schema: + type: object + properties: + message: + type: string + example: You logged in successfully. + access_token: + type: string + example: hash-generated-by-jwt + refresh_token: + type: string + example: hash-generated-by-jwt + + 404: + description: Fields are missing + schema: + type: object + properties: + message: + type: string + example: Please provide all the required fields. + 500: + description: Something went wrong + schema: + type: object + properties: + message: + type: string + example: Something went wrong diff --git a/fact-bounty-flask/docs/users/register.yml b/fact-bounty-flask/docs/users/register.yml new file mode 100644 index 00000000..6c042f36 --- /dev/null +++ b/fact-bounty-flask/docs/users/register.yml @@ -0,0 +1,67 @@ +User registration via form +--- +tags: + - User +consumes: + - "application/json" +produces: + - "application/json" +parameters: +- in: body + name: body + schema: + type: object + properties: + name: + type: string + example: xyz + email: + type: string + example: xyz@gmail.com + password: + type: string + example: password123 + password2: + type: string + example: password123 +responses: + 201: + description: JSON object containing success message + schema: + type: object + properties: + message: + type: string + example: You registered successfully. Please log in. + 202: + description: JSON object containing success message + schema: + type: object + properties: + message: + type: string + example: User already exists. Please login. + 401: + description: Passwords do not match + schema: + type: object + properties: + message: + type: string + example: Both passwords does not match + 404: + description: Fields are missing + schema: + type: object + properties: + message: + type: string + example: Please provide all the required fields. + 500: + description: Something went wrong + schema: + type: object + properties: + message: + type: string + example: Something went wrong diff --git a/fact-bounty-flask/docs/users/token_refresh.yml b/fact-bounty-flask/docs/users/token_refresh.yml new file mode 100644 index 00000000..1176c67b --- /dev/null +++ b/fact-bounty-flask/docs/users/token_refresh.yml @@ -0,0 +1,15 @@ +Token refresh +--- +tags: +- User +produces: + - application/json +parameters: +- in: header + name: Authorization + description: an authorization header + required: true + type: string +responses: + 200: + description: Token refreshed successfully. diff --git a/fact-bounty-flask/migrations/versions/02db7a3aaa95_.py b/fact-bounty-flask/migrations/versions/02db7a3aaa95_.py new file mode 100644 index 00000000..a10bf7ff --- /dev/null +++ b/fact-bounty-flask/migrations/versions/02db7a3aaa95_.py @@ -0,0 +1,66 @@ +"""empty message + +Revision ID: 02db7a3aaa95 +Revises: 78d18dc2789d +Create Date: 2020-01-25 11:13:21.447023 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "02db7a3aaa95" +down_revision = "78d18dc2789d" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "revoked_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("jti", sa.String(length=120), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=False), + sa.Column("password", sa.String(length=128), nullable=True), + sa.Column("email", sa.String(length=100), nullable=False), + sa.Column("date", sa.DateTime(), nullable=True), + sa.Column("type", sa.String(length=50), nullable=True), + sa.Column("role", sa.String(length=60), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + op.create_table( + "comment", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("story_id", sa.String(length=128), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("content", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"],), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "vote", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("story_id", sa.String(length=128), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("value", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"],), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("vote") + op.drop_table("comment") + op.drop_table("user") + op.drop_table("revoked_tokens") + # ### end Alembic commands ### diff --git a/fact-bounty-flask/migrations/versions/78d18dc2789d_.py b/fact-bounty-flask/migrations/versions/78d18dc2789d_.py new file mode 100644 index 00000000..1d7f5203 --- /dev/null +++ b/fact-bounty-flask/migrations/versions/78d18dc2789d_.py @@ -0,0 +1,58 @@ +"""empty message + +Revision ID: 78d18dc2789d +Revises: d2fc5d7ff47e +Create Date: 2020-01-25 10:50:19.720464 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "78d18dc2789d" +down_revision = "d2fc5d7ff47e" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "revoked_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("jti", sa.String(length=120), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "comment", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("story_id", sa.String(length=128), nullable=True), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("content", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"],), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column( + "user", sa.Column("role", sa.String(length=60), nullable=True) + ) + op.alter_column( + "user", "password", existing_type=sa.VARCHAR(length=128), nullable=True + ) + op.create_unique_constraint(None, "user", ["email"]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "user", type_="unique") + op.alter_column( + "user", + "password", + existing_type=sa.VARCHAR(length=128), + nullable=False, + ) + op.drop_column("user", "role") + op.drop_table("comment") + op.drop_table("revoked_tokens") + # ### end Alembic commands ### diff --git a/fact-bounty-flask/requirements.txt b/fact-bounty-flask/requirements.txt index 08ad1896..734b3fff 100644 --- a/fact-bounty-flask/requirements.txt +++ b/fact-bounty-flask/requirements.txt @@ -43,7 +43,7 @@ SQLAlchemy==1.2.18 tornado==6.0 tweepy==3.8.0 typed-ast==1.2.0 -urllib3==1.24.1 +urllib3==1.23.0 Werkzeug==0.14.1 wrapt==1.11.1 WTForms==2.2.1