]>
Commit | Line | Data |
---|---|---|
08df6527 MT |
1 | #!/usr/bin/python |
2 | ||
3 | import logging | |
4 | import tornado.web | |
2fe1d960 | 5 | import urllib.parse |
08df6527 | 6 | |
124a8404 | 7 | from . import base |
5806d6fc | 8 | from . import ui_modules |
08df6527 | 9 | |
da24ac0a | 10 | class AuthenticationMixin(object): |
d8a15b2e | 11 | def login(self, account): |
08df6527 | 12 | # User has logged in, create a session |
906e1e6a MT |
13 | session_id, session_expires = self.backend.accounts.create_session( |
14 | account, self.request.host) | |
08df6527 MT |
15 | |
16 | # Check if a new session was created | |
17 | if not session_id: | |
18 | raise tornado.web.HTTPError(500, "Could not create session") | |
19 | ||
20 | # Send session cookie to the client | |
21 | self.set_cookie("session_id", session_id, | |
abd1d7aa | 22 | domain=self.request.host, expires=session_expires, secure=True) |
08df6527 MT |
23 | |
24 | def logout(self): | |
25 | session_id = self.get_cookie("session_id") | |
26 | if not session_id: | |
27 | return | |
28 | ||
906e1e6a | 29 | success = self.backend.accounts.destroy_session(session_id, self.request.host) |
08df6527 MT |
30 | if success: |
31 | self.clear_cookie("session_id") | |
32 | ||
33 | ||
28e09035 | 34 | class LoginHandler(base.AnalyticsMixin, AuthenticationMixin, base.BaseHandler): |
08df6527 MT |
35 | def get(self): |
36 | next = self.get_argument("next", None) | |
37 | ||
a76ac21e MT |
38 | self.render("auth/login.html", next=next, |
39 | incorrect=False, username=None) | |
08df6527 | 40 | |
53a15fe0 | 41 | @base.ratelimit(minutes=15, requests=10) |
08df6527 MT |
42 | def post(self): |
43 | username = self.get_argument("username") | |
44 | password = self.get_argument("password") | |
a76ac21e | 45 | next = self.get_argument("next", "/") |
08df6527 | 46 | |
328a7710 MT |
47 | # Find user |
48 | account = self.backend.accounts.auth(username, password) | |
49 | if not account: | |
a76ac21e MT |
50 | logging.error("Unknown user or invalid password: %s" % username) |
51 | ||
52 | # Set status to 401 | |
53 | self.set_status(401) | |
54 | ||
55 | # Render login page again | |
56 | return self.render("auth/login.html", | |
57 | incorrect=True, username=username, next=next, | |
58 | ) | |
328a7710 MT |
59 | |
60 | # Create session | |
08df6527 | 61 | with self.db.transaction(): |
328a7710 | 62 | self.login(account) |
08df6527 | 63 | |
a76ac21e MT |
64 | # Redirect the user |
65 | return self.redirect(next) | |
08df6527 MT |
66 | |
67 | ||
68 | class LogoutHandler(AuthenticationMixin, base.BaseHandler): | |
69 | def get(self): | |
70 | with self.db.transaction(): | |
71 | self.logout() | |
72 | ||
73 | # Get back to the start page | |
74 | self.redirect("/") | |
9b8ff27d MT |
75 | |
76 | ||
28e09035 | 77 | class JoinHandler(base.AnalyticsMixin, base.BaseHandler): |
f32dd17f | 78 | def get(self): |
d521c9df MT |
79 | # Redirect logged in users away |
80 | if self.current_user: | |
81 | self.redirect("/") | |
73f2f29a | 82 | return |
d521c9df | 83 | |
268a972b | 84 | self.render("auth/join.html") |
f32dd17f | 85 | |
53a15fe0 | 86 | @base.ratelimit(minutes=15, requests=5) |
9fdf4fb7 | 87 | async def post(self): |
f32dd17f MT |
88 | uid = self.get_argument("uid") |
89 | email = self.get_argument("email") | |
90 | ||
91 | first_name = self.get_argument("first_name") | |
92 | last_name = self.get_argument("last_name") | |
93 | ||
17484575 MT |
94 | # If the honey field has been set, we probably have a bot |
95 | honey = self.get_argument("honey", None) | |
96 | if honey: | |
97 | raise tornado.web.HTTPError(503) | |
98 | ||
bf941d7c MT |
99 | # Fail if first name and last name match |
100 | # Some bots don't seem to be very creative when filling in the form | |
101 | if first_name == last_name: | |
102 | raise tornado.web.HTTPError(503) | |
103 | ||
0da2bda2 MT |
104 | # Fail if the email address isn't valid |
105 | if self.backend.accounts.mail_is_spam(email): | |
106 | raise tornado.web.HTTPError(503, "Email address looks spammy") | |
107 | ||
f32dd17f | 108 | # Register account |
f2ba8a1f MT |
109 | try: |
110 | with self.db.transaction(): | |
268a972b | 111 | self.backend.accounts.join(uid, email, |
757372cd MT |
112 | first_name=first_name, last_name=last_name, |
113 | country_code=self.current_country_code) | |
f2ba8a1f | 114 | except ValueError as e: |
09c67399 | 115 | raise tornado.web.HTTPError(400, "%s" % e) from e |
f32dd17f | 116 | |
268a972b | 117 | self.render("auth/join-success.html") |
f32dd17f MT |
118 | |
119 | ||
d8a15b2e MT |
120 | class ActivateHandler(AuthenticationMixin, base.BaseHandler): |
121 | def get(self, uid, activation_code): | |
b4d72c76 | 122 | self.render("auth/activate.html") |
d8a15b2e MT |
123 | |
124 | def post(self, uid, activation_code): | |
b4d72c76 MT |
125 | password1 = self.get_argument("password1") |
126 | password2 = self.get_argument("password2") | |
d8a15b2e | 127 | |
b4d72c76 MT |
128 | if not password1 == password2: |
129 | raise tornado.web.HTTPError(400, "Passwords do not match") | |
d8a15b2e | 130 | |
b4d72c76 MT |
131 | with self.db.transaction(): |
132 | account = self.backend.accounts.activate(uid, activation_code) | |
133 | if not account: | |
134 | raise tornado.web.HTTPError(400, "Account not found: %s" % uid) | |
d8a15b2e | 135 | |
b4d72c76 MT |
136 | # Set the new password |
137 | account.passwd(password1) | |
d8a15b2e | 138 | |
b4d72c76 MT |
139 | # Create session |
140 | self.login(account) | |
d8a15b2e | 141 | |
b00cc400 MT |
142 | # Redirect to success page |
143 | self.render("auth/activated.html", account=account) | |
689effd0 MT |
144 | |
145 | ||
43dfefb5 MT |
146 | class DebugActivatedHandler(base.BaseHandler): |
147 | @tornado.web.authenticated | |
148 | def get(self): | |
149 | self.render("auth/activated.html", account=self.current_user) | |
150 | ||
151 | ||
da24ac0a | 152 | class PasswordResetInitiationHandler(base.BaseHandler): |
c7594d58 MT |
153 | def get(self): |
154 | username = self.get_argument("username", None) | |
155 | ||
156 | self.render("auth/password-reset-initiation.html", username=username) | |
157 | ||
53a15fe0 | 158 | @base.ratelimit(minutes=15, requests=10) |
c7594d58 MT |
159 | def post(self): |
160 | username = self.get_argument("username") | |
161 | ||
162 | # Fetch account and submit password reset | |
ee5f3269 MT |
163 | with self.db.transaction(): |
164 | account = self.backend.accounts.find_account(username) | |
165 | if account: | |
391ede9e | 166 | account.request_password_reset() |
c7594d58 MT |
167 | |
168 | self.render("auth/password-reset-successful.html") | |
169 | ||
170 | ||
391ede9e MT |
171 | class PasswordResetHandler(AuthenticationMixin, base.BaseHandler): |
172 | def get(self, uid, reset_code): | |
173 | account = self.backend.accounts.get_by_uid(uid) | |
174 | if not account: | |
175 | raise tornado.web.HTTPError(404, "Could not find account: %s" % uid) | |
176 | ||
177 | self.render("auth/password-reset.html", account=account) | |
178 | ||
179 | def post(self, uid, reset_code): | |
180 | account = self.backend.accounts.get_by_uid(uid) | |
181 | if not account: | |
182 | raise tornado.web.HTTPError(404, "Could not find account: %s" % uid) | |
183 | ||
184 | password1 = self.get_argument("password1") | |
185 | password2 = self.get_argument("password2") | |
186 | ||
187 | if not password1 == password2: | |
188 | raise tornado.web.HTTPError(400, "Passwords do not match") | |
189 | ||
190 | # Try to perform password reset | |
191 | with self.db.transaction(): | |
192 | account.reset_password(reset_code, password1) | |
193 | ||
194 | # Login the user straight away after reset was successful | |
195 | self.login(account) | |
196 | ||
197 | # Redirect back to / | |
198 | self.redirect("/") | |
199 | ||
200 | ||
fb163874 MT |
201 | class ConfirmEmailHandler(base.BaseHandler): |
202 | def get(self, uid, token): | |
203 | account = self.backend.accounts.get_by_uid(uid) | |
204 | if not account: | |
205 | raise tornado.web.HTTPError(404, "Could not find account: %s" % uid) | |
206 | ||
207 | with self.db.transaction(): | |
208 | success = account.confirm_email(token) | |
209 | ||
210 | self.render("auth/email-confirmed.html", success=success) | |
211 | ||
212 | ||
69a212f3 MT |
213 | class WellKnownChangePasswordHandler(base.BaseHandler): |
214 | @tornado.web.authenticated | |
215 | def get(self): | |
216 | """ | |
217 | Implements https://web.dev/articles/change-password-url | |
218 | """ | |
219 | self.redirect("/users/%s/passwd" % self.current_user.uid) | |
220 | ||
221 | ||
da24ac0a | 222 | class SSODiscourse(base.BaseHandler): |
2fe1d960 MT |
223 | @base.ratelimit(minutes=24*60, requests=100) |
224 | @tornado.web.authenticated | |
225 | def get(self): | |
226 | # Fetch Discourse's parameters | |
227 | sso = self.get_argument("sso") | |
228 | sig = self.get_argument("sig") | |
229 | ||
230 | # Decode payload | |
231 | try: | |
232 | params = self.accounts.decode_discourse_payload(sso, sig) | |
233 | ||
234 | # Raise bad request if the signature is invalid | |
235 | except ValueError: | |
236 | raise tornado.web.HTTPError(400) | |
237 | ||
238 | # Redirect back if user is already logged in | |
239 | args = { | |
240 | "nonce" : params.get("nonce"), | |
241 | "external_id" : self.current_user.uid, | |
242 | ||
243 | # Pass email address | |
244 | "email" : self.current_user.email, | |
245 | "require_activation" : "false", | |
246 | ||
247 | # More details about the user | |
248 | "username" : self.current_user.uid, | |
249 | "name" : "%s" % self.current_user, | |
250 | "bio" : self.current_user.description or "", | |
251 | ||
252 | # Avatar | |
253 | "avatar_url" : self.current_user.avatar_url(absolute=True), | |
254 | "avatar_force_update" : "true", | |
255 | ||
256 | # Send a welcome message | |
257 | "suppress_welcome_message" : "false", | |
258 | ||
259 | # Group memberships | |
098361bf MT |
260 | "groups" : ",".join((group.gid for group in self.current_user.groups)), |
261 | ||
262 | # Admin? | |
2fe1d960 | 263 | "admin" : "true" if self.current_user.is_admin() else "false", |
098361bf MT |
264 | |
265 | # Moderator? | |
2fe1d960 MT |
266 | "moderator" : "true" if self.current_user.is_moderator() else "false", |
267 | } | |
268 | ||
269 | # Format payload and sign it | |
270 | payload = self.accounts.encode_discourse_payload(**args) | |
271 | signature = self.accounts.sign_discourse_payload(payload) | |
272 | ||
273 | qs = urllib.parse.urlencode({ | |
274 | "sso" : payload, | |
275 | "sig" : signature, | |
276 | }) | |
277 | ||
278 | # Redirect user | |
279 | self.redirect("%s?%s" % (params.get("return_sso_url"), qs)) | |
280 | ||
281 | ||
5806d6fc MT |
282 | class PasswordModule(ui_modules.UIModule): |
283 | def render(self, account=None): | |
284 | return self.render_string("auth/modules/password.html", account=account) | |
285 | ||
286 | def javascript_files(self): | |
287 | return "js/zxcvbn.js" | |
288 | ||
289 | def embedded_javascript(self): | |
290 | return self.render_string("auth/modules/password.js") | |
291 | ||
292 | ||
689effd0 | 293 | class APICheckUID(base.APIHandler): |
66181c96 | 294 | @base.ratelimit(minutes=1, requests=100) |
689effd0 MT |
295 | def get(self): |
296 | uid = self.get_argument("uid") | |
297 | result = None | |
298 | ||
299 | if not uid: | |
300 | result = "empty" | |
301 | ||
302 | # Check if the username is syntactically valid | |
303 | elif not self.backend.accounts.uid_is_valid(uid): | |
304 | result = "invalid" | |
305 | ||
306 | # Check if the username is already taken | |
307 | elif self.backend.accounts.uid_exists(uid): | |
308 | result = "taken" | |
309 | ||
310 | # Username seems to be okay | |
311 | self.finish({ "result" : result or "ok" }) | |
66181c96 MT |
312 | |
313 | ||
314 | class APICheckEmail(base.APIHandler): | |
315 | @base.ratelimit(minutes=1, requests=100) | |
316 | def get(self): | |
317 | email = self.get_argument("email") | |
318 | result = None | |
319 | ||
320 | if not email: | |
321 | result = "empty" | |
322 | ||
3095c017 MT |
323 | elif not self.backend.accounts.mail_is_valid(email): |
324 | result = "invalid" | |
325 | ||
66181c96 MT |
326 | # Check if this email address is blacklisted |
327 | elif self.backend.accounts.mail_is_blacklisted(email): | |
3095c017 | 328 | result = "blacklisted" |
66181c96 MT |
329 | |
330 | # Check if this email address is already useed | |
331 | elif self.backend.accounts.get_by_mail(email): | |
332 | result = "taken" | |
333 | ||
334 | self.finish({ "result" : result or "ok" }) |