diff --git a/cookie_api/app.py b/cookie_api/app.py index 0361d93..5f8190b 100644 --- a/cookie_api/app.py +++ b/cookie_api/app.py @@ -1,6 +1,7 @@ from apistar import Route from apistar.http import HTMLResponse from apistar_jwt import JWT +from apistar_mail import MailComponent import logbook from sqlalchemy import create_engine from pathlib import Path @@ -42,6 +43,15 @@ _components = [ 'JWT_USER_ID': 'sub', 'JWT_SECRET': 'thisisasecret', }), + MailComponent(**{ + 'MAIL_SERVER': 'mail.example.com', + 'MAIL_USERNAME': 'me@example.com', + 'MAIL_PASSWORD': 'donotsavethistoversioncontrol', + 'MAIL_PORT': 587, + 'MAIL_USE_TLS': True, + 'MAIL_DEFAULT_SENDER': 'me@example.com', + 'MAIL_SUPPRESS_SEND': True + }) ] @@ -51,11 +61,11 @@ def application_factory(routes=_routes, components=_components, hooks=_hooks, se _settings = {**app_settings, **settings} global_init(_settings) - logger.info("Template directory {}".format(TEMPLATE_DIR)) - logger.info("Static directory {}".format(STATIC_DIR)) + logger.debug("Template directory {}".format(template_dir)) + logger.debug("Static directory {}".format(STATIC_DIR)) return App(components=components, event_hooks=hooks, routes=routes, - template_dir=template_dir.name, # have to use name because of Jinja2 + template_dir=str(template_dir), # have to use name because of Jinja2 static_dir=static_dir) diff --git a/cookie_api/auth.py b/cookie_api/auth.py index dde68b9..6c4d859 100644 --- a/cookie_api/auth.py +++ b/cookie_api/auth.py @@ -1,10 +1,14 @@ import datetime as dt +import re -from apistar import http, Route, Include +from apistar.exceptions import HTTPException +from apistar import http, Route, Include, App from apistar_jwt.token import JWT, JWTUser -# from apistar_mail import Message, Mail +from apistar_mail import Message, Mail # from sqlalchemy.exc import IntegrityError, InvalidRequestError +from itsdangerous import URLSafeTimedSerializer, BadSignature +import logbook from sqlalchemy.orm import Session from sqlalchemy.orm.exc import NoResultFound @@ -12,6 +16,31 @@ from cookie_api.models import User from cookie_api.schema import UserExportSchema, UserCreateSchema from cookie_api.util import ExtJSONResponse +# https://realpython.com/handling-email-confirmation-in-flask/ + +EMAIL_REGEX = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" +email_regex = re.compile(EMAIL_REGEX) + +logger = logbook.Logger(__name__) + + +def generate_confirmation_token(email): + serializer = URLSafeTimedSerializer('secret-key') + return serializer.dumps(email, salt='password-salt') + + +def confirm_token(token, expiration=3600): + serializer = URLSafeTimedSerializer('secret-key') + try: + email = serializer.loads( + token, + salt='password-salt', + max_age=expiration + ) + except BadSignature: + return False + return email + def login(json_data: http.RequestData, session: Session, jwt: JWT): user_id = json_data.get('email') @@ -53,33 +82,61 @@ def logout(): pass -# TODO Add email confirmation to registration -# def register(user_data: UserCreateSchema, session: Session, mail: Mail): -def register(user_data: UserCreateSchema, session: Session) -> UserExportSchema: +def register(user_data: UserCreateSchema, session: Session, mail: Mail, app: App) -> UserExportSchema: + if not email_regex.match(user_data['email']): + raise HTTPException('Please provide a valid email address', status_code=400) + email_check = session.query(User).filter_by(email=user_data['email']).one_or_none() if email_check is not None: - error = { - 'status': 'error', - 'message': 'user email address is already in use' - } - return ExtJSONResponse(error, 400) + raise HTTPException('user email address is already in use', status_code=400) user = User(email=user_data['email'], password=user_data['password']) + try: + tokenized_email = generate_confirmation_token(user.email) + + email_body = app.render_template('confirm_email.jinja2', + confirm_url=f'http://localhost:5000/auth/confirm?token={tokenized_email}' + ) + + msg = Message(subject="Confirm your email", + html=email_body, + recipients=[user_data['email']]) + mail.send(msg) + except Exception as e: + logger.error(e, exc_info=True) + raise HTTPException('Error an sending email', status_code=500) + session.add(user) session.commit() - # msg = Message("Thank you for registering please confirm your email", recipients=[user_rep['email']]) - # mail.send(msg) - - # headers = {} + headers = {} message = { - 'status': 'success', 'message': 'Please check your inbox and confirm your email', 'data': UserExportSchema(user) } - return ExtJSONResponse(message, 201) + return ExtJSONResponse(message, 201, headers) + + +# TODO Return an HTML response since the user will be clicking on a link in a web browser +# TODO Since you are returning a user respresentation add a location and Content-Location header +def confirm(params: http.QueryParams, session: Session): + token = params['token'] + email = confirm_token(token) + + if not email: + raise HTTPException('Email token is expired or invalid', status_code=400) + + user = session.query(User).filter_by(email=email).one() + + if user.confirmed: + raise HTTPException('Account already confirmed. Please login.', status_code=400) + + user.confirmed = True + session.add(user) + session.commit() + return ExtJSONResponse(UserExportSchema(user)) def user_profile(user: JWTUser, session: Session) -> UserExportSchema: @@ -87,15 +144,10 @@ def user_profile(user: JWTUser, session: Session) -> UserExportSchema: user = session.query(User).filter_by(id=user.id).one() except NoResultFound as e: error = {'message': str(e)} - return ExtJSONResponse(error, 400) + raise HTTPException(error, status_code=400) return ExtJSONResponse(UserExportSchema(user)) -# TODO Add email confirmation -def confirm(json_data: http.RequestData, session: Session): - pass - - # TODO Add email password reset def reset(): pass @@ -104,7 +156,8 @@ def reset(): routes = [ Route('/login', 'POST', login), Route('/register', 'POST', register), - Route('/status', 'GET', user_profile) + Route('/status', 'GET', user_profile), + Route('/confirm', 'GET', confirm) ] -auth_routes = [Include('/auth', name='auth', routes=routes)] +auth_routes = [Include('/auth', name='dirp', routes=routes)] diff --git a/cookie_api/templates/confirm_email.jinja2 b/cookie_api/templates/confirm_email.jinja2 new file mode 100644 index 0000000..a3c8b10 --- /dev/null +++ b/cookie_api/templates/confirm_email.jinja2 @@ -0,0 +1,4 @@ +

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

+

Confirm your email

+
+

Cheers!

\ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b949484..4554712 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ alembic==0.9.9 apistar==0.5.18 +apistar-mail=0.3.0 apistar-jwt==0.4.2 bcrypt==3.1.4 certifi==2018.4.16 diff --git a/wsgi.py b/wsgi.py index b93baa0..e0027a0 100644 --- a/wsgi.py +++ b/wsgi.py @@ -6,10 +6,26 @@ except ImportError: compat.register() +from apistar_jwt import JWT +from apistar_mail import MailComponent +from sqlalchemy import create_engine + from cookie_api import application_factory +from cookie_api.util import SQLAlchemySession, SQLAlchemyHook +from config import db_config, jwt_config, mail_config + +components = [ + SQLAlchemySession(create_engine(db_config)), + JWT(jwt_config), + MailComponent(**mail_config) +] + +hooks = [ + SQLAlchemyHook() +] -app = application_factory() +app = application_factory(components=components, hooks=hooks) if __name__ == '__main__': app = application_factory() - app.serve('127.0.0.1', 8080, debug=True) + app.serve('127.0.0.1', 5000, debug=True)