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/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
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/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() }}
+
+{% 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
+
+
+
Already signed up? Please log in.
+
+
+
+{% endblock body %}
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()