Email verification (#172)

* Add email_verification table and endpoints, setup email verification page, adjust emails to actually verify.

* Add User.create method
This commit is contained in:
William O'Beirne 2018-11-02 12:07:06 -04:00 committed by GitHub
parent f823488abb
commit cdc3ea0107
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 291 additions and 30 deletions

View File

@ -1,6 +1,7 @@
# Environment variable overrides for local development
FLASK_APP=app.py
FLASK_ENV=development
SITE_URL="https://grant.io" # No trailing slash
DATABASE_URL="sqlite:////tmp/dev.db"
REDISTOGO_URL="redis://localhost:6379"
SECRET_KEY="not-so-secret"

View File

@ -3,7 +3,7 @@
from flask import Flask
from flask_cors import CORS
from grant import commands, proposal, user, comment, milestone, admin
from grant import commands, proposal, user, comment, milestone, admin, email
from grant.extensions import bcrypt, migrate, db, ma, mail
@ -35,6 +35,7 @@ def register_blueprints(app):
app.register_blueprint(user.views.blueprint)
app.register_blueprint(milestone.views.blueprint)
app.register_blueprint(admin.views.blueprint)
app.register_blueprint(email.views.blueprint)
def register_shellcontext(app):
@ -55,3 +56,4 @@ def register_commands(app):
app.cli.add_command(commands.urls)
app.cli.add_command(proposal.commands.create_proposal)
app.cli.add_command(user.commands.delete_user)

View File

@ -0,0 +1,2 @@
from . import views
from . import models

View File

@ -0,0 +1,28 @@
from grant.extensions import ma, db
from grant.utils.misc import gen_random_code
class EmailVerification(db.Model):
__tablename__ = "email_verification"
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
code = db.Column(db.String(255), unique=True, nullable=False)
has_verified = db.Column(db.Boolean)
user = db.relationship("User", back_populates="email_verification")
def __init__(self, user_id: int):
self.user_id = user_id
self.code = gen_random_code(32)
self.has_verified = False
class EmailVerificationSchema(ma.Schema):
class Meta:
model = EmailVerification
# Fields to expose
fields = (
"user_id",
"code",
"has_verified"
)
email_verification_schema = EmailVerificationSchema()

View File

@ -0,0 +1,30 @@
from flask import Blueprint, jsonify
from animal_case import animalify
from flask_yoloapi import endpoint, parameter
from .models import EmailVerification, db
blueprint = Blueprint("email", __name__, url_prefix="/api/v1/email")
@blueprint.route("/<code>/verify", methods=["POST"])
@endpoint.api()
def verify_email(code):
ev = EmailVerification.query.filter_by(code=code).first()
if ev:
ev.has_verified = True
db.session.commit()
return {"message": "Email verified"}, 200
else:
return {"message": "Invalid email code"}, 400
@blueprint.route("/<code>/unsubscribe", methods=["POST"])
@endpoint.api()
def unsubscribe_email():
ev = EmailVerification.query.filter_by(code=code).first()
if ev:
return {"message": "Not yet implemented"}, 500
else:
return {"message": "Invalid email code"}, 400

View File

@ -13,6 +13,7 @@ env.read_env()
ENV = env.str("FLASK_ENV", default="production")
DEBUG = ENV == "development"
SITE_URL = env.str('SITE_URL', default='https://grant.io')
AUTH_URL = env.str('AUTH_URL', default='https://eip-712.herokuapp.com')
SQLALCHEMY_DATABASE_URI = env.str("DATABASE_URL")
QUEUES = ["default"]

View File

@ -7,9 +7,9 @@
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#1890ff">
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 40px; border-radius: 4px; border: 1px solid #1890ff; display: inline-block;">
Confirm Account
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
<a href="{{ args.confirm_url }}" target="_blank" style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid #530EEC; display: inline-block;">
Confirm Email
</a>
</td>
</tr>
@ -18,12 +18,12 @@
</tr>
</table>
<p style="margin: 0 0 10px; font-size: 16px;">
If that doesn't work, copy and paste the following link in your browser:
<p style="margin: 0 0 10px; font-size: 14px; text-align: center;">
If that doesn't work, copy and paste the following link in your browser
</p>
<p style="margin: 0 0 30px; font-size: 12px;">
<a href="{{ args.confirm_url }}" target="_blank" style="color: #1890ff;">{{ args.confirm_url }}</a>
<p style="margin: 0 0 30px; font-size: 12px; text-align: center;">
<a href="{{ args.confirm_url }}" target="_blank" style="color: #530EEC;">{{ args.confirm_url }}</a>
</p>
<p style="margin: 0; font-size: 10px; color: #AAA; text-align: center;">

View File

@ -54,7 +54,7 @@
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<!-- LOGO -->
<tr>
<td bgcolor="#4a4a4a" align="center">
<td bgcolor="#530EEC" align="center">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
@ -78,7 +78,7 @@
</tr>
<!-- TITLE -->
<tr>
<td bgcolor="#4a4a4a" align="center" style="padding: 0px 10px 0px 10px;">
<td bgcolor="#530EEC" align="center" style="padding: 0px 10px 0px 10px;">
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600">
<tr>
@ -86,7 +86,7 @@
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;" >
<tr>
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #111111; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<td bgcolor="#ffffff" align="center" valign="top" style="padding: 40px 20px 20px 20px; border-radius: 4px 4px 0px 0px; color: #221F1F; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 48px; font-weight: 400; letter-spacing: 4px; line-height: 48px;">
<h1 style="font-size: 42px; font-weight: 400; margin: 0;">
{{ args.title }}
</h1>
@ -135,9 +135,9 @@
<tr>
<td bgcolor="#f4f4f4" align="center" style="padding: 30px 30px 30px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
<p style="margin: 0;">
<a href="{{ args.home_url }}" target="_blank" style="color: #111111; font-weight: 700;">Grant.io</a> -
<a href="{{ args.account_url }}" target="_blank" style="color: #111111; font-weight: 700;">Your Account</a> -
<a href="{{ args.email_settings_url }}" target="_blank" style="color: #111111; font-weight: 700;">Email Settings</a>
<a href="{{ args.home_url }}" target="_blank" style="color: #221F1F; font-weight: 700;">Grant.io</a> -
<a href="{{ args.account_url }}" target="_blank" style="color: #221F1F; font-weight: 700;">Your Account</a> -
<a href="{{ args.email_settings_url }}" target="_blank" style="color: #221F1F; font-weight: 700;">Email Settings</a>
</p>
</td>
</tr>
@ -146,7 +146,7 @@
<td bgcolor="#f4f4f4" align="center" style="padding: 0px 30px 30px 30px; color: #666666; font-family: 'Nunito Sans', Helvetica, Arial, sans-serif; font-size: 14px; font-weight: 400; line-height: 18px;" >
<p style="margin: 0;">
Dont want anymore emails?
<a href="{{ args.unsubscribe_url }}" target="_blank" style="color: #111111; font-weight: 700;">
<a href="{{ args.unsubscribe_url }}" target="_blank" style="color: #221F1F; font-weight: 700;">
Click here to unsubscribe
</a>
.

View File

@ -1,2 +1,3 @@
from . import commands
from . import views
from . import models

View File

@ -0,0 +1,28 @@
import click
from flask.cli import with_appcontext
from .models import User, db
@click.command()
@click.argument('identity')
@with_appcontext
def delete_user(identity):
print(identity)
user = None
if str.isdigit(identity):
user = User.query.filter(id=identity).first()
else:
user = User.query.filter(
(User.account_address == identity) |
(User.email_address == identity)
).first()
if user:
db.session.delete(user)
db.session.commit()
click.echo(f'Succesfully deleted {user.display_name} (uid {user.id})')
else:
raise click.BadParameter('Invalid user identity. Must be a userid, '\
'account address, or email address of an '\
'existing user.')

View File

@ -1,5 +1,8 @@
from grant.comment.models import Comment
from grant.email.models import EmailVerification
from grant.extensions import ma, db
from grant.utils.misc import make_url
from grant.email.send import send_email
class SocialMedia(db.Model):
@ -40,6 +43,7 @@ class User(db.Model):
social_medias = db.relationship(SocialMedia, backref="user", lazy=True)
comments = db.relationship(Comment, backref="user", lazy=True)
avatar = db.relationship(Avatar, uselist=False, back_populates="user")
email_verification = db.relationship(EmailVerification, uselist=False, back_populates="user", lazy=True)
# TODO - add create and validate methods
@ -52,6 +56,29 @@ class User(db.Model):
self.display_name = display_name
self.title = title
@staticmethod
def create(email_address=None, account_address=None, display_name=None, title=None):
user = User(
account_address=account_address,
email_address=email_address,
display_name=display_name,
title=title
)
db.session.add(user)
db.session.flush()
# Setup & send email verification
ev = EmailVerification(user_id=user.id)
db.session.add(ev)
db.session.commit()
send_email(user.email_address, 'signup', {
'display_name': user.display_name,
'confirm_url': make_url(f'/email/verify?code={ev.code}')
})
return user
@staticmethod
def get_by_email_or_account_address(email_address: str = None, account_address: str = None):
if not email_address and not account_address:
@ -62,7 +89,6 @@ class User(db.Model):
(User.email_address == email_address)
).first()
class UserSchema(ma.Schema):
class Meta:
model = User

View File

@ -2,10 +2,10 @@ from animal_case import animalify
from flask import Blueprint, g, jsonify
from flask_yoloapi import endpoint, parameter
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
from ..email.send import send_email
from ..proposal.models import Proposal, proposal_team
from ..utils.auth import requires_sm
from grant.proposal.models import Proposal, proposal_team
from grant.utils.auth import requires_sm
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
@ -56,22 +56,12 @@ def create_user(account_address, email_address, display_name, title):
return {"message": "User with that address or email already exists"}, 409
# TODO: Handle avatar & social stuff too
user = User(
user = User.create(
account_address=account_address,
email_address=email_address,
display_name=display_name,
title=title
)
db.session.add(user)
db.session.flush()
db.session.commit()
send_email(email_address, 'signup', {
'display_name': display_name,
# TODO: Make this dynamic
'confirm_url': 'https://grant.io/user/confirm',
})
result = user_schema.dump(user)
return result

View File

@ -1,5 +1,8 @@
import datetime
import time
import random
import string
from grant.settings import SITE_URL
epoch = datetime.datetime.utcfromtimestamp(0)
@ -15,3 +18,11 @@ def dt_to_ms(dt):
def dt_to_unix(dt):
return int(time.mktime(dt.timetuple()))
def gen_random_code(length=32):
return ''.join(
[random.choice(string.ascii_letters + string.digits) for n in range(length)]
)
def make_url(path: str):
return f'{SITE_URL}{path}'

View File

@ -0,0 +1,35 @@
"""empty message
Revision ID: 6e02ee4b9ca3
Revises: 5f38d8603897
Create Date: 2018-11-01 16:29:11.190975
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6e02ee4b9ca3'
down_revision = '5f38d8603897'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('email_verification',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=255), nullable=False),
sa.Column('has_verified', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('user_id'),
sa.UniqueConstraint('code')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('email_verification')
# ### end Alembic commands ###

View File

@ -26,6 +26,7 @@ const Tos = loadable(() => import('pages/tos'));
const About = loadable(() => import('pages/about'));
const Privacy = loadable(() => import('pages/privacy'));
const Contact = loadable(() => import('pages/contact'));
const VerifyEmail = loadable(() => import('pages/email-verify'));
import 'styles/style.less';
@ -192,6 +193,17 @@ const routeConfigs: RouteConfig[] = [
title: 'Signed out',
},
},
{
// Verify email
route: {
path: '/email/verify',
component: VerifyEmail,
exact: true,
},
template: {
title: 'Verify email',
},
},
{
// 404
route: {

View File

@ -73,3 +73,7 @@ export function updateUser(user: TeamMember): Promise<{ data: TeamMember }> {
return res;
});
}
export function verifyEmail(code: string): Promise<any> {
return axios.post(`/api/v1/email/${code}/verify`);
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import { Spin, Button } from 'antd';
import qs from 'query-string';
import { withRouter, RouteComponentProps, Link } from 'react-router-dom';
import Result from 'ant-design-pro/lib/Result';
import { verifyEmail } from 'api/api';
interface State {
isVerifying: boolean;
hasVerified: boolean;
error: string | null;
}
class VerifyEmail extends React.Component<RouteComponentProps, State> {
state: State = {
isVerifying: false,
hasVerified: false,
error: null,
};
componentDidMount() {
const args = qs.parse(this.props.location.search);
if (args.code) {
this.setState({ isVerifying: true });
verifyEmail(args.code)
.then(() => {
this.setState({
hasVerified: true,
isVerifying: false,
})
})
.catch(err => {
this.setState({
error: err.message || err.toString(),
isVerifying: false,
});
});
} else {
this.setState({
error: `
Missing code parameter from email.
Make sure you copied the full link.
`
});
}
}
render() {
const { hasVerified, error } = this.state;
const actions = (
<div>
<Link to="/profile">
<Button size="large" type="primary">
View profile
</Button>
</Link>
<Link to="/proposals">
<Button size="large" style={{ marginLeft: '0.5rem' }}>
Browse proposals
</Button>
</Link>
</div>
);
if (hasVerified) {
return (
<Result
type="success"
title="Email has been verified"
description="You now have full access to Grant.io"
actions={actions}
/>
);
} else if (error) {
return (
<Result
type="error"
title="Unable to verify email"
description={error}
actions={actions}
/>
);
} else {
return <Spin size="large" />;
}
}
}
export default withRouter(VerifyEmail);