diff --git a/alembic/versions/192aff8bc1e2_add_indieauth_auth_request_model.py b/alembic/versions/192aff8bc1e2_add_indieauth_auth_request_model.py new file mode 100644 index 0000000..f6b3f3d --- /dev/null +++ b/alembic/versions/192aff8bc1e2_add_indieauth_auth_request_model.py @@ -0,0 +1,43 @@ +"""Add IndieAuth auth request model + +Revision ID: 192aff8bc1e2 +Revises: 79b5bcc918ce +Create Date: 2022-07-10 09:55:29.768385 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = '192aff8bc1e2' +down_revision = '79b5bcc918ce' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('indieauth_authorization_request', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('code', sa.String(), nullable=False), + sa.Column('scope', sa.String(), nullable=False), + sa.Column('redirect_uri', sa.String(), nullable=False), + sa.Column('client_id', sa.String(), nullable=False), + sa.Column('code_challenge', sa.String(), nullable=True), + sa.Column('code_challenge_method', sa.String(), nullable=True), + sa.Column('is_used', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_indieauth_authorization_request_code'), 'indieauth_authorization_request', ['code'], unique=True) + op.create_index(op.f('ix_indieauth_authorization_request_id'), 'indieauth_authorization_request', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_indieauth_authorization_request_id'), table_name='indieauth_authorization_request') + op.drop_index(op.f('ix_indieauth_authorization_request_code'), table_name='indieauth_authorization_request') + op.drop_table('indieauth_authorization_request') + # ### end Alembic commands ### diff --git a/alembic/versions/65387f69edfb_add_indieauth_access_token_model.py b/alembic/versions/65387f69edfb_add_indieauth_access_token_model.py new file mode 100644 index 0000000..710d438 --- /dev/null +++ b/alembic/versions/65387f69edfb_add_indieauth_access_token_model.py @@ -0,0 +1,42 @@ +"""Add IndieAuth access token model + +Revision ID: 65387f69edfb +Revises: 192aff8bc1e2 +Create Date: 2022-07-10 10:21:23.652014 + +""" +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = '65387f69edfb' +down_revision = '192aff8bc1e2' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('indieauth_access_token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('indieauth_authorization_request_id', sa.Integer(), nullable=False), + sa.Column('access_token', sa.String(), nullable=False), + sa.Column('expires_in', sa.Integer(), nullable=False), + sa.Column('scope', sa.String(), nullable=False), + sa.Column('is_revoked', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['indieauth_authorization_request_id'], ['indieauth_authorization_request.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_indieauth_access_token_access_token'), 'indieauth_access_token', ['access_token'], unique=True) + op.create_index(op.f('ix_indieauth_access_token_id'), 'indieauth_access_token', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_indieauth_access_token_id'), table_name='indieauth_access_token') + op.drop_index(op.f('ix_indieauth_access_token_access_token'), table_name='indieauth_access_token') + op.drop_table('indieauth_access_token') + # ### end Alembic commands ### diff --git a/app/admin.py b/app/admin.py index 4b0fd1e..d32c82b 100644 --- a/app/admin.py +++ b/app/admin.py @@ -38,7 +38,7 @@ def user_session_or_redirect( ) -> None: _RedirectToLoginPage = HTTPException( status_code=302, - headers={"Location": request.url_for("login")}, + headers={"Location": request.url_for("login") + f"?redirect={request.url}"}, ) if not session: @@ -689,7 +689,10 @@ async def login( db_session, request, "login.html", - {"csrf_token": generate_csrf_token()}, + { + "csrf_token": generate_csrf_token(), + "redirect": request.query_params.get("redirect", ""), + }, ) @@ -697,12 +700,13 @@ async def login( async def login_validation( request: Request, password: str = Form(), + redirect: str = Form(), csrf_check: None = Depends(verify_csrf_token), ) -> RedirectResponse: if not verify_password(password): raise HTTPException(status_code=401) - resp = RedirectResponse("/admin/inbox", status_code=302) + resp = RedirectResponse(redirect or "/admin/inbox", status_code=302) resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True})) # type: ignore # noqa: E501 return resp diff --git a/app/boxes.py b/app/boxes.py index 5705a58..549dd9b 100644 --- a/app/boxes.py +++ b/app/boxes.py @@ -519,6 +519,7 @@ async def _handle_delete_activity( logger.info(f"Deleting {ap_object_to_delete.ap_type}/{ap_object_to_delete.ap_id}") ap_object_to_delete.is_deleted = True + # FIXME(ts): decrement reply count for in reply to (and fix reply tree) async def _handle_follow_follow_activity( @@ -779,6 +780,8 @@ async def save_to_inbox( if httpsig_info.signed_by_ap_actor_id != actor.ap_id: logger.info(f"Processing a forwarded activity {httpsig_info=}/{actor.ap_id}") if not (await ldsig.verify_signature(db_session, raw_object)): + logger.warning("Failed to verify LD sig") + # FIXME(ts): fetch the remote object raise fastapi.HTTPException(status_code=401, detail="Invalid LD sig") if ( diff --git a/app/indieauth.py b/app/indieauth.py new file mode 100644 index 0000000..e75531e --- /dev/null +++ b/app/indieauth.py @@ -0,0 +1,328 @@ +import secrets +from dataclasses import dataclass +from datetime import timedelta +from datetime import timezone +from typing import Any + +from fastapi import APIRouter +from fastapi import Depends +from fastapi import Form +from fastapi import HTTPException +from fastapi import Request +from fastapi.responses import JSONResponse +from fastapi.responses import RedirectResponse +from loguru import logger +from sqlalchemy import select + +from app import config +from app import models +from app import templates +from app.admin import user_session_or_redirect +from app.config import verify_csrf_token +from app.database import AsyncSession +from app.database import get_db_session +from app.database import now +from app.utils import indieauth + +router = APIRouter() + + +@router.get("/.well-known/oauth-authorization-server") +async def well_known_authorization_server( + request: Request, +) -> dict[str, Any]: + return { + "issuer": config.ID + "/", + "authorization_endpoint": request.url_for("indieauth_authorization_endpoint"), + "token_endpoint": request.url_for("indieauth_token_endpoint"), + "code_challenge_methods_supported": ["S256"], + "revocation_endpoint": request.url_for("indieauth_revocation_endpoint"), + "revocation_endpoint_auth_methods_supported": ["none"], + } + + +@router.get("/auth") +async def indieauth_authorization_endpoint( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + _: None = Depends(user_session_or_redirect), +) -> templates.TemplateResponse: + me = request.query_params.get("me") + client_id = request.query_params.get("client_id") + redirect_uri = request.query_params.get("redirect_uri") + state = request.query_params.get("state", "") + response_type = request.query_params.get("response_type", "id") + scope = request.query_params.get("scope", "").split() + code_challenge = request.query_params.get("code_challenge", "") + code_challenge_method = request.query_params.get("code_challenge_method", "") + + return await templates.render_template( + db_session, + request, + "indieauth_flow.html", + dict( + client=await indieauth.get_client_id_data(client_id), + scopes=scope, + redirect_uri=redirect_uri, + state=state, + response_type=response_type, + client_id=client_id, + me=me, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ), + ) + + +@router.post("/admin/indieauth") +async def indieauth_flow( + request: Request, + db_session: AsyncSession = Depends(get_db_session), + csrf_check: None = Depends(verify_csrf_token), + _: None = Depends(user_session_or_redirect), +) -> RedirectResponse: + form_data = await request.form() + logger.info(f"{form_data=}") + + # Params needed for the redirect + redirect_uri = form_data["redirect_uri"] + code = secrets.token_urlsafe(32) + iss = config.ID + "/" + state = form_data["state"] + + scope = " ".join(form_data.getlist("scopes")) + client_id = form_data["client_id"] + + # TODO: Ensure that me is correct + # me = form_data.get("me") + + # XXX: should always be code + # response_type = form_data["response_type"] + + code_challenge = form_data["code_challenge"] + code_challenge_method = form_data["code_challenge_method"] + + auth_request = models.IndieAuthAuthorizationRequest( + code=code, + scope=scope, + redirect_uri=redirect_uri, + client_id=client_id, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + + db_session.add(auth_request) + await db_session.commit() + + return RedirectResponse( + redirect_uri + f"?code={code}&state={state}&iss={iss}", + status_code=302, + ) + + +async def _check_auth_code( + db_session: AsyncSession, + code: str, + client_id: str, + redirect_uri: str, + code_verifier: str | None, +) -> tuple[bool, models.IndieAuthAuthorizationRequest | None]: + auth_code_req = ( + await db_session.scalars( + select(models.IndieAuthAuthorizationRequest).where( + models.IndieAuthAuthorizationRequest.code == code + ) + ) + ).one_or_none() + if not auth_code_req: + return False, None + if auth_code_req.is_used: + logger.info("code was already used") + return False, None + # + if now() > auth_code_req.created_at.replace(tzinfo=timezone.utc) + timedelta( + seconds=120 + ): + logger.info("Auth code request expired") + return False, None + + if ( + auth_code_req.redirect_uri != redirect_uri + or auth_code_req.client_id != client_id + ): + logger.info("redirect_uri/client_id does not match request") + return False, None + + auth_code_req.is_used = True + await db_session.commit() + + return True, auth_code_req + + +@router.post("/auth") +async def indieauth_reedem_auth_code( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +) -> JSONResponse: + form_data = await request.form() + logger.info(f"{form_data=}") + grant_type = form_data.get("grant_type", "authorization_code") + if grant_type != "authorization_code": + raise ValueError(f"Invalid grant_type {grant_type}") + + code = form_data["code"] + + # These must match the params from the first request + client_id = form_data["client_id"] + redirect_uri = form_data["redirect_uri"] + # code_verifier is optional for backward compat + code_verifier = form_data.get("code_verifier") + + is_code_valid, _ = await _check_auth_code( + db_session, + code=code, + client_id=client_id, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + ) + if is_code_valid: + return JSONResponse( + content={ + "me": config.ID + "/", + }, + status_code=200, + ) + else: + return JSONResponse( + content={"error": "invalid_grant"}, + status_code=400, + ) + + +@router.post("/token") +async def indieauth_token_endpoint( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +) -> JSONResponse: + form_data = await request.form() + logger.info(f"{form_data=}") + grant_type = form_data.get("grant_type", "authorization_code") + if grant_type != "authorization_code": + raise ValueError(f"Invalid grant_type {grant_type}") + + code = form_data["code"] + + # These must match the params from the first request + client_id = form_data["client_id"] + redirect_uri = form_data["redirect_uri"] + # code_verifier is optional for backward compat + code_verifier = form_data.get("code_verifier") + + is_code_valid, auth_code_request = await _check_auth_code( + db_session, + code=code, + client_id=client_id, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + ) + if not is_code_valid or (auth_code_request and not auth_code_request.scope): + return JSONResponse( + content={"error": "invalid_grant"}, + status_code=400, + ) + + if not auth_code_request: + raise ValueError("Should never happen") + + access_token = models.IndieAuthAccessToken( + indieauth_authorization_request_id=auth_code_request.id, + access_token=secrets.token_urlsafe(32), + expires_in=3600, + scope=auth_code_request.scope, + ) + db_session.add(access_token) + await db_session.commit() + + return JSONResponse( + content={ + "access_token": access_token.access_token, + "token_type": "Bearer", + "scope": auth_code_request.scope, + "me": config.ID + "/", + "expires_in": 3600, + }, + status_code=200, + ) + + +async def _check_access_token( + db_session: AsyncSession, + token: str, +) -> tuple[bool, models.IndieAuthAccessToken | None]: + access_token_info = ( + await db_session.scalars( + select(models.IndieAuthAccessToken).where( + models.IndieAuthAccessToken.access_token == token + ) + ) + ).one_or_none() + if not access_token_info: + return False, None + + if access_token_info.is_revoked: + logger.info("Access token is revoked") + return False, None + + if now() > access_token_info.created_at.replace(tzinfo=timezone.utc) + timedelta( + seconds=access_token_info.expires_in + ): + logger.info("Access token is expired") + return False, None + + return True, access_token_info + + +@dataclass(frozen=True) +class AccessTokenInfo: + scopes: list[str] + + +async def verify_access_token( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +) -> AccessTokenInfo: + token = request.headers.get("Authorization", "").removeprefix("Bearer ") + is_token_valid, access_token = await _check_access_token(db_session, token) + if not is_token_valid: + raise HTTPException( + detail="Invalid access token", + status_code=401, + ) + + if not access_token or not access_token.scope: + raise ValueError("Should never happen") + + return AccessTokenInfo( + scopes=access_token.scope.split(), + ) + + +@router.post("/revoke_token") +async def indieauth_revocation_endpoint( + request: Request, + token: str = Form(), + db_session: AsyncSession = Depends(get_db_session), +) -> JSONResponse: + + is_token_valid, token_info = await _check_access_token(db_session, token) + if is_token_valid: + if not token_info: + raise ValueError("Should never happen") + + token_info.is_revoked = True + await db_session.commit() + + return JSONResponse( + content={}, + status_code=200, + ) diff --git a/app/ldsig.py b/app/ldsig.py index 3668516..5ff0bad 100644 --- a/app/ldsig.py +++ b/app/ldsig.py @@ -65,6 +65,7 @@ async def verify_signature( key_id = doc["signature"]["creator"] key = await _get_public_key(db_session, key_id) + print(key) to_be_signed = _options_hash(doc) + _doc_hash(doc) signature = doc["signature"]["signatureValue"] signer = PKCS1_v1_5.new(key.pubkey or key.privkey) # type: ignore diff --git a/app/main.py b/app/main.py index aef9c57..89c57d8 100644 --- a/app/main.py +++ b/app/main.py @@ -35,6 +35,7 @@ from app import admin from app import boxes from app import config from app import httpsig +from app import indieauth from app import models from app import templates from app.actor import LOCAL_ACTOR @@ -80,6 +81,7 @@ app = FastAPI(docs_url=None, redoc_url=None) app.mount("/static", StaticFiles(directory="app/static"), name="static") app.include_router(admin.router, prefix="/admin") app.include_router(admin.unauthenticated_router, prefix="/admin") +app.include_router(indieauth.router) logger.configure(extra={"request_id": "no_req_id"}) logger.remove() diff --git a/app/models.py b/app/models.py index 379377e..c37b9bd 100644 --- a/app/models.py +++ b/app/models.py @@ -398,3 +398,35 @@ class OutboxObjectAttachment(Base): upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False) upload = relationship(Upload, uselist=False) + + +class IndieAuthAuthorizationRequest(Base): + __tablename__ = "indieauth_authorization_request" + + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=now) + + code = Column(String, nullable=False, unique=True, index=True) + scope = Column(String, nullable=False) + redirect_uri = Column(String, nullable=False) + client_id = Column(String, nullable=False) + code_challenge = Column(String, nullable=True) + code_challenge_method = Column(String, nullable=True) + + is_used = Column(Boolean, nullable=False, default=False) + + +class IndieAuthAccessToken(Base): + __tablename__ = "indieauth_access_token" + + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=now) + + indieauth_authorization_request_id = Column( + Integer, ForeignKey("indieauth_authorization_request.id"), nullable=False + ) + + access_token = Column(String, nullable=False, unique=True, index=True) + expires_in = Column(Integer, nullable=False) + scope = Column(String, nullable=False) + is_revoked = Column(Boolean, nullable=False, default=False) diff --git a/app/templates/index.html b/app/templates/index.html index 0484278..bcbdb31 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,6 +2,9 @@ {% extends "layout.html" %} {% block head %} + + + diff --git a/app/templates/indieauth_flow.html b/app/templates/indieauth_flow.html new file mode 100644 index 0000000..3a7f641 --- /dev/null +++ b/app/templates/indieauth_flow.html @@ -0,0 +1,42 @@ +{%- import "utils.html" as utils with context -%} +{% extends "layout.html" %} +{% block content %} +
+
+{% if client.logo %} +
+ +
+{% endif %} +
+
+{{ client.name }} +

wants you to login as {{ me }}

+
+
+
+ +
+ + {% if scopes %} +

Scopes

+ + {% endif %} + + + + + + + + +
+ +
+ +{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 22381f3..7142c05 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -5,6 +5,7 @@
+
diff --git a/app/utils/indieauth.py b/app/utils/indieauth.py new file mode 100644 index 0000000..22f489b --- /dev/null +++ b/app/utils/indieauth.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass +from typing import Any + +import httpx +import mf2py # type: ignore +from loguru import logger + +from app import config +from app.utils.url import make_abs + + +@dataclass +class IndieAuthClient: + logo: str | None + name: str + url: str + + +def _get_prop(props: dict[str, Any], name: str, default=None) -> Any: + if name in props: + items = props.get(name) + if isinstance(items, list): + return items[0] + return items + return default + + +async def get_client_id_data(url: str) -> IndieAuthClient | None: + async with httpx.AsyncClient() as client: + try: + resp = await client.get( + url, + headers={ + "User-Agent": config.USER_AGENT, + }, + follow_redirects=True, + ) + resp.raise_for_status() + except (httpx.HTTPError, httpx.HTTPStatusError): + logger.exception(f"Failed to discover webmention endpoint for {url}") + return None + + data = mf2py.parse(doc=resp.text) + for item in data["items"]: + if "h-x-app" in item["type"] or "h-app" in item["type"]: + props = item.get("properties", {}) + print(props) + logo = _get_prop(props, "logo") + return IndieAuthClient( + logo=make_abs(logo, url) if logo else None, + name=_get_prop(props, "name"), + url=_get_prop(props, "url", url), + ) + + return IndieAuthClient( + logo=None, + name=url, + url=url, + ) diff --git a/app/utils/url.py b/app/utils/url.py index 8afb3fc..16d97ab 100644 --- a/app/utils/url.py +++ b/app/utils/url.py @@ -8,6 +8,18 @@ from loguru import logger from app.config import DEBUG +def make_abs(url: str | None, parent: str) -> str | None: + if url is None: + return None + + if url.startswith("http"): + return url + + return ( + urlparse(parent)._replace(path=url, params="", query="", fragment="").geturl() + ) + + class InvalidURLError(Exception): pass diff --git a/app/utils/webmentions.py b/app/utils/webmentions.py index f339e7c..68df615 100644 --- a/app/utils/webmentions.py +++ b/app/utils/webmentions.py @@ -1,23 +1,10 @@ -from urllib.parse import urlparse - import httpx from bs4 import BeautifulSoup # type: ignore from loguru import logger from app import config from app.utils.url import is_url_valid - - -def _make_abs(url: str | None, parent: str) -> str | None: - if url is None: - return None - - if url.startswith("http"): - return url - - return ( - urlparse(parent)._replace(path=url, params="", query="", fragment="").geturl() - ) +from app.utils.url import make_abs async def _discover_webmention_endoint(url: str) -> str | None: @@ -37,13 +24,13 @@ async def _discover_webmention_endoint(url: str) -> str | None: for k, v in resp.links.items(): if k and "webmention" in k: - return _make_abs(resp.links[k].get("url"), url) + return make_abs(resp.links[k].get("url"), url) soup = BeautifulSoup(resp.text, "html5lib") wlinks = soup.find_all(["link", "a"], attrs={"rel": "webmention"}) for wlink in wlinks: if "href" in wlink.attrs: - return _make_abs(wlink.attrs["href"], url) + return make_abs(wlink.attrs["href"], url) return None