-
Notifications
You must be signed in to change notification settings - Fork 426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use a separate auth cookie for API requests (fixed version) #8946
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -30,7 +30,7 @@ def __init__( | |
|
||
def identity(self, request): | ||
self.helper.add_vary_by_cookie(request) | ||
identity = self.helper.identity(self.html_authcookie, request) | ||
identity, ticket_id = self.helper.identity(self.html_authcookie, request) | ||
|
||
# If a request was successfully authenticated using the HTML auth | ||
# cookie and that request did *not* also contain the API auth cookie, | ||
|
@@ -47,7 +47,7 @@ def identity(self, request): | |
# this won't happen but it could happen if the API auth cookie (but not | ||
# the HTML one) was deleted somehow, for example by the user manually | ||
# deleting the cookie in the browser's developer tools, or another way. | ||
self._issue_api_authcookie(identity, request) | ||
self._issue_api_authcookie(identity, request, ticket_id) | ||
|
||
return identity | ||
|
||
|
@@ -76,9 +76,11 @@ def remember(self, request, userid, **kw): # pylint:disable=unused-argument | |
# would set the same cookie twice. | ||
request.h_api_authcookie_headers_added = True | ||
|
||
ticket_id = self.helper.add_ticket(request, userid) | ||
|
||
return [ | ||
*self.helper.remember(self.html_authcookie, request, userid), | ||
*self.helper.remember(self.api_authcookie, request, userid), | ||
*self.helper.remember(self.html_authcookie, userid, ticket_id), | ||
*self.helper.remember(self.api_authcookie, userid, ticket_id), | ||
Comment on lines
+79
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the first of the two places where cookies are issued. This is the code that's executed when logging in. It now creates a single |
||
] | ||
|
||
def forget(self, request): | ||
|
@@ -91,7 +93,7 @@ def forget(self, request): | |
def permits(self, request, context, permission) -> Allowed | Denied: | ||
return identity_permits(self.identity(request), context, permission) | ||
|
||
def _issue_api_authcookie(self, identity, request): | ||
def _issue_api_authcookie(self, identity, request, ticket_id): | ||
if not identity: | ||
return | ||
|
||
|
@@ -105,7 +107,7 @@ def _issue_api_authcookie(self, identity, request): | |
return | ||
|
||
headers = self.helper.remember( | ||
self.api_authcookie, request, identity.user.userid | ||
self.api_authcookie, identity.user.userid, ticket_id | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the second of the two places where cookies are issued. It's the possibly temporary "transitional" code that's executed when a request contains a valid HTML auth cookie but does not have an API auth cookie. As before, it issues the browser with a new API auth cookie. But that new API auth cookie will now re-use the existing HTML auth cookie's DB ticket rather than creating a new one. |
||
) | ||
|
||
def add_api_authcookie_headers( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,19 +16,29 @@ def is_api_request(request) -> bool: | |
class AuthTicketCookieHelper: | ||
def identity( | ||
self, cookie: SignedCookieProfile, request: Request | ||
) -> Identity | None: | ||
) -> tuple[Identity, AuthTicket] | tuple[None, None]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because of the "temporary" / "transitional" cookie-issuing code it's necessary for |
||
userid, ticket_id = self.get_cookie_value(cookie) | ||
|
||
user = request.find_service(AuthTicketService).verify_ticket(userid, ticket_id) | ||
ticket = request.find_service(AuthTicketService).verify_ticket( | ||
userid, ticket_id | ||
) | ||
Comment on lines
+22
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
||
if (not user) or user.deleted: | ||
return None | ||
if (not ticket) or ticket.user.deleted: | ||
return (None, None) | ||
|
||
return Identity.from_models(user=user) | ||
return (Identity.from_models(user=ticket.user), ticket) | ||
|
||
def remember(self, cookie: SignedCookieProfile, request: Request, userid: str): | ||
def add_ticket(self, request: Request, userid): | ||
""" | ||
Add a new auth ticket for the given user to the DB. | ||
|
||
Returns the ID of the newly-created auth ticket. | ||
""" | ||
Comment on lines
+31
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
ticket_id = helper.add_ticket(request, userid)
headers = [
*helper.remember(html_authcookie, userid, ticket_id),
*helper.remember(api_authcookie, userid, ticket_id),
] |
||
ticket_id = AuthTicket.generate_ticket_id() | ||
request.find_service(AuthTicketService).add_ticket(userid, ticket_id) | ||
return ticket_id | ||
|
||
def remember(self, cookie: SignedCookieProfile, userid: str, ticket_id): | ||
return cookie.get_headers([userid, ticket_id]) | ||
|
||
def forget(self, cookie: SignedCookieProfile, request: Request): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -50,7 +50,7 @@ def get_subpolicy(request): | |
api_authcookie = webob.cookies.SignedCookieProfile( | ||
secret=request.registry.settings["h_api_auth_cookie_secret"], | ||
salt=request.registry.settings["h_api_auth_cookie_salt"], | ||
cookie_name="h_api_authcookie", | ||
cookie_name="h_api_authcookie.v2", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are existing |
||
httponly=True, | ||
secure=request.scheme == "https", | ||
samesite="strict", | ||
|
@@ -61,6 +61,15 @@ def get_subpolicy(request): | |
) | ||
api_authcookie = api_authcookie.bind(request) | ||
|
||
if is_api_request(request): | ||
return APIPolicy( | ||
[ | ||
BearerTokenPolicy(), | ||
AuthClientPolicy(), | ||
APICookiePolicy(api_authcookie, AuthTicketCookieHelper()), | ||
] | ||
) | ||
Comment on lines
+64
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
||
# The cookie that's used to authenticate HTML page requests. | ||
html_authcookie = webob.cookies.SignedCookieProfile( | ||
secret=request.registry.settings["h_auth_cookie_secret"], | ||
|
@@ -71,22 +80,4 @@ def get_subpolicy(request): | |
secure=request.scheme == "https", | ||
) | ||
html_authcookie = html_authcookie.bind(request) | ||
|
||
if is_api_request(request): | ||
return APIPolicy( | ||
[ | ||
BearerTokenPolicy(), | ||
AuthClientPolicy(), | ||
APICookiePolicy( | ||
# Use html_authcookie rather than api_authcookie to | ||
# authenticate API requests, at least for now. This is a | ||
# hopefully temporary workaround for an undiagnosed issue | ||
# we've been seeing in production, see: | ||
# https://hypothes-is.slack.com/archives/C2BLQDKHA/p1725041696040459 | ||
html_authcookie, | ||
AuthTicketCookieHelper(), | ||
), | ||
] | ||
) | ||
|
||
return CookiePolicy(html_authcookie, api_authcookie, AuthTicketCookieHelper()) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ | |
|
||
import sqlalchemy as sa | ||
|
||
from h.models import AuthTicket, User | ||
from h.models import AuthTicket | ||
|
||
|
||
class AuthTicketService: | ||
|
@@ -16,19 +16,21 @@ class AuthTicketService: | |
def __init__(self, session, user_service): | ||
self._session = session | ||
self._user_service = user_service | ||
self._user = None | ||
self._ticket = None | ||
|
||
def verify_ticket(self, userid: str | None, ticket_id: str | None) -> User | None: | ||
def verify_ticket( | ||
self, userid: str | None, ticket_id: str | None | ||
) -> AuthTicket | None: | ||
Comment on lines
+21
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The change to this file is complicated by the fact that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code does do a DB lookup that won't be cached, so this might be a reasonable thing to do if there is a chance that a request might access the same ticket multiple times. I'm not familiar enough with the code to know if that is the case. |
||
""" | ||
Return the User object matching the given userid and ticket_id, or None. | ||
Return the AuthTicket matching the given userid and ticket_id, or None. | ||
|
||
Verify that there is an unexpired AuthTicket in the DB matching the | ||
given `userid` and `ticket_id` and if so return the corresponding User. | ||
given `userid` and `ticket_id` and if so return the AuthTicket. | ||
""" | ||
|
||
if self._user: | ||
# We've already vetted the user! | ||
return self._user | ||
if self._ticket: | ||
# We've already verified this request's ticket. | ||
return self._ticket | ||
|
||
if not userid or not ticket_id: | ||
return None | ||
|
@@ -53,34 +55,40 @@ def verify_ticket(self, userid: str | None, ticket_id: str | None) -> User | Non | |
if (datetime.utcnow() - ticket.updated) > self.TICKET_REFRESH_INTERVAL: | ||
ticket.expires = datetime.utcnow() + self.TICKET_TTL | ||
|
||
# Update the user cache to allow quick checking if we are called again | ||
self._user = ticket.user | ||
# Update the cache to allow quick checking if we are called again | ||
self._ticket = ticket | ||
|
||
return self._user | ||
return self._ticket | ||
|
||
def add_ticket(self, userid: str, ticket_id: str) -> None: | ||
"""Add a new auth ticket for the given userid and token_id to the DB.""" | ||
|
||
# Update the user cache to allow quick checking if we are called again | ||
self._user = self._user_service.fetch(userid) | ||
if self._user is None: | ||
user = self._user_service.fetch(userid) | ||
|
||
if user is None: | ||
raise ValueError(f"Cannot find user with userid {userid}") | ||
|
||
ticket = AuthTicket( | ||
id=ticket_id, | ||
user=self._user, | ||
user_userid=self._user.userid, | ||
user=user, | ||
user_userid=user.userid, | ||
expires=datetime.utcnow() + self.TICKET_TTL, | ||
) | ||
|
||
self._session.add(ticket) | ||
|
||
# Update the cache to allow quick checking if we are called again. | ||
self._ticket = ticket | ||
|
||
return ticket | ||
|
||
def remove_ticket(self, ticket_id: str) -> None: | ||
"""Remove any ticket with the given ID from the DB.""" | ||
|
||
self._session.query(AuthTicket).filter_by(id=ticket_id).delete() | ||
|
||
# Empty the cached user to force revalidation. | ||
self._user = None | ||
# Empty the cache to force revalidation. | ||
self._ticket = None | ||
|
||
|
||
def factory(_context, request): | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
self.helper.identity()
now returns an(Identity, AuthTicket)
2-tuple instead of just anIdentity
.APICookiePolicy
only needs theIdentity
so it can just discard theAuthTicket
.