Add Admin and security
parent
f0ad281f31
commit
daf343535e
|
@ -0,0 +1,45 @@
|
||||||
|
from flask_admin import Admin, helpers as admin_helpers
|
||||||
|
from flask_admin.contrib.sqla import ModelView
|
||||||
|
from flask_security import current_user, utils
|
||||||
|
from wtforms.fields import PasswordField
|
||||||
|
|
||||||
|
from libertywiki.db import session
|
||||||
|
from libertywiki.models import User, Role
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdmin(ModelView):
|
||||||
|
"""A custom view for user administration"""
|
||||||
|
column_exclude_list = ('password',)
|
||||||
|
form_excluded_columns = ('password',)
|
||||||
|
column_auto_select_related = True
|
||||||
|
|
||||||
|
def is_accessible(self):
|
||||||
|
"""Prevent administration of users from users without the "admin" role"""
|
||||||
|
return current_user.has_role('admin')
|
||||||
|
|
||||||
|
def scaffold_form(self):
|
||||||
|
"""Remove the password field so that we can bypass it and set the password manually"""
|
||||||
|
form_class = super().scaffold_form()
|
||||||
|
form_class.password2 = PasswordField('New Password')
|
||||||
|
return form_class
|
||||||
|
|
||||||
|
def on_model_change(self, form, model, is_created):
|
||||||
|
"""Update the password when the user is saved"""
|
||||||
|
if len(model.password2):
|
||||||
|
model.password = utils.encrypt_password(model.password2)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleAdmin(ModelView):
|
||||||
|
"""A custom view for role administration"""
|
||||||
|
|
||||||
|
def is_accessible(self):
|
||||||
|
"""Prevent administration of roles from users without the "admin" role"""
|
||||||
|
return current_user.has_role('admin')
|
||||||
|
|
||||||
|
|
||||||
|
def setup_admin(app):
|
||||||
|
"""Set up the admin interface"""
|
||||||
|
admin = Admin(app, 'LibertyWiki Admin', base_template='admin/my_master.html', template_mode='bootstrap4')
|
||||||
|
admin.add_view(UserAdmin(User, session))
|
||||||
|
admin.add_view(RoleAdmin(Role, session))
|
||||||
|
return admin, admin_helpers
|
|
@ -1,26 +1,92 @@
|
||||||
from flask import Flask
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from flask import Flask, url_for
|
||||||
|
from flask_security import Security, SQLAlchemyUserDatastore
|
||||||
|
from flask_security.utils import hash_password
|
||||||
from rst2html import rst2html
|
from rst2html import rst2html
|
||||||
|
|
||||||
from libertywiki.db import db
|
from libertywiki.admin import setup_admin
|
||||||
|
from libertywiki.db import db, session
|
||||||
|
from libertywiki.models import User, Role
|
||||||
from libertywiki.views import wiki
|
from libertywiki.views import wiki
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
'DEBUG': False,
|
||||||
|
'SECRET_KEY': secrets.token_urlsafe(128),
|
||||||
|
'SECURITY_PASSWORD_SALT': secrets.token_urlsafe(128),
|
||||||
|
'SECURITY_USERNAME_ENABLE': False,
|
||||||
|
'SECURITY_REGISTERABLE': True,
|
||||||
|
'SECURITY_SEND_REGISTER_EMAIL': False,
|
||||||
|
'SQLALCHEMY_DATABASE_URI': 'sqlite://',
|
||||||
|
'SQLALCHEMY_ENGINE_OPTIONS': {'pool_pre_ping': True},
|
||||||
|
'SQLALCHEMY_TRACK_MODIFICATIONS': False
|
||||||
|
}
|
||||||
|
CONFIG_VARS = [
|
||||||
|
'LIBERTYWIKI_CONFIG',
|
||||||
|
'LW_CONFIG',
|
||||||
|
'WIKI_CONFIG'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_app():
|
def get_app():
|
||||||
"""Create and configure the application object"""
|
"""Create and configure the application object"""
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Set app defaults
|
||||||
|
app.config.update(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
# Load config from file
|
||||||
|
for config_var in CONFIG_VARS:
|
||||||
|
if os.environ.get(config_var):
|
||||||
|
config_fname = Path(os.environ[config_var])
|
||||||
|
if config_fname.suffix == 'json':
|
||||||
|
loader = json.load
|
||||||
|
elif config_fname.suffix in ['yaml', 'yml']:
|
||||||
|
loader = yaml.safe_load
|
||||||
|
with Path(os.environ[config_var]).open() as conf_file:
|
||||||
|
app.config.update(**loader(conf_file))
|
||||||
|
break
|
||||||
|
|
||||||
|
# Load from environment variables
|
||||||
|
app.config.update(**{key: os.environ[key] for key in DEFAULT_CONFIG.keys() if os.environ.get(key)})
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
|
||||||
|
security = Security(app, user_datastore)
|
||||||
|
admin, admin_helpers = setup_admin(app)
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
# Create all the tables in the database
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
# Create the basic roles
|
||||||
|
admin_role = user_datastore.find_or_create_role(name='admin', description='Administrator')
|
||||||
|
user_datastore.find_or_create_role(name='end-user', description='End user')
|
||||||
|
session.commit()
|
||||||
|
# Create an administrator
|
||||||
|
if not User.query.filter(User.roles.contains(admin_role)).first():
|
||||||
|
user = user_datastore.create_user(name='Administrator', email='admin@example.com',
|
||||||
|
password=hash_password('password1234'))
|
||||||
|
user.roles.append(admin_role)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
@app.template_filter('rst2html')
|
@app.template_filter('rst2html')
|
||||||
def rst2html_filter(text):
|
def rst2html_filter(text):
|
||||||
html, warning = rst2html(text)
|
html, warning = rst2html(text)
|
||||||
print(html)
|
|
||||||
print(warning)
|
|
||||||
return html
|
return html
|
||||||
|
|
||||||
|
@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.register_blueprint(wiki)
|
app.register_blueprint(wiki)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
|
@ -12,6 +12,7 @@ LargeBinary = db.LargeBinary
|
||||||
Table = db.Table
|
Table = db.Table
|
||||||
String = db.String
|
String = db.String
|
||||||
Text = db.Text
|
Text = db.Text
|
||||||
|
backref = db.backref
|
||||||
relationship = db.relationship
|
relationship = db.relationship
|
||||||
inspect = db.inspect
|
inspect = db.inspect
|
||||||
session = db.session
|
session = db.session
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy.ext.hybrid import hybrid_property
|
from flask_security import UserMixin, RoleMixin
|
||||||
|
|
||||||
from libertywiki.db import Model, Column, ForeignKey, DateTime, Integer, String, Text
|
from libertywiki.db import Model, Table, Column, ForeignKey, Boolean, DateTime, Integer, String, Text, backref, \
|
||||||
from libertywiki.utils import bcrypt
|
relationship
|
||||||
|
|
||||||
|
|
||||||
|
roles_users = Table(
|
||||||
|
'roles_users',
|
||||||
|
Column('role_id', Integer, ForeignKey('roles.id')),
|
||||||
|
Column('user_id', Integer, ForeignKey('users.id'))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Page(Model):
|
class Page(Model):
|
||||||
|
@ -21,10 +28,24 @@ class Page(Model):
|
||||||
modified = Column(DateTime, default=datetime.now())
|
modified = Column(DateTime, default=datetime.now())
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return '<Page {}>'.format(self.title)
|
||||||
|
|
||||||
|
|
||||||
class User(Model):
|
class Role(Model, RoleMixin):
|
||||||
|
"""
|
||||||
|
Role model
|
||||||
|
"""
|
||||||
|
__tablename__ = 'roles'
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
description = Column(Text)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '<Role {}>'.format(self.name)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Model, UserMixin):
|
||||||
"""
|
"""
|
||||||
User model
|
User model
|
||||||
"""
|
"""
|
||||||
|
@ -33,13 +54,13 @@ class User(Model):
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String(255))
|
name = Column(String(255))
|
||||||
email = Column(String(255), nullable=False, index=True, unique=True)
|
email = Column(String(255), nullable=False, index=True, unique=True)
|
||||||
_password = Column('password', String(255), nullable=False)
|
password = Column(String(255), nullable=False)
|
||||||
activation_code = Column(String(255))
|
activation_code = Column(String(255), index=True)
|
||||||
|
active = Column('is_active', Boolean, index=True)
|
||||||
|
confirmed_at = Column(DateTime)
|
||||||
|
fs_uniquifier = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
|
||||||
@hybrid_property
|
roles = relationship('Role', secondary=roles_users, backref=backref('users', lazy='dynamic'))
|
||||||
def password(self):
|
|
||||||
return self.password
|
|
||||||
|
|
||||||
@password.setter
|
def __str__(self):
|
||||||
def password(self, value):
|
return '<User {}>'.format(self.email)
|
||||||
self._password = bcrypt.generate_password_hash(value)
|
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
{% extends 'admin/master.html' %}
|
||||||
|
{% block body %}
|
||||||
|
{{ super() }}
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-10 col-sm-offset-1">
|
||||||
|
<h1>Flask-Admin example</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Authentication
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This example shows how you can use <a href="https://pythonhosted.org/Flask-Security/index.html" target="_blank">Flask-Security</a> for authentication.
|
||||||
|
</p>
|
||||||
|
{% if not current_user.is_authenticated %}
|
||||||
|
<p>You can register as a regular user, or log in as a superuser with the following credentials:
|
||||||
|
<ul>
|
||||||
|
<li>email: <b>admin</b></li>
|
||||||
|
<li>password: <b>admin</b></li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-primary" href="{{ url_for('security.login') }}">login</a> <a class="btn btn-default" href="{{ url_for('security.register') }}">register</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-primary" href="/"><i class="glyphicon glyphicon-chevron-left"></i> Back</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock body %}
|
|
@ -0,0 +1,18 @@
|
||||||
|
{% extends 'admin/base.html' %}
|
||||||
|
|
||||||
|
{% block access_control %}
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="navbar-text btn-group pull-right">
|
||||||
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
|
||||||
|
<i class="glyphicon glyphicon-user"></i>
|
||||||
|
{% if current_user.first_name -%}
|
||||||
|
{{ current_user.first_name }}
|
||||||
|
{% else -%}
|
||||||
|
{{ current_user.email }}
|
||||||
|
{%- endif %}<span class="caret"></span></a>
|
||||||
|
<ul class="dropdown-menu" role="menu">
|
||||||
|
<li><a href="{{ url_for('security.logout') }}">Log out</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% macro render_field_with_errors(field) %}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
{{ field.label }} {{ field(class_='form-control', **kwargs)|safe }}
|
||||||
|
{% if field.errors %}
|
||||||
|
<ul>
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_field(field) %}
|
||||||
|
<p>{{ field(class_='form-control', **kwargs)|safe }}</p>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_checkbox_field(field) -%}
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="checkbox">
|
||||||
|
<label>
|
||||||
|
{{ field(type='checkbox', **kwargs) }} {{ field.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endmacro %}
|
|
@ -0,0 +1,15 @@
|
||||||
|
{% if security.registerable or security.recoverable or security.confirmable %}
|
||||||
|
<h2>Menu</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{ url_for_security('login') }}{% if 'next' in request.args %}?next={{ request.args.next|urlencode }}{% endif %}">Login</a></li>
|
||||||
|
{% if security.registerable %}
|
||||||
|
<li><a href="{{ url_for_security('register') }}{% if 'next' in request.args %}?next={{ request.args.next|urlencode }}{% endif %}">Register</a><br/></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if security.recoverable %}
|
||||||
|
<li><a href="{{ url_for_security('forgot_password') }}">Forgot password</a><br/></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if security.confirmable %}
|
||||||
|
<li><a href="{{ url_for_security('send_confirmation') }}">Confirm account</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
|
@ -0,0 +1,9 @@
|
||||||
|
{%- with messages = get_flashed_messages(with_categories=true) -%}
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="flashes">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<li class="{{ category }}">{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{%- endwith %}
|
|
@ -0,0 +1,22 @@
|
||||||
|
{% extends 'admin/master.html' %}
|
||||||
|
{% from "security/_macros.html" import render_field, render_field_with_errors, render_checkbox_field %}
|
||||||
|
{% include "security/_messages.html" %}
|
||||||
|
{% block body %}
|
||||||
|
{{ super() }}
|
||||||
|
<div class="row-fluid">
|
||||||
|
<div class="col-sm-8 col-sm-offset-2">
|
||||||
|
<h1>Login</h1>
|
||||||
|
<div class="well">
|
||||||
|
<form action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
|
||||||
|
{{ login_user_form.hidden_tag() }}
|
||||||
|
{{ render_field_with_errors(login_user_form.email) }}
|
||||||
|
{{ render_field_with_errors(login_user_form.password) }}
|
||||||
|
{{ render_checkbox_field(login_user_form.remember) }}
|
||||||
|
{{ render_field(login_user_form.next) }}
|
||||||
|
{{ render_field(login_user_form.submit, class="btn btn-primary") }}
|
||||||
|
</form>
|
||||||
|
<p>Not yet signed up? Please <a href="{{ url_for('security.register') }}">register for an account</a>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock body %}
|
|
@ -0,0 +1,23 @@
|
||||||
|
{% extends 'admin/master.html' %}
|
||||||
|
{% from "security/_macros.html" import render_field_with_errors, render_field %}
|
||||||
|
{% include "security/_messages.html" %}
|
||||||
|
{% block body %}
|
||||||
|
{{ super() }}
|
||||||
|
<div class="row-fluid">
|
||||||
|
<div class="col-sm-8 col-sm-offset-2">
|
||||||
|
<h1>Register</h1>
|
||||||
|
<div class="well">
|
||||||
|
<form action="{{ url_for_security('register') }}" method="POST" name="register_user_form">
|
||||||
|
{{ 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") }}
|
||||||
|
</form>
|
||||||
|
<p>Already signed up? Please <a href="{{ url_for('security.login') }}">log in</a>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock body %}
|
|
@ -20,9 +20,13 @@ classifiers =
|
||||||
packages = libertywiki
|
packages = libertywiki
|
||||||
install_requires =
|
install_requires =
|
||||||
Flask
|
Flask
|
||||||
|
Flask-Admin
|
||||||
Flask-SQLAlchemy
|
Flask-SQLAlchemy
|
||||||
Flask-Bcrypt
|
Flask-Bcrypt
|
||||||
Flask-Security
|
Flask-Security-Too
|
||||||
|
bcrypt
|
||||||
|
email_validator
|
||||||
|
pyyaml
|
||||||
rst2html
|
rst2html
|
||||||
setup_requires =
|
setup_requires =
|
||||||
setuptools_scm
|
setuptools_scm
|
||||||
|
|
Loading…
Reference in New Issue