From 910ad0aedf00e721e7bcb56693ea3f6d0a9889a0 Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sat, 23 Sep 2023 08:03:04 -0400 Subject: [PATCH] Adding argon2 hashing to user model --- README.md | 1 + htmx_contact/__init__.py | 11 ++++++++++ htmx_contact/models.py | 20 ++++++++++++++++++- htmx_contact/user.py | 18 +++++++++++++++++ migrations/versions/f06a2e34836b_.py | 30 ++++++++++++++++++++++++++++ requirements.in | 1 + requirements.txt | 8 ++++++++ 7 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 htmx_contact/user.py create mode 100644 migrations/versions/f06a2e34836b_.py diff --git a/README.md b/README.md index 047bde8..8831b6a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ For a list of current utilities. - [x] User accounts - [ ] Flask Login integration - [ ] Oauth Integration + - [ ] Secure password hashing with [argon2](https://www.password-hashing.net/) - [ ] RBAC for contact sharing - [ ] Email integration - [ ] Admin portal diff --git a/htmx_contact/__init__.py b/htmx_contact/__init__.py index dc25902..f5ff2e1 100644 --- a/htmx_contact/__init__.py +++ b/htmx_contact/__init__.py @@ -1,15 +1,26 @@ from flask import Flask +from flask_login import LoginManager from .config import ContactSettings +# Configure Authentication +login_manager = LoginManager() +login_manager.session_protection = "strong" +login_manager.login_view = "user.user_login" + def create_app(config: ContactSettings = None): app = Flask("htmx_contact") app.config.from_object(config if config else ContactSettings()) + login_manager.init_app(app) from . import main app.register_blueprint(main.bp) + from . import user + + app.register_blueprint(user.bp) + return app diff --git a/htmx_contact/models.py b/htmx_contact/models.py index e75b537..330f343 100644 --- a/htmx_contact/models.py +++ b/htmx_contact/models.py @@ -1,6 +1,8 @@ import re from typing import List +from argon2 import PasswordHasher +from flask_login import UserMixin from sqlalchemy import ForeignKey from sqlalchemy import String from sqlalchemy.orm import DeclarativeBase @@ -16,20 +18,36 @@ def _is_valid_phone_number(phone_number): return bool(re.match(PHONE_REGEX, phone_number)) +# TODO make hashing values configurable +ph = PasswordHasher() + + class Base(DeclarativeBase): pass -class User(Base): +class User(Base, UserMixin): __tablename__ = "user" id: Mapped[int] = mapped_column(primary_key=True) username: Mapped[str] = mapped_column(String(30), unique=True) primary_email: Mapped[str] = mapped_column(String(120), unique=True) + password_hash: Mapped[str] = mapped_column(String()) contacts: Mapped[List["Contact"]] = relationship(back_populates="user", cascade="all, delete-orphan") def __repr__(self) -> str: return f"User(id={self.id!r}, username={self.username!r})" # see format specifiers for !r + @property + def password(self): + raise AttributeError('password: write-only field') + + @password.setter + def password(self, password): + self.password_hash = ph.hash(password) + + def check_password(self, password): + return ph.verify(self.password_hash, password) + class Contact(Base): __tablename__ = "contact" diff --git a/htmx_contact/user.py b/htmx_contact/user.py new file mode 100644 index 0000000..9c3851d --- /dev/null +++ b/htmx_contact/user.py @@ -0,0 +1,18 @@ +from flask import Blueprint + +bp = Blueprint("user", __name__, url_prefix="/user") + + +@bp.route("/login", methods=["GET", "POST"]) +def user_login(): + pass + + +@bp.route("/logout", methods=["GET"]) +def user_logout(): + pass + + +@bp.route("/sign-up") +def user_sign_up(): + pass diff --git a/migrations/versions/f06a2e34836b_.py b/migrations/versions/f06a2e34836b_.py new file mode 100644 index 0000000..6a24341 --- /dev/null +++ b/migrations/versions/f06a2e34836b_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: f06a2e34836b +Revises: d76ecaeb13db +Create Date: 2023-09-23 07:58:10.338559 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f06a2e34836b' +down_revision: Union[str, None] = 'd76ecaeb13db' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('password_hash', sa.String(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'password_hash') + # ### end Alembic commands ### diff --git a/requirements.in b/requirements.in index 8ab06f6..e93355d 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,5 @@ alembic>=1.12.0,<2.0 +argon2-cffi==23.1.0 flask>=2.3.3,<3.0 flask-login>=0.6.2,<1.0 pydantic>=2.3.0,<3.0 diff --git a/requirements.txt b/requirements.txt index 770890c..49836d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,8 +8,14 @@ alembic==1.12.0 # via -r requirements.in annotated-types==0.5.0 # via pydantic +argon2-cffi==23.1.0 + # via -r requirements.in +argon2-cffi-bindings==21.2.0 + # via argon2-cffi blinker==1.6.2 # via flask +cffi==1.15.1 + # via argon2-cffi-bindings click==8.1.7 # via flask flask==2.3.3 @@ -31,6 +37,8 @@ markupsafe==2.1.3 # jinja2 # mako # werkzeug +pycparser==2.21 + # via cffi pydantic==2.3.0 # via # -r requirements.in