diff --git a/Dockerfile b/Dockerfile index 4657575753dd61cfdf5457aeae04baea430512f1..3f16218e32ade3d05b6c261e308532b932acfa95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,12 @@ -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/ . diff --git a/README.md b/README.md index d5bdf930a62e9309ce02938ef1631c4d2467e55c..a6207abfa1d7ad29c078cd851733885c9dbdb4cd 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/create_account.py b/app/create_account.py new file mode 100644 index 0000000000000000000000000000000000000000..204d45c9faf22c5456eec51a4a45acd2c061f517 --- /dev/null +++ b/app/create_account.py @@ -0,0 +1,93 @@ +#!/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) diff --git a/app/delete-account.html b/app/delete-account.html new file mode 100644 index 0000000000000000000000000000000000000000..b3e78e2fc7e560a67f030daf319bee9ad46f014f --- /dev/null +++ b/app/delete-account.html @@ -0,0 +1,14 @@ + + + Delete /e/ account + + +
+ Email:
+
+ Password:
+
+ +
+ + diff --git a/app/delete_account.py b/app/delete_account.py new file mode 100644 index 0000000000000000000000000000000000000000..e8979b9e1aaff662165b65c5cd4688516226f57d --- /dev/null +++ b/app/delete_account.py @@ -0,0 +1,120 @@ +#!/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") diff --git a/app/main.py b/app/main.py index 78f477c35db055fb73dd58be1cf2a5016230d94f..d4aa1dcb3f2f15d9c09fd09341efe07e0d1e7f8c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,116 +1,28 @@ #!/usr/bin/python3 import time -from http.server import BaseHTTPRequestHandler, HTTPServer -import urllib.parse -import paramiko -import requests -from requests.auth import HTTPBasicAuth -import os -import logging -import sys -import json -import shlex -from threading import Thread - -ch = logging.StreamHandler(sys.stdout) -ch.setLevel(logging.DEBUG) -logger = logging.getLogger('create-account').addHandler(ch) -paramiko.util.get_logger("paramiko").addHandler(ch) +from wsgiref.simple_server import make_server +from pyramid.config import Configurator +from create_account import create_account +from delete_account import delete_account, delete_account_form, confirm_delete_account HOST_NAME = '0.0.0.0' PORT_NUMBER = 9000 -class MyHandler(BaseHTTPRequestHandler): - def do_PUT(self): - if self.path == '/create-account' or self.path == '/create-account/': - length = int(self.headers['Content-Length']) - query = self.rfile.read(length) - postvars = dict(urllib.parse.parse_qsl(query.decode())) - if postvars['auth'] != os.environ['CREATE_ACCOUNT_PASSWORD']: - data = json.dumps({'success': False, 'message': 'no_auth'}) - self.respond(401, data) - return - - print(f'Creating account for {postvars["target_email"]}, displayname={postvars["displayname"]}, fallback email={postvars["fallback_email"]}') - - self.create_account(postvars['target_email'], postvars['password'], postvars['password_confirm'], - postvars['displayname'], postvars['email_quota'], postvars['fallback_email'], - postvars['nextcloud_quota']) - - def create_account(self, target_email: str, password: str, password_confirm: str, displayname: str, email_quota: str, - fallback_email: str, nextcloud_quota: str): - 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'}) - self.respond(403, data) - return - # 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'}) - self.respond(403, data) - return - - 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'}) - self.respond(500, data) - return - - self.respond(200, json.dumps({'success': True})) - - # 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=self.call_nextcloud_api, args=(target_email, password, fallback_email, nextcloud_quota)).start() - - def respond(self, status: int, message: str): - print(f'sending response status {status}, message {message}') - self.send_response(status) - self.end_headers() - self.wfile.write(message.encode()) - - def call_nextcloud_api(self, 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 - r1 = 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(f'status code: {r1.status_code}') - print(r2.text) - print(f'status code: {r2.status_code}') - - +# https://gitlab.e.foundation/e/infra/ecloud-selfhosting/issues/64#note_26118 +# https://docs.pylonsproject.org/projects/pyramid/en/latest/quick_tour.html if __name__ == '__main__': - server_class = HTTPServer - httpd = server_class((HOST_NAME, PORT_NUMBER), MyHandler) - print(time.asctime(), 'Server Starts - %s:%s' % (HOST_NAME, PORT_NUMBER)) - try: - httpd.serve_forever() - except (SystemExit, KeyboardInterrupt): - pass - httpd.server_close() - print(time.asctime(), 'Server Stops - %s:%s' % (HOST_NAME, PORT_NUMBER)) + with Configurator() as config: + config.add_route('create-account', '/create-account', request_method='PUT') + config.add_view(create_account, route_name='create-account') + config.add_route('delete-account', '/delete-account') + config.add_view(delete_account, route_name='delete-account') + config.add_route('delete-account-form', '/delete-account-form', request_method='POST') + config.add_view(delete_account_form, route_name='delete-account-form') + config.add_route('confirm-delete-account', '/confirm-delete-account') + config.add_view(confirm_delete_account, route_name='confirm-delete-account') + app = config.make_wsgi_app() + server = make_server(HOST_NAME, PORT_NUMBER, app) + print(time.asctime(), 'Server started - %s:%s' % (HOST_NAME, PORT_NUMBER)) + server.serve_forever() diff --git a/app/requirements.txt b/app/requirements.txt index 505dd5710f00fbff35f0c3b742274214b42ddc42..c2684e2bc6470a3ebdcc8fb81ec1e3edc0efe5f0 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,2 +1,3 @@ -paramiko>2.4, <2.5 -requests>2.19, < 2.20 \ No newline at end of file +paramiko==2.7.1 +requests==2.22.0 +pyramid==1.10.4