piccolo-orm / piccolo_admin

A powerful web admin for your database.
https://piccolo-orm.com/ecosystem/
MIT License
321 stars 39 forks source link

Multi-factor login #236

Open dantownsend opened 2 years ago

dantownsend commented 2 years ago

BaseUser has an email field, so we could integrate multi-factor login.

When a user attempts to login, after verifying the username and password, we email them a code which they need to enter into the UI.

We would need a table for storing the codes - we could create a new Piccolo app called something like multi_factor.

We would have a base class called something like MultiFactorProvider, with a send_code method. Out of the box we can have a subclass for sending email over SMTP.

from piccolo_admin.multi_factor import SMTPProvider

SMTP_CREDENTIALS = {
    ...
}

app = create_admin(tables=[Movie, Director], multi_factor_provider=SMTPProvider(**SMTP_CREDENTIALS))

There are already some security measures on the login page, like rate limiting, but having multi-factor would be a great step forward.

Email seems like the best route forward. With authenticator apps, we would need to build additional UI for displaying QR codes etc. With SMS, there's no standardised API that I'm aware of for sending them, like with SMTP, so would be more work.

This is a large feature, but would be pretty awesome.

Example UI / workflow:

multi_factor

sinisaos commented 2 years ago

@dantownsend It's a nice idea for a regular website, but for the admin interface I don't think it's necessary because it's too much work, and the Piccolo Admin has already limited access. When a user create an account admin argument is False by default and only the superuser can change it for users he trusts, so I don't think additional authorization is needed for those users (other users without admin=True cannot access the admin anyway, and only superuser can change data thanks to superuser_validators). That's just my opinion, but it won't hurt if you implement that feature.

dantownsend commented 2 years ago

@sinisaos It's mostly a defence-in-depth in case someone brute forces the username and password on the login page. We use rate limiting, so it would take a long time, but it's possible if someone had a really obvious username and password (like admin/admin), or their credentials had been leaked somewhere.

It would be a nice feature - I would definitely use it. But it is a fair bit of work. I'll leave this issue here for now, and might pick it up in the future.

sinisaos commented 2 years ago

@dantownsend Good point. I didn't think about the superuser himself and his potential mistakes. In that case, multi-factor authentication makes perfect sense. It will be a great feature when it's done.

Skelmis commented 1 year ago

Here's my two cents on MFA from both a user view and security view. Note MFA is amazing from a security viewpoint and is recommended for basically all use-cases haha.

Support multiple forms of MFA

As a user when I think of the ability to sign up with MFA, I tend to think of TOTP codes or Yubikeys. It would be nice to provide an end user the ability to sign up using their preferred MFA method. Some examples are listed below:

From a security aspect, each of these approaches come at different security risks. For example, it's a lot easier to conduct phishing on TOTP codes versus something like hardware keys which are currently considered unphishable.

From a developers point of view as well, if you added support for things such as hardware keys or TOTP then it would be a lot easier to run a local development environment. I'm just thinking that if you only support email, then in order to test locally you'd always need an email backend which isn't always trivial to acquire.

Allow for multiple forms of MFA at once

Often sites only allow an end user to set up one form of MFA at once which isn't great from both a usability aspect and security aspect. For example, what happens when you lose your only form of MFA?

All I have to say here is I'd recommend that this is made as something like an M2M and allow users to setup as many forms of MFA as they wish. For example I personally want to sign up using TOTP and hardware based keys. I know others who use a primary hardware key but also sign up a second one for if they lose the first.

Ensure codes expire and are one time usage

From a security aspect, ensuring any email or SMS codes meet the following criteria is something worth seriously considering:

Other forms of MFA should also meet these expectations, but RFC compliant libraries in theory already handle that for you. A lot don't so it'd be worth checking.

Ensure MFA checks count towards lockouts

As far as I am aware Piccolo does not currently feature an account lockout policy. If that changes in the future however, it should be noted that MFA failures should count towards a form of lockout. If it does not, then MFA codes could possibly be brute forced for example.

Ensure MFA modifications are treated as privileged actions

Prior clarification, a privileged action is something which in my eyes modifies an account / access to an account and should require higher verification to undertake. An example of a privileged action within the current code base is changing your password via the admin panel. This change requires you to submit your current password in order to set a new one.

When implementing similar functionality for adding/changing/deleting a users MFA options it should also enter this higher privileged mode.

As an example, the following is a real world attack which may be undertaken if any authenticated session can simply modify MFA sessions. A malicious user could find and abuse some form of stored cross site scripting vulnerability which when viewed executes arbitrary javascript from the context of a users session. If a privileged mode is not enforced, this javascript could for example delete a users current MFA. After this occurs an attacker would then only need to bruteforce the password in order to gain account takeover.


Lastly, I know that adding support for these points would involve more effort then email alone however from a security aspect I feel they are definitely worth it. If I end up sometime in the future I may do some work towards this point also, but I'll use this issue as a discussion area.

dantownsend commented 6 months ago

@Skelmis I didn't adequately thank you for this - it's super helpful, thanks!

I'm going to take a serious look at implementing MFA.

Skelmis commented 4 months ago

I've thought more on this topic also, and I'd propose the following as a base class or something similar. The idea being the user can implement any form of MFA they want, provided it implements the aforementioned API's. This would allow Piccolo to meet the constraints I outlined in my original post, while maintaining enough flexibility for future demands. Further, this would mean each type of MFA can maintain a user back reference and have custom table schemas.

from abc import ABC, abstractmethod

from piccolo.apps.user.tables import BaseUser
from piccolo.table import Table

class MFA(Table, ABC):
    enabled: bool
    """Whether or not this MFA type can currently be used."""
    user: BaseUser
    """The back reference to the user table"""
    display_name: str
    """A display name for users. 
    This allows them to differentiate between
    multiple instances of the same MFA type.
    """

    @abstractmethod
    def setup_hook(self, *args, **kwargs):
        """Called when a user attempts to add this form of MFA.

        In an example flow such as email, this method would
        generate an email code and send it to the user before
        asking them to verify by entering the code.
        """
        ...

    @abstractmethod
    def setup_verify(self, *args, **kwargs) -> bool:
        """Called by a user when attempting the verify the addition of MFA.

        In the example flow, this method would be called with
        the code that was emailed to the end user.

        :rtype: bool
        :return: True if MFA verified successfully, False otherwise.
        :notes: On success, this method should add MFA to the users account.
        """
        ...

    @abstractmethod
    def login_hook(self, *args, **kwargs):
        """Called when a user attempts to log in and selects this form of MFA.

        In the example flow such as email, this method would
        email a code to the end user before asking them
        to verify by entering the code.
        """
        ...

    @abstractmethod
    def login_verify(self, *args, **kwargs) -> bool:
        """Called by a user attempting to authenticate with this MFA.

        In the example flow such as email, this method would
        validate that the code provided by login_hook was used.

        :rtype: bool
        :returns: True if MFA verified successfully, False otherwise.
        """
        ...

I've also decided to include some example implementations as this provides a lot of flexibility and easy of implementation.

No MFA

This would be the default situation, and just provides a way inline with the proposed API to allow existing behavior.

class NoMFA(MFA):
    def setup_hook(self, *args, **kwargs):
        """No MFA means no prompts or anything here."""
        pass

    def setup_verify(self, *args, **kwargs) -> bool:
        """No MFA means this is always successful."""
        return True

    def login_hook(self, *args, **kwargs):
        """No MFA means no prompts or anything here."""
        pass

    def login_verify(self, *args, **kwargs) -> bool:
        """No MFA means this is always successful."""
        return True

Email based

This form of MFA supports the ability to send user's emails and verify the code. For simplicity, this is psudocode methods etc.

import secrets
from datetime import timedelta, datetime

class EmailMFA(MFA):
    VALIDITY_PERIOD = timedelta(minutes=5)
    EMAIL_TEMPLATE = (
        "Hey {},\nHere is your verification code: {}\n\nThis code is valid for {}."
    )
    IS_CONFIGURED: bool = False

    def generate_new_code(self) -> str:
        return secrets.token_hex(4)

    def code_is_valid(self, created_at) -> bool:
        """Return true if the code is still valid"""
        ...

    def email_user(self, code):
        email = self.EMAIL_TEMPLATE.format(
            self.user.username, code, self.VALIDITY_PERIOD
        )
        ...
        # SMTP email or whatever here

    def setup_hook(self, *args, **kwargs):
        if self.IS_CONFIGURED:
            ...
            # Tell the user this is already configured
            # and to make a new one instead

        self.setup_code = self.generate_new_code()
        self.setup_code_generated_at = datetime.now()
        self.email_user(code=self.setup_code)
        ...
        # Have a pop-up appear asking for the code

    def setup_verify(self, code) -> bool:
        if self.IS_CONFIGURED:
            ...
            # Tell the user this is already configured
            # and to make a new one instead
            return False

        if not self.code_is_valid(self.setup_code_generated_at):
            ...
            # Tell the user the code expired
            return False

        if self.setup_code != code:
            ...
            # Tell the user the code is wrong
            return False

        self.IS_CONFIGURED = True
        self.save()
        return True  # Tell user its setup

    def login_hook(self, *args, **kwargs):
        self.current_code = self.generate_new_code()
        self.current_code_generated_at = datetime.now()
        self.email_user(code=self.setup_code)
        ...
        # Have a pop-up appear asking for the code

    def login_verify(self, code) -> bool:
        if not self.IS_CONFIGURED:
            ...
            # Tell the user this is not configured
            # and to configure one instead
            return False

        if not self.code_is_valid(self.setup_code_generated_at):
            ...
            # Tell the user the code expired
            return False

        if self.setup_code != code:
            ...
            # Tell the user the code is wrong
            return False

        return True  # Auth the user

Hopefully this all helps and you can see the benefits to a plugin based API to allow for support of multiple forms of MFA, including user supplied means.

Then for end consumers, they could simply supply something like:

supported_mfa_claims: list[MFA] = [...]

Which Piccolo would consume and then allow users to pick anything in the given list

Skelmis commented 4 months ago

I've also had a bit more of a think of this, Piccolo should also provide a way for end developers to enforce MFA via some form of setting. When set, users must have atleast one form of MFA present on their account

dantownsend commented 4 months ago

@Skelmis Thanks a lot for this.

I agree that allowing the user to configure which MFA they want make a lot of sense.

I had a look into it a few weeks back, because I'd love to get it added to Piccolo Admin asap.

The first step is figuring out which MFA to add first:

What do you think makes most sense to tackle first?

Skelmis commented 4 months ago

I'd likely look to tackle authenticator apps first, this also means you can more easily conduct local development and testing within an MFA environment. Further, while this oftens falls to the users perspective email can sometimes fail to be considered multi factor (still worth implementing mind you. Any form of MFA is better then none). For a bit more of a reason as to why, here's a link to the OWASP page on it.

I would recommend having backup code's implemented alongside all forms of MFA. It's likely not worth tying it to one specific form of MFA however, but rather an entire account so users can get in if all MFA fails.

My knowledge of passkeys is only as an end user, however I do believe there are some open source packages to hook into. Same for TOTP mind you.

For some further reading, this is a great cheat sheet I'd recommend having a read of.

sinisaos commented 3 months ago

I've been playing around with two-factor authentication for a server-side rendering app. This has some mitigating circumstances because it is much easier to implement with server side Jinja templates and two-factor authentication is always enabled in that app (no option to disable 2FA). Workflow is straightforward. New user is registered, after registration new user is redirected to page with QR code. Then the user scans that QR code with Authy or some other authenticator (register account in Authy). After that user must log in and when pass valid credential is redirected to page to insert TOPT code and than user is logged in (pretty similar like Github does). Then I tried to manage that in Piccolo Admin. I see that there is a discussion about the implementation and how it should all be implemented, but I don't know if someone make any progress. Here is an branch with actual working example. I tried not to change Piccolo ORM and Piccolo API.

2fa.webm

First, it's much harder to implement this with a separate backend and frontend. Even React Admin (which is, I think, the most popular partially open source frontend admin) doesn't have an official (or any other publicly available) implementation of two-factor authentication. As you can see in the posted video, everything works, but there are a few issues which I have trouble to solve.

  1. If you restart the server, there is no more 2FA login even though it is activated for that user (I used a class variable two_factor_auth in AdminRouter to somehow distinguish if 2FA is enabled or disabled, but the problem is that the default value is False and if we restart the server, the variable will be set to False whether 2FA is enabled or disabled)
  2. The login page is public and has no request.user (which I use for 2FA) and I don't see a way to check if the user has enabled or disabled 2FA because the user is simply not logged in.
  3. If one user has 2FA turned on, the login form has a TOTP code. If we want to log in another admin user, we can't do it because that other user doesn't have 2FA enabled and cannot pass TOTP code to login form.

There are probably some other problems that I haven't encountered. Sorry for the long post, but I tried to explain the difficulties I ran into and this may be useful as an example.

dantownsend commented 3 months ago

@sinisaos That's very impressive - thanks!

I was looking into it yesterday too. I was starting more from the database tables - you've made a lot of progress already on the endpoints, and generating the QR codes etc which is really cool.

I'll try and merge both our branches.

If you restart the server, there is no more 2FA login even though it is activated for that user (I used a class variable two_factor_auth in AdminRouter to somehow distinguish if 2FA is enabled or disabled, but the problem is that the default value is False and if we restart the server, the variable will be set to False whether 2FA is enabled or disabled)

Yeah, we'll need a database table for storing the secrets, so we know the user has MFA enabled.

The login page is public and has no request.user (which I use for 2FA) and I don't see a way to check if the user has enabled or disabled 2FA because the user is simply not logged in.

This is tricky - we'll have to make the user sign in with username and password first, so we know they're a valid user, and the API response will ask them to enter a TOTP code. The front end will then show a TOTP input field, and will submit that along with the username and password to the login endpoint.

I thought about having separate middleware for checking the TOTP token, but it's kind of hard to separate it from SessionAuth - so most likely we will just have to make TOTP part of SessionAuth.

If one user has 2FA turned on, the login form has a TOTP code. If we want to log in another admin user, we can't do it because that other user doesn't have 2FA enabled and cannot pass TOTP code to login form.

We can solve this by using a database table, which will tell us which user has MFA turned on.

Skelmis commented 3 months ago

Hrmm, that does look like it could be problematic. I have a couple ideas for this, but not a lot of time right now.

I almost think the way it may need to be done looks something like the following flow from the perspective of the admin form being -> and the response being <-

# On login load
-> Fetch supported MFA claims
<- TOTP, Email, No MFA

# The front end now displays the supported styles and asks the user to select the one they want to use

# User interactions
## User clicks No MFA
-> Login using username / password
if user_exists and not user_needs_mfa:
  <- "Authed"
else:
  <- "Login failed"

## User clicks Email
-> Login using username
if user_exists and has_email_enabled:
  <- "Email sent, please enter code alongside password"
else:
  # Dont disclose info
  <- "Email sent, please enter code alongside password"

-> Login with username / password / email code
if all_details_correct:
  <- "Authed"
else:
 <- "Login failed"

## User clicks TOTP
-> Login using username / password / totp code
if has_totp_enabled and all_details_correct:
  <- "Authed"
else:
 <- "Login failed"

This also resolves one of the security gotchas with regards to MFA because by asking the user to submit the relevant details at the same time (password, mfa) it now forces an attacker to brute force both parameters at the same time versus one at a time. As a result even if an attacker manages to brute force a password, Piccolo won't disclose that to that person.

Although, it does mean the need to implement a couple separate flows within the frontend based on the auth type

sinisaos commented 3 months ago

@dantownsend Thanks. Code in that branch is only example and feel free to use anything you find interesting. @Skelmis Thanks for your comment. I'm not a security expert and I absolutely agree with you, but I want to try implement some code beyond the implementation theory. Maybe some of that code would be useful and maybe not. As I said the code from that branch is just an experiment and has flaws. Also I haven't changed any code in Piccolo ORM or Piccolo API, but I think it will be necessary. Thanks again.

Skelmis commented 3 months ago

Haha all good! Security does often get in the way of getting something out the door. To which I'd also say ignoring the proposed flow is an acceptable solution tbh. Having an implementation beats not having one. It can always be iterated on in the future after all :)

I did have some time set aside in a few weeks to possibly have a bit of an initial look into things, although by the looks of things it'll be available for a bit more in depth stuff. I look forward to seeing where all this goes, it's nice to see it all coming together