import logging
import tornado.web
+import urllib.parse
from . import base
if self.current_user:
self.add_header("Cache-Control", "private")
- self.add_header("Vary", "Cookie")
+ self.add_header("Cache-Control", "no-store")
class AuthenticationMixin(CacheMixin):
- def authenticate(self, username, password):
- # Find account
- account = self.backend.accounts.find_account(username)
- if not account:
- raise tornado.web.HTTPError(401, "Unknown user: %s" % username)
-
- # Check credentials
- if not account.check_password(password):
- raise tornado.web.HTTPError(401, "Invalid password for %s" % account)
-
- return self.login(account)
-
def login(self, account):
# User has logged in, create a session
session_id, session_expires = self.backend.accounts.create_session(
# Send session cookie to the client
self.set_cookie("session_id", session_id,
- domain=self.request.host, expires=session_expires)
+ domain=self.request.host, expires=session_expires, secure=True)
def logout(self):
session_id = self.get_cookie("session_id")
class LoginHandler(AuthenticationMixin, base.BaseHandler):
- @base.blacklisted
def get(self):
next = self.get_argument("next", None)
- self.render("auth/login.html", next=next)
+ self.render("auth/login.html", next=next,
+ incorrect=False, username=None)
- @base.blacklisted
+ @base.ratelimit(minutes=15, requests=10)
def post(self):
username = self.get_argument("username")
password = self.get_argument("password")
+ next = self.get_argument("next", "/")
- with self.db.transaction():
- self.authenticate(username, password)
+ # Find user
+ account = self.backend.accounts.auth(username, password)
+ if not account:
+ logging.error("Unknown user or invalid password: %s" % username)
- # Determine the page we should redirect to
- next = self.get_argument("next", None)
+ # Set status to 401
+ self.set_status(401)
+
+ # Render login page again
+ return self.render("auth/login.html",
+ incorrect=True, username=username, next=next,
+ )
- return self.redirect(next or "/")
+ # Create session
+ with self.db.transaction():
+ self.login(account)
+
+ # Redirect the user
+ return self.redirect(next)
class LogoutHandler(AuthenticationMixin, base.BaseHandler):
self.redirect("/")
-class RegisterHandler(base.BaseHandler):
- @base.blacklisted
+class RegisterHandler(CacheMixin, base.BaseHandler):
def get(self):
# Redirect logged in users away
if self.current_user:
self.redirect("/")
+ return
self.render("auth/register.html")
- @base.blacklisted
- def post(self):
+ @base.ratelimit(minutes=15, requests=5)
+ async def post(self):
uid = self.get_argument("uid")
email = self.get_argument("email")
first_name = self.get_argument("first_name")
last_name = self.get_argument("last_name")
+ # Check if this is a spam account
+ is_spam = await self.backend.accounts.check_spam(email,
+ address=self.get_remote_ip())
+
+ if is_spam:
+ self.render("auth/register-spam.html")
+ return
+
# Register account
try:
with self.db.transaction():
self.backend.accounts.register(uid, email,
- first_name=first_name, last_name=last_name)
+ first_name=first_name, last_name=last_name,
+ country_code=self.current_country_code)
except ValueError as e:
- raise tornado.web.HTTPError(400) from e
+ raise tornado.web.HTTPError(400, "%s" % e) from e
self.render("auth/register-success.html")
# Create session
self.login(account)
- # Redirect to main page
+ # Redirect to success page
+ self.render("auth/activated.html", account=account)
+
+
+class PasswordResetInitiationHandler(CacheMixin, base.BaseHandler):
+ def get(self):
+ username = self.get_argument("username", None)
+
+ self.render("auth/password-reset-initiation.html", username=username)
+
+ @base.ratelimit(minutes=15, requests=10)
+ def post(self):
+ username = self.get_argument("username")
+
+ # Fetch account and submit password reset
+ account = self.backend.accounts.get_by_uid(username)
+ if account:
+ with self.db.transaction():
+ account.request_password_reset()
+
+ self.render("auth/password-reset-successful.html")
+
+
+class PasswordResetHandler(AuthenticationMixin, base.BaseHandler):
+ def get(self, uid, reset_code):
+ account = self.backend.accounts.get_by_uid(uid)
+ if not account:
+ raise tornado.web.HTTPError(404, "Could not find account: %s" % uid)
+
+ self.render("auth/password-reset.html", account=account)
+
+ def post(self, uid, reset_code):
+ account = self.backend.accounts.get_by_uid(uid)
+ if not account:
+ raise tornado.web.HTTPError(404, "Could not find account: %s" % uid)
+
+ password1 = self.get_argument("password1")
+ password2 = self.get_argument("password2")
+
+ if not password1 == password2:
+ raise tornado.web.HTTPError(400, "Passwords do not match")
+
+ # Try to perform password reset
+ with self.db.transaction():
+ account.reset_password(reset_code, password1)
+
+ # Login the user straight away after reset was successful
+ self.login(account)
+
+ # Redirect back to /
self.redirect("/")
+
+
+class SSODiscourse(CacheMixin, base.BaseHandler):
+ @base.ratelimit(minutes=24*60, requests=100)
+ @tornado.web.authenticated
+ def get(self):
+ # Fetch Discourse's parameters
+ sso = self.get_argument("sso")
+ sig = self.get_argument("sig")
+
+ # Decode payload
+ try:
+ params = self.accounts.decode_discourse_payload(sso, sig)
+
+ # Raise bad request if the signature is invalid
+ except ValueError:
+ raise tornado.web.HTTPError(400)
+
+ # Redirect back if user is already logged in
+ args = {
+ "nonce" : params.get("nonce"),
+ "external_id" : self.current_user.uid,
+
+ # Pass email address
+ "email" : self.current_user.email,
+ "require_activation" : "false",
+
+ # More details about the user
+ "username" : self.current_user.uid,
+ "name" : "%s" % self.current_user,
+ "bio" : self.current_user.description or "",
+
+ # Avatar
+ "avatar_url" : self.current_user.avatar_url(absolute=True),
+ "avatar_force_update" : "true",
+
+ # Send a welcome message
+ "suppress_welcome_message" : "false",
+
+ # Group memberships
+ "admin" : "true" if self.current_user.is_admin() else "false",
+ "moderator" : "true" if self.current_user.is_moderator() else "false",
+ }
+
+ # Format payload and sign it
+ payload = self.accounts.encode_discourse_payload(**args)
+ signature = self.accounts.sign_discourse_payload(payload)
+
+ qs = urllib.parse.urlencode({
+ "sso" : payload,
+ "sig" : signature,
+ })
+
+ # Redirect user
+ self.redirect("%s?%s" % (params.get("return_sso_url"), qs))
+
+
+class APICheckUID(base.APIHandler):
+ @base.ratelimit(minutes=1, requests=100)
+ def get(self):
+ uid = self.get_argument("uid")
+ result = None
+
+ if not uid:
+ result = "empty"
+
+ # Check if the username is syntactically valid
+ elif not self.backend.accounts.uid_is_valid(uid):
+ result = "invalid"
+
+ # Check if the username is already taken
+ elif self.backend.accounts.uid_exists(uid):
+ result = "taken"
+
+ # Username seems to be okay
+ self.finish({ "result" : result or "ok" })
+
+
+class APICheckEmail(base.APIHandler):
+ @base.ratelimit(minutes=1, requests=100)
+ def get(self):
+ email = self.get_argument("email")
+ result = None
+
+ if not email:
+ result = "empty"
+
+ elif not self.backend.accounts.mail_is_valid(email):
+ result = "invalid"
+
+ # Check if this email address is blacklisted
+ elif self.backend.accounts.mail_is_blacklisted(email):
+ result = "blacklisted"
+
+ # Check if this email address is already useed
+ elif self.backend.accounts.get_by_mail(email):
+ result = "taken"
+
+ self.finish({ "result" : result or "ok" })