From bf681f4568fd1cdcc5cfc0d3258f06a4bdad4202 Mon Sep 17 00:00:00 2001 From: esensar Date: Wed, 10 Oct 2018 20:54:38 +0200 Subject: [PATCH] Add email confirmation feature and endpoints --- .env | 3 +++ app/accounts/api.py | 29 +++++++++++++++++++++++++--- app/accounts/email.py | 10 ++++++++++ app/accounts/emailtoken.py | 21 ++++++++++++++++++++ app/accounts/tasks.py | 10 ++++++++++ app/api/blueprint.py | 8 +++++++- app/api/resources/account.py | 33 ++++++++++++++++++++++++++++---- app/api/resources/token.py | 4 ++-- app/celery_builder.py | 4 ++-- app/core.py | 2 ++ app/templates/activate_mail.html | 4 ++++ config.py | 15 +++++++++++++++ requirements.txt | 4 ++++ 13 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 app/accounts/email.py create mode 100644 app/accounts/emailtoken.py create mode 100644 app/accounts/tasks.py create mode 100644 app/templates/activate_mail.html diff --git a/.env b/.env index a9e6407..0b46463 100644 --- a/.env +++ b/.env @@ -3,3 +3,6 @@ REDIS_URL=redis://localhost:6379 CELERY_TASK_SERIALIZER=json DEBUG=True MQTT_CLIENT=local +APP_MAIL_USERNAME=final.iot.backend.mailer@gmail.com +APP_MAIL_PASSWORD=FinalIoT1234 +FLASK_ENV=development diff --git a/app/accounts/api.py b/app/accounts/api.py index 4a2d9d8..95df5c4 100644 --- a/app/accounts/api.py +++ b/app/accounts/api.py @@ -1,5 +1,7 @@ +import datetime from app.core import bcrypt from .models import Account, Role +from .emailtoken import generate_confirmation_token, confirm_token def create_account(username, email, password): @@ -12,19 +14,36 @@ def create_account(username, email, password): :type username: string :type email: string :type password: string - :returns: True if account is successfully created - :rtype: Boolean + :returns: Email confirmation token if creation was successful + :rtype: string :raises: ValueError if account already exists """ if not Account.exists_with_any_of(username=username, email=email): pw_hash = bcrypt.generate_password_hash(password).decode('utf-8') account = Account(username, pw_hash, email) account.save() - return True + + emailtoken = generate_confirmation_token(account.email) + return emailtoken raise ValueError("Account with given parameters already exists") +def confirm_email_token(token): + try: + email = confirm_token(token) + except Exception: + return False + user = Account.query.filter_by(email=email).first_or_404() + if user.confirmed: + return True + else: + user.confirmed = True + user.confirmed_on = datetime.datetime.now() + user.save() + return True + + def update_account_role(account_id, role_id): """ Tries to update account role @@ -99,6 +118,10 @@ def create_token(username, password): if not bcrypt.check_password_hash(account.password, password): raise ValueError("Invalid credentials") + if not account.confirmed: + print('ACCOUNT NOT CONFIRMED?') + raise ValueError("Email not confirmed") + return account.create_auth_token() diff --git a/app/accounts/email.py b/app/accounts/email.py new file mode 100644 index 0000000..047965d --- /dev/null +++ b/app/accounts/email.py @@ -0,0 +1,10 @@ +from flask_mail import Message + + +def send_email(mailmanager, to, subject, template): + msg = Message( + subject, + recipients=[to], + html=template + ) + mailmanager.send(msg) diff --git a/app/accounts/emailtoken.py b/app/accounts/emailtoken.py new file mode 100644 index 0000000..226b74c --- /dev/null +++ b/app/accounts/emailtoken.py @@ -0,0 +1,21 @@ +from itsdangerous import URLSafeTimedSerializer + +from app.core import app + + +def generate_confirmation_token(email): + serializer = URLSafeTimedSerializer(app.config['SECRET_KEY']) + return serializer.dumps(email, salt=app.config['SECURITY_PASSWORD_SALT']) + + +def confirm_token(token, expiration=3600): + serializer = URLSafeTimedSerializer(app.config['SECRET_KEY']) + try: + email = serializer.loads( + token, + salt=app.config['SECURITY_PASSWORD_SALT'], + max_age=expiration + ) + except Exception: + return False + return email diff --git a/app/accounts/tasks.py b/app/accounts/tasks.py new file mode 100644 index 0000000..d5bd14d --- /dev/null +++ b/app/accounts/tasks.py @@ -0,0 +1,10 @@ +from app.celery_builder import task_builder +from app.accounts.email import send_email +from flask import current_app as app +from flask_mail import Mail + + +@task_builder.task() +def send_email_task(to, subject, template): + mailmanager = Mail(app) + send_email(mailmanager, to, subject, template) diff --git a/app/api/blueprint.py b/app/api/blueprint.py index 969abc5..6408d9d 100644 --- a/app/api/blueprint.py +++ b/app/api/blueprint.py @@ -12,7 +12,9 @@ def add_resources(): AccountListResource, AccountRoleResource, RoleResource, - RolesResource) + RolesResource, + AccountEmailTokenResource, + AccountEmailTokenResendResource) from .resources.token import TokenResource, ValidateTokenResource from .resources.device import (DeviceResource, DeviceRecordingResource, @@ -25,6 +27,10 @@ def add_resources(): api.add_resource(AccountResource, '/v1/accounts/') api.add_resource(AccountListResource, '/v1/accounts') api.add_resource(AccountRoleResource, '/v1/accounts//role') + api.add_resource(AccountEmailTokenResource, + '/v1/email/confirm/') + api.add_resource(AccountEmailTokenResendResource, + '/v1/email/resend') api.add_resource(RoleResource, '/v1/roles/') api.add_resource(RolesResource, '/v1/roles') api.add_resource(TokenResource, '/v1/token') diff --git a/app/api/resources/account.py b/app/api/resources/account.py index 6cf9b99..eef7e51 100644 --- a/app/api/resources/account.py +++ b/app/api/resources/account.py @@ -1,9 +1,11 @@ from flask_restful import Resource, abort -from flask import g +from flask import g, render_template from marshmallow import Schema, fields from webargs.flaskparser import use_args from flasgger import swag_from +from app.api.blueprint import api import app.accounts.api as accounts +from app.accounts.tasks import send_email_task from app.api.auth_protection import ProtectedResource from app.api.permission_protection import (requires_permission, valid_permissions) @@ -85,11 +87,34 @@ class AccountListResource(Resource): @swag_from('swagger/create_account_spec.yaml') def post(self, args): try: - success = accounts.create_account( + emailtoken = accounts.create_account( args['username'], args['email'], args['password']) - if success: - return '', 201 + confirm_url = api.url_for( + AccountEmailTokenResource, + token=emailtoken, _external=True) + html = render_template( + 'activate_mail.html', + confirm_url=confirm_url) + send_email_task.delay( + args['email'], + 'Please confirm your email', + html) + return '', 201 except ValueError: abort(422, message='Account already exists', status='error') + + +class AccountEmailTokenResource(Resource): + def get(self, token): + success = accounts.confirm_email_token(token) + if success: + return '{"status": "success", \ + "message": "Successfully confirmed email"}', 200 + + +class AccountEmailTokenResendResource(Resource): + @use_args(UserSchema(), locations=('json',)) + def post(self, args): + return '', 201 diff --git a/app/api/resources/token.py b/app/api/resources/token.py index b0bba45..2f9c066 100644 --- a/app/api/resources/token.py +++ b/app/api/resources/token.py @@ -23,8 +23,8 @@ class TokenResource(Resource): args['password']) if token: return {'status': 'success', 'token': token}, 200 - except ValueError: - abort(401, message='Invalid credentials', status='error') + except ValueError, e: + abort(401, message=str(e), status='error') class ValidateTokenResource(ProtectedResource): diff --git a/app/celery_builder.py b/app/celery_builder.py index a2bf10e..a6445aa 100644 --- a/app/celery_builder.py +++ b/app/celery_builder.py @@ -1,10 +1,10 @@ # App initialization from flask import Flask -from .tasks.celery_configurator import make_celery +from app.tasks.celery_configurator import make_celery app = Flask(__name__, instance_relative_config=True) app.config.from_object('config') app.config.from_pyfile('config.py', silent=True) app.config['MQTT_CLIENT_ID'] = app.config['MQTT_CLIENT_ID'] + '-worker' task_builder = make_celery(app) -task_builder.autodiscover_tasks(['app.devices']) +task_builder.autodiscover_tasks(['app.devices', 'app.accounts']) diff --git a/app/core.py b/app/core.py index 50034f8..32002f2 100644 --- a/app/core.py +++ b/app/core.py @@ -2,6 +2,7 @@ from flask_api import FlaskAPI from flask_sqlalchemy import SQLAlchemy from flask_bcrypt import Bcrypt +from flask_mail import Mail from flasgger import Swagger from flask_cors import CORS from .tasks import celery_configurator @@ -11,6 +12,7 @@ app.config.from_object('config') app.config.from_pyfile('config.py', silent=True) db = SQLAlchemy(app) bcrypt = Bcrypt(app) +mail = Mail(app) swagger = Swagger(app, template_file='swagger/template.yaml') swagger.template['info']['version'] = app.config['APP_VERSION'] CORS(app) diff --git a/app/templates/activate_mail.html b/app/templates/activate_mail.html new file mode 100644 index 0000000..8634c07 --- /dev/null +++ b/app/templates/activate_mail.html @@ -0,0 +1,4 @@ +

Welcome! Thanks for signing up. Please follow this link to activate your account:

+

{{ confirm_url }}

+
+

Cheers!

diff --git a/config.py b/config.py index 230b5e0..838b01e 100644 --- a/config.py +++ b/config.py @@ -27,6 +27,7 @@ CSRF_SESSION_KEY = "secret" # Secret key for signing cookies SECRET_KEY = "?['Z(Z\x83Y \x06T\x12\x96<\xff\x12\xe0\x1b\xd1J\xe0\xd9ld" +SECURITY_PASSWORD_SALT = "IyoZvOJb4feT3xKlYXyOJveHSIY4GDg6" # MQTT configuration MQTT_CLIENT_ID = 'final-iot-backend-server-' + os.environ['MQTT_CLIENT'] @@ -40,6 +41,20 @@ MQTT_REFRESH_TIME = 1.0 # refresh time in seconds CELERY_BROKER_URL = os.environ['REDIS_URL'] CELERY_RESULT_BACKEND = os.environ['REDIS_URL'] +# Mailer config +MAIL_SERVER = 'smtp.googlemail.com' +MAIL_PORT = 465 +MAIL_USE_TLS = False +MAIL_USE_SSL = True +MAIL_DEBUG = False + +# gmail authentication +MAIL_USERNAME = os.environ['APP_MAIL_USERNAME'] +MAIL_PASSWORD = os.environ['APP_MAIL_PASSWORD'] + +# mail accounts +MAIL_DEFAULT_SENDER = 'final.iot.backend.mailer@gmail.com' + # Flasgger config SWAGGER = { 'uiversion': 3 diff --git a/requirements.txt b/requirements.txt index 892b0f7..2c02378 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ alembic==0.9.9 amqp==2.3.2 aniso8601==3.0.0 +apispec==0.39.0 bcrypt==3.1.4 billiard==3.5.0.4 +blinker==1.4 celery==4.2.0 cffi==1.11.5 click==6.7 @@ -11,11 +13,13 @@ Flask==1.0.2 Flask-API==1.0 Flask-Bcrypt==0.7.1 Flask-Cors==3.0.4 +Flask-Mail==0.9.1 Flask-Migrate==2.1.1 Flask-MQTT==1.0.3 Flask-RESTful==0.3.6 Flask-Script==2.0.6 Flask-SQLAlchemy==2.3.2 +functools32==3.2.3.post2 gunicorn==19.8.1 itsdangerous==0.24 Jinja2==2.10