Add IndieAuth support
This commit is contained in:
		
							parent
							
								
									10676b039a
								
							
						
					
					
						commit
						c10a27cc08
					
				
					 14 changed files with 578 additions and 19 deletions
				
			
		|  | @ -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 ### | ||||||
|  | @ -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 ### | ||||||
							
								
								
									
										10
									
								
								app/admin.py
									
										
									
									
									
								
							
							
						
						
									
										10
									
								
								app/admin.py
									
										
									
									
									
								
							|  | @ -38,7 +38,7 @@ def user_session_or_redirect( | ||||||
| ) -> None: | ) -> None: | ||||||
|     _RedirectToLoginPage = HTTPException( |     _RedirectToLoginPage = HTTPException( | ||||||
|         status_code=302, |         status_code=302, | ||||||
|         headers={"Location": request.url_for("login")}, |         headers={"Location": request.url_for("login") + f"?redirect={request.url}"}, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|     if not session: |     if not session: | ||||||
|  | @ -689,7 +689,10 @@ async def login( | ||||||
|         db_session, |         db_session, | ||||||
|         request, |         request, | ||||||
|         "login.html", |         "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( | async def login_validation( | ||||||
|     request: Request, |     request: Request, | ||||||
|     password: str = Form(), |     password: str = Form(), | ||||||
|  |     redirect: str = Form(), | ||||||
|     csrf_check: None = Depends(verify_csrf_token), |     csrf_check: None = Depends(verify_csrf_token), | ||||||
| ) -> RedirectResponse: | ) -> RedirectResponse: | ||||||
|     if not verify_password(password): |     if not verify_password(password): | ||||||
|         raise HTTPException(status_code=401) |         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 |     resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True}))  # type: ignore  # noqa: E501 | ||||||
| 
 | 
 | ||||||
|     return resp |     return resp | ||||||
|  |  | ||||||
|  | @ -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}") |     logger.info(f"Deleting {ap_object_to_delete.ap_type}/{ap_object_to_delete.ap_id}") | ||||||
|     ap_object_to_delete.is_deleted = True |     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( | 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: |     if httpsig_info.signed_by_ap_actor_id != actor.ap_id: | ||||||
|         logger.info(f"Processing a forwarded activity {httpsig_info=}/{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)): |         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") |             raise fastapi.HTTPException(status_code=401, detail="Invalid LD sig") | ||||||
| 
 | 
 | ||||||
|     if ( |     if ( | ||||||
|  |  | ||||||
							
								
								
									
										328
									
								
								app/indieauth.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										328
									
								
								app/indieauth.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||||
|  |     ) | ||||||
|  | @ -65,6 +65,7 @@ async def verify_signature( | ||||||
| 
 | 
 | ||||||
|     key_id = doc["signature"]["creator"] |     key_id = doc["signature"]["creator"] | ||||||
|     key = await _get_public_key(db_session, key_id) |     key = await _get_public_key(db_session, key_id) | ||||||
|  |     print(key) | ||||||
|     to_be_signed = _options_hash(doc) + _doc_hash(doc) |     to_be_signed = _options_hash(doc) + _doc_hash(doc) | ||||||
|     signature = doc["signature"]["signatureValue"] |     signature = doc["signature"]["signatureValue"] | ||||||
|     signer = PKCS1_v1_5.new(key.pubkey or key.privkey)  # type: ignore |     signer = PKCS1_v1_5.new(key.pubkey or key.privkey)  # type: ignore | ||||||
|  |  | ||||||
|  | @ -35,6 +35,7 @@ from app import admin | ||||||
| from app import boxes | from app import boxes | ||||||
| from app import config | from app import config | ||||||
| from app import httpsig | from app import httpsig | ||||||
|  | from app import indieauth | ||||||
| from app import models | from app import models | ||||||
| from app import templates | from app import templates | ||||||
| from app.actor import LOCAL_ACTOR | 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.mount("/static", StaticFiles(directory="app/static"), name="static") | ||||||
| app.include_router(admin.router, prefix="/admin") | app.include_router(admin.router, prefix="/admin") | ||||||
| app.include_router(admin.unauthenticated_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.configure(extra={"request_id": "no_req_id"}) | ||||||
| logger.remove() | logger.remove() | ||||||
|  |  | ||||||
|  | @ -398,3 +398,35 @@ class OutboxObjectAttachment(Base): | ||||||
| 
 | 
 | ||||||
|     upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False) |     upload_id = Column(Integer, ForeignKey("upload.id"), nullable=False) | ||||||
|     upload = relationship(Upload, uselist=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) | ||||||
|  |  | ||||||
|  | @ -2,6 +2,9 @@ | ||||||
| {% extends "layout.html" %} | {% extends "layout.html" %} | ||||||
| 
 | 
 | ||||||
| {% block head %} | {% block head %} | ||||||
|  | <link rel="indieauth-metadata" href="{{ url_for("well_known_authorization_server") }}"> | ||||||
|  | <link rel="authorization_endpoint" href="{{ url_for("indieauth_authorization_endpoint") }}"> | ||||||
|  | <link rel="token_endpoint" href="{{ url_for("indieauth_token_endpoint") }}"> | ||||||
| <link rel="alternate" href="{{ local_actor.url }}" title="ActivityPub profile"  type="application/activity+json"> | <link rel="alternate" href="{{ local_actor.url }}" title="ActivityPub profile"  type="application/activity+json"> | ||||||
| <meta content="profile" property="og:type" /> | <meta content="profile" property="og:type" /> | ||||||
| <meta content="{{ local_actor.url }}" property="og:url" /> | <meta content="{{ local_actor.url }}" property="og:url" /> | ||||||
|  |  | ||||||
							
								
								
									
										42
									
								
								app/templates/indieauth_flow.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								app/templates/indieauth_flow.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,42 @@ | ||||||
|  | {%- import "utils.html" as utils with context -%} | ||||||
|  | {% extends "layout.html" %} | ||||||
|  | {% block content %} | ||||||
|  | <div class="box"> | ||||||
|  | <div style="display:flex"> | ||||||
|  | {% if client.logo %} | ||||||
|  | <div style="flex:initial;width:100px;"> | ||||||
|  | <img src="{{client.logo}}" style="max-width:100px;"> | ||||||
|  | </div> | ||||||
|  | {% endif %} | ||||||
|  | <div style="flex:1;"> | ||||||
|  | <div style="margin-top:20px"> | ||||||
|  | <a class="lcolor" style="font-size:1.2em;font-weight:600;text-decoration:none;" href="{{ client.url }}">{{ client.name }}</a> | ||||||
|  | <p>wants you to login as <strong class="lcolor">{{ me }}</strong></p> | ||||||
|  | </div> | ||||||
|  | </div> | ||||||
|  | </div> | ||||||
|  | 
 | ||||||
|  | <form method="POST" action="{{ url_for('indieauth_flow') }}"> | ||||||
|  |     <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | ||||||
|  | 	{% if scopes %} | ||||||
|  | 	<h3>Scopes</h3> | ||||||
|  | 	<ul> | ||||||
|  | 	{% for scope in scopes %} | ||||||
|  | 	<li><input type="checkbox" name="scopes" value="{{scope}}" id="scope-{{scope}}"><label for="scope-{{scope}}">{{ scope }}</label> | ||||||
|  | 	</li> | ||||||
|  | 	{% endfor %} | ||||||
|  | 	</ul> | ||||||
|  | 	{% endif %} | ||||||
|  | 	<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}"> | ||||||
|  | 	<input type="hidden" name="state" value="{{ state }}"> | ||||||
|  | 	<input type="hidden" name="client_id" value="{{ client_id }}"> | ||||||
|  | 	<input type="hidden" name="me" value="{{ me }}"> | ||||||
|  | 	<input type="hidden" name="response_type" value="{{ response_type }}"> | ||||||
|  | 	<input type="hidden" name="code_challenge" value="{{ code_challenge }}"> | ||||||
|  | 	<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}"> | ||||||
|  | 	<input type="submit" value="login"> | ||||||
|  | </form> | ||||||
|  | 
 | ||||||
|  | </div> | ||||||
|  | </div> | ||||||
|  | {% endblock %} | ||||||
|  | @ -5,6 +5,7 @@ | ||||||
| <div style="margin:auto;"> | <div style="margin:auto;"> | ||||||
| <form class="form" action="/admin/login" method="POST"> | <form class="form" action="/admin/login" method="POST"> | ||||||
| <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> | ||||||
|  | <input type="hidden" name="redirect" value="{{ redirect }}"> | ||||||
| <input type="password" placeholder="password" name="password" autofocus> | <input type="password" placeholder="password" name="password" autofocus> | ||||||
| <input type="submit" value="login"> | <input type="submit" value="login"> | ||||||
| </form> | </form> | ||||||
|  |  | ||||||
							
								
								
									
										59
									
								
								app/utils/indieauth.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								app/utils/indieauth.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||||
|  |     ) | ||||||
|  | @ -8,6 +8,18 @@ from loguru import logger | ||||||
| from app.config import DEBUG | 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): | class InvalidURLError(Exception): | ||||||
|     pass |     pass | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,23 +1,10 @@ | ||||||
| from urllib.parse import urlparse |  | ||||||
| 
 |  | ||||||
| import httpx | import httpx | ||||||
| from bs4 import BeautifulSoup  # type: ignore | from bs4 import BeautifulSoup  # type: ignore | ||||||
| from loguru import logger | from loguru import logger | ||||||
| 
 | 
 | ||||||
| from app import config | from app import config | ||||||
| from app.utils.url import is_url_valid | from app.utils.url import is_url_valid | ||||||
| 
 | from app.utils.url import make_abs | ||||||
| 
 |  | ||||||
| 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() |  | ||||||
|     ) |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| async def _discover_webmention_endoint(url: str) -> str | None: | 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(): |     for k, v in resp.links.items(): | ||||||
|         if k and "webmention" in k: |         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") |     soup = BeautifulSoup(resp.text, "html5lib") | ||||||
|     wlinks = soup.find_all(["link", "a"], attrs={"rel": "webmention"}) |     wlinks = soup.find_all(["link", "a"], attrs={"rel": "webmention"}) | ||||||
|     for wlink in wlinks: |     for wlink in wlinks: | ||||||
|         if "href" in wlink.attrs: |         if "href" in wlink.attrs: | ||||||
|             return _make_abs(wlink.attrs["href"], url) |             return make_abs(wlink.attrs["href"], url) | ||||||
| 
 | 
 | ||||||
|     return None |     return None | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue