Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit acf0c92e authored by Felix's avatar Felix
Browse files

Merge branch 'delete-account' into 'master'

Implement account deletion

See merge request !4
parents 49f6b593 41c602a0
Loading
Loading
Loading
Loading
Loading
+4 −5
Original line number Diff line number Diff line
FROM python:3-alpine3.9
# Use Debian-based image for faster build
# https://pythonspeed.com/articles/alpine-docker-python/
FROM python:3-slim-buster

WORKDIR /usr/src/app

COPY app/requirements.txt .

RUN apk add --no-cache openssl \
 && apk add --no-cache --virtual build-deps build-base libffi-dev openssl-dev \
 && pip install --no-cache-dir -r requirements.txt \
 && apk del build-deps
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ .

+5 −0
Original line number Diff line number Diff line
@@ -12,6 +12,11 @@ values via Nextcloud.
      - POSTFIXADMIN_SSH_PASSWORD=${POSTFIXADMIN_SSH_PASSWORD}
      - DOMAIN=${DOMAIN}
      - CREATE_ACCOUNT_PASSWORD=${CREATE_ACCOUNT_PASSWORD}
      - SMTP_HOST=${SMTP_HOST}
      - SMTP_FROM=${SMTP_FROM}
      - SMTP_PASSWORD=${SMTP_PASSWORD}
    volumes:
      - /mnt/repo-base/volumes/create-account:/data
    networks:
      - serverbase
    depends_on:

app/create_account.py

0 → 100644
+93 −0
Original line number Diff line number Diff line
#!/usr/bin/python3

import os
import sys
import json
import shlex
from threading import Thread
import logging
import requests
from requests.auth import HTTPBasicAuth
import paramiko
from pyramid.response import Response
from pyramid.request import Request

ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG)
logging.getLogger('create-account').addHandler(ch)
paramiko.util.get_logger("paramiko").addHandler(ch)


def create_account(request: Request) -> Response:
    if request.params['auth'] != os.environ['CREATE_ACCOUNT_PASSWORD']:
        data = json.dumps({'success': False, 'message': 'no_auth'})
        return respond(401, data)

    print(f'Creating account for {request.params["target_email"]}, displayname='
          f'{request.params["displayname"]}, fallback email={request.params["fallback_email"]}')

    return create_account_perform(request.params['target_email'], request.params['password'],
                                  request.params['password_confirm'], request.params['displayname'],
                                  request.params['email_quota'], request.params['fallback_email'],
                                  request.params['nextcloud_quota'])


def create_account_perform(target_email: str, password: str, password_confirm: str,
                           displayname: str, email_quota: str, fallback_email: str,
                           nextcloud_quota: str) -> Response:
    target_username = target_email.split('@')[0]
    with open('forbidden_usernames') as f:
        if target_username in f.read().splitlines():
            data = json.dumps({'success': False, 'message': 'username_forbidden'})
            return respond(403, data)
    # create account via postfixadmin ssh
    with paramiko.SSHClient() as ssh:
        try:
            ssh.set_missing_host_key_policy(paramiko.MissingHostKeyPolicy)
            ssh.connect(hostname='postfixadmin', username='pfexec', password=os.environ['POSTFIXADMIN_SSH_PASSWORD'])

            stdin, stdout, stderr = ssh.exec_command(
                f'/postfixadmin/scripts/postfixadmin-cli mailbox view {shlex.quote(target_email)}')
            if b"error: the email is not valid!" not in stderr.read().lower():
                data = json.dumps({'success': False, 'message': 'username_taken'})
                return respond(403, data)

            stdin, stdout, stderr = ssh.exec_command(
                f'/postfixadmin/scripts/postfixadmin-cli mailbox add {shlex.quote(target_email)} ' +
                f'--password {shlex.quote(password)} --password2 {shlex.quote(password_confirm)} ' +
                f'--name {shlex.quote(displayname)} --quota {shlex.quote(email_quota)} --active 1 ' +
                f'--welcome-mail 0 --email-other {shlex.quote(fallback_email)}')
            print(stdout.read())
            print(stderr.read())
        except (ssh.SSHException, ssh.AuthenticationException, ssh.socket.error, ssh.BadHostKeyException) as e:
            print(e)
            data = json.dumps({'success': False, 'message': 'internal_error'})
            return respond(500, data)

    # Run Nextcloud API call in seperate thread. Needed because the API calls take 50+ seconds
    # each for unknown reasons. Instead we just run them in the background.
    Thread(target=call_nextcloud_api, args=(target_email, password, fallback_email, nextcloud_quota)).start()

    return respond(200, json.dumps({'success': True}))


def respond(status: int, message: str) -> Response:
    print(f'sending response status {status}, message {message}')
    return Response(status=status, body=message)


def call_nextcloud_api(target_email: str, password: str, fallback_email: str, nextcloud_quota: str):
    # Nextcloud only creates external accounts after the first login. We login through the API
    # to trigger the account creation.
    headers = {'OCS-APIRequest': 'true'}
    auth = HTTPBasicAuth(target_email, password)
    url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{target_email}'
    requests.put(url, headers=headers, auth=auth)

    # Edit nextcloud account, set quota and email
    auth = HTTPBasicAuth(os.environ['NEXTCLOUD_ADMIN_USER'], os.environ['NEXTCLOUD_ADMIN_PASSWORD'])
    url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{target_email.lower()}'
    r1 = requests.put(url, data={'key': 'email', 'value': fallback_email}, headers=headers, auth=auth)
    r2 = requests.put(url, data={'key': 'quota', 'value': nextcloud_quota}, headers=headers, auth=auth)
    print(r1.text)
    print(r2.text)
+14 −0
Original line number Diff line number Diff line
<html>
<head>
    <title>Delete /e/ account</title>
</head>
<body>
    <form action="/delete-account-form" method="post">
        Email:<br>
        <input type="text" name="email"><br>
        Password:<br>
        <input type="password" name="password"><br>
        <input type="submit" value="Submit">
    </form>
</body>
</html>

app/delete_account.py

0 → 100644
+120 −0
Original line number Diff line number Diff line
#!/usr/bin/python3

import os
import time
import shlex
import random
import string
import sqlite3
from sqlite3 import Error, Connection
import smtplib
from xml.dom import minidom
import requests
from requests.auth import HTTPBasicAuth
import paramiko
from pyramid.response import Response, FileResponse
from pyramid.request import Request


SQLITE_DATABASE = r"/data/database.sqlite"
CONFIRMATION_LINK_VALIDITY_DURATION_SECONDS = 24 * 60 * 60


def create_connection() -> Connection:
    try:
        conn = sqlite3.connect(SQLITE_DATABASE)
        conn.execute("""CREATE TABLE IF NOT EXISTS confirm_delete_codes (
                        id INTEGER PRIMARY KEY,
                        user_email TEXT NOT NULL,
                        confirmation_code TEXT NOT NULL,
                        creation_timestamp INTEGER NOT NULL
                        ); """)
        return conn
    except Error as e:
        print(e)


def delete_account(request: Request) -> Response:
    return FileResponse(path='delete-account.html', content_type='text/html')


def delete_account_form(request: Request) -> Response:
    email = request.params['email']
    password = request.params['password']
    url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{email}'
    r = requests.get(url, headers={'OCS-APIRequest': 'true'}, auth=HTTPBasicAuth(email, password))
    xmldoc = minidom.parseString(r.text)
    if xmldoc.getElementsByTagName('statuscode')[0].lastChild.nodeValue == '100':
        confirmation_code = ''.join(random.choice(string.ascii_lowercase) for i in range(20))
        conn = create_connection()
        conn.execute(
            "INSERT INTO confirm_delete_codes(user_email, confirmation_code, creation_timestamp) VALUES (?, ?, ?)",
            (email, confirmation_code, int(time.time())))
        conn.commit()
        conn.close()
        confirmation_link = f'https://{os.environ["DOMAIN"]}/confirm-delete-account?email={email}&confirmation_code={confirmation_code}'
        print('confirmation_link=' + confirmation_link)
        fallback_email = xmldoc.getElementsByTagName('email')[0].lastChild.nodeValue
        return send_confirmation_email(fallback_email, confirmation_link)
    else:
        return Response(status=403, body="Invalid email or password")


def send_confirmation_email(email: str, confirmation_link: str):
    sender = os.environ['SMTP_FROM']
    message = f"""From: {sender}
    To: {email}
    Subject: /e/ account deletion

    Please click the following link to delete your account. If you do not want to delete your account, 
    ignore this email.

    {confirmation_link}
    """
    try:
        smtp = smtplib.SMTP(os.environ['SMTP_HOST'])
        smtp.connect(os.environ['SMTP_HOST'])
        smtp.ehlo()
        smtp.starttls()
        smtp.ehlo()
        smtp.login(user=sender, password=os.environ['SMTP_PASSWORD'])
        smtp.sendmail(sender, [email], message)
        smtp.quit()
        return Response(status=200, body=f'Sent confirmation email to {email}')
    except smtplib.SMTPException as e:
        print(e)
        return Response(status=500, body=f'Failed to send confirmation email')


def confirm_delete_account(request: Request) -> Response:
    email = request.params['email']
    confirmation_code = request.params['confirmation_code']
    conn = create_connection()
    c = conn.execute(
        "SELECT EXISTS(SELECT 1 FROM confirm_delete_codes WHERE user_email=? AND confirmation_code=? AND creation_timestamp>?)",
        (email, confirmation_code, int(time.time()) - CONFIRMATION_LINK_VALIDITY_DURATION_SECONDS))
    exists = c.fetchone()[0]
    conn.close()
    if exists == 1:
        auth = HTTPBasicAuth(os.environ['NEXTCLOUD_ADMIN_USER'], os.environ['NEXTCLOUD_ADMIN_PASSWORD'])
        url = f'https://{os.environ["DOMAIN"]}/ocs/v1.php/cloud/users/{email.lower()}'
        r1 = requests.delete(url, headers={'OCS-APIRequest': 'true'}, auth=auth)
        print(r1.text)
        xmldoc = minidom.parseString(r1.text)
        if xmldoc.getElementsByTagName('statuscode')[0].lastChild.nodeValue != '100':
            return Response(status=500, body="Internal server error")
        with paramiko.SSHClient() as ssh:
            try:
                ssh.set_missing_host_key_policy(paramiko.MissingHostKeyPolicy)
                ssh.connect(hostname='postfixadmin', username='pfexec', password=os.environ['POSTFIXADMIN_SSH_PASSWORD'])

                stdin, stdout, stderr = ssh.exec_command(
                    f'/postfixadmin/scripts/postfixadmin-cli mailbox delete {shlex.quote(email)}')
                print(stdout.read())
                print(stderr.read())
            except (ssh.SSHException, ssh.AuthenticationException, ssh.socket.error, ssh.BadHostKeyException) as e:
                print(e)
                return Response(status=500, body="Internal server error")
        return Response(status=200, body="Account successfully deleted")
    else:
        return Response(status=403, body="Invalid email or confirmation code")
Loading