diff --git a/app.py b/app.py index 5e278ba..8d4f9cb 100644 --- a/app.py +++ b/app.py @@ -1,205 +1,258 @@ # -*- coding: utf-8 -*- # # This file is part of Phab Ban # # Copyright (C) 2018 Bryan Davis and contributors # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU 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; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for # more details. # # You should have received a copy of the GNU General Public License along # with this program. If not, see . """Tool to disable Phabricator accounts.""" +import datetime import functools import logging import os import authlib.integrations.flask_client import flask import flask.logging +import mwclient import requests import werkzeug.middleware.proxy_fix import yaml import phabricator class UserIsAdminError(Exception): """Marked exception for attempts to ban an admin.""" pass # Create the Flask application app = flask.Flask(__name__) # Add the ProxyFix middleware which reads X-Forwarded-* headers app.wsgi_app = werkzeug.middleware.proxy_fix.ProxyFix(app.wsgi_app) # Load configuration from YAML file(s). # See default_config.yaml for more information __dir__ = os.path.dirname(__file__) app.config.update( yaml.safe_load(open(os.path.join(__dir__, "default_config.yaml"))) ) try: app.config.update( yaml.safe_load(open(os.path.join(__dir__, "config.yaml"))) ) except IOError: # It is ok if there is no local config file pass logging.getLogger().addHandler(flask.logging.default_handler) # Setup OAuth authentication oauth = authlib.integrations.flask_client.OAuth(app) oauth.register( "phabricator", client_id=app.config["OAUTH_PHAB_CLIENT_ID"], client_secret=app.config["OAUTH_PHAB_CLIENT_SECRET"], api_base_url="%s/api/" % app.config["OAUTH_PHAB_URL"], access_token_url="%s/oauthserver/token/" % app.config["OAUTH_PHAB_URL"], authorize_url="%s/oauthserver/auth/" % app.config["OAUTH_PHAB_URL"], ) # Phabricator client phab = phabricator.Client( app.config["PHAB_URL"], app.config["PHAB_USER"], app.config["PHAB_TOKEN"] ) +# MediaWiki client +mwsite = mwclient.Site( + app.config["MEDIAWIKI_HOST"], + consumer_token=app.config["MEDIAWIKI_CONSUMER_TOKEN"], + consumer_secret=app.config["MEDIAWIKI_CONSUMER_SECRET"], + access_token=app.config["MEDIAWIKI_ACCESS_TOKEN"], + access_secret=app.config["MEDIAWIKI_ACCESS_SECRET"], + clients_useragent="phab-ban (https://phab-ban.toolforge.org)", +) + def login_required(f): """Require authentication for the decorated route.""" @functools.wraps(f) def decorated_function(*args, **kwargs): if "user" not in flask.session: return flask.redirect( flask.url_for("login", next=flask.request.url) ) return f(*args, **kwargs) return decorated_function @app.route("/") def index(): """Application landing page.""" user = flask.session.get("user", None) return flask.render_template("index.html", user=user) @app.route("/login") def login(): """Initiate an OAuth login with Phabricator.""" flask.session["oauth_next"] = flask.request.args.get( "next", flask.url_for("index") ) redirect_uri = flask.url_for("oauth_callback", _external=True) return oauth.phabricator.authorize_redirect(redirect_uri) @app.route("/oauth-callback") def oauth_callback(): """OAuth handshake callback.""" try: token = oauth.phabricator.authorize_access_token() app.logger.debug("OAuth token: %s", token) except authlib.integrations.base_client.errors.MismatchingStateError: app.logger.exception("OAuth authorize failed.") flask.flash( "OAuth login failed. " "State returned from server does not match local state.", "warning", ) return flask.redirect(flask.url_for("index")) else: # Use token to call the whoami api r = requests.post( "%s/api/user.whoami" % app.config["PHAB_URL"], data={"access_token": token["access_token"]}, ).json() app.logger.debug("Whoami: %s", r) flask.session["user"] = r["result"] return flask.redirect( flask.session.get("oauth_next", flask.url_for("index")) ) @app.route("/logout") def logout(): """Log the user out by clearing their session.""" flask.session.clear() return flask.redirect(flask.url_for("index")) @app.route("/ban", methods=["POST"]) @login_required def ban(): """Ban a user.""" username = flask.session["user"]["userName"] if not can_ban(flask.session["user"]): flask.flash( "Sorry %s. You are not authorized to ban accounts." % username, "warning", ) return flask.redirect(flask.url_for("index")) pusername = flask.request.form["phabuser"] try: phabuser = phab.user_search(pusername) if "admin" in phabuser["fields"]["roles"]: # Do not allow admins to be disabled via this tool raise UserIsAdminError r = phab.user_disable(phabuser["phid"]) app.logger.debug("Disable result: %s", r) except IndexError: msg = "User %s not found" % pusername app.logger.error(msg) flask.flash(msg, "warning") except UserIsAdminError: msg = ( "Disabling user %s not allowed because of their " "Phabricator admin status." ) % pusername app.logger.error(msg) flask.flash(msg, "danger") except phabricator.APIError: app.logger.exception("Failed to disable user %s", pusername) flask.flash( "Disabling user %s failed. See logs for details." % pusername, "danger", ) else: app.logger.warning("%s disabled by %s", pusername, username) + log_on_wiki(username, pusername) flask.flash("%s disabled" % pusername, "success") return flask.redirect(flask.url_for("index")) def can_ban(user): """Can this user ban another?""" gname = app.config["ACL_GROUP"] try: members = phab.project_members(gname) return user["phid"] in members except KeyError: app.logger.error("Failed to lookup members of %s", gname) except phabricator.APIError: app.logger.exception("Failed to query for project %s", gname) flask.flash("Error checking for %s membership" % gname, "warning") return False +def log_on_wiki(acting_user, disabled_user): + """Log a user disable action on-wiki with code loaned from Stashbot.""" + now = datetime.datetime.utcnow() + target_section = now.strftime("== %Y-%m-%d ==") + + logline = ( + "* %(hour)02d:%(minute)02d [[phab:p/%(disabled_user)s" + "|%(disabled_user)s]] was disabled by " + "[[phab:p/%(acting_user)s/|%(acting_user)s]]" + % { + "hour": now.hour, + "minute": now.minute, + "disabled_user": disabled_user, + "acting_user": acting_user, + } + ) + + summary = disabled_user + " was disabled by " + acting_user + + page = mwsite.Pages[app.config["MEDIAWIKI_LOG_PAGE"]] + + text = page.text() + lines = text.split("\n") + first_header = 0 + + for pos, line in enumerate(lines): + if line.startswith("== "): + first_header = pos + break + + if lines[first_header] == target_section: + lines.insert(first_header + 1, logline) + else: + lines.insert(first_header, "") + lines.insert(first_header, logline) + lines.insert(first_header, target_section) + + page.save("\n".join(lines), summary=summary, bot=True) + + @app.route("/favicon.ico") def favicon(): """Favicon.""" return flask.redirect(flask.url_for("static", filename="favicon.ico")) diff --git a/default_config.yaml b/default_config.yaml index 794d94b..3f0829e 100644 --- a/default_config.yaml +++ b/default_config.yaml @@ -1,40 +1,48 @@ --- # Default configuration values for Phab Ban. # # This YAML file provides default settings for the Flask application. Rather # than editing this file directly, create a file named 'config.yaml' in the # same directory containing the keys and values that you wish to override. # # Some settings are commented out. These are used to show secret settings # that MUST be provided in a 'config.yaml' file. Secret settings like # password, OAuth tokens, and cryptographic seeds should never be commited to # version control. # # See http://flask.pocoo.org/docs/0.12/config/ for other settings that may be # useful. # Session cookies should only be sent over HTTPS secured connections SESSION_COOKIE_SECURE: True # Only send the cookie header to the client when its content changes SESSION_REFRESH_EACH_REQUEST: False # Generate https://... links by default PREFERRED_URL_SCHEME: https # Flask secret key. Used to create secure session cookies among other things. # This should be a complex random value. #SECRET_KEY: # Phabricator API access PHAB_URL: https://phabricator.wikimedia.org/ #PHAB_USER: #PHAB_TOKEN: # Phabricator OAuth settings OAUTH_PHAB_URL: https://phabricator.wikimedia.org #OAUTH_PHAB_CLIENT_ID: #OAUTH_PHAB_CLIENT_SECRET: # Name of Phabricator project used to for authorization ACL_GROUP: "acl*userdisable" + +# MediaWiki logging access +MEDIAWIKI_HOST: wikitech.wikimedia.org +MEDIAWIKI_LOG_PAGE: "Tool:Phab-ban/Log" +#MEDIAWIKI_CONSUMER_TOKEN: +#MEDIAWIKI_CONSUMER_SECRET: +#MEDIAWIKI_ACCESS_TOKEN: +#MEDIAWIKI_ACCESS_SECRET: diff --git a/requirements.txt b/requirements.txt index c82c68e..c8dd191 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ authlib flask pyyaml requests +mwclient