e2e/src/server.py (view raw)
1#!/usr/bin/env python3
2
3import cgi
4import ntpath
5import os
6import signal
7import smtplib
8from email.mime.application import MIMEApplication
9from email.mime.multipart import MIMEMultipart
10from email.mime.text import MIMEText
11from hashlib import scrypt
12from http import HTTPStatus, cookies
13from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
14
15from itsdangerous import URLSafeTimedSerializer
16
17SERVER_PORT = int(os.getenv('SERVER_PORT', '8080'))
18SMTP_HOST = os.getenv('SMTP_HOST', 'localhost:1025')
19USER_NAME = os.getenv('USER_NAME')
20USER_MAIL = os.getenv('USER_MAIL', 'user@example')
21USER_SALT = os.getenv('USER_SALT', '').encode() or \
22 os.urandom(32).hex().encode()
23USER_HASH = bytes.fromhex(os.getenv('USER_HASH', '')) or \
24 scrypt('password'.encode(), salt=USER_SALT, n=2, r=8, p=1)
25SECRET_KEY = os.getenv('SECRET_KEY') or os.urandom(32).hex()
26SESSION_SECURE = os.getenv('SESSION_SECURE')
27SESSION_MAX_AGE = int(os.getenv('SESSION_MAX_AGE', 0)) or None
28HTML_DIR = os.path.dirname(__file__) + '/html'
29
30# Monkey-patch Python 3.7 cookies module to support the SameSite attribute:
31cookies.Morsel._reserved[str('samesite')] = str('SameSite')
32
33
34class MailRequestHandler(SimpleHTTPRequestHandler):
35
36 def get_sender(self):
37 if USER_NAME:
38 return '%s <%s>' % USER_NAME, USER_MAIL
39 else:
40 return USER_MAIL
41
42 def send_mail(self, **kwargs):
43 msg = MIMEMultipart()
44 msg['From'] = self.get_sender()
45 msg['To'] = kwargs.get('recipient', '')
46 msg['Subject'] = kwargs.get('subject', '')
47 msg.attach(MIMEText(kwargs.get('content', '')))
48 for attachment in kwargs.get('attachments', []):
49 if not attachment.filename:
50 break
51 part = MIMEApplication(attachment.file.read())
52 if attachment.type:
53 part.set_type(attachment.type)
54 part.add_header(
55 'Content-Disposition',
56 'attachment',
57 # Remove Windows directory path added by IE:
58 filename=ntpath.basename(attachment.filename)
59 )
60 msg.attach(part)
61 smtp = smtplib.SMTP(SMTP_HOST)
62 smtp.send_message(msg)
63 smtp.quit()
64
65 def get_form_data(self):
66 return cgi.FieldStorage(
67 fp=self.rfile,
68 headers=self.headers,
69 environ={
70 'REQUEST_METHOD': self.command,
71 'CONTENT_TYPE': self.headers.get('content-type'),
72 }
73 )
74
75 def get_attachments(self, form):
76 if 'attachments' in form:
77 if type(form['attachments']) is list:
78 return form['attachments']
79 else:
80 return [form['attachments']]
81 else:
82 return []
83
84 def create_cookie(self, value, max_age=None):
85 cookie = cookies.SimpleCookie()
86 cookie['session'] = value
87 cookie['session']['path'] = '/'
88 cookie['session']['httponly'] = True
89 cookie['session']['samesite'] = 'Strict'
90 if SESSION_SECURE:
91 cookie['session']['secure'] = True
92 if max_age is not None:
93 cookie['session']['max-age'] = max_age
94 return cookie
95
96 def login(self, **kwargs):
97 if kwargs.get('email', '') != USER_MAIL:
98 return None
99 # Limit password length to 1024:
100 password = kwargs.get('password', '')[0:1024].encode()
101 password_hash = scrypt(password, salt=USER_SALT, n=2, r=8, p=1)
102 if password_hash != USER_HASH:
103 return None
104 auth = URLSafeTimedSerializer(SECRET_KEY).dumps({'email': USER_MAIL})
105 return self.create_cookie(auth, SESSION_MAX_AGE)
106
107 def logout(self):
108 return self.create_cookie(0, 0)
109
110 def redirect(self, location, cookie=None):
111 self.send_response(HTTPStatus.FOUND)
112 if cookie:
113 self.send_header('Set-Cookie', cookie.output(header='', sep=''))
114 self.send_header('Location', location)
115 self.end_headers()
116
117 def authenticate(self):
118 try:
119 cookie = cookies.SimpleCookie(self.headers.get('cookie'))
120 session = cookie['session'].value
121 auth = URLSafeTimedSerializer(SECRET_KEY).loads(
122 session, max_age=SESSION_MAX_AGE)
123 if auth['email'] != USER_MAIL:
124 raise Exception('Email %s does not match %s' %
125 auth['email'], USER_MAIL)
126 except Exception as err:
127 self.log_error('code %d, message %s',
128 HTTPStatus.UNAUTHORIZED, str(err))
129 self.redirect('/login.html')
130 return False
131 return True
132
133 def handle_login(self):
134 form = self.get_form_data()
135 cookie = self.login(
136 email=form.getfirst('email', ''),
137 password=form.getfirst('password', '')
138 )
139 if cookie:
140 self.redirect('/', cookie)
141 else:
142 self.send_error(
143 HTTPStatus.UNAUTHORIZED,
144 'Authorization failed',
145 'Incorrect email or password'
146 )
147
148 def handle_logout(self):
149 if not self.authenticate():
150 return None
151 self.redirect('/login.html', self.logout())
152
153 def handle_mail(self):
154 if not self.authenticate():
155 return None
156 form = self.get_form_data()
157 try:
158 self.send_mail(
159 recipient=form.getfirst('recipient', ''),
160 subject=form.getfirst('subject', ''),
161 content=form.getfirst('content', ''),
162 attachments=self.get_attachments(form)
163 )
164 except Exception as err:
165 self.send_error(
166 HTTPStatus.BAD_GATEWAY,
167 'Sending mail failed',
168 str(err)
169 )
170 return None
171 self.redirect('/sent.html')
172
173 def do_POST(self):
174 if self.path == '/':
175 self.handle_mail()
176 elif self.path == '/login':
177 self.handle_login()
178 elif self.path == '/logout':
179 self.handle_logout()
180 else:
181 self.send_error(HTTPStatus.METHOD_NOT_ALLOWED)
182
183 def do_GET(self):
184 if self.path != '/' or self.authenticate():
185 super().do_GET()
186
187 def do_HEAD(self):
188 if self.path != '/' or self.authenticate():
189 super().do_HEAD()
190
191 # Override to add cache-control and vary headers and remove server header:
192 def send_response(self, code, message=None):
193 self.log_request(code)
194 self.send_response_only(code, message)
195 self.send_header('Date', self.date_time_string())
196 if self.path == '/':
197 self.send_header('Cache-Control', 'no-cache')
198 self.send_header('Vary', 'Cookie')
199
200
201def handle_exit(sig, frame): raise(SystemExit)
202
203
204# Graceful shutdown on SIGTERM/SIGINT:
205signal.signal(signal.SIGTERM, handle_exit)
206signal.signal(signal.SIGINT, handle_exit)
207
208try:
209 os.chdir(HTML_DIR)
210 server = ThreadingHTTPServer(('', SERVER_PORT), MailRequestHandler)
211 server.serve_forever()
212except SystemExit:
213 server.socket.close()