mirror of
				https://github.com/optim-enterprises-bv/Mailu-OIDC.git
				synced 2025-10-30 01:32:22 +00:00 
			
		
		
		
	Merge remote-tracking branch 'origin/oidc'
This commit is contained in:
		
							
								
								
									
										48
									
								
								.github/workflows/build_test_deploy.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										48
									
								
								.github/workflows/build_test_deploy.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,10 +4,6 @@ | ||||
| #    Username of docker login for logging in docker for pulling images (higher pull rate limit) | ||||
| # ${{ secrets.Docker_Password }} | ||||
| #    Password of docker login for logging in docker for pulling images (higher pull rate limit) | ||||
| # ${{ secrets.Docker_Login2 }} | ||||
| #    Second Username of docker login for logging in docker for pulling images (higher pull rate limit) | ||||
| # ${{ secrets.Docker_Password2 }} | ||||
| #    Second Password of docker login for logging in docker for pulling images (higher pull rate limit) | ||||
| ################################################ | ||||
|  | ||||
| name: build-test-deploy | ||||
| @@ -37,14 +33,14 @@ on: | ||||
|         type: string | ||||
|       deploy: | ||||
|         description: Deploy to container registry. Happens for all branches but staging. Use string true or false. | ||||
|         default: true | ||||
|         default: 'true' | ||||
|         required: false | ||||
|         type: boolean | ||||
|         type: string | ||||
|       release: | ||||
|         description: Tag and create the github release. Use string true or false. | ||||
|         default: false | ||||
|         default: 'false' | ||||
|         required: false | ||||
|         type: boolean | ||||
|         type: string | ||||
|  | ||||
|   workflow_dispatch: | ||||
|     inputs: | ||||
| @@ -71,14 +67,14 @@ on: | ||||
|         type: string | ||||
|       deploy: | ||||
|         description: Deploy to container registry. Happens for all branches but staging. Use string true or false. | ||||
|         default: true | ||||
|         default: 'true' | ||||
|         required: false | ||||
|         type: boolean | ||||
|         type: string | ||||
|       release: | ||||
|         description: Tag and create the github release. Use string true or false. | ||||
|         default: false | ||||
|         default: 'false' | ||||
|         required: false | ||||
|         type: boolean | ||||
|         type: string | ||||
| env: | ||||
|   HCL_FILE: ./tests/build-ci.hcl | ||||
|  | ||||
| @@ -87,7 +83,7 @@ jobs: | ||||
| # This is used by the next build job. | ||||
|   targets: | ||||
|     name: create targets | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-24.04 | ||||
|     outputs: | ||||
|       matrix: ${{ steps.targets.outputs.matrix }} | ||||
|     steps: | ||||
| @@ -109,7 +105,7 @@ jobs: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         target: ["base", "assets"] | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-24.04 | ||||
|     permissions: | ||||
|      contents: read | ||||
|      packages: write | ||||
| @@ -184,7 +180,7 @@ jobs: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         target: ["base", "assets"] | ||||
|     runs-on: self-hosted | ||||
|     runs-on: ubuntu-24.04-arm | ||||
|     permissions: | ||||
|      contents: read | ||||
|      packages: write | ||||
| @@ -219,8 +215,8 @@ jobs: | ||||
|           PINNED_LABEL_VERSION: ${{ env.PINNED_MAILU_VERSION }} | ||||
|           ARCH: linux/arm64/v8,linux/arm/v7 | ||||
|           BUILDER: ${{ steps.uuid.outputs.uuid }} | ||||
|           DOCKER_LOGIN2: ${{ secrets.Docker_Login2 }} | ||||
|           DOCKER_PASSW2: ${{ secrets.Docker_Password2 }} | ||||
|           DOCKER_LOGIN: ${{ secrets.Docker_Login }} | ||||
|           DOCKER_PASSW: ${{ secrets.Docker_Password }} | ||||
|           BUILDX_NO_DEFAULT_ATTESTATIONS: 1 | ||||
|         uses: nick-fields/retry@v3 | ||||
|         with: | ||||
| @@ -232,7 +228,7 @@ jobs: | ||||
|             set -euxo pipefail \ | ||||
|               ; /usr/bin/docker info \ | ||||
|               ; echo "${{ github.token }}" |        docker login --username "${{ github.repository_owner }}" --password-stdin ghcr.io \ | ||||
|               ; echo "$DOCKER_PASSW2" |    docker login --username "$DOCKER_LOGIN2" --password-stdin \ | ||||
|               ; echo "$DOCKER_PASSW" |    docker login --username "$DOCKER_LOGIN" --password-stdin \ | ||||
|               ; /usr/bin/docker buildx rm builder-${{ env.BUILDER }} \ | ||||
|               || echo "builder does not exist" \ | ||||
|               ; /usr/bin/docker buildx create --name builder-${{ env.BUILDER }} --driver docker-container --use \ | ||||
| @@ -261,7 +257,7 @@ jobs: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         target: ${{ fromJson(needs.targets.outputs.matrix) }} | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-24.04 | ||||
|     permissions: | ||||
|      contents: read | ||||
|      packages: write | ||||
| @@ -339,7 +335,7 @@ jobs: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         target: ${{ fromJson(needs.targets.outputs.matrix) }} | ||||
|     runs-on: self-hosted | ||||
|     runs-on: ubuntu-24.04-arm | ||||
|     permissions: | ||||
|      contents: read | ||||
|      packages: write | ||||
| @@ -374,8 +370,8 @@ jobs: | ||||
|           PINNED_LABEL_VERSION: ${{ env.PINNED_MAILU_VERSION }} | ||||
|           ARCH: linux/arm64/v8,linux/arm/v7 | ||||
|           BUILDER: ${{ steps.uuid.outputs.uuid }} | ||||
|           DOCKER_LOGIN2: ${{ secrets.Docker_Login2 }} | ||||
|           DOCKER_PASSW2: ${{ secrets.Docker_Password2 }} | ||||
|           DOCKER_LOGIN: ${{ secrets.Docker_Login }} | ||||
|           DOCKER_PASSW: ${{ secrets.Docker_Password }} | ||||
|           BUILDX_NO_DEFAULT_ATTESTATIONS: 1 | ||||
|         uses: nick-fields/retry@v3 | ||||
|         with: | ||||
| @@ -387,7 +383,7 @@ jobs: | ||||
|             set -euxo pipefail \ | ||||
|               ; /usr/bin/docker info \ | ||||
|               ; echo "${{ github.token }}" |        docker login --username "${{ github.repository_owner }}" --password-stdin ghcr.io \ | ||||
|               ; echo "$DOCKER_PASSW2" |    docker login --username "$DOCKER_LOGIN2" --password-stdin \ | ||||
|               ; echo "$DOCKER_PASSW" |    docker login --username "$DOCKER_LOGIN" --password-stdin \ | ||||
|               ; /usr/bin/docker buildx rm builder-${{ env.BUILDER }} \ | ||||
|               || echo "builder does not exist" \ | ||||
|               ; /usr/bin/docker buildx create --name builder-${{ env.BUILDER }} --driver docker-container --use \ | ||||
| @@ -409,7 +405,7 @@ jobs: | ||||
|   tests: | ||||
|     name: tests | ||||
|     if: contains(inputs.architecture, 'linux/amd64') | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-24.04 | ||||
|     permissions: | ||||
|      contents: read | ||||
|      packages: read | ||||
| @@ -466,7 +462,7 @@ jobs: | ||||
|     name: Deploy images | ||||
|     # Deploying is not required for staging | ||||
|     if: inputs.deploy == 'true' | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-24.04 | ||||
|     needs: | ||||
|       - build | ||||
|       - build-arm | ||||
| @@ -535,7 +531,7 @@ jobs: | ||||
| #This job creates a tagged release. A tag is created for the pinned version x.y.z. The GH release refers to this tag. | ||||
|   tag-release: | ||||
|     if: inputs.release == 'true' | ||||
|     runs-on: ubuntu-latest | ||||
|     runs-on: ubuntu-24.04 | ||||
|     needs: | ||||
|       - deploy | ||||
|     steps: | ||||
|   | ||||
							
								
								
									
										23
									
								
								.github/workflows/multiarch.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										23
									
								
								.github/workflows/multiarch.yml
									
									
									
									
										vendored
									
									
								
							| @@ -4,6 +4,9 @@ on: | ||||
|     branches: | ||||
|       - master | ||||
|       - oidc | ||||
|   push: | ||||
|     branches: | ||||
|       - '2024.06' | ||||
|   merge_group: | ||||
|  | ||||
| concurrency: ci-multiarch-${{ github.ref }} | ||||
| @@ -83,29 +86,15 @@ jobs: | ||||
|       - derive-variables | ||||
|     uses: ./.github/workflows/build_test_deploy.yml | ||||
|     with: | ||||
|       # linux/arm64/v8,linux/arm/v7 will be added when GitHub hosted arm64 runners are available by the end of 2024 | ||||
|       # https://github.blog/news-insights/product-news/arm64-on-github-actions-powering-faster-more-efficient-build-systems/ | ||||
|       architecture: 'linux/amd64' | ||||
|       architecture: 'linux/amd64,linux/arm64/v8,linux/arm/v7' | ||||
|       mailu_version: ${{needs.derive-variables.outputs.MAILU_VERSION}} | ||||
|       pinned_mailu_version: ${{needs.derive-variables.outputs.PINNED_MAILU_VERSION}} | ||||
|       docker_org: ${{needs.derive-variables.outputs.DOCKER_ORG}} | ||||
|       branch: ${{needs.derive-variables.outputs.BRANCH}} | ||||
|       deploy: ${{needs.derive-variables.outputs.DEPLOY == 'true'}} | ||||
|       release: ${{needs.derive-variables.outputs.RELEASE == 'true'}} | ||||
|       deploy: ${{needs.derive-variables.outputs.DEPLOY}} | ||||
|       release: ${{needs.derive-variables.outputs.RELEASE}} | ||||
|     secrets: inherit | ||||
|  | ||||
|  # This job is watched by bors. It only complets if building,testing and deploy worked. | ||||
|   ci-success: | ||||
|     name: CI-Done | ||||
|     #Returns true when none of the **previous** steps have failed or have been canceled. | ||||
|     if: success() | ||||
|     needs: | ||||
|       - build-test-deploy | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: CI/CD succeeded. | ||||
|         run: exit 0 | ||||
|  | ||||
| ################################################ | ||||
| # Code block that is used as one liner for the step: | ||||
| # Derive PINNED_MAILU_VERSION and DEPLOY/RELEASE for normal release x.y | ||||
|   | ||||
| @@ -27,7 +27,7 @@ def api_token_authorization(func): | ||||
|             abort(401, 'A valid Authorization header is mandatory') | ||||
|         if len(v1.api_token) < 4 or not hmac.compare_digest(request.headers.get('Authorization').removeprefix('Bearer '), v1.api_token): | ||||
|             utils.limiter.rate_limit_ip(client_ip) | ||||
|             flask.current_app.logger.warn(f'Invalid API token provided by {client_ip}.') | ||||
|             flask.current_app.logger.warning(f'Invalid API token provided by {client_ip}.') | ||||
|             abort(403, 'Invalid API token') | ||||
|         flask.current_app.logger.info(f'Valid API token provided by {client_ip}.') | ||||
|         return func(*args, **kwds) | ||||
|   | ||||
| @@ -157,7 +157,7 @@ class ConfigManager: | ||||
|             self.config['RATELIMIT_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/2' | ||||
|  | ||||
|         self.config['SESSION_STORAGE_URL'] = f'redis://{self.config["REDIS_ADDRESS"]}/3' | ||||
|         self.config['SESSION_COOKIE_SAMESITE'] = 'Strict' | ||||
|         self.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # TODO: enhance security here | ||||
|         self.config['SESSION_COOKIE_HTTPONLY'] = True | ||||
|         if self.config['SESSION_COOKIE_SECURE'] is None: | ||||
|             self.config['SESSION_COOKIE_SECURE'] = self.config['TLS_FLAVOR'] != 'notls' | ||||
|   | ||||
| @@ -14,6 +14,7 @@ STATUSES = { | ||||
|         "imap": "AUTHENTICATIONFAILED", | ||||
|         "smtp": "535 5.7.8", | ||||
|         "submission": "535 5.7.8", | ||||
|         "lmtp": "535 5.7.8", | ||||
|         "pop3": "-ERR Authentication failed", | ||||
|         "sieve": "AuthFailed" | ||||
|     }), | ||||
| @@ -21,6 +22,7 @@ STATUSES = { | ||||
|         "imap": "PRIVACYREQUIRED", | ||||
|         "smtp": "530 5.7.0", | ||||
|         "submission": "530 5.7.0", | ||||
|         "lmtp": "530 5.7.0", | ||||
|         "pop3": "-ERR Authentication canceled.", | ||||
|         "sieve": "ENCRYPT-NEEDED" | ||||
|     }), | ||||
| @@ -28,6 +30,7 @@ STATUSES = { | ||||
|         "imap": "LIMIT", | ||||
|         "smtp": "451 4.3.2", | ||||
|         "submission": "451 4.3.2", | ||||
|         "lmtp": "451 4.3.2", | ||||
|         "pop3": "-ERR [LOGIN-DELAY] Retry later", | ||||
|         "sieve": "AuthFailed" | ||||
|     }), | ||||
| @@ -102,13 +105,13 @@ def handle_authentication(headers): | ||||
|             password = urllib.parse.unquote(headers["Auth-Pass"]) | ||||
|             ip = urllib.parse.unquote(headers["Client-Ip"]) | ||||
|         except: | ||||
|             app.logger.warn(f'Received undecodable user/password from front: {headers.get("Auth-User", "")!r}') | ||||
|             app.logger.warning(f'Received undecodable user/password from front: {headers.get("Auth-User", "")!r}') | ||||
|         else: | ||||
|             try: | ||||
|                 user = models.User.query.get(user_email) if '@' in user_email else None | ||||
|             except sqlalchemy.exc.StatementError as exc: | ||||
|                 exc = str(exc).split('\n', 1)[0] | ||||
|                 app.logger.warn(f'Invalid user {user_email!r}: {exc}') | ||||
|                 app.logger.warning(f'Invalid user {user_email!r}: {exc}') | ||||
|             else: | ||||
|                 is_valid_user = user is not None | ||||
|                 ip = urllib.parse.unquote(headers["Client-Ip"]) | ||||
|   | ||||
| @@ -103,7 +103,7 @@ def basic_authentication(): | ||||
|             user = models.User.query.get(user_email) if '@' in user_email else None | ||||
|         except sqlalchemy.exc.StatementError as exc: | ||||
|             exc = str(exc).split('\n', 1)[0] | ||||
|             app.logger.warn(f'Invalid user {user_email!r}: {exc}') | ||||
|             app.logger.warning(f'Invalid user {user_email!r}: {exc}') | ||||
|         else: | ||||
|             if user is not None and nginx.check_credentials(user, password.decode('utf-8'), client_ip, "web", flask.request.headers.get('X-Real-Port', None), user_email): | ||||
|                 response = flask.Response() | ||||
|   | ||||
| @@ -180,4 +180,4 @@ def autoconfig_apple(): | ||||
| <integer>1</integer> | ||||
| </dict> | ||||
| </plist>\r\n''' | ||||
|     return flask.Response(xml, mimetype='text/xml', status=200) | ||||
|     return flask.Response(xml, content_type='application/x-apple-aspen-config', status=200) | ||||
|   | ||||
| @@ -49,7 +49,7 @@ class LimitWraperFactory(object): | ||||
|         client_network = utils.extract_network_from_ip(ip) | ||||
|         is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(client_network) | ||||
|         if is_rate_limited: | ||||
|             app.logger.warn(f'Authentication attempt from {ip} has been rate-limited.') | ||||
|             app.logger.warning(f'Authentication attempt from {ip} has been rate-limited.') | ||||
|         return is_rate_limited | ||||
|  | ||||
|     def rate_limit_ip(self, ip, username=None): | ||||
| @@ -65,7 +65,7 @@ class LimitWraperFactory(object): | ||||
|         limiter = self.get_limiter(app.config["AUTH_RATELIMIT_USER"], 'auth-user') | ||||
|         is_rate_limited = self.is_subject_to_rate_limits(ip) and not limiter.test(device_cookie if device_cookie_name == username else username) | ||||
|         if is_rate_limited: | ||||
|             app.logger.warn(f'Authentication attempt from {ip} for {username} has been rate-limited.') | ||||
|             app.logger.warning(f'Authentication attempt from {ip} for {username} has been rate-limited.') | ||||
|         return is_rate_limited | ||||
|  | ||||
|     def rate_limit_user(self, username, ip, device_cookie=None, device_cookie_name=None, password=''): | ||||
| @@ -78,10 +78,10 @@ class LimitWraperFactory(object): | ||||
|             limiter.hit(device_cookie if device_cookie_name == username else username) | ||||
|             self.rate_limit_ip(ip, username) | ||||
|  | ||||
|     """ Device cookies as described on: | ||||
|     https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies | ||||
|     """ | ||||
|     def parse_device_cookie(self, cookie): | ||||
|     def parse_device_cookie(self, cookie: str): | ||||
|         """ Device cookies as described on: | ||||
|         https://owasp.org/www-community/Slow_Down_Online_Guessing_Attacks_with_Device_Cookies | ||||
|         """ | ||||
|         try: | ||||
|             login, nonce, _ = cookie.split('$') | ||||
|             if hmac.compare_digest(cookie, self.device_cookie(login, nonce)): | ||||
| @@ -90,11 +90,11 @@ class LimitWraperFactory(object): | ||||
|             pass | ||||
|         return None, None | ||||
|  | ||||
|     """ Device cookies don't require strong crypto: | ||||
|         72bits of nonce, 96bits of signature is more than enough | ||||
|         and these values avoid padding in most cases | ||||
|     """ | ||||
|     def device_cookie(self, username, nonce=None): | ||||
|         """ Device cookies don't require strong crypto: | ||||
|             72bits of nonce, 96bits of signature is more than enough | ||||
|             and these values avoid padding in most cases | ||||
|         """ | ||||
|         if not nonce: | ||||
|             nonce = secrets.token_urlsafe(9) | ||||
|         sig = str(base64.urlsafe_b64encode(hmac.new(app.device_cookie_key, bytearray(f'device_cookie|{username}|{nonce}', 'utf-8'), 'sha256').digest()[20:]), 'utf-8') | ||||
|   | ||||
| @@ -560,12 +560,12 @@ class User(Base, Email): | ||||
|     def is_authenticated(self): | ||||
|         if 'oidc_token' not in session: | ||||
|             return self._is_authenticated | ||||
|         token = utils.oic_client.check_validity(self.openid_token) | ||||
|         token = utils.oic_client.check_validity(self.oidc_token) | ||||
|         if token is None: | ||||
|             flask_login.logout_user() | ||||
|             session.destroy() | ||||
|             return False | ||||
|         session['openid_token'] = token | ||||
|         session['oidc_token'] = token | ||||
|         return True | ||||
|      | ||||
|     # [OIDC] is_authenticated property setter | ||||
| @@ -635,7 +635,7 @@ class User(Base, Email): | ||||
|             return False | ||||
|          | ||||
|         # [OIDC] Check if the user is authenticated with OIDC | ||||
|         if utils.oic_client.is_enabled() and 'openid_token' in session: | ||||
|         if utils.oic_client.is_enabled() and 'oidc_token' in session: | ||||
|             return self.is_authenticated() | ||||
|          | ||||
|         cache_result = self._credential_cache.get(self.get_id()) | ||||
|   | ||||
							
								
								
									
										277
									
								
								core/admin/mailu/oidc.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								core/admin/mailu/oidc.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,277 @@ | ||||
| """OIDC Client""" | ||||
|  | ||||
| from time import time | ||||
| from typing import Optional | ||||
| import flask | ||||
|  | ||||
| # [OIDC] Import the OIDC related modules | ||||
| from oic.oic import Client | ||||
| from oic.exception import PyoidcError | ||||
| from oic.extension.client import Client as ExtensionClient | ||||
| from oic.utils.authn.client import CLIENT_AUTHN_METHOD | ||||
| from oic.utils.settings import OicClientSettings | ||||
| from oic import rndstr | ||||
| from oic.exception import MessageException, NotForMe | ||||
| from oic.oauth2.message import ( | ||||
|     AccessTokenResponse, | ||||
|     ErrorResponse, | ||||
| ) | ||||
| from oic.oic.message import ( | ||||
|     AuthorizationResponse, | ||||
|     RegistrationResponse, | ||||
|     BackChannelLogoutRequest, | ||||
|     OpenIDSchema, | ||||
|     UserInfoErrorResponse, | ||||
| ) | ||||
| from oic.oauth2.grant import Token | ||||
|  | ||||
| # [OIDC] Client class | ||||
| class OicClient: | ||||
|     """OpenID Connect Client""" | ||||
|  | ||||
|     ready: bool = False | ||||
|     app: Optional[flask.Flask] = None | ||||
|     client: Optional[Client] = None | ||||
|     extension_client: Optional[ExtensionClient] = None | ||||
|     registration_response: Optional[RegistrationResponse] = None | ||||
|     enable_change_password_redirect: bool = True | ||||
|     change_password_url: Optional[str] = None | ||||
|     allowed_hostnames: list[str] = [] | ||||
|  | ||||
|     def receive_provider_info(self): | ||||
|         self.app.logger.info("[OIDC] Getting provider config..") | ||||
|         try: | ||||
|             self.client.provider_config(self.app.config["OIDC_PROVIDER_INFO_URL"]) | ||||
|             self.extension_client.provider_config(self.app.config["OIDC_PROVIDER_INFO_URL"]) | ||||
|  | ||||
|             self.change_password_url = self.app.config["OIDC_CHANGE_PASSWORD_REDIRECT_URL"] or ( | ||||
|             self.client.issuer + "/.well-known/change-password" | ||||
|             ) | ||||
|  | ||||
|             redirect_uris = [f"{hostname}/sso/login" for hostname in self.allowed_hostnames] | ||||
|      | ||||
|             client_reg = RegistrationResponse( | ||||
|                 client_id=self.app.config["OIDC_CLIENT_ID"], | ||||
|                 client_secret=self.app.config["OIDC_CLIENT_SECRET"], | ||||
|                 redirect_uris=redirect_uris, | ||||
|             ) | ||||
|  | ||||
|             self.client.store_registration_info(client_reg) | ||||
|             self.extension_client.store_registration_info(client_reg) | ||||
|  | ||||
|             self.ready = True | ||||
|         except Exception as e: | ||||
|             self.app.logger.warning(f"[OIDC] Error getting provider config: {e}") | ||||
|             self.app.logger.warning("[OIDC] Retrying with the next request..") | ||||
|         return self.ready | ||||
|  | ||||
|     def init_app(self, app: flask.Flask): | ||||
|         """Initialize OIDC client""" | ||||
|  | ||||
|         self.app = app | ||||
|         self.allowed_hostnames = [host.strip() for host in self.app.config['HOSTNAMES'].split(',')] | ||||
|  | ||||
|         settings = OicClientSettings(verify_ssl=app.config["OIDC_VERIFY_SSL"]) | ||||
|  | ||||
|         self.enable_change_password_redirect = ( | ||||
|             app.config["OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED"] or False | ||||
|         ) | ||||
|  | ||||
|         self.client = Client(client_authn_method=CLIENT_AUTHN_METHOD, settings=settings) | ||||
|         self.extension_client = ExtensionClient( | ||||
|             client_authn_method=CLIENT_AUTHN_METHOD, settings=settings | ||||
|         ) | ||||
|  | ||||
|     def get_redirect_url(self) -> Optional[str]: | ||||
|         """Get the redirect URL""" | ||||
|         if not self.is_enabled(): | ||||
|             return None | ||||
|          | ||||
|         if not self.ready and self.receive_provider_info() == False: | ||||
|             return None | ||||
|  | ||||
|         flask.session["state"] = rndstr() | ||||
|         flask.session["nonce"] = rndstr() | ||||
|  | ||||
|         redirect_uri = flask.request.host_url + "sso/login" | ||||
|  | ||||
|         if self.app.config["OIDC_REDIRECT_URL"]: | ||||
|             redirect_uri = self.app.config["OIDC_REDIRECT_URL"] | ||||
|         elif flask.request.host not in self.allowed_hostnames: | ||||
|             return None | ||||
|  | ||||
|         args = { | ||||
|             "client_id": self.client.client_id, | ||||
|             "response_type": ["code"], | ||||
|             "scope": ["openid", "email"], | ||||
|             "nonce": flask.session["nonce"], | ||||
|             "redirect_uri": redirect_uri, | ||||
|             "state": flask.session["state"], | ||||
|         } | ||||
|  | ||||
|         auth_req = self.client.construct_AuthorizationRequest(request_args=args) | ||||
|         login_url = auth_req.request(self.client.authorization_endpoint) | ||||
|         return login_url | ||||
|  | ||||
|     def _get_authorization_code(self, query: str) -> Optional[AuthorizationResponse]: | ||||
|         """ | ||||
|         Get the authorization response and check if it is valid | ||||
|  | ||||
|         :param query: The query string | ||||
|         :return: The authorization code if valid, None otherwise | ||||
|         """ | ||||
|  | ||||
|         auth_response = self.client.parse_response( | ||||
|             AuthorizationResponse, info=query, sformat="urlencoded" | ||||
|         ) | ||||
|  | ||||
|         if not isinstance(auth_response, AuthorizationResponse): | ||||
|             self.app.logger.debug(f"[OIDC] Error response in authorization: {auth_response}") | ||||
|             raise PyoidcError("Error response in authorization") | ||||
|  | ||||
|         if "state" not in flask.session: | ||||
|             self.app.logger.warning("[OIDC] No state in session") | ||||
|             raise PyoidcError("No state in session") | ||||
|  | ||||
|         if flask.session["state"] != auth_response["state"]: | ||||
|             self.app.logger.warning( | ||||
|                 f"[OIDC] State mismatch: expected {flask.session['state']}, got {auth_response['state']}" | ||||
|             ) | ||||
|             raise PyoidcError("State mismatch") | ||||
|  | ||||
|         return auth_response["code"] | ||||
|  | ||||
|     def _get_id_and_access_tokens(self, auth_response_code: str): | ||||
|         """ | ||||
|         Get the id and access tokens | ||||
|  | ||||
|         :param auth_response_code: The authorization response code | ||||
|         :return: The token response if valid, None otherwise | ||||
|         """ | ||||
|  | ||||
|         token_response = self.client.do_access_token_request( | ||||
|             state=flask.session["state"], | ||||
|             request_args={"code": auth_response_code}, | ||||
|             authn_method="client_secret_basic", | ||||
|         ) | ||||
|  | ||||
|         if not isinstance(token_response, AccessTokenResponse): | ||||
|             self.app.logger.warning( | ||||
|                 f"[OIDC] No access token or invalid response: {token_response}" | ||||
|             ) | ||||
|             raise PyoidcError("No access token or invalid response") | ||||
|  | ||||
|         if "id_token" not in token_response: | ||||
|             self.app.logger.warning("[OIDC] No id token in response") | ||||
|             raise PyoidcError("No id token in response") | ||||
|  | ||||
|         if token_response["id_token"]["nonce"] != flask.session["nonce"]: | ||||
|             self.app.logger.warning("[OIDC] Nonce mismatch") | ||||
|             raise PyoidcError("Nonce mismatch") | ||||
|  | ||||
|         if "access_token" not in token_response: | ||||
|             self.app.logger.warning("[OIDC] No access token or invalid response") | ||||
|             raise PyoidcError("No access token or invalid response") | ||||
|  | ||||
|         return token_response | ||||
|  | ||||
|     def exchange_code( | ||||
|         self, query: str | ||||
|     ) -> tuple[str, str, str, AccessTokenResponse] | tuple[None, None, None, None]: | ||||
|         """Exchange the code for the token""" | ||||
|  | ||||
|         if not self.ready and self.receive_provider_info() == False: | ||||
|             return None, None, None, None | ||||
|  | ||||
|         auth_response_code = self._get_authorization_code(query) | ||||
|         if not auth_response_code: | ||||
|             raise PyoidcError("Error response in authorization") | ||||
|  | ||||
|         token_response = self._get_id_and_access_tokens(auth_response_code) | ||||
|         if not token_response: | ||||
|             raise PyoidcError("Error response in token") | ||||
|  | ||||
|         user_info_response = self.get_user_info(token_response) | ||||
|         if not isinstance(user_info_response, OpenIDSchema): | ||||
|             self.app.logger.debug("[OIDC] Error response in user info") | ||||
|             raise PyoidcError("Error response in user info") | ||||
|  | ||||
|         return ( | ||||
|             user_info_response["email"], | ||||
|             user_info_response["sub"], | ||||
|             token_response["id_token"], | ||||
|             token_response, | ||||
|         ) | ||||
|  | ||||
|     def get_user_info( | ||||
|         self, token: AccessTokenResponse | ||||
|     ) -> OpenIDSchema | UserInfoErrorResponse | ErrorResponse: | ||||
|         """Get user info from the token""" | ||||
|         return self.client.do_user_info_request(token=token["access_token"]) | ||||
|  | ||||
|     def check_validity( | ||||
|         self, token: AccessTokenResponse | ||||
|     ) -> Optional[AccessTokenResponse]: | ||||
|         """Check if the token is still valid""" | ||||
|  | ||||
|         # Assume the token is valid if it has an expiration time and it has not expired | ||||
|         if "exp" in token["id_token"] and token["id_token"]["exp"] > time(): | ||||
|             return token | ||||
|         return self.refresh_token(token) | ||||
|  | ||||
|     def refresh_token( | ||||
|         self, token: AccessTokenResponse | ||||
|     ) -> Optional[AccessTokenResponse]: | ||||
|         """Refresh the token""" | ||||
|         try: | ||||
|             args = {"refresh_token": token["refresh_token"]} | ||||
|             response = self.client.do_access_token_refresh( | ||||
|                 request_args=args, token=Token(token) | ||||
|             ) | ||||
|             if isinstance(response, AccessTokenResponse): | ||||
|                 return response | ||||
|  | ||||
|         except Exception as e: | ||||
|             flask.current_app.logger.error(f"Error refreshing token: {e}") | ||||
|         return None | ||||
|  | ||||
|     def backchannel_logout(self, body): | ||||
|         """ | ||||
|         Backchannel logout. See https://openid.net/specs/openid-connect-backchannel-1_0.html | ||||
|         """ | ||||
|  | ||||
|         # TODO: Finish backchannel logout implementation | ||||
|         req = BackChannelLogoutRequest().from_dict(body) | ||||
|  | ||||
|         kwargs = { | ||||
|             "aud": self.client.client_id, | ||||
|             "iss": self.client.issuer, | ||||
|             "keyjar": self.client.keyjar, | ||||
|         } | ||||
|  | ||||
|         try: | ||||
|             req.verify(**kwargs) | ||||
|         except (MessageException, ValueError, NotForMe) as err: | ||||
|             self.app.logger.error(err) | ||||
|             return None | ||||
|  | ||||
|         sub = req["logout_token"]["sub"] | ||||
|  | ||||
|         if sub is not None and sub != "": | ||||
|             return sub | ||||
|  | ||||
|         return True | ||||
|  | ||||
|     def is_enabled(self): | ||||
|         """Check if OIDC is enabled""" | ||||
|  | ||||
|         if self.app is not None and self.app.config["OIDC_ENABLED"]: | ||||
|             return True | ||||
|         return False | ||||
|  | ||||
|     def change_password(self) -> Optional[str]: | ||||
|         """Get the URL for changing password if the redirect is enabled""" | ||||
|  | ||||
|         if self.enable_change_password_redirect: | ||||
|             return self.change_password_url | ||||
|         return None | ||||
| @@ -13,7 +13,7 @@ from urllib.parse import urlparse, urljoin, unquote | ||||
|  | ||||
| @sso.route('/login', methods=['GET', 'POST']) | ||||
| def login(): | ||||
|     if flask.request.headers.get(app.config['PROXY_AUTH_HEADER']) and not 'noproxyauth' in flask.request.url: | ||||
|     if flask.request.headers.get(app.config['PROXY_AUTH_HEADER']) and 'noproxyauth' not in flask.request.url: | ||||
|         return _proxy() | ||||
|  | ||||
|     client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr) | ||||
| @@ -31,34 +31,7 @@ def login(): | ||||
|  | ||||
|     fields = [] | ||||
|  | ||||
|     # [OIDC] Add the OIDC login flow | ||||
|     if 'code' in flask.request.args: | ||||
|         username, sub, id_token, token_response = utils.oic_client.exchange_code(flask.request.query_string.decode()) | ||||
|          | ||||
|         if username is None: | ||||
|             utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip) | ||||
|             flask.current_app.logger.warn(f'Login failed for {username} from {client_ip}.') | ||||
|             flask.flash('Wrong e-mail or password', 'error') | ||||
|             # TODO: Check if this is the correct way to handle this | ||||
|             return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url()) | ||||
|          | ||||
|         user = models.User.get(username) | ||||
|         if user is None: | ||||
|             user = models.User.create(username) | ||||
|          | ||||
|         flask.session['openid_token'] = token_response | ||||
|         flask.session['openid_sub'] = sub | ||||
|         flask.session['openid_id_token'] = id_token | ||||
|         flask.session.regenerate() | ||||
|  | ||||
|         flask_login.login_user(user) | ||||
|  | ||||
|         response = redirect(app.config['WEB_ADMIN']) | ||||
|         response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True) | ||||
|         flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') | ||||
|         return response | ||||
|  | ||||
|     if 'url' in flask.request.args and not 'homepage' in flask.request.url: | ||||
|     if 'url' in flask.request.args and 'homepage' not in flask.request.url: | ||||
|         fields.append(form.submitAdmin) | ||||
|     else: | ||||
|         form.submitAdmin.label.text = form.submitAdmin.label.text + ' Admin' | ||||
| @@ -69,6 +42,36 @@ def login(): | ||||
|             fields.append(form.submitAdmin) | ||||
|     fields = [fields] | ||||
|  | ||||
|     # [OIDC] Add the OIDC login flow | ||||
|     if 'code' in flask.request.args: | ||||
|         try: | ||||
|             username, sub, id_token, token_response = utils.oic_client.exchange_code(flask.request.query_string.decode()) | ||||
|          | ||||
|             if username is None: | ||||
|                 utils.limiter.rate_limit_user(username, client_ip, device_cookie, device_cookie_username) if models.User.get(username) else utils.limiter.rate_limit_ip(client_ip) | ||||
|                 flask.current_app.logger.warning(f'Login failed for {username} from {client_ip}.') | ||||
|                 flask.flash('Wrong e-mail or password', 'error') | ||||
|                 return render_oidc_template(form, fields) | ||||
|              | ||||
|             user = models.User.get(username) | ||||
|             if user is None: | ||||
|                 user = models.User.create(username) | ||||
|  | ||||
|             flask.session.regenerate() | ||||
|  | ||||
|             flask.session['oidc_token'] = token_response | ||||
|             flask.session['oidc_sub'] = sub | ||||
|             flask.session['oidc_id_token'] = id_token | ||||
|  | ||||
|             flask_login.login_user(user) | ||||
|  | ||||
|             response = redirect(flask.session['redirect_to'] if 'redirect_to' in flask.session else app.config['WEB_ADMIN']) | ||||
|             response.set_cookie('rate_limit', utils.limiter.device_cookie(username), max_age=31536000, path=flask.url_for('sso.login'), secure=app.config['SESSION_COOKIE_SECURE'], httponly=True) | ||||
|             flask.current_app.logger.info(f'Login succeeded for {username} from {client_ip}.') | ||||
|             return response | ||||
|         except Exception as e: | ||||
|             flask.flash(str(e), 'error') | ||||
|  | ||||
|     if form.validate_on_submit(): | ||||
|         if destination := _has_usable_redirect(): | ||||
|             pass | ||||
| @@ -82,10 +85,10 @@ def login(): | ||||
|         if not utils.is_app_token(form.pw.data): | ||||
|             if username != device_cookie_username and utils.limiter.should_rate_limit_ip(client_ip): | ||||
|                 flask.flash(_('Too many attempts from your IP (rate-limit)'), 'error') | ||||
|                 return flask.render_template('login.html', form=form, fields=fields) | ||||
|                 return render_oidc_template(form, fields) | ||||
|             if utils.limiter.should_rate_limit_user(username, client_ip, device_cookie, device_cookie_username): | ||||
|                 flask.flash(_('Too many attempts for this user (rate-limit)'), 'error') | ||||
|                 return flask.render_template('login.html', form=form, fields=fields) | ||||
|                 return render_oidc_template(form, fields) | ||||
|         user = models.User.login(username, form.pw.data) | ||||
|         if user: | ||||
|             flask.session.regenerate() | ||||
| @@ -104,7 +107,7 @@ def login(): | ||||
|             flask.current_app.logger.info(f'Login attempt for: {username}/sso/{flask.request.headers.get("X-Forwarded-Proto")} from: {client_ip}/{client_port}: failed: badauth: {utils.truncated_pw_hash(form.pw.data)}') | ||||
|             flask.flash(_('Wrong e-mail or password'), 'error') | ||||
|     # [OIDC] Forward the OIDC data to the login template | ||||
|     return flask.render_template('login.html', form=form, fields=fields, openId=app.config['OIDC_ENABLED'], openIdEndpoint=utils.oic_client.get_redirect_url()) | ||||
|     return render_oidc_template(form, fields) | ||||
|  | ||||
| @sso.route('/pw_change', methods=['GET', 'POST']) | ||||
| @access.authenticated | ||||
| @@ -132,11 +135,11 @@ def pw_change(): | ||||
|             user.change_pw_next_login = False | ||||
|             models.db.session.commit() | ||||
|             flask.current_app.logger.info(f'Forced password change by {user} from: {client_ip}/{client_port}: success: password: {form.pwned.data}') | ||||
|             destination = flask.session.pop('redir_to', None) or app.config['WEB_ADMIN'] | ||||
|             destination = flask.session.pop('redirect_to', None) or app.config['WEB_ADMIN'] | ||||
|             return flask.redirect(destination) | ||||
|         flask.flash(_("The current password is incorrect!"), "error") | ||||
|  | ||||
|     return flask.render_template('pw_change.html', form=form) | ||||
|     return flask.render_template('pw_change.html', form=form, fields=[]) | ||||
|  | ||||
| @sso.route('/logout', methods=['GET']) | ||||
| @access.authenticated | ||||
| @@ -218,3 +221,13 @@ def _proxy(): | ||||
|     user.send_welcome() | ||||
|     flask.current_app.logger.info(f'Login succeeded by proxy created user: {user} from {client_ip} through {flask.request.remote_addr}.') | ||||
|     return flask.redirect(url) | ||||
|  | ||||
| def render_oidc_template(form, fields): | ||||
|     redirect_url = utils.oic_client.get_redirect_url() | ||||
|     if 'url' in flask.request.args: | ||||
|         flask.session['redirect_to'] = flask.request.args.get('url') | ||||
|      | ||||
|     oidc_enabled = utils.oic_client.is_enabled() | ||||
|     if redirect_url is None: | ||||
|         oidc_enabled = False | ||||
|     return flask.render_template('login.html', form=form, fields=fields, openId=oidc_enabled, openIdEndpoint=redirect_url) | ||||
| @@ -90,7 +90,7 @@ def owner(args, kwargs, model, key): | ||||
| def authenticated(args, kwargs): | ||||
|     """ The view is only available to logged in users. | ||||
|     """ | ||||
|     return True | ||||
|     return flask_login.current_user.is_authenticated | ||||
|  | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -24,7 +24,7 @@ | ||||
|       <li class="nav-item" role="none"> | ||||
|         <a href="{{ oic_client.change_password() if current_user.is_oidc_user else url_for('.user_password_change') }}" class="nav-link" role="menuitem"> | ||||
|           <i class="nav-icon fa fa-lock"></i> | ||||
|           <p>{% trans %}Update password{% endtrans %}</p> | ||||
|           <p>{% trans %}Update password{% endtrans %}{% if current_user.is_oidc_user %} <i class="fas fa-external-link-alt text-xs"></i>{% endif %}</p> | ||||
|         </a> | ||||
|       </li> | ||||
|       {%- endif %} | ||||
|   | ||||
| @@ -1,10 +1,14 @@ | ||||
| """ Mailu admin app utilities | ||||
| """ | ||||
|  | ||||
| try: | ||||
|     import cPickle as pickle | ||||
| except ImportError: | ||||
|     import pickle | ||||
| from datetime import datetime, timedelta | ||||
| import hmac | ||||
| import ipaddress | ||||
| from multiprocessing import Value | ||||
| import pickle | ||||
| import secrets | ||||
| import string | ||||
| import time | ||||
|  | ||||
| import dns.resolver | ||||
| import dns.exception | ||||
| @@ -13,38 +17,20 @@ import dns.rdtypes | ||||
| import dns.rdatatype | ||||
| import dns.rdataclass | ||||
|  | ||||
| import hmac | ||||
| import secrets | ||||
| import string | ||||
| import time | ||||
|  | ||||
| from multiprocessing import Value | ||||
| from mailu import limiter | ||||
| from flask import current_app as app | ||||
|  | ||||
| import flask | ||||
| from flask import current_app as app | ||||
| from flask.sessions import SessionMixin, SessionInterface | ||||
| import flask_login | ||||
| import flask_migrate | ||||
| import flask_babel | ||||
| import ipaddress | ||||
|  | ||||
| import redis | ||||
|  | ||||
| from datetime import datetime, timedelta | ||||
| from flask.sessions import SessionMixin, SessionInterface | ||||
| from itsdangerous.encoding import want_bytes | ||||
| from werkzeug.datastructures import CallbackDict | ||||
| from werkzeug.middleware.proxy_fix import ProxyFix | ||||
|  | ||||
| # [OIDC] Import the OIDC related modules | ||||
| from oic.oic import Client | ||||
| from oic.extension.client import Client as ExtensionClient | ||||
| from oic.utils.authn.client import CLIENT_AUTHN_METHOD | ||||
| from oic.utils.settings import OicClientSettings | ||||
| from oic import rndstr | ||||
| from oic.exception import MessageException, NotForMe | ||||
| from oic.oauth2.message import ROPCAccessTokenRequest, AccessTokenResponse | ||||
| from oic.oic.message import AuthorizationResponse, RegistrationResponse, EndSessionRequest, BackChannelLogoutRequest | ||||
| from oic.oauth2.grant import Token | ||||
| from mailu import limiter, oidc | ||||
|  | ||||
| # Login configuration | ||||
| login = flask_login.LoginManager() | ||||
| @@ -137,157 +123,8 @@ class PrefixMiddleware(object): | ||||
|  | ||||
| proxy = PrefixMiddleware() | ||||
|  | ||||
| # [OIDC] Client class | ||||
| class OicClient: | ||||
|     "Redirects user to OpenID Provider if configured" | ||||
|  | ||||
|     def __init__(self): | ||||
|         self.app = None | ||||
|         self.client = None | ||||
|         self.extension_client = None | ||||
|         self.registration_response = None | ||||
|         self.change_password_redirect_enabled = True, | ||||
|         self.change_password_url = None | ||||
|  | ||||
|     def init_app(self, app): | ||||
|         self.app = app | ||||
|  | ||||
|         settings = OicClientSettings() | ||||
|              | ||||
|         settings.verify_ssl = app.config['OIDC_VERIFY_SSL'] | ||||
|  | ||||
|         self.change_password_redirect_enabled = app.config['OIDC_CHANGE_PASSWORD_REDIRECT_ENABLED'] | ||||
|  | ||||
|         self.client = Client(client_authn_method=CLIENT_AUTHN_METHOD,settings=settings) | ||||
|         self.client.provider_config(app.config['OIDC_PROVIDER_INFO_URL']) | ||||
|      | ||||
|         self.extension_client = ExtensionClient(client_authn_method=CLIENT_AUTHN_METHOD,settings=settings) | ||||
|         self.extension_client.provider_config(app.config['OIDC_PROVIDER_INFO_URL']) | ||||
|         self.change_password_url = app.config['OIDC_CHANGE_PASSWORD_REDIRECT_URL'] or (self.client.issuer + '/.well-known/change-password') | ||||
|         self.redirect_url = app.config['OIDC_REDIRECT_URL'] or ("https://" + self.app.config['HOSTNAME']) | ||||
|         info = {"client_id": app.config['OIDC_CLIENT_ID'], "client_secret": app.config['OIDC_CLIENT_SECRET'], "redirect_uris": [ self.redirect_url + "/sso/login" ]} | ||||
|         client_reg = RegistrationResponse(**info) | ||||
|         self.client.store_registration_info(client_reg) | ||||
|         self.extension_client.store_registration_info(client_reg) | ||||
|  | ||||
|     def get_redirect_url(self): | ||||
|         if not self.is_enabled(): | ||||
|             return None | ||||
|         flask.session["state"] = rndstr() | ||||
|         flask.session["nonce"] = rndstr() | ||||
|         args = { | ||||
|             "client_id": self.client.client_id, | ||||
|             "response_type": ["code"], | ||||
|             "scope": ["openid", "email"], | ||||
|             "nonce": flask.session["nonce"], | ||||
|             "redirect_uri": self.redirect_url + "/sso/login", | ||||
|             "state": flask.session["state"] | ||||
|         } | ||||
|  | ||||
|         auth_req = self.client.construct_AuthorizationRequest(request_args=args) | ||||
|         login_url = auth_req.request(self.client.authorization_endpoint) | ||||
|         return login_url | ||||
|  | ||||
|     def exchange_code(self, query): | ||||
|         aresp = self.client.parse_response(AuthorizationResponse, info=query, sformat="urlencoded") | ||||
|         if not ("state" in flask.session and aresp["state"] == flask.session["state"]): | ||||
|             return None, None, None, None | ||||
|         args = { | ||||
|             "code": aresp["code"] | ||||
|         } | ||||
|         response = self.client.do_access_token_request(state=aresp["state"], | ||||
|             request_args=args, | ||||
|             authn_method="client_secret_basic") | ||||
|          | ||||
|         if "id_token" not in response or response["id_token"]["nonce"] != flask.session["nonce"]: | ||||
|             return None, None, None, None | ||||
|         if 'access_token' not in response or not isinstance(response, AccessTokenResponse): | ||||
|             return None, None, None, None | ||||
|         user_response = self.client.do_user_info_request( | ||||
|             access_token=response['access_token']) | ||||
|         return user_response['email'], user_response['sub'], response["id_token"], response | ||||
|  | ||||
|  | ||||
|     def get_token(self, username, password): | ||||
|         args = { | ||||
|             "username": username, | ||||
|             "password": password, | ||||
|             "client_id": self.extension_client.client_id, | ||||
|             "client_secret": self.extension_client.client_secret, | ||||
|             "grant_type": "password" | ||||
|         } | ||||
|         url, body, ht_args, csi = self.extension_client.request_info(ROPCAccessTokenRequest, | ||||
|                 request_args=args, method="POST") | ||||
|         response = self.extension_client.request_and_return(url, AccessTokenResponse, "POST", body, "json", "", ht_args) | ||||
|         if isinstance(response, AccessTokenResponse): | ||||
|             return response | ||||
|         return None | ||||
|          | ||||
|  | ||||
|     def get_user_info(self, token): | ||||
|         return self.client.do_user_info_request( | ||||
|             access_token=token['access_token']) | ||||
|  | ||||
|     def check_validity(self, token): | ||||
|         if 'exp' in token['id_token'] and token['id_token']['exp'] > time.time(): | ||||
|             return token | ||||
|         else: | ||||
|             return self.refresh_token(token) | ||||
|      | ||||
|     def refresh_token(self, token): | ||||
|         try: | ||||
|             args = { | ||||
|                 "refresh_token": token['refresh_token'] | ||||
|             } | ||||
|             response = self.client.do_access_token_refresh(request_args=args, token=Token(token)) | ||||
|             if isinstance(response, AccessTokenResponse): | ||||
|                 return response | ||||
|             else: | ||||
|                 return None | ||||
|         except Exception as e: | ||||
|             print(e) | ||||
|             return None | ||||
|  | ||||
|     def logout(self, id_token): | ||||
|         state = rndstr() | ||||
|         flask.session['state'] = state | ||||
|  | ||||
|         args = { | ||||
|             "state": state, | ||||
|             "id_token_hint": id_token, | ||||
|             "post_logout_redirect_uri": self.redirect_url + "/sso/logout", | ||||
|             "client_id": self.client.client_id | ||||
|         } | ||||
|  | ||||
|         request = self.client.construct_EndSessionRequest(request_args=args) | ||||
|         uri, body, h_args, cis = self.client.uri_and_body(EndSessionRequest, method="GET", request_args=args, cis=request) | ||||
|         return uri | ||||
|      | ||||
|     def backchannel_logout(self, body): | ||||
|         req = BackChannelLogoutRequest().from_dict(body) | ||||
|  | ||||
|         kwargs = {"aud": self.client.client_id, "iss": self.client.issuer, "keyjar": self.client.keyjar} | ||||
|  | ||||
|         try: | ||||
|             req.verify(**kwargs) | ||||
|         except (MessageException, ValueError, NotForMe) as err: | ||||
|             self.app.logger.error(err) | ||||
|             return False | ||||
|  | ||||
|         sub = req["logout_token"]["sub"] | ||||
|  | ||||
|         if sub is not None and sub != '': | ||||
|             MailuSessionExtension.prune_sessions(None, None, self.app, sub) | ||||
|          | ||||
|         return True | ||||
|          | ||||
|     def is_enabled(self): | ||||
|         return self.app is not None and self.app.config['OIDC_ENABLED'] | ||||
|      | ||||
|     def change_password(self): | ||||
|         return self.change_password_url if self.change_password_redirect_enabled else None | ||||
|  | ||||
| oic_client = OicClient() | ||||
| # OIDC client | ||||
| oic_client = oidc.OicClient() | ||||
|  | ||||
| # Data migrate | ||||
| migrate = flask_migrate.Migrate() | ||||
| @@ -629,7 +466,7 @@ class MailuSessionExtension: | ||||
|             if key not in keep and not key.startswith(b'token-'): | ||||
|                 # [OIDC] Prune sessions by sub | ||||
|                 session = MailuSession(key, app) if sub is not None else None | ||||
|                 if sub is None or ('openid_sub' in session and session['openid_sub'] == sub): | ||||
|                 if sub is None or ('oidc_sub' in session and session['oidc_sub'] == sub): | ||||
|                     app.session_store.delete(key) | ||||
|                     count += 1 | ||||
|  | ||||
|   | ||||
| @@ -19,7 +19,7 @@ def resolve_hostname(hostname): | ||||
|     try: | ||||
|         return sorted(socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_PASSIVE), key=lambda s:s[0])[0][4][0] | ||||
|     except Exception as e: | ||||
|         log.warn("Unable to lookup '%s': %s",hostname,e) | ||||
|         log.warning("Unable to lookup '%s': %s",hostname,e) | ||||
|         raise e | ||||
|  | ||||
| def _coerce_value(value): | ||||
|   | ||||
| @@ -119,7 +119,7 @@ def test_managesieve(server, username, password): | ||||
|     except managesieve.MANAGESIEVE.abort: | ||||
|         pass | ||||
|  | ||||
|     m=managesieve.MANAGESIEVE(server, use_tls=True) | ||||
|     m=managesieve.MANAGESIEVE(server, use_tls=True, tls_verify=False) | ||||
|     if m.login('', username, 'wrongpass') != 'NO': | ||||
|         print(f'Authenticating to sieve://{username}:{password}@{server}:4190/ with wrong creds has worked!') | ||||
|         sys.exit(108) | ||||
|   | ||||
| @@ -1 +1 @@ | ||||
| X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* | ||||
| X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* | ||||
| @@ -1,4 +1,4 @@ | ||||
| docker==7.1.0 | ||||
| colorama==0.4.6 | ||||
| managesieve==0.7.1 | ||||
| managesieve==0.8 | ||||
| requests==2.32.3 | ||||
|   | ||||
							
								
								
									
										1
									
								
								towncrier/newsfragments/3690.bugfix
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								towncrier/newsfragments/3690.bugfix
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| Ensure the apple mobileconfig is served with the appropriate content-type | ||||
							
								
								
									
										1
									
								
								towncrier/newsfragments/3696.bugfix
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								towncrier/newsfragments/3696.bugfix
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| Include error messages for LMTP | ||||
							
								
								
									
										3
									
								
								towncrier/newsfragments/58.misc
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								towncrier/newsfragments/58.misc
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| Refactor OIDC client implementation | ||||
| Add lazy initialization for the OIDC provider info request (happens now with the first request to the admin panel) | ||||
| Fix access token parameter naming for `oic` library function; might be a solution for #50 | ||||
							
								
								
									
										1
									
								
								towncrier/newsfragments/59.bugfix
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								towncrier/newsfragments/59.bugfix
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| Fix #54 | ||||
		Reference in New Issue
	
	Block a user
	 Encotric
					Encotric