diff --git a/auth.py b/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..1faf872c8e8e01ef1ad6623a19a85d8a457c6545 --- /dev/null +++ b/auth.py @@ -0,0 +1,67 @@ +import ldap +import hmac, hashlib + +class User: + def __init__(self, username, groups): + self.username = username + self.groups = groups + + def summarize(self): + return "{}:{}".format(self.username, ",".join(self.groups)) + + @staticmethod + def from_summary(summary): + name, groupstring = summary.split(":", 1) + groups = groupstring.split(",") + return User(name, groups) + + @staticmethod + def from_hashstring(secure_string): + summary, hash = secure_string.split("=", 1) + return User.from_summary(summary) + +class LdapManager: + def __init__(self, url, base): + self.connection = ldap.initialize(url) + self.base = base + + def login(self, username, password): + if not self.authenticate(username, password): + return None + groups = list(map(lambda g: g.decode("utf-8"), self.groups(username))) + print(groups) + return User(username, groups) + + def authenticate(self, username, password): + try: + self.connection.simple_bind_s("uid={},ou=users,{}".format(username, self.base), password) + return True + except ldap.INVALID_CREDENTIALS: + return False + return False + + def groups(self, username): + result = [] + for _, result_dict in self.connection.search_s(self.base, ldap.SCOPE_SUBTREE, "(memberUid={})".format(username), ["cn"]): + result.append(result_dict["cn"][0]) + return result + +class SecurityManager: + def __init__(self, key): + self.maccer = hmac.new(key.encode("utf-8"), digestmod=hashlib.sha512) + + def hash_user(self, user): + maccer = self.maccer.copy() + summary = user.summarize() + maccer.update(summary.encode("utf-8")) + return "{}={}".format(summary, maccer.hexdigest()) + + def check_user(self, string): + parts = string.split("=", 1) + if len(parts) != 2: + # wrong format, expecting summary:hash + return False + summary, hash = map(lambda s: s.encode("utf-8"), parts) + maccer = self.maccer.copy() + maccer.update(summary) + return hmac.compare_digest(maccer.hexdigest().encode("utf-8"), hash) diff --git a/requirements.txt b/requirements.txt index ea72be555d893bfa30ec83484869df860093bf73..928fb5c88ed319661823139d7094bf5c06c041dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,37 @@ -alembic -blinker -Flask -Flask-Migrate -Flask-Script -Flask-SQLAlchemy -Flask-WTF -Flask-SocketIO -Jinja2 -Mako -MarkupSafe -psycopg2 -python-editor -SQLAlchemy -Werkzeug -wheel -WTForms -celery[redis] -regex -watchdog +alembic==0.8.10 +amqp==2.1.4 +appdirs==1.4.0 +argh==0.26.2 +billiard==3.5.0.2 +blinker==1.4 +celery==4.0.2 +click==6.7 +Flask==0.12 +Flask-Migrate==2.0.3 +Flask-Script==2.0.5 +Flask-SocketIO==2.8.4 +Flask-SQLAlchemy==2.1 +Flask-WTF==0.14.2 +itsdangerous==0.24 +Jinja2==2.9.5 +kombu==4.0.2 +Mako==1.0.6 +MarkupSafe==0.23 +packaging==16.8 +pathtools==0.1.2 +psycopg2==2.6.2 +pyldap==2.4.28 +pyparsing==2.1.10 +python-editor==1.0.3 +python-engineio==1.2.2 +python-socketio==1.7.1 +pytz==2016.10 +PyYAML==3.12 +redis==2.10.5 +regex==2017.2.8 +six==1.10.0 +SQLAlchemy==1.1.5 +vine==1.1.3 +watchdog==0.8.3 +Werkzeug==0.11.15 +WTForms==2.1 diff --git a/server.py b/server.py index 54e202d183db1b8ef4b2d6b57ac4bdc0f6544c24..c5385205793ff48453150569cb8b443d66038265 100755 --- a/server.py +++ b/server.py @@ -7,10 +7,12 @@ from flask_script import Manager, prompt from flask_migrate import Migrate, MigrateCommand #from flask_socketio import SocketIO from celery import Celery +from functools import wraps import config -from shared import db, date_filter, datetime_filter +from shared import db, date_filter, datetime_filter, ldap_manager, security_manager from utils import is_past, mail_manager, url_manager +from views.forms import LoginForm app = Flask(__name__) app.config.from_object(config) @@ -35,26 +37,63 @@ app.jinja_env.lstrip_blocks = True app.jinja_env.filters["datify"] = date_filter app.jinja_env.filters["datetimify"] = datetime_filter app.jinja_env.filters["url_complete"] = url_manager.complete +app.jinja_env.tests["auth_valid"] = security_manager.check_user import tasks +from auth import User + +def check_login(): + return "auth" in session and security_manager.check_user(session["auth"]) +def current_user(): + if not check_login(): + return None + return User.from_hashstring(session["auth"]) + +def login_required(function): + @wraps(function) + def decorated_function(*args, **kwargs): + if check_login(): + return function(*args, **kwargs) + else: + return redirect(url_for("login", next=request.url)) + return decorated_function + +app.jinja_env.globals.update(check_login=check_login) +app.jinja_env.globals.update(current_user=current_user) + # blueprints here @app.route("/") +@login_required def index(): return render_template("index.html") -@app.route("/imprint/") -def imprint(): - return render_template("imprint.html") +@app.route("/login", methods=["GET", "POST"]) +def login(): + if "auth" in session: + flash("You are already logged in.", "alert-success") + return redirect(url_for(request.args.get("next") or "index")) + form = LoginForm() + if form.validate_on_submit(): + user = ldap_manager.login(form.username.data, form.password.data) + if user is not None: + session["auth"] = security_manager.hash_user(user) + flash("Login successful, {}!".format(user.username), "alert-success") + return redirect(request.args.get("next") or url_for("index")) + else: + flash("Wrong login data. Try again.", "alert-error") + return render_template("login.html", form=form) -@app.route("/contact/") -def contact(): - return render_template("contact.html") +@app.route("/logout") +@login_required +def logout(): + if "auth" in session: + session.pop("auth") + else: + flash("You are not logged in.", "alert-error") + return redirect(url_for(".index")) -@app.route("/privacy/") -def privacy(): - return render_template("privacy.html") if __name__ == "__main__": manager.run() diff --git a/shared.py b/shared.py index d3d79364de14a3a8e18fa8d89aa672be008df95e..9db6902009e882d6a34adeb44e86cf39af617b3f 100644 --- a/shared.py +++ b/shared.py @@ -71,3 +71,7 @@ def date_filter_long(date): return date.strftime("%A, %d.%m.%Y, Kalenderwoche %W") def time_filter(time): return time.strftime("%H:%m") + +from auth import LdapManager, SecurityManager +ldap_manager = LdapManager(config.LDAP_PROVIDER_URL, config.LDAP_BASE) +security_manager = SecurityManager(config.SECURITY_KEY) diff --git a/templates/layout.html b/templates/layout.html index 1137842421915f8bc8664c1812493341f88254bc..95c63bd1ef62de301888445121cc4f0483015fdb 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -29,6 +29,13 @@ <li><a href="{{url_for("index")}}">Zuhause</a></li> {# todo: add more links #} </ul> + <ul class="nav navbar-nav navbar-right"> + {% if check_login() %} + <li><a href="{{url_for("logout")}}">Logout</a></li> + {% else %} + <li><a href="{{url_for("login")}}">Login</a></li> + {% endif %} + </ul> </div> </div> </nav> diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000000000000000000000000000000000000..fa86d85d59518580a5205050809aa05d7cdd3cc1 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,7 @@ +{% extends "layout.html" %} +{% from "macros.html" import render_form %} +{% block title %}Login{% endblock %} + +{% block content %} + {{render_form(form)}} +{% endblock %} diff --git a/templates/macros.html b/templates/macros.html new file mode 100644 index 0000000000000000000000000000000000000000..bc416ca5b18255e28a0a76d8e922b62f4e7062e6 --- /dev/null +++ b/templates/macros.html @@ -0,0 +1,109 @@ +<!-- Taken from https://gist.github.com/bearz/7394681 and modified +to not render a label for the CRSFTokenField --> + +{# Renders field for bootstrap 3 standards. + + Params: + field - WTForm field + kwargs - pass any arguments you want in order to put them into the html attributes. + There are few exceptions: for - for_, class - class_, class__ - class_ + + Example usage: + {{ macros.render_field(form.email, placeholder='Input email', type='email') }} +#} +{% macro render_field(field, label_visible=true) -%} + + <div class="form-group {% if field.errors %}has-error{% endif %} {{ kwargs.pop('class_', '') }}"> + {% if field.type != 'HiddenField' and field.type !='CSRFTokenField' and label_visible %} + <label for="{{ field.id }}" class="control-label">{{ field.label }}</label> + <!--<span onclick="el=document.getElementById('{{field.id}}-description');el.style.display=(el.style.display=='none'?'flex':'none')" class="field-description-questionmark">?</span>--> + {% endif %} + {{ field(title=field.description, class_='form-control', **kwargs) }} + {% if field.errors %} + {% for e in field.errors %} + <p class="help-block">{{ e }}</p> + {% endfor %} + {% endif %} + </div> + <div id="{{field.id}}-description" style="display:none" class="field-description">{{field.description}}</div> +{%- endmacro %} + +{# Renders checkbox fields since they are represented differently in bootstrap + Params: + field - WTForm field (there are no check, but you should put here only BooleanField. + kwargs - pass any arguments you want in order to put them into the html attributes. + There are few exceptions: for - for_, class - class_, class__ - class_ + + Example usage: + {{ macros.render_checkbox_field(form.remember_me) }} + #} +{% macro render_checkbox_field(field) -%} + <div class="checkbox {% if field.errors %}has-error{% endif %}"> + <label> + {{ field(type='checkbox', **kwargs) }} {{ field.label }} + </label> + <span onclick="el=document.getElementById('{{field.id}}-description');el.style.display=(el.style.display=='none'?'flex':'none')" class="field-description-questionmark">?</span> + {% if field.errors %} + {% for e in field.errors %} + <p class="help-block">{{ e }}</p> + {% endfor %} + {% endif %} + </div> + <div id="{{field.id}}-description" style="display:none" class="field-description">{{field.description}}</div> +{%- endmacro %} + +{# Renders radio field + Params: + field - WTForm field (there are no check, but you should put here only BooleanField. + kwargs - pass any arguments you want in order to put them into the html attributes. + There are few exceptions: for - for_, class - class_, class__ - class_ + + Example usage: + {{ macros.render_radio_field(form.answers) }} + #} +{% macro render_radio_field(field) -%} + {% for value, label, _ in field.iter_choices() %} + <div class="radio"> + <label> + <input type="radio" name="{{ field.id }}" id="{{ field.id }}" value="{{ value }}">{{ label }} + </label> + </div> + {% endfor %} +{%- endmacro %} + +{# Renders WTForm in bootstrap way. There are two ways to call function: + - as macros: it will render all field forms using cycle to iterate over them + - as call: it will insert form fields as you specify: + e.g. {% call macros.render_form(form, action_url=url_for('login_view'), action_text='Login', + class_='login-form') %} + {{ macros.render_field(form.email, placeholder='Input email', type='email') }} + {{ macros.render_field(form.password, placeholder='Input password', type='password') }} + {{ macros.render_checkbox_field(form.remember_me, type='checkbox') }} + {% endcall %} + + Params: + form - WTForm class + action_url - url where to submit this form + action_text - text of submit button + class_ - sets a class for form + #} +{% macro render_form(form, action_url='', action_text='Submit', class_='', btn_class='btn btn-default') -%} + + <form method="POST" action="{{ action_url }}" role="form" class="{{ class_ }}"> + {{ form.hidden_tag() if form.hidden_tag }} + {% if caller %} + {{ caller() }} + {% else %} + {% for f in form %} + {% if f.type == 'BooleanField' %} + {{ render_checkbox_field(f) }} + {% elif f.type == 'RadioField' %} + {{ render_radio_field(f) }} + {% else %} + {{ render_field(f) }} + {% endif %} + {% endfor %} + {% endif %} + <button type="submit" class="{{ btn_class }}">{{ action_text }} </button> + </form> +{%- endmacro %} diff --git a/views/forms.py b/views/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..175fcc24c2d558e2ebf621f31f4d9918406495a3 --- /dev/null +++ b/views/forms.py @@ -0,0 +1,7 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField +from wtforms.validators import InputRequired + +class LoginForm(FlaskForm): + username = StringField("User", validators=[InputRequired("Please input the username.")]) + password = PasswordField("Password", validators=[InputRequired("Please input the password.")])