Commit 7f9c0a5a authored by Pietro Albini's avatar Pietro Albini

Merge branch 'auth' into develop

parents 45a28f05 8e6e4e6d
Pipeline #78 passed with stage
in 0 seconds
...@@ -31,17 +31,19 @@ setuptools.setup( ...@@ -31,17 +31,19 @@ setuptools.setup(
description = "Source code of the ubuntu-it website", description = "Source code of the ubuntu-it website",
install_requires = [ install_requires = [
"flask",
"click", "click",
"flask",
"flask-openid",
"gunicorn", "gunicorn",
"requests",
"itsdangerous", "itsdangerous",
"toml",
"pyyaml", "pyyaml",
"requests",
"toml",
], ],
packages = [ packages = [
"uitwww", "uitwww",
"uitwww.third_party"
], ],
entry_points = { entry_points = {
......
# Source code of the Ubuntu-it website # Source code of the Ubuntu-it website
# Copyright (C) 2015-2016 Pietro Albini <pietroalbini@ubuntu.com> # Copyright (C) 2015-2018 Pietro Albini <pietroalbini@ubuntu.com>
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published # it under the terms of the GNU Affero General Public License as published
...@@ -18,11 +18,14 @@ import os ...@@ -18,11 +18,14 @@ import os
import flask import flask
from . import pages from . import auth
from . import cache from . import cache
from . import db
from . import pages
from . import utils from . import utils
from . import download from . import download
from . import navbar from . import navbar
from . import utils
def create_app(data_path): def create_app(data_path):
...@@ -42,14 +45,21 @@ def create_app(data_path): ...@@ -42,14 +45,21 @@ def create_app(data_path):
with open(os.path.join(data_path, "secret_key")) as f: with open(os.path.join(data_path, "secret_key")) as f:
app.secret_key = f.read().strip() app.secret_key = f.read().strip()
# Initialize the database
app.db = db.Database(os.path.join(data_path, "database.db"))
app.db.init()
app.config["CACHE_PATH"] = os.path.join(data_path, "cache") app.config["CACHE_PATH"] = os.path.join(data_path, "cache")
cache.install_cache(app) cache.install_cache(app)
utils.prepare_app(app)
app.download = download.Downloads(data_path) app.download = download.Downloads(data_path)
app.register_blueprint( app.register_blueprint(
app.download.prepare_blueprint(app), app.download.prepare_blueprint(app),
url_prefix="/download", url_prefix="/download",
) )
app.register_blueprint(auth.prepare_blueprint(app), url_prefix="/+auth")
app.register_blueprint(pages.prepare_blueprint(app)) app.register_blueprint(pages.prepare_blueprint(app))
...@@ -57,6 +67,10 @@ def create_app(data_path): ...@@ -57,6 +67,10 @@ def create_app(data_path):
nav.add_generator("download-distros", app.download.generate_navbar) nav.add_generator("download-distros", app.download.generate_navbar)
nav.install(app) nav.install(app)
@app.errorhandler(403)
def forbidden(error):
return flask.render_template("403.html"), 403
@app.errorhandler(404) @app.errorhandler(404)
def not_found(error): def not_found(error):
return flask.render_template("404.html"), 404 return flask.render_template("404.html"), 404
......
...@@ -51,6 +51,7 @@ def run(data, gunicorn_config, port, public, workers, debug): ...@@ -51,6 +51,7 @@ def run(data, gunicorn_config, port, public, workers, debug):
if debug: if debug:
extra_files = [ extra_files = [
os.path.join(src_directory, "data/navbar.yml"), os.path.join(src_directory, "data/navbar.yml"),
os.path.join(src_directory, "data/permissions.yml"),
] ]
app.run(debug=True, port=port, host=host, extra_files=extra_files) app.run(debug=True, port=port, host=host, extra_files=extra_files)
......
# Source code of the Ubuntu-it website
# Copyright (C) 2018 Pietro Albini <pietroalbini@ubuntu.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; witout even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import functools
import uuid
import flask
import flask_openid
import pkg_resources
import time
import yaml
from uitwww.third_party import openid_teams
SESSION_EXPIRES_AFTER = 86400
class SessionError(BaseException):
pass
class Sessions:
def __init__(self, db):
self.db = db
def create(self, nickname, teams):
"""Create a new session and return its ID"""
id = str(uuid.uuid4())
ip = flask.request.remote_addr
self.db.update(
"INSERT INTO auth_sessions (id, nickname, teams, ip, expires_at) "
"VALUES (?, ?, ?, ?, ?)", id, nickname, ",".join(teams), ip,
int(time.time()) + SESSION_EXPIRES_AFTER,
)
return id
def check(self, id):
"""Check if a session is valid and return its data"""
data = self.db.query(
"SELECT nickname, teams, ip, expires_at FROM auth_sessions "
"WHERE id = ?", id,
)
if not data:
raise SessionError("La tua sessione è scaduta, accedi di nuovo.")
nickname = data[0][0]
teams = data[0][1].split(",")
ip = data[0][2]
expires_at = data[0][3]
if expires_at is None or expires_at < time.time():
self.delete(id)
raise SessionError("La tua sessione è scaduta, accedi di nuovo.")
else:
# Bump the session every time a new page is visited
self.db.update(
"UPDATE auth_sessions SET expires_at = ? WHERE id = ?",
int(time.time()) + SESSION_EXPIRES_AFTER, id,
)
if ip != flask.request.remote_addr:
raise SessionError("Questa sessione è valida solo su un'altra rete, accedi di nuovo.")
return {
"nickname": data[0][0],
"teams": data[0][1].split(","),
}
def delete(self, id):
"""Delete a session"""
self.db.update("DELETE FROM auth_sessions WHERE id = ?;", id)
def delete_all(self, except_id):
"""Delete every session except this one"""
self.db.update("DELETE FROM auth_sessions WHERE id != ?;", except_id)
def all(self):
"""Return all the sessions"""
rows = self.db.query("SELECT id, nickname, teams, ip, expires_at FROM auth_sessions;")
now = time.time()
result = []
for row in rows:
# Delete expired sessions
if row[0] is None or row[4] < now:
self.delete(row[0])
continue
result.append({
"id": row[0],
"nickname": row[1],
"teams": row[2].split(","),
"ip": row[3],
"expires_at": row[4],
})
return result
def get(self, id):
"""Return details about a session"""
row = self.db.query("SELECT nickname, teams, ip, expires_at FROM auth_sessions WHERE id = ?;", id)
if row:
# Delete expired sessions
if row[0][3] is None or row[0][3] < time.time():
self.delete(id)
return
return {
"id": id,
"nickname": row[0][0],
"teams": row[0][1].split(","),
"ip": row[0][2],
"expires_at": row[0][3],
}
def count(self):
"""Return the number of active sessions"""
now = time.time()
return self.db.query(
"SELECT COUNT(*) FROM auth_sessions "
"WHERE expires_at IS NOT NULL AND expires_at >= ?;", now,
)[0][0]
class Permissions:
def __init__(self):
raw = pkg_resources.resource_string("uitwww", "data/permissions.yml")
self.config = yaml.load(raw.decode("utf-8"))
def allowed_teams(self):
"""Return a list of teams allowed to log in"""
return list(self.config["teams"].keys())
def check(self, name):
"""Check if the current user has the permission"""
# The user must be authenticated
if "auth_teams" not in flask.g:
return False
# The permission must exist
if name not in self.config["permissions"]:
raise RuntimeError("Missing permission: %s" % name)
# Check if one of the user's teams has the permission
for team in flask.g.auth_teams:
if self.config["teams"][team] == "*":
return True
elif name in self.config["teams"][team]:
return True
return False
def permission(perms):
"""Process the endpoint only if the user has permission"""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
# Don't check anything if the user is not logged in
if "auth_id" not in flask.g:
return flask.abort(403)
# Generate the list of allowed permissions
names = []
if type(perms) == dict:
for name, condition in perms.items():
if condition(*args, **kwargs):
names.append(name)
else:
names.append(perms)
# Check each allowed permission
for name in names:
if flask.current_app.permissions.check(name):
return func(*args, **kwargs)
return flask.abort(403)
return wrapper
return decorator
def prepare_blueprint(app):
"""Prepare the auth blueprint"""
bp = flask.Blueprint("auth", __name__)
oid = flask_openid.OpenID(
app,
safe_roots=[],
extension_responses=[openid_teams.TeamsResponse],
)
sessions = Sessions(app.db)
app.permissions = Permissions()
@app.context_processor
def add_permission_function():
return {
"permission": app.permissions.check,
}
@app.before_request
def check_auth():
if "auth" in flask.session:
try:
data = sessions.check(flask.session["auth"])
except SessionError as e:
del flask.session["auth"]
flask.flash(str(e), "error")
return
flask.g.auth_id = flask.session["auth"]
flask.g.auth_name = data["nickname"]
flask.g.auth_teams = data["teams"]
flask.g.auth_sessions_count = sessions.count()
@oid.after_login
def receive_openid(resp):
teams = resp.extensions["lp"].is_member
if not teams or teams == [""]:
flask.flash("Non hai i permessi per accedere al sito.", "error")
return flask.redirect(flask.url_for("pages.index"))
flask.session["auth"] = sessions.create(resp.nickname, teams)
flask.flash("Benvenuto %s!" % resp.nickname, "success")
return flask.redirect(flask.url_for("pages.index"))
@bp.route("/login")
@oid.loginhandler
def login():
if "auth_name" not in flask.g:
return oid.try_login(
"https://login.ubuntu.com/+openid",
ask_for=["nickname"],
extensions=[
openid_teams.TeamsRequest(app.permissions.allowed_teams())
],
)
else:
flask.flash("Hai già effettuato l'accesso!", "info")
return flask.redirect(flask.url_for("pages.index"))
@bp.route("/logout")
@permission("auth.logout")
def logout():
sessions.delete(flask.session["auth"])
del flask.session["auth"]
flask.flash("La sessione è stata terminata correttamente.", "success")
return flask.redirect(flask.url_for("pages.index"))
@bp.route("/sessions")
@permission("auth.sessions.manage")
def sessions_list():
return flask.render_template(
"auth/sessions.html",
sessions=sessions.all(),
)
@bp.route("/sessions/+all/revoke")
@permission("auth.sessions.manage")
def sessions_revoke_all():
sessions.delete_all(flask.g.auth_id)
return flask.redirect(flask.url_for(".sessions_list"))
@bp.route("/sessions/<id>")
@permission({
"auth.sessions.manage": lambda id: True,
"auth.sessions.own": lambda id: flask.g.auth_id == id,
})
def sessions_show(id):
data = sessions.get(id)
if data is None:
return flask.abort(404)
all = sessions.all()
others = []
for session in all:
if session["id"] == id or session["nickname"] != data["nickname"]:
continue
others.append(session)
return flask.render_template(
"auth/session.html",
session=data,
others=others,
)
@bp.route("/sessions/<id>/revoke")
@permission({
"auth.sessions.manage": lambda id: True,
"auth.sessions.own": lambda id: flask.g.auth_id == id,
})
def sessions_revoke(id):
sessions.delete(id)
return flask.redirect(flask.url_for(".sessions_list"))
return bp
...@@ -61,6 +61,14 @@ def after_request(response): ...@@ -61,6 +61,14 @@ def after_request(response):
else: else:
cache_path = os.path.realpath(app.config["CACHE_PATH"]) cache_path = os.path.realpath(app.config["CACHE_PATH"])
# Don't cache the page if flashed messages were displayed
if flask.get_flashed_messages():
return response
# Don't cache the page if the user is authenticated
if "auth_name" in flask.g:
return response
url = flask.request.path url = flask.request.path
method = flask.request.method method = flask.request.method
status = response.status_code status = response.status_code
......
permissions:
- auth.logout
- auth.sessions.manage
- auth.sessions.own
teams:
ubuntu-it-council: "*"
ubuntu-it-www: "*"
ubuntu-it-members:
- auth.sessions.own
- auth.logout
ubuntu-it-newsletter:
- auth.sessions.own
- auth.logout
# Source code of the Ubuntu-it website
# Copyright (C) 2015 Pietro Albini <pietroalbini@ubuntu.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; witout even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import threading
import sqlite3
import pkg_resources
_LOCAL = threading.local()
class Database:
def __init__(self, path):
self._path = path
def init(self):
"""Initialize the database"""
db_version = int(self.query("PRAGMA user_version;")[0][0])
cursor = self.cursor()
if db_version == 0:
cursor.executescript("""
CREATE TABLE migrations (name TEXT PRIMARY KEY);
PRAGMA user_version = 1;
""")
applied = [m[0] for m in self.query("SELECT * FROM migrations;")]
for name, sql in MIGRATIONS:
if name in applied:
continue
cursor.executescript(sql)
cursor.execute("INSERT INTO migrations (name) VALUES (?)", [name])
cursor.connection.commit()
def cursor(self):
"""Get a new cursor"""
if not hasattr(_LOCAL, "db"):
_LOCAL.db = sqlite3.connect(self._path)
return _LOCAL.db.cursor()
def query(self, query, *params, update=False):
"""Make a new query against the db"""
cursor = self.cursor()
cursor.execute(query, params)
try:
if update:
cursor.connection.commit()
else:
return cursor.fetchall()
finally:
cursor.close()
def update(self, query, *params):
"""Make a new update query against the db"""
self.query(query, *params, update=True)
MIGRATIONS = [
("create_auth_sessions_table", """
CREATE TABLE auth_sessions (
id TEXT PRIMARY KEY,
nickname TEXT NOT NULL,
teams TEXT NOT NULL,
ip TEXT NOT NULL
);
"""),
("add_auth_sessions_expires_at_column", """
ALTER TABLE auth_sessions ADD COLUMN expires_at INTEGER;
"""),
]
{# Source code of the Ubuntu-it website
# Copyright (C) 2018 Pietro Albini <pietroalbini@ubuntu.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; witout even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "layout.html" %}
{% block title %}Accesso negato{% endblock %}
{% block content %}
<div class="page">
<div class="row">
<div class="col">
<h1>Accesso negato</h1>
{% if g.auth_id %}
<p>
Il tuo gruppo non dispone dei permessi necessari per
visualizzare questa pagina.
</p>
{% else %}
<p>
Questa pagina è riservata ai membri di Ubuntu-it con
adeguati permessi. <a href="{{ url_for("auth.login") }}">
Effettua l'accesso</a> per visualizzarla.
</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{# Source code of the Ubuntu-it website
# Copyright (C) 2018 Pietro Albini <pietroalbini@ubuntu.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; witout even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "layout.html" %}
{% block title %}Dettagli sessione{% endblock %}
{% block content %}
<div class="page">
<div class="row">
<div class="col">
<h1>Dettagli sessione</h1>
<div class="table">
<table>
<tr>
<td>Nome utente</td>
<td><b>{{ session.nickname }}</b></td>
</tr>
<tr>
<td>Indirizzo IP</td>
<td>{{ session.ip }}</td>
</tr>
<tr>
<td>Scadenza</td>
<td>{{ session.expires_at|format_timestamp }}</td>
</tr>
<tr>
<td>Team</td>
<td>
{% for team in session.teams %}
<a href="https://launchpad.net/~{{ team }}">
~{{ team -}}
</a>
{%- if not loop.last %},{% endif %}
{% endfor %}
</td>
</tr>
</table>
</div>
{% if g.auth_id != session.id %}
<a class="btn" href="{{ url_for(".sessions_revoke", id=session.id) }}">
Disabilita sessione
</a>
{% endif %}
</div>
</div>
{% if others %}
<div class="row">
<div class="col">
<h2>Altre sessioni dell'utente</h2>
<div class="table">
<table>
<tr>
<th>Indirizzo IP</th>
<th>Scadenza</th>
<th></th>
<th></th>
</tr>
{% for session in others %}
<tr>
<td>{{ session.ip }}</td>
<td>{{ session.expires_at|format_timestamp }}
<td>
<a href="{{ url_for(".sessions_show", id=session.id) }}">
Dettagli
</a>
</td>
<td>
{% if session.id == g.auth_id %}
Sessione corrente
{% else %}
<a href="{{ url_for(".sessions_revoke", id=session.id) }}">
Disabilita sessione
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{# Source code of the Ubuntu-it website
# Copyright (C) 2018 Pietro Albini <pietroalbini@ubuntu.com>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; witout even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "layout.html" %}
{% block title %}Sessioni attive{% endblock %}
{% block content %}
<div class="page">
<div class="row">
<div class="col">
<h1>Sessioni attive</h1>
{% if g.auth_sessions_count > 1 %}
<p>
<a class="btn" href="{{ url_for(".sessions_revoke_all") }}">
Disabilita ogni altra sessione
</a>
</p>
{% endif %}
</div>
</div>
<div class="row">
<div class="col">
<div class="table">
<table>
<tr>
<th>Nome utente</th>
<th>Indirizzo IP</th>
<th>Scadenza</th>
<th></th>
<th></th>
</tr>
{% for session in sessions %}
<tr>
<td>{{ session.nickname }}</td>
<td>{{ session.ip }}</td>
<td>{{ session.expires_at|format_timestamp }}</td>
<td>
<a href="{{ url_for(".sessions_show", id=session.id) }}">
Dettagli
</a>
</td>
<td>
{% if session.id == g.auth_id %}
Sessione corrente
{% else %}
<a href="{{ url_for(".sessions_revoke", id=session.id) }}">
Disabilita sessione
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
</div>
</div>
{% endblock %}
...@@ -44,6 +44,30 @@ ...@@ -44,6 +44,30 @@
</div> </div>
</div> </div>
{% if g.auth_name %}
<nav class="sites-list">
<div class="container">
<ul class="left">
{% if permission("auth.sessions.manage") %}
<li><a href="{{ url_for("auth.sessions_list") }}">
Sessioni attive: {{ g.auth_sessions_count }}
</a></li>
{% endif %}
</ul>
<ul class="right">
{% if permission("auth.sessions.own") %}
<li><a href="{{ url_for("auth.sessions_show", id=g.auth_id) }}">
{{ g.auth_name }}
</a></li>
{% endif %}
{% if permission("auth.logout") %}
<li><a href="{{ url_for("auth.logout") }}">Esci</a></li>
{% endif %}
</ul>
</div>
</nav>
{% endif %}
<nav class="sites-list"> <nav class="sites-list">
<div class="container"> <div class="container">
<ul> <ul>
...@@ -84,6 +108,17 @@ ...@@ -84,6 +108,17 @@
{% endfor %} {% endfor %}
<div class="container"> <div class="container">
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert {% if category != "message" %}{{ category }}{% else %}info{% endif %}">
{{ message }}
<span class="close">&times;</span>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
<footer> <footer>
...@@ -95,7 +130,7 @@ ...@@ -95,7 +130,7 @@
Sito web realizzato dal Gruppo Web di Ubuntu-it, con Sito web realizzato dal Gruppo Web di Ubuntu-it, con
<a href="https://www.python.org/">Python</a>, <a href="https://www.python.org/">Python</a>,
<a href="https://www.palletsproject.com/p/flask/">Flask</a> e <a href="https://www.palletsproject.com/p/flask/">Flask</a> e
<a href="http://www.postgresql.org/">PostgreSQL</a>. <a href="https://www.sqlite.org/">SQLite</a>.
</p> </p>
<ul> <ul>
<li><a href="{{ url_for("pages.cookies") }}"> <li><a href="{{ url_for("pages.cookies") }}">
...@@ -110,6 +145,11 @@ ...@@ -110,6 +145,11 @@
<li><a href="https://wiki.ubuntu-it.org/GruppoWeb"> <li><a href="https://wiki.ubuntu-it.org/GruppoWeb">
Collabora con noi Collabora con noi
</a></li> </a></li>
{% if not g.auth_name %}
<li><a href="{{ url_for("auth.login") }}">
Accedi
</a></li>
{% endif %}
</ul> </ul>
</footer> </footer>
</div> </div>
......
This diff is collapsed.
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import random import random
import string import string
...@@ -100,3 +101,13 @@ class ReverseProxied: ...@@ -100,3 +101,13 @@ class ReverseProxied:
if scheme: if scheme:
environ['wsgi.url_scheme'] = scheme environ['wsgi.url_scheme'] = scheme
return self.app(environ, start_response) return self.app(environ, start_response)
def prepare_app(app):
"""Add some utilities to the app"""
@app.template_filter("format_timestamp")
def format_timestamp(timestamp):
return datetime.datetime.fromtimestamp(
int(timestamp)
).strftime('%d/%m/%Y %H:%M:%S')
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment