From 5c622cd4d2a990c65f0f9a6d773ab97da4e4964a Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Tue, 15 Sep 2020 20:54:42 +0200 Subject: [PATCH] providers/oauth2: make sub configurable based on hash, username, email and upn --- passbook/providers/oauth2/api.py | 1 + passbook/providers/oauth2/forms.py | 1 + .../0002_oauth2provider_sub_mode.py | 33 ++++++++++ passbook/providers/oauth2/models.py | 66 ++++++++++++++++--- passbook/providers/oauth2/views/authorize.py | 4 +- passbook/providers/oauth2/views/token.py | 8 +-- swagger.yaml | 10 +++ 7 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 passbook/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py diff --git a/passbook/providers/oauth2/api.py b/passbook/providers/oauth2/api.py index a4dad4461..d16c0135d 100644 --- a/passbook/providers/oauth2/api.py +++ b/passbook/providers/oauth2/api.py @@ -23,6 +23,7 @@ class OAuth2ProviderSerializer(ModelSerializer): "rsa_key", "redirect_uris", "post_logout_redirect_uris", + "sub_mode", "property_mappings", ] diff --git a/passbook/providers/oauth2/forms.py b/passbook/providers/oauth2/forms.py index 296cc32fa..97ec9ff84 100644 --- a/passbook/providers/oauth2/forms.py +++ b/passbook/providers/oauth2/forms.py @@ -42,6 +42,7 @@ class OAuth2ProviderForm(forms.ModelForm): "rsa_key", "redirect_uris", "post_logout_redirect_uris", + "sub_mode", "property_mappings", ] widgets = { diff --git a/passbook/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py b/passbook/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py new file mode 100644 index 000000000..b33c7075f --- /dev/null +++ b/passbook/providers/oauth2/migrations/0002_oauth2provider_sub_mode.py @@ -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.", + ), + ), + ] diff --git a/passbook/providers/oauth2/models.py b/passbook/providers/oauth2/models.py index c4cc196eb..dc289860c 100644 --- a/passbook/providers/oauth2/models.py +++ b/passbook/providers/oauth2/models.py @@ -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 = 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. now = int(time.time()) diff --git a/passbook/providers/oauth2/views/authorize.py b/passbook/providers/oauth2/views/authorize.py index 5ed79eae1..01aaf59d4 100644 --- a/passbook/providers/oauth2/views/authorize.py +++ b/passbook/providers/oauth2/views/authorize.py @@ -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. diff --git a/passbook/providers/oauth2/views/token.py b/passbook/providers/oauth2/views/token.py index 6eaa325c7..f134349cd 100644 --- a/passbook/providers/oauth2/views/token.py +++ b/passbook/providers/oauth2/views/token.py @@ -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() ), } diff --git a/swagger.yaml b/swagger.yaml index b47022c79..241018062 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -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: