panel icon indicating copy to clipboard operation
panel copied to clipboard

Enable serving admin site at custom and secure url

Open MarcSkovMadsen opened this issue 3 years ago • 2 comments

The new admin page looks really, really nice.

But as far as I can see everybody can access it if enabled. I would like to be able to restrict access.

For example via a username and password. Or alternatively at a custom "token" url by specifying something like --admin=879B996862F69D8561D1B369F7E44 and then the admin page is available at /879B996862F69D8561D1B369F7E44.

My use case would be for example awesome-panel.org where I finally could start getting some insights :-)

MarcSkovMadsen avatar Feb 08 '22 18:02 MarcSkovMadsen

Another reason for enabling a custom admin url is a multipage app where each page is running independently in a separate pod. If I want to enable admin page for all pages/ pods this will be quite difficult to manage wrt. routing/ ingress on kubernetes.

It would be nice to use urls like page1, page1-admin, page2, page2-admin etc.

MarcSkovMadsen avatar Feb 11 '22 16:02 MarcSkovMadsen

I also think the ability to restrict access to the admin page is very important.

A password-protected page would be welcome. A parameter to set a custom URL would also be nice, but as a complement rather than alternative in my opinion.

TheoMathurin avatar Jul 25 '22 08:07 TheoMathurin

Managed to restrict access by using a custom tornado LoginHandler.

Put the allowed paths of every user in the (secure) cookies, and in the get_user_async check if request_handler.request.path is in the allowed paths for that user. If not, clear the cookies and return.

So, now a given user can access /app1, /app2, ... But they can't access /admin, unless they are an admin.

Material-Scientist avatar Mar 04 '23 08:03 Material-Scientist

Managed to restrict access by using a custom tornado LoginHandler.

Put the allowed paths of every user in the (secure) cookies, and in the get_user_async check if request_handler.request.path is in the allowed paths for that user. If not, clear the cookies and return.

So, now a given user can access /app1, /app2, ... But they can't access /admin, unless they are an admin.

Would you mind sharing a bit more on what your LoginHandler looks like?

Lnk2past avatar Mar 28 '23 16:03 Lnk2past

This is the important part:

async def get_user_async(request_handler):
    # Get cookies
    user = request_handler.get_secure_cookie('user')
    subs = request_handler.get_secure_cookie('subs')
    
    # Secure cookies have no default value. So, replace '' manually.
    if not subs:
        subs = ''
    else:
        subs = subs.decode('utf-8')
    
    subs = np.char.lower(
        np.array(subs.split(','),dtype=str) # Flatten subs + lower_case
    )
    # Get current path
    path = request_handler.request.path.lower()
    
    # Make sure user cannot visit forbidden/unauthorized site
    if path.split('/')[1] not in subs:
        print(f'Forbidden. Path: {path}, subs: {subs}, user: {user}\n')
        logger.info(f'Forbidden. Path: {path}, subs: {subs}, user: {user}')
        request_handler.clear_cookie('user')
        user = None
        
    # Return user to app
    return user

subs contains all allowed endpoints like 'admin'. You can retrieve these allowed endpoints from your user database, and then store them in the cookies.

You'll also have to write your custom class LoginHandler(RequestHandler): where you check authorizations and set the cookie values.

Then, you should be able reach the admin endpoint, whereas ordinary users won't be able to.

Material-Scientist avatar Mar 28 '23 17:03 Material-Scientist

+1 for allowing for custom URL. Another solution I found to handle authorization for any route (including /admin) without using cookies, let me know the thoughts -

from bokeh.server.urls import per_app_patterns
from panel.io.server import DocHandler as PanelDocHandler
from tornado.web import HTTPError, authenticated

def _check_authorization(curr_user, req_path):
    """
    Implement authorization logic for req_path page for
    curr_user and raise HTTPError(403) as needed
    """
    pass

class DocHandler(PanelDocHandler):
    @authenticated
    async def get(self, *args, **kwargs):
        curr_user = self.get_current_user().decode("utf-8")
        req_path = self.request.path.lower()
        _check_authorization(curr_user, req_path)
        return await super().get(self, *args, **kwargs)

per_app_patterns[0] = (r"/?", DocHandler)

Proposal - I think we should extend support in authorization callbacks (Somewhere here) for allow passing separate callbacks for each route/page OR additional parameter req_path can be made available in authorize callback. We can then implement above kind of thing in Panel's DocHandller class. This way it will be generic & work not only for /admin but for any other page of the app.

nishikantparmariam avatar Jul 25 '23 09:07 nishikantparmariam

I think the PR may be sufficient, but we will see what the maintainers think. To replicate the scenario, I created two apps (all code is below). Each app is independent (although I mostly used the example code from the documentation's multipage app). The auth.py module creates the authentication logic where it checks that a user has been granted access to a specific app (or path). Panel's config is given the method, and in panel/io/server.py checks that the returned value from it allows the user to see the page or not. To run the given code, use the following command.

panel serve app1.py app2.py --basic-auth credentials.json --cookie-secret my_super_safe_cookie_secret

This will restrict access based on the user name and the paths the user is allowed to visit.

user

user-access.webm

admin

admin-access.webm


Code

# app1.py
from pathlib import Path
import panel as pn
import param
from auth import check_user_authorization
pn.extension()
pn.config.authorize_callback = check_user_authorization

class App1(param.Parameterized):
    a = param.Integer(default=2, bounds=(0, 10))
    b = param.Integer(default=3, bounds=(0, 10))

    def view(self):
        return pn.pane.HTML(f"<p>a={self.a} b={self.b}</p>")

    def panel(self):
        return pn.Row(self.param, self.view).servable()

app1 = App1()
app1.panel()
# app2.py
from pathlib import Path
import panel as pn
import param
from auth import check_user_authorization
pn.extension()
pn.config.authorize_callback = check_user_authorization

class App2(param.Parameterized):
    c = param.Integer(default=6, bounds=(0, None))
    exp = param.Number(default=0.1, bounds=(0, 3))

    def view(self):
        out = self.c**self.exp
        return pn.Column(out)

    def panel(self):
        return pn.Row(self.param, self.view).servable()

app2 = App2()
app2.panel()
# auth.py
from typing import Any
from urllib import parse as urlparse

authorized_user_paths = {
    "admin": ["/app1", "/app2"],
    "user": ["/app1"],
}

def check_user_authorization(user_info: dict[str, Any], request_path: str) -> bool:
    current_user = user_info["user"]
    if current_user in list(authorized_user_paths.keys()):
        path = urlparse.urlparse(request_path).path
        if path in authorized_user_paths[current_user]:
            return True
    return False

credentials.json

{
  "user": "user",
  "admin": "admin"
}

ndmlny-qs avatar Aug 10 '23 17:08 ndmlny-qs

@MarcSkovMadsen PR #5447 will work to change the name of the admin page. It does not change the fact that if a user logs in to the panel app, they can still access the admin page. So with a login you can try the branch out using

panel serve app1.py app2.py --basic-auth credentials.json --cookie-secret my_super_safe_cookie_secret \
    --admin --admin-endpoint="/random-id"

and you will be redirected to a 404 if you go to /admin, but get the admin page if you go to /random-id

ndmlny-qs avatar Sep 13 '23 13:09 ndmlny-qs