PKуN1apasee/__init__.py"""HTTP server managing users. """ __version__ = "19.7.0" class PaseeException(Exception): """An exception parent of all Pasee exceptions. """ class MissingSettings(ValueError, PaseeException): """A mandatory setting is missing from the configuration file. """ class Unauthorized(PaseeException): """An exception raised when someone is *not* authorized to do an action. """ def __init__(self, reason, **kwargs): self.reason = reason super().__init__(**kwargs) class Unauthenticated(PaseeException): """An exception used to distinguish incoming requests showing authentication and requests without authentication """ def __init__(self, reason, **kwargs): self.reason = reason super().__init__(**kwargs) PKуNQpasee/__main__.py"""Entry point for console script. """ import argparse import sys from aiohttp import web try: import sentry_sdk except ImportError: sentry_sdk = None import pasee from pasee.pasee import identification_app def pasee_arg_parser() -> argparse.ArgumentParser: """Parses command line arguments. """ parser = argparse.ArgumentParser( prog="pasee", description="Pasee Identity Manager", epilog="All options, if given, take precedence over settings file.", ) parser.add_argument("--settings-file", default="settings.toml") parser.add_argument("--host", help="Hostname to bind to.") parser.add_argument("--port", help="Port to bind to.") return parser def main(): # pragma: no cover """Command line entry point. """ if sentry_sdk: sentry_sdk.init() parser = pasee_arg_parser() try: app = identification_app(**vars(parser.parse_args())) except pasee.MissingSettings as err: print(err, file=sys.stderr) parser.print_help() exit(1) web.run_app(app, host=app.settings["host"], port=app.settings["port"]) if __name__ == "__main__": main() PKKTN$upasee/middlewares.py"""middleswares for the pasee server """ import json from typing import Callable from aiohttp import web from pasee import Unauthorized, Unauthenticated from pasee.serializers import serialize @web.middleware async def verify_input_body_is_json( request: web.Request, handler: Callable ) -> Callable: """ Middleware to verify that input body is of json format """ if request.has_body: try: await request.json() except json.decoder.JSONDecodeError: raise web.HTTPBadRequest(reason="Malformed JSON.") return await handler(request) @web.middleware async def transform_unauthorized(request: web.Request, handler: Callable) -> Callable: """Middleware to except pasee.Unauthorized exceptions and transform them to proper HTTP exceptions. """ try: return await handler(request) except (Unauthorized, Unauthenticated) as err: raise web.HTTPBadRequest( reason=err.reason, headers={"WWW-Authenticate": "Bearer"} ) @web.middleware async def coreapi_error_middleware(request, handler): """Implementation of: http://www.coreapi.org/specification/transport/#coercing-4xx-and-5xx-responses-to-errors """ try: return await handler(request) except web.HTTPException as ex: return serialize( request, {"_type": "error", "_meta": {"title": ex.reason}}, ex.status ) SECURITY_HEADERS = { "Content-Security-Policy": "default-src 'none'", "Referrer-Policy": "origin-when-cross-origin, strict-origin-when-cross-origin", "Strict-Transport-Security": "max-age=31536000; includeSubdomains; preload", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "deny", "X-XSS-Protection": "1; mode=block", } @web.middleware async def security_headers(request, handler): """Add some security headers like CSP, Referrer-Policy and so on. """ response = await handler(request) response.headers.update(SECURITY_HEADERS) return response PK#UN@`pasee/pasee.py"""Pasee main module. """ import os import logging from typing import Dict, Optional from aiohttp import web import aiohttp_cors import pytoml as toml from pasee.middlewares import ( verify_input_body_is_json, transform_unauthorized, coreapi_error_middleware, security_headers, ) from pasee import views, MissingSettings from pasee.groups import views as group_views from pasee.tokens import views as token_views from pasee.users import views as user_views from pasee.utils import import_class logging.basicConfig(level=logging.DEBUG) def load_conf( settings_path: Optional[str] = None, host: str = None, port: int = None, identity_backend_class: str = None, ) -> Dict: """Search for a settings.toml file and load it. """ del identity_backend_class candidates: tuple if settings_path: candidates = ( settings_path, os.path.join(os.getcwd(), settings_path), os.path.join(os.getcwd(), "settings.toml"), os.path.expanduser("~/settings.toml"), os.path.expanduser(os.path.join("~/", settings_path)), "/etc/pasee/settings.toml", os.path.join("/etc/pasee/", settings_path), ) else: candidates = ( os.path.join(os.getcwd(), "settings.toml"), os.path.expanduser("~/settings.toml"), "/etc/pasee/settings.toml", ) settings = None for candidate in candidates: if os.path.exists(candidate): with open(candidate) as candidate_file: settings = toml.load(candidate_file) break if not settings: raise MissingSettings( f"No settings files found, tried: {', '.join(set(candidates))}" ) if host: settings["host"] = host if "host" not in settings: settings["host"] = "127.0.0.1" if port: settings["port"] = port if "port" not in settings: settings["port"] = 8140 for mandatory_setting in {"private_key", "public_key", "identity_providers"}: if mandatory_setting not in settings: raise MissingSettings(f"No {mandatory_setting} in settings, see README.md") settings["idps"] = {idp["name"]: idp for idp in settings["identity_providers"]} return settings def identification_app( settings_file: str = None, host: str = None, port: int = None, identity_backend_class: str = None, ): """Identification provider entry point: builds and run a webserver. """ settings = load_conf(settings_file, host, port, identity_backend_class) app = web.Application( middlewares=[ verify_input_body_is_json, transform_unauthorized, coreapi_error_middleware, security_headers, ] ) app.settings = settings app.storage_backend = import_class(settings["storage_backend"]["class"])( settings["storage_backend"]["options"] ) async def on_startup_wrapper(app): """Wrapper to call __aenter__. """ await app.storage_backend.__aenter__() async def on_cleanup_wrapper(app): """Wrapper to call __exit__. """ await app.storage_backend.__aexit__(None, None, None) app.on_startup.append(on_startup_wrapper) app.on_cleanup.append(on_cleanup_wrapper) app.add_routes( [ web.get("/", views.get_root, name="get_root"), web.get("/public-key/", views.get_public_key, name="get_public_key"), web.get("/tokens/", token_views.get_tokens, name="get_tokens"), web.post("/tokens/", token_views.post_token, name="post_tokens"), web.get("/users/", user_views.get_users), web.get("/users/{username}", user_views.get_user), web.delete("/users/{username}", user_views.delete_user), web.patch("/users/{username}", user_views.patch_user), web.get("/groups/", group_views.get_groups), web.post("/groups/", group_views.post_groups), web.get("/groups/{group_uid}/", group_views.get_group), web.delete("/groups/{group_uid}/", group_views.delete_group), web.post("/groups/{group_uid}/", group_views.post_group), web.delete( "/groups/{group_uid}/{username}/", group_views.delete_group_member ), ] ) cors = aiohttp_cors.setup( app, defaults={ "*": aiohttp_cors.ResourceOptions( allow_credentials=True, expose_headers="*", allow_headers="*", allow_methods=["GET", "OPTIONS", "PUT", "POST", "DELETE", "PATCH"], ) }, ) for route in list(app.router.routes()): cors.add(route) return app PKVsYN ~~pasee/serializers.py"""Serialisers using coreapi, the idea is to (in the future) provide various representations of our resources like mason, json-ld, hal, ... """ import coreapi from aiohttp import web def serialize( request: web.Request, document: dict, status=200, headers=None ) -> web.Response: """Serialize the given document according to the Accept header of the given request. """ del request codec = coreapi.utils.negotiate_encoder([coreapi.codecs.CoreJSONCodec()], None) content = codec.dump(document) return web.Response( body=content, content_type=codec.media_type, headers=headers, status=status ) PK#UN - pasee/storage_interface.py"""Abstract class representing Storage backend """ from abc import abstractmethod from typing import AsyncContextManager, List, Any class StorageBackend(AsyncContextManager): # pylint: disable=inherit-non-class # (see https://github.com/PyCQA/pylint/issues/2472) """Abstract class for representing an Storage backend """ def __init__(self, options: dict, **kwargs: Any) -> None: self.options = options super().__init__(**kwargs) # type: ignore # mypy issue 4335 @abstractmethod async def get_authorizations_for_user(self, user) -> List[str]: """get list the list of group a user identity belongs to """ @abstractmethod async def create_group(self, group_name): """Add group """ @abstractmethod async def get_groups(self, last_element: str = "") -> List[str]: """Get groups paginated by group name in alphabetical order List of groups is returned by page of 20 last_element is the last know element returned in previous page So passing the last element to this function will retrieve the next page """ @abstractmethod async def get_groups_of_user(self, user: str, last_element: str = "") -> List[str]: """Get groups of user """ @abstractmethod async def delete_group(self, group: str): """Delete group """ @abstractmethod async def get_users(self, last_element: str = ""): """Get users """ @abstractmethod async def get_user(self, username: str = ""): """Get user """ @abstractmethod async def get_members_of_group(self, group) -> List[str]: """Get members of group """ @abstractmethod async def group_exists(self, group) -> bool: """Assert group exists """ @abstractmethod async def user_exists(self, user) -> bool: """Assert user exists """ @abstractmethod async def create_user(self, username): """Create user """ @abstractmethod async def delete_user(self, username): """Delete user """ @abstractmethod async def is_user_in_group(self, user, group) -> bool: """Verify that user is in group """ @abstractmethod async def add_member_to_group(self, member, group) -> bool: """ staff adds member to group """ @abstractmethod async def delete_member_in_group(self, member, group): """Delete member in group """ @abstractmethod async def delete_members_in_group(self, group): """Delete all members of group """ @abstractmethod async def ban_user(self, username: str, ban: bool = True): """Ban user """ PK%HNNAupasee/utils.py"""Some functions not directly linked with the core of pasee but still usefull. """ from typing import MutableMapping, Mapping, Union, Any from importlib import import_module import jwt from pasee import Unauthorized, Unauthenticated Claims = MutableMapping[str, Union[Any]] RequestHeaders = Mapping[str, Union[Any]] Settings = Mapping[str, Union[Any]] def import_class(dotted_path: str) -> type: """Import a dotted module path and return the attribute/class designated by the last name in the path. Raise ImportError if the import failed. """ try: module_path, class_name = dotted_path.rsplit(".", 1) except ValueError as err: raise ImportError("%s doesn't look like a module path" % dotted_path) from err module = import_module(module_path) try: return getattr(module, class_name) except AttributeError as err: raise ImportError( 'Module "%s" does not define a "%s" attribute/class' % (module_path, class_name) ) from err def enforce_authorization(headers: RequestHeaders, settings: Settings) -> Claims: """claim user authorization middleware handler written as a standalone function to allow easier mocking for test """ if not headers.get("Authorization"): raise Unauthenticated("Missing authorization header") try: scheme, token = headers.get("Authorization", "").strip().split(" ") except ValueError as err: raise Unauthorized("Malformed authorization header") from err if scheme != "Bearer": raise Unauthorized("Expected Bearer token") try: return { **jwt.decode( token, settings["public_key"], algorithms=settings["algorithm"] ) } except jwt.ExpiredSignatureError as err: raise Unauthorized("Expired signature") from err except jwt.InvalidTokenError as err: raise Unauthorized("Invalid token") from err PK#UNJͧNNpasee/views.py"""Views for the Pasee server, implementing: - GET / """ import json import logging from aiohttp import web logger = logging.getLogger(__name__) async def get_root(request: web.Request) -> web.Response: """https://tools.ietf.org/html/draft-nottingham-json-home-06 """ hostname = request.app.settings["hostname"] home = { "api": { "title": "Identification Manager", "links": { "author": "mailto:julien@palard.fr", "describedBy": "https://doc.meltylab.fr", }, }, "resources": { "public-key": {"href": f"{hostname}/public-key/"}, "tokens": { "hints": {"allow": ["GET", "POST"]}, "href": f"{hostname}/tokens/", }, "groups": { "href": f"{hostname}/groups/", "hints": {"allow": ["GET", "POST"]}, }, "group": { "hrefTemplate": f"{hostname}/groups/{{group_uid}}/", "hrefVars": {"group_uid": "group unique id"}, "hints": {"allow": ["GET", "POST", "DELETE"]}, }, "users": { "href": f"{hostname}/users/", "hints": {"allow": ["GET", "DELETE", "PATCH"]}, }, }, } return web.Response( body=json.dumps(home, indent=4), headers={"Vary": "Origin"}, content_type="application/json-home+json", ) async def get_public_key(request: web.Request) -> web.Response: """Get public key """ public_key = request.app.settings["public_key"] algorithm = request.app.settings["algorithm"] return web.Response( body=json.dumps({"public_key": public_key, "algorithm": algorithm}), headers={"Vary": "Origin"}, content_type="application/json", ) PK5 Mpasee/groups/__init__.pyPK<.NƟpasee/groups/utils.py"""Handlers for groups management """ from typing import List def is_authorized_for_group(authorized_groups: List[str], group: str) -> bool: """Check `authorized group` list have access to manage `group` """ return "staff" in authorized_groups or f"{group}.staff" in authorized_groups def is_root(authorized_groups: List[str]) -> bool: """Check staff is in group list """ return "staff" in authorized_groups def is_parent_group_staff(authorized_groups: List[str], group: str) -> bool: """Verify if group parent is in authorized_groups """ parent_group = group.rsplit(".", 1)[0] return f"{parent_group}.staff" in authorized_groups def is_authorized_for_group_create(authorized_groups: List[str], group: str) -> bool: """Only root staff or parent group staff can create group """ return is_root(authorized_groups) or is_parent_group_staff(authorized_groups, group) PK#UN!$66pasee/groups/views.py"""Views for groups ressource in Pasee server, implementing: """ import logging from typing import List import coreapi from aiohttp import web from pasee import utils from pasee.serializers import serialize from pasee.groups.utils import ( is_authorized_for_group, is_authorized_for_group_create, is_root, ) from pasee import Unauthorized, Unauthenticated logger = logging.getLogger(__name__) async def get_groups(request: web.Request) -> web.Response: """Handlers for GET /groups/ """ hostname = request.app.settings["hostname"] groups: List = [] errors: List[coreapi.Error] = [] try: claims = utils.enforce_authorization(request.headers, request.app.settings) if is_root(claims["groups"]): user = request.rel_url.query.get("user") last_element = request.rel_url.query.get("last_element", "") if not user: groups = await request.app.storage_backend.get_groups(last_element) else: groups = await request.app.storage_backend.get_groups_of_user( user, last_element ) except Unauthorized as unauthorized_error: errors.append(coreapi.Error(content={"reason": unauthorized_error.reason})) except Unauthenticated: pass content = { "groups": [ coreapi.Document( url=f"{hostname}/groups/{group}/", content={"group": group} ) for group in groups ], "create_group": coreapi.Link( action="post", title="Create a group", description="A method to create a group by a staff member", fields=[coreapi.Field(name="group", required=True)], ), "get_groups_of_user": coreapi.Link( action="get", title="Get groups of user", description="Get list of groups of a user", url=f"{hostname}/groups/{{?user}}", ), "errors": errors, } if groups: content["next"] = coreapi.Link( url=f"{hostname}/groups/?last_element={groups[-1]}" ) return serialize( request, coreapi.Document( url=f"{hostname}/groups/", title="Groups of Identity Manager", content=content, ), headers={"Vary": "Origin"}, ) async def post_groups(request: web.Request) -> web.Response: """Handler for POST /groups/ """ claims = utils.enforce_authorization(request.headers, request.app.settings) input_data = await request.json() if "group" not in input_data: raise web.HTTPBadRequest(reason="Missing group") group_name = input_data["group"] if not is_authorized_for_group_create(claims["groups"], group_name): raise web.HTTPForbidden(reason="Not authorized to create group") storage_backend = request.app.storage_backend staff_group_name = f"{group_name}.staff" if await storage_backend.group_exists(group_name): raise web.HTTPConflict(reason="Group already exists") await storage_backend.create_group(group_name) await storage_backend.create_group(staff_group_name) await storage_backend.add_member_to_group(claims["sub"], staff_group_name) location = f"/groups/{group_name}/" return web.Response(status=201, headers={"Location": location}) async def get_group(request: web.Request) -> web.Response: """Handler for GET /groups/{group_uid} """ hostname = request.app.settings["hostname"] claims = utils.enforce_authorization(request.headers, request.app.settings) storage_backend = request.app.storage_backend group = request.match_info["group_uid"] if not is_authorized_for_group(claims["groups"], group): raise web.HTTPForbidden(reason="Not authorized to view group") if not await storage_backend.group_exists(group): raise web.HTTPNotFound(reason="Group does not exist") members = await storage_backend.get_members_of_group(group) return serialize( request, coreapi.Document( url=f"{hostname}/groups/{{group}}/", title=f"{group} group management interface", content={ "members": [ coreapi.Document( url=f"{hostname}/users/{member}", content={"username": member} ) for member in members ], "add_member": coreapi.Link( action="post", title="Add a member to group", description="A method to add a member to group", fields=[coreapi.Field(name="username", required=True)], ), }, ), headers={"Vary": "Origin"}, ) async def post_group(request: web.Request) -> web.Response: """Handler for POST /groups/{group_id}/ add a user to {group_id} """ claims = utils.enforce_authorization(request.headers, request.app.settings) input_data = await request.json() storage_backend = request.app.storage_backend group = request.match_info["group_uid"] if not is_authorized_for_group(claims["groups"], group): raise web.HTTPForbidden(reason="Not authorized to manage group") if not await storage_backend.group_exists(group): raise web.HTTPNotFound(reason="Group does not exist") if "username" not in input_data: raise web.HTTPBadRequest(reason="Missing username in request body") username = input_data["username"] if not await storage_backend.user_exists(username): await request.app.storage_backend.create_user(username) if await storage_backend.is_user_in_group(username, group): raise web.HTTPBadRequest(reason="User already in group") await storage_backend.add_member_to_group(username, group) return web.Response(status=201) async def delete_group(request: web.Request) -> web.Response: """Handler for POST /groups/{group_id}/ add a user to {group_id} """ claims = utils.enforce_authorization(request.headers, request.app.settings) storage_backend = request.app.storage_backend group = request.match_info["group_uid"] if not is_authorized_for_group(claims["groups"], "staff"): raise web.HTTPForbidden(reason="Not authorized to manage group") if not await storage_backend.group_exists(group): raise web.HTTPNotFound(reason="Group does not exist") await storage_backend.delete_group(group) await storage_backend.delete_group(f"{group}.staff") return web.Response(status=204) async def delete_group_member(request: web.Request) -> web.Response: """Delete group member of group """ claims = utils.enforce_authorization(request.headers, request.app.settings) storage_backend = request.app.storage_backend group = request.match_info["group_uid"] username = request.match_info["username"] if not await storage_backend.group_exists(group): raise web.HTTPNotFound(reason="Group does not exist") if not is_authorized_for_group(claims["groups"], group): raise web.HTTPForbidden(reason="Not authorized to manage group") if not await storage_backend.is_user_in_group(username, group): raise web.HTTPNotFound(reason="User does not exist in group") await storage_backend.delete_member_in_group(username, group) return web.Response(status=204) PKbM$M$pasee/identity_providers/__init__.pyPKqfM̗Y[[#pasee/identity_providers/backend.py"""Abstract class representing an Identity provider """ from abc import ABC, abstractmethod from typing import Optional, Mapping, MutableMapping, Union, Any Claims = MutableMapping[str, Union[Any]] LoginCredentials = Mapping[str, str] BACKENDS = { "kisee": "pasee.identity_providers.kisee.KiseeIdentityProvider", "twitter": "pasee.identity_providers.twitter.TwitterIdentityProvider", } class IdentityProviderBackend(ABC): """Abstract class for representing an Identity provider backend """ def __init__(self, settings, **kwargs) -> None: self.settings = settings super().__init__(**kwargs) # type: ignore # mypy issue 4335 @abstractmethod async def authenticate_user(self, data: LoginCredentials, step: int = 1) -> Claims: """Authenticate user """ @abstractmethod async def get_endpoint(self, action: Optional[str] = None): """Get identity backend endpoint for specific action Returns root endpoint if action is None """ @abstractmethod def get_name(self): """Get identity backend name """ PKsdNW* !pasee/identity_providers/kisee.py"""Identity provider for Kisee """ import json from typing import Optional, Dict import aiohttp from aiohttp import web import jwt from pasee.identity_providers.backend import IdentityProviderBackend from pasee.identity_providers.backend import Claims, LoginCredentials class KiseeIdentityProvider(IdentityProviderBackend): """Kisee Identity Provider """ def __init__(self, settings, **kwargs) -> None: super().__init__(settings, **kwargs) self.public_keys = self.settings["settings"]["public_keys"] self.endpoint = self.settings["endpoint"] self.name = self.settings["name"] self.action_to_endpoint: Dict = dict() async def _identify_to_kisee(self, data: LoginCredentials): """Async request to identify to kisee""" create_token_endpoint = await self.get_endpoint("create-token") async with aiohttp.ClientSession() as session: async with session.post( create_token_endpoint, headers={"Content-Type": "application/json"}, json=data, ) as response: if response.status == 403: raise web.HTTPForbidden(reason="Can not authenticate on kisee") if response.status != 201: raise web.HTTPBadGateway( reason="Something went wrong with identity provider" ) kisee_response = await response.text() kisee_response = json.loads(kisee_response) return kisee_response def _decode_token(self, token: str): """Decode token with public keys. """ for public_key in self.public_keys: try: decoded = jwt.decode(token, public_key, algorithms="ES256") return decoded except (ValueError, jwt.DecodeError): pass raise web.HTTPInternalServerError() async def authenticate_user(self, data: LoginCredentials, step: int = 1) -> Claims: if not all(key in data.keys() for key in {"login", "password"}): raise web.HTTPBadRequest( reason="Missing login or password fields for kisee authentication" ) kisee_response = await self._identify_to_kisee(data) # TODO use header location instead to retrieve token # kisee_headers = response.headers # token_location = kisee_headers["Location"] token = kisee_response["tokens"][0] decoded = self._decode_token(token) decoded["sub"] = f"{self.name}-{decoded['sub']}" return decoded async def get_endpoint(self, action: Optional[str] = None): if not action: return self.endpoint if action in self.action_to_endpoint: return self.action_to_endpoint[action] async with aiohttp.ClientSession() as session: try: async with session.get(self.endpoint) as response: root = await response.json() except aiohttp.client_exceptions.ClientConnectorError: raise web.HTTPServiceUnavailable(reason="kisee not responding") self.action_to_endpoint[action] = root["actions"][action]["href"] return self.action_to_endpoint[action] def get_name(self): return self.name PKqfMop}#pasee/identity_providers/twitter.py"""Identity provider for Twitter """ from typing import Optional from aiohttp import web from aioauth_client import TwitterClient from pasee.identity_providers.backend import IdentityProviderBackend from pasee.identity_providers.backend import Claims, LoginCredentials class TwitterIdentityProvider(IdentityProviderBackend): """Twitter Identity Provider """ def __init__(self, settings, **kwargs) -> None: super().__init__(settings, **kwargs) self.name = "twitter" self.consumer_key = self.settings["settings"]["consumer_key"] self.consumer_secret = self.settings["settings"]["consumer_secret"] self.callback_url = self.settings["settings"]["callback_url"] self.client = TwitterClient( consumer_key=self.consumer_key, consumer_secret=self.consumer_secret ) async def authenticate_user(self, data: LoginCredentials, step: int = 1) -> Claims: """Twitter authenticate user returns a link that user use to for for identity verification """ if step == 1: request_token, _, data = await self.client.get_request_token( oauth_callback=self.callback_url ) authorize_url = self.client.get_authorize_url(request_token) return {"authorize_url": authorize_url} elif step == 2: self.client.oauth_token = data["oauth_token"] oauth_token, _, oauth_data = await self.client.get_access_token( data["oauth_verifier"], request_token=data["oauth_token"] ) return {"access_token": oauth_token, "sub": oauth_data["user_id"]} else: raise ValueError("only step 1 or 2 is available") async def get_endpoint(self, action: Optional[str] = None): raise web.HTTPNotImplemented( reason="No other action possible in twitter but authentication" ) def get_name(self): return self.name PK<.N!pasee/identity_providers/utils.py"""Utils for handling identity providers """ from pasee.utils import import_class def get_identity_provider_with_capability(settings, capability): """Returns an identity provider with capability passed in argument """ for idp in settings["identity_providers"]: if capability in idp.get("capabilities", set()): return import_class(idp["implementation"])(idp) return None PKqfM!pasee/storage_backend/__init__.pyPK<.N.pasee/storage_backend/demo_backend/__init__.pyPK#UN '',pasee/storage_backend/demo_backend/sqlite.py"""sqlite """ from typing import List import logging import sqlite3 from pasee.storage_interface import StorageBackend logger = logging.getLogger(__name__) class DemoSqliteStorage(StorageBackend): """Exposing a simple backend that fetch authorizations from a dictionary. """ def __init__(self, options: dict, **kwargs) -> None: super().__init__(options, **kwargs) self.file = options["file"] self.connection = None async def __aenter__(self): self.connection = sqlite3.connect(self.file) cursor = self.connection.cursor() cursor.execute( """ CREATE TABLE IF NOT EXISTS users( name TEXT PRIMARY KEY, is_banned BOOLEAN DEFAULT FALSE ); """ ) cursor.execute("CREATE TABLE IF NOT EXISTS groups(name TEXT PRIMARY KEY);") cursor.execute( """ CREATE TABLE IF NOT EXISTS user_in_group( id INTEGER PRIMARY KEY, user TEXT, group_name TEXT ); """ ) cursor.execute( """ CREATE UNIQUE INDEX IF NOT EXISTS group_name_index on groups (name); """ ) async def __aexit__(self, exc_type, exc_value, traceback): self.connection.close() async def get_authorizations_for_user(self, user: str) -> List[str]: """Claim list of groups an user belongs to We suppose db schema to be created like this: $>CREATE TABLE users(name TEXT PRIMARY KEY); $>CREATE TABLE groups(name TEXT PRIMARY KEY); $>CREATE TABLE user_in_group ( id INTEGER PRIMARY KEY, user TEXT, group_name TEXT );" """ if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() results = cursor.execute( """ SELECT groups.name FROM groups JOIN user_in_group ON groups.name = user_in_group.group_name WHERE user_in_group.user = :user ORDER BY name ASC """, {"user": user}, ) return [elem[0] for elem in results] async def create_group(self, group_name): """Staff member adds group method """ if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() cursor.execute( "INSERT INTO groups (name) VALUES (:group_name)", {"group_name": group_name} ) self.connection.commit() async def get_groups(self, last_element: str = "") -> List[str]: """Get groups paginated by group name in alphabetical order List of groups is returned by page of 20 last_element is the last know element returned in previous page So passing the last element to this function will retrieve the next page """ if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() results = cursor.execute( """ SELECT name FROM groups WHERE name > :last_element ORDER BY name ASC LIMIT 20 """, {"last_element": last_element}, ) groups = [group[0] for group in results] cursor.close() return groups async def get_groups_of_user(self, user: str, last_element: str = "") -> List[str]: if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() results = cursor.execute( """ SELECT groups.name FROM groups JOIN user_in_group ON groups.name = user_in_group.group_name WHERE user_in_group.user = :user AND groups.name > :last_element ORDER BY groups.name ASC LIMIT 20 """, {"user": user, "last_element": last_element}, ) groups = [group[0] for group in results] cursor.close() return groups async def delete_group(self, group: str): """Delete group """ await self.delete_members_in_group(group) if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() cursor.execute("DELETE FROM groups WHERE name = :group", {"group": group}) self.connection.commit() async def get_users(self, last_element: str = ""): """Get users """ if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() results = cursor.execute( "SELECT * FROM users WHERE name > :name ORDER BY name ASC LIMIT 50", {"name": last_element}, ) return [elem[0] for elem in results] async def get_user(self, username: str = ""): if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() result = cursor.execute( """ SELECT name, is_banned FROM users WHERE name = :username """, {"username": username}, ).fetchone() if not result: return None return {"username": result[0], "is_banned": result[1]} async def get_members_of_group(self, group: str) -> List[str]: """Get members of group """ if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() query_result = cursor.execute( """ SELECT user FROM user_in_group WHERE group_name = :group """, {"group": group}, ) members = [member[0] for member in query_result] return members async def group_exists(self, group: str) -> bool: if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() result = cursor.execute( "SELECT 1 FROM groups WHERE name = :group", {"group": group} ).fetchone() return bool(result) async def create_user(self, username): if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() cursor.execute( "INSERT INTO users(name) VALUES(:username)", {"username": username} ) self.connection.commit() async def delete_user(self, username): if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() cursor.execute( "DELETE FROM user_in_group WHERE user = :username", {"username": username} ) cursor.execute( "DELETE FROM users WHERE name = :username", {"username": username} ) self.connection.commit() async def user_exists(self, user: str) -> bool: if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() result = cursor.execute( "SELECT 1 FROM users WHERE name = :user", {"user": user} ).fetchone() return bool(result) async def is_user_in_group(self, user: str, group: str) -> bool: if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() result = cursor.execute( """ SELECT 1 FROM user_in_group WHERE user = :user AND group_name = :group """, {"user": user, "group": group}, ).fetchone() return bool(result) async def add_member_to_group(self, member, group): """Staff adds member to group """ cursor = self.connection.cursor() cursor.execute( """ INSERT INTO user_in_group( user, group_name ) VALUES ( :user, :group ) """, {"user": member, "group": group}, ) self.connection.commit() async def delete_member_in_group(self, member, group): """Delete member in group """ cursor = self.connection.cursor() cursor.execute( """ DELETE FROM user_in_group WHERE user = :user AND group_name = :group """, {"user": member, "group": group}, ) self.connection.commit() async def delete_members_in_group(self, group): """Delete all members of group """ if self.connection is None: return cursor = self.connection.cursor() cursor.execute( """ DELETE FROM user_in_group WHERE group_name = :group """, {"group": group}, ) self.connection.commit() async def ban_user(self, username: str, ban: bool = True): """Ban user """ if self.connection is None: raise RuntimeError("This class should be used in a context manager.") cursor = self.connection.cursor() cursor.execute( "UPDATE users SET is_banned = :ban WHERE name = :username", {"username": username, "ban": ban}, ) PKqfM/pasee/storage_backend/pgsql_backend/__init__.pyPK#UN'9##,pasee/storage_backend/pgsql_backend/pgsql.py"""sqlite """ from typing import List import asyncpg from pasee.storage_interface import StorageBackend class PostgresStorage(StorageBackend): """Exposing a simple backend that fetch authorizations from a dictionary. """ def __init__(self, options: dict, **kwargs) -> None: super().__init__(options, **kwargs) # type: ignore self.user = options["user"] self.password = options["password"] self.database = options["database"] self.host = options["host"] self.port = options["port"] async def __aenter__(self): self.pool = await asyncpg.create_pool( # pylint: disable=W0201 database=self.database, # W0201 is attribute-defined-outside-init user=self.user, # we define it outside of init on purpose password=self.password, host=self.host, port=self.port, min_size=1, max_size=5, ) async def __aexit__(self, exc_type, exc_value, traceback): try: self.pool.terminate() except AttributeError: pass async def get_authorizations_for_user(self, user: str) -> List[str]: """Claim list of groups an user belongs to """ async with self.pool.acquire() as connection: results = await connection.fetch( """ SELECT groups.name FROM groups JOIN user_in_group ON groups.id = user_in_group.group_id JOIN users ON user_in_group.user_id = users.id WHERE users.username = $1 ORDER BY groups.name ASC """, user, ) return [elem[0] for elem in results] async def create_group(self, group_name): """Staff member adds group method """ async with self.pool.acquire() as connection: await connection.execute("INSERT INTO groups(name) VALUES($1)", group_name) async def get_groups(self, last_element: str = "") -> List[str]: """Get groups paginated by group name in alphabetical order List of groups is returned by page of 20 last_element is the last know element returned in previous page So passing the last element to this function will retrieve the next page """ async with self.pool.acquire() as connection: results = await connection.fetch( """ SELECT name FROM groups WHERE name > $1 ORDER BY name LIMIT 20 """, last_element, ) return [group[0] for group in results] async def get_groups_of_user(self, user: str, last_element: str = "") -> List[str]: async with self.pool.acquire() as connection: results = await connection.fetch( """ SELECT groups.name FROM groups JOIN user_in_group ON groups.id = user_in_group.group_id JOIN users ON user_in_group.user_id = users.id WHERE users.username = $1 AND groups.name > $2 ORDER BY groups.name ASC LIMIT 20 """, user, last_element, ) return [group[0] for group in results] async def get_users(self, last_element: str = ""): async with self.pool.acquire() as connection: results = await connection.fetch( """ SELECT username FROM users WHERE username > $1 ORDER BY username ASC LIMIT 50 """, last_element, ) return [group[0] for group in results] async def get_user(self, username: str = ""): async with self.pool.acquire() as connection: result = await connection.fetchrow( """ SELECT username, is_banned FROM users WHERE username = $1 LIMIT 1 """, username, ) if not result: return None return {"username": result["username"], "is_banned": result["is_banned"]} async def delete_group(self, group: str): """Delete group """ await self.delete_members_in_group(group) async with self.pool.acquire() as connection: await connection.execute("DELETE FROM groups WHERE name = $1", group) async def get_members_of_group(self, group: str) -> List[str]: """Get members of group """ async with self.pool.acquire() as connection: results = await connection.fetch( """ SELECT users.username FROM user_in_group JOIN users ON users.id = user_in_group.user_id JOIN groups ON groups.id = user_in_group.group_id WHERE groups.name = $1 """, group, ) return [member[0] for member in results] async def create_user(self, username): async with self.pool.acquire() as connection: await connection.execute( """ INSERT INTO users(username) VALUES ($1) """, username, ) async def delete_user(self, username): async with self.pool.acquire() as connection: await connection.execute( """ DELETE FROM user_in_group USING users WHERE user_in_group.user_id = users.id AND users.username = $1 """, username, ) await connection.execute("DELETE FROM users WHERE username = $1", username) async def group_exists(self, group: str) -> bool: async with self.pool.acquire() as connection: result = await connection.fetch( "SELECT 1 FROM groups WHERE name = $1", group ) return bool(result) async def user_exists(self, user: str) -> bool: async with self.pool.acquire() as connection: result = await connection.fetch( "SELECT 1 FROM users WHERE username = $1", user ) return bool(result) async def add_member_to_group(self, member, group): """Staff adds member to group """ async with self.pool.acquire() as connection: await connection.execute( """ INSERT INTO user_in_group (user_id, group_id) SELECT users.id, groups.id FROM users, groups WHERE users.username = $1 AND groups.name = $2 """, member, group, ) async def is_user_in_group(self, user: str, group: str) -> bool: """Verify that user is in group """ async with self.pool.acquire() as connection: result = await connection.fetch( """ SELECT 1 FROM user_in_group JOIN users ON users.id = user_in_group.user_id JOIN groups ON groups.id = user_in_group.group_id WHERE groups.name = $1 AND users.username = $2 """, group, user, ) return bool(result) async def delete_member_in_group(self, member, group): """Delete member in group """ async with self.pool.acquire() as connection: await connection.execute( """ DELETE FROM user_in_group USING users, groups WHERE user_in_group.user_id = users.id AND user_in_group.group_id = groups.id AND users.username = $1 AND groups.name = $2 """, member, group, ) async def delete_members_in_group(self, group): async with self.pool.acquire() as connection: await connection.execute( """ DELETE FROM user_in_group USING groups WHERE user_in_group.group_id = groups.id AND groups.name = $1 """, group, ) async def ban_user(self, username: str, ban: bool = True): """Ban user """ async with self.pool.acquire() as connection: await connection.execute( """ UPDATE users SET is_banned = $1 WHERE username = $2 """, ban, username, ) PK#UN\;zz1pasee/storage_backend/pgsql_backend/schema_01.sqlCREATE DATABASE pasee; CREATE TABLE "groups" ("id" serial NOT NULL PRIMARY KEY, "name" text NOT NULL UNIQUE); CREATE TABLE "users" ("id" serial NOT NULL PRIMARY KEY, "username" text NOT NULL UNIQUE, "is_banned" boolean DEFAULT FALSE); CREATE TABLE "user_in_group" ("id" serial NOT NULL PRIMARY KEY, "user_id" integer NOT NULL, "group_id" integer NOT NULL); CREATE INDEX "groups_name_46b2c599_like" ON "groups" ("name" text_pattern_ops); CREATE INDEX "users_username_e8658fc8_like" ON "users" ("username" text_pattern_ops); ALTER TABLE "user_in_group" ADD CONSTRAINT "user_in_group_user_id_8b54342e_fk_users_id" FOREIGN KEY ("user_id") REFERENCES "users" ("id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "user_in_group" ADD CONSTRAINT "user_in_group_group_id_2691306a_fk_groups_id" FOREIGN KEY ("group_id") REFERENCES "groups" ("id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "user_in_group" ADD CONSTRAINT "user_in_group_user_id_group_id_1e50f3dc_uniq" UNIQUE ("user_id", "group_id"); CREATE INDEX "user_in_group_user_id_8b54342e" ON "user_in_group" ("user_id"); CREATE INDEX "user_in_group_group_id_2691306a" ON "user_in_group" ("group_id"); PK5 Mpasee/tokens/__init__.pyPK$HNNt t pasee/tokens/handlers.py"""Hanlers for tokens """ import json from datetime import datetime, timedelta import jwt import shortuuid from aiohttp import web from pasee.identity_providers import backend as identity_providers from pasee.identity_providers.backend import Claims from pasee.utils import import_class def create_jti_and_expiration_values(hours_to_add: int): """Returns new uuid and expiration time """ return shortuuid.uuid(), datetime.utcnow() + timedelta(hours=hours_to_add) def generate_access_token_and_refresh_token_pairs(claims, private_key, algorithm): """Create new access token with refresh token """ claims["jti"], claims["exp"] = create_jti_and_expiration_values( # type: ignore hours_to_add=1 ) access_token = jwt.encode(claims, private_key, algorithm=algorithm) claims["jti"], claims["exp"] = create_jti_and_expiration_values( # type: ignore hours_to_add=720 ) del claims["groups"] claims["refresh_token"] = True refresh_token = jwt.encode(claims, private_key, algorithm=algorithm) return access_token, refresh_token async def authenticate_with_identity_provider(request: web.Request) -> Claims: """Use identity provider provided by user to authenticate. """ try: input_data = await request.json() except json.decoder.JSONDecodeError: input_data = {} identity_provider_input = request.rel_url.query.get("idp", None) if not identity_provider_input: raise web.HTTPBadRequest( reason="Identity provider not provided in query string" ) if identity_provider_input not in request.app.settings["idps"]: raise web.HTTPBadRequest(reason="Identity provider not implemented") identity_provider_path = request.app.settings["idps"][identity_provider_input][ "implementation" ] identity_provider_settings = request.app.settings["idps"][identity_provider_input] identity_provider = import_class(identity_provider_path)(identity_provider_settings) return await identity_provider.authenticate_user(input_data) async def handle_oauth_callback(identity_provider_input: str, request: web.Request): """Callback handler for oauth protocol """ if identity_provider_input not in identity_providers.BACKENDS: raise web.HTTPBadRequest(reason="Identity provider not implemented") identity_provider_path = identity_providers.BACKENDS[identity_provider_input] identity_provider_settings = request.app.settings["idps"][identity_provider_input] identity_provider = import_class(identity_provider_path)(identity_provider_settings) idp_claims = await identity_provider.authenticate_user( { "oauth_verifier": request.rel_url.query.get("oauth_verifier"), "oauth_token": request.rel_url.query.get("oauth_token"), }, step=2, ) sub = f"{identity_provider_input}-{idp_claims['sub']}" if not await request.app.storage_backend.user_exists(sub): await request.app.storage_backend.create_user(sub) groups = await request.app.storage_backend.get_authorizations_for_user(sub) pasee_claims = { "iss": request.app.settings["jwt"]["iss"], "sub": sub, "groups": groups, } return generate_access_token_and_refresh_token_pairs( pasee_claims, request.app.settings["private_key"], algorithm=request.app.settings["algorithm"], ) PK<.NfQpasee/tokens/views.py"""Views for tokens ressource in Pasee server, implementing: - GET /tokens/ - POST /tokens/ """ import logging from typing import List import coreapi from aiohttp import web from pasee.serializers import serialize from pasee.tokens.handlers import generate_access_token_and_refresh_token_pairs from pasee.tokens.handlers import authenticate_with_identity_provider from pasee.tokens.handlers import handle_oauth_callback from pasee import utils, Unauthorized logger = logging.getLogger(__name__) async def get_tokens(request: web.Request) -> web.Response: """Handlers for GET /token/, just describes that a POST is possible. """ hostname = request.app.settings["hostname"] access_token, refresh_token = None, None identity_provider_input = request.rel_url.query.get("idp", None) if identity_provider_input: access_token, refresh_token = await handle_oauth_callback( identity_provider_input, request ) access_token = access_token.decode("utf-8") refresh_token = refresh_token.decode("utf-8") idps = {} for idp_name, idp_conf in request.app.settings["idps"].items(): fields: List[coreapi.Field] = [] if idp_conf.get("input_fields"): fields = [ coreapi.Field(name=field.get("name"), required=field.get("required")) for field in idp_conf["input_fields"] ] idps[f"identify_via_{idp_name}"] = coreapi.Link( action="post", title=idp_conf.get("title"), description=idp_conf.get("description"), fields=fields, url=f"/tokens/?idp={idp_name}", ) return serialize( request, coreapi.Document( url=f"{hostname}/token", title="Request Token From Identity Provider", content={ "access_token": access_token, "refresh_token": refresh_token, **idps, }, ), ) async def post_token(request: web.Request) -> web.Response: """Post to IDP to create a jwt token """ # Proceed as a refresh token handler if query string refresh is available if "refresh" in request.rel_url.query: if not request.headers.get("Authorization"): raise web.HTTPBadRequest( reason="Missing Authorization header for refreshing access token" ) claims = utils.enforce_authorization(request.headers, request.app.settings) if not claims.get("refresh_token", False): raise Unauthorized("Token is not a refresh token") else: claims = await authenticate_with_identity_provider(request) response_content = { "identify_to_kisee": coreapi.Link( action="post", title="Login via login/password pair", description=""" POSTing to this endpoint will identify you by login/password. """, fields=[ coreapi.Field(name="login", required=True), coreapi.Field(name="password", required=True), ], url="/tokens/?idp=kisee", ) } if "sub" in claims: storage_backend = request.app.storage_backend if not await storage_backend.user_exists(claims["sub"]): await storage_backend.create_user(claims["sub"]) claims["groups"] = await storage_backend.get_authorizations_for_user( claims["sub"] ) access_token, refresh_token = generate_access_token_and_refresh_token_pairs( claims, request.app.settings["private_key"], algorithm=request.app.settings["algorithm"], ) response_content["access_token"] = access_token.decode("utf-8") response_content["refresh_token"] = refresh_token.decode("utf-8") else: response_content["authorize_url"] = claims["authorize_url"] return serialize( request, coreapi.Document( url="/tokens/", title="Create a token with Identify Provider", content=response_content, ), headers={"Cache-Control": "no-store", "Pragma": "no-cache"}, status=201, ) PKafMpasee/users/__init__.pyPK#UNWpasee/users/views.py"""Views for users ressource in Pasee server, implementing: - GET /users/ - POST /users/ """ import logging from typing import List from urllib.parse import parse_qs import coreapi from aiohttp import web from pasee import Unauthorized, Unauthenticated from pasee.serializers import serialize from pasee.identity_providers.utils import get_identity_provider_with_capability from pasee.groups.utils import is_root from pasee import utils logger = logging.getLogger(__name__) async def get_users(request: web.Request) -> web.Response: """Handlers for GET /users/, just describes that a POST is possible. """ hostname = request.app.settings["hostname"] identity_provider = get_identity_provider_with_capability( request.app.settings, "register-user" ) register_user_endpoint = await identity_provider.get_endpoint("register-user") users: List[str] = [] errors: List[coreapi.Error] = [] last_element = None try: claims = utils.enforce_authorization(request.headers, request.app.settings) if is_root(claims["groups"]): after = request.rel_url.query.get("after", "") users = await request.app.storage_backend.get_users(after) last_element = users[-1] if users else None except Unauthorized as unauthorized_error: errors.append(coreapi.Error(content={"reason": unauthorized_error.reason})) except Unauthenticated: pass content = { "users": [ coreapi.Document( url=f"{hostname}/users/{user}/", content={"username": user} ) for user in users ], "Self service registration": coreapi.Link( action="get", title="Self service registration for identity provider", description="Get more information on how to self register", url=register_user_endpoint, ), "errors": errors, } if last_element: if request.rel_url.query: if not request.rel_url.query.get("after"): next_endpoint = f"{hostname}{request.rel_url}&after={last_element}" else: parsed_query = parse_qs(request.rel_url.raw_query_string) query = {key: "".join(value) for key, value in parsed_query.items()} query["after"] = last_element query_string = "?" + "&".join( [f"{key}={value}" for key, value in query.items()] ) next_endpoint = f"{hostname}{request.path}{query_string}" else: next_endpoint = f"{hostname}{request.rel_url}?after={last_element}" content["next"] = coreapi.Link(url=next_endpoint) return serialize( request, coreapi.Document( url=f"{hostname}/users/", title="Users management interface", content=content, ), headers={"Vary": "Origin"}, ) async def get_user(request: web.Request) -> web.Response: """Handlers for GET /users/{username} List groups of {username} """ hostname = request.app.settings["hostname"] username = request.match_info["username"] claims = utils.enforce_authorization(request.headers, request.app.settings) if not is_root(claims["groups"]) and not claims["sub"] == username: # is user raise web.HTTPForbidden(reason="Do not have rights to view user info") last_element = request.rel_url.query.get("last_element", "") user = await request.app.storage_backend.get_user(username) if not user: raise web.HTTPNotFound(reason="User does not exist") groups = await request.app.storage_backend.get_groups_of_user( username, last_element ) content = user content["patch"] = coreapi.Link( action="patch", title="Patch fields of user", description="A method to patch fields of user", fields=[coreapi.Field(name="is_banned")], ) if groups: content["next"] = coreapi.Link( url=f"{hostname}/users/{username}?last_element={groups[-1]}" ) content["groups"] = [ coreapi.Document( url=f"{hostname}/groups/{group}/", content={"group": group} ) for group in groups ] return serialize( request, coreapi.Document( url=f"{hostname}/users/{username}", title="User interface", content=content ), headers={"Vary": "Origin"}, ) async def patch_user(request: web.Request) -> web.Response: """Handlers for PATCH /users/{username} Patch an user """ username = request.match_info["username"] claims = utils.enforce_authorization(request.headers, request.app.settings) if not is_root(claims["groups"]): raise web.HTTPForbidden(reason="Do not have rights to patch") user = await request.app.storage_backend.get_user(username) if not user: raise web.HTTPNotFound(reason="User does not exist") input_data = await request.json() if "username" in input_data: raise web.HTTPBadRequest(reason="can not patch username") if "is_banned" in input_data: ban = input_data["is_banned"] await request.app.storage_backend.ban_user(username, ban) return web.Response(status=204) async def delete_user(request: web.Request) -> web.Response: """Handlers for DELETE /users/{username} Delete {username} """ username = request.match_info["username"] claims = utils.enforce_authorization(request.headers, request.app.settings) if not is_root(claims["groups"]): raise web.HTTPForbidden(reason="Do not have rights to delete user") await request.app.storage_backend.delete_user(username) return web.Response(status=204) PK!HK)-'pasee-19.7.0.dist-info/entry_points.txtN+I/N.,()*H,NMz񹉙yV PKqN55pasee-19.7.0.dist-info/LICENSEThe MIT License (MIT) Copyright (c) 2019 meltygroup Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HPOpasee-19.7.0.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,szd&Y)r$[)T&UrPK!H ;М$pasee-19.7.0.dist-info/METADATATr0)rڦc&@ڤZ[Gl IN'gxVqRHRo~Z}}\pϓOhu=6`CdW5Ump<C;C y)\u%2޸iB_sTg #} 1$[tA k3:22Ǽ.8qWJ*nIDNz̅0:LTFn663r#O]uNhʽOˡӁνRlZHx1F:W|϶,w,z &l㘠d{R-Ybnˎ!-مm0igRms O ZeﴏtnD9=E1_~!R'(tid.𮵢쌋YzcY鿇HalLcMKBP+%rC΃CSv@4~AE@&Wpڊ?Lp\[jrXz :;ȢAwO0BOw83=bClL'Ճ0a?O{1h7 N({X1WM]f-Y!S,9 NM{1,*wyN+D̃6H n |Ǩ[1+t6yuhZ9q fu'rh^)A>Av{vmb%V4 BM u@tphV+eS ݱ61e VkHau\V4%ZJS^U w66.󅒟HӸ?m*K⋹w4quLY#5ѫ&*?C6b>k՝`Kd7bafM[J6#J8Uq]d(kdMPgZ:|FqQ1斁KL~I1}ɐ4wp[%F1 X,킠$I:]u*<=Eh+ <0T謝%kJHdӽ`nEU!v_K\>-3ɠc+KS!|b;H..X J<T v e1~@U[ܫ!邗|-kW |`'rv8/JG &6}"*R ?5E9x;1d"0:/ث 6_ӚN2хh-)]C`:uq6 (F!oQ8+!b!ղ{70߆&-l=@g>bt["bL"~HчJgB!'ӌN;)(TdOeY??:+9Uź@Qtgʼj6+}ĹsS! fu\=ZW4:UboG\醠tҮ2v1e PKуN1apasee/__init__.pyPKуNQDpasee/__main__.pyPKKTN$u pasee/middlewares.pyPK#UN@`-pasee/pasee.pyPKVsYN ~~<#pasee/serializers.pyPK#UN - %pasee/storage_interface.pyPK%HNNAu1pasee/utils.pyPK#UNJͧNN8pasee/views.pyPK5 Mb@pasee/groups/__init__.pyPK<.NƟ@pasee/groups/utils.pyPK#UN!$66fDpasee/groups/views.pyPKbM$M$apasee/identity_providers/__init__.pyPKqfM̗Y[[#bpasee/identity_providers/backend.pyPKsdNW* !fpasee/identity_providers/kisee.pyPKqfMop}# tpasee/identity_providers/twitter.pyPK<.N!|pasee/identity_providers/utils.pyPKqfM!}pasee/storage_backend/__init__.pyPK<.N.~pasee/storage_backend/demo_backend/__init__.pyPK#UN '',g~pasee/storage_backend/demo_backend/sqlite.pyPKqfM/\pasee/storage_backend/pgsql_backend/__init__.pyPK#UN'9##,pasee/storage_backend/pgsql_backend/pgsql.pyPK#UN\;zz1pasee/storage_backend/pgsql_backend/schema_01.sqlPK5 MIpasee/tokens/__init__.pyPK$HNNt t pasee/tokens/handlers.pyPK<.NfQ)pasee/tokens/views.pyPKafMpasee/users/__init__.pyPK#UNW pasee/users/views.pyPK!HK)-'pasee-19.7.0.dist-info/entry_points.txtPKqN55tpasee-19.7.0.dist-info/LICENSEPK!HPO pasee-19.7.0.dist-info/WHEELPK!H ;М$o pasee-19.7.0.dist-info/METADATAPK!Hf ^ H pasee-19.7.0.dist-info/RECORDPK P p