Implement self-service account deletion
This commit is contained in:
parent
e3759a2e5c
commit
8f8aaa4210
|
@ -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)
|
||||
|
|
|
@ -1143,7 +1143,6 @@ var usernameBlacklist []string = []string{
|
|||
"shop",
|
||||
"signin",
|
||||
"signup",
|
||||
"sircmpwn",
|
||||
"sirhat",
|
||||
"sirhit",
|
||||
"site",
|
||||
|
|
|
@ -530,5 +530,5 @@ type Mutation {
|
|||
"""
|
||||
Deletes the authenticated user's account.
|
||||
"""
|
||||
deleteUser: Int! @internal
|
||||
deleteUser(reserve: Boolean!): Int! @internal
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
""")
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -4,6 +4,11 @@ img {
|
|||
border: 1px solid #eceeef;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
position: inherit;
|
||||
top: inherit;
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
|
Loading…
Reference in New Issue