More AP C2S support

This commit is contained in:
Thomas Sileo 2022-12-16 20:20:40 +01:00
parent 5cf54c2782
commit 7b506f2519
3 changed files with 93 additions and 7 deletions

View file

@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import joinedload
from app import config from app import config
from app import models from app import models
@ -115,7 +116,7 @@ async def indieauth_authorization_endpoint(
"url": registered_client.client_uri, "url": registered_client.client_uri,
} }
else: else:
client = await indieauth.get_client_id_data(client_id) client = await indieauth.get_client_id_data(client_id) # type: ignore
return await templates.render_template( return await templates.render_template(
db_session, db_session,
@ -321,8 +322,10 @@ async def _check_access_token(
) -> tuple[bool, models.IndieAuthAccessToken | None]: ) -> tuple[bool, models.IndieAuthAccessToken | None]:
access_token_info = ( access_token_info = (
await db_session.scalars( await db_session.scalars(
select(models.IndieAuthAccessToken).where( select(models.IndieAuthAccessToken)
models.IndieAuthAccessToken.access_token == token .where(models.IndieAuthAccessToken.access_token == token)
.options(
joinedload(models.IndieAuthAccessToken.indieauth_authorization_request)
) )
) )
).one_or_none() ).one_or_none()
@ -345,6 +348,7 @@ async def _check_access_token(
@dataclass(frozen=True) @dataclass(frozen=True)
class AccessTokenInfo: class AccessTokenInfo:
scopes: list[str] scopes: list[str]
client_id: str | None
async def verify_access_token( async def verify_access_token(
@ -371,9 +375,57 @@ async def verify_access_token(
return AccessTokenInfo( return AccessTokenInfo(
scopes=access_token.scope.split(), 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") @router.post("/revoke_token")
async def indieauth_revocation_endpoint( async def indieauth_revocation_endpoint(
request: Request, request: Request,

View file

@ -464,7 +464,12 @@ async def followers(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse: ) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request): 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( return ActivityPubResponse(
await _empty_followx_collection( await _empty_followx_collection(
db_session=db_session, db_session=db_session,
@ -523,7 +528,12 @@ async def following(
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse | templates.TemplateResponse: ) -> ActivityPubResponse | templates.TemplateResponse:
if is_activitypub_requested(request): 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( return ActivityPubResponse(
await _empty_followx_collection( await _empty_followx_collection(
db_session=db_session, db_session=db_session,
@ -579,22 +589,34 @@ async def following(
@app.get("/outbox") @app.get("/outbox")
async def outbox( async def outbox(
request: Request,
db_session: AsyncSession = Depends(get_db_session), db_session: AsyncSession = Depends(get_db_session),
_: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker),
) -> ActivityPubResponse: ) -> 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 # By design, we only show the last 20 public activities in the oubox
outbox_objects = ( outbox_objects = (
await db_session.scalars( await db_session.scalars(
select(models.OutboxObject) select(models.OutboxObject)
.where( .where(
models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC,
models.OutboxObject.is_deleted.is_(False), 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()) .order_by(models.OutboxObject.ap_published_at.desc())
.limit(20) .limit(20)
) )
).all() ).all()
return ActivityPubResponse( return ActivityPubResponse(
{ {
"@context": ap.AS_EXTENDED_CTX, "@context": ap.AS_EXTENDED_CTX,
@ -646,6 +668,14 @@ async def _check_outbox_object_acl(
if templates.is_current_user_admin(request): if templates.is_current_user_admin(request):
return None 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 [ if ap_object.visibility in [
ap.VisibilityEnum.PUBLIC, ap.VisibilityEnum.PUBLIC,
ap.VisibilityEnum.UNLISTED, ap.VisibilityEnum.UNLISTED,

View file

@ -465,6 +465,10 @@ class IndieAuthAccessToken(Base):
indieauth_authorization_request_id = Column( indieauth_authorization_request_id = Column(
Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True 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) access_token = Column(String, nullable=False, unique=True, index=True)
expires_in = Column(Integer, nullable=False) expires_in = Column(Integer, nullable=False)