Skip to content

Commit

Permalink
Merge pull request #47 from kenny-niu/two-factor-authentication
Browse files Browse the repository at this point in the history
Add Simple Two Factor Authentication
kenny-niu authored Dec 1, 2022

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents c25032c + 51f4734 commit 9300f94
Showing 8 changed files with 273 additions and 64 deletions.
38 changes: 38 additions & 0 deletions oxe-api/migrations/versions/b1327fd4d9d7_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""empty message
Revision ID: b1327fd4d9d7
Revises: 8692e9512214
Create Date: 2022-12-01 06:33:42.361181
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql


# revision identifiers, used by Alembic.
revision = 'b1327fd4d9d7'
down_revision = '8692e9512214'
branch_labels = None
depends_on = None


def upgrade():
op.create_table('UserOtp',
sa.Column('id', mysql.INTEGER(), autoincrement=True, nullable=False),
sa.Column('token', mysql.VARCHAR(charset='utf8mb4', collation='utf8mb4_unicode_ci', length=110), nullable=False),
sa.Column('timestamp', sa.DATETIME(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),

sa.Column('user_id', mysql.INTEGER(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['User.id'], name='otp_relationship_upfk_1', ondelete='CASCADE'),

sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', name='otp_relationship_type_unique_user'),
mysql_collate='utf8mb4_unicode_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)


def downgrade():
op.drop_table('UserOtp')
42 changes: 32 additions & 10 deletions oxe-api/resource/account/login.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import datetime

from flask import session, render_template
from flask_apispec import MethodResource
from flask_apispec import use_kwargs, doc
from flask_bcrypt import check_password_hash
from flask_jwt_extended import create_access_token, create_refresh_token
from flask_restful import Resource
from utils.token import generate_otp
from utils.mail import send_email
from webargs import fields

from decorator.catch_exception import catch_exception
@@ -14,9 +17,11 @@
class Login(MethodResource, Resource):

db = None
mail = None

def __init__(self, db):
def __init__(self, db, mail):
self.db = db
self.mail = mail

@log_request
@doc(tags=['account'],
@@ -44,13 +49,30 @@ def post(self, **kwargs):
if not data[0].is_admin and data[0].status == "NEW":
return "", "401 This account is not active. Please check your email for an activation link."

access_token_expires = datetime.timedelta(days=1)
refresh_token_expires = datetime.timedelta(days=365)
access_token = create_access_token(identity=str(data[0].id), expires_delta=access_token_expires)
refresh_token = create_refresh_token(identity=str(data[0].id), expires_delta=refresh_token_expires)
# delete old otp if exists
old_otp = self.db.get(self.db.tables["UserOtp"], {"user_id": data[0].id})
if len(old_otp) > 0:
self.db.delete(self.db.tables["UserOtp"], {"user_id": data[0].id})

return {
"user": data[0].id,
"access_token": access_token,
"refresh_token": refresh_token,
}, "200 "
# create new otp
otp = self.db.insert({
"token": generate_otp(),
"user_id": data[0].id,
}, self.db.tables["UserOtp"])

# send otp to user
send_email(self.mail,
subject=f"Login One Time Pin",
recipients=[kwargs["email"]],
html_body=render_template(
'login_otp.html',
token=otp.token,
)
)

# access_token_expires = datetime.timedelta(days=1)
# refresh_token_expires = datetime.timedelta(days=365)
# access_token = create_access_token(identity=str(data[0].id), expires_delta=access_token_expires)
# refresh_token = create_refresh_token(identity=str(data[0].id), expires_delta=refresh_token_expires)

return {}, "200 "
65 changes: 65 additions & 0 deletions oxe-api/resource/account/verify_login.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import datetime
from flask import request
from flask_apispec import MethodResource
from flask_apispec import use_kwargs, doc
from flask_restful import Resource
from flask_jwt_extended import create_access_token, create_refresh_token
from webargs import fields

from decorator.catch_exception import catch_exception

class VerifyLogin(MethodResource, Resource):

db = None
mail = None

def __init__(self, db, mail):
self.db = db
self.mail = mail

@doc(tags=['account'],
description='Verify login token',
responses={
"200": {},
"422.a": {"description": "This one time pin is invalid"},
"422.b": {"description": "This one time pin has expired"},
})
@use_kwargs({
'email': fields.Str(),
'token': fields.Str(),
})
@catch_exception
def post(self, **kwargs):

if 'HTTP_ORIGIN' in request.environ and request.environ['HTTP_ORIGIN']:
origin = request.environ['HTTP_ORIGIN']
else:
return "", "500 Impossible to find the origin. Please contact the administrator"

user = self.db.get(self.db.tables["User"], {"email": kwargs["email"]})
otp = self.db.get(self.db.tables["UserOtp"], {"user_id": user[0].id})

if len(otp) < 1:
return "", "422 This one time pin is invalid."

# Verify token
if otp[0].token != kwargs["token"]:
return "", "422 This one time pin is invalid."
token_timestamp = otp[0].timestamp.timestamp()
now_timestamp = datetime.datetime.now().timestamp()
if now_timestamp - token_timestamp > 600:
return "", "422 This one time pin has expired."

# delete token if valid
self.db.delete(self.db.tables["UserOtp"], {"id": otp[0].id})

access_token_expires = datetime.timedelta(days=1)
refresh_token_expires = datetime.timedelta(days=365)
access_token = create_access_token(identity=str(user[0].id), expires_delta=access_token_expires)
refresh_token = create_refresh_token(identity=str(user[0].id), expires_delta=refresh_token_expires)

return {
"user": user[0].id,
"access_token": access_token,
"refresh_token": refresh_token,
}, "200 "
1 change: 1 addition & 0 deletions oxe-api/routes.py
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@
from resource.account.verify_account import VerifyAccount
from resource.account.update_status import UpdateStatus
from resource.account.add_profile import AddProfile
from resource.account.verify_login import VerifyLogin
from resource.analytics.get_ecosystem_activity import GetEcosystemActivity
from resource.article.copy_article_version import CopyArticleVersion
from resource.article.get_articles import GetArticles
13 changes: 13 additions & 0 deletions oxe-api/template/login_otp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<p>Dear {{ email }},</p>
<p>To complete your login, please enter the following One Time Pin:</p>

<p style="background: #fefefe; padding: 10px; border-radius: 5px; font-size: 20px;">
{{ token|safe }}
</p>

<span style="font-style: italic; font-size: 0.8rem;">This code expires in 10 minutes.</span>

<p>Sincerely,</p>
<p>
<img src="cid:logo" alt="Logo" style="width:200px;">
</p>
9 changes: 8 additions & 1 deletion oxe-api/utils/token.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import random
from itsdangerous import URLSafeTimedSerializer

from config.config import SECRET_KEY, SECURITY_SALT
@@ -14,4 +15,10 @@ def confirm_token(token, expiration=3600):
token,
salt=SECURITY_SALT,
max_age=expiration
)
)

def generate_otp(otp_size = 6):
final_otp = ''
for _ in range(otp_size):
final_otp = final_otp + str(random.randint(0,9))
return final_otp
163 changes: 110 additions & 53 deletions oxe-web-community/src/component/Login.jsx
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ import "./Login.css";
import { NotificationManager as nm } from "react-notifications";
import FormLine from "./form/FormLine.jsx";
import { getRequest, postRequest } from "../utils/request.jsx";
import { validatePassword, validateEmail } from "../utils/re.jsx";
import { validatePassword, validateEmail, validateOtp } from "../utils/re.jsx";
import Info from "./box/Info.jsx";
import { getUrlParameter } from "../utils/url.jsx";
import { getCookieOptions, getGlobalAppURL, getApiURL } from "../utils/env.jsx";
@@ -19,6 +19,7 @@ export default class Login extends React.Component {
this.requestReset = this.requestReset.bind(this);
this.resetPassword = this.resetPassword.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.verifyLogin = this.verifyLogin.bind(this);

let view = null;

@@ -45,8 +46,10 @@ export default class Login extends React.Component {
partOfEntity: false,
entity: "",
entityDepartment: "",
otp: "",
checkedVerified: false,
verified: false,
verifyLogin: false,
};
}

@@ -122,12 +125,32 @@ export default class Login extends React.Component {
password: this.state.password,
};

postRequest.call(this, "account/login", params, (response) => {
postRequest.call(this, "account/login", params, () => {
nm.info("Please check your email for the One Time Pin");
this.setState({ verifyLogin: true });
}, (response) => {
nm.warning(response.statusText);
}, (error) => {
nm.error(error.message);
});
}

verifyLogin() {
if (!validateOtp(this.state.otp)) {
nm.warning("Invalid OTP");
return;
}
const params = {
email: this.state.email,
token: this.state.otp,
};
postRequest.call(this, "account/verify_login", params, (response) => {
this.props.cookies.set("access_token_cookie", response.access_token, getCookieOptions());
this.props.connect(this.state.email);
this.fetchUser();
}, (response) => {
nm.warning(response.statusText);
this.setState({ verifyLogin: false });
}, (error) => {
nm.error(error.message);
});
@@ -402,69 +425,103 @@ export default class Login extends React.Component {
</div>
<div id="Login-box" className={"fade-in"}>
<div id="Login-inner-box">

{this.state.view === "login"
&& <div className="row">
<div className="col-md-12">
<div className="Login-title">
{this.props.settings !== null
&& this.props.settings.PRIVATE_SPACE_PLATFORM_NAME !== undefined
? this.props.settings.PRIVATE_SPACE_PLATFORM_NAME
: "PRIVATE SPACE"
}

{this.props.settings !== null
&& this.props.settings.PROJECT_NAME !== undefined
&& <div className={"Login-title-small"}>
{this.props.settings.PROJECT_NAME} private space
{this.state.verifyLogin === false
&& <>
<div className="col-md-12">
<div className="Login-title">
{this.props.settings !== null
&& this.props.settings.PRIVATE_SPACE_PLATFORM_NAME !== undefined
? this.props.settings.PRIVATE_SPACE_PLATFORM_NAME
: "PRIVATE SPACE"
}

{this.props.settings !== null
&& this.props.settings.PROJECT_NAME !== undefined
&& <div className={"Login-title-small"}>
{this.props.settings.PROJECT_NAME} private space
</div>
}
</div>
}
</div>
</div>
<div className="col-md-12">
<FormLine
label="Email"
fullWidth={true}
value={this.state.email}
onChange={(v) => this.changeState("email", v)}
autofocus={true}
onKeyDown={this.onKeyDown}
/>
<FormLine
label="Password"
type={"password"}
fullWidth={true}
value={this.state.password}
onChange={(v) => this.changeState("password", v)}
onKeyDown={this.onKeyDown}
/>
</div>
<div className="col-md-12">
<FormLine
label="Email"
fullWidth={true}
value={this.state.email}
onChange={(v) => this.changeState("email", v)}
autofocus={true}
onKeyDown={this.onKeyDown}
/>
<FormLine
label="Password"
type={"password"}
fullWidth={true}
value={this.state.password}
onChange={(v) => this.changeState("password", v)}
onKeyDown={this.onKeyDown}
/>

<div>
<div className="right-buttons">
<button
className="blue-button"
onClick={this.login}
>
Login
</button>
</div>
<div className="left-buttons">
<button
className="link-button"
onClick={() => this.changeState("view", "create")}
>
I want to create an account
</button>
</div>
<div className="left-buttons">
<button
className="link-button"
onClick={() => this.changeState("view", "forgot")}
>
I forgot my password
</button>
</div>
</div>
</div>
</>
}

{this.state.verifyLogin === true
&& <div>
<div className="Login-title">
Verify Login
</div>
<FormLine
label="Please enter the One Time Pin you received via email"
fullWidth={true}
value={this.state.otp}
onChange={(v) => this.changeState("otp", v)}
autofocus={true}
onKeyDown={this.onKeyDown}
format={validateOtp}
/>

<div>
<div className="right-buttons">
<button
className="blue-button"
onClick={this.login}
>
Login
</button>
</div>
<div className="left-buttons">
<button
className="link-button"
onClick={() => this.changeState("view", "create")}
onClick={this.verifyLogin}
>
I want to create an account
</button>
</div>
<div className="left-buttons">
<button
className="link-button"
onClick={() => this.changeState("view", "forgot")}
>
I forgot my password
Submit
</button>
</div>

</div>
</div>
}

</div>
}

6 changes: 6 additions & 0 deletions oxe-web-community/src/utils/re.jsx
Original file line number Diff line number Diff line change
@@ -59,3 +59,9 @@ export function validatePostcode(postcode) {
const re = /^[-a-zA-Z]{3}(\s)?[0-9]{4}$/;
return re.test(String(postcode).toUpperCase()) || !postcode;
}

export function validateOtp(otp) {
if (otp === null || typeof otp === "undefined" || otp.length === 0) return false;
const re = /^[0-9]{6}$/;
return re.test(String(otp).toUpperCase()) || !otp;
}

0 comments on commit 9300f94

Please sign in to comment.