~fnux/meta.sr.ht

a3a131f1f4ec5f4c00494f31509e68310190b319 — Drew DeVault 9 months ago 88d2d5b 0.49.0
Prompt for 2FA to reset password

Previously this would just tell the user to contact support instead.
M metasrht/blueprints/auth.py => metasrht/blueprints/auth.py +62 -42
@@ 52,6 52,22 @@ def validate_return_url(return_to):
        return return_to
    return "/"

def issue_reset(user):
    rh = user.gen_reset_hash()
    db.session.commit()
    encrypt_key = None
    if user.pgp_key:
        encrypt_key = user.pgp_key.key
    send_email("reset_pw", user.email,
            f"Reset your password on {site_name}",
            headers={
                "From": f"{cfg('mail', 'smtp-from')}",
                "To": f"{user.username} <{user.email}>",
                "Reply-To": f"{cfg('sr.ht', 'owner-name')} <{cfg('sr.ht', 'owner-email')}>",
            }, user=user, encrypt_key=encrypt_key)
    audit_log("password reset requested", user=user)
    return render_template("forgot.html", done=True)

@auth.route("/")
def index():
    if current_user:


@@ 258,6 274,7 @@ def login_POST():
    if any(factors):
        session['extra_factors'] = [f.id for f in factors]
        session['authorized_user'] = user.id
        session['challenge_type'] = 'login'
        session['return_to'] = return_to
        return get_challenge(factors[0])



@@ 273,12 290,14 @@ def totp_challenge_GET():
    user = session.get('authorized_user')
    if not user:
        return redirect("/login")
    return render_template("totp-challenge.html")
    challenge_type = session.get('challenge_type')
    return render_template("totp-challenge.html", challenge_type=challenge_type)

@auth.route("/login/challenge/totp", methods=["POST"])
def totp_challenge_POST():
    user_id = session.get('authorized_user')
    factors = session.get('extra_factors')
    challenge_type = session.get('challenge_type')
    return_to = session.get('return_to') or '/'
    if not user_id or not factors:
        return redirect("/login")


@@ 316,17 335,23 @@ def totp_challenge_POST():
    if len(factors) != 0:
        return get_challenge(UserAuthFactor.query.get(factors[0]))

    del session['authorized_user']
    del session['extra_factors']
    del session['return_to']
    session.pop('authorized_user', None)
    session.pop('extra_factors', None)
    session.pop('challenge_type', None)
    session.pop('return_to', None)

    login_user(user, set_cookie=True)
    audit_log("logged in")
    print(f"Logged in account: {user.username} ({user.email})")
    db.session.commit()
    metrics.meta_logins_success.inc()
    return_to = validate_return_url(return_to)
    return redirect(return_to)
    if challenge_type == "login":
        login_user(user, set_cookie=True)
        audit_log("logged in")
        print(f"Logged in account: {user.username} ({user.email})")
        db.session.commit()
        metrics.meta_logins_success.inc()
        return_to = validate_return_url(return_to)
        return redirect(return_to)
    elif challenge_type == "reset":
        return issue_reset(user)
    else:
        raise NotImplemented

@auth.route("/login/challenge/totp-recovery")
def totp_recovery_GET():


@@ 342,6 367,7 @@ def totp_recovery_GET():
def totp_recovery_POST():
    user_id = session.get('authorized_user')
    factors = session.get('extra_factors')
    challenge_type = session.get('challenge_type')
    return_to = session.get('return_to') or '/'
    if not user_id or not factors:
        return redirect("/login")


@@ 372,19 398,25 @@ def totp_recovery_POST():
    if len(factors) != 0:
        return get_challenge(UserAuthFactor.query.get(factors[0]))

    del session['authorized_user']
    del session['extra_factors']
    del session['return_to']
    session.pop('authorized_user', None)
    session.pop('extra_factors', None)
    session.pop('return_to', None)
    session.pop('challenge_type', None)

    user = User.query.get(user_id)

    login_user(user, set_cookie=True)
    audit_log("logged in")
    print(f"Logged in account: {user.username} ({user.email})")
    db.session.commit()
    metrics.meta_logins_success.inc()
    return_to = validate_return_url(return_to)
    return redirect(return_to)
    if challenge_type == "login":
        login_user(user, set_cookie=True)
        audit_log("logged in")
        print(f"Logged in account: {user.username} ({user.email})")
        db.session.commit()
        metrics.meta_logins_success.inc()
        return_to = validate_return_url(return_to)
        return redirect(return_to)
    elif challenge_type == "reset":
        return issue_reset(user)
    else:
        raise NotImplemented

@auth.route("/logout")
def logout():


@@ 417,28 449,16 @@ def forgot_POST():
            "You can't reset the password of an admin.")
    if not valid.ok:
        return render_template("forgot.html", **valid.kwargs)

    factors = (UserAuthFactor.query
        .filter(UserAuthFactor.user_id == user.id)
    ).all()
    valid.expect(not any(f for f in factors if f.factor_type in [
        FactorType.totp, FactorType.u2f
    ]), "This account has two-factor authentication enabled, contact support.")
    if not valid.ok:
        return render_template("forgot.html", **valid.kwargs)
    rh = user.gen_reset_hash()
    db.session.commit()
    encrypt_key = None
    if user.pgp_key:
        encrypt_key = user.pgp_key.key
    send_email("reset_pw", user.email,
            f"Reset your password on {site_name}",
            headers={
                "From": f"{cfg('mail', 'smtp-from')}",
                "To": f"{user.username} <{user.email}>",
                "Reply-To": f"{cfg('sr.ht', 'owner-name')} <{cfg('sr.ht', 'owner-email')}>",
            }, user=user, encrypt_key=encrypt_key)
    audit_log("password reset requested", user=user)
    return render_template("forgot.html", done=True)
        .filter(UserAuthFactor.user_id == user.id)).all()
    if any(factors):
        session['extra_factors'] = [f.id for f in factors]
        session['authorized_user'] = user.id
        session['challenge_type'] = 'reset'
        return get_challenge(factors[0])

    return issue_reset(user)

@auth.route("/reset-password/<token>")
def reset_GET(token):

M metasrht/templates/forgot.html => metasrht/templates/forgot.html +4 -8
@@ 5,13 5,7 @@
{% block content %}
<div class="row">
  <div class="col-md-8">
    <h3>
      Reset password
    </h3>
  </div>
</div>
<div class="row">
  <div class="col-md-6">
    <h3>Reset password</h3>
    {% if is_external_auth() %}
    Password reset is disabled because {{cfg("sr.ht", "site-name")}}
    authentication is managed by a different service.


@@ 35,7 29,9 @@
        {{valid.summary("email")}}
      </div>
      {{valid.summary()}}
      <button class="btn btn-default pull-right" type="submit">Reset</button>
      <button class="btn btn-primary pull-right" type="submit">
        Continue {{icon('caret-right')}}
      </button>
    </form>
    {% endif %}
  </div>

M metasrht/templates/reset.html => metasrht/templates/reset.html +2 -2
@@ 26,8 26,8 @@
        {{valid.summary("password")}}
      </div>
      {{valid.summary()}}
      <button class="btn btn-default pull-right" type="submit">
        Reset password
      <button class="btn btn-primary pull-right" type="submit">
        Reset password {{icon('caret-right')}}
      </button>
    </form>
  </div>

M metasrht/templates/security.html => metasrht/templates/security.html +49 -44
@@ 4,55 4,60 @@
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-md-12">
    <h3>Two-factor auth</h3>
    <h4>TOTP</h4>
    {% if totp %}
    <p>
      <strong>Enabled</strong> on your account since {{totp.created | date}}.
    </p>
    <p>
      <form method="POST" action="/security/totp/disable">
  <div class="col-md-12 event-list">
    <div class="event">
      <h3>Two-factor auth</h3>
      <p>
        Two-factor authentication increases the security of your account by
        requiring you to complete a secondary challenge in addition to
        providing your password on login. Use of 2FA is strongly recommended.
      </p>
      <h4>TOTP</h4>
      {% if totp %}
      <div>
        <strong>Enabled</strong> on your account since {{totp.created | date}}.
        <form method="POST" action="/security/totp/disable" class="d-inline">
          {{csrf_token()}}
          <button class="btn btn-link" type="submit">
            Disable {{icon('caret-right')}}
          </button>
        </form>
      </div>
      {% else %}
      <p>
        <strong>Disabled</strong> on your account. Enable this and we'll prompt
        you for a TOTP code each time you log in.
      </p>
      <p>
        <a href="/security/totp/enable">
          <button class="btn btn-primary" type="submit">
            Enable TOTP
            {{icon('caret-right')}}
          </button>
        </a>
      </p>
      {% endif %}
    </div>
    <div class="event">
      <h3>Change your password</h3>
      <p>A link to complete the process will be sent to the email on file for
      your account ({{current_user.email}}).</p>
      {% if not is_external_auth() %}
      <form method="POST" action="/forgot">
        {{csrf_token()}}
        <input type="hidden" name="email" value="{{current_user.email}}" />
        <button class="btn btn-default" type="submit">
          Disable TOTP
          {{icon('caret-right')}}
          Send reset link {{icon('caret-right')}}
        </button>
      </form>
    </p>
    {% else %}
    <p>
      <strong>Disabled</strong> on your account. Enable this and we'll prompt
      you for a TOTP code each time you log in.
    </p>
    <p>
      <a href="/security/totp/enable">
        <button class="btn btn-primary" type="submit">
          Enable TOTP
          {{icon('caret-right')}}
        </button>
      </a>
    </p>
    {% endif %}
  </section>
  <section class="col-md-12">
    <h3>Reset your password</h3>
    {% if not is_external_auth() %}
    <form method="POST" action="/forgot">
      {{csrf_token()}}
      <input type="hidden" name="email" value="{{current_user.email}}" />
      <button class="btn btn-default" type="submit">
        Send reset link
        {{icon('caret-right')}}
      </button>
    </form>
    {% else %}
    Password reset is disabled because {{cfg("sr.ht", "site-name")}}
    authentication is managed by a different service.
    {% endif %}
  </section>
      {% else %}
      Password reset is disabled because {{cfg("sr.ht", "site-name")}}
      authentication is managed by a different service.
      {% endif %}
    </div>
  </div>
  <section class="col-md-12">
    <h3>Audit Log</h3>
    <h3>Account event log</h3>
    <table class="table">
      <thead>
        <tr>

M metasrht/templates/totp-challenge.html => metasrht/templates/totp-challenge.html +5 -1
@@ 13,6 13,10 @@
<div class="row">
  <div class="col-md-8">
    <p>
      {% if challenge_type == "reset" %}
      This account has two-factor authentication enabled. You must complete a
      verification challenge in order to reset your password.
      {% endif %}
      Please enter your TOTP code to continue:
    </p>
    <form method="POST" action="/login/challenge/totp">


@@ 32,7 36,7 @@
      <div class="alert alert-info">
        If you have lost access to your 2FA device, you may
        <a href="{{url_for("auth.totp_recovery_GET")}}">use a recovery code</a>
        instead.
        instead. Otherwise, <a href="mailto:{{owner_email}}">contact support</a>.
      </div>
      <button class="btn btn-primary" type="submit">
        Continue {{icon('caret-right')}}

M scss/main.scss => scss/main.scss +4 -0
@@ 45,3 45,7 @@ input[type="date"], input[type="number"] {
  padding-left: 0;
  padding-right: 0;
}

.event h4 {
  margin: 1rem 0;
}