OAuth Provider Rewrite (#182)
This commit is contained in:
0
passbook/providers/oauth2/views/__init__.py
Normal file
0
passbook/providers/oauth2/views/__init__.py
Normal file
374
passbook/providers/oauth2/views/authorize.py
Normal file
374
passbook/providers/oauth2/views/authorize.py
Normal file
@ -0,0 +1,374 @@
|
||||
"""passbook OAuth2 Authorization views"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Set
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Application, Token
|
||||
from passbook.flows.models import in_memory_stage
|
||||
from passbook.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
PLAN_CONTEXT_SSO,
|
||||
FlowPlan,
|
||||
FlowPlanner,
|
||||
)
|
||||
from passbook.flows.stage import StageView
|
||||
from passbook.flows.views import SESSION_KEY_PLAN
|
||||
from passbook.lib.utils.time import timedelta_from_string
|
||||
from passbook.lib.utils.urls import redirect_with_qs
|
||||
from passbook.lib.views import bad_request_message
|
||||
from passbook.policies.mixins import PolicyAccessMixin
|
||||
from passbook.providers.oauth2.constants import (
|
||||
PROMPT_CONSNET,
|
||||
PROMPT_NONE,
|
||||
SCOPE_OPENID,
|
||||
)
|
||||
from passbook.providers.oauth2.errors import (
|
||||
AuthorizeError,
|
||||
ClientIdError,
|
||||
OAuth2Error,
|
||||
RedirectUriError,
|
||||
)
|
||||
from passbook.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
ResponseTypes,
|
||||
)
|
||||
from passbook.providers.oauth2.views.userinfo import UserInfoView
|
||||
from passbook.stages.consent.models import ConsentMode, ConsentStage
|
||||
from passbook.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE,
|
||||
ConsentStageView,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions"
|
||||
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET}
|
||||
|
||||
|
||||
@dataclass
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class OAuthAuthorizationParams:
|
||||
"""Parameteres required to authorize an OAuth Client"""
|
||||
|
||||
client_id: str
|
||||
redirect_uri: str
|
||||
response_type: str
|
||||
scope: List[str]
|
||||
state: str
|
||||
nonce: str
|
||||
prompt: Set[str]
|
||||
grant_type: str
|
||||
|
||||
provider: OAuth2Provider = field(default_factory=OAuth2Provider)
|
||||
|
||||
code_challenge: Optional[str] = None
|
||||
code_challenge_method: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "OAuthAuthorizationParams":
|
||||
"""
|
||||
Get all the params used by the Authorization Code Flow
|
||||
(and also for the Implicit and Hybrid).
|
||||
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
"""
|
||||
# Because in this endpoint we handle both GET
|
||||
# and POST request.
|
||||
query_dict = request.POST if request.method == "POST" else request.GET
|
||||
|
||||
response_type = query_dict.get("response_type", "")
|
||||
grant_type = None
|
||||
# Determine which flow to use.
|
||||
if response_type in [ResponseTypes.CODE]:
|
||||
grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
elif response_type in [
|
||||
ResponseTypes.id_token,
|
||||
ResponseTypes.id_token_token,
|
||||
ResponseTypes.token,
|
||||
]:
|
||||
grant_type = GrantTypes.IMPLICIT
|
||||
elif response_type in [
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
grant_type = GrantTypes.HYBRID
|
||||
|
||||
# Grant type validation.
|
||||
if not grant_type:
|
||||
LOGGER.warning("Invalid response type", type=response_type)
|
||||
raise AuthorizeError(
|
||||
query_dict.get("redirect_uri", ""),
|
||||
"unsupported_response_type",
|
||||
grant_type,
|
||||
)
|
||||
|
||||
return OAuthAuthorizationParams(
|
||||
client_id=query_dict.get("client_id", ""),
|
||||
redirect_uri=query_dict.get("redirect_uri", ""),
|
||||
response_type=response_type,
|
||||
grant_type=grant_type,
|
||||
scope=query_dict.get("scope", "").split(),
|
||||
state=query_dict.get("state", ""),
|
||||
nonce=query_dict.get("nonce", ""),
|
||||
prompt=ALLOWED_PROMPT_PARAMS.intersection(
|
||||
set(query_dict.get("prompt", "").split())
|
||||
),
|
||||
code_challenge=query_dict.get("code_challenge"),
|
||||
code_challenge_method=query_dict.get("code_challenge_method"),
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
try:
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.get(
|
||||
client_id=self.client_id
|
||||
)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.warning("Invalid client identifier", client_id=self.client_id)
|
||||
raise ClientIdError()
|
||||
is_open_id = SCOPE_OPENID in self.scope
|
||||
|
||||
# Redirect URI validation.
|
||||
if is_open_id and not self.redirect_uri:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError()
|
||||
if self.redirect_uri not in self.provider.redirect_uris:
|
||||
LOGGER.warning("Invalid redirect uri", redirect_uri=self.redirect_uri)
|
||||
raise RedirectUriError()
|
||||
|
||||
if not is_open_id and (
|
||||
self.grant_type == GrantTypes.HYBRID
|
||||
or self.response_type
|
||||
in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
|
||||
):
|
||||
LOGGER.warning("Missing 'openid' scope.")
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type)
|
||||
|
||||
# Nonce parameter validation.
|
||||
if is_open_id and self.grant_type == GrantTypes.IMPLICIT and not self.nonce:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||
|
||||
# Response type parameter validation.
|
||||
if is_open_id and self.response_type != self.provider.response_type:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||
|
||||
# PKCE validation of the transformation method.
|
||||
if self.code_challenge:
|
||||
if not (self.code_challenge_method in ["plain", "S256"]):
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "invalid_request", self.grant_type
|
||||
)
|
||||
|
||||
def create_code(self, request: HttpRequest) -> AuthorizationCode:
|
||||
"""Create an AuthorizationCode object for the request"""
|
||||
code = AuthorizationCode()
|
||||
code.user = request.user
|
||||
code.provider = self.provider
|
||||
|
||||
code.code = uuid4().hex
|
||||
|
||||
if self.code_challenge and self.code_challenge_method:
|
||||
code.code_challenge = self.code_challenge
|
||||
code.code_challenge_method = self.code_challenge_method
|
||||
|
||||
code.expires_at = timezone.now() + timedelta_from_string(
|
||||
self.provider.token_validity
|
||||
)
|
||||
code.scope = self.scope
|
||||
code.nonce = self.nonce
|
||||
code.is_open_id = SCOPE_OPENID in self.scope
|
||||
|
||||
return code
|
||||
|
||||
|
||||
class OAuthFulfillmentStage(StageView):
|
||||
"""Final stage, restores params from Flow."""
|
||||
|
||||
params: OAuthAuthorizationParams
|
||||
provider: OAuth2Provider
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(
|
||||
PLAN_CONTEXT_PARAMS
|
||||
)
|
||||
application: Application = self.executor.plan.context.pop(
|
||||
PLAN_CONTEXT_APPLICATION
|
||||
)
|
||||
self.provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
|
||||
try:
|
||||
# At this point we don't need to check permissions anymore
|
||||
if {PROMPT_NONE, PROMPT_CONSNET}.issubset(self.params.prompt):
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri,
|
||||
"consent_required",
|
||||
self.params.grant_type,
|
||||
)
|
||||
return redirect(self.create_response_uri())
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
# pylint: disable=no-member
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
except AuthorizeError as error:
|
||||
uri = error.create_uri(self.params.redirect_uri, self.params.state)
|
||||
return redirect(uri)
|
||||
|
||||
def create_response_uri(self) -> str:
|
||||
"""Create a final Response URI the user is redirected to."""
|
||||
uri = urlsplit(self.params.redirect_uri)
|
||||
query_params = parse_qs(uri.query)
|
||||
query_fragment = {}
|
||||
|
||||
try:
|
||||
code = None
|
||||
|
||||
if self.params.grant_type in [
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantTypes.HYBRID,
|
||||
]:
|
||||
code = self.params.create_code(self.request)
|
||||
code.save()
|
||||
|
||||
if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
|
||||
query_params["code"] = code.code
|
||||
query_params["state"] = [
|
||||
str(self.params.state) if self.params.state else ""
|
||||
]
|
||||
elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
token: Token = self.provider.create_token(
|
||||
user=self.request.user, scope=self.params.scope,
|
||||
)
|
||||
|
||||
# Check if response_type must include access_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.id_token_token,
|
||||
ResponseTypes.code_id_token_token,
|
||||
ResponseTypes.token,
|
||||
ResponseTypes.code_token,
|
||||
]:
|
||||
query_fragment["access_token"] = token.access_token
|
||||
|
||||
# We don't need id_token if it's an OAuth2 request.
|
||||
if SCOPE_OPENID in self.params.scope:
|
||||
id_token = token.create_id_token(
|
||||
user=self.request.user,
|
||||
request=self.request,
|
||||
scope=self.params.scope,
|
||||
)
|
||||
id_token.nonce = self.params.nonce
|
||||
id_token.scope = self.params.scope
|
||||
# Include at_hash when access_token is being returned.
|
||||
if "access_token" in query_fragment:
|
||||
id_token.at_hash = token.at_hash
|
||||
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
query_fragment["id_token"] = id_token.encode(self.provider)
|
||||
token.id_token = id_token
|
||||
else:
|
||||
token.id_token = {}
|
||||
|
||||
# Store the token.
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = "bearer"
|
||||
query_fragment["expires_in"] = timedelta_from_string(
|
||||
self.provider.token_validity
|
||||
).seconds
|
||||
query_fragment["state"] = self.params.state if self.params.state else ""
|
||||
|
||||
except OAuth2Error as error:
|
||||
LOGGER.exception("Error when trying to create response uri", error=error)
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri, "server_error", self.params.grant_type
|
||||
)
|
||||
|
||||
uri = uri._replace(
|
||||
query=urlencode(query_params, doseq=True),
|
||||
fragment=uri.fragment + urlencode(query_fragment, doseq=True),
|
||||
)
|
||||
|
||||
return urlunsplit(uri)
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(PolicyAccessMixin, View):
|
||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Check access to application, start FlowPLanner, return to flow executor shell"""
|
||||
client_id = request.GET.get("client_id")
|
||||
# TODO: This whole block should be moved to a base class
|
||||
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
||||
try:
|
||||
application = self.provider_to_application(provider)
|
||||
except Application.DoesNotExist:
|
||||
return self.handle_no_permission_authorized()
|
||||
# Check if user is unauthenticated, so we pass the application
|
||||
# for the identification stage
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission(application)
|
||||
# Check permissions
|
||||
result = self.user_has_access(application)
|
||||
if not result.passing:
|
||||
return self.handle_no_permission_authorized()
|
||||
# TODO: End block
|
||||
# Extract params so we can save them in the plan context
|
||||
try:
|
||||
params = OAuthAuthorizationParams.from_request(request)
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
# pylint: disable=no-member
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(provider.authorization_flow)
|
||||
# planner.use_cache = False
|
||||
planner.allow_empty_flows = True
|
||||
plan: FlowPlan = planner.plan(
|
||||
self.request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: application,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_PARAMS: params,
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
|
||||
params.scope
|
||||
),
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html",
|
||||
},
|
||||
)
|
||||
# OpenID clients can specify a `prompt` parameter, and if its set to consent we
|
||||
# need to inject a consent stage
|
||||
if PROMPT_CONSNET in params.prompt:
|
||||
if not any([isinstance(x, ConsentStageView) for x in plan.stages]):
|
||||
# Plan does not have any consent stage, so we add an in-memory one
|
||||
stage = ConsentStage(
|
||||
name="OAuth2 Provider In-memory consent stage",
|
||||
mode=ConsentMode.ALWAYS_REQUIRE,
|
||||
)
|
||||
plan.append(stage)
|
||||
plan.append(in_memory_stage(OAuthFulfillmentStage))
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"passbook_flows:flow-executor-shell",
|
||||
self.request.GET,
|
||||
flow_slug=provider.authorization_flow.slug,
|
||||
)
|
||||
69
passbook/providers/oauth2/views/github.py
Normal file
69
passbook/providers/oauth2/views/github.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""passbook pretend GitHub Views"""
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.views import View
|
||||
|
||||
from passbook.providers.oauth2.models import RefreshToken
|
||||
|
||||
|
||||
class GitHubUserView(View):
|
||||
"""Emulate GitHub's /user API Endpoint"""
|
||||
|
||||
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||
"""Emulate GitHub's /user API Endpoint"""
|
||||
user = token.user
|
||||
return JsonResponse(
|
||||
{
|
||||
"login": user.username,
|
||||
"id": user.pk,
|
||||
"node_id": "",
|
||||
"avatar_url": "",
|
||||
"gravatar_id": "",
|
||||
"url": "",
|
||||
"html_url": "",
|
||||
"followers_url": "",
|
||||
"following_url": "",
|
||||
"gists_url": "",
|
||||
"starred_url": "",
|
||||
"subscriptions_url": "",
|
||||
"organizations_url": "",
|
||||
"repos_url": "",
|
||||
"events_url": "",
|
||||
"received_events_url": "",
|
||||
"type": "User",
|
||||
"site_admin": False,
|
||||
"name": user.name,
|
||||
"company": "",
|
||||
"blog": "",
|
||||
"location": "",
|
||||
"email": user.email,
|
||||
"hireable": False,
|
||||
"bio": "",
|
||||
"public_repos": 0,
|
||||
"public_gists": 0,
|
||||
"followers": 0,
|
||||
"following": 0,
|
||||
"created_at": user.date_joined,
|
||||
"updated_at": user.date_joined,
|
||||
"private_gists": 0,
|
||||
"total_private_repos": 0,
|
||||
"owned_private_repos": 0,
|
||||
"disk_usage": 0,
|
||||
"collaborators": 0,
|
||||
"two_factor_authentication": True,
|
||||
"plan": {
|
||||
"name": "None",
|
||||
"space": 0,
|
||||
"private_repos": 0,
|
||||
"collaborators": 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GitHubUserTeamsView(View):
|
||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||
return JsonResponse([], safe=False)
|
||||
113
passbook/providers/oauth2/views/introspection.py
Normal file
113
passbook/providers/oauth2/views/introspection.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""passbook OAuth2 Token Introspection Views"""
|
||||
from dataclasses import InitVar, dataclass
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.oauth2.constants import SCOPE_OPENID_INTROSPECTION
|
||||
from passbook.providers.oauth2.errors import TokenIntrospectionError
|
||||
from passbook.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||
from passbook.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenIntrospectionParams:
|
||||
"""Parameters for Token Introspection"""
|
||||
|
||||
client_id: str
|
||||
client_secret: str
|
||||
|
||||
raw_token: InitVar[str]
|
||||
|
||||
token: Optional[RefreshToken] = None
|
||||
|
||||
provider: Optional[OAuth2Provider] = None
|
||||
id_token: Optional[IDToken] = None
|
||||
|
||||
def __post_init__(self, raw_token: str):
|
||||
try:
|
||||
self.token = RefreshToken.objects.get(access_token=raw_token)
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
raise TokenIntrospectionError()
|
||||
if self.token.has_expired():
|
||||
LOGGER.debug("Token is not valid", token=raw_token)
|
||||
raise TokenIntrospectionError()
|
||||
try:
|
||||
self.provider = OAuth2Provider.objects.get(
|
||||
client_id=self.client_id, client_secret=self.client_secret,
|
||||
)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.debug("provider for ID not found", client_id=self.client_id)
|
||||
raise TokenIntrospectionError()
|
||||
if SCOPE_OPENID_INTROSPECTION not in self.provider.scope_names:
|
||||
LOGGER.debug(
|
||||
"OAuth2Provider does not have introspection scope",
|
||||
client_id=self.client_id,
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
self.id_token = self.token.id_token
|
||||
|
||||
if not self.token.id_token:
|
||||
LOGGER.debug(
|
||||
"token not an authentication token", token=self.token,
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
audience = self.token.id_token.aud
|
||||
if not audience:
|
||||
LOGGER.debug(
|
||||
"No audience found for token", token=self.token,
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
if audience not in self.provider.scope_names:
|
||||
LOGGER.debug(
|
||||
"provider does not audience scope",
|
||||
client_id=self.client_id,
|
||||
audience=audience,
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "TokenIntrospectionParams":
|
||||
"""Extract required Parameters from HTTP Request"""
|
||||
# Introspection only supports POST requests
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
return TokenIntrospectionParams(
|
||||
raw_token=request.POST.get("token"),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
|
||||
class TokenIntrospectionView(View):
|
||||
"""Token Introspection
|
||||
https://tools.ietf.org/html/rfc7662"""
|
||||
|
||||
token: RefreshToken
|
||||
params: TokenIntrospectionParams
|
||||
provider: OAuth2Provider
|
||||
id_token: IDToken
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Introspection handler"""
|
||||
self.params = TokenIntrospectionParams.from_request(request)
|
||||
|
||||
try:
|
||||
response_dic = {}
|
||||
if self.id_token:
|
||||
token_dict = self.id_token.to_dict()
|
||||
for k in ("aud", "sub", "exp", "iat", "iss"):
|
||||
response_dic[k] = token_dict[k]
|
||||
response_dic["active"] = True
|
||||
response_dic["client_id"] = self.token.provider.client_id
|
||||
|
||||
return TokenResponse(response_dic)
|
||||
except TokenIntrospectionError:
|
||||
return TokenResponse({"active": False})
|
||||
40
passbook/providers/oauth2/views/jwks.py
Normal file
40
passbook/providers/oauth2/views/jwks.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""passbook OAuth2 JWKS Views"""
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
from jwkest import long_to_base64
|
||||
from jwkest.jwk import import_rsa_key
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
|
||||
|
||||
|
||||
class JWKSView(View):
|
||||
"""Show RSA Key data for Provider"""
|
||||
|
||||
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Show RSA Key data for Provider"""
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: OAuth2Provider = get_object_or_404(
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
|
||||
response_data = {}
|
||||
|
||||
if provider.jwt_alg == JWTAlgorithms.RS256:
|
||||
public_key = import_rsa_key(provider.rsa_key.key_data).publickey()
|
||||
response_data["keys"] = [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"kid": provider.rsa_key.kid,
|
||||
"n": long_to_base64(public_key.n),
|
||||
"e": long_to_base64(public_key.e),
|
||||
}
|
||||
]
|
||||
|
||||
response = JsonResponse(response_data)
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
return response
|
||||
65
passbook/providers/oauth2/views/provider.py
Normal file
65
passbook/providers/oauth2/views/provider.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""passbook OAuth2 OpenID well-known views"""
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, reverse
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.providers.oauth2.models import OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_SCOPES = "scopes"
|
||||
|
||||
|
||||
class ProviderInfoView(View):
|
||||
"""OpenID-compliant Provider Info"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(
|
||||
self, request: HttpRequest, application_slug: str, *args, **kwargs
|
||||
) -> HttpResponse:
|
||||
"""OpenID-compliant Provider Info"""
|
||||
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: OAuth2Provider = get_object_or_404(
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
response = JsonResponse(
|
||||
{
|
||||
"issuer": provider.get_issuer(request),
|
||||
"authorization_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:authorize")
|
||||
),
|
||||
"token_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:token")
|
||||
),
|
||||
"userinfo_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:userinfo")
|
||||
),
|
||||
"end_session_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:end-session")
|
||||
),
|
||||
"introspection_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:token-introspection")
|
||||
),
|
||||
"response_types_supported": [provider.response_type],
|
||||
"jwks_uri": request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_oauth2:jwks",
|
||||
kwargs={"application_slug": application.slug},
|
||||
)
|
||||
),
|
||||
"id_token_signing_alg_values_supported": [provider.jwt_alg],
|
||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||
"subject_types_supported": ["public"],
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
],
|
||||
}
|
||||
)
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
return response
|
||||
45
passbook/providers/oauth2/views/session.py
Normal file
45
passbook/providers/oauth2/views/session.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""passbook OAuth2 Session Views"""
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from django.contrib.auth.views import LogoutView
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.providers.oauth2.models import OAuth2Provider
|
||||
from passbook.providers.oauth2.utils import client_id_from_id_token
|
||||
|
||||
|
||||
class EndSessionView(LogoutView):
|
||||
"""Allow the client to end the Session"""
|
||||
|
||||
def dispatch(
|
||||
self, request: HttpRequest, application_slug: str, *args, **kwargs
|
||||
) -> HttpResponse:
|
||||
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: OAuth2Provider = get_object_or_404(
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
|
||||
id_token_hint = request.GET.get("id_token_hint", "")
|
||||
post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri", "")
|
||||
state = request.GET.get("state", "")
|
||||
|
||||
if id_token_hint:
|
||||
client_id = client_id_from_id_token(id_token_hint)
|
||||
try:
|
||||
provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||
if post_logout_redirect_uri in provider.post_logout_redirect_uris:
|
||||
if state:
|
||||
uri = urlsplit(post_logout_redirect_uri)
|
||||
query_params = parse_qs(uri.query)
|
||||
query_params["state"] = state
|
||||
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||
self.next_page = urlunsplit(uri)
|
||||
else:
|
||||
self.next_page = post_logout_redirect_uri
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
241
passbook/providers/oauth2/views/token.py
Normal file
241
passbook/providers/oauth2/views/token.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""passbook OAuth2 Token views"""
|
||||
from base64 import urlsafe_b64encode
|
||||
from dataclasses import InitVar, dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.time import timedelta_from_string
|
||||
from passbook.providers.oauth2.constants import (
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
)
|
||||
from passbook.providers.oauth2.errors import TokenError, UserAuthError
|
||||
from passbook.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
)
|
||||
from passbook.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class TokenParams:
|
||||
"""Token params"""
|
||||
|
||||
client_id: str
|
||||
client_secret: str
|
||||
redirect_uri: str
|
||||
grant_type: str
|
||||
state: str
|
||||
scope: List[str]
|
||||
|
||||
authorization_code: Optional[AuthorizationCode] = None
|
||||
refresh_token: Optional[RefreshToken] = None
|
||||
|
||||
code_verifier: Optional[str] = None
|
||||
|
||||
raw_code: InitVar[str] = ""
|
||||
raw_token: InitVar[str] = ""
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "TokenParams":
|
||||
"""Extract Token Parameters from http request"""
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
|
||||
return TokenParams(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=request.POST.get("redirect_uri", ""),
|
||||
grant_type=request.POST.get("grant_type", ""),
|
||||
raw_code=request.POST.get("code", ""),
|
||||
raw_token=request.POST.get("refresh_token", ""),
|
||||
state=request.POST.get("state", ""),
|
||||
scope=request.POST.get("scope", "").split(),
|
||||
# PKCE parameter.
|
||||
code_verifier=request.POST.get("code_verifier"),
|
||||
)
|
||||
|
||||
def __post_init__(self, raw_code, raw_token):
|
||||
try:
|
||||
provider: OAuth2Provider = OAuth2Provider.objects.get(
|
||||
client_id=self.client_id
|
||||
)
|
||||
self.provider = provider
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.warning("OAuth2Provider does not exist", client_id=self.client_id)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
if self.provider.client_type == "confidential":
|
||||
if self.provider.client_secret != self.client_secret:
|
||||
LOGGER.warning(
|
||||
"Invalid client secret: client does not have secret",
|
||||
client_id=self.provider.client_id,
|
||||
secret=self.provider.client_secret,
|
||||
)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
self.__post_init_code(raw_code)
|
||||
|
||||
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing refresh token")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
try:
|
||||
self.refresh_token = RefreshToken.objects.get(
|
||||
refresh_token=raw_token, client=self.provider
|
||||
)
|
||||
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.warning(
|
||||
"Refresh token does not exist", token=raw_token,
|
||||
)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
else:
|
||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||
raise TokenError("unsupported_grant_type")
|
||||
|
||||
def __post_init_code(self, raw_code):
|
||||
if not raw_code:
|
||||
LOGGER.warning("Missing authorization code")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if self.redirect_uri not in self.provider.redirect_uris:
|
||||
LOGGER.warning("Invalid redirect uri", uri=self.redirect_uri)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
try:
|
||||
self.authorization_code = AuthorizationCode.objects.get(code=raw_code)
|
||||
except AuthorizationCode.DoesNotExist:
|
||||
LOGGER.warning("Code does not exist", code=raw_code)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if (
|
||||
self.authorization_code.provider != self.provider
|
||||
or self.authorization_code.is_expired
|
||||
):
|
||||
LOGGER.warning("Invalid code: invalid client or code has expired")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
# Validate PKCE parameters.
|
||||
if self.code_verifier:
|
||||
if self.authorization_code.code_challenge_method == "S256":
|
||||
new_code_challenge = (
|
||||
urlsafe_b64encode(
|
||||
sha256(self.code_verifier.encode("ascii")).digest()
|
||||
)
|
||||
.decode("utf-8")
|
||||
.replace("=", "")
|
||||
)
|
||||
else:
|
||||
new_code_challenge = self.code_verifier
|
||||
|
||||
if new_code_challenge != self.authorization_code.code_challenge:
|
||||
LOGGER.warning("Code challenge not matching")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
|
||||
class TokenView(View):
|
||||
"""Generate tokens for clients"""
|
||||
|
||||
params: TokenParams
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Generate tokens for clients"""
|
||||
try:
|
||||
self.params = TokenParams.from_request(request)
|
||||
|
||||
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
return TokenResponse(self.create_code_response_dic())
|
||||
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
return TokenResponse(self.create_refresh_response_dic())
|
||||
raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
|
||||
except TokenError as error:
|
||||
return TokenResponse(error.create_dict(), status=400)
|
||||
except UserAuthError as error:
|
||||
return TokenResponse(error.create_dict(), status=403)
|
||||
|
||||
def create_code_response_dic(self) -> Dict[str, Any]:
|
||||
"""See https://tools.ietf.org/html/rfc6749#section-4.1"""
|
||||
|
||||
refresh_token = self.params.authorization_code.provider.create_refresh_token(
|
||||
user=self.params.authorization_code.user,
|
||||
scope=self.params.authorization_code.scope,
|
||||
)
|
||||
|
||||
if self.params.authorization_code.is_open_id:
|
||||
id_token = refresh_token.create_id_token(
|
||||
user=self.params.authorization_code.user, request=self.request,
|
||||
)
|
||||
id_token.nonce = self.params.authorization_code.nonce
|
||||
id_token.at_hash = refresh_token.at_hash
|
||||
refresh_token.id_token = id_token
|
||||
|
||||
# Store the token.
|
||||
refresh_token.save()
|
||||
|
||||
# We don't need to store the code anymore.
|
||||
self.params.authorization_code.delete()
|
||||
|
||||
dic = {
|
||||
"access_token": refresh_token.access_token,
|
||||
"refresh_token": refresh_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": timedelta_from_string(
|
||||
self.params.provider.token_validity
|
||||
).seconds,
|
||||
"id_token": refresh_token.id_token.encode(refresh_token.provider),
|
||||
}
|
||||
|
||||
return dic
|
||||
|
||||
def create_refresh_response_dic(self) -> Dict[str, Any]:
|
||||
"""See https://tools.ietf.org/html/rfc6749#section-6"""
|
||||
|
||||
unauthorized_scopes = set(self.params.scope) - set(
|
||||
self.params.refresh_token.scope
|
||||
)
|
||||
if unauthorized_scopes:
|
||||
raise TokenError("invalid_scope")
|
||||
|
||||
refresh_token = self.params.refresh_token.provider.create_token(
|
||||
user=self.params.refresh_token.user,
|
||||
provider=self.params.refresh_token.provider,
|
||||
scope=self.params.scope,
|
||||
)
|
||||
|
||||
# If the Token has an id_token it's an Authentication request.
|
||||
if self.params.refresh_token.id_token:
|
||||
refresh_token.id_token = refresh_token.create_id_token(
|
||||
user=self.params.refresh_token.user, request=self.request,
|
||||
)
|
||||
refresh_token.id_token.at_hash = refresh_token.at_hash
|
||||
|
||||
# Store the refresh_token.
|
||||
refresh_token.save()
|
||||
|
||||
# Forget the old token.
|
||||
self.params.refresh_token.delete()
|
||||
|
||||
dic = {
|
||||
"access_token": refresh_token.access_token,
|
||||
"refresh_token": refresh_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": timedelta_from_string(
|
||||
refresh_token.provider.token_validity
|
||||
).seconds,
|
||||
"id_token": refresh_token.id_token.encode(
|
||||
self.params.refresh_token.provider
|
||||
),
|
||||
}
|
||||
|
||||
return dic
|
||||
92
passbook/providers/oauth2/views/userinfo.py
Normal file
92
passbook/providers/oauth2/views/userinfo.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""passbook OAuth2 OpenID Userinfo views"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.oauth2.constants import (
|
||||
SCOPE_GITHUB_ORG_READ,
|
||||
SCOPE_GITHUB_USER,
|
||||
SCOPE_GITHUB_USER_EMAIL,
|
||||
SCOPE_GITHUB_USER_READ,
|
||||
)
|
||||
from passbook.providers.oauth2.models import RefreshToken, ScopeMapping
|
||||
from passbook.providers.oauth2.utils import TokenResponse, cors_allow_any
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class UserInfoView(View):
|
||||
"""Create a dictionary with all the requested claims about the End-User.
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse"""
|
||||
|
||||
def get_scope_descriptions(self, scopes: List[str]) -> List[str]:
|
||||
"""Get a list of all Scopes's descriptions"""
|
||||
scope_descriptions = []
|
||||
for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by(
|
||||
"scope_name"
|
||||
):
|
||||
if scope.description != "":
|
||||
scope_descriptions.append(scope.description)
|
||||
# GitHub Compatibility Scopes are handeled differently, since they required custom paths
|
||||
# Hence they don't exist as Scope objects
|
||||
github_scope_map = {
|
||||
SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"),
|
||||
SCOPE_GITHUB_USER_READ: _(
|
||||
"GitHub Compatibility: Access your User Information"
|
||||
),
|
||||
SCOPE_GITHUB_USER_EMAIL: _(
|
||||
"GitHub Compatibility: Access you Email addresses"
|
||||
),
|
||||
SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"),
|
||||
}
|
||||
for scope in scopes:
|
||||
if scope in github_scope_map:
|
||||
scope_descriptions.append(github_scope_map[scope])
|
||||
return scope_descriptions
|
||||
|
||||
def get_claims(self, token: RefreshToken) -> Dict[str, Any]:
|
||||
"""Get a dictionary of claims from scopes that the token
|
||||
requires and are assigned to the provider."""
|
||||
|
||||
scopes_from_client = token.scope
|
||||
final_claims = {}
|
||||
for scope in ScopeMapping.objects.filter(
|
||||
provider=token.provider, scope_name__in=scopes_from_client
|
||||
).order_by("scope_name"):
|
||||
value = scope.evaluate(
|
||||
user=token.user,
|
||||
request=self.request,
|
||||
provider=token.provider,
|
||||
token=token,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
LOGGER.warning(
|
||||
"Scope returned a non-dict value, ignoring",
|
||||
scope=scope,
|
||||
value=value,
|
||||
)
|
||||
continue
|
||||
LOGGER.debug("updated scope", scope=scope)
|
||||
final_claims.update(value)
|
||||
return final_claims
|
||||
|
||||
def options(self, request: HttpRequest) -> HttpResponse:
|
||||
return cors_allow_any(self.request, TokenResponse({}))
|
||||
|
||||
def get(self, request: HttpRequest, **kwargs) -> HttpResponse:
|
||||
"""Handle GET Requests for UserInfo"""
|
||||
token: RefreshToken = kwargs["token"]
|
||||
claims = self.get_claims(token)
|
||||
claims["sub"] = token.id_token.sub
|
||||
response = TokenResponse(claims)
|
||||
cors_allow_any(self.request, response)
|
||||
return response
|
||||
|
||||
def post(self, request: HttpRequest, **kwargs) -> HttpResponse:
|
||||
"""POST Requests behave the same as GET Requests, so the get handler is called here"""
|
||||
return self.get(request, **kwargs)
|
||||
Reference in New Issue
Block a user