mirror of
				https://git.sr.ht/~tsileo/microblog.pub
				synced 2025-06-05 21:59:23 +02:00 
			
		
		
		
	Add IndieAuth support
This commit is contained in:
		| @@ -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: | ||||
|     _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 | ||||
|   | ||||
| @@ -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 ( | ||||
|   | ||||
							
								
								
									
										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 = 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 | ||||
|   | ||||
| @@ -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() | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -2,6 +2,9 @@ | ||||
| {% extends "layout.html" %} | ||||
|  | ||||
| {% 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"> | ||||
| <meta content="profile" property="og:type" /> | ||||
| <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;"> | ||||
| <form class="form" action="/admin/login" method="POST"> | ||||
| <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="submit" value="login"> | ||||
| </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 | ||||
|  | ||||
|  | ||||
| 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 | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user