diff --git a/api/app/api/tokens.py b/api/app/api/tokens.py index 3fbc3a7..fa2e23d 100644 --- a/api/app/api/tokens.py +++ b/api/app/api/tokens.py @@ -1,4 +1,4 @@ -from flask import jsonify, g, make_response, request +from flask import jsonify, g, make_response, request, redirect from app import db from app.models import User from app.api import bp @@ -17,6 +17,16 @@ def get_token(): # response.headers.add('Set-Cookie','refresh_token='+tokens_dict['refresh_token']+'; Expires='+timezone('GMT').localize(tokens_dict['refresh_token_expiration']).strftime('%a, %d-%b-%Y %H:%M:%S %Z')+'; SameSite=Lax; Path=/') db.session.commit() return response + +@bp.route('/verify-email/', methods=['GET']) +def verify_user_email(token): + user = User.verify_email(token) + g.current_user = user + if not user: + return error_response(404) + user.verified = True + db.session.commit() + return redirect(app.config['FRONTEND_URL'], code=303) @bp.route('/refresh', methods=['GET']) def refresh_token(): diff --git a/api/app/api/users.py b/api/app/api/users.py index 0ed947a..27581b3 100644 --- a/api/app/api/users.py +++ b/api/app/api/users.py @@ -4,6 +4,7 @@ from app.api import bp from app.api.auth import token_auth from app.api.errors import error_response, bad_request +from app.email import send_verification_email from datetime import datetime, timedelta @bp.route('/users/current', methods=['GET']) @@ -37,9 +38,10 @@ def create_user(): user.from_dict(data, new_user=True) db.session.add(user) db.session.commit() - token = user.get_token() + send_verification_email(user) + # token = user.get_token() user_dict = user.to_dict() - user_dict['token'] = token + # user_dict['token'] = token response = jsonify(user_dict) response.status_code = 201 response.headers['Location'] = url_for('api.get_user', id=user.id) @@ -77,4 +79,4 @@ def delete_user(id): user = User.query.get_or_404(id) db.session.delete(user) db.session.commit() - return '', 204 + return '', 204 \ No newline at end of file diff --git a/api/app/email.py b/api/app/email.py new file mode 100644 index 0000000..33ddba8 --- /dev/null +++ b/api/app/email.py @@ -0,0 +1,40 @@ +from threading import Thread +from flask import current_app, render_template +from flask_mail import Message +from app import mail + +def send_async_email(app, msg): + with app.app_context(): + mail.send(msg) + +def send_email(subject, sender, recipients, text_body, html_body, attachments=None, sync=False): + msg = Message(subject, sender=sender, recipients=recipients) + msg.body = text_body + msg.html = html_body + if attachments: + for attachment in attachments: + msg.attach(*attachment) + if sync: + mail.send(msg) + else: + Thread(target=send_async_email, args=(current_app._get_current_object(), msg)).start() + +def send_verification_email(user): + token = user.get_email_verification_token() + send_email('[Totlahtol] Verify Your Email', + sender=current_app.config['ADMINS'][0], + recipients=[user.email], + text_body=render_template('verify_email.txt', + user=user, token=token), + html_body=render_template('verify_email.html', + user=user, token=token)) + +def send_password_reset_email(user): + token = user.get_reset_password_token() + send_email('[Totlahtol] Reset Your Password', + sender=current_app.config['ADMINS'][0], + recipients=[user.email], + text_body=render_template('reset_password.txt', + user=user, token=token), + html_body=render_template('reset_password.html', + user=user, token=token)) \ No newline at end of file diff --git a/api/app/models.py b/api/app/models.py index 5525467..15b8699 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -1,11 +1,11 @@ -from flask import url_for +from flask import url_for, current_app from datetime import datetime, timedelta from time import time from app import db # from app.search import add_to_index, remove_from_index, query_index from werkzeug.security import generate_password_hash, check_password_hash from hashlib import md5 -# import jwt +import jwt import os import base64 import threading @@ -53,6 +53,7 @@ class User(PaginatedAPIMixin, db.Model): #Basic User Info username = db.Column(db.String(64), index=True, unique=True) email = db.Column(db.String(120), index=True, unique=True) + verified = db.Column(db.Boolean, default=False) password_hash = db.Column(db.String(128)) about_me = db.Column(db.String(140)) token = db.Column(db.String(32), index=True, unique=True) @@ -122,11 +123,37 @@ def refresh(refresh_token): print("Refresh Token Valid; Right this way, sir!") return user.get_token() + def get_email_verification_token(self, expires_in=600): + return jwt.encode({'verify_user_email': self.id, 'exp': time() + expires_in}, + current_app.config['SECRET_KEY'], algorithm='HS256') + + def get_reset_password_token(self, expires_in=600): + return jwt.encode({'reset_password': self.id, 'exp': time() + expires_in}, + current_app.config['SECRET_KEY'], algorithm='HS256') + + @staticmethod + def verify_email(token): + try: + id = jwt.decode(token, current_app.config['SECRET_KEY'], + algorithms=['HS256'])['verify_user_email'] + except: + return + return User.query.get(id) + + @staticmethod + def verify_reset_password_token(token): + try: + id = jwt.decode(token, current_app.config['SECRET_KEY'], + algorithms=['HS256'])['reset_password'] + except: + return + return User.query.get(id) def to_dict(self, include_email=False): data = { 'id': self.id, 'username': self.username, + 'verified': str(self.verified).lower(), # 'last_seen': self.last_seen.isoformat() + 'Z', 'about_me': self.about_me, # 'post_count': self.posts.count(), @@ -172,6 +199,8 @@ def get_rec(self): # else returns recs based on item2item_similarity pass + + class Lesson(PaginatedAPIMixin, db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(120), index=True, unique=True) diff --git a/api/app/templates/reset_password.html b/api/app/templates/reset_password.html new file mode 100644 index 0000000..f5911f2 --- /dev/null +++ b/api/app/templates/reset_password.html @@ -0,0 +1,12 @@ +

Dear {{ user.username }},

+

+ To reset your password + + click here + . +

+

Alternatively, you can paste the following link in your browser's address bar:

+

{{ url_for('reset_password', token=token, _external=True) }}

+

If you have not requested a password reset, simply ignore this message.

+

Sincerely,

+

The Totlahtol Team

\ No newline at end of file diff --git a/api/app/templates/reset_password.txt b/api/app/templates/reset_password.txt new file mode 100644 index 0000000..a95e243 --- /dev/null +++ b/api/app/templates/reset_password.txt @@ -0,0 +1,11 @@ +Dear {{ user.username }}, + +To reset your password click on the following link: + +{{ url_for('api.reset_password', token=token, _external=True) }} + +If you have not requested a password reset simply ignore this message. + +Sincerely, + +The Microblog Team \ No newline at end of file diff --git a/api/app/templates/verify_email.html b/api/app/templates/verify_email.html new file mode 100644 index 0000000..7bf3c70 --- /dev/null +++ b/api/app/templates/verify_email.html @@ -0,0 +1,12 @@ +

Dear {{ user.username }},

+

+ To verify your email + + click here + . +

+

Alternatively, you can paste the following link in your browser's address bar:

+

{{ url_for('api.verify_user_email', token=token, _external=True) }}

+

If you have not signed up for our app, simply ignore this message.

+

Sincerely,

+

The Totlahtol Team

\ No newline at end of file diff --git a/api/app/templates/verify_email.txt b/api/app/templates/verify_email.txt new file mode 100644 index 0000000..fc207a8 --- /dev/null +++ b/api/app/templates/verify_email.txt @@ -0,0 +1,11 @@ +Dear {{ user.username }}, + +To verify your email click on the following link: + +{{ url_for('api.verify_user_email', token=token, _external=True) }} + +If you have not signed up for our app, simply ignore this message. + +Sincerely, + +The Microblog Team \ No newline at end of file diff --git a/api/config.py b/api/config.py index 4d60f7a..74d35bc 100644 --- a/api/config.py +++ b/api/config.py @@ -22,4 +22,5 @@ class Config(object): ADMINS = ['aavillaverde11@gmail.com'] # POSTS_PER_PAGE = 3 # LANGUAGES = ['en', 'es'] - # MS_TRANSLATOR_KEY=os.environ.get('MS_TRANSLATOR_KEY') \ No newline at end of file + # MS_TRANSLATOR_KEY=os.environ.get('MS_TRANSLATOR_KEY') + FRONTEND_URL = os.environ.get('TOTLAHTOL_FRONTEND_URL') or '/' \ No newline at end of file diff --git a/api/migrations/versions/5171de0b0403_add_verified_field_to_user_model.py b/api/migrations/versions/5171de0b0403_add_verified_field_to_user_model.py new file mode 100644 index 0000000..6d32b95 --- /dev/null +++ b/api/migrations/versions/5171de0b0403_add_verified_field_to_user_model.py @@ -0,0 +1,32 @@ +"""add 'verified' field to User model + +Revision ID: 5171de0b0403 +Revises: 5d988c97b3b7 +Create Date: 2021-01-02 22:52:45.718436 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5171de0b0403' +down_revision = '5d988c97b3b7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('verified', sa.Boolean(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('verified') + + # ### end Alembic commands ### diff --git a/api/requirements.txt b/api/requirements.txt index 25c67c3..2629105 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -35,6 +35,7 @@ pytest-flask==1.1.0 python-dateutil==2.8.0 python-dotenv==0.10.2 python-editor==1.0.4 +PyJWT==2.0.0 pytz==2019.1 regex==2020.11.13 requests==2.22.0 diff --git a/api/totlahtol.py b/api/totlahtol.py index a372b95..4e35200 100644 --- a/api/totlahtol.py +++ b/api/totlahtol.py @@ -1,7 +1,8 @@ -from app import create_app, db +from app import create_app, db#,cli from app.models import User, Lesson, Tlahtolli, Review app = create_app() +# cli.register(app) @app.shell_context_processor def make_shell_context():