providers/oauth2: make sub configurable based on hash, username, email and upn
This commit is contained in:
parent
c4de808c4e
commit
5c622cd4d2
|
@ -23,6 +23,7 @@ class OAuth2ProviderSerializer(ModelSerializer):
|
||||||
"rsa_key",
|
"rsa_key",
|
||||||
"redirect_uris",
|
"redirect_uris",
|
||||||
"post_logout_redirect_uris",
|
"post_logout_redirect_uris",
|
||||||
|
"sub_mode",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -42,6 +42,7 @@ class OAuth2ProviderForm(forms.ModelForm):
|
||||||
"rsa_key",
|
"rsa_key",
|
||||||
"redirect_uris",
|
"redirect_uris",
|
||||||
"post_logout_redirect_uris",
|
"post_logout_redirect_uris",
|
||||||
|
"sub_mode",
|
||||||
"property_mappings",
|
"property_mappings",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
|
|
|
@ -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.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -46,6 +46,26 @@ class GrantTypes(models.TextChoices):
|
||||||
HYBRID = "hybrid"
|
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):
|
class ResponseTypes(models.TextChoices):
|
||||||
"""Response Type required by the client."""
|
"""Response Type required by the client."""
|
||||||
|
|
||||||
|
@ -84,7 +104,7 @@ class ScopeMapping(PropertyMapping):
|
||||||
return ScopeMappingForm
|
return ScopeMappingForm
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Scope Mapping '{self.scope_name}'"
|
return f"Scope Mapping {self.name} ({self.scope_name})"
|
||||||
|
|
||||||
class Meta:
|
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(
|
rsa_key = models.ForeignKey(
|
||||||
CertificateKeyPair,
|
CertificateKeyPair,
|
||||||
verbose_name=_("RSA Key"),
|
verbose_name=_("RSA Key"),
|
||||||
|
@ -249,6 +280,14 @@ class OAuth2Provider(Provider):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"OAuth2 Provider {self.name}"
|
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]:
|
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||||
try:
|
try:
|
||||||
|
@ -368,14 +407,6 @@ class IDToken:
|
||||||
dic.update(self.claims)
|
dic.update(self.claims)
|
||||||
return dic
|
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):
|
class RefreshToken(ExpiringModel, BaseGrantModel):
|
||||||
"""OAuth2 Refresh Token"""
|
"""OAuth2 Refresh Token"""
|
||||||
|
@ -424,7 +455,22 @@ class RefreshToken(ExpiringModel, BaseGrantModel):
|
||||||
def create_id_token(self, user: User, request: HttpRequest) -> IDToken:
|
def create_id_token(self, user: User, request: HttpRequest) -> IDToken:
|
||||||
"""Creates the id_token.
|
"""Creates the id_token.
|
||||||
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||||
sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
|
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.
|
# Convert datetimes into timestamps.
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
|
|
|
@ -281,7 +281,9 @@ class OAuthFulfillmentStage(StageView):
|
||||||
ResponseTypes.CODE_ID_TOKEN,
|
ResponseTypes.CODE_ID_TOKEN,
|
||||||
ResponseTypes.CODE_ID_TOKEN_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
|
token.id_token = id_token
|
||||||
|
|
||||||
# Store the token.
|
# Store the token.
|
||||||
|
|
|
@ -193,11 +193,11 @@ class TokenView(View):
|
||||||
dic = {
|
dic = {
|
||||||
"access_token": refresh_token.access_token,
|
"access_token": refresh_token.access_token,
|
||||||
"refresh_token": refresh_token.refresh_token,
|
"refresh_token": refresh_token.refresh_token,
|
||||||
"token_type": "bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": timedelta_from_string(
|
"expires_in": timedelta_from_string(
|
||||||
self.params.provider.token_validity
|
self.params.provider.token_validity
|
||||||
).seconds,
|
).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
|
return dic
|
||||||
|
@ -237,8 +237,8 @@ class TokenView(View):
|
||||||
"expires_in": timedelta_from_string(
|
"expires_in": timedelta_from_string(
|
||||||
refresh_token.provider.token_validity
|
refresh_token.provider.token_validity
|
||||||
).seconds,
|
).seconds,
|
||||||
"id_token": refresh_token.id_token.encode(
|
"id_token": self.params.provider.encode(
|
||||||
self.params.refresh_token.provider
|
self.params.refresh_token.id_token.to_dict()
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
10
swagger.yaml
10
swagger.yaml
|
@ -6646,6 +6646,16 @@ definitions:
|
||||||
title: Post Logout Redirect URIs
|
title: Post Logout Redirect URIs
|
||||||
description: Enter each URI on a new line.
|
description: Enter each URI on a new line.
|
||||||
type: string
|
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:
|
property_mappings:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|
Reference in New Issue