]> git.ipfire.org Git - ipfire.org.git/blobdiff - src/web/auth.py
people: Move SSO for Discourse
[ipfire.org.git] / src / web / auth.py
index 14ae0d213a205623690d1c38b9279c51d72e45dd..792205feb40030e1e700abbfd78c1aab0fce4d65 100644 (file)
@@ -2,22 +2,20 @@
 
 import logging
 import tornado.web
+import urllib.parse
 
 from . import base
 
-class AuthenticationMixin(object):
-       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)
+class CacheMixin(object):
+       def prepare(self):
+               # Mark this as private when someone is logged in
+               if self.current_user:
+                       self.add_header("Cache-Control", "private")
 
-               # Check credentials
-               if not account.check_password(password):
-                       raise tornado.web.HTTPError(401, "Invalid password for %s" % account)
+               self.add_header("Cache-Control", "no-store")
 
-               return self.login(account)
 
+class AuthenticationMixin(CacheMixin):
        def login(self, account):
                # User has logged in, create a session
                session_id, session_expires = self.backend.accounts.create_session(
@@ -29,7 +27,7 @@ class AuthenticationMixin(object):
 
                # 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")
@@ -42,24 +40,37 @@ class AuthenticationMixin(object):
 
 
 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):
@@ -71,66 +82,212 @@ 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
-               with self.db.transaction():
-                       self.backend.accounts.create(uid, email,
-                               first_name=first_name, last_name=last_name)
+               try:
+                       with self.db.transaction():
+                               self.backend.accounts.register(uid, email,
+                                       first_name=first_name, last_name=last_name,
+                                       country_code=self.current_country_code)
+               except ValueError as e:
+                       raise tornado.web.HTTPError(400, "%s" % e) from e
 
                self.render("auth/register-success.html")
 
 
 class ActivateHandler(AuthenticationMixin, base.BaseHandler):
        def get(self, uid, activation_code):
-               # Fetch the account
-               account = self.backend.accounts.get_by_uid(uid)
-               if not account:
-                       raise tornado.web.HTTPError(400, "Account not found: %s" % uid)
+               self.render("auth/activate.html")
 
-               # Validate activation code
-               if not account.check_password(activation_code):
-                       raise tornado.web.HTTPError(400, "Activation code did not match: %s" % activation_code)
+       def post(self, uid, activation_code):
+               password1 = self.get_argument("password1")
+               password2 = self.get_argument("password2")
 
-               self.render("auth/activate.html", account=account)
+               if not password1 == password2:
+                       raise tornado.web.HTTPError(400, "Passwords do not match")
+
+               with self.db.transaction():
+                       account = self.backend.accounts.activate(uid, activation_code)
+                       if not account:
+                               raise tornado.web.HTTPError(400, "Account not found: %s" % uid)
+
+                       # Set the new password
+                       account.passwd(password1)
+
+                       # Create session
+                       self.login(account)
+
+               # 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")
 
-       def post(self, uid, activation_code):
-               password = self.get_argument("password1")
 
-               # Fetch the account
+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, "Account not found: %s" % uid)
+                       raise tornado.web.HTTPError(404, "Could not find account: %s" % uid)
 
-               # Validate activation code
-               if not account.check_password(activation_code):
-                       raise tornado.web.HTTPError(403, "Activation code did not match: %s" % activation_code)
+               self.render("auth/password-reset.html", account=account)
 
-               # Set the new password
-               account.passwd(password)
+       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)
 
-               # Create session
-               self.login(account)
+               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 to main page
+               # Redirect back to /
                self.redirect("/")
 
 
-class CacheMixin(object):
-       def prepare(self):
-               # Mark this as private when someone is logged in
-               if self.current_user:
-                       self.add_header("Cache-Control", "private")
+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.add_header("Vary", "Cookie")
+               self.finish({ "result" : result or "ok" })