kopia lustrzana https://git.sr.ht/~tsileo/microblog.pub
Porównaj commity
4 Commity
573a76c0c5
...
1de108b019
Autor | SHA1 | Data |
---|---|---|
Thomas Sileo | 1de108b019 | |
Thomas Sileo | 7b506f2519 | |
Thomas Sileo | 5cf54c2782 | |
Thomas Sileo | db6016394b |
|
@ -0,0 +1,48 @@
|
|||
"""Add OAuth client
|
||||
|
||||
Revision ID: 4ab54becec04
|
||||
Revises: 9b404c47970a
|
||||
Create Date: 2022-12-16 17:30:54.520477+00:00
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '4ab54becec04'
|
||||
down_revision = '9b404c47970a'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('oauth_client',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('client_name', sa.String(), nullable=False),
|
||||
sa.Column('redirect_uris', sa.JSON(), nullable=True),
|
||||
sa.Column('client_uri', sa.String(), nullable=True),
|
||||
sa.Column('logo_uri', sa.String(), nullable=True),
|
||||
sa.Column('scope', sa.String(), nullable=True),
|
||||
sa.Column('client_id', sa.String(), nullable=False),
|
||||
sa.Column('client_secret', sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('client_secret')
|
||||
)
|
||||
with op.batch_alter_table('oauth_client', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_oauth_client_client_id'), ['client_id'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_oauth_client_id'), ['id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('oauth_client', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_oauth_client_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_oauth_client_client_id'))
|
||||
|
||||
op.drop_table('oauth_client')
|
||||
# ### end Alembic commands ###
|
130
app/indieauth.py
130
app/indieauth.py
|
@ -10,9 +10,10 @@ 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 pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app import config
|
||||
from app import models
|
||||
|
@ -21,6 +22,7 @@ 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.redirect import redirect
|
||||
from app.utils import indieauth
|
||||
from app.utils.datetime import now
|
||||
|
||||
|
@ -38,9 +40,54 @@ async def well_known_authorization_server(
|
|||
"code_challenge_methods_supported": ["S256"],
|
||||
"revocation_endpoint": request.url_for("indieauth_revocation_endpoint"),
|
||||
"revocation_endpoint_auth_methods_supported": ["none"],
|
||||
"registration_endpoint": request.url_for("oauth_registration_endpoint"),
|
||||
}
|
||||
|
||||
|
||||
class OAuthRegisterClientRequest(BaseModel):
|
||||
client_name: str
|
||||
redirect_uris: list[str] | str
|
||||
|
||||
client_uri: str | None = None
|
||||
logo_uri: str | None = None
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
@router.post("/oauth/register")
|
||||
async def oauth_registration_endpoint(
|
||||
register_client_request: OAuthRegisterClientRequest,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> JSONResponse:
|
||||
"""Implements OAuth 2.0 Dynamic Registration."""
|
||||
|
||||
client = models.OAuthClient(
|
||||
client_name=register_client_request.client_name,
|
||||
redirect_uris=[register_client_request.redirect_uris]
|
||||
if isinstance(register_client_request.redirect_uris, str)
|
||||
else register_client_request.redirect_uris,
|
||||
client_uri=register_client_request.client_uri,
|
||||
logo_uri=register_client_request.logo_uri,
|
||||
scope=register_client_request.scope,
|
||||
client_id=secrets.token_hex(16),
|
||||
client_secret=secrets.token_hex(32),
|
||||
)
|
||||
|
||||
db_session.add(client)
|
||||
await db_session.commit()
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
**register_client_request.dict(),
|
||||
"client_id_issued_at": int(client.created_at.timestamp()), # type: ignore
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"client_secret_expires_at": 0,
|
||||
"client_id": client.client_id,
|
||||
"client_secret": client.client_secret,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/auth")
|
||||
async def indieauth_authorization_endpoint(
|
||||
request: Request,
|
||||
|
@ -56,12 +103,29 @@ async def indieauth_authorization_endpoint(
|
|||
code_challenge = request.query_params.get("code_challenge", "")
|
||||
code_challenge_method = request.query_params.get("code_challenge_method", "")
|
||||
|
||||
# Check if the authorization request is coming from an OAuth client
|
||||
registered_client = (
|
||||
await db_session.scalars(
|
||||
select(models.OAuthClient).where(
|
||||
models.OAuthClient.client_id == client_id,
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if registered_client:
|
||||
client = {
|
||||
"name": registered_client.client_name,
|
||||
"logo": registered_client.logo_uri,
|
||||
"url": registered_client.client_uri,
|
||||
}
|
||||
else:
|
||||
client = await indieauth.get_client_id_data(client_id) # type: ignore
|
||||
|
||||
return await templates.render_template(
|
||||
db_session,
|
||||
request,
|
||||
"indieauth_flow.html",
|
||||
dict(
|
||||
client=await indieauth.get_client_id_data(client_id),
|
||||
client=client,
|
||||
scopes=scope,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
|
@ -80,7 +144,7 @@ async def indieauth_flow(
|
|||
db_session: AsyncSession = Depends(get_db_session),
|
||||
csrf_check: None = Depends(verify_csrf_token),
|
||||
_: None = Depends(user_session_or_redirect),
|
||||
) -> RedirectResponse:
|
||||
) -> templates.TemplateResponse:
|
||||
form_data = await request.form()
|
||||
logger.info(f"{form_data=}")
|
||||
|
||||
|
@ -114,9 +178,8 @@ async def indieauth_flow(
|
|||
db_session.add(auth_request)
|
||||
await db_session.commit()
|
||||
|
||||
return RedirectResponse(
|
||||
redirect_uri + f"?code={code}&state={state}&iss={iss}",
|
||||
status_code=302,
|
||||
return await redirect(
|
||||
request, db_session, redirect_uri + f"?code={code}&state={state}&iss={iss}"
|
||||
)
|
||||
|
||||
|
||||
|
@ -261,8 +324,10 @@ async def _check_access_token(
|
|||
) -> tuple[bool, models.IndieAuthAccessToken | None]:
|
||||
access_token_info = (
|
||||
await db_session.scalars(
|
||||
select(models.IndieAuthAccessToken).where(
|
||||
models.IndieAuthAccessToken.access_token == token
|
||||
select(models.IndieAuthAccessToken)
|
||||
.where(models.IndieAuthAccessToken.access_token == token)
|
||||
.options(
|
||||
joinedload(models.IndieAuthAccessToken.indieauth_authorization_request)
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
|
@ -285,6 +350,7 @@ async def _check_access_token(
|
|||
@dataclass(frozen=True)
|
||||
class AccessTokenInfo:
|
||||
scopes: list[str]
|
||||
client_id: str | None
|
||||
|
||||
|
||||
async def verify_access_token(
|
||||
|
@ -311,9 +377,57 @@ async def verify_access_token(
|
|||
|
||||
return AccessTokenInfo(
|
||||
scopes=access_token.scope.split(),
|
||||
client_id=(
|
||||
access_token.indieauth_authorization_request.client_id
|
||||
if access_token.indieauth_authorization_request
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def check_access_token(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> AccessTokenInfo | None:
|
||||
token = request.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
is_token_valid, access_token = await _check_access_token(db_session, token)
|
||||
if not is_token_valid:
|
||||
return None
|
||||
|
||||
if not access_token or not access_token.scope:
|
||||
raise ValueError("Should never happen")
|
||||
|
||||
access_token_info = AccessTokenInfo(
|
||||
scopes=access_token.scope.split(),
|
||||
client_id=(
|
||||
access_token.indieauth_authorization_request.client_id
|
||||
if access_token.indieauth_authorization_request
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Authenticated with access token from client_id="
|
||||
f"{access_token_info.client_id} scopes={access_token.scope}"
|
||||
)
|
||||
|
||||
return access_token_info
|
||||
|
||||
|
||||
async def enforce_access_token(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> AccessTokenInfo:
|
||||
maybe_access_token_info = await check_access_token(request, db_session)
|
||||
if not maybe_access_token_info:
|
||||
raise HTTPException(status_code=401, detail="access token required")
|
||||
|
||||
return maybe_access_token_info
|
||||
|
||||
|
||||
@router.post("/revoke_token")
|
||||
async def indieauth_revocation_endpoint(
|
||||
request: Request,
|
||||
|
|
38
app/main.py
38
app/main.py
|
@ -464,7 +464,12 @@ async def followers(
|
|||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||
if is_activitypub_requested(request):
|
||||
if config.HIDES_FOLLOWERS:
|
||||
maybe_access_token_info = await indieauth.check_access_token(
|
||||
request,
|
||||
db_session,
|
||||
)
|
||||
|
||||
if config.HIDES_FOLLOWERS and not maybe_access_token_info:
|
||||
return ActivityPubResponse(
|
||||
await _empty_followx_collection(
|
||||
db_session=db_session,
|
||||
|
@ -523,7 +528,12 @@ async def following(
|
|||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||
) -> ActivityPubResponse | templates.TemplateResponse:
|
||||
if is_activitypub_requested(request):
|
||||
if config.HIDES_FOLLOWING:
|
||||
maybe_access_token_info = await indieauth.check_access_token(
|
||||
request,
|
||||
db_session,
|
||||
)
|
||||
|
||||
if config.HIDES_FOLLOWING and not maybe_access_token_info:
|
||||
return ActivityPubResponse(
|
||||
await _empty_followx_collection(
|
||||
db_session=db_session,
|
||||
|
@ -579,22 +589,34 @@ async def following(
|
|||
|
||||
@app.get("/outbox")
|
||||
async def outbox(
|
||||
request: Request,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
|
||||
) -> ActivityPubResponse:
|
||||
maybe_access_token_info = await indieauth.check_access_token(
|
||||
request,
|
||||
db_session,
|
||||
)
|
||||
|
||||
# Default restrictions unless the request is authenticated with an access token
|
||||
restricted_where = [
|
||||
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
||||
models.OutboxObject.ap_type.in_(["Create", "Note", "Article", "Announce"]),
|
||||
]
|
||||
|
||||
# By design, we only show the last 20 public activities in the oubox
|
||||
outbox_objects = (
|
||||
await db_session.scalars(
|
||||
select(models.OutboxObject)
|
||||
.where(
|
||||
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
|
||||
models.OutboxObject.is_deleted.is_(False),
|
||||
models.OutboxObject.ap_type.in_(["Create", "Announce"]),
|
||||
*([] if maybe_access_token_info else restricted_where),
|
||||
)
|
||||
.order_by(models.OutboxObject.ap_published_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
).all()
|
||||
|
||||
return ActivityPubResponse(
|
||||
{
|
||||
"@context": ap.AS_EXTENDED_CTX,
|
||||
|
@ -646,6 +668,14 @@ async def _check_outbox_object_acl(
|
|||
if templates.is_current_user_admin(request):
|
||||
return None
|
||||
|
||||
maybe_access_token_info = await indieauth.check_access_token(
|
||||
request,
|
||||
db_session,
|
||||
)
|
||||
if maybe_access_token_info:
|
||||
# TODO: check scopes
|
||||
return None
|
||||
|
||||
if ap_object.visibility in [
|
||||
ap.VisibilityEnum.PUBLIC,
|
||||
ap.VisibilityEnum.UNLISTED,
|
||||
|
|
|
@ -465,6 +465,10 @@ class IndieAuthAccessToken(Base):
|
|||
indieauth_authorization_request_id = Column(
|
||||
Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True
|
||||
)
|
||||
indieauth_authorization_request = relationship(
|
||||
IndieAuthAuthorizationRequest,
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
access_token = Column(String, nullable=False, unique=True, index=True)
|
||||
expires_in = Column(Integer, nullable=False)
|
||||
|
@ -472,6 +476,26 @@ class IndieAuthAccessToken(Base):
|
|||
is_revoked = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class OAuthClient(Base):
|
||||
__tablename__ = "oauth_client"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False, default=now)
|
||||
|
||||
# Request
|
||||
client_name = Column(String, nullable=False)
|
||||
redirect_uris: Mapped[list[str]] = Column(JSON, nullable=True)
|
||||
|
||||
# Optional from request
|
||||
client_uri = Column(String, nullable=True)
|
||||
logo_uri = Column(String, nullable=True)
|
||||
scope = Column(String, nullable=True)
|
||||
|
||||
# Response
|
||||
client_id = Column(String, nullable=False, unique=True, index=True)
|
||||
client_secret = Column(String, nullable=False, unique=True)
|
||||
|
||||
|
||||
@enum.unique
|
||||
class WebmentionType(str, enum.Enum):
|
||||
UNKNOWN = "unknown"
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
from fastapi import Request
|
||||
|
||||
from app import templates
|
||||
from app.database import AsyncSession
|
||||
|
||||
|
||||
async def redirect(
|
||||
request: Request,
|
||||
db_session: AsyncSession,
|
||||
url: str,
|
||||
) -> templates.TemplateResponse:
|
||||
"""
|
||||
Similar to RedirectResponse, but uses a 200 response with HTML.
|
||||
|
||||
Needed for remote redirects on form submission endpoints,
|
||||
since our CSP policy disallows remote form submission.
|
||||
https://github.com/w3c/webappsec-csp/issues/8#issuecomment-810108984
|
||||
"""
|
||||
return await templates.render_template(
|
||||
db_session,
|
||||
request,
|
||||
"redirect.html",
|
||||
{
|
||||
"request": request,
|
||||
"url": url,
|
||||
},
|
||||
headers={"Refresh": "0;url=" + url},
|
||||
)
|
|
@ -459,7 +459,7 @@ a.label-btn {
|
|||
border: 2px dashed $secondary-color;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
.error-box, .scolor {
|
||||
color: $secondary-color;
|
||||
}
|
||||
|
||||
|
|
|
@ -10,8 +10,12 @@
|
|||
{% endif %}
|
||||
<div class="indieauth-details">
|
||||
<div>
|
||||
<a class="lcolor" href="{{ client.url }}">{{ client.name }}</a>
|
||||
<p>wants you to login as <strong class="lcolor">{{ me }}</strong> with the following redirect URI: <code>{{ redirect_uri }}</code>.</p>
|
||||
{% if client.url %}
|
||||
<a class="scolor" href="{{ client.url }}">{{ client.name }}</a>
|
||||
{% else %}
|
||||
<span class="scolor">{{ client.name }}</span>
|
||||
{% endif %}
|
||||
<p>wants you to login{% if me %} as <strong class="lcolor">{{ me }}</strong>{% endif %} with the following redirect URI: <code>{{ redirect_uri }}</code>.</p>
|
||||
|
||||
|
||||
<form method="POST" action="{{ url_for('indieauth_flow') }}" class="form">
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
{%- import "utils.html" as utils with context -%}
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ local_actor.display_name }}'s microblog - Redirect</title>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "header.html" %}
|
||||
|
||||
<div class="box">
|
||||
<p>You are being redirected to: <a href="{{ url }}">{{ url }}</a></p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
|
@ -10,7 +10,7 @@ from app.utils.url import make_abs
|
|||
class IndieAuthClient:
|
||||
logo: str | None
|
||||
name: str
|
||||
url: str
|
||||
url: str | None
|
||||
|
||||
|
||||
def _get_prop(props: dict[str, Any], name: str, default=None) -> Any:
|
||||
|
|
Ładowanie…
Reference in New Issue