Merged in feature/registration-confirmation (pull request #8)
Feature/registration confirmationdevelop
commit
1a9cb3f973
3
.env
3
.env
|
@ -3,3 +3,6 @@ REDIS_URL=redis://localhost:6379
|
||||||
CELERY_TASK_SERIALIZER=json
|
CELERY_TASK_SERIALIZER=json
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
MQTT_CLIENT=local
|
MQTT_CLIENT=local
|
||||||
|
APP_MAIL_USERNAME=final.iot.backend.mailer@gmail.com
|
||||||
|
APP_MAIL_PASSWORD=FinalIoT1234
|
||||||
|
FLASK_ENV=development
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
import datetime
|
||||||
from app.core import bcrypt
|
from app.core import bcrypt
|
||||||
from .models import Account, Role
|
from .models import Account, Role
|
||||||
|
from .emailtoken import generate_confirmation_token, confirm_token
|
||||||
|
|
||||||
|
|
||||||
def create_account(username, email, password):
|
def create_account(username, email, password):
|
||||||
|
@ -12,19 +14,36 @@ def create_account(username, email, password):
|
||||||
:type username: string
|
:type username: string
|
||||||
:type email: string
|
:type email: string
|
||||||
:type password: string
|
:type password: string
|
||||||
:returns: True if account is successfully created
|
:returns: Email confirmation token if creation was successful
|
||||||
:rtype: Boolean
|
:rtype: string
|
||||||
:raises: ValueError if account already exists
|
:raises: ValueError if account already exists
|
||||||
"""
|
"""
|
||||||
if not Account.exists_with_any_of(username=username, email=email):
|
if not Account.exists_with_any_of(username=username, email=email):
|
||||||
pw_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
pw_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
account = Account(username, pw_hash, email)
|
account = Account(username, pw_hash, email)
|
||||||
account.save()
|
account.save()
|
||||||
return True
|
|
||||||
|
emailtoken = generate_confirmation_token(account.email)
|
||||||
|
return emailtoken
|
||||||
|
|
||||||
raise ValueError("Account with given parameters already exists")
|
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):
|
def update_account_role(account_id, role_id):
|
||||||
"""
|
"""
|
||||||
Tries to update account role
|
Tries to update account role
|
||||||
|
@ -99,6 +118,10 @@ def create_token(username, password):
|
||||||
if not bcrypt.check_password_hash(account.password, password):
|
if not bcrypt.check_password_hash(account.password, password):
|
||||||
raise ValueError("Invalid credentials")
|
raise ValueError("Invalid credentials")
|
||||||
|
|
||||||
|
if not account.confirmed:
|
||||||
|
print('ACCOUNT NOT CONFIRMED?')
|
||||||
|
raise ValueError("Email not confirmed")
|
||||||
|
|
||||||
return account.create_auth_token()
|
return account.create_auth_token()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -13,6 +13,8 @@ class Account(db.Model):
|
||||||
email = db.Column(db.String, index=True, unique=True)
|
email = db.Column(db.String, index=True, unique=True)
|
||||||
role_id = db.Column(db.Integer, db.ForeignKey("roles.id"))
|
role_id = db.Column(db.Integer, db.ForeignKey("roles.id"))
|
||||||
role = db.relationship("Role", foreign_keys=[role_id])
|
role = db.relationship("Role", foreign_keys=[role_id])
|
||||||
|
confirmed = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
confirmed_at = db.Column(db.DateTime, nullable=True)
|
||||||
created_at = db.Column(db.DateTime,
|
created_at = db.Column(db.DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=db.func.current_timestamp())
|
default=db.func.current_timestamp())
|
||||||
|
|
|
@ -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)
|
|
@ -12,7 +12,9 @@ def add_resources():
|
||||||
AccountListResource,
|
AccountListResource,
|
||||||
AccountRoleResource,
|
AccountRoleResource,
|
||||||
RoleResource,
|
RoleResource,
|
||||||
RolesResource)
|
RolesResource,
|
||||||
|
AccountEmailTokenResource,
|
||||||
|
AccountEmailTokenResendResource)
|
||||||
from .resources.token import TokenResource, ValidateTokenResource
|
from .resources.token import TokenResource, ValidateTokenResource
|
||||||
from .resources.device import (DeviceResource,
|
from .resources.device import (DeviceResource,
|
||||||
DeviceRecordingResource,
|
DeviceRecordingResource,
|
||||||
|
@ -25,6 +27,10 @@ def add_resources():
|
||||||
api.add_resource(AccountResource, '/v1/accounts/<int:account_id>')
|
api.add_resource(AccountResource, '/v1/accounts/<int:account_id>')
|
||||||
api.add_resource(AccountListResource, '/v1/accounts')
|
api.add_resource(AccountListResource, '/v1/accounts')
|
||||||
api.add_resource(AccountRoleResource, '/v1/accounts/<int:account_id>/role')
|
api.add_resource(AccountRoleResource, '/v1/accounts/<int:account_id>/role')
|
||||||
|
api.add_resource(AccountEmailTokenResource,
|
||||||
|
'/v1/email/confirm/<string:token>')
|
||||||
|
api.add_resource(AccountEmailTokenResendResource,
|
||||||
|
'/v1/email/resend')
|
||||||
api.add_resource(RoleResource, '/v1/roles/<int:role_id>')
|
api.add_resource(RoleResource, '/v1/roles/<int:role_id>')
|
||||||
api.add_resource(RolesResource, '/v1/roles')
|
api.add_resource(RolesResource, '/v1/roles')
|
||||||
api.add_resource(TokenResource, '/v1/token')
|
api.add_resource(TokenResource, '/v1/token')
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
from flask_restful import Resource, abort
|
from flask_restful import Resource, abort
|
||||||
from flask import g
|
from flask import g, render_template
|
||||||
from marshmallow import Schema, fields
|
from marshmallow import Schema, fields
|
||||||
from webargs.flaskparser import use_args
|
from webargs.flaskparser import use_args
|
||||||
from flasgger import swag_from
|
from flasgger import swag_from
|
||||||
|
from app.api.blueprint import api
|
||||||
import app.accounts.api as accounts
|
import app.accounts.api as accounts
|
||||||
|
from app.accounts.tasks import send_email_task
|
||||||
from app.api.auth_protection import ProtectedResource
|
from app.api.auth_protection import ProtectedResource
|
||||||
from app.api.permission_protection import (requires_permission,
|
from app.api.permission_protection import (requires_permission,
|
||||||
valid_permissions)
|
valid_permissions)
|
||||||
|
@ -85,11 +87,34 @@ class AccountListResource(Resource):
|
||||||
@swag_from('swagger/create_account_spec.yaml')
|
@swag_from('swagger/create_account_spec.yaml')
|
||||||
def post(self, args):
|
def post(self, args):
|
||||||
try:
|
try:
|
||||||
success = accounts.create_account(
|
emailtoken = accounts.create_account(
|
||||||
args['username'],
|
args['username'],
|
||||||
args['email'],
|
args['email'],
|
||||||
args['password'])
|
args['password'])
|
||||||
if success:
|
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
|
return '', 201
|
||||||
except ValueError:
|
except ValueError:
|
||||||
abort(422, message='Account already exists', status='error')
|
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
|
||||||
|
|
|
@ -23,8 +23,8 @@ class TokenResource(Resource):
|
||||||
args['password'])
|
args['password'])
|
||||||
if token:
|
if token:
|
||||||
return {'status': 'success', 'token': token}, 200
|
return {'status': 'success', 'token': token}, 200
|
||||||
except ValueError:
|
except ValueError, e:
|
||||||
abort(401, message='Invalid credentials', status='error')
|
abort(401, message=str(e), status='error')
|
||||||
|
|
||||||
|
|
||||||
class ValidateTokenResource(ProtectedResource):
|
class ValidateTokenResource(ProtectedResource):
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
# App initialization
|
# App initialization
|
||||||
from flask import Flask
|
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 = Flask(__name__, instance_relative_config=True)
|
||||||
app.config.from_object('config')
|
app.config.from_object('config')
|
||||||
app.config.from_pyfile('config.py', silent=True)
|
app.config.from_pyfile('config.py', silent=True)
|
||||||
app.config['MQTT_CLIENT_ID'] = app.config['MQTT_CLIENT_ID'] + '-worker'
|
app.config['MQTT_CLIENT_ID'] = app.config['MQTT_CLIENT_ID'] + '-worker'
|
||||||
task_builder = make_celery(app)
|
task_builder = make_celery(app)
|
||||||
task_builder.autodiscover_tasks(['app.devices'])
|
task_builder.autodiscover_tasks(['app.devices', 'app.accounts'])
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
from flask_api import FlaskAPI
|
from flask_api import FlaskAPI
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from flask_bcrypt import Bcrypt
|
from flask_bcrypt import Bcrypt
|
||||||
|
from flask_mail import Mail
|
||||||
from flasgger import Swagger
|
from flasgger import Swagger
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from .tasks import celery_configurator
|
from .tasks import celery_configurator
|
||||||
|
@ -11,6 +12,7 @@ app.config.from_object('config')
|
||||||
app.config.from_pyfile('config.py', silent=True)
|
app.config.from_pyfile('config.py', silent=True)
|
||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
bcrypt = Bcrypt(app)
|
bcrypt = Bcrypt(app)
|
||||||
|
mail = Mail(app)
|
||||||
swagger = Swagger(app, template_file='swagger/template.yaml')
|
swagger = Swagger(app, template_file='swagger/template.yaml')
|
||||||
swagger.template['info']['version'] = app.config['APP_VERSION']
|
swagger.template['info']['version'] = app.config['APP_VERSION']
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<p>Welcome! Thanks for signing up. Please follow this link to activate your account:</p>
|
||||||
|
<p><a href="{{ confirm_url }}">{{ confirm_url }}</a></p>
|
||||||
|
<br>
|
||||||
|
<p>Cheers!</p>
|
15
config.py
15
config.py
|
@ -27,6 +27,7 @@ CSRF_SESSION_KEY = "secret"
|
||||||
|
|
||||||
# Secret key for signing cookies
|
# Secret key for signing cookies
|
||||||
SECRET_KEY = "?['Z(Z\x83Y \x06T\x12\x96<\xff\x12\xe0\x1b\xd1J\xe0\xd9ld"
|
SECRET_KEY = "?['Z(Z\x83Y \x06T\x12\x96<\xff\x12\xe0\x1b\xd1J\xe0\xd9ld"
|
||||||
|
SECURITY_PASSWORD_SALT = "IyoZvOJb4feT3xKlYXyOJveHSIY4GDg6"
|
||||||
|
|
||||||
# MQTT configuration
|
# MQTT configuration
|
||||||
MQTT_CLIENT_ID = 'final-iot-backend-server-' + os.environ['MQTT_CLIENT']
|
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_BROKER_URL = os.environ['REDIS_URL']
|
||||||
CELERY_RESULT_BACKEND = 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
|
# Flasgger config
|
||||||
SWAGGER = {
|
SWAGGER = {
|
||||||
'uiversion': 3
|
'uiversion': 3
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: ae43d7caad52
|
||||||
|
Revises: fd5cfe0f1c51
|
||||||
|
Create Date: 2018-10-10 21:01:06.071591
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ae43d7caad52'
|
||||||
|
down_revision = 'fd5cfe0f1c51'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
accounts = sa.table('accounts', sa.column('id', sa.Integer),
|
||||||
|
sa.column('confirmed', sa.BOOLEAN()))
|
||||||
|
op.execute(accounts.update().
|
||||||
|
values({'confirmed': True}))
|
||||||
|
|
||||||
|
op.alter_column('accounts', 'confirmed',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('accounts', 'confirmed',
|
||||||
|
existing_type=sa.BOOLEAN(),
|
||||||
|
nullable=True)
|
||||||
|
# ### end Alembic commands ###
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: fd5cfe0f1c51
|
||||||
|
Revises: 55611f1868bd
|
||||||
|
Create Date: 2018-10-08 23:07:48.054401
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'fd5cfe0f1c51'
|
||||||
|
down_revision = '55611f1868bd'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('accounts', sa.Column('confirmed', sa.Boolean(), nullable=True))
|
||||||
|
op.add_column('accounts', sa.Column('confirmed_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
role = sa.table('roles', sa.column('id', sa.Integer),
|
||||||
|
sa.column('permissions', sa.ARRAY(sa.String)))
|
||||||
|
op.execute(role.update().where(role.c.id == op.inline_literal(1)).
|
||||||
|
values({'permissions':
|
||||||
|
['CREATE_DEVICE_TYPE', 'CREATE_ROLE',
|
||||||
|
'ASSIGN_ROLE',
|
||||||
|
'CREATE_DEVICE', 'CREATE_DASHBOARD',
|
||||||
|
'READ_DEVICE_TYPES', 'READ_ROLES']})
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('accounts', 'confirmed_at')
|
||||||
|
op.drop_column('accounts', 'confirmed')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -1,8 +1,10 @@
|
||||||
alembic==0.9.9
|
alembic==0.9.9
|
||||||
amqp==2.3.2
|
amqp==2.3.2
|
||||||
aniso8601==3.0.0
|
aniso8601==3.0.0
|
||||||
|
apispec==0.39.0
|
||||||
bcrypt==3.1.4
|
bcrypt==3.1.4
|
||||||
billiard==3.5.0.4
|
billiard==3.5.0.4
|
||||||
|
blinker==1.4
|
||||||
celery==4.2.0
|
celery==4.2.0
|
||||||
cffi==1.11.5
|
cffi==1.11.5
|
||||||
click==6.7
|
click==6.7
|
||||||
|
@ -11,11 +13,13 @@ Flask==1.0.2
|
||||||
Flask-API==1.0
|
Flask-API==1.0
|
||||||
Flask-Bcrypt==0.7.1
|
Flask-Bcrypt==0.7.1
|
||||||
Flask-Cors==3.0.4
|
Flask-Cors==3.0.4
|
||||||
|
Flask-Mail==0.9.1
|
||||||
Flask-Migrate==2.1.1
|
Flask-Migrate==2.1.1
|
||||||
Flask-MQTT==1.0.3
|
Flask-MQTT==1.0.3
|
||||||
Flask-RESTful==0.3.6
|
Flask-RESTful==0.3.6
|
||||||
Flask-Script==2.0.6
|
Flask-Script==2.0.6
|
||||||
Flask-SQLAlchemy==2.3.2
|
Flask-SQLAlchemy==2.3.2
|
||||||
|
functools32==3.2.3.post2
|
||||||
gunicorn==19.8.1
|
gunicorn==19.8.1
|
||||||
itsdangerous==0.24
|
itsdangerous==0.24
|
||||||
Jinja2==2.10
|
Jinja2==2.10
|
||||||
|
|
Loading…
Reference in New Issue