Implement self-service account deletion

This commit is contained in:
Drew DeVault 2022-11-08 12:24:34 +01:00
parent e3759a2e5c
commit 8f8aaa4210
13 changed files with 213 additions and 21 deletions

View File

@ -32,7 +32,7 @@ func Middleware(queue *work.Queue) func(next http.Handler) http.Handler {
}
// Schedules a user account deletion.
func Delete(ctx context.Context, userID int, username string) {
func Delete(ctx context.Context, userID int, username string, reserve bool) {
queue, ok := ctx.Value(ctxKey).(*work.Queue)
if !ok {
panic("No account worker for this context")
@ -53,6 +53,17 @@ func Delete(ctx context.Context, userID int, username string) {
wg.Wait()
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
if reserve {
_, err := tx.ExecContext(ctx, `
INSERT INTO reserved_usernames (
username
) VALUES ($1);
`, username)
if err != nil {
return err
}
}
_, err := tx.ExecContext(ctx, `
DELETE FROM "user" WHERE id = $1
`, userID)

View File

@ -1143,7 +1143,6 @@ var usernameBlacklist []string = []string{
"shop",
"signin",
"signup",
"sircmpwn",
"sirhat",
"sirhit",
"site",

View File

@ -530,5 +530,5 @@ type Mutation {
"""
Deletes the authenticated user's account.
"""
deleteUser: Int! @internal
deleteUser(reserve: Boolean!): Int! @internal
}

View File

@ -712,7 +712,21 @@ func (r *mutationResolver) RegisterAccount(ctx context.Context, email string, us
var user model.User
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
var reserved string
row := tx.QueryRowContext(ctx, `
SELECT * FROM reserved_usernames WHERE username = $1;
`, username)
if err := row.Scan(&reserved); err == nil {
valid.Expect(false, "This username is not available").
WithField("username")
return errors.New("placeholder") // Roll back transaction
} else if err == sql.ErrNoRows {
// no-op
} else if err != nil {
return err
}
row = tx.QueryRowContext(ctx, `
INSERT INTO "user" (
created, updated, username, email, user_type, password,
confirmation_hash, invites
@ -1206,9 +1220,9 @@ func (r *mutationResolver) SendEmailNotification(ctx context.Context, message st
}
// DeleteUser is the resolver for the deleteUser field.
func (r *mutationResolver) DeleteUser(ctx context.Context) (int, error) {
func (r *mutationResolver) DeleteUser(ctx context.Context, reserve bool) (int, error) {
user := auth.ForContext(ctx)
account.Delete(ctx, user.UserID, user.Username)
account.Delete(ctx, user.UserID, user.Username, reserve)
return user.UserID, nil
}

View File

@ -0,0 +1,31 @@
"""Add reserved accounts table
Revision ID: 20ca7d8cb982
Revises: 8bf166ebda01
Create Date: 2022-11-08 11:59:30.633263
"""
# revision identifiers, used by Alembic.
revision = '20ca7d8cb982'
down_revision = '8bf166ebda01'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.execute("""
CREATE TABLE reserved_usernames (
username varchar NOT NULL
);
CREATE INDEX reserved_usernames_ix ON reserved_usernames(username);
""")
def downgrade():
op.execute("""
DROP INDEX reserved_usernames_ix;
DROP TABLE reserved_usernames;
""")

View File

@ -223,7 +223,10 @@ def login_GET():
if current_user:
return redirect("/")
return_to = request.args.get('return_to')
return render_template("login.html", return_to=return_to)
context = session.get("login_context")
return render_template("login.html",
return_to=return_to,
login_context=context)
def get_challenge(factor):
if factor.factor_type == FactorType.totp:
@ -263,6 +266,7 @@ def login_POST():
factors = (UserAuthFactor.query
.filter(UserAuthFactor.user_id == user.id)).all()
session.pop("login_context", None)
if any(factors):
session['extra_factors'] = [f.id for f in factors]
session['authorized_user'] = user.id
@ -271,6 +275,8 @@ def login_POST():
return get_challenge(factors[0])
login_user(user, set_cookie=True)
print("session_login = True")
session["session_login"] = True
audit_log("logged in")
print(f"Logged in account: {user.username} ({user.email})")
db.session.commit()
@ -335,6 +341,7 @@ def totp_challenge_POST():
if challenge_type == "login":
login_user(user, set_cookie=True)
session["session_login"] = True
audit_log("logged in")
print(f"Logged in account: {user.username} ({user.email})")
db.session.commit()
@ -410,6 +417,7 @@ def totp_recovery_POST():
if challenge_type == "login":
login_user(user, set_cookie=True)
session["session_login"] = True
audit_log("logged in")
print(f"Logged in account: {user.username} ({user.email})")
db.session.commit()
@ -492,6 +500,7 @@ def reset_POST(token):
audit_log("password reset", user=user, email=True,
subject=f"Your {cfg('sr.ht', 'site-name')} password has been reset",
email_details="Account password reset")
session["session_login"] = True
login_user(user, set_cookie=True)
print(f"Reset password: {user.username} ({user.email})")
metrics.meta_pw_resets.inc()

View File

@ -1,9 +1,10 @@
from flask import Blueprint, Response, render_template, request, abort
from flask import redirect, url_for, session
from flask import redirect, url_for
from metasrht.types import User, UserAuthFactor, FactorType
from srht.config import cfg
from srht.flask import session
from srht.database import db
from srht.oauth import current_user, loginrequired, login_user
from srht.oauth import current_user, loginrequired, login_user, logout_user
from srht.graphql import exec_gql
from srht.validation import Validation
@ -60,3 +61,43 @@ def profile_POST():
user = User.query.filter(User.id == resp["updateUser"]["id"]).one()
login_user(user, set_cookie=True)
return redirect(url_for(".profile_GET"))
@profile.route("/profile/delete")
@loginrequired
def profile_delete_GET():
print(session.get("session_login"))
if not session.get("session_login"):
logout_user()
session["login_context"] = "You must re-authenticate before deleting your account."
return redirect(url_for("auth.login_GET",
return_to=url_for("profile.profile_delete_POST")))
return render_template("profile-delete.html")
@profile.route("/profile/delete", methods=["POST"])
@loginrequired
def profile_delete_POST():
if not session.get("session_login"):
logout_user()
session["login_context"] = "You must re-authenticate before deleting your account."
return redirect(url_for("auth.login_GET",
return_to=url_for("profile.profile_delete_POST")))
valid = Validation(request)
confirm = valid.require("confirm")
valid.expect(confirm == "on", "You must confirm you really want to delete this account.")
reserve = valid.optional("reserve-username")
reserve = reserve == "on"
if not valid.ok:
return render_template("profile-delete.html", **valid.kwargs)
r = exec_gql("meta.sr.ht", """
mutation DeleteUser($reserve: Boolean!) {
deleteUser(reserve: $reserve)
}
""", reserve=reserve)
logout_user()
return redirect(url_for(".profile_deleted_GET"))
@profile.route("/profile/deleted")
def profile_deleted_GET():
return render_template("profile-deleted.html")

View File

@ -293,13 +293,6 @@ def user_delete_POST(username):
if request.form.get("safe-2") != "on":
return redirect(url_for(".user_by_username_GET", username=username))
user = User.query.filter(User.username == username).one_or_none()
details = f"User account deleted by an administrator"
audit_log(details, details=details, user=user, email=True,
subject="Your account has been deleted",
email_details=details)
db.session.rollback()
r = exec_gql("meta.sr.ht", """
mutation {
deleteUser

View File

@ -17,6 +17,11 @@
<div class="col-md-6 offset-md-3">
<form method="POST" action="/login">
{{csrf_token()}}
{% if login_context %}
<div class="alert alert-info">
{{login_context}}
</div>
{% endif %}
<div class="form-group">
<label for="username">Username</label>
<input

View File

@ -0,0 +1,54 @@
{% extends "meta.html" %}
{% block title %}
<title>Delete your account - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<h3>Delete your {{cfg("sr.ht", "site-name")}} account</h3>
<form method="POST" action="{{url_for(".profile_delete_POST")}}">
{{csrf_token()}}
<p>
Before you delete your account, you may want to
<a href="https://sr.ht/~emersion/hut">export your account data</a>. If
you choose to prevent future registrations with your username, no
future accounts (including yourself) will be able to sign up with that
username.
</p>
<div class="form-group form-check">
<input
type="checkbox"
class="form-check-input"
name="confirm"
id="confirm">
<label class="form-check-label" for="confirm">
I really want to permanently delete my account
</label>
{{valid.summary("confirm")}}
</div>
<div class="form-group form-check">
<input
type="checkbox"
class="form-check-input"
name="reserve-username"
id="reserve-username">
<label class="form-check-label" for="reserve-username">
Prevent future registrations with the name "{{current_user.username}}"
</label>
</div>
{{valid.summary()}}
<div class="alert alert-danger">
<strong>Notice</strong>: Clicking "confirm account deletion" will
<strong>permanently</strong> remove your account, your projects, and
all of your personal data on our services. This cannot be undone.
</div>
<button type="submit" class="btn btn-danger">
Confirm account deletion {{icon('caret-right')}}
</button>
<a href="{{url_for(".profile_GET")}}" class="btn btn-default">
Nevermind {{icon("caret-right")}}
</a>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "layout.html" %}
{% block title %}
<title>Your {{cfg("sr.ht", "site-name")}} account has been deleted</title>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2" style="margin-top: 10rem">
<div class="alert alert-success">
Your {{cfg("sr.ht", "site-name")}} account is being deleted.
No further action is required on your part.
</div>
<p class="text-center">👋</p>
</div>
</div>
{% endblock %}

View File

@ -3,13 +3,9 @@
<title>Profile - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-12">
<h3>User Profile</h3>
</div>
</div>
<div class="row">
<div class="col-lg-6">
<h3>Edit your profile</h3>
<form method="POST" action="/profile">
{{csrf_token()}}
<div class="form-group">
@ -70,9 +66,28 @@
{{valid.summary("bio")}}
</div>
<button type="submit" class="btn btn-primary pull-right">
Save {{icon("caret-right")}}
Save changes {{icon("caret-right")}}
</button>
</form>
</div>
<div class="col-lg-6">
<h3>Export your data</h3>
<p>
You may export your account data in standard formats using the
<a href="https://sr.ht/~emersion/hut">hut tool</a>. This data can be
imported into another SourceHut instance or used with any compatible
software (e.g. git, GNU Mailman, etc).
</p>
<h3>Close your account</h3>
<p>
To close your account, permanently removing your projects and all
personal data from our services, you may do so here. This button will
bring you to a confirmation page.
</p>
<a href="{{url_for(".profile_delete_GET")}}" class="btn btn-danger">
Delete my account {{icon('caret-right')}}
</a>
</div>
</div>
{% endblock %}

View File

@ -4,6 +4,11 @@ img {
border: 1px solid #eceeef;
}
input[type="checkbox"] {
position: inherit;
top: inherit;
}
.progress {
position: relative;
overflow: visible;