Add IndieAuth support

This commit is contained in:
Thomas Sileo 2022-07-10 11:04:28 +02:00
parent 10676b039a
commit c10a27cc08
14 changed files with 578 additions and 19 deletions

View file

@ -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 ###

View file

@ -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 ###

View file

@ -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

View file

@ -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
View 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,
)

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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" />

View 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 %}

View file

@ -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
View 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,
)

View file

@ -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

View file

@ -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