commit 001de296cc75172eefc1bfa62b886086c370abb8 Author: Maryam Bint Ibrahim Date: Thu Aug 17 16:25:36 2023 +0200 Initial commit diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..660fea3 --- /dev/null +++ b/.flaskenv @@ -0,0 +1 @@ +FLASK_APP=project.app:create_app \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e71c76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +actual.env +.env +.venv +venv +data.db +*__pycache__* diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5762f67 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Flask", + "type": "python", + "request": "launch", + "module": "flask", + "args": [ + "run", + "--debug", + ], + "jinja": true, + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..18182ee --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn project.app:app \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5327d75 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Flask Secure application + +This is a template for a Flask application, using Flask-Security-Too and SQLAlchemy. + +## Usage + +1. Clone the project +2. Rename prod.env or dev.env to .env +3. In .env, modify what you need to +4. Create and activate python virtual env +5. Install requirements with `pip install -r requirements.txt` +6. Generate secret keys and paswords salts and put them in .env : `openssl rand -hex 32` +6. Now you can run your app with `flask run` \ No newline at end of file diff --git a/model.env b/model.env new file mode 100644 index 0000000..68830e2 --- /dev/null +++ b/model.env @@ -0,0 +1,22 @@ +APP_SETTINGS="project.config.DevelopmentConfig" +DATABASE_URL="sqlite:///data.db" +DEV_SECRET_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +DEV_SECURITY_PASSWORD_SALT="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +DOKKU_APP_RESTORE="1" +DOKKU_APP_TYPE="herokuish" +DOKKU_PROXY_PORT="80" +DOKKU_PROXY_PORT_MAP="http:80:5000 https:443:5000" +DOKKU_PROXY_SSL_PORT="443" +FLASK_APP="project.app" +GOOGLE_OAUTH_CLIENT_ID="xxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com" +GOOGLE_OAUTH_CLIENT_SECRET="GOCSPX-XXXXXX-XXXXXXXXXXXXXXXXX" +MAIL_PASSWORD="xxxxxxxxxxxxxxxxxx" +MAIL_PORT="587" +MAIL_SERVER="mail.xx.xx" +MAIL_USERNAME="mail@mail.xx" +MAIL_USE_TLS="True" +OAUTHLIB_INSECURE_TRANSPORT="true" +PRODUCTION_SECRET_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx" +PRODUCTION_SECURITY_PASSWORD_SALT="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx" +SQLALCHEMY_ECHO="True" +ALLOWED_USERS=["user@test.xx, "admin@test.xx"] diff --git a/project/admin.py b/project/admin.py new file mode 100644 index 0000000..20597ea --- /dev/null +++ b/project/admin.py @@ -0,0 +1,53 @@ +from flask_admin import Admin +from flask_admin.contrib.sqla import ModelView +from flask_security import Security, current_user +from wtforms.fields import PasswordField + +from flask import current_app +from database import db_session +from models import User, Role + + +bulma = ["https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.4/css/bulma.css"] +current_app.config['FLASK_ADMIN_EXTRA_CSS'] = bulma +admin = Admin(current_app, name='Admin', template_mode='bootstrap4') + +class UserModelView(ModelView): + column_list = ['email','role'] + create_modal = False + can_view_details = True + extra_css = ["https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.4/css/bulma.css"] + + def is_accessible(self): + return current_user.is_authenticated + + def inaccessible_callback(self, name, **kwargs): + return redirect(url_for('security.login', next=request.url)) + + def scaffold_form(self): + form_class = super(UserModelView, self).scaffold_form() + form_class.password2 = PasswordField('New Password') + return form_class + def on_model_change(self, form, model, is_created): + if len(model.password2): + model.password = utils.encrypt_password(model.password2) + + def get_save_return_url(self, model, is_created): + return self.get_url('.details_view', id=model.id) + + # define a context processor for merging flask-admin's template context into the + # flask-security views. + security = current_app.security + @security.context_processor + def security_context_processor(): + def get_url(admin, **kwargs): + return url_for(admin, **kwargs) + return dict( + admin_base_template=admin.base_template, + admin_view=admin.index_view, + h=admin_helpers, + get_url=url_for + ) + + +admin.add_view(UserModelView(User, db_session)) diff --git a/project/app.py b/project/app.py new file mode 100644 index 0000000..d5af50b --- /dev/null +++ b/project/app.py @@ -0,0 +1,154 @@ +import os +import sys +import string +import random +import uuid + +from flask import Flask, session, render_template_string, render_template, redirect, url_for, flash, request, current_app +from flask_security import Security, current_user, auth_required, hash_password, SQLAlchemySessionUserDatastore +from flask_login import login_user, logout_user +from flask_font_awesome import FontAwesome + +from flask_dance.contrib.google import make_google_blueprint, google +from urllib.parse import urlencode + +from flask_mailman import Mail +from werkzeug.middleware.proxy_fix import ProxyFix +from werkzeug.security import generate_password_hash + +from .config import Config, ProductionConfig, DevelopmentConfig +from dotenv import load_dotenv + +from .database import db_session, init_db +from .models import User, Role + +from .views import views_bp + +fa = FontAwesome() + +# Create app +def create_app(): + app = Flask(__name__, instance_relative_config=True) + app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024 # Set the maximum allowed request size to 100MB + debug_mode = False + print("Creating app") + for i, arg in enumerate(sys.argv): + print("arg: " + str(arg)) + if arg == "--debug": + print("debug mode activé") + app.config.from_object(DevelopmentConfig) + debug_mode = True + break + print("end of variable retrieval") + + if not debug_mode: + app.config.from_object(ProductionConfig) + # Create Google OAuth blueprint + google_blueprint = make_google_blueprint( + client_id=app.config['GOOGLE_OAUTH_CLIENT_ID'], + client_secret=app.config['GOOGLE_OAUTH_CLIENT_SECRET'], + scope=["https://www.googleapis.com/auth/userinfo.email", "openid"], + redirect_url="/authorized", + ) + # Register the Google OAuth blueprint + app.register_blueprint(google_blueprint, url_prefix="/login") + + def google_login_url(): + google_auth_url = url_for("google.login", _external=True) + params = { + "access_type": "offline", + "prompt": "consent", + } + return f"{google_auth_url}?{urlencode(params)}" + + @app.context_processor + def inject_google_login_url(): + return dict(google_login_url=google_login_url) + + user_datastore = SQLAlchemySessionUserDatastore(db_session, User, Role) + print("user_datastore retrieved in prod") + app.security = Security(app, user_datastore) + print("app.security initialized in prod") + + mail = Mail(app) + + app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 + ) + + @app.route('/authorized') + def authorized(): + if debug_mode: + return "Not available in development mode", 404 + if not google.authorized: + return redirect(url_for("google.login")) + resp = google.get("/oauth2/v3/tokeninfo") + if not resp.ok: + flash("Failed to authenticate with Google.", "danger") + return redirect(url_for("views.home")) + + idinfo = resp.json() + email = idinfo['email'] + if 'name' in idinfo: + name = idinfo['name'] + else: + name = email + + # Check if the user's email is in the allowed list + if email not in app.config['ALLOWED_USERS']: + flash("Your email is not authorized.", "danger") + return redirect(url_for("views.home")) + + # Check if the user exists in the database + user = app.security.datastore.find_user(email=email) + # If the user does not exist, create a new user + if not user: + random_password = generate_random_password() + hashed_password = generate_password_hash(random_password, method='sha256') + #fs_uniquifier = str(uuid.uuid4()) + user = app.security.datastore.create_user(email=email, username=name, password=hashed_password) + db_session.commit() + + login_user(user) + + # Set the session variable + session['logged_in_with_google'] = True + + # Redirect to the desired page after login, e.g., the index page + return redirect(url_for("views.home")) + + # Register the Google authorized route only in production + app.add_url_rule('/authorized', view_func=authorized) + + + def generate_random_password(length=12): + characters = string.ascii_letters + string.digits + string.punctuation + return ''.join(random.choice(characters) for i in range(length)) + + @app.route("/logout") + @auth_required() + def logout(): + logout_user() + return redirect(url_for("views.home")) + + app.register_blueprint(views_bp) + + with app.app_context(): + # Create a user to test with + init_db() + for user in app.config['ALLOWED_USERS']: + if not (user.endswith("gmail.com") or user.endswith("thargo.io")) and not app.security.datastore.find_user(email=user): + app.security.datastore.create_user(email=user, password=hash_password(generate_random_password())) + db_session.commit() + + #from admin import admin + #admin.init_app(app) + + fa.init_app(app) + + return app + +app = create_app() + +if __name__ == "__main__": + app.run() \ No newline at end of file diff --git a/project/config.py b/project/config.py new file mode 100644 index 0000000..79ff161 --- /dev/null +++ b/project/config.py @@ -0,0 +1,45 @@ +import os +from dotenv import load_dotenv +import json + +basedir = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(os.path.dirname(basedir), '.env')) + +class Config(): + DEBUG = False + TESTING = False + CSRF_ENABLED = True + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = 'None' + MAX_CONTENT_LENGTH = 100 * 1024 * 1024 + MAIL_SERVER = os.environ.get("MAIL_SERVER") + MAIL_PORT = os.environ.get("MAIL_PORT") + MAIL_USE_TLS = os.environ.get("MAIL_USE_TLS") + MAIL_USE_SSL = os.environ.get("MAIL_USE_SSL") + MAIL_USERNAME = os.environ.get("MAIL_USERNAME") + MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") + MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER") + SECURITY_RECOVERABLE = True + SECURITY_EMAIL_SENDER = os.environ.get("MAIL_SENDER") + ALLOWED_USERS = json.loads(os.environ.get("ALLOWED_USERS")) + +class ProductionConfig(Config): + DEBUG = False + FLASK_DEBUG = False + SECRET_KEY = os.environ.get("PRODUCTION_SECRET_KEY") + SECURITY_PASSWORD_SALT = os.environ.get("PRODUCTION_SECURITY_PASSWORD_SALT") + SQLALCHEMY_TRACK_MODIFICATIONS = False + GOOGLE_OAUTH_CLIENT_ID = os.environ.get("GOOGLE_OAUTH_CLIENT_ID") + GOOGLE_OAUTH_CLIENT_SECRET = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET") + +class DevelopmentConfig(Config): + DEVELOPMENT = True + FLASK_DEBUG = True + DEBUG = True + OAUTHLIB_INSECURE_TRANSPORT = True + SECRET_KEY = os.environ.get("DEV_SECRET_KEY") + SECURITY_PASSWORD_SALT = os.environ.get("DEV_SECURITY_PASSWORD_SALT") + SQLALCHEMY_TRACK_MODIFICATIONS = True diff --git a/project/database.py b/project/database.py new file mode 100644 index 0000000..f6e11cf --- /dev/null +++ b/project/database.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.ext.declarative import declarative_base + +from flask import current_app + +import os + +engine = None +db_session = scoped_session(sessionmaker(autocommit=False, + autoflush=False, + bind=engine)) +Base = declarative_base() +Base.query = db_session.query_property() + +def init_db(): + DATABASE_URL = current_app.config['SQLALCHEMY_DATABASE_URI'] + print("inside init_db, database_url : " + str(DATABASE_URL)) + engine = create_engine(DATABASE_URL, pool_size=10, max_overflow=20) + db_session.configure(bind=engine) + # import all modules here that might define models so that + # they will be registered properly on the metadata. Otherwise + # you will have to import them first before calling init_db() + from . import models + Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/project/models.py b/project/models.py new file mode 100644 index 0000000..a30807b --- /dev/null +++ b/project/models.py @@ -0,0 +1,36 @@ +from .database import Base +from flask_security import UserMixin, RoleMixin +from sqlalchemy import create_engine +from sqlalchemy.orm import relationship, backref +from sqlalchemy import Boolean, DateTime, Column, Integer, \ + String, ForeignKey, UnicodeText + +class RolesUsers(Base): + __tablename__ = 'roles_users' + id = Column(Integer(), primary_key=True) + user_id = Column('user_id', Integer(), ForeignKey('user.id')) + role_id = Column('role_id', Integer(), ForeignKey('role.id')) + +class Role(Base, RoleMixin): + __tablename__ = 'role' + id = Column(Integer(), primary_key=True) + name = Column(String(80), unique=True) + description = Column(String(255)) + permissions = Column(UnicodeText) + +class User(Base, UserMixin): + __tablename__ = 'user' + id = Column(Integer, primary_key=True) + email = Column(String(255), unique=True) + username = Column(String(255), unique=True, nullable=True) + password = Column(String(255), nullable=False) + last_login_at = Column(DateTime()) + current_login_at = Column(DateTime()) + last_login_ip = Column(String(100)) + current_login_ip = Column(String(100)) + login_count = Column(Integer) + active = Column(Boolean()) + fs_uniquifier = Column(String(255), unique=True, nullable=False) + confirmed_at = Column(DateTime()) + roles = relationship('Role', secondary='roles_users', + backref=backref('users', lazy='dynamic')) \ No newline at end of file diff --git a/project/templates/admin/actions.html b/project/templates/admin/actions.html new file mode 100644 index 0000000..d8e29d2 --- /dev/null +++ b/project/templates/admin/actions.html @@ -0,0 +1,43 @@ +{% import 'admin/static.html' as admin_static with context %} + +{% macro dropdown(actions) -%} + +{% endmacro %} + +{% macro form(actions, url) %} + {% if actions %} + + {% endif %} +{% endmacro %} + +{% macro script(message, actions, actions_confirmation) %} + {% if actions %} + + + + {% endif %} +{% endmacro %} diff --git a/project/templates/admin/file/form.html b/project/templates/admin/file/form.html new file mode 100644 index 0000000..d78246e --- /dev/null +++ b/project/templates/admin/file/form.html @@ -0,0 +1,9 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} + +{% block content %} + {% block header %}

{{ header_text }}

{% endblock %} + {% block fa_form %} + {{ lib.render_form(form, dir_url) }} + {% endblock %} +{% endblock %} diff --git a/project/templates/admin/file/list.html b/project/templates/admin/file/list.html new file mode 100644 index 0000000..8616b65 --- /dev/null +++ b/project/templates/admin/file/list.html @@ -0,0 +1,191 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% import 'admin/actions.html' as actionslib with context %} + +{% block content %} + {% block breadcrums %} + + {% endblock %} + + {% block file_list_table %} +
+ + + + {% block list_header scoped %} + {% if actions %} + + {% endif %} + + {% for column in admin_view.column_list %} + + {% endfor %} + {% endblock %} + + + {% for name, path, is_dir, size, date in items %} + + {% block list_row scoped %} + {% if actions %} + + {% endif %} + + {% if is_dir %} + + {% else %} + + {% if admin_view.is_column_visible('size') %} + + {% endif %} + {% endif %} + {% if admin_view.is_column_visible('date') %} + + {% endif %} + {% endblock %} + + {% endfor %} +
+ +   + {% if admin_view.is_column_sortable(column) %} + {% if sort_column == column %} + + {{ admin_view.column_label(column) }} + {% if sort_desc %} + + {% else %} + + {% endif %} + + {% else %} + {{ admin_view.column_label(column) }} + {% endif %} + {% else %} + {{ _gettext(admin_view.column_label(column)) }} + {% endif %} +
+ {% if not is_dir %} + + {% endif %} + + {% block list_row_actions scoped %} + {% if admin_view.can_rename and path and name != '..' %} + {%- if admin_view.rename_modal -%} + {{ lib.add_modal_button(url=get_url('.rename', path=path, modal=True), + title=_gettext('Rename File'), + content='') }} + {% else %} + + + + {%- endif -%} + {% endif %} + {%- if admin_view.can_delete and path -%} + {% if is_dir %} + {% if name != '..' and admin_view.can_delete_dirs %} +
+ {{ delete_form.path(value=path) }} + {{ delete_form.csrf_token }} + +
+ {% endif %} + {% else %} +
+ {{ delete_form.path(value=path) }} + {{ delete_form.csrf_token }} + +
+ {% endif %} + {%- endif -%} + {% endblock %} +
+ + {{ name }} + + + {% if admin_view.can_download %} + {%- if admin_view.edit_modal and admin_view.is_file_editable(path) -%} + {{ lib.add_modal_button(url=get_file_url(path, modal=True)|safe, + btn_class='', content=name) }} + {% else %} + {{ name }} + {%- endif -%} + {% else %} + {{ name }} + {% endif %} + + {{ size|filesizeformat }} + + {{ timestamp_format(date) }} +
+
+ {% endblock %} + {% block toolbar %} +
+ {% if admin_view.can_upload %} +
+ {%- if admin_view.upload_modal -%} + {{ lib.add_modal_button(url=get_dir_url('.upload', path=dir_path, modal=True), + btn_class="btn btn-secondary", + content=_gettext('Upload File')) }} + {% else %} + {{ _gettext('Upload File') }} + {%- endif -%} +
+ {% endif %} + {% if admin_view.can_mkdir %} +
+ {%- if admin_view.mkdir_modal -%} + {{ lib.add_modal_button(url=get_dir_url('.mkdir', path=dir_path, modal=True), + btn_class="btn btn-secondary", + content=_gettext('Create Directory')) }} + {% else %} + {{ _gettext('Create Directory') }} + {%- endif -%} +
+ {% endif %} + {% if actions %} +
+ {{ actionslib.dropdown(actions, 'dropdown-toggle btn btn-secondary') }} +
+ {% endif %} +
+ {% endblock %} + + {% block actions %} + {{ actionslib.form(actions, get_url('.action_view')) }} + {% endblock %} + + {%- if admin_view.rename_modal or admin_view.mkdir_modal + or admin_view.upload_modal or admin_view.edit_modal -%} + {{ lib.add_modal_window() }} + {%- endif -%} +{% endblock %} + +{% block tail %} + {{ super() }} + {{ actionslib.script(_gettext('Please select at least one file.'), + actions, + actions_confirmation) }} + +{% endblock %} diff --git a/project/templates/admin/file/modals/form.html b/project/templates/admin/file/modals/form.html new file mode 100644 index 0000000..eebc699 --- /dev/null +++ b/project/templates/admin/file/modals/form.html @@ -0,0 +1,19 @@ +{% import 'admin/static.html' as admin_static with context %} +{% import 'admin/lib.html' as lib with context %} + +{% block body %} + {# content added to modal-content #} + + +{% endblock %} + +{% block tail %} + +{% endblock %} diff --git a/project/templates/admin/index.html b/project/templates/admin/index.html new file mode 100644 index 0000000..89cfd27 --- /dev/null +++ b/project/templates/admin/index.html @@ -0,0 +1,5 @@ +{% extends 'admin/master.html' %} + +{% block content %} +Bonjour +{% endblock %} diff --git a/project/templates/admin/lib.html b/project/templates/admin/lib.html new file mode 100644 index 0000000..a4d0c37 --- /dev/null +++ b/project/templates/admin/lib.html @@ -0,0 +1,294 @@ +{% import 'admin/static.html' as admin_static with context %} + +{# ---------------------- Pager -------------------------- #} +{% macro pager(page, pages, generator) -%} +{% if pages > 1 %} + +{% endif %} +{%- endmacro %} + +{% macro simple_pager(page, have_next, generator) -%} + +{%- endmacro %} + +{# ---------------------- Modal Window ------------------- #} +{% macro add_modal_window(modal_window_id='fa_modal_window', modal_label_id='fa_modal_label') %} + +{% endmacro %} + +{% macro add_modal_button(url='', title='', content='', modal_window_id='fa_modal_window', btn_class='icon') %} + + {{ content|safe }} + +{% endmacro %} + +{# ---------------------- Forms -------------------------- #} +{% macro render_field(form, field, kwargs={}, caller=None) %} + {% set direct_error = h.is_field_error(field.errors) %} + {% set prepend = kwargs.pop('prepend', None) %} + {% set append = kwargs.pop('append', None) %} +
+ + {% if prepend or append %} +
+ {%- if prepend -%} +
+ {{ prepend }} +
+ {%- endif -%} + {% endif %} + {% if field.widget.input_type == 'checkbox' %} + {% set _class = kwargs.setdefault('class', '') %} + {% elif field.widget.input_type == 'file' %} + {% set _class = kwargs.setdefault('class', 'form-control-file') %} + {% else %} + {% set _class = kwargs.setdefault('class', 'form-control') %} + {% endif %} + {%- if direct_error %} {% set _ = kwargs.update({'class': kwargs['class'] ~ ' is-invalid'}) %} {% endif -%} + {{ field(**kwargs) | safe }} + {%- if append -%} +
+ {{ append }} +
+ {%- endif -%} + {% if direct_error %} +
+
    + {% for e in field.errors if e is string %} +
  • {{ e }}
  • + {% endfor %} +
+
+ {% elif field.description %} + + {{ field.description|safe }} + + {% endif %} + {% if prepend or append %} +
+ {% endif %} + {% if caller %} + {{ caller(form, field, direct_error, kwargs) }} + {% endif %} +
+{% endmacro %} + +{% macro render_header(form, text) %} +

{{ text }}

+{% endmacro %} + +{% macro render_form_fields(form, form_opts=None) %} + {% if form.hidden_tag is defined %} + {{ form.hidden_tag() }} + {% else %} + {% if csrf_token %} + + {% endif %} + {% for f in form if f.widget.input_type == 'hidden' %} + {{ f }} + {% endfor %} + {% endif %} + + {% if form_opts and form_opts.form_rules %} + {% for r in form_opts.form_rules %} + {{ r(form, form_opts=form_opts) }} + {% endfor %} + {% else %} + {% for f in form if f.widget.input_type != 'hidden' %} + {% if form_opts %} + {% set kwargs = form_opts.widget_args.get(f.short_name, {}) %} + {% else %} + {% set kwargs = {} %} + {% endif %} + {{ render_field(form, f, kwargs) }} + {% endfor %} + {% endif %} +{% endmacro %} + +{% macro form_tag(form=None, action=None) %} +
+
+ {{ caller() }} +
+
+{% endmacro %} + +{% macro render_form_buttons(cancel_url, extra=None, is_modal=False) %} + {% if is_modal %} + + {% if extra %} + {{ extra }} + {% endif %} + {% if cancel_url %} + {{ _gettext('Cancel') }} + {% endif %} + {% else %} +
+
+
+ + {% if extra %} + {{ extra }} + {% endif %} + {% if cancel_url %} + {{ _gettext('Cancel') }} + {% endif %} +
+
+ {% endif %} +{% endmacro %} + +{% macro render_form(form, cancel_url, extra=None, form_opts=None, action=None, is_modal=False) -%} + {% call form_tag(action=action) %} + {{ render_form_fields(form, form_opts=form_opts) }} + {{ render_form_buttons(cancel_url, extra, is_modal) }} + {% endcall %} +{% endmacro %} + +{% macro form_css() %} + + + + {% if config.MAPBOX_MAP_ID %} + + + {% endif %} + {% if editable_columns %} + + {% endif %} +{% endmacro %} + +{% macro form_js() %} + {% if config.MAPBOX_MAP_ID %} + + + + {% if config.MAPBOX_SEARCH %} + + + {% endif %} + {% endif %} + + {% if editable_columns %} + + {% endif %} + +{% endmacro %} + +{% macro extra() %} + {% if admin_view.can_create %} + + {% endif %} + {% if admin_view.can_edit %} + + {% endif %} +{% endmacro %} diff --git a/project/templates/admin/master.html b/project/templates/admin/master.html new file mode 100644 index 0000000..0403237 --- /dev/null +++ b/project/templates/admin/master.html @@ -0,0 +1,72 @@ +{% import 'admin/layout.html' as layout with context -%} +{% import 'admin/static.html' as admin_static with context %} + +{% extends "/base.html" %} + +{% block head_scripts %} + {% block head_admin_scripts %} + {% endblock %} + + {% if config.get('FLASK_ADMIN_SWATCH', 'default') == 'default' %} + + {% endif %} + + + {% if admin_view.extra_css %} + {% for css_url in admin_view.extra_css %} + + {% endfor %} + {% endif %} + + {{ super() }} + +{% endblock %} + +{%- block styles %} + {{ super() }} +{% endblock %} + + + +{% block page_body %} + {% block brand %} + {% endblock %} + {% block main_menu %} + {% endblock %} + {% block menu_links %} + {% endblock %} + {% block access_control %} + {% endblock %} + {% block messages %} + {% endblock %} + {# store the jinja2 context for form_rules rendering logic #} + {% set render_ctx = h.resolve_ctx() %} + +{% endblock %} + +{% block scripts %} + {{ super() }} + {% block tail_js %} + + + + + + + + + + {% if admin_view.extra_js %} + {% for js_url in admin_view.extra_js %} + + {% endfor %} + {% endif %} + {% endblock tail_js %} + {% block tail %} + {% endblock %} +{% endblock %} + + diff --git a/project/templates/admin/model/create.html b/project/templates/admin/model/create.html new file mode 100644 index 0000000..c1f8792 --- /dev/null +++ b/project/templates/admin/model/create.html @@ -0,0 +1,32 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% from 'admin/lib.html' import extra with context %} {# backward compatible #} + +{% block head_admin_scripts %} + {{ super() }} + {{ lib.form_css() }} +{% endblock %} + +{% block content %} + {% block navlinks %} +
+ +
+ {% endblock %} + + {% block create_form %} + {{ lib.render_form(form, return_url, extra(), form_opts) }} + {% endblock %} +{% endblock %} + +{% block tail %} + {{ super() }} + {{ lib.form_js() }} +{% endblock %} diff --git a/project/templates/admin/model/details.html b/project/templates/admin/model/details.html new file mode 100644 index 0000000..a222f87 --- /dev/null +++ b/project/templates/admin/model/details.html @@ -0,0 +1,54 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} + +{% block content %} + {% block navlinks %} +
+ +
+ {% endblock %} + + {% block details_search %} +
+ + +
+ {% endblock %} + + {% block details_table %} + + {% for c, name in details_columns %} + + + + + {% endfor %} +
+ {{ name }} + + {{ get_value(model, c) }} +
+ {% endblock %} +{% endblock %} + +{% block tail %} + {{ super() }} + +{% endblock %} diff --git a/project/templates/admin/model/edit.html b/project/templates/admin/model/edit.html new file mode 100644 index 0000000..f7cac97 --- /dev/null +++ b/project/templates/admin/model/edit.html @@ -0,0 +1,42 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% from 'admin/lib.html' import extra with context %} {# backward compatible #} + +{% block head_admin_scripts %} + {{ super() }} + {{ lib.form_css() }} +{% endblock %} + +{% block content %} + {% block navlinks %} +
+ +
+ {% endblock %} + + {% block edit_form %} + {{ lib.render_form(form, return_url, extra(), form_opts) }} + {% endblock %} +{% endblock %} + +{% block tail %} + {{ super() }} + {{ lib.form_js() }} +{% endblock %} diff --git a/project/templates/admin/model/list.html b/project/templates/admin/model/list.html new file mode 100755 index 0000000..6ff2e70 --- /dev/null +++ b/project/templates/admin/model/list.html @@ -0,0 +1,212 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% import 'admin/static.html' as admin_static with context%} +{% import 'admin/model/layout.html' as model_layout with context %} +{% import 'admin/actions.html' as actionlib with context %} +{% import 'admin/model/row_actions.html' as row_actions with context %} + +{% block head_admin_scripts %} + {{ super() }} + {{ lib.form_css() }} +{% endblock %} + +{% block content %} + {% block model_menu_bar %} +
+ +
+ {% endblock %} + + {% if filters %} + {{ model_layout.filter_form() }} +
+ {% endif %} + + {% block model_list_table %} +
+ + + + {% block list_header scoped %} + {% if actions %} + + {% endif %} + {% block list_row_actions_header %} + {% if admin_view.column_display_actions %} + + {% endif %} + {% endblock %} + {% for c, name in list_columns %} + {% set column = loop.index0 %} + + {% endfor %} + {% endblock %} + + + {% for row in data %} + + {% block list_row scoped %} + {% if actions %} + + {% endif %} + {% block list_row_actions_column scoped %} + {% if admin_view.column_display_actions %} + + {%- endif -%} + {% endblock %} + + {% for c, name in list_columns %} + + {% endfor %} + {% endblock %} + + {% else %} + + + + {% endfor %} +
+ +   + {% if admin_view.is_sortable(c) %} + {% if sort_column == column %} + + {{ name }} + {% if sort_desc %} + + {% else %} + + {% endif %} + + {% else %} + {{ name }} + {% endif %} + {% else %} + {{ name }} + {% endif %} + {% if admin_view.column_descriptions.get(c) %} + + {% endif %} +
+ + + {% block list_row_actions scoped %} + {% for action in list_row_actions %} + {{ action.render_ctx(get_pk_value(row), row) }} + {% endfor %} + {% endblock %} + + {% if admin_view.is_editable(c) %} + {% set form = list_forms[get_pk_value(row)] %} + {% if form.csrf_token %} + {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=form.csrf_token._value()) }} + {% elif csrf_token %} + {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c), csrf=csrf_token()) }} + {% else %} + {{ form[c](pk=get_pk_value(row), display_value=get_value(row, c)) }} + {% endif %} + {% else %} + {{ get_value(row, c) }} + {% endif %} +
+ {% block empty_list_message %} +
+ {{ admin_view.get_empty_list_message() }} +
+ {% endblock %} +
+
+ {% block list_pager %} + {% if num_pages is not none %} + {{ lib.pager(page, num_pages, pager_url) }} + {% else %} + {{ lib.simple_pager(page, data|length == page_size, pager_url) }} + {% endif %} + {% endblock %} + {% endblock %} + + {% block actions %} + {{ actionlib.form(actions, get_url('.action_view')) }} + {% endblock %} + + {%- if admin_view.edit_modal or admin_view.create_modal or admin_view.details_modal -%} + {{ lib.add_modal_window() }} + {%- endif -%} +{% endblock %} + +{% block tail %} + {{ super() }} + + {% if filter_groups %} + + + {% endif %} + {{ lib.form_js() }} + + + + + {{ actionlib.script(_gettext('Please select at least one record.'), + actions, + actions_confirmation) }} + + +{% endblock %} diff --git a/project/templates/admin/model/modals/create.html b/project/templates/admin/model/modals/create.html new file mode 100644 index 0000000..3f5097f --- /dev/null +++ b/project/templates/admin/model/modals/create.html @@ -0,0 +1,36 @@ +{% import 'admin/static.html' as admin_static with context%} +{% import 'admin/lib.html' as lib with context %} + +{# store the jinja2 context for form_rules rendering logic #} +{% set render_ctx = h.resolve_ctx() %} + +{% block body %} + + + {% call lib.form_tag(action=url_for('.create_view', url=return_url)) %} + + + {% endcall %} + + {# "save and add" button is removed from modal (it won't function properly) #} + {# % block create_form %} + {{ lib.render_form(form, return_url, extra=None, form_opts=form_opts, + action=url_for('.create_view', url=return_url), + is_modal=True) }} + {% endblock % #} + + +{% endblock %} + +{% block tail %} + +{% endblock %} diff --git a/project/templates/admin/model/modals/details.html b/project/templates/admin/model/modals/details.html new file mode 100755 index 0000000..793b4d0 --- /dev/null +++ b/project/templates/admin/model/modals/details.html @@ -0,0 +1,40 @@ +{% import 'admin/static.html' as admin_static with context%} +{% import 'admin/lib.html' as lib with context %} + +{% block body %} + + + +{% endblock %} + +{% block tail %} + + +{% endblock %} diff --git a/project/templates/admin/model/modals/edit.html b/project/templates/admin/model/modals/edit.html new file mode 100644 index 0000000..dd9e777 --- /dev/null +++ b/project/templates/admin/model/modals/edit.html @@ -0,0 +1,31 @@ +{% import 'admin/static.html' as admin_static with context%} +{% import 'admin/lib.html' as lib with context %} + +{# store the jinja2 context for form_rules rendering logic #} +{% set render_ctx = h.resolve_ctx() %} + +{% block body %} + + + {% call lib.form_tag(action=url_for('.edit_view', id=request.args.get('id'), url=return_url)) %} + + + {% endcall %} + +{% endblock %} + +{% block tail %} + +{% endblock %} diff --git a/project/templates/admin/rediscli/console.html b/project/templates/admin/rediscli/console.html new file mode 100644 index 0000000..d101ffe --- /dev/null +++ b/project/templates/admin/rediscli/console.html @@ -0,0 +1,27 @@ +{% extends 'admin/master.html' %} +{% import 'admin/lib.html' as lib with context %} +{% import 'admin/static.html' as admin_static with context%} + +{%- block head_scripts %} + {{ super() }} + +{% endblock %} + +{% block content %} +
+
+
+
+
+ +
+
+
+{% endblock %} + +{% block tail %} + {{ super() }} + + + +{% endblock %} diff --git a/project/templates/base.html b/project/templates/base.html new file mode 100644 index 0000000..3c052f7 --- /dev/null +++ b/project/templates/base.html @@ -0,0 +1,54 @@ +{% extends "security/base.html" %} +{% block metas %} + + + + + +{% endblock %} + +{%- block head_scripts %} + +{%- endblock head_scripts %} + + +{%- block styles %} + +{%- endblock %} + + {% block body %} + +
+ +
+ {% block navbar %} + {% include "partials/navbar.html" %} + {% endblock navbar %} +
+
+
+
+ {% block content -%} + {%- endblock content %} +
+
+
+
+ {% block footer -%} + {%- endblock footer %} +
+
+ + {% block scripts %} + {%- endblock scripts %} + +{% endblock body %} + + diff --git a/project/templates/index.html b/project/templates/index.html new file mode 100644 index 0000000..c73d4a4 --- /dev/null +++ b/project/templates/index.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block content %} +
+

+ Flask Login Example +

+

+ Easy authentication and authorization in Flask. +

+
+{% endblock %} \ No newline at end of file diff --git a/project/templates/partials/navbar.html b/project/templates/partials/navbar.html new file mode 100644 index 0000000..e4c6f60 --- /dev/null +++ b/project/templates/partials/navbar.html @@ -0,0 +1,37 @@ +{% block navbar %} + +{% endblock %} \ No newline at end of file diff --git a/project/templates/profile.html b/project/templates/profile.html new file mode 100644 index 0000000..8731029 --- /dev/null +++ b/project/templates/profile.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Hello {{name}}

+
+
+{% endblock %} \ No newline at end of file diff --git a/project/templates/security/_macros.html b/project/templates/security/_macros.html new file mode 100644 index 0000000..fdd85c5 --- /dev/null +++ b/project/templates/security/_macros.html @@ -0,0 +1,55 @@ +{% macro render_field_with_errors(field) %} +
+
+ {% set placeholder = field.label.text %} + {{ field(placeholder=placeholder,**kwargs)|safe }} +
+ {% if field.errors %} + + {% endif %} +
+{% endmacro %} + +{% macro render_field(field) %} +
+ {% if field.label.text == "Login" %} + {{ field(**kwargs)|safe }} + {% else %} + {{ field.label }} {{ field(**kwargs)|safe }} + + {% endif %} +
+{% endmacro %} + +{% macro render_field_errors(field) %} +
+ {% if field and field.errors %} + + {% endif %} +
+{% endmacro %} + +{# render WTForms (>3.0) form level errors #} +{% macro render_form_errors(form) %} + {% if form.form_errors %} +
+ +
+ {% endif %} +{% endmacro %} + +{% macro prop_next() -%} + {% if 'next' in request.args %}?next={{ request.args.next|urlencode }}{% endif %} +{%- endmacro %} diff --git a/project/templates/security/forgot_password.html b/project/templates/security/forgot_password.html new file mode 100644 index 0000000..48bda09 --- /dev/null +++ b/project/templates/security/forgot_password.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field %} + +{% block content %} +{% include "security/_messages.html" %} +

{{ _fsdomain('Send password reset instructions') }}

+
+ {{ forgot_password_form.hidden_tag() }} + {{ render_field_with_errors(forgot_password_form.email) }} + {{ render_field(forgot_password_form.submit, class="button") }} +
+{% endblock %} diff --git a/project/templates/security/login_user.html b/project/templates/security/login_user.html new file mode 100644 index 0000000..885d792 --- /dev/null +++ b/project/templates/security/login_user.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors, prop_next %} + +{% block content %} +
+ {% include "security/_messages.html" %} +

{{ _fsdomain('Login') }}

+
+
+
+ {{ login_user_form.hidden_tag() }} + {{ render_form_errors(login_user_form) }} + {% if "email" in identity_attributes %} + {{ render_field_with_errors(login_user_form.email) }} + {% endif %} + {% if login_user_form.username and "username" in identity_attributes %} + {% if "email" in identity_attributes %} +

{{ _fsdomain("or") }}

+ {% endif %} + {{ render_field_with_errors(login_user_form.username) }} + {% endif %} + + {{ render_field_with_errors(login_user_form.password) }} +
+ {{ render_field(login_user_form.remember) }} + {{ render_field_errors(login_user_form.csrf_token) }} + {{ render_field(login_user_form.submit, class="button is-rounded is-primary", label="bonjour") }} +
+ {% if security.webauthn %} +
+

{{ _fsdomain("Use WebAuthn to Sign In") }}

+
+
+ +
+
+ {% endif %} + {% if security.oauthglue %} +
+

{{ _fsdomain("Use Social Oauth to Sign In") }}

+ {% for provider in security.oauthglue.provider_names %} +
+
+ +
+
+ {% endfor %} + {% endif %} + {% if google_login_url is defined %} + + + + + Sign in with Google + + {% endif %} + {% if security.recoverable %} + {{ _fsdomain('Forgot password') }}
+ {% endif %} +
+
+{% endblock %} diff --git a/project/templates/security/register_user.html b/project/templates/security/register_user.html new file mode 100644 index 0000000..1650b3a --- /dev/null +++ b/project/templates/security/register_user.html @@ -0,0 +1,21 @@ +{% extends "security/base.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field, render_form_errors %} + +{% block content %} +{% include "security/_messages.html" %} +

{{ _fsdomain('Register') }}

+
+ {{ register_user_form.hidden_tag() }} + {{ render_form_errors(register_user_form) }} + {{ render_field_with_errors(register_user_form.email) }} + {% if security.username_enable %} + {{ render_field_with_errors(register_user_form.username) }} + {% endif %} + {{ 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) }} +
+{% include "security/_menu.html" %} +{% endblock %} diff --git a/project/templates/users.html b/project/templates/users.html new file mode 100644 index 0000000..d3cff1f --- /dev/null +++ b/project/templates/users.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} +{% block content %} +

User Management

+ + + + + + + + + + {% for user in users %} + + + + + + {% endfor %} + +
UsernameEmailActions
{{ user.username }}{{ user.email }} + Edit + {% if not user.is_admin %} + {% if user.is_active %} + Block + {% else %} + Unblock + {% endif %} + Delete + {% endif %} +
+ +
+
+

Create User

+
+ {{ form.csrf_token }} +
+ + {{ form.username(class="form-control", placeholder="Enter username") }} +
+
+ + {{ form.email(class="form-control", placeholder="Enter email address") }} +
+
+ + {{ form.password(class="form-control", placeholder="Enter password") }} +
+ +
+
+ +
+

Change Password

+
+ {{ form.csrf_token }} +
+ + {{ form.email(class="form-control", placeholder="Enter email address") }} +
+
+ + {{ form.password(class="form-control", placeholder="Enter new password") }} +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/project/views.py b/project/views.py new file mode 100644 index 0000000..bda5365 --- /dev/null +++ b/project/views.py @@ -0,0 +1,37 @@ +# Views + +from flask import Blueprint, render_template, redirect, url_for +from flask_security import current_user, auth_required + +from .models import User, Role +from .database import db_session + +views_bp = Blueprint('views', __name__) + +@views_bp.route('/') +def home(): + return render_template('index.html') + + +@views_bp.route('/profile') +@auth_required() +def profile(): + if not current_user.is_authenticated: + return redirect(url_for('security.login')) + else: + return render_template('profile.html', name=current_user.email) + +''' @app.route('/users', endpoint='users', methods=['GET', 'POST']) +@auth_required +def users(): + form = CreateUserForm() + if form.validate_on_submit(): + user = User(email=form.email.data, password=form.password.data) + db.session.add(user) + db.session.commit() + flash('User created successfully!', 'success') + return redirect(url_for('users')) + + users = User.query.all() + + return render_template('users.html', users=users, form=form) ''' \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..586bcdc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +Flask-Security-Too[common]==5.1.0 +SQLAlchemy==2.0.4 +python-dotenv +Flask-Admin==1.6.1 +Flask-WTF==1.1.1 +Font-Awesome-Flask==0.1.1 +Flask-Dance==6.2.0 +google-auth==2.17.0 +google-auth-httplib2==0.1.0 +google-auth-oauthlib==1.0.0 +gunicorn==20.1.0 +oauth2client==4.1.3 +