Skip to content

Commit

Permalink
Merge pull request #222 from kfkitsune/2fa
Browse files Browse the repository at this point in the history
Add Two-Factor Authentication (2FA)
  • Loading branch information
skylerbunny authored May 6, 2017
2 parents 47a79e7 + 3eebd3f commit 33080b9
Show file tree
Hide file tree
Showing 26 changed files with 1,787 additions and 70 deletions.
5 changes: 5 additions & 0 deletions config/site.config.txt.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,8 @@ url = postgresql+psycopg2cffi:///weasyl
[recaptcha-lo.weasyl.com]
public_key = 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI
private_key = 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe

[two_factor_auth]
# This key MUST be changed when in production;
# See https://cryptography.io/en/latest/fernet/ -- Fernet.generate_key()
secret_key = 2iY4trxnpmNLlQifnQ21pFF0nb-VlmpxRUI6W_uP1oQ=
3 changes: 3 additions & 0 deletions etc/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ Dozer==0.5 # For WSGI memory profiling
Pillow==3.3.1 # For WSGI memory profiling
pyramid==1.7.3
WebTest==2.0.23
pyotp==2.2.4 # For Two-Factor Authentication
qrcodegen==1.0.0 # For Two-Factor Authentication
cryptography==1.8.1 # For Two-Factor Authentication
38 changes: 38 additions & 0 deletions libweasyl/libweasyl/alembic/versions/abeefecabdad_implement_2fa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
Implements two-factor authentication.
Revision ID: abeefecabdad
Revises: 40c00abab5f9
Create Date: 2017-01-19 01:56:20.093477
"""

# revision identifiers, used by Alembic.
revision = 'abeefecabdad'
down_revision = '40c00abab5f9'

from alembic import op
import sqlalchemy as sa


def upgrade():
# Create a table to store 2FA backup codes for use when the authenticator is unavailable.
op.create_table('twofa_recovery_codes',
sa.Column('userid', sa.Integer(), nullable=False),
sa.Column('recovery_code_hash', sa.String(length=100), nullable=False),
sa.ForeignKeyConstraint(['userid'], ['login.userid'], name='twofa_recovery_codes_userid_fkey', onupdate='CASCADE', ondelete='CASCADE'),
)
op.create_index('ind_twofa_recovery_codes_userid', 'twofa_recovery_codes', ['userid'])

# Modify `login` to hold the 2FA code (if set) for a user account
op.add_column(
'login',
sa.Column('twofa_secret', sa.String(length=420), nullable=True, server_default=None),
)


def downgrade():
# Remove 2FA logic of recovery codes and secret
op.drop_index('ind_twofa_recovery_codes_userid', 'twofa_recovery_codes')
op.drop_table('twofa_recovery_codes')
op.drop_column('login', 'twofa_secret')
11 changes: 11 additions & 0 deletions libweasyl/libweasyl/models/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,11 +391,22 @@ def default_fkey(*args, **kwargs):
},
}, length=20), nullable=False, server_default=''),
Column('email', String(length=100), nullable=False, server_default=''),
Column('twofa_secret', String(length=420), nullable=True),
)

Index('ind_login_login_name', login.c.login_name)


twofa_recovery_codes = Table(
'twofa_recovery_codes', metadata,
Column('userid', Integer(), nullable=False),
Column('recovery_code_hash', String(length=100), nullable=False),
default_fkey(['userid'], ['login.userid'], name='twofa_recovery_codes_userid_fkey'),
)

Index('ind_twofa_recovery_codes_userid', twofa_recovery_codes.c.userid)


loginaddress = Table(
'loginaddress', metadata,
Column('userid', Integer(), primary_key=True, nullable=False),
Expand Down
6 changes: 4 additions & 2 deletions libweasyl/libweasyl/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@


secure_random = random.SystemRandom()
key_characters = string.ascii_letters + string.digits
KEY_CHARACTERS = string.ascii_letters + string.digits


def generate_key(size):
def generate_key(size, key_characters=KEY_CHARACTERS):
"""
Generate a cryptographically-secure random key.
Parameters:
size (int): The number of characters in the key.
key_characters (string): The character set to use during key generation.
defaults to KEY_CHARACTERS (as defined in this module).
Returns:
An ASCII printable :term:`native string` of length *size*.
Expand Down
31 changes: 31 additions & 0 deletions templates/control/2fa/disable.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
$def with (username, error)
$:{RENDER("common/stage_title.html", ["Disable 2FA", "Two-Factor Authentication", "/control/2fa/status"])}

<div class="content form clear">
<form method="POST" class="form skinny clear" action="/control/2fa/disable">
$if error == "2fa":
<div id="signin_error"><strong>Whoops!</strong> The 2FA response token or recovery code you provided did not validate.</div>
$elif error == "verify":
<div id="signin_error"><strong>Hey!</strong> Did you want to disable 2FA? You didn't check the verification checkbox!</div>
<h3>Disable Two-Factor Authentication (2FA)</h3>
<p><strong>Username: ${username}</strong></p>
<p>
You are about to disable 2FA for the above account, and remove the
security which it provides. If you wish to re-enable 2FA, you will
need to go through through the enabling process once again.
</p>

<label for="disable-tfa">2FA token or Recovery Code</label>
<input type="text" class="input" maxlength="24" id="disable-tfa" name="tfaresponse" autocomplete="off" required />
<div style="padding-top: 1em;">
<label><input class="checkbox" type="checkbox" name="verify" required /> I confirm that I want to disable 2FA</label>
</div>

$:{CSRF()}

<div style="padding-top: 1em;">
<a class="button negative" href="/control/2fa/status">Cancel</a>
<button class="button positive">Disable 2FA</button>
</div>
</form>
</div>
55 changes: 55 additions & 0 deletions templates/control/2fa/generate_recovery_codes.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
$def with (tfa_recovery_codes, error)
$:{RENDER("common/stage_title.html", ["Generate Recovery Codes", "Two-Factor Authentication", "/control/2fa/status"])}

<div class="content form clear">
<div class="constrained">
$if error == "2fa":
<div id="signin_error">
<strong>Whoops!</strong> The 2FA response token or recovery code you provided did not validate.
</div>
$elif error == "verify":
<div id="signin_error">
<strong>Hey!</strong> Did you want to generate a new set of recovery codes? You didn't check the verification checkbox!
<strong>If entered, your recovery code was <em>not</em> consumed.</strong>
</div>

<h3>Generate New Recovery Codes</h3>
<p>
The following recovery codes are <strong>not yet active</strong>. Once you provide
a valid 2FA token or recovery code, the below list of codes will replace all recovery
codes currently associated with your account.
</p>
<p>
Before proceeding, print or save these codes to a secure place. In the event you lose access
to your authenticator app, you will need these codes to regain access to your Weasyl account.
If you no longer have access to your authenticator app, please <a href="/control/2fa/disable">disable 2FA</a>
instead. You may then set up another authenticator.
</p>
</div> <!-- /div.constrained -->

<form method="POST" class="form skinny clear" action="/control/2fa/generate_recovery_codes">
<h3>Your new recovery codes will be:</h3>
<ol>
$for code in tfa_recovery_codes:
<li>${code[0:4] + ' ' + code[4:8] + ' ' + code[8:12] + ' ' + code[12:16] + ' ' + code[16:20]}</li>
</ol>

<br />

<p>
These codes are one-time use, and upon successful use will not be able to be reused or retrieved.
Codes may be used in any order. Cross each code off when used.
</p>

<label for="tfa-regenerate-codes">Enter 2FA token or Recovery Code</label>
<input type="text" id="tfa-regenerate-codes" maxlength="24" name="tfaresponse" placeholder="012345" autocomplete="off" required /><br />
<label><input class="checkbox" type="checkbox" name="verify" required /> I confirm that I have saved the above new recovery codes.</label>

$:{CSRF()}

<div style="padding-top: 1em;">
<a class="button negative" href="/control/2fa/status">Cancel</a>
<button class="button positive">Save New Recovery Codes</button>
</div>
</form>
</div>
29 changes: 29 additions & 0 deletions templates/control/2fa/generate_recovery_codes_verify_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
$def with (error)
$:{RENDER("common/stage_title.html", ["Generate Recovery Codes: Verify Password", "Two-Factor Authentication", "/control/2fa/status"])}

<div class="content form clear">
<form method="POST" class="form skinny clear" action="/control/2fa/generate_recovery_codes_verify_password">
$if error == "password":
<div id="signin_error"><strong>Whoops!</strong> You entered an incorrect password.</div>

<h3>Verify your password</h3>
<p>
Before generating new recovery codes, we need to verify your current Weasyl password.
</p>
<br />
<p>
<strong>Note</strong>: For the security of your account, entering your password
will clear all other active login sessions for your account.
</p>

$:{CSRF()}

<label for="login-pass">Confirm your Password</label>
<input type="password" id="login-pass" class="input" name="password" placeholder="Password" required />

<div style="padding-top: 1em;">
<a class="button negative" href="/control/2fa/status">Cancel</a>
<button class="button positive">Continue</button>
</div>
</form>
</div>
50 changes: 50 additions & 0 deletions templates/control/2fa/init.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
$def with (username, error)
$:{RENDER("common/stage_title.html", ["Enable 2FA: Step 1", "Two-Factor Authentication", "/control/2fa/status"])}

<div class="content form clear">
<div class="constrained">
$if error == "password":
<div id="signin_error"><strong>Whoops!</strong> You entered an incorrect password.</div>

<h3>Two-Factor Authentication</h3>
<p>
This process will guide you through enabling Two-Factor Authentication (2FA), a method of enhancing the security of
your Weasyl account. For more information about 2FA, please visit the <a href="/help/two_factor_authentication">2FA help page</a>. As part of this
process, you will:
<ol>
<li>Confirm that you have access to this account;</li>
<li>Set-up your authenticator application; and finally</li>
<li>Be presented a set of recovery codes used to regain access if you lose your authenticator.</li>
</ol>
</p>

<hr />

<a href="#begin-setup" class="faq-permalink">Link</a>
<h3 id="begin-setup">Begin setting up 2FA</h3>

<p><strong>Username: ${username}</strong></p>
<p>
You are about to enable 2FA for the Weasyl account identified above.
In order to proceed through this process, you will need a compatible app such as
<a href="https://support.google.com/accounts/answer/1066447?hl=en">Google Authenticator</a> for Android and iOS devices,
or <a href="http://www.nongnu.org/oath-toolkit/">OATH Toolkit</a> for Linux,
to generate time-based tokens each time you log into your Weasyl account.
</p>
<p>
To begin, we need to confirm that you have control over this account. Please enter your password to continue.
</p>

<form method="POST" class="form skinny clear" action="/control/2fa/init">
$:{CSRF()}

<label for="login-pass">Confirm your Password</label>
<input type="password" id="login-pass" class="input" name="password" placeholder="Password" required />

<div style="padding-top: 1em;">
<a class="button negative" href="/control/2fa/status">Cancel</a>
<button class="button positive">Continue</button>
</div>
</form>
</div>
</div>
37 changes: 37 additions & 0 deletions templates/control/2fa/init_qrcode.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
$def with (username, tfa_secret, qrcode, error)
$:{RENDER("common/stage_title.html", ["Enable 2FA: Step 2", "Two-Factor Authentication", "/control/2fa/status"])}

<div class="content form clear">
<form method="POST" class="form skinny clear" action="/control/2fa/init_qrcode">
$if error == "2fa":
<div id="signin_error"><strong>Whoops!</strong> The 2FA response token you provided did not validate.</div>

<h3>Two-Factor Authentication</h3>
<p><strong>Username: ${username}</strong></p>
<p>
In order to proceed from this point onwards, you will need a compatible app such as
<a href="https://support.google.com/accounts/answer/1066447?hl=en">Google Authenticator</a> for Android and iOS devices,
or <a href="http://www.nongnu.org/oath-toolkit/">OATH Toolkit</a> for Linux,
to generate time-based tokens each time you log into your Weasyl account.
</p>

<h3>Scan with your authenticator</h3>
<div>
<img src="data:image/svg+xml;utf8,${qrcode}">
</div>
<h3>Or manually enter your key</h3>
<p>
Your TOTP secret key is: ${tfa_secret[0:4] + ' ' + tfa_secret[4:8] + ' ' + tfa_secret[8:12] + ' ' + tfa_secret[12:16]}
</p>

<label for="tfa-init-verify">Enter 2FA token</label>
<input type="text" id="tfa-init-verify" maxlength="7" name="tfaresponse" placeholder="012345" autocomplete="off" required />

$:{CSRF()}

<div style="padding-top: 1em;">
<a class="button negative" href="/control/2fa/status">Cancel</a>
<button class="button positive">Continue</button>
</div>
</form>
</div>
62 changes: 62 additions & 0 deletions templates/control/2fa/init_verify.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
$def with (tfa_recovery_codes, error)
$:{RENDER("common/stage_title.html", ["Enable 2FA: Final Step", "Two-Factor Authentication", "/control/2fa/status"])}

<div class="content form clear">
<div class="constrained">
$if error == "2fa":
<div id="signin_error">
<strong>Whoops!</strong> The 2FA response token you provided did not validate.
</div>
$elif error == "verify":
<div id="signin_error">
<strong>Hey!</strong> Did you want to enable 2FA? You didn't check the verification checkbox!
</div>

<h3>Final Step: Verify Receipt of Two-Factor Authentication (2FA) Recovery Codes</h3>
<h4>You've almost enabled 2FA for your account. One final step remains!</h4>

<br />

<p>
<strong>Print these codes, and secure them as you would your password.</strong>
In the event you lose access to your authenticator app, you will need these
codes to regain access to your Weasyl account. Recovery codes can be
refreshed at any time from your settings page.
</p>
<p>
<strong>Warning:</strong> For security reasons, Weasyl staff will be unable to assist you if
you lose access to your recovery codes.
<br />
As a precaution against being locked out your account if all recovery codes are used, 2FA will
automatically be disabled if--and only if--all recovery codes are consumed during the login process.
Generating a new set of codes will prevent this from occurring. If this occurs, you will need
to set-up 2FA again.
</p>
</div> <!-- /div.constrained -->

<form method="POST" class="form skinny clear" action="/control/2fa/init_verify">
<h3>Your recovery codes are:</h3>
<ol>
$for code in tfa_recovery_codes:
<li>${code[0:4] + ' ' + code[4:8] + ' ' + code[8:12] + ' ' + code[12:16] + ' ' + code[16:20]}</li>
</ol>

<br />

<p>
These codes are one-time use, and upon successful use will not be able to be reused or retrieved.
Codes may be used in any order. Cross each code off when used.
</p>

<label for="tfa-init-verify">Enter 2FA token</label>
<input type="text" id="tfa-init-verify" maxlength="7" name="tfaresponse" placeholder="012345" autocomplete="off" required /><br />
<label><input class="checkbox" type="checkbox" name="verify" required /> I have printed or saved the above recovery codes and want to enable 2FA.</label>

$:{CSRF()}

<div style="padding-top: 1em;">
<a class="button negative" href="/control/2fa/status">Cancel</a>
<button class="button positive">Enable 2FA</button>
</div>
</form>
</div>
Loading

0 comments on commit 33080b9

Please sign in to comment.