#!/usr/bin/python3 # Copyright (C) 2018 Lars Wirzenius # 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; without 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 . import copy import json import logging import os import sys import urllib import uuid import bottle import jinja2 import requests COOKIE_NAME = 'effireg' class EffiAPI: _scopes = [ 'create', 'update', 'show', 'delete', 'super', ] def __init__(self, app, our_url, apiurl, client_id, client_secret, template_dir): self._add_routes(app) self._our_url = our_url self._apiurl = apiurl self._auth_url = '{}/auth'.format(apiurl) self._token_url = '{}/token'.format(apiurl) self._client_id = client_id self._client_secret = client_secret self._sessions = Sessions() self._pagemaker = PageMaker(template_dir) def _add_routes(self, bottleapp): routes = [ { 'method': 'GET', 'path': '/', 'callback': self._call(self._frontpage), }, { 'method': 'GET', 'path': '/login', 'callback': self._call(self._redirect_to_login), }, { 'method': 'GET', 'path': '/logout', 'callback': self._call(self._logout), }, { 'method': 'GET', 'path': '/callback', 'callback': self._call(self._auth_callback), }, ] for route in routes: bottleapp.route(**route) def _call(self, callback): def helper(): r = bottle.request logging.info('Request: path=%s', r.path) logging.info('Request: content type: %s', r.content_type) for h in r.headers: logging.info('Request: headers: %s: %s', h, r.get_header(h)) logging.info('Request: body: %r', r.body.read()) r = callback() logging.debug('Response: %r', r) return r return helper def _frontpage(self): session = None cookie = bottle.request.get_cookie(COOKIE_NAME) if cookie: session = self._sessions.find_by_cookie(cookie) if session is None: return self._not_logged_in_page() return self._logged_in_page(cookie) def _not_logged_in_page(self): return self._pagemaker.page('not-logged-in', {'foo': 'bar'}) def _logged_in_page(self, cookie): session = self._sessions.find_by_cookie(cookie) if not session: return self._not_logged_in_page() members = self._get_members(session.token) if not members: return self._not_logged_in_page() return self._pagemaker.page( 'logged-in', { 'cookie': session.cookie, 'token': session.token, 'member': members[0], }) def _get_members(self, token): rids = self._find_ids_of_resources_owned_by_user(token) return self._get_resources(token, rids) def _find_ids_of_resources_owned_by_user(self, token): url = '{}/search'.format(self._apiurl) cond = { 'cond': [ { 'op': '>=', 'where': 'meta', 'field': 'id', 'pattern': '', }, ] } headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer {}'.format(token), } logging.debug('Finding owned by user: url=%s headers=%r', url, headers) r = requests.get(url, headers=headers, data=json.dumps(cond)) if not r.ok: logging.error('{} {}'.format(r.status_code, r.text)) return [] obj = r.json() return obj['resources'] def _get_resources(self, token, rids): return [ self._get_resource(token, rid) for rid in rids ] def _get_resource(self, token, rid): logging.info('Retrieving resource %s', rid) url = '{}/mem'.format(self._apiurl) headers = { 'Muck-Id': rid, 'Authorization': 'Bearer {}'.format(token), } r = requests.get(url, headers=headers) if not r.ok: logging.error('{} {}'.format(r.status_code, r.text)) return {} return r.json() def _redirect_to_frontpage(self): headers = { 'Location': self._our_url, } logging.info('Redirecting with headers %s', headers) return bottle.HTTPResponse(status=302 , headers=headers) def _redirect_to_login(self): session = self._sessions.new() params = { 'response_type': 'code', 'scope': ' '.join(self._scopes), 'client_id': self._client_id, 'state': session.state, 'redirect_uri': '{}/callback'.format(self._our_url), } auth_url = '{}?{}'.format( self._auth_url, urllib.parse.urlencode(params)) headers = { 'Location': auth_url, } logging.info('Redirecting with headers %s', headers) return bottle.HTTPResponse(status=302 , headers=headers) def _auth_callback(self): code = bottle.request.query.get('code') state = bottle.request.query.get('state') if code is None or state is None: return regesta_api.bad_request_response() token = self._get_token_using_authz_code(code) session = self._sessions.find_by_state(state) if session is None: return self._redirect_to_login() session.token = token logging.debug('Redirecting to / with cookie %s', session.cookie) bottle.response.set_cookie(COOKIE_NAME, session.cookie, path='/') bottle.redirect(self._our_url) return None headers = { 'Location': self._our_url, } r = bottle.HTTPResponse(302, headers=headers) r.set_cookie(COOKIE_NAME, session.cookie, path='/') logging.debug('Redirecting to / with cookie %s', session.cookie) return r def _get_token_using_authz_code(self, code): auth = (self._client_id, self._client_secret) data = { 'grant_type': 'authorization_code', 'code': code, } url = self._token_url logging.debug('Requesting token using authz code from %s', url) r = requests.post(url, auth=auth, data=data) if not r.ok: logging.error( 'Could not get token using authz code: %s %s', r.status_code, r.text) raise bottle.HTTPError(500) obj = r.json() return obj[u'access_token'] def _logout(self): cookie = bottle.request.get_cookie(COOKIE_NAME) if not cookie: return self._redirect_to_frontpage() session = self._sessions.find_by_cookie(cookie) self._sessions.delete(session) logging.debug('Redirecting to / without') bottle.response.delete_cookie(COOKIE_NAME, path='/') bottle.redirect(self._our_url) return None class PageMaker: def __init__(self, dirname): self._dirname = os.path.abspath(dirname) def page(self, basename, variables): logging.debug( 'PageMaker: dirname=%s basename=%s variables=%r', self._dirname, basename, variables) loader = jinja2.FileSystemLoader(self._dirname) env = jinja2.Environment( loader=loader, autoescape=lambda foo: True, extensions=['jinja2.ext.autoescape']) template = env.get_template('{}.j2'.format(basename)) return template.render(**variables) class Session: def __init__(self): self.id = str(uuid.uuid4()) self.state = str(uuid.uuid4()) self.cookie = str(uuid.uuid4()) self.token = None class Sessions: def __init__(self): self._sessions = [] def new(self): session = Session() self._sessions.append(session) return session def delete(self, session): self._sessions = [s for s in self._sessions if s.id != session.id] def find_by_state(self, state): return self._find(lambda s: s.state == state) def find_by_cookie(self, cookie): return self._find(lambda s: s.cookie == cookie) def _find(self, is_this_it): for session in self._sessions: if is_this_it(session): return session return None def main(): with open(sys.argv[1]) as f: config = json.load(f) logging.basicConfig( filename=config['log'], level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s') logging.info('Effi-reg starts') if config.get('pid'): pid = os.getpid() with open(config['pid'], 'w') as f: f.write(str(pid)) app = bottle.default_app() api = EffiAPI( app, config['our-url'], config['api-url'], config['client-id'], config['client-secret'], config['templates']) app.run(host='127.0.0.1', port=8181) if __name__ == '__main__': main()