From 98c2b7af4f9c885e7e3c593ee4e29b1da9e88a92 Mon Sep 17 00:00:00 2001 From: oafernandes Date: Tue, 21 Jun 2022 11:07:42 -0400 Subject: [PATCH 1/3] Adds flask admin as a feature. Bumps flask admin to 1.6.0 --- .env | 4 ++++ Dockerfile | 2 ++ app/__init__.py | 1 - app/models.py | 35 ++++++++++++++++++++++++++++++ configs.py | 17 +++++++++++++++ pyproject.toml | 10 ++++++--- run.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 120 insertions(+), 6 deletions(-) diff --git a/.env b/.env index 033b04cc..947d8bef 100644 --- a/.env +++ b/.env @@ -7,3 +7,7 @@ FLASK_DEBUG=1 ALGOLIA_APP_ID=search_id ALGOLIA_API_KEY=search_key INDEX_NAME=resources_api +SECRET_KEY=sammy +SECURITY_PASSWORD_SALT=saltedpop +ADMIN_EMAIL=test@me.com +ADMIN_PASSWORD=1234 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2eea6695..109cd5fe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,8 @@ RUN apt-get update \ && pip install poetry \ && poetry config virtualenvs.create false +RUN poetry lock + RUN poetry install --no-dev --no-interaction --no-ansi COPY . /src diff --git a/app/__init__.py b/app/__init__.py index 3e2d3a11..293530cb 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,6 +1,5 @@ from algoliasearch.search_client import SearchClient from configs import Config - from flask import Flask from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy diff --git a/app/models.py b/app/models.py index 21522506..b2661488 100644 --- a/app/models.py +++ b/app/models.py @@ -2,6 +2,7 @@ from sqlalchemy import DateTime from sqlalchemy.sql import func from sqlalchemy_utils import URLType +from flask_security import UserMixin, RoleMixin language_identifier = db.Table('language_identifier', db.Column( @@ -206,3 +207,37 @@ class VoteInformation(db.Model): current_direction = db.Column(db.String, nullable=True) resource = db.relationship('Resource', back_populates='voters') voter = db.relationship('Key', back_populates='voted_resources') + + +roles_users = db.Table( + 'roles_users', + db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), + db.Column('role_id', db.Integer(), db.ForeignKey('role.id'))) + + +class Role(db.Model, RoleMixin): + '''Role has three fields, ID, name and description''' + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) + + def __str__(self): + return self.name + + # __hash__ method avoids the exception, returns attribute that does not change + # TypeError:unhashable type:'Role' when saving a User + def __hash__(self): + return self.name + + +class User(db.Model, UserMixin): + id = db.Column(db.Integer(), primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + roles = db.relationship( + 'Role', + secondary=roles_users, + backref=db.backref('users', lazy='dynamic') + ) diff --git a/configs.py b/configs.py index 65ac3bfa..107cacd5 100644 --- a/configs.py +++ b/configs.py @@ -37,6 +37,13 @@ def get_sys_exec_root_or_drive(): if not all([algolia_app_id, algolia_api_key]): print("Application requires 'ALGOLIA_APP_ID' and 'ALGOLIA_API_KEY' for search") +secret_key = os.environ.get('SECRET_KEY', None) +security_password_hash = 'pbkdf2_sha512' +security_password_salt = os.environ.get('SECURITY_PASSWORD_SALT', None) + +if not all([secret_key, security_password_salt]): + print('Application requires "SECRET_KEY" and "SECURITY_HASH"') + index_name = os.environ.get("INDEX_NAME") @@ -49,6 +56,16 @@ class Config: ALGOLIA_API_KEY = algolia_api_key INDEX_NAME = index_name + SECRET_KEY = secret_key + SECURITY_URL_PREFIX = "/admin" + SECURITY_PASSWORD_HASH = security_password_hash + SECURITY_PASSWORD_SALT = security_password_salt + SECURITY_LOGIN_URL = "/login/" + SECURITY_LOGOUT_URL = "/logout/" + SECURITY_POST_LOGIN_VIEW = "/admin/" + SECURITY_POST_LOGOUT_VIEW = "/admin/" + SECURITY_REGISTERABLE = False + SECURITY_SEND_REGISTER_EMAIL = False # Can pass in changes to defaults, such as PaginatorConfig(per_page=40) RESOURCE_PAGINATOR = PaginatorConfig() LANGUAGE_PAGINATOR = PaginatorConfig() diff --git a/pyproject.toml b/pyproject.toml index 450bd64f..9e1e130b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,9 @@ python = "^3.7" algoliasearch = ">=2.0,<3.0" alembic = "1.5.8" bandit = "1.5.1" -click = "7.1.2" +click = "8.1.3" flake8 = "3.9.0" -flask = "1.1.2" +flask = "2.1.2" Flask-Cors = "3.0.10" Flask-Migrate = "2.7.0" prometheus_client = "0.9.0" @@ -27,9 +27,13 @@ requests = "2.25.1" sqlalchemy = "1.3.22" SQLAlchemy-Utils = "0.36.8" uWSGI = "2.0.19.1" -Werkzeug = "1.0.1" +Werkzeug = "2.1.2" pyjwt = "^2.0.1" cryptography = "^3.4" +flask-admin = "^1.6.0" +Flask-Login = "^0.6.1" +Flask-Security = "^3.0.0" +email-validator = "^1.2.1" [tool.poetry.dev-dependencies] diff --git a/run.py b/run.py index cfe86401..51a25edf 100644 --- a/run.py +++ b/run.py @@ -1,8 +1,18 @@ from app import app, cli -from app.models import Category, Language, Resource, db +from app.admin import run_flask_admin +from app.models import Category, Language, Resource, db, Role, User +import os +from flask_security import Security, SQLAlchemyUserDatastore, utils +from flask import url_for +from flask_admin import helpers as admin_helpers from werkzeug.middleware.dispatcher import DispatcherMiddleware from prometheus_client import make_wsgi_app +from sqlalchemy import event +admin = run_flask_admin(app) + +user_datastore = SQLAlchemyUserDatastore(db, User, Role) +security = Security(app, user_datastore) if __name__ == "__main__": app.run() @@ -15,6 +25,49 @@ }) +# @event.listens_for(User.password, 'set', retval=True) +# def hash_user_password(target, value, oldvalue, initiator): +# """Encrypts password when new admin created in User View""" +# if value != oldvalue: +# return utils.encrypt_password(value) +# return value + + +@security.context_processor +def security_context_processor(): + return dict( + admin_base_template=admin.base_template, + admin_view=admin.index_view, + h=admin_helpers, + get_url=url_for + ) + + @app.shell_context_processor def make_shell_context(): - return {'db': db, 'Resource': Resource, 'Category': Category, 'Language': Language} + return {'db': db, 'Resource': Resource, 'Category': Category, 'Language': Language, + 'User': User, 'Role': Role} + + +@app.before_first_request +def before_first_request(): + """ Adds admin/user roles and default admin account and password if none exists""" + db.create_all() + user_datastore.find_or_create_role(name='admin', description='Administrator') + user_datastore.find_or_create_role(name='user', description='End User') + + admin_email = os.environ.get('ADMIN_EMAIL', "admin@example.com") + admin_password = os.environ.get('ADMIN_PASSWORD', 'password') + + encrypted_password = utils.encrypt_password(admin_password) + + if not user_datastore.get_user(admin_email): + user_datastore.create_user(email=admin_email, password=encrypted_password) + db.session.commit() + + user_datastore.add_role_to_user(admin_email, 'admin') + db.session.commit() + + +if __name__ == "__main__": + app.run() From 54591fbc919cfc2f2ac07fe1591dd4a3caf93794 Mon Sep 17 00:00:00 2001 From: oafernandes Date: Wed, 6 Jul 2022 14:57:16 -0400 Subject: [PATCH 2/3] Add admin.py --- app/admin.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 app/admin.py diff --git a/app/admin.py b/app/admin.py new file mode 100644 index 00000000..e9b938bc --- /dev/null +++ b/app/admin.py @@ -0,0 +1,45 @@ +from flask_admin import Admin, AdminIndexView +from flask_admin.contrib.sqla import ModelView +from flask import url_for, redirect, request, abort +from app import db +from .models import Resource, Category, Language, User, Role +from flask_security import current_user + + +class AdminView(ModelView): + def is_accessible(self): + return (current_user.is_active and + current_user.is_authenticated and current_user.has_role('admin')) + + def _handle_view(self, name, **kwargs): + """ Override builtin _handle_view in order to redirect users when a view + is not accessible. + """ + if not self.is_accessible(): + if current_user.is_authenticated: + # permission denied + abort(403) + else: + # login + return redirect(url_for('security.login', next=request.url)) + + +class HomeAdminView(AdminIndexView): + def is_accessible(self): + return current_user.has_role('admin') + + def inaccessible_callback(self, name): + return redirect(url_for('security.login', next=request.url)) + + +def run_flask_admin(app): + """Creates the admin object and defines which views will be visible""" + admin_obj = Admin(app, name='Resources_api', url='/', + base_template='my_master.html', + index_view=HomeAdminView(name='Home')) + admin_obj.add_view(AdminView(Role, db.session)) + admin_obj.add_view(AdminView(User, db.session)) + admin_obj.add_view(AdminView(Resource, db.session)) + admin_obj.add_view(AdminView(Category, db.session)) + admin_obj.add_view(AdminView(Language, db.session)) + return admin_obj From 4339c3eb3ddd02e928f3a067b79cea54c5f2970f Mon Sep 17 00:00:00 2001 From: oafernandes Date: Wed, 6 Jul 2022 15:00:10 -0400 Subject: [PATCH 3/3] Add html --- app/templates/admin/index.html | 24 +++++++++++++++++++++++ app/templates/my_master.html | 16 +++++++++++++++ app/templates/security/login_user.html | 20 +++++++++++++++++++ app/templates/security/register_user.html | 23 ++++++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 app/templates/admin/index.html create mode 100644 app/templates/my_master.html create mode 100644 app/templates/security/login_user.html create mode 100644 app/templates/security/register_user.html diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html new file mode 100644 index 00000000..8aa57e22 --- /dev/null +++ b/app/templates/admin/index.html @@ -0,0 +1,24 @@ +{% extends 'admin/master.html' %} + +{% block body %} + {{ super() }} +
+
+
+

Welcome!

+ {% if not current_user.is_authenticated %} +

Please log in to continue

+
+ login +

+
+ {% endif %} + {% if current_user.is_authenticated %} +

You have successfully logged in.

+

You now have access to the administrator view.

+ Log out + {% endif %} +
+
+
+{% endblock body %} diff --git a/app/templates/my_master.html b/app/templates/my_master.html new file mode 100644 index 00000000..56c79ee1 --- /dev/null +++ b/app/templates/my_master.html @@ -0,0 +1,16 @@ +{% extends 'admin/base.html' %} + +{% block access_control %} + {% if current_user.is_authenticated==True %} + + {% endif %} +{% endblock %} diff --git a/app/templates/security/login_user.html b/app/templates/security/login_user.html new file mode 100644 index 00000000..de02fdc8 --- /dev/null +++ b/app/templates/security/login_user.html @@ -0,0 +1,20 @@ +{% extends 'admin/master.html' %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} + +{% block body %} +{{ super() }} +
+
+

Login

+
+
+ {{ login_user_form.hidden_tag() }} + {{ render_field_with_errors(login_user_form.email) }} + {{ render_field_with_errors(login_user_form.password) }} + {{ render_field(login_user_form.next) }} + {{ render_field(login_user_form.submit, class="btn btn-primary") }} +
+
+
+
+{% endblock %} diff --git a/app/templates/security/register_user.html b/app/templates/security/register_user.html new file mode 100644 index 00000000..04ec1831 --- /dev/null +++ b/app/templates/security/register_user.html @@ -0,0 +1,23 @@ +{% extends 'admin/master.html' %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} + +{% block body %} +{{ super() }} +
+
+

Register

+
+
+ {{ register_user_form.hidden_tag() }} + {{ render_field_with_errors(register_user_form.email) }} + {{ render_field_with_errors(register_user_form.password) }} + {% if register_user_form.password_confirm %} + {{ render_field_with_errors(register_user_form.password_confirm) }} + {% endif %} + {{ render_field(register_user_form.submit, class="btn btn-primary") }} +
+

Already signed up? Please log in.

+
+
+
+{% endblock body %}