diff options
Diffstat (limited to 'python/fatcat_web')
-rw-r--r-- | python/fatcat_web/__init__.py | 32 | ||||
-rw-r--r-- | python/fatcat_web/auth.py | 141 | ||||
-rw-r--r-- | python/fatcat_web/routes.py | 78 | ||||
-rw-r--r-- | python/fatcat_web/templates/auth_account.html | 27 | ||||
-rw-r--r-- | python/fatcat_web/templates/auth_ia_login.html | 31 | ||||
-rw-r--r-- | python/fatcat_web/templates/auth_login.html | 18 | ||||
-rw-r--r-- | python/fatcat_web/templates/auth_logout.html | 8 | ||||
-rw-r--r-- | python/fatcat_web/templates/auth_token_login.html | 29 | ||||
-rw-r--r-- | python/fatcat_web/templates/base.html | 32 | ||||
-rw-r--r-- | python/fatcat_web/templates/editor_changelog.html | 4 | ||||
-rw-r--r-- | python/fatcat_web/templates/editor_view.html | 4 | ||||
-rw-r--r-- | python/fatcat_web/web_config.py | 64 |
12 files changed, 446 insertions, 22 deletions
diff --git a/python/fatcat_web/__init__.py b/python/fatcat_web/__init__.py index 3c790e7a..cd7af195 100644 --- a/python/fatcat_web/__init__.py +++ b/python/fatcat_web/__init__.py @@ -2,21 +2,47 @@ from flask import Flask from flask_uuid import FlaskUUID from flask_debugtoolbar import DebugToolbarExtension +from flask_login import LoginManager +from authlib.flask.client import OAuth +from loginpass import create_flask_blueprint, Gitlab from raven.contrib.flask import Sentry -from web_config import Config import fatcat_client +from fatcat_web.web_config import Config + toolbar = DebugToolbarExtension() app = Flask(__name__) app.config.from_object(Config) toolbar = DebugToolbarExtension(app) FlaskUUID(app) +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = "/auth/login" +oauth = OAuth(app) + # Grabs sentry config from SENTRY_DSN environment variable sentry = Sentry(app) conf = fatcat_client.Configuration() -conf.host = "http://localhost:9411/v0" +conf.host = Config.FATCAT_API_HOST api = fatcat_client.DefaultApi(fatcat_client.ApiClient(conf)) -from fatcat_web import routes +def auth_api(token): + conf = fatcat_client.Configuration() + conf.api_key["Authorization"] = token + conf.api_key_prefix["Authorization"] = "Bearer" + conf.host = Config.FATCAT_API_HOST + return fatcat_client.DefaultApi(fatcat_client.ApiClient(conf)) + +if Config.FATCAT_API_AUTH_TOKEN: + print("Found and using privileged token (eg, for account signup)") + priv_api = auth_api(Config.FATCAT_API_AUTH_TOKEN) +else: + print("No privileged token found") + priv_api = None + +from fatcat_web import routes, auth + +gitlab_bp = create_flask_blueprint(Gitlab, oauth, auth.handle_oauth) +app.register_blueprint(gitlab_bp, url_prefix='/auth/gitlab') diff --git a/python/fatcat_web/auth.py b/python/fatcat_web/auth.py new file mode 100644 index 00000000..8035cbe5 --- /dev/null +++ b/python/fatcat_web/auth.py @@ -0,0 +1,141 @@ + +from collections import namedtuple +import requests +import pymacaroons +from flask import Flask, render_template, send_from_directory, request, \ + url_for, abort, g, redirect, jsonify, session, flash +from fatcat_web import login_manager, api, priv_api, Config +from flask_login import logout_user, login_user, UserMixin +import fatcat_client + +def handle_logout(): + logout_user() + for k in ('editor', 'api_token'): + if k in session: + session.pop(k) + session.clear() + +def handle_token_login(token): + try: + m = pymacaroons.Macaroon.deserialize(token) + except pymacaroons.exceptions.MacaroonDeserializationException: + # TODO: what kind of Exceptions? + return abort(400) + # extract editor_id + editor_id = None + for caveat in m.first_party_caveats(): + caveat = caveat.caveat_id + if caveat.startswith(b"editor_id = "): + editor_id = caveat[12:].decode('utf-8') + if not editor_id: + abort(400) + # fetch editor info + editor = api.get_editor(editor_id) + session.permanent = True + session['api_token'] = token + session['editor'] = editor.to_dict() + login_user(load_user(editor.editor_id)) + return redirect("/auth/account") + +# This will need to login/signup via fatcatd API, then set token in session +def handle_oauth(remote, token, user_info): + if user_info: + # fetch api login/signup using user_info + # ISS is basically the API url (though more formal in OIDC) + # SUB is the stable internal identifier for the user (not usually the username itself) + # TODO: should have the real sub here + # TODO: would be nicer to pass preferred_username for account creation + iss = remote.OAUTH_CONFIG['api_base_url'] + + # we reuse 'preferred_username' for account name auto-creation (but + # don't store it otherwise in the backend, at least currently). But i'm + # not sure all loginpass backends will set it + if user_info.get('preferred_username'): + preferred_username = user_info['preferred_username'] + else: + preferred_username = user_info['sub'] + + params = fatcat_client.AuthOidc(remote.name, user_info['sub'], iss, user_info['preferred_username']) + # this call requires admin privs + (resp, http_status, http_headers) = priv_api.auth_oidc_with_http_info(params) + editor = resp.editor + api_token = resp.token + + if http_status == 201: + flash("Welcome to Fatcat! An account has been created for you with a temporary username; you may wish to change it under account settings") + flash("You must use the same mechanism ({}) to login in the future".format(remote.name)) + else: + flash("Welcome back!") + + # write token and username to session + session.permanent = True + session['api_token'] = api_token + session['editor'] = editor.to_dict() + + # call login_user(load_user(editor_id)) + login_user(load_user(editor.editor_id)) + return redirect("/auth/account") + + # XXX: what should this actually be? + raise Exception("didn't receive OAuth user_info") + +def handle_ia_xauth(email, password): + resp = requests.post(Config.IA_XAUTH_URI, + params={'op': 'authenticate'}, + json={ + 'version': '1', + 'email': email, + 'password': password, + 'access': Config.IA_XAUTH_CLIENT_ID, + 'secret': Config.IA_XAUTH_CLIENT_SECRET, + }) + if resp.status_code == 401 or (not resp.json().get('success')): + flash("Internet Archive email/password didn't match: {}".format(resp.json()['values']['reason'])) + return render_template('auth_ia_login.html', email=email), resp.status_code + elif resp.status_code != 200: + flash("Internet Archive login failed (internal error?)") + # TODO: log.warn + print("IA XAuth fail: {}".format(resp.content)) + return render_template('auth_ia_login.html', email=email), resp.status_code + + # Successful login; now fetch info... + resp = requests.post(Config.IA_XAUTH_URI, + params={'op': 'info'}, + json={ + 'version': '1', + 'email': email, + 'access': Config.IA_XAUTH_CLIENT_ID, + 'secret': Config.IA_XAUTH_CLIENT_SECRET, + }) + if resp.status_code != 200: + flash("Internet Archive login failed (internal error?)") + # TODO: log.warn + print("IA XAuth fail: {}".format(resp.content)) + return render_template('auth_ia_login.html', email=email), resp.status_code + ia_info = resp.json()['values'] + + # and pass off "as if" we did OAuth successfully + FakeOAuthRemote = namedtuple('FakeOAuthRemote', ['name', 'OAUTH_CONFIG']) + remote = FakeOAuthRemote(name='archive', OAUTH_CONFIG={'api_base_url': Config.IA_XAUTH_URI}) + oauth_info = { + 'preferred_username': ia_info['screenname'], + 'iss': Config.IA_XAUTH_URI, + 'sub': ia_info['itemname'], + } + return handle_oauth(remote, None, oauth_info) + +@login_manager.user_loader +def load_user(editor_id): + # looks for extra info in session, and updates the user object with that. + # If session isn't loaded/valid, should return None + if (not session.get('editor')) or (not session.get('api_token')): + return None + editor = session['editor'] + token = session['api_token'] + user = UserMixin() + user.id = editor_id + user.editor_id = editor_id + user.username = editor['username'] + user.token = token + return user + diff --git a/python/fatcat_web/routes.py b/python/fatcat_web/routes.py index 998697bc..789d7bed 100644 --- a/python/fatcat_web/routes.py +++ b/python/fatcat_web/routes.py @@ -2,8 +2,10 @@ import os import json from flask import Flask, render_template, send_from_directory, request, \ - url_for, abort, g, redirect, jsonify, session -from fatcat_web import app, api + url_for, abort, g, redirect, jsonify, session, flash +from flask_login import login_required +from fatcat_web import app, api, auth_api +from fatcat_web.auth import handle_token_login, handle_logout, load_user, handle_ia_xauth from fatcat_client.rest import ApiException from fatcat_web.search import do_search @@ -295,12 +297,6 @@ def work_view(ident): return render_template('deleted_entity.html', entity=entity) return render_template('work_view.html', work=entity, releases=releases) -@app.route('/editgroup/current', methods=['GET']) -def editgroup_current(): - raise NotImplementedError - #eg = api.get_or_create_editgroup() - #return redirect('/editgroup/{}'.format(eg.id)) - @app.route('/editgroup/<ident>', methods=['GET']) def editgroup_view(ident): try: @@ -327,6 +323,17 @@ def editor_changelog(ident): return render_template('editor_changelog.html', editor=editor, changelog_entries=changelog_entries) +@app.route('/editor/<ident>/wip', methods=['GET']) +def editor_wip(ident): + raise NotImplementedError + try: + editor = api.get_editor(ident) + entries = api.get_editor_wip(ident) + except ApiException as ae: + abort(ae.status) + return render_template('editor_changelog.html', editor=editor, + entries=entries) + @app.route('/changelog', methods=['GET']) def changelog_view(): try: @@ -367,6 +374,61 @@ def search(): return render_template('release_search.html', query=query, fulltext_only=fulltext_only) +### Auth #################################################################### + +@app.route('/auth/login') +def login(): + # show the user a list of login options + return render_template('auth_login.html') + +@app.route('/auth/ia/login', methods=['GET', 'POST']) +def ia_xauth_login(): + if 'email' in request.form: + # if a login attempt... + return handle_ia_xauth(request.form.get('email'), request.form.get('password')) + # else show form + return render_template('auth_ia_login.html') + +@app.route('/auth/token_login', methods=['GET', 'POST']) +def token_login(): + # show the user a list of login options + if 'token' in request.args: + return handle_token_login(request.args.get('token')) + if 'token' in request.form: + return handle_token_login(request.form.get('token')) + return render_template('auth_token_login.html') + +@app.route('/auth/change_username', methods=['POST']) +@login_required +def change_username(): + # show the user a list of login options + if not 'username' in request.form: + abort(400) + # on behalf of user... + user_api = auth_api(session['api_token']) + editor = user_api.get_editor(session['editor']['editor_id']) + editor.username = request.form['username'] + editor = user_api.update_editor(editor.editor_id, editor) + # update our session + session['editor'] = editor.to_dict() + load_user(editor.editor_id) + flash("Username updated successfully") + return redirect('/auth/account') + +@app.route('/auth/logout') +def logout(): + handle_logout() + return render_template('auth_logout.html') + +@app.route('/auth/account') +@login_required +def auth_account(): + editor = api.get_editor(session['editor']['editor_id']) + session['editor'] = editor.to_dict() + load_user(editor.editor_id) + return render_template('auth_account.html') + + ### Static Routes ########################################################### @app.errorhandler(404) diff --git a/python/fatcat_web/templates/auth_account.html b/python/fatcat_web/templates/auth_account.html new file mode 100644 index 00000000..57155722 --- /dev/null +++ b/python/fatcat_web/templates/auth_account.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block body %} + +<h1>Your Account</h1> + +<p><b>Username:</b> <code>{{ current_user.username }}</code> +<p><b>Editor Id:</b> <code><a href="/editor/{{ current_user.editor_id }}">{{ current_user.editor_id }}</a></code> + +<div> +<p>Change username: +<form class="" role="change_username" action="/auth/change_username" method="post"> + <div class="ui form"> + <div class="ui action input medium fluid"> + <input type="text" name="username" value="{{ current_user.username }}" aria-label="account username"> + <button class="ui button">Update</button> + </div> + </div> +</form> +</div> + +<p>In the future, you might be able to... +<ul> + <li>Create a bot user + <li>Generate an API token +</ul> + +{% endblock %} diff --git a/python/fatcat_web/templates/auth_ia_login.html b/python/fatcat_web/templates/auth_ia_login.html new file mode 100644 index 00000000..ebf08021 --- /dev/null +++ b/python/fatcat_web/templates/auth_ia_login.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block body %} +<h1>Login with Internet Archive account</h1> + +<p>Warning: still experimental! + +<br> +<br> +<br> + +{% if current_user.is_authenticated %} + <div class="ui negative message"> + <div class="header">You are already logged in!</div> + <p>You should logout first. Re-authenticating would be undefined behavior. + </div> +{% else %} + <form class="" role="login" action="/auth/ia/login" method="post"> + <div class="ui form"> + <div class="ui input huge fluid"> + <input type="email" placeholder="user@domain.tdl..." name="email" {% if email %}value="{{ email }}"{% endif %} aria-label="email for login"> + </div> + <div class="ui action input huge fluid"> + <input type="password" placeholder="password" name="password" aria-label="internet archive password"> + <button class="ui button">Login</button> + </div> + </div> + </div> + </form> +{% endif %} + +{% endblock %} diff --git a/python/fatcat_web/templates/auth_login.html b/python/fatcat_web/templates/auth_login.html new file mode 100644 index 00000000..9ccae816 --- /dev/null +++ b/python/fatcat_web/templates/auth_login.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block body %} +<h1>Login</h1> + +<p>via OAuth / OpenID Connect: +<ul> + <li><a href="/auth/gitlab/login">gitlab.com</a> + <li><strike><a href="/auth/google/login">google.com</a></strike> + <li><strike><a href="/auth/orcid/login">orcid.org</a></strike> +</ul> + +<p>Other options... +<ul> + <li><a href="/auth/token_login">Using auth token</a> (admin/operator) + <li><a href="/auth/ia/login">With Internet Archive account</a> (experimental) +</ul> + +{% endblock %} diff --git a/python/fatcat_web/templates/auth_logout.html b/python/fatcat_web/templates/auth_logout.html new file mode 100644 index 00000000..819d42fe --- /dev/null +++ b/python/fatcat_web/templates/auth_logout.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% block body %} +<h1>Logout</h1> + +<p>If you are seeing this page, you are now logged out. + +<p>Use the links above to return to the home page or log back in. +{% endblock %} diff --git a/python/fatcat_web/templates/auth_token_login.html b/python/fatcat_web/templates/auth_token_login.html new file mode 100644 index 00000000..4c28f938 --- /dev/null +++ b/python/fatcat_web/templates/auth_token_login.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block body %} +<h1>Login with Token</h1> + +<p>This page is intended for operators and contingencies, not for general use. It +allows editors (users) to use an existing token (macaroon) for authentication; +a new web interface session and cookie are constructed using the token. + +<br> +<br> +<br> + +{% if current_user.is_authenticated %} + <div class="ui negative message"> + <div class="header">You are already logged in!</div> + <p>You should logout first. Re-authenticating would be undefined behavior. + </div> +{% else %} + <form class="" role="login" action="/auth/token_login" method="post"> + <div class="ui form"> + <div class="ui action input huge fluid"> + <input type="password" placeholder="Your Fatcat API Auth Token..." name="token" value="{% if token %}{{ token }}{% endif %}" aria-label="login using token"> + <button class="ui button">Login</button> + </div> + </div> + </form> +{% endif %} + +{% endblock %} diff --git a/python/fatcat_web/templates/base.html b/python/fatcat_web/templates/base.html index 4b3b7e0b..cce841e5 100644 --- a/python/fatcat_web/templates/base.html +++ b/python/fatcat_web/templates/base.html @@ -29,23 +29,41 @@ </div> </form> </div> +{% if current_user.is_authenticated %} <div class="ui simple dropdown item"> - demo-user <i class="dropdown icon"></i> + {{ current_user.username }} <i class="dropdown icon"></i> <div class="menu"> - <a class="item" href="/editgroup/current"><i class="edit icon"></i>Edits in Progress</a> - <a class="item" href="/editor/aaaaaaaaaaaabkvkaaaaaaaaae/changelog"><i class="history icon"></i>History</a> + <a class="item" href="#"><i class="edit icon"></i>Edits in Progress</a> + <a class="item" href="/editor/{{ current_user.editor_id }}/changelog"><i class="history icon"></i>History</a> <div class="divider"></div> - <a class="item" href="/editor/aaaaaaaaaaaabkvkaaaaaaaaae"><i class="user icon"></i>Account</a> - <a class="item" href="/logout"><i class="sign out icon"></i>Logout</a> + <a class="item" href="/auth/account"><i class="user icon"></i>Account</a> + <a class="item" href="/auth/logout"><i class="sign out icon"></i>Logout</a> </div> </div> - +{% else %} + <div class="ui simple item"> + <a href="/auth/login">Login/Signup</a> + </div> +{% endif %} </div> </div> </header> <!-- 4em top margin is "enough" --> <main class="ui main container" style="margin-top: 6em; margin-bottom: 2em;"> +{% with messages = get_flashed_messages() %} + {% if messages %} + <div class="ui message"> + {# Needs more javascript: <i class="close icon"></i> #} + <div class="header">Now Hear This...</div> + <ul class="list"> + {% for message in messages %} + <li>{{ message }} + {% endfor %} + </ul> + </div> + {% endif %} +{% endwith %} {% block fullbody %} <div class="ui container text"> {% block body %}Nothing to see here.{% endblock %} @@ -62,7 +80,7 @@ <a class="item" href="https://guide.{{ config.FATCAT_DOMAIN }}/sources.html">Sources</a> <a class="item" href="{% if config.FATCAT_DOMAIN == "fatcat.wiki" %}https://stats.uptimerobot.com/GM9YNSrB0{% elif config.FATCAT_DOMAIN =="qa.fatcat.wiki" %}https://stats.uptimerobot.com/WQ8wAUREA{% else %}#{% endif %}">Status</a> <a class="item" href="https://guide.{{ config.FATCAT_DOMAIN }}/bulk_exports.html">Bulk Exports</a> - <a class="item" href="https://github.com/internetarchive/fatcat/">Source Code (<code>{{ config.GIT_REVISION.decode() }}</code>)</a> + <a class="item" href="https://github.com/internetarchive/fatcat/">Source Code (<code>{{ config.GIT_REVISION }}</code>)</a> </div> </div> </footer> diff --git a/python/fatcat_web/templates/editor_changelog.html b/python/fatcat_web/templates/editor_changelog.html index 79127312..785c19bd 100644 --- a/python/fatcat_web/templates/editor_changelog.html +++ b/python/fatcat_web/templates/editor_changelog.html @@ -3,8 +3,8 @@ <h1 class="ui header">Editor Changelog: {{ editor.username }} <div class="sub header"> - <a href="/editor/{{editor.id}}"> - <code>editor {{ editor.id }}</code> + <a href="/editor/{{editor.editor_id}}"> + <code>editor {{ editor.editor_id }}</code> </a> </div> </h1> diff --git a/python/fatcat_web/templates/editor_view.html b/python/fatcat_web/templates/editor_view.html index c9b61f5d..eef4f040 100644 --- a/python/fatcat_web/templates/editor_view.html +++ b/python/fatcat_web/templates/editor_view.html @@ -3,10 +3,10 @@ <h1 class="ui header">{{ editor.username }} <div class="sub header"> - <code>editor {{ editor.id }}</code> + <code>editor {{ editor.editor_id }}</code> </div> </h1> -<p><b><a href="/editor/{{ editor.id }}/changelog">View editor's changelog</a></b> +<p><b><a href="/editor/{{ editor.editor_id }}/changelog">View editor's changelog</a></b> {% endblock %} diff --git a/python/fatcat_web/web_config.py b/python/fatcat_web/web_config.py new file mode 100644 index 00000000..cbe519b0 --- /dev/null +++ b/python/fatcat_web/web_config.py @@ -0,0 +1,64 @@ + +""" +Default configuration for fatcat web interface (Flask application). + +In production, we currently reconfigure these values using environment +variables, not by (eg) deploying a variant copy of this file. + +This config is *only* for the web interface, *not* for any of the workers or +import scripts. +""" + +import os +import raven +import subprocess + +basedir = os.path.abspath(os.path.dirname(__file__)) + +class Config(object): + GIT_REVISION = subprocess.check_output(["git", "describe", "--always"]).strip().decode('utf-8') + + # This is, effectively, the QA/PROD flag + FATCAT_DOMAIN = os.environ.get("FATCAT_DOMAIN", default="qa.fatcat.wiki") + FATCAT_API_AUTH_TOKEN = os.environ.get("FATCAT_API_AUTH_TOKEN", default=None) + FATCAT_API_HOST = os.environ.get("FATCAT_API_HOST", default="https://{}/v0".format(FATCAT_DOMAIN)) + + # can set this to https://search.fatcat.wiki for some experimentation + ELASTICSEARCH_BACKEND = os.environ.get("ELASTICSEARCH_BACKEND", default="http://localhost:9200") + ELASTICSEARCH_INDEX = os.environ.get("ELASTICSEARCH_INDEX", default="fatcat") + + # for flask things, like session cookies + FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", default=None) + SECRET_KEY = FLASK_SECRET_KEY + + GITLAB_CLIENT_ID = os.environ.get("GITLAB_CLIENT_ID", default=None) + GITLAB_CLIENT_SECRET = os.environ.get("GITLAB_CLIENT_SECRET", default=None) + + IA_XAUTH_URI = "https://archive.org/services/xauthn/" + IA_XAUTH_CLIENT_ID = os.environ.get("IA_XAUTH_CLIENT_ID", default=None) + IA_XAUTH_CLIENT_SECRET = os.environ.get("IA_XAUTH_CLIENT_SECRET", default=None) + + # protect cookies (which include API tokens) + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SECURE = True + SESSION_COOKIE_SAMESITE = 'Lax' + PERMANENT_SESSION_LIFETIME = 2678400 # 31 days, in seconds + + try: + GIT_RELEASE = raven.fetch_git_sha('..') + except Exception as e: + print("WARNING: couldn't set sentry git release automatically: " + str(e)) + GIT_RELEASE = None + + SENTRY_CONFIG = { + #'include_paths': ['fatcat_web', 'fatcat_client', 'fatcat_tools'], + 'enable-threads': True, # for uWSGI + 'release': GIT_RELEASE, + 'tags': { + 'fatcat_domain': FATCAT_DOMAIN, + }, + } + + # "Even more verbose" debug options + #SQLALCHEMY_ECHO = True + #DEBUG = True |