From 4b6003a4cb047ebc53ef0f9ccd28d4d631d868ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Rodrigo?= Date: Tue, 27 Feb 2024 12:01:16 +0100 Subject: [PATCH] Switch to OAuth2 --- .dockerignore | 1 + .gitignore | 2 ++ docker/.env.template | 3 ++ docker/README.md | 2 ++ docker/docker-compose.yml | 3 ++ requirements.txt | 6 ++-- web_api/app.py | 56 ++++++++++++++++++++++-------- web_api/editor.py | 48 ++++++++++++------------- web_api/map.py | 2 +- web_api/tool/oauth.py | 73 --------------------------------------- web_api/tool/session.py | 4 +-- 11 files changed, 81 insertions(+), 119 deletions(-) create mode 100644 docker/.env.template delete mode 100644 web_api/tool/oauth.py diff --git a/.dockerignore b/.dockerignore index c6002933..4abfbb80 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,3 +3,4 @@ .mypy_cache web/node_modules web/public/assets +docker/.env diff --git a/.gitignore b/.gitignore index c9278149..e82d0d9e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ osmose-frontend-venv web/public/assets .mypy_cache + +docker/.env diff --git a/docker/.env.template b/docker/.env.template new file mode 100644 index 00000000..c0f85f0d --- /dev/null +++ b/docker/.env.template @@ -0,0 +1,3 @@ +OSM_CLIENT_ID= +OSM_CLIENT_SECRET= +COOKIE_SIGN_KEY= diff --git a/docker/README.md b/docker/README.md index abb4f042..0c751a6e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,5 +1,7 @@ # Docker +Copy `.env.template` as `.env` and adjustcontent, only required to enable loggin from osm.org. + Build the Docker image, within the docker directory: ``` curl http://osmose.openstreetmap.fr/export/osmose-menu.sql.bz2 | bzcat > osmose-menu.sql diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 14426f7c..54de08ec 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -32,6 +32,9 @@ services: - postgres environment: - DB_HOST=postgres + - OSM_CLIENT_ID=${OSM_CLIENT_ID} + - OSM_CLIENT_SECRET=${OSM_CLIENT_SECRET} + - COOKIE_SIGN_KEY=${COOKIE_SIGN_KEY} ports: - 20009:20009 networks: diff --git a/requirements.txt b/requirements.txt index 88fec5fd..94b99817 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,8 +8,6 @@ pyclipper fastapi fastapi-sessions python-multipart -# Use a patch fork, for unmaintened rauth: -# - https://github.com/litl/rauth/issues/185 (data dict/bytes issue) -# - https://github.com/litl/rauth/pull/208 (python 3.8 compatibility) -git+https://github.com/osm-fr/rauth.git +Authlib +httpx lxml diff --git a/web_api/app.py b/web_api/app.py index dbd12ed9..d3cdecfb 100644 --- a/web_api/app.py +++ b/web_api/app.py @@ -1,16 +1,21 @@ +import os from typing import Any, Dict, Optional from uuid import UUID, uuid4 -from fastapi import Depends, FastAPI, Response +import requests +from authlib.integrations.starlette_client import OAuth # type: ignore +from fastapi import Depends, FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi from fastapi.responses import HTMLResponse, RedirectResponse +from starlette.config import Config +from starlette.middleware.sessions import SessionMiddleware from modules import utils -from modules.dependencies import database +from modules.dependencies import database, langs +from modules.utils import LangsNegociation from . import byuser, editor, false_positive, issue, issues, map -from .tool import oauth from .tool.session import SessionData, backend, cookie, verifier openapi_tags = [ @@ -37,17 +42,37 @@ def custom_openapi() -> Dict[str, Any]: app.openapi = custom_openapi # type: ignore +app.add_middleware(SessionMiddleware, secret_key=os.getenv("COOKIE_SIGN_KEY", "")) + +oauth = OAuth( + Config( + environ={ + "OSM_CLIENT_ID": os.getenv("OSM_CLIENT_ID", ""), + "OSM_CLIENT_SECRET": os.getenv("OSM_CLIENT_SECRET", ""), + } + ) +) +oauth.register( + name="osm", + server_metadata_url="https://www.openstreetmap.org/.well-known/oauth-authorization-server", + client_kwargs={"scope": "read_prefs write_api write_notes"}, +) + @app.get("/login") -async def login(session_id: Optional[UUID] = Depends(cookie)) -> RedirectResponse: +async def login( + request: Request, + session_id: Optional[UUID] = Depends(cookie), + langs: LangsNegociation = Depends(langs.langs), +) -> RedirectResponse: if session_id: await backend.delete(session_id) - (url, oauth_tokens) = oauth.fetch_request_token() session = uuid4() - await backend.create(session, SessionData(oauth_tokens=oauth_tokens)) + await backend.create(session, SessionData()) - response = RedirectResponse(url) + redirect_uri = utils.website + "/en/oauth2" + response = await oauth.osm.authorize_redirect(request, redirect_uri) cookie.attach_to_response(response, session) return response @@ -62,19 +87,22 @@ async def logout( return RedirectResponse("map/") -@app.get("/oauth") -async def oauth_( +@app.get("/oauth2") +async def oauth2( + request: Request, session_id: UUID = Depends(cookie), session_data: Optional[SessionData] = Depends(verifier), ) -> RedirectResponse: if session_id and session_data: try: - oauth_tokens = oauth.fetch_access_token(session_data.oauth_tokens) - session_data.oauth_tokens = oauth_tokens - user_request = oauth.get( - oauth_tokens, utils.remote_url + "api/0.6/user/details.json" + oauth2_token = await oauth.osm.authorize_access_token(request) + session_data.oauth2_token = oauth2_token["access_token"] + + user_request = requests.get( + utils.remote_url + "api/0.6/user/details.json", + headers={"Authorization": f"Bearer {session_data.oauth2_token}"}, ) - if user_request: + if user_request and user_request.status_code == 200: session_data.user = user_request.json() await backend.update(session_id, session_data) except Exception: diff --git a/web_api/editor.py b/web_api/editor.py index dfe3ee7a..76c1be81 100644 --- a/web_api/editor.py +++ b/web_api/editor.py @@ -1,14 +1,14 @@ import io -from typing import Dict, Optional, Tuple +from typing import Dict, Optional from uuid import UUID +import requests from asyncpg import Connection from fastapi import APIRouter, Depends, HTTPException, Request from modules import OsmSax, utils from modules.dependencies import database -from .tool import oauth from .tool.session import SessionData, backend, cookie, verifier router = APIRouter() @@ -21,7 +21,7 @@ async def save( session_id: UUID = Depends(cookie), session_data: Optional[SessionData] = Depends(verifier), ) -> None: - if not session_data: + if not session_data or not session_data.oauth2_token: raise HTTPException(status_code=401) json = await request.json() @@ -44,7 +44,7 @@ async def save( changeset = session_data.changeset if changeset and not reuse_changeset: try: - _changeset_close(session_data.oauth_tokens, changeset) + _changeset_close(session_data.oauth2_token, changeset) except Exception: pass changeset = None @@ -52,14 +52,14 @@ async def save( await backend.update(session_id, session_data) elif changeset: try: - _changeset_update(session_data.oauth_tokens, changeset, tags) + _changeset_update(session_data.oauth2_token, changeset, tags) except Exception: changeset = None session_data.changeset = changeset await backend.update(session_id, session_data) if not changeset: - changeset = _changeset_create(session_data.oauth_tokens, tags) + changeset = _changeset_create(session_data.oauth2_token, tags) session_data.changeset = changeset await backend.update(session_id, session_data) @@ -91,7 +91,7 @@ async def save( osmchange = out.getvalue() # Fire the changeset - _changeset_upload(session_data.oauth_tokens, changeset, osmchange) + _changeset_upload(session_data.oauth2_token, changeset, osmchange) def _osm_changeset(tags, id: str = "0") -> str: @@ -108,35 +108,33 @@ def _osm_changeset(tags, id: str = "0") -> str: return out.getvalue() -def _changeset_create(oauth_tokens: Tuple[str, str], tags: Dict[str, str]) -> str: - changeset = oauth.put( - oauth_tokens, +def _changeset_create(oauth2_token: str, tags: Dict[str, str]) -> str: + request = requests.put( utils.remote_url_write + "api/0.6/changeset/create", - _osm_changeset(tags), + data=_osm_changeset(tags), + headers={"Authorization": f"Bearer {oauth2_token}"}, ) - return changeset + return request.text -def _changeset_update( - oauth_tokens: Tuple[str, str], id: str, tags: Dict[str, str] -) -> None: - oauth.put( - oauth_tokens, +def _changeset_update(oauth2_token: str, id: str, tags: Dict[str, str]) -> None: + requests.put( utils.remote_url_write + "api/0.6/changeset/" + id, - _osm_changeset(tags, id=id), + data=_osm_changeset(tags, id=id), + headers={"Authorization": f"Bearer {oauth2_token}"}, ) -def _changeset_close(oauth_tokens: Tuple[str, str], id: str) -> None: - oauth.put( - oauth_tokens, +def _changeset_close(oauth2_token: str, id: str) -> None: + requests.put( utils.remote_url_write + "api/0.6/changeset/" + id + "/close", + headers={"Authorization": f"Bearer {oauth2_token}"}, ) -def _changeset_upload(oauth_tokens: Tuple[str, str], id: str, osmchange) -> None: - oauth.post( - oauth_tokens, +def _changeset_upload(oauth2_token: str, id: str, osmchange) -> None: + requests.post( utils.remote_url_write + "api/0.6/changeset/" + id + "/upload", - osmchange, + data=osmchange, + headers={"Authorization": f"Bearer {oauth2_token}"}, ) diff --git a/web_api/map.py b/web_api/map.py index d03cf672..4d68b493 100644 --- a/web_api/map.py +++ b/web_api/map.py @@ -67,7 +67,7 @@ async def index( timestamp = await db.fetchval(sql) if session_data and session_data.user: - user = session_data.user["osm"]["user"]["@display_name"] + user = session_data.user["user"]["display_name"] user_error_count = await _user_count(params, db, user) else: user = None diff --git a/web_api/tool/oauth.py b/web_api/tool/oauth.py deleted file mode 100644 index 3157e4d6..00000000 --- a/web_api/tool/oauth.py +++ /dev/null @@ -1,73 +0,0 @@ -from typing import Tuple - -import requests -from rauth import OAuth1Service, OAuth1Session # type: ignore - -################################################################################ - -oauth_client_key = "dYwl0uhsxOmbPqWOobdr8AUP4L4CjTibNcObmbLT" -oauth_client_secret = "ZO1rm8YrJESfCuebY8MgcdE9nDlO5d0Y3f2GOS7g" -oauth_server = "https://www.openstreetmap.org/oauth/" - -################################################################################ - -oauth_request_token = oauth_server + "request_token" -oauth_access_token = oauth_server + "access_token" -oauth_authorize = oauth_server + "authorize" - - -oauth = OAuth1Service( - consumer_key=oauth_client_key, - consumer_secret=oauth_client_secret, - request_token_url=oauth_request_token, - access_token_url=oauth_access_token, - authorize_url=oauth_authorize, -) - - -def fetch_request_token() -> Tuple[str, Tuple[str, str]]: - request_token, request_token_secret = oauth.get_request_token() - authorize_url = oauth.get_authorize_url(request_token) - return (authorize_url, (request_token, request_token_secret)) - - -def fetch_access_token(oauth_tokens: Tuple[str, str]) -> Tuple[str, str]: - session = oauth.get_auth_session(oauth_tokens[0], oauth_tokens[1], method="POST") - return (session.access_token, session.access_token_secret) - - -def _session(oauth_tokens: Tuple[str, str]) -> OAuth1Session: - return OAuth1Session( - oauth_client_key, - oauth_client_secret, - access_token=oauth_tokens[0], - access_token_secret=oauth_tokens[1], - ) - - -def get(oauth_tokens: Tuple[str, str], url: str) -> str: - resp = _session( - oauth_tokens, - ).get(url) - if resp and resp.status_code == requests.codes.ok: - return resp.text - else: - raise Exception(resp.status_code) - - -def put(oauth_tokens: Tuple[str, str], url: str, data=None) -> str: - headers = {"content-type": "text/xml; charset=utf-8"} - resp = _session(oauth_tokens).put(url, data=data.encode("utf-8"), headers=headers) - if resp and resp.status_code == requests.codes.ok: - return resp.text - else: - raise Exception(resp.status_code) - - -def post(oauth_tokens: Tuple[str, str], url: str, data: str) -> str: - headers = {"content-type": "text/xml; charset=utf-8"} - resp = _session(oauth_tokens).post(url, data=data.encode("utf-8"), headers=headers) - if resp and resp.status_code == requests.codes.ok: - return resp.text - else: - raise Exception(resp.status_code) diff --git a/web_api/tool/session.py b/web_api/tool/session.py index d3a8cb45..4baccc24 100644 --- a/web_api/tool/session.py +++ b/web_api/tool/session.py @@ -1,6 +1,6 @@ import json import pathlib -from typing import Any, Dict, Generic, Optional, Tuple, TypeVar, Union +from typing import Any, Dict, Generic, Optional, TypeVar, Union from uuid import UUID from fastapi import HTTPException, Request @@ -19,7 +19,7 @@ class SessionData(BaseModel): - oauth_tokens: Tuple[str, str] + oauth2_token: Optional[str] = None user: Optional[Dict[str, Any]] = None changeset: Optional[Any] = None