mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2024-12-21 20:54:27 +00:00
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…
Reference in a new issue