providers/oauth2: make sub configurable based on hash, username, email and upn

This commit is contained in:
Jens Langhammer 2020-09-15 20:54:42 +02:00
parent c4de808c4e
commit 5c622cd4d2
7 changed files with 108 additions and 15 deletions

View File

@ -23,6 +23,7 @@ class OAuth2ProviderSerializer(ModelSerializer):
"rsa_key",
"redirect_uris",
"post_logout_redirect_uris",
"sub_mode",
"property_mappings",
]

View File

@ -42,6 +42,7 @@ class OAuth2ProviderForm(forms.ModelForm):
"rsa_key",
"redirect_uris",
"post_logout_redirect_uris",
"sub_mode",
"property_mappings",
]
widgets = {

View File

@ -0,0 +1,33 @@
# Generated by Django 3.1.1 on 2020-09-15 18:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_providers_oauth2", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="oauth2provider",
name="sub_mode",
field=models.TextField(
choices=[
("hashed_user_id", "Based on the Hashed User ID"),
("user_username", "Based on the username"),
(
"user_email",
"Based on the User's Email. This is recommended over the UPN method.",
),
(
"user_upn",
"Based on the User's UPN, only works if user has a 'upn' attribute set. Use this method only if you have different UPN and Mail domains.",
),
],
default="hashed_user_id",
help_text="Configure what data should be used as unique User Identifier. For most cases, the default should be fine.",
),
),
]

View File

@ -46,6 +46,26 @@ class GrantTypes(models.TextChoices):
HYBRID = "hybrid"
class SubModes(models.TextChoices):
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
USER_USERNAME = "user_username", _("Based on the username")
USER_EMAIL = (
"user_email",
_("Based on the User's Email. This is recommended over the UPN method."),
)
USER_UPN = (
"user_upn",
_(
(
"Based on the User's UPN, only works if user has a 'upn' attribute set. "
"Use this method only if you have different UPN and Mail domains."
)
),
)
class ResponseTypes(models.TextChoices):
"""Response Type required by the client."""
@ -84,7 +104,7 @@ class ScopeMapping(PropertyMapping):
return ScopeMappingForm
def __str__(self):
return f"Scope Mapping '{self.scope_name}'"
return f"Scope Mapping {self.name} ({self.scope_name})"
class Meta:
@ -162,6 +182,17 @@ class OAuth2Provider(Provider):
),
)
sub_mode = models.TextField(
choices=SubModes.choices,
default=SubModes.HASHED_USER_ID,
help_text=_(
(
"Configure what data should be used as unique User Identifier. For most cases, "
"the default should be fine."
)
),
)
rsa_key = models.ForeignKey(
CertificateKeyPair,
verbose_name=_("RSA Key"),
@ -249,6 +280,14 @@ class OAuth2Provider(Provider):
def __str__(self):
return f"OAuth2 Provider {self.name}"
def encode(self, payload: Dict[str, Any]) -> str:
"""Represent the ID Token as a JSON Web Token (JWT)."""
keys = self.get_jwt_keys()
# If the provider does not have an RSA Key assigned, it was switched to Symmetric
self.refresh_from_db()
jws = JWS(payload, alg=self.jwt_alg)
return jws.sign_compact(keys)
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
try:
@ -368,14 +407,6 @@ class IDToken:
dic.update(self.claims)
return dic
def encode(self, provider: OAuth2Provider) -> str:
"""Represent the ID Token as a JSON Web Token (JWT)."""
keys = provider.get_jwt_keys()
# If the provider does not have an RSA Key assigned, it was switched to Symmetric
provider.refresh_from_db()
jws = JWS(self.to_dict(), alg=provider.jwt_alg)
return jws.sign_compact(keys)
class RefreshToken(ExpiringModel, BaseGrantModel):
"""OAuth2 Refresh Token"""
@ -424,7 +455,22 @@ class RefreshToken(ExpiringModel, BaseGrantModel):
def create_id_token(self, user: User, request: HttpRequest) -> IDToken:
"""Creates the id_token.
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
sub = ""
if self.provider.sub_mode == SubModes.HASHED_USER_ID:
sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
elif self.provider.sub_mode == SubModes.USER_EMAIL:
sub = user.email
elif self.provider.sub_mode == SubModes.USER_USERNAME:
sub = user.username
elif self.provider.sub_mode == SubModes.USER_UPN:
sub = user.attributes["upn"]
else:
raise ValueError(
(
f"Provider {self.provider} has invalid sub_mode "
f"selected: {self.provider.sub_mode}"
)
)
# Convert datetimes into timestamps.
now = int(time.time())

View File

@ -281,7 +281,9 @@ class OAuthFulfillmentStage(StageView):
ResponseTypes.CODE_ID_TOKEN,
ResponseTypes.CODE_ID_TOKEN_TOKEN,
]:
query_fragment["id_token"] = id_token.encode(self.provider)
query_fragment["id_token"] = self.provider.encode(
id_token.to_dict()
)
token.id_token = id_token
# Store the token.

View File

@ -193,11 +193,11 @@ class TokenView(View):
dic = {
"access_token": refresh_token.access_token,
"refresh_token": refresh_token.refresh_token,
"token_type": "bearer",
"token_type": "Bearer",
"expires_in": timedelta_from_string(
self.params.provider.token_validity
).seconds,
"id_token": refresh_token.id_token.encode(refresh_token.provider),
"id_token": refresh_token.provider.encode(refresh_token.id_token.to_dict()),
}
return dic
@ -237,8 +237,8 @@ class TokenView(View):
"expires_in": timedelta_from_string(
refresh_token.provider.token_validity
).seconds,
"id_token": refresh_token.id_token.encode(
self.params.refresh_token.provider
"id_token": self.params.provider.encode(
self.params.refresh_token.id_token.to_dict()
),
}

View File

@ -6646,6 +6646,16 @@ definitions:
title: Post Logout Redirect URIs
description: Enter each URI on a new line.
type: string
sub_mode:
title: Sub mode
description: Configure what data should be used as unique User Identifier.
For most cases, the default should be fine.
type: string
enum:
- hashed_user_id
- user_username
- user_email
- user_upn
property_mappings:
type: array
items: