diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..60cad9a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3-alpine +ENV PYTHONDONTWRITEBYECODE=1 \ + PYTHONUNBUFFERED=1 +WORKDIR /app +RUN apk add --no-cache gcc g++ make +COPY . /tmp/aiohttp-oauth2 +COPY ./example/. . +RUN pip install -r requirements.txt +RUN pip install --upgrade /tmp/aiohttp-oauth2 +EXPOSE 8080 +CMD gunicorn app:app_factory --bind=0.0.0.0:8080 --worker-class aiohttp.GunicornWebWorker diff --git a/README.md b/README.md index 5e810fa..5efab7d 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,15 @@ $ pip install -r requirements.txt $ PYTHONPATH=".." python github.py ``` +# OAuth2 Setup + +As part of the oauth2 spec, you will need to register your application with the oauth2 provider. To do so, you need to know your host (and port), along with the callback URL. Don't forget to include the prefix you used for your subapp. + +| Path | Type | +| ---- | ---- | +| {path\_prefix}/auth | This is where you need to send your user to log in to the specific provider | +| {path\_prefix}/callback | This is what handles the oauth2 callback (exchange a code for an access token) | + # Tips ## Incorrect URL scheme (missing `https`) diff --git a/aiohttp_oauth2/client/contrib.py b/aiohttp_oauth2/client/contrib.py index 494f0db..7e0a76d 100644 --- a/aiohttp_oauth2/client/contrib.py +++ b/aiohttp_oauth2/client/contrib.py @@ -4,6 +4,18 @@ from .app import oauth2_app +coinbase = partial( + oauth2_app, + authorize_url="https://www.coinbase.com/oauth/authorize", + token_url="https://api.coinbase.com/oauth/token", +) + +digital_ocean = partial( + oauth2_app, + authorize_url="https://cloud.digitalocean.com/v1/oauth/authorize", + token_url="https://cloud.digitalocean.com/v1/oauth/token", +) + github = partial( oauth2_app, authorize_url="https://github.com/login/oauth/authorize", @@ -23,8 +35,8 @@ json_data=False, ) -twitter = partial( +twitch = partial( oauth2_app, - authorize_url="https://api.twitter.com/oauth/authorize", - token_url="https://api.twitter.com/oauth2/token", + authorize_url="https://id.twitch.tv/oauth2/authorize", + token_url="https://id.twitch.tv/oauth2/token", ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0a9d044 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.4" + +services: + app: + image: aiohttp-oauth2 + build: . + environment: + - COINBASE_CLIENT_ID + - COINBASE_CLIENT_SECRET + - DIGITALOCEAN_CLIENT_ID + - DIGITALOCEAN_CLIENT_SECRET + - GITHUB_CLIENT_ID + - GITHUB_CLIENT_SECRET + - GOOGLE_CLIENT_ID + - GOOGLE_CLIENT_SECRET + - TWITCH_CLIENT_ID + - TWITCH_CLIENT_SECRET + ports: + - "8080:8080" + volumes: + - ${PWD}/example:/app diff --git a/example/app.py b/example/app.py new file mode 100644 index 0000000..d7e3753 --- /dev/null +++ b/example/app.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import os +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import jinja2 +from aiohttp import web +from aiohttp_jinja2 import setup as jinja2_setup, template +from aiohttp_session import SimpleCookieStorage, get_session, setup as session_setup +from aiohttp_oauth2.client.contrib import ( + coinbase, + digital_ocean as digitalocean, + github, + google, + twitch, +) + + +@dataclass +class SocialUser: + name: str + id: str + img: str + + +@dataclass +class Provider: + func: Callable + name: str + on_login: Callable[[web.Request, Any], web.Response] + scopes: Optional[List[str]] = field(default_factory=list) + + def __str__(self) -> str: + return self.name + + @property + def url(self) -> str: + return f"/auth/{self}" + + @property + def client_id(self) -> str: + return os.getenv(f"{self}_CLIENT_ID".upper()) + + @property + def client_secret(self) -> str: + return os.getenv(f"{self}_CLIENT_SECRET".upper()) + + +async def on_coinbase_login(request: web.Request, access_token): + session = await get_session(request) + + async with request.app["session"].get( + "https://api.coinbase.com/v2/user", + headers={ + "Authorization": f"Bearer {access_token['access_token']}", + "CB-VERSION": "2019-11-15", + }, + ) as r: + body = (await r.json())["data"] + session["coinbase_user"] = asdict(SocialUser( + name=body["name"], + img=body["avatar_url"], + id=body["id"], + )) + + return web.HTTPTemporaryRedirect(location="/") + + +async def on_digitalocean_login(request: web.Request, access_token): + session = await get_session(request) + + session["digitalocean_user"] = asdict(SocialUser( + name=access_token["info"]["name"], + img="", + id=access_token["info"]["uuid"], + )) + + return web.HTTPTemporaryRedirect(location="/") + + +async def on_github_login(request: web.Request, github_token): + session = await get_session(request) + + async with request.app["session"].get( + "https://api.github.com/user", + headers={"Authorization": f"Bearer {github_token['access_token']}"}, + ) as r: + user = await r.json() + session["github_user"] = asdict(SocialUser( + id=user["id"], + name=user["login"], + img=user["avatar_url"], + )) + + return web.HTTPTemporaryRedirect(location="/") + + +async def on_google_login(request: web.Request, google_token: str): + session = await get_session(request) + + async with request.app["session"].get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers={"Authorization": f"Bearer {google_token['access_token']}"}, + ) as r: + google_user = await r.json() + session["google_user"] = asdict(SocialUser( + id=google_user["sub"], + img=google_user["picture"], + name=google_user["name"], + )) + + return web.HTTPTemporaryRedirect(location="/") + + +async def on_twitch_login(request: web.Request, access_token): + session = await get_session(request) + + async with request.app["session"].get( + "https://api.twitch.tv/helix/users", + headers={ + "Authorization": f"Bearer {access_token['access_token']}", + "Client-ID": os.getenv("TWITCH_CLIENT_ID"), + }, + ) as r: + twitch_user = (await r.json())["data"][0] + session["twitch_user"] = asdict(SocialUser( + id=twitch_user["id"], + name=twitch_user["login"], + img=twitch_user["profile_image_url"], + )) + + return web.HTTPTemporaryRedirect(location="/") + + +async def app_factory() -> web.Application: + app = web.Application() + + providers = [ + Provider(coinbase, "coinbase", on_coinbase_login, ["wallet:user:read"]), + Provider(digitalocean, "digitalocean", on_digitalocean_login), + Provider(github, "github", on_github_login), + Provider(google, "google", on_google_login, ["profile", "email"]), + Provider(twitch, "twitch", on_twitch_login), + ] + + jinja2_setup( + app, + loader=jinja2.FileSystemLoader([Path(__file__).parent / "templates"]), + extensions=["jinja2.ext.with_"], + ) + session_setup(app, SimpleCookieStorage()) + + for provider in providers[:]: + if provider.client_id and provider.client_secret: + print("Adding provider", provider) + app.add_subapp( + provider.url, + provider.func(provider.client_id, provider.client_secret, on_login=provider.on_login, scopes=provider.scopes or []) + ) + else: + providers.remove(provider) + print("Missing credentials for", provider) + + app["providers"] = providers + + app.add_routes([ + web.get("/", index), + web.get("/auth/logout", logout), + ]) + + return app + + +@template("index.html") +async def index(request: web.Request) -> Dict[str, Any]: + session = await get_session(request) + + context = { + "users": {}, + "providers": request.app["providers"], + } + + for provider in request.app["providers"]: + social_user = None + if (u := session.get(f"{provider}_user")): + social_user = SocialUser(**u) + context["users"][str(provider)] = social_user + + return context + + +async def logout(request: web.Request): + session = await get_session(request) + if (provider := request.query.get("provider")): + session.pop(f"{provider}_user") + else: + session.invalidate() + return web.HTTPTemporaryRedirect(location="/") + + +if __name__ == "__main__": + web.run_app(app_factory(), host="0.0.0.0", port=8080) diff --git a/examples/requirements.txt b/example/requirements.txt similarity index 72% rename from examples/requirements.txt rename to example/requirements.txt index 104da61..8f6bab3 100644 --- a/examples/requirements.txt +++ b/example/requirements.txt @@ -1,2 +1,3 @@ aiohttp-jinja2==1.1.1 aiohttp-session==2.7.0 +gunicorn==20.0.4 diff --git a/example/templates/index.html b/example/templates/index.html new file mode 100644 index 0000000..083c014 --- /dev/null +++ b/example/templates/index.html @@ -0,0 +1,63 @@ + + +
+ + + ++ {% if user %} + {{ user.name }} ({{ user.id }}) + {% else %} + Not logged in + {% endif %} +
++ {% if username %} + {{ username }} + {% else %} + Not logged in + {% endif %} +
+