Skip to content

Commit

Permalink
Add support for Flask-SQLAlchemy-Lite datastore (#994)
Browse files Browse the repository at this point in the history
- This introduces new pre-configured models that use 'modern' sqlalchemy type annotations
- The querying is done using the 2.0 select() mechanism (rather than the old query interface)
- Enhance view_scaffold test harness to work with either Flask-SQLAlchemy or Flask-SQLAlchemy-Lite
- FS-Lite requires newer Flask/Werkzeug - bump up lowest tested versions
- per sqlalchemy best practice - don't install mypy extension or stubs

close #989
  • Loading branch information
jwag956 authored Jul 3, 2024
1 parent ec70c1d commit 25ad68d
Show file tree
Hide file tree
Showing 18 changed files with 697 additions and 60 deletions.
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Features & Improvements
- (:issue:`944`) Change default password hash to argon2 (was bcrypt). See below for details.
- (:pr:`990`) Add freshness capability to auth tokens (enables /us-setup to function w/ just auth tokens).
- (:pr:`991`) Add support /tf-setup to not require sessions (use a state token).
- (:issue:`xxx`) Add support for Flask-SQLAlchemy-Lite - including new all-inclusive models
that confirm to sqlalchemy latest best-practice (type-annotated).

Fixes
+++++
Expand Down
4 changes: 4 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@
"wtforms": ("https://wtforms.readthedocs.io/", None),
"flask_wtforms": ("https://flask-wtf.readthedocs.io", None),
"flask_sqlalchemy": ("https://flask-sqlalchemy.palletsprojects.com/", None),
"flask_sqlalchemy_lite": (
"https://flask-sqlalchemy-lite.readthedocs.io/en/latest/",
None,
),
"flask_login": ("https://flask-login.readthedocs.io/en/latest/", None),
"passlib": ("https://passlib.readthedocs.io/en/stable", None),
"authlib": ("https://docs.authlib.org/en/latest/", None),
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ extensions out of the box for data persistence:
3. `Peewee Flask utils <https://docs.peewee-orm.com/en/latest/peewee/playhouse.html#flask-utils>`_
4. `PonyORM <https://pypi.python.org/pypi/pony/>`_ - NOTE: not currently working - Help needed!.
5. `SQLAlchemy sessions <https://docs.sqlalchemy.org/en/20/orm/session_basics.html>`_
6. `Flask-SQLAlchemy-Lite <https://pypi.python.org/pypi/flask-sqlalchemy-lite/>`_


Getting Started
Expand Down
40 changes: 27 additions & 13 deletions docs/models.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,21 @@ which are a bit of a pain. To make things easier - Flask-Security includes mixin
contain ALL the fields and tables required for all features. They also contain
various `best practice` fields - such as update and create times. These mixins can
be easily extended to add any sort of custom fields and can be found in the
`models` module (today there is just one for using Flask-SQLAlchemy).
`models` module.

The provided models are versioned since they represent actual DB models, and any
changes require a schema migration (and perhaps a data migration). Applications
must specifically import the version they want (and handle any required migration).

There are 2 available models - one when using Flask-SQLAlchemy and one when
using 'raw' sqlalchemy or Flask-SQLAlchemy-Lite.

.. note::
Using these models, you can override the tables names (and provide them to .set_db_info()
however your model class names MUST be `User`, `Role`, and `WebAuthn`.

Flask-SQLAlchemy
^^^^^^^^^^^^^^^^
Your application code should import just the required version e.g.::

from flask_security.models import fsqla_v3 as fsqla
Expand All @@ -33,6 +43,15 @@ Your application code should import just the required version e.g.::
A single method ``fsqla.FsModels.set_db_info`` is provided to glue the supplied models to your
DB instance. This is only needed if you use the packaged models.

Flask-SQLAlchemy-Lite
^^^^^^^^^^^^^^^^^^^^^
Your application code should import just the required version e.g.::

from flask_security.models import sqla as sqla

A single method ``sqla.FsModels.set_db_info`` is provided to glue the supplied mixins to your
models. This is only needed if you use the packaged models.

Model Specification
-------------------

Expand Down Expand Up @@ -176,18 +195,18 @@ the User record (since we need to look up the ``User`` based on a WebAuthn ``cre
Add the following to the WebAuthn model (assuming your primary key is named ``id``):

@declared_attr
def user_id(cls):
return Column(
Integer,
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
def user_id(cls) -> Mapped[int]:
return mapped_column(
ForeignKey("user.id", ondelete="CASCADE")
)

Add the following to the User model:

@declared_attr
def webauthn(cls):
return relationship("WebAuthn", backref="users", cascade="all, delete")
return relationship(
"WebAuthn", back_populates="user", cascade="all, delete"
)

**For mongoengine**::

Expand Down Expand Up @@ -244,12 +263,7 @@ serializable object:
.. code-block:: python
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
email = TextField()
password = TextField()
active = BooleanField(default=True)
confirmed_at = DateTimeField(null=True)
name = db.Column(db.String(80))
# ... define columns ...
# Custom User Payload
def get_security_payload(self):
Expand Down
116 changes: 108 additions & 8 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,22 @@ There are some complete (but simple) examples available in the *examples* direct

.. _argon_cffi: https://pypi.org/project/argon2-cffi/

* :ref:`basic-sqlalchemy-application`
* :ref:`basic-flask-sqlalchemy-application`
* :ref:`basic-flask-sqlalchemy-lite-application`
* :ref:`basic-sqlalchemy-application-with-session`
* :ref:`basic-mongoengine-application`
* :ref:`basic-peewee-application`
* :ref:`mail-configuration`
* :ref:`proxy-configuration`
* :ref:`unit-testing`

.. _basic-sqlalchemy-application:
.. _basic-flask-sqlalchemy-application:

Basic SQLAlchemy Application
----------------------------
Basic Flask-SQLAlchemy Application
-----------------------------------

SQLAlchemy Install requirements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Flask-SQLAlchemy Install requirements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

::

Expand All @@ -46,8 +47,8 @@ SQLAlchemy Install requirements
$ pip install flask-security-too[fsqla,common]


SQLAlchemy Application
~~~~~~~~~~~~~~~~~~~~~~
Flask-SQLAlchemy Application
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The following code sample illustrates how to get started as quickly as
possible using Flask-SQLAlchemy and the built-in model mixins:
Expand Down Expand Up @@ -126,6 +127,105 @@ or::

python app.py

.. _basic-flask-sqlalchemy-lite-application:

Basic Flask-SQLAlchemy-Lite Application
----------------------------------------

Flask-SQLAlchemy Install requirements
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

This requires python >= 3.10::

$ python3 -m venv pymyenv
$ . pymyenv/bin/activate
$ pip install flask-security-too[common] sqlalchemy flask-sqlalchemy-lite

Flask-SQLAlchemy-Lite Application
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The following code sample illustrates how to get started as quickly as
possible using Flask-SQLAlchemy-Lite and the built-in model mixins.
Note that Flask-SQLAlchemy-Lite is a very thin wrapper above sqlalchemy.orm
and just provides session and engine initialization. Everything else is
pure sqlalchemy (unlike Flask-SQLAlchemy).

::

import os

from flask import Flask, render_template_string
from flask_sqlalchemy_lite import SQLAlchemy
from flask_security import Security, SQLAlchemyUserDatastore, auth_required, hash_password
from flask_security.models import sqla as sqla

# Create app
app = Flask(__name__)
app.config['DEBUG'] = True

# Generate a nice key using secrets.token_urlsafe()
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'pf9Wkove4IKEAXvy-cQkeDPhv9Cb3Ag-wyJILbq_dFw')
# Generate a good salt for password hashing using: secrets.SystemRandom().getrandbits(128)
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '146585145368132386173505678016728509634')

# have session and remember cookie be samesite (flask/flask_login)
app.config["REMEMBER_COOKIE_SAMESITE"] = "strict"
app.config["SESSION_COOKIE_SAMESITE"] = "strict"

# Use an in-memory db
app.config |= {
"SQLALCHEMY_ENGINES": {
"default": {"url": "sqlite:///:memory:", "pool_pre_ping": True},
},
}

# Create database connection object
db = SQLAlchemy(app)

# Define models
class Model(DeclarativeBase):
pass

# NOTE: call this PRIOR to declaring models
sqla.FsModels.set_db_info(base_model=Model)

class Role(Model, sqla.FsRoleMixin):
__tablename__ = "Role"
pass

class User(Model, sqla.FsUserMixin):
__tablename__ = "User"
pass

# Setup Flask-Security
user_datastore = FSQLALiteUserDatastore(db, User, Role)
app.security = Security(app, user_datastore)

# Views
@app.route("/")
@auth_required()
def home():
return render_template_string("Hello {{ current_user.email }}")

# one time setup
with app.app_context():
# Create User to test with
Model.metadata.create_all(db.engine)
if not app.security.datastore.find_user(email="test@me.com"):
app.security.datastore.create_user(email="test@me.com", password=hash_password("password"))
db.session.commit()

if __name__ == '__main__':
app.run()

You can run this either with::

flask run

or::

python app.py

.. _basic-sqlalchemy-application-with-session:

Basic SQLAlchemy Application with session
Expand Down
1 change: 1 addition & 0 deletions flask_security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
current_user,
)
from .datastore import (
FSQLALiteUserDatastore,
UserDatastore,
SQLAlchemyUserDatastore,
AsaList,
Expand Down
105 changes: 105 additions & 0 deletions flask_security/datastore.py
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,111 @@ def commit(self):
super().commit()


class FSQLALiteUserDatastore(SQLAlchemyDatastore, UserDatastore):
"""A UserDatastore implementation that assumes the use of
`Flask-SQLAlchemy-Lite <https://pypi.python.org/pypi/flask-sqlalchemy-lite/>`_
for datastore transactions.
:param db:
:param user_model: See :ref:`Models <models_topic>`.
:param role_model: See :ref:`Models <models_topic>`.
:param webauthn_model: See :ref:`Models <models_topic>`.
"""

if t.TYPE_CHECKING: # pragma: no cover
from flask_sqlalchemy_lite import SQLAlchemy

def __init__(
self,
db: SQLAlchemy,
user_model: t.Type[User],
role_model: t.Type[Role],
webauthn_model: t.Type[WebAuthn] | None = None,
):
SQLAlchemyDatastore.__init__(self, db)
UserDatastore.__init__(self, user_model, role_model, webauthn_model)

def find_user(self, case_insensitive: bool = False, **kwargs: t.Any) -> User | None:
from sqlalchemy import func, select
from sqlalchemy.orm import joinedload

attr, value = kwargs.popitem() # only a single query attribute accepted
val = getattr(self.user_model, attr)
stmt = select(self.user_model)

if cv("JOIN_USER_ROLES") and hasattr(self.user_model, "roles"):
stmt = stmt.options(joinedload(self.user_model.roles)) # type: ignore
if case_insensitive:
# While it is of course possible to pass in multiple keys to filter on
# that isn't the normal use case. If caller asks for case_insensitive
# AND gives multiple keys - throw an error.
if len(kwargs) > 0:
raise ValueError("Case insensitive option only supports single key")
stmt = stmt.where(
func.lower(val) == func.lower(value) # type: ignore[arg-type]
)
else:
stmt = stmt.where(val == value) # type: ignore[arg-type]
return self.db.session.scalar(stmt)

def find_role(self, role: str) -> Role | None:
from sqlalchemy import select

return self.db.session.scalar(
select(self.role_model).where(self.role_model.name == role) # type: ignore
)

def find_webauthn(self, credential_id: bytes) -> WebAuthn | None:
from sqlalchemy import select

if not self.webauthn_model: # pragma: no cover
raise NotImplementedError

return self.db.session.scalar(
select(self.webauthn_model).where(
self.webauthn_model.credential_id == credential_id # type: ignore
)
)

def create_webauthn(
self,
user: User,
credential_id: bytes,
public_key: bytes,
name: str,
sign_count: int,
usage: str,
device_type: str,
backup_state: bool,
transports: list[str] | None = None,
extensions: str | None = None,
**kwargs: t.Any,
) -> None:
from .proxies import _security

if (
not hasattr(self, "webauthn_model") or not self.webauthn_model
): # pragma: no cover
raise NotImplementedError

webauthn = self.webauthn_model(
credential_id=credential_id,
public_key=public_key,
name=name,
sign_count=sign_count,
usage=usage,
device_type=device_type,
backup_state=backup_state,
transports=transports,
extensions=extensions,
lastuse_datetime=_security.datetime_factory(),
**kwargs,
)
user.webauthn.append(webauthn)
self.put(webauthn)
self.put(user)


class MongoEngineUserDatastore(MongoEngineDatastore, UserDatastore):
"""A UserDatastore implementation that assumes the
use of
Expand Down
Loading

0 comments on commit 25ad68d

Please sign in to comment.