forked from forks/microblog.pub
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:
|
||||
_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
|
||||
|
||||
|
|
Loading…
Reference in a new issue