Skip to content

Commit

Permalink
Introduce an init_app(app) for factory pattern
Browse files Browse the repository at this point in the history
  • Loading branch information
rayluo committed Jun 16, 2024
1 parent a02d672 commit 45c86ea
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 124 deletions.
3 changes: 3 additions & 0 deletions docs/abc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ It is an abstract base class. You cannot use it directly.

.. automethod:: __init__

.. autoclass:: identity.pallet.PalletAuth
:members:

111 changes: 49 additions & 62 deletions identity/flask.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,66 @@
from functools import partial, wraps
import logging
import os
from typing import List # Needed in Python 3.7 & 3.8
from urllib.parse import urlparse

from typing import List, Optional # Needed in Python 3.7 & 3.8
from flask import (
Blueprint, Flask,
redirect, render_template, request, session, url_for,
)
from flask_session import Session
from .pallet import PalletAuth

from .web import WebFrameworkAuth


logger = logging.getLogger(__name__)


class Auth(WebFrameworkAuth):
class Auth(PalletAuth):
"""A long-live identity auth helper for a Flask web project."""
_Blueprint = Blueprint
_Session = Session
_redirect = redirect

def __init__(self, app: Flask, *args, **kwargs):
def __init__(self, app: Optional[Flask], *args, **kwargs):
"""Create an identity helper for a web application.
:param Flask app:
A Flask app instance. It will be used to register the routes.
It can be a Flask app instance, or ``None``.
1. If your app object is globally available, you may pass it in here.
Usage::
# In your app.py
app = Flask(__name__)
auth = Auth(app, authority=..., client_id=..., ...)
2. But if you are using `Application Factory pattern
<https://flask.palletsprojects.com/en/latest/patterns/appfactories/>`_,
your app is not available globally, so you need to pass ``None`` here,
and call :func:`Auth.init_app()` later,
inside or after your app factory function. Usage::
# In your auth.py
auth = Auth(app=None, authority=..., client_id=..., ...)
# In your other blueprints or modules
from auth import auth
bp = Blueprint("my_blueprint", __name__)
@bp.route("/")
@auth.login_required
def my_view(*, context):
...
# In your app.py
from auth import auth
import my_blueprint
def build_app():
app = Flask(__name__)
auth.init_app(app)
app.register_blueprint(my_blueprint.bp)
return app
app = build_app()
It also passes extra parameters to :class:`identity.web.WebFrameworkAuth`.
"""
super(Auth, self).__init__(*args, **kwargs)
Session(app)
self._endpoint_prefix = "identity" # A convention to match the template's folder name
bp = Blueprint(
self._endpoint_prefix,
__name__, # It decides blueprint resource folder
template_folder='templates',
)
# Manually register the routes, since we cannot use @app or @bp on methods
if self._redirect_uri:
redirect_path = urlparse(self._redirect_uri).path
bp.route(redirect_path)(self.auth_response)
bp.route(
f"{os.path.dirname(redirect_path)}/logout" # Use it in template by url_for("identity.logout")
)(self.logout)
else: # For Device Code Flow, we don't have a redirect_uri
bp.route("/auth_response")(self.auth_response)
bp.route("/logout")(self.logout)
app.register_blueprint(bp)
# "Don’t do self.app = app", see https://flask.palletsprojects.com/en/3.0.x/extensiondev/#the-extension-class-and-initialization
self._auth = self._build_auth(session)
self._request = request # Not available during class definition
self._session = session # Not available during class definition
super(Auth, self).__init__(app, *args, **kwargs)

def _render_auth_error(self, *, error, error_description=None):
return render_template(
Expand Down Expand Up @@ -88,9 +101,6 @@ def auth_response(self):
)
return redirect(result.get("next_link") or "/")

def logout(self):
return redirect(self._auth.log_out(request.host_url))

def login_required( # Named after Django's login_required
self,
function=None,
Expand Down Expand Up @@ -133,28 +143,5 @@ def call_an_api(*, context):
)
...
"""
# With or without brackets. Inspired by https://stackoverflow.com/a/39335652/728675

# Called with brackets, i.e. @login_required()
if function is None:
logger.debug(f"Called as @login_required(..., scopes={scopes})")
return partial(
self.login_required,
scopes=scopes,
)

# Called without brackets, i.e. @login_required
@wraps(function)
def wrapper(*args, **kwargs):
auth = self._auth # In Flask, the entire app uses a singleton _auth
user = auth.get_user()
context = self._login_required(auth, user, scopes)
if context:
return function(*args, context=context, **kwargs)
# Save an http 302 by calling self.login(request) instead of redirect(self.login)
return self.login(
next_link=request.path, # https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request.path
scopes=scopes,
)
return wrapper
return super(Auth, self).login_required(function, scopes=scopes)

108 changes: 108 additions & 0 deletions identity/pallet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
from abc import abstractmethod
from functools import partial, wraps
from inspect import iscoroutinefunction
import logging
import os
from typing import List # Needed in Python 3.7 & 3.8
from urllib.parse import urlparse
from .web import WebFrameworkAuth


logger = logging.getLogger(__name__)


class PalletAuth(WebFrameworkAuth): # A common base class for Flask and Quart
_endpoint_prefix = "identity" # A convention to match the template's folder name
_auth = None # None means not initialized yet

def __init__(self, app, *args, **kwargs):
if not (
self._Blueprint and self._Session and self._redirect
and getattr(self, "_session", None) is not None
and getattr(self, "_request", None) is not None
):
raise RuntimeError(
"Subclass must provide "
"_Blueprint, _Session, _redirect, _session, and _request.")
super(PalletAuth, self).__init__(*args, **kwargs)
self._bp = bp = self._Blueprint(
self._endpoint_prefix,
__name__, # It decides blueprint resource folder
template_folder='templates',
)
# Manually register the routes, since we cannot use @app or @bp on methods
if self._redirect_uri:
redirect_path = urlparse(self._redirect_uri).path
bp.route(redirect_path)(self.auth_response)
bp.route(
f"{os.path.dirname(redirect_path)}/logout" # Use it in template by url_for("identity.logout")
)(self.logout)
else: # For Device Code Flow, we don't have a redirect_uri
bp.route("/auth_response")(self.auth_response)
bp.route("/logout")(self.logout)
if app:
self.init_app(app)

def init_app(self, app):
"""Initialize the auth helper with your app instance.""" # Note:
# This doc string will be shared by Flask and Quart,
# so we use a vague "your app" without mentioning Flask or Quart here.
self._Session(app)
# "Don’t do self.app = app", see https://flask.palletsprojects.com/en/3.0.x/extensiondev/#the-extension-class-and-initialization
app.register_blueprint(self._bp)
self._auth = self._build_auth(self._session)

def __getattribute__(self, name):
# self._auth also doubles as a flag for initialization
if name == "_auth" and not super(PalletAuth, self).__getattribute__(name):
# Can't use self._render_auth_error(...) for friendly error message
# because bp has not been registered to the app yet
raise RuntimeError(
"You must call auth.init_app(app) before using "
"@auth.login_required() or auth.logout() etc.")
return super(PalletAuth, self).__getattribute__(name)

def logout(self):
return self._redirect(self._auth.log_out(self._request.host_url))

def login_required( # Named after Django's login_required
self,
function=None,
/, # Requires Python 3.8+
*,
scopes: List[str]=None,
):
# With or without brackets. Inspired by https://stackoverflow.com/a/39335652/728675

# Called with brackets, i.e. @login_required()
if function is None:
logger.debug(f"Called as @login_required(..., scopes={scopes})")
return partial(
self.login_required,
scopes=scopes,
)

# Called without brackets, i.e. @login_required
if iscoroutinefunction(function): # For Quart
@wraps(function)
async def wrapper(*args, **kwargs):
user = self._auth.get_user()
context = self._login_required(self._auth, user, scopes)
if context:
return await function(*args, context=context, **kwargs)
# Save an http 302 by calling self.login(request) instead of redirect(self.login)
return await self.login(next_link=self._request.path, scopes=scopes)
else: # For Flask
@wraps(function)
def wrapper(*args, **kwargs):
user = self._auth.get_user()
context = self._login_required(self._auth, user, scopes)
if context:
return function(*args, context=context, **kwargs)
# Save an http 302 by calling self.login(request) instead of redirect(self.login)
return self.login(
next_link=self._request.path, # https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request.path
scopes=scopes,
)
return wrapper

111 changes: 49 additions & 62 deletions identity/quart.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,66 @@
from functools import partial, wraps
import logging
import os
from typing import List # Needed in Python 3.7 & 3.8
from urllib.parse import urlparse

from typing import List, Optional # Needed in Python 3.7 & 3.8
from quart import (
Blueprint, Quart,
redirect, render_template, request, session, url_for,
)
from quart_session import Session
from .pallet import PalletAuth

from .web import WebFrameworkAuth

class Auth(PalletAuth):
"""A long-live identity auth helper for a Quart web project."""
_Blueprint = Blueprint
_Session = Session
_redirect = redirect

logger = logging.getLogger(__name__)
def __init__(self, app: Optional[Quart], *args, **kwargs):
"""Create an identity helper for a web application.
:param Quart app:
It can be a Quart app instance, or ``None``.
class Auth(WebFrameworkAuth):
"""A long-live identity auth helper for a Flask web project."""
1. If your app object is globally available, you may pass it in here.
Usage::
def __init__(self, app: Quart, *args, **kwargs):
"""Create an identity helper for a web application.
# In your app.py
app = Quart(__name__)
auth = Auth(app, authority=..., client_id=..., ...)
:param Quart app:
A Quart app instance. It will be used to register the routes.
2. But if you are using `Application Factory pattern
<https://flask.palletsprojects.com/en/latest/patterns/appfactories/>`_,
your app is not available globally, so you need to pass ``None`` here,
and call :func:`Auth.init_app()` later,
inside or after your app factory function. Usage::
# In your auth.py
auth = Auth(app=None, authority=..., client_id=..., ...)
# In your other blueprints or modules
from auth import auth
bp = Blueprint("my_blueprint", __name__)
@bp.route("/")
@auth.login_required
async def my_view(*, context):
...
# In your app.py
from auth import auth
import my_blueprint
def build_app():
app = Quart(__name__)
auth.init_app(app)
app.register_blueprint(my_blueprint.bp)
return app
app = build_app()
It also passes extra parameters to :class:`identity.web.WebFrameworkAuth`.
"""
super(Auth, self).__init__(*args, **kwargs)
Session(app)
self._endpoint_prefix = "identity" # A convention to match the template's folder name
bp = Blueprint(
self._endpoint_prefix,
__name__, # It decides blueprint resource folder
template_folder='templates',
)
# Manually register the routes, since we cannot use @app or @bp on methods
if self._redirect_uri:
redirect_path = urlparse(self._redirect_uri).path
bp.route(redirect_path)(self.auth_response)
bp.route(
f"{os.path.dirname(redirect_path)}/logout" # Use it in template by url_for("identity.logout")
)(self.logout)
else: # For Device Code Flow, we don't have a redirect_uri
bp.route("/auth_response")(self.auth_response)
bp.route("/logout")(self.logout)
app.register_blueprint(bp)
# "Don’t do self.app = app", see https://flask.palletsprojects.com/en/3.0.x/extensiondev/#the-extension-class-and-initialization
self._auth = self._build_auth(session)
self._request = request # Not available during class definition
self._session = session # Not available during class definition
super(Auth, self).__init__(app, *args, **kwargs)

async def _render_auth_error(self, *, error, error_description=None):
return await render_template(
Expand Down Expand Up @@ -88,9 +101,6 @@ async def auth_response(self):
)
return redirect(result.get("next_link") or "/")

def logout(self):
return redirect(self._auth.log_out(request.host_url))

def login_required( # Named after Django's login_required
self,
function=None,
Expand Down Expand Up @@ -134,28 +144,5 @@ async def call_api(*, context):
return await render_template('display.html', result=api_result)
"""
# With or without brackets. Inspired by https://stackoverflow.com/a/39335652/728675

# Called with brackets, i.e. @login_required()
if function is None:
logger.debug(f"Called as @login_required(..., scopes={scopes})")
return partial(
self.login_required,
scopes=scopes,
)

# Called without brackets, i.e. @login_required
@wraps(function)
async def wrapper(*args, **kwargs):
auth = self._auth # In Flask, the entire app uses a singleton _auth
user = auth.get_user()
context = self._login_required(auth, user, scopes)
if context:
return await function(*args, context=context, **kwargs)
# Save an http 302 by calling self.login(request) instead of redirect(self.login)
return await self.login(
next_link=request.path, # https://flask.palletsprojects.com/en/3.0.x/api/#flask.Request.path
scopes=scopes,
)
return wrapper
return super(Auth, self).login_required(function, scopes=scopes)

0 comments on commit 45c86ea

Please sign in to comment.