Compare commits
	
		
			22 Commits
		
	
	
		
			version/0.
			...
			version/0.
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4eaa46e717 | |||
| 59e8dca499 | |||
| 945d5bfaf6 | |||
| dbcdab05ff | |||
| e2cc2843d8 | |||
| 241d59be8d | |||
| 74251a8883 | |||
| 585afd1bcd | |||
| 8358574484 | |||
| cbcdaaf532 | |||
| f99eaa85ac | |||
| 5007a6befe | |||
| 50c75087b8 | |||
| 438e4efd49 | |||
| c7ca95ff2b | |||
| 9f403a71ed | |||
| 2f4139df65 | |||
| f3ee8f7d9c | |||
| 5fa3729702 | |||
| 87f44fada4 | |||
| c0026f3e16 | |||
| c1051059f4 | 
@ -1,5 +1,5 @@
 | 
				
			|||||||
[bumpversion]
 | 
					[bumpversion]
 | 
				
			||||||
current_version = 0.10.4-stable
 | 
					current_version = 0.10.6-stable
 | 
				
			||||||
tag = True
 | 
					tag = True
 | 
				
			||||||
commit = True
 | 
					commit = True
 | 
				
			||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
 | 
					parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							@ -18,11 +18,11 @@ jobs:
 | 
				
			|||||||
      - name: Building Docker Image
 | 
					      - name: Building Docker Image
 | 
				
			||||||
        run: docker build
 | 
					        run: docker build
 | 
				
			||||||
          --no-cache
 | 
					          --no-cache
 | 
				
			||||||
          -t beryju/passbook:0.10.4-stable
 | 
					          -t beryju/passbook:0.10.6-stable
 | 
				
			||||||
          -t beryju/passbook:latest
 | 
					          -t beryju/passbook:latest
 | 
				
			||||||
          -f Dockerfile .
 | 
					          -f Dockerfile .
 | 
				
			||||||
      - name: Push Docker Container to Registry (versioned)
 | 
					      - name: Push Docker Container to Registry (versioned)
 | 
				
			||||||
        run: docker push beryju/passbook:0.10.4-stable
 | 
					        run: docker push beryju/passbook:0.10.6-stable
 | 
				
			||||||
      - name: Push Docker Container to Registry (latest)
 | 
					      - name: Push Docker Container to Registry (latest)
 | 
				
			||||||
        run: docker push beryju/passbook:latest
 | 
					        run: docker push beryju/passbook:latest
 | 
				
			||||||
  build-proxy:
 | 
					  build-proxy:
 | 
				
			||||||
@ -48,11 +48,11 @@ jobs:
 | 
				
			|||||||
          cd proxy
 | 
					          cd proxy
 | 
				
			||||||
          docker build \
 | 
					          docker build \
 | 
				
			||||||
          --no-cache \
 | 
					          --no-cache \
 | 
				
			||||||
          -t beryju/passbook-proxy:0.10.4-stable \
 | 
					          -t beryju/passbook-proxy:0.10.6-stable \
 | 
				
			||||||
          -t beryju/passbook-proxy:latest \
 | 
					          -t beryju/passbook-proxy:latest \
 | 
				
			||||||
          -f Dockerfile .
 | 
					          -f Dockerfile .
 | 
				
			||||||
      - name: Push Docker Container to Registry (versioned)
 | 
					      - name: Push Docker Container to Registry (versioned)
 | 
				
			||||||
        run: docker push beryju/passbook-proxy:0.10.4-stable
 | 
					        run: docker push beryju/passbook-proxy:0.10.6-stable
 | 
				
			||||||
      - name: Push Docker Container to Registry (latest)
 | 
					      - name: Push Docker Container to Registry (latest)
 | 
				
			||||||
        run: docker push beryju/passbook-proxy:latest
 | 
					        run: docker push beryju/passbook-proxy:latest
 | 
				
			||||||
  build-static:
 | 
					  build-static:
 | 
				
			||||||
@ -77,11 +77,11 @@ jobs:
 | 
				
			|||||||
        run: docker build
 | 
					        run: docker build
 | 
				
			||||||
          --no-cache
 | 
					          --no-cache
 | 
				
			||||||
          --network=$(docker network ls | grep github | awk '{print $1}')
 | 
					          --network=$(docker network ls | grep github | awk '{print $1}')
 | 
				
			||||||
          -t beryju/passbook-static:0.10.4-stable
 | 
					          -t beryju/passbook-static:0.10.6-stable
 | 
				
			||||||
          -t beryju/passbook-static:latest
 | 
					          -t beryju/passbook-static:latest
 | 
				
			||||||
          -f static.Dockerfile .
 | 
					          -f static.Dockerfile .
 | 
				
			||||||
      - name: Push Docker Container to Registry (versioned)
 | 
					      - name: Push Docker Container to Registry (versioned)
 | 
				
			||||||
        run: docker push beryju/passbook-static:0.10.4-stable
 | 
					        run: docker push beryju/passbook-static:0.10.6-stable
 | 
				
			||||||
      - name: Push Docker Container to Registry (latest)
 | 
					      - name: Push Docker Container to Registry (latest)
 | 
				
			||||||
        run: docker push beryju/passbook-static:latest
 | 
					        run: docker push beryju/passbook-static:latest
 | 
				
			||||||
  test-release:
 | 
					  test-release:
 | 
				
			||||||
@ -114,5 +114,5 @@ jobs:
 | 
				
			|||||||
          SENTRY_PROJECT: passbook
 | 
					          SENTRY_PROJECT: passbook
 | 
				
			||||||
          SENTRY_URL: https://sentry.beryju.org
 | 
					          SENTRY_URL: https://sentry.beryju.org
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          tagName: 0.10.4-stable
 | 
					          tagName: 0.10.6-stable
 | 
				
			||||||
          environment: beryjuorg-prod
 | 
					          environment: beryjuorg-prod
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							@ -74,7 +74,8 @@
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
        "boto3": {
 | 
					        "boto3": {
 | 
				
			||||||
            "hashes": [
 | 
					            "hashes": [
 | 
				
			||||||
                "sha256:44073b1b1823ffc9edcf9027afbca908dad6bd5000f512ca73f929f6a604ae24"
 | 
					                "sha256:44073b1b1823ffc9edcf9027afbca908dad6bd5000f512ca73f929f6a604ae24",
 | 
				
			||||||
 | 
					                "sha256:888be45e289ba56c4e47cfae5d6b08f097bc981d077fbe6521a6d3dc7a4d757e"
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
            "index": "pypi",
 | 
					            "index": "pypi",
 | 
				
			||||||
            "version": "==1.15.1"
 | 
					            "version": "==1.15.1"
 | 
				
			||||||
@ -748,20 +749,25 @@
 | 
				
			|||||||
                "sha256:54bdedd28476dea8a3cd86cb67c0df1f0e3d71cae8022354b0f879c41a3d27b2",
 | 
					                "sha256:54bdedd28476dea8a3cd86cb67c0df1f0e3d71cae8022354b0f879c41a3d27b2",
 | 
				
			||||||
                "sha256:55eb61aca2c883db770999f50d091ff7c14016f2769ad7bca3d9b75d1d7c1b68",
 | 
					                "sha256:55eb61aca2c883db770999f50d091ff7c14016f2769ad7bca3d9b75d1d7c1b68",
 | 
				
			||||||
                "sha256:6276478ada411aca97c0d5104916354b3d740d368407912722bd4d11aa9ee4c2",
 | 
					                "sha256:6276478ada411aca97c0d5104916354b3d740d368407912722bd4d11aa9ee4c2",
 | 
				
			||||||
 | 
					                "sha256:663f8de2b3df2e744d6e1610506e0ea4e213bde906795953c1e82279c169f0a7",
 | 
				
			||||||
                "sha256:67dcad1b8b201308586a8ca2ffe89df1e4f731d5a4cdd0610cc4ea790351c739",
 | 
					                "sha256:67dcad1b8b201308586a8ca2ffe89df1e4f731d5a4cdd0610cc4ea790351c739",
 | 
				
			||||||
                "sha256:709b9f144d23e290b9863121d1ace14a72e01f66ea9c903fbdc690520dfdfcf0",
 | 
					                "sha256:709b9f144d23e290b9863121d1ace14a72e01f66ea9c903fbdc690520dfdfcf0",
 | 
				
			||||||
                "sha256:8063a712fba642f78d3c506b0896846601b6de7f5c3d534e388ad0cc07f5a149",
 | 
					                "sha256:8063a712fba642f78d3c506b0896846601b6de7f5c3d534e388ad0cc07f5a149",
 | 
				
			||||||
                "sha256:80d57177a0b7c14d4594c62bbb47fe2f6309ad3b0a34348a291d570925c97a82",
 | 
					                "sha256:80d57177a0b7c14d4594c62bbb47fe2f6309ad3b0a34348a291d570925c97a82",
 | 
				
			||||||
 | 
					                "sha256:87006cf0d81505408f1ae4f55cf8a5d95a8e029a4793360720ae17c6500f7ecc",
 | 
				
			||||||
 | 
					                "sha256:9f62d21bc693f3d7d444f17ed2ad7a913b4c37c15cd807895d013c39c0517dfd",
 | 
				
			||||||
                "sha256:a207231a52426de3ff20f5608f0687261a3329d97a036c51f7d4c606a6f30c23",
 | 
					                "sha256:a207231a52426de3ff20f5608f0687261a3329d97a036c51f7d4c606a6f30c23",
 | 
				
			||||||
                "sha256:abc2e126c9490e58a36a0f83516479e781d83adfb134576a5cbe5c6af2a3e93c",
 | 
					                "sha256:abc2e126c9490e58a36a0f83516479e781d83adfb134576a5cbe5c6af2a3e93c",
 | 
				
			||||||
                "sha256:b56638d58a3a4be13229c6a815cd448f9e3ce40c00880a5398471b42ee86f50e",
 | 
					                "sha256:b56638d58a3a4be13229c6a815cd448f9e3ce40c00880a5398471b42ee86f50e",
 | 
				
			||||||
                "sha256:bcd5b8416e73e4b0d48afba3704d8c826414764dafaed7a1a93c442188d90ccc",
 | 
					                "sha256:bcd5b8416e73e4b0d48afba3704d8c826414764dafaed7a1a93c442188d90ccc",
 | 
				
			||||||
                "sha256:bec2bcdf7c9ce7f04d718e51887f3b05dc5c1cfaf5d2c2e9065ecddd1b2f6c9a",
 | 
					                "sha256:bec2bcdf7c9ce7f04d718e51887f3b05dc5c1cfaf5d2c2e9065ecddd1b2f6c9a",
 | 
				
			||||||
                "sha256:c8bf40cf6e281a4378e25846924327e728a887e8bf0ee83b2604a0f4b61692e8",
 | 
					                "sha256:c8bf40cf6e281a4378e25846924327e728a887e8bf0ee83b2604a0f4b61692e8",
 | 
				
			||||||
 | 
					                "sha256:cecbf67e81d6144a50dc615629772859463b2e4f815d0c082fa421db362f040e",
 | 
				
			||||||
                "sha256:d8074c8448cfd0705dfa71ca333277fce9786d0b9cac75d120545de6253f996a",
 | 
					                "sha256:d8074c8448cfd0705dfa71ca333277fce9786d0b9cac75d120545de6253f996a",
 | 
				
			||||||
                "sha256:dd302b6ae3965afeb5ef1b0d92486f986c0e65183cd7835973f0b593800590e6",
 | 
					                "sha256:dd302b6ae3965afeb5ef1b0d92486f986c0e65183cd7835973f0b593800590e6",
 | 
				
			||||||
                "sha256:de6e1cd75677423ff64712c337521e62e3a7a4fc84caabbd93207752e831a85a",
 | 
					                "sha256:de6e1cd75677423ff64712c337521e62e3a7a4fc84caabbd93207752e831a85a",
 | 
				
			||||||
                "sha256:ef39c98d9b8c0736d91937d193653e47c3b19ddf4fc3bccdc5e09aaa4b0c5d21",
 | 
					                "sha256:ef39c98d9b8c0736d91937d193653e47c3b19ddf4fc3bccdc5e09aaa4b0c5d21",
 | 
				
			||||||
 | 
					                "sha256:f2e045224074d5664dc9cbabbf4f4d4d46f1ee90f24780e3a9a668fd096ff17f",
 | 
				
			||||||
                "sha256:f521178e5a991ffd04182ed08f552daca1affcb826aeda0e1945cd989a9d4345",
 | 
					                "sha256:f521178e5a991ffd04182ed08f552daca1affcb826aeda0e1945cd989a9d4345",
 | 
				
			||||||
                "sha256:f78a68c2c820e4731e510a2df3eef0322f24fde1781ced970bf497b6c7d92982",
 | 
					                "sha256:f78a68c2c820e4731e510a2df3eef0322f24fde1781ced970bf497b6c7d92982",
 | 
				
			||||||
                "sha256:fbe65d5cfe04ff2f7684160d50f5118bdefb01e3af4718eeb618bfed40f19d94"
 | 
					                "sha256:fbe65d5cfe04ff2f7684160d50f5118bdefb01e3af4718eeb618bfed40f19d94"
 | 
				
			||||||
@ -1316,11 +1322,11 @@
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
        "django-debug-toolbar": {
 | 
					        "django-debug-toolbar": {
 | 
				
			||||||
            "hashes": [
 | 
					            "hashes": [
 | 
				
			||||||
                "sha256:eabbefe89881bbe4ca7c980ff102e3c35c8e8ad6eb725041f538988f2f39a943",
 | 
					                "sha256:23129b4f771605c7ccf8733cc53558a68c5d463d60cdc83408d34b713acf4f5f",
 | 
				
			||||||
                "sha256:ff94725e7aae74b133d0599b9bf89bd4eb8f5d2c964106e61d11750228c8774c"
 | 
					                "sha256:7c9bf93eabb1e745fe1fca830242d49f3c839d35163e5b53914009ed111209b1"
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
            "index": "pypi",
 | 
					            "index": "pypi",
 | 
				
			||||||
            "version": "==2.2"
 | 
					            "version": "==3.0"
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "docker": {
 | 
					        "docker": {
 | 
				
			||||||
            "hashes": [
 | 
					            "hashes": [
 | 
				
			||||||
 | 
				
			|||||||
@ -8,6 +8,10 @@ variables:
 | 
				
			|||||||
  POSTGRES_DB: passbook
 | 
					  POSTGRES_DB: passbook
 | 
				
			||||||
  POSTGRES_USER: passbook
 | 
					  POSTGRES_USER: passbook
 | 
				
			||||||
  POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
 | 
					  POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
 | 
				
			||||||
 | 
					  ${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/') }}:
 | 
				
			||||||
 | 
					    branchName: ${{ replace(variables['Build.SourceBranchName'], 'refs/heads/', '') }}
 | 
				
			||||||
 | 
					  ${{ if startsWith(variables['Build.SourceBranch'], 'refs/pull/') }}:
 | 
				
			||||||
 | 
					    branchName: ${{ replace(variables['System.PullRequest.SourceBranch'], 'refs/heads/', '') }}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
stages:
 | 
					stages:
 | 
				
			||||||
  - stage: Lint
 | 
					  - stage: Lint
 | 
				
			||||||
@ -117,6 +121,41 @@ stages:
 | 
				
			|||||||
          - task: CmdLine@2
 | 
					          - task: CmdLine@2
 | 
				
			||||||
            inputs:
 | 
					            inputs:
 | 
				
			||||||
              script: pipenv run ./manage.py migrate
 | 
					              script: pipenv run ./manage.py migrate
 | 
				
			||||||
 | 
					      - job: migrations_from_previous_release
 | 
				
			||||||
 | 
					        pool:
 | 
				
			||||||
 | 
					          vmImage: 'ubuntu-latest'
 | 
				
			||||||
 | 
					        steps:
 | 
				
			||||||
 | 
					          - task: UsePythonVersion@0
 | 
				
			||||||
 | 
					            inputs:
 | 
				
			||||||
 | 
					              versionSpec: '3.8'
 | 
				
			||||||
 | 
					          - task: DockerCompose@0
 | 
				
			||||||
 | 
					            displayName: Run services
 | 
				
			||||||
 | 
					            inputs:
 | 
				
			||||||
 | 
					              dockerComposeFile: 'scripts/ci.docker-compose.yml'
 | 
				
			||||||
 | 
					              action: 'Run services'
 | 
				
			||||||
 | 
					              buildImages: false
 | 
				
			||||||
 | 
					          - task: CmdLine@2
 | 
				
			||||||
 | 
					            displayName: Prepare Last tagged release
 | 
				
			||||||
 | 
					            inputs:
 | 
				
			||||||
 | 
					              script: |
 | 
				
			||||||
 | 
					                git checkout $(git describe --abbrev=0 --match 'version/*')
 | 
				
			||||||
 | 
					                sudo pip install -U wheel pipenv
 | 
				
			||||||
 | 
					                pipenv install --dev
 | 
				
			||||||
 | 
					          - task: CmdLine@2
 | 
				
			||||||
 | 
					            displayName: Migrate to last tagged release
 | 
				
			||||||
 | 
					            inputs:
 | 
				
			||||||
 | 
					              script: pipenv run ./manage.py migrate
 | 
				
			||||||
 | 
					          - task: CmdLine@2
 | 
				
			||||||
 | 
					            displayName: Install current branch
 | 
				
			||||||
 | 
					            inputs:
 | 
				
			||||||
 | 
					              script: |
 | 
				
			||||||
 | 
					                set -x
 | 
				
			||||||
 | 
					                git checkout ${{ variables.branchName }}
 | 
				
			||||||
 | 
					                pipenv sync --dev
 | 
				
			||||||
 | 
					          - task: CmdLine@2
 | 
				
			||||||
 | 
					            displayName: Migrate to current branch
 | 
				
			||||||
 | 
					            inputs:
 | 
				
			||||||
 | 
					              script: pipenv run ./manage.py migrate
 | 
				
			||||||
      - job: coverage_unittest
 | 
					      - job: coverage_unittest
 | 
				
			||||||
        pool:
 | 
					        pool:
 | 
				
			||||||
          vmImage: 'ubuntu-latest'
 | 
					          vmImage: 'ubuntu-latest'
 | 
				
			||||||
@ -265,7 +304,7 @@ stages:
 | 
				
			|||||||
            repository: 'beryju/passbook'
 | 
					            repository: 'beryju/passbook'
 | 
				
			||||||
            command: 'buildAndPush'
 | 
					            command: 'buildAndPush'
 | 
				
			||||||
            Dockerfile: 'Dockerfile'
 | 
					            Dockerfile: 'Dockerfile'
 | 
				
			||||||
            tags: 'gh-$(Build.SourceBranchName)'
 | 
					            tags: "gh-${{ variables.branchName }}"
 | 
				
			||||||
      - job: build_static
 | 
					      - job: build_static
 | 
				
			||||||
        pool:
 | 
					        pool:
 | 
				
			||||||
          vmImage: 'ubuntu-latest'
 | 
					          vmImage: 'ubuntu-latest'
 | 
				
			||||||
@ -282,14 +321,14 @@ stages:
 | 
				
			|||||||
            repository: 'beryju/passbook-static'
 | 
					            repository: 'beryju/passbook-static'
 | 
				
			||||||
            command: 'build'
 | 
					            command: 'build'
 | 
				
			||||||
            Dockerfile: 'static.Dockerfile'
 | 
					            Dockerfile: 'static.Dockerfile'
 | 
				
			||||||
            tags: 'gh-$(Build.SourceBranchName)'
 | 
					            tags: "gh-${{ variables.branchName }}"
 | 
				
			||||||
            arguments: "--network=beryjupassbook_default"
 | 
					            arguments: "--network=beryjupassbook_default"
 | 
				
			||||||
        - task: Docker@2
 | 
					        - task: Docker@2
 | 
				
			||||||
          inputs:
 | 
					          inputs:
 | 
				
			||||||
            containerRegistry: 'dockerhub'
 | 
					            containerRegistry: 'dockerhub'
 | 
				
			||||||
            repository: 'beryju/passbook-static'
 | 
					            repository: 'beryju/passbook-static'
 | 
				
			||||||
            command: 'push'
 | 
					            command: 'push'
 | 
				
			||||||
            tags: 'gh-$(Build.SourceBranchName)'
 | 
					            tags: "gh-${{ variables.branchName }}"
 | 
				
			||||||
  - stage: Deploy
 | 
					  - stage: Deploy
 | 
				
			||||||
    jobs:
 | 
					    jobs:
 | 
				
			||||||
      - job: deploy_dev
 | 
					      - job: deploy_dev
 | 
				
			||||||
 | 
				
			|||||||
@ -23,7 +23,7 @@ services:
 | 
				
			|||||||
    labels:
 | 
					    labels:
 | 
				
			||||||
      - traefik.enable=false
 | 
					      - traefik.enable=false
 | 
				
			||||||
  server:
 | 
					  server:
 | 
				
			||||||
    image: beryju/passbook:${PASSBOOK_TAG:-0.10.4-stable}
 | 
					    image: beryju/passbook:${PASSBOOK_TAG:-0.10.6-stable}
 | 
				
			||||||
    command: server
 | 
					    command: server
 | 
				
			||||||
    environment:
 | 
					    environment:
 | 
				
			||||||
      PASSBOOK_REDIS__HOST: redis
 | 
					      PASSBOOK_REDIS__HOST: redis
 | 
				
			||||||
@ -41,7 +41,7 @@ services:
 | 
				
			|||||||
    env_file:
 | 
					    env_file:
 | 
				
			||||||
      - .env
 | 
					      - .env
 | 
				
			||||||
  worker:
 | 
					  worker:
 | 
				
			||||||
    image: beryju/passbook:${PASSBOOK_TAG:-0.10.4-stable}
 | 
					    image: beryju/passbook:${PASSBOOK_TAG:-0.10.6-stable}
 | 
				
			||||||
    command: worker
 | 
					    command: worker
 | 
				
			||||||
    networks:
 | 
					    networks:
 | 
				
			||||||
      - internal
 | 
					      - internal
 | 
				
			||||||
@ -55,7 +55,7 @@ services:
 | 
				
			|||||||
    env_file:
 | 
					    env_file:
 | 
				
			||||||
      - .env
 | 
					      - .env
 | 
				
			||||||
  static:
 | 
					  static:
 | 
				
			||||||
    image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.4-stable}
 | 
					    image: beryju/passbook-static:${PASSBOOK_TAG:-0.10.6-stable}
 | 
				
			||||||
    networks:
 | 
					    networks:
 | 
				
			||||||
      - internal
 | 
					      - internal
 | 
				
			||||||
    labels:
 | 
					    labels:
 | 
				
			||||||
 | 
				
			|||||||
@ -84,15 +84,6 @@
 | 
				
			|||||||
            }
 | 
					            }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "identifiers": {
 | 
					 | 
				
			||||||
                "pk": "9922212c-47a2-475a-9905-abeb5e621652"
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            "model": "passbook_policies_expression.expressionpolicy",
 | 
					 | 
				
			||||||
            "attrs": {
 | 
					 | 
				
			||||||
                "name": "policy-enrollment-password-equals",
 | 
					 | 
				
			||||||
                "expression": "# Verifies that the passwords are equal\r\nreturn request.context['password'] == request.context['password_repeat']"
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        },{
 | 
					 | 
				
			||||||
            "identifiers": {
 | 
					            "identifiers": {
 | 
				
			||||||
                "pk": "096e6282-6b30-4695-bd03-3b143eab5580",
 | 
					                "pk": "096e6282-6b30-4695-bd03-3b143eab5580",
 | 
				
			||||||
                "name": "default-enrollment-email-verficiation"
 | 
					                "name": "default-enrollment-email-verficiation"
 | 
				
			||||||
@ -135,9 +126,6 @@
 | 
				
			|||||||
                    "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4",
 | 
					                    "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4",
 | 
				
			||||||
                    "7db91ee8-4290-4e08-8d39-63f132402515",
 | 
					                    "7db91ee8-4290-4e08-8d39-63f132402515",
 | 
				
			||||||
                    "d30b5eb4-7787-4072-b1ba-65b46e928920"
 | 
					                    "d30b5eb4-7787-4072-b1ba-65b46e928920"
 | 
				
			||||||
                ],
 | 
					 | 
				
			||||||
                "validation_policies": [
 | 
					 | 
				
			||||||
                    "9922212c-47a2-475a-9905-abeb5e621652"
 | 
					 | 
				
			||||||
                ]
 | 
					                ]
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
				
			|||||||
@ -55,16 +55,6 @@
 | 
				
			|||||||
                "order": 1
 | 
					                "order": 1
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "identifiers": {
 | 
					 | 
				
			||||||
                "pk": "cd042fc6-cc92-4b98-b7e6-f4729df798d8"
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            "model": "passbook_policies_expression.expressionpolicy",
 | 
					 | 
				
			||||||
            "attrs": {
 | 
					 | 
				
			||||||
                "name": "default-password-change-password-equal",
 | 
					 | 
				
			||||||
                "expression": "# Check that both passwords are equal.\nreturn request.context['password'] == request.context['password_repeat']"
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            "identifiers": {
 | 
					            "identifiers": {
 | 
				
			||||||
                "pk": "e54045a7-6ecb-4ad9-ad37-28e72d8e565e",
 | 
					                "pk": "e54045a7-6ecb-4ad9-ad37-28e72d8e565e",
 | 
				
			||||||
@ -118,9 +108,6 @@
 | 
				
			|||||||
                "fields": [
 | 
					                "fields": [
 | 
				
			||||||
                    "7db91ee8-4290-4e08-8d39-63f132402515",
 | 
					                    "7db91ee8-4290-4e08-8d39-63f132402515",
 | 
				
			||||||
                    "d30b5eb4-7787-4072-b1ba-65b46e928920"
 | 
					                    "d30b5eb4-7787-4072-b1ba-65b46e928920"
 | 
				
			||||||
                ],
 | 
					 | 
				
			||||||
                "validation_policies": [
 | 
					 | 
				
			||||||
                    "cd042fc6-cc92-4b98-b7e6-f4729df798d8"
 | 
					 | 
				
			||||||
                ]
 | 
					                ]
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
 | 
				
			|||||||
@ -13,7 +13,7 @@ Download the latest `docker-compose.yml` from [here](https://raw.githubuserconte
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=true >> .env`
 | 
					To optionally enable error-reporting, run `echo PASSBOOK_ERROR_REPORTING__ENABLED=true >> .env`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.4-stable >> .env`
 | 
					To optionally deploy a different version run `echo PASSBOOK_TAG=0.10.6-stable >> .env`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
If this is a fresh passbook install run the following commands to generate a password:
 | 
					If this is a fresh passbook install run the following commands to generate a password:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -39,4 +39,6 @@ Now you can pull the Docker images needed by running `docker-compose pull`. Afte
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
passbook will then be reachable via HTTP on port 80, and HTTPS on port 443. You can optionally configure the packaged traefik to use Let's Encrypt certificates for TLS Encryption.
 | 
					passbook will then be reachable via HTTP on port 80, and HTTPS on port 443. You can optionally configure the packaged traefik to use Let's Encrypt certificates for TLS Encryption.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					If you plan to access passbook via a reverse proxy which does SSL Termination, make sure you use the HTTPS port, so passbook is aware of the SSL connection.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The initial setup process also creates a default admin user, the username and password for which is `pbadmin`. It is highly recommended to change this password as soon as you log in.
 | 
					The initial setup process also creates a default admin user, the username and password for which is `pbadmin`. It is highly recommended to change this password as soon as you log in.
 | 
				
			||||||
 | 
				
			|||||||
@ -11,7 +11,7 @@ This installation automatically applies database migrations on startup. After th
 | 
				
			|||||||
image:
 | 
					image:
 | 
				
			||||||
  name: beryju/passbook
 | 
					  name: beryju/passbook
 | 
				
			||||||
  name_static: beryju/passbook-static
 | 
					  name_static: beryju/passbook-static
 | 
				
			||||||
  tag: 0.10.4-stable
 | 
					  tag: 0.10.6-stable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
nameOverride: ""
 | 
					nameOverride: ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -10,7 +10,6 @@ from selenium.webdriver.support import expected_conditions as ec
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
from e2e.utils import USER, SeleniumTestCase
 | 
					from e2e.utils import USER, SeleniumTestCase
 | 
				
			||||||
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
 | 
					from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
 | 
				
			||||||
from passbook.policies.expression.models import ExpressionPolicy
 | 
					 | 
				
			||||||
from passbook.stages.email.models import EmailStage, EmailTemplates
 | 
					from passbook.stages.email.models import EmailStage, EmailTemplates
 | 
				
			||||||
from passbook.stages.identification.models import IdentificationStage
 | 
					from passbook.stages.identification.models import IdentificationStage
 | 
				
			||||||
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
 | 
					from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
 | 
				
			||||||
@ -59,16 +58,9 @@ class TestFlowsEnroll(SeleniumTestCase):
 | 
				
			|||||||
            field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
 | 
					            field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Password checking policy
 | 
					 | 
				
			||||||
        password_policy = ExpressionPolicy.objects.create(
 | 
					 | 
				
			||||||
            name="policy-enrollment-password-equals",
 | 
					 | 
				
			||||||
            expression="return request.context['password'] == request.context['password_repeat']",
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Stages
 | 
					        # Stages
 | 
				
			||||||
        first_stage = PromptStage.objects.create(name="prompt-stage-first")
 | 
					        first_stage = PromptStage.objects.create(name="prompt-stage-first")
 | 
				
			||||||
        first_stage.fields.set([username_prompt, password, password_repeat])
 | 
					        first_stage.fields.set([username_prompt, password, password_repeat])
 | 
				
			||||||
        first_stage.validation_policies.set([password_policy])
 | 
					 | 
				
			||||||
        first_stage.save()
 | 
					        first_stage.save()
 | 
				
			||||||
        second_stage = PromptStage.objects.create(name="prompt-stage-second")
 | 
					        second_stage = PromptStage.objects.create(name="prompt-stage-second")
 | 
				
			||||||
        second_stage.fields.set([name_field, email])
 | 
					        second_stage.fields.set([name_field, email])
 | 
				
			||||||
@ -152,16 +144,9 @@ class TestFlowsEnroll(SeleniumTestCase):
 | 
				
			|||||||
            field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
 | 
					            field_key="email", label="E-Mail", order=1, type=FieldTypes.EMAIL
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Password checking policy
 | 
					 | 
				
			||||||
        password_policy = ExpressionPolicy.objects.create(
 | 
					 | 
				
			||||||
            name="policy-enrollment-password-equals",
 | 
					 | 
				
			||||||
            expression="return request.context['password'] == request.context['password_repeat']",
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Stages
 | 
					        # Stages
 | 
				
			||||||
        first_stage = PromptStage.objects.create(name="prompt-stage-first")
 | 
					        first_stage = PromptStage.objects.create(name="prompt-stage-first")
 | 
				
			||||||
        first_stage.fields.set([username_prompt, password, password_repeat])
 | 
					        first_stage.fields.set([username_prompt, password, password_repeat])
 | 
				
			||||||
        first_stage.validation_policies.set([password_policy])
 | 
					 | 
				
			||||||
        first_stage.save()
 | 
					        first_stage.save()
 | 
				
			||||||
        second_stage = PromptStage.objects.create(name="prompt-stage-second")
 | 
					        second_stage = PromptStage.objects.create(name="prompt-stage-second")
 | 
				
			||||||
        second_stage.fields.set([name_field, email])
 | 
					        second_stage.fields.set([name_field, email])
 | 
				
			||||||
 | 
				
			|||||||
@ -1,8 +1,8 @@
 | 
				
			|||||||
apiVersion: v2
 | 
					apiVersion: v2
 | 
				
			||||||
appVersion: "0.10.4-stable"
 | 
					appVersion: "0.10.6-stable"
 | 
				
			||||||
description: A Helm chart for passbook.
 | 
					description: A Helm chart for passbook.
 | 
				
			||||||
name: passbook
 | 
					name: passbook
 | 
				
			||||||
version: "0.10.4-stable"
 | 
					version: "0.10.6-stable"
 | 
				
			||||||
icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
 | 
					icon: https://github.com/BeryJu/passbook/blob/master/docs/images/logo.svg
 | 
				
			||||||
dependencies:
 | 
					dependencies:
 | 
				
			||||||
  - name: postgresql
 | 
					  - name: postgresql
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,7 @@
 | 
				
			|||||||
image:
 | 
					image:
 | 
				
			||||||
  name: beryju/passbook
 | 
					  name: beryju/passbook
 | 
				
			||||||
  name_static: beryju/passbook-static
 | 
					  name_static: beryju/passbook-static
 | 
				
			||||||
  tag: 0.10.4-stable
 | 
					  tag: 0.10.6-stable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
nameOverride: ""
 | 
					nameOverride: ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,16 +1,28 @@
 | 
				
			|||||||
#!/usr/bin/env python
 | 
					#!/usr/bin/env python
 | 
				
			||||||
"""This file needs to be run from the root of the project to correctly
 | 
					"""This file needs to be run from the root of the project to correctly
 | 
				
			||||||
import passbook. This is done by the dockerfile."""
 | 
					import passbook. This is done by the dockerfile."""
 | 
				
			||||||
 | 
					from json import dumps
 | 
				
			||||||
 | 
					from sys import stderr
 | 
				
			||||||
from time import sleep
 | 
					from time import sleep
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from psycopg2 import OperationalError, connect
 | 
					from psycopg2 import OperationalError, connect
 | 
				
			||||||
from redis import Redis
 | 
					from redis import Redis
 | 
				
			||||||
from redis.exceptions import RedisError
 | 
					from redis.exceptions import RedisError
 | 
				
			||||||
from structlog import get_logger
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.lib.config import CONFIG
 | 
					from passbook.lib.config import CONFIG
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					
 | 
				
			||||||
 | 
					def j_print(event: str, log_level: str = "info", **kwargs):
 | 
				
			||||||
 | 
					    """Print event in the same format as structlog with JSON.
 | 
				
			||||||
 | 
					    Used before structlog is configured."""
 | 
				
			||||||
 | 
					    data = {
 | 
				
			||||||
 | 
					        "event": event,
 | 
				
			||||||
 | 
					        "level": log_level,
 | 
				
			||||||
 | 
					        "logger": __name__,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    data.update(**kwargs)
 | 
				
			||||||
 | 
					    print(dumps(data), file=stderr)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
while True:
 | 
					while True:
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
@ -24,7 +36,7 @@ while True:
 | 
				
			|||||||
        break
 | 
					        break
 | 
				
			||||||
    except OperationalError:
 | 
					    except OperationalError:
 | 
				
			||||||
        sleep(1)
 | 
					        sleep(1)
 | 
				
			||||||
        LOGGER.warning("PostgreSQL Connection failed, retrying...")
 | 
					        j_print("PostgreSQL Connection failed, retrying...")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
while True:
 | 
					while True:
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
@ -38,4 +50,4 @@ while True:
 | 
				
			|||||||
        break
 | 
					        break
 | 
				
			||||||
    except RedisError:
 | 
					    except RedisError:
 | 
				
			||||||
        sleep(1)
 | 
					        sleep(1)
 | 
				
			||||||
        LOGGER.warning("Redis Connection failed, retrying...")
 | 
					        j_print("Redis Connection failed, retrying...")
 | 
				
			||||||
 | 
				
			|||||||
@ -1,2 +1,2 @@
 | 
				
			|||||||
"""passbook"""
 | 
					"""passbook"""
 | 
				
			||||||
__version__ = "0.10.4-stable"
 | 
					__version__ = "0.10.6-stable"
 | 
				
			||||||
 | 
				
			|||||||
@ -5,18 +5,6 @@
 | 
				
			|||||||
{% load passbook_utils %}
 | 
					{% load passbook_utils %}
 | 
				
			||||||
{% load admin_reflection %}
 | 
					{% load admin_reflection %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block head %}
 | 
					 | 
				
			||||||
{{ block.super }}
 | 
					 | 
				
			||||||
<style>
 | 
					 | 
				
			||||||
.pf-m-success {
 | 
					 | 
				
			||||||
    color: var(--pf-global--success-color--100);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.pf-m-danger {
 | 
					 | 
				
			||||||
    color: var(--pf-global--danger-color--100);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
</style>
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block content %}
 | 
					{% block content %}
 | 
				
			||||||
<section class="pf-c-page__main-section pf-m-light">
 | 
					<section class="pf-c-page__main-section pf-m-light">
 | 
				
			||||||
    <div class="pf-c-content">
 | 
					    <div class="pf-c-content">
 | 
				
			||||||
 | 
				
			|||||||
@ -9,11 +9,12 @@ from django.urls import reverse_lazy
 | 
				
			|||||||
from django.utils.translation import gettext as _
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
from django.views.generic import ListView, UpdateView
 | 
					from django.views.generic import ListView, UpdateView
 | 
				
			||||||
from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
 | 
					from guardian.mixins import PermissionListMixin, PermissionRequiredMixin
 | 
				
			||||||
 | 
					from guardian.shortcuts import get_objects_for_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.admin.views.utils import DeleteMessageView
 | 
					from passbook.admin.views.utils import DeleteMessageView
 | 
				
			||||||
from passbook.lib.views import CreateAssignPermView
 | 
					from passbook.lib.views import CreateAssignPermView
 | 
				
			||||||
from passbook.policies.forms import PolicyBindingForm
 | 
					from passbook.policies.forms import PolicyBindingForm
 | 
				
			||||||
from passbook.policies.models import PolicyBinding, PolicyBindingModel
 | 
					from passbook.policies.models import PolicyBinding
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
 | 
					class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
 | 
				
			||||||
@ -29,13 +30,18 @@ class PolicyBindingListView(LoginRequiredMixin, PermissionListMixin, ListView):
 | 
				
			|||||||
        # Since `select_subclasses` does not work with a foreign key, we have to do two queries here
 | 
					        # Since `select_subclasses` does not work with a foreign key, we have to do two queries here
 | 
				
			||||||
        # First, get all pbm objects that have bindings attached
 | 
					        # First, get all pbm objects that have bindings attached
 | 
				
			||||||
        objects = (
 | 
					        objects = (
 | 
				
			||||||
            PolicyBindingModel.objects.filter(policies__isnull=False)
 | 
					            get_objects_for_user(
 | 
				
			||||||
 | 
					                self.request.user, "passbook_policies.view_policybindingmodel"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .filter(policies__isnull=False)
 | 
				
			||||||
            .select_subclasses()
 | 
					            .select_subclasses()
 | 
				
			||||||
            .select_related()
 | 
					            .select_related()
 | 
				
			||||||
            .order_by("pk")
 | 
					            .order_by("pk")
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        for pbm in objects:
 | 
					        for pbm in objects:
 | 
				
			||||||
            pbm.bindings = PolicyBinding.objects.filter(target__pk=pbm.pbm_uuid)
 | 
					            pbm.bindings = get_objects_for_user(
 | 
				
			||||||
 | 
					                self.request.user, self.permission_required
 | 
				
			||||||
 | 
					            ).filter(target__pk=pbm.pbm_uuid)
 | 
				
			||||||
        return objects
 | 
					        return objects
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										87
									
								
								passbook/audit/middleware.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								passbook/audit/middleware.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,87 @@
 | 
				
			|||||||
 | 
					"""Audit middleware"""
 | 
				
			||||||
 | 
					from functools import partial
 | 
				
			||||||
 | 
					from typing import Callable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.contrib.auth.models import User
 | 
				
			||||||
 | 
					from django.db.models import Model
 | 
				
			||||||
 | 
					from django.db.models.signals import post_save, pre_delete
 | 
				
			||||||
 | 
					from django.http import HttpRequest, HttpResponse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from passbook.audit.models import Event, EventAction, model_to_dict
 | 
				
			||||||
 | 
					from passbook.audit.signals import EventNewThread
 | 
				
			||||||
 | 
					from passbook.core.middleware import LOCAL
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class AuditMiddleware:
 | 
				
			||||||
 | 
					    """Register handlers for duration of request-response that log creation/update/deletion
 | 
				
			||||||
 | 
					    of models"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    get_response: Callable[[HttpRequest], HttpResponse]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
 | 
				
			||||||
 | 
					        self.get_response = get_response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, request: HttpRequest) -> HttpResponse:
 | 
				
			||||||
 | 
					        # Connect signal for automatic logging
 | 
				
			||||||
 | 
					        if hasattr(request, "user") and getattr(
 | 
				
			||||||
 | 
					            request.user, "is_authenticated", False
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            post_save_handler = partial(
 | 
				
			||||||
 | 
					                self.post_save_handler, user=request.user, request=request
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            pre_delete_handler = partial(
 | 
				
			||||||
 | 
					                self.pre_delete_handler, user=request.user, request=request
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            post_save.connect(
 | 
				
			||||||
 | 
					                post_save_handler,
 | 
				
			||||||
 | 
					                dispatch_uid=LOCAL.passbook["request_id"],
 | 
				
			||||||
 | 
					                weak=False,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            pre_delete.connect(
 | 
				
			||||||
 | 
					                pre_delete_handler,
 | 
				
			||||||
 | 
					                dispatch_uid=LOCAL.passbook["request_id"],
 | 
				
			||||||
 | 
					                weak=False,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        response = self.get_response(request)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
 | 
				
			||||||
 | 
					        pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def process_exception(self, request: HttpRequest, exception: Exception):
 | 
				
			||||||
 | 
					        """Unregister handlers in case of exception"""
 | 
				
			||||||
 | 
					        post_save.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
 | 
				
			||||||
 | 
					        pre_delete.disconnect(dispatch_uid=LOCAL.passbook["request_id"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def post_save_handler(
 | 
				
			||||||
 | 
					        user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """Signal handler for all object's post_save"""
 | 
				
			||||||
 | 
					        if isinstance(instance, Event):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
 | 
				
			||||||
 | 
					        EventNewThread(
 | 
				
			||||||
 | 
					            action, request, user=user, kwargs={"model": model_to_dict(instance)}
 | 
				
			||||||
 | 
					        ).run()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @staticmethod
 | 
				
			||||||
 | 
					    # pylint: disable=unused-argument
 | 
				
			||||||
 | 
					    def pre_delete_handler(
 | 
				
			||||||
 | 
					        user: User, request: HttpRequest, sender, instance: Model, **_
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """Signal handler for all object's pre_delete"""
 | 
				
			||||||
 | 
					        if isinstance(instance, Event):
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        EventNewThread(
 | 
				
			||||||
 | 
					            EventAction.MODEL_DELETED,
 | 
				
			||||||
 | 
					            request,
 | 
				
			||||||
 | 
					            user=user,
 | 
				
			||||||
 | 
					            kwargs={"model": model_to_dict(instance)},
 | 
				
			||||||
 | 
					        ).run()
 | 
				
			||||||
							
								
								
									
										59
									
								
								passbook/audit/migrations/0003_auto_20200917_1155.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								passbook/audit/migrations/0003_auto_20200917_1155.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,59 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.1.1 on 2020-09-17 11:55
 | 
				
			||||||
 | 
					from django.apps.registry import Apps
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import passbook.audit.models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			||||||
 | 
					    Event = apps.get_model("passbook_audit", "Event")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    db_alias = schema_editor.connection.alias
 | 
				
			||||||
 | 
					    for event in Event.objects.all():
 | 
				
			||||||
 | 
					        event.delete()
 | 
				
			||||||
 | 
					        # Because event objects cannot be updated, we have to re-create them
 | 
				
			||||||
 | 
					        event.pk = None
 | 
				
			||||||
 | 
					        event.user_json = (
 | 
				
			||||||
 | 
					            passbook.audit.models.get_user(event.user) if event.user else {}
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        event._state.adding = True
 | 
				
			||||||
 | 
					        event.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("passbook_audit", "0002_auto_20200918_2116"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="event",
 | 
				
			||||||
 | 
					            name="action",
 | 
				
			||||||
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                choices=[
 | 
				
			||||||
 | 
					                    ("LOGIN", "login"),
 | 
				
			||||||
 | 
					                    ("LOGIN_FAILED", "login_failed"),
 | 
				
			||||||
 | 
					                    ("LOGOUT", "logout"),
 | 
				
			||||||
 | 
					                    ("AUTHORIZE_APPLICATION", "authorize_application"),
 | 
				
			||||||
 | 
					                    ("SUSPICIOUS_REQUEST", "suspicious_request"),
 | 
				
			||||||
 | 
					                    ("SIGN_UP", "sign_up"),
 | 
				
			||||||
 | 
					                    ("PASSWORD_RESET", "password_reset"),
 | 
				
			||||||
 | 
					                    ("INVITE_CREATED", "invitation_created"),
 | 
				
			||||||
 | 
					                    ("INVITE_USED", "invitation_used"),
 | 
				
			||||||
 | 
					                    ("IMPERSONATION_STARTED", "impersonation_started"),
 | 
				
			||||||
 | 
					                    ("IMPERSONATION_ENDED", "impersonation_ended"),
 | 
				
			||||||
 | 
					                    ("CUSTOM", "custom"),
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name="event", name="user_json", field=models.JSONField(default=dict),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        migrations.RunPython(convert_user_to_json),
 | 
				
			||||||
 | 
					        migrations.RemoveField(model_name="event", name="user",),
 | 
				
			||||||
 | 
					        migrations.RenameField(
 | 
				
			||||||
 | 
					            model_name="event", old_name="user_json", new_name="user"
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
							
								
								
									
										37
									
								
								passbook/audit/migrations/0004_auto_20200921_1829.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								passbook/audit/migrations/0004_auto_20200921_1829.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.1.1 on 2020-09-21 18:29
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("passbook_audit", "0003_auto_20200917_1155"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="event",
 | 
				
			||||||
 | 
					            name="action",
 | 
				
			||||||
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                choices=[
 | 
				
			||||||
 | 
					                    ("login", "Login"),
 | 
				
			||||||
 | 
					                    ("login_failed", "Login Failed"),
 | 
				
			||||||
 | 
					                    ("logout", "Logout"),
 | 
				
			||||||
 | 
					                    ("sign_up", "Sign Up"),
 | 
				
			||||||
 | 
					                    ("authorize_application", "Authorize Application"),
 | 
				
			||||||
 | 
					                    ("suspicious_request", "Suspicious Request"),
 | 
				
			||||||
 | 
					                    ("password_set", "Password Set"),
 | 
				
			||||||
 | 
					                    ("invitation_created", "Invite Created"),
 | 
				
			||||||
 | 
					                    ("invitation_used", "Invite Used"),
 | 
				
			||||||
 | 
					                    ("source_linked", "Source Linked"),
 | 
				
			||||||
 | 
					                    ("impersonation_started", "Impersonation Started"),
 | 
				
			||||||
 | 
					                    ("impersonation_ended", "Impersonation Ended"),
 | 
				
			||||||
 | 
					                    ("model_created", "Model Created"),
 | 
				
			||||||
 | 
					                    ("model_updated", "Model Updated"),
 | 
				
			||||||
 | 
					                    ("model_deleted", "Model Deleted"),
 | 
				
			||||||
 | 
					                    ("custom_", "Custom Prefix"),
 | 
				
			||||||
 | 
					                ]
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -1,7 +1,6 @@
 | 
				
			|||||||
"""passbook audit models"""
 | 
					"""passbook audit models"""
 | 
				
			||||||
from enum import Enum
 | 
					 | 
				
			||||||
from inspect import getmodule, stack
 | 
					from inspect import getmodule, stack
 | 
				
			||||||
from typing import Any, Dict, Optional
 | 
					from typing import Any, Dict, Optional, Union
 | 
				
			||||||
from uuid import UUID, uuid4
 | 
					from uuid import UUID, uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.conf import settings
 | 
					from django.conf import settings
 | 
				
			||||||
@ -12,13 +11,17 @@ from django.db.models.base import Model
 | 
				
			|||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from django.utils.translation import gettext as _
 | 
					from django.utils.translation import gettext as _
 | 
				
			||||||
from django.views.debug import SafeExceptionReporterFilter
 | 
					from django.views.debug import SafeExceptionReporterFilter
 | 
				
			||||||
from guardian.shortcuts import get_anonymous_user
 | 
					from guardian.utils import get_anonymous_user
 | 
				
			||||||
from structlog import get_logger
 | 
					from structlog import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER
 | 
					from passbook.core.middleware import (
 | 
				
			||||||
 | 
					    SESSION_IMPERSONATE_ORIGINAL_USER,
 | 
				
			||||||
 | 
					    SESSION_IMPERSONATE_USER,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from passbook.core.models import User
 | 
				
			||||||
from passbook.lib.utils.http import get_client_ip
 | 
					from passbook.lib.utils.http import get_client_ip
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger("passbook.audit")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
 | 
					def cleanse_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
 | 
				
			||||||
@ -50,6 +53,22 @@ def model_to_dict(model: Model) -> Dict[str, Any]:
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_user(user: User, original_user: Optional[User] = None) -> Dict[str, Any]:
 | 
				
			||||||
 | 
					    """Convert user object to dictionary, optionally including the original user"""
 | 
				
			||||||
 | 
					    if isinstance(user, AnonymousUser):
 | 
				
			||||||
 | 
					        user = get_anonymous_user()
 | 
				
			||||||
 | 
					    user_data = {
 | 
				
			||||||
 | 
					        "username": user.username,
 | 
				
			||||||
 | 
					        "pk": user.pk,
 | 
				
			||||||
 | 
					        "email": user.email,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if original_user:
 | 
				
			||||||
 | 
					        original_data = get_user(original_user)
 | 
				
			||||||
 | 
					        original_data["on_behalf_of"] = user_data
 | 
				
			||||||
 | 
					        return original_data
 | 
				
			||||||
 | 
					    return user_data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
 | 
					def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
 | 
				
			||||||
    """clean source of all Models that would interfere with the JSONField.
 | 
					    """clean source of all Models that would interfere with the JSONField.
 | 
				
			||||||
    Models are replaced with a dictionary of {
 | 
					    Models are replaced with a dictionary of {
 | 
				
			||||||
@ -70,38 +89,39 @@ def sanitize_dict(source: Dict[Any, Any]) -> Dict[Any, Any]:
 | 
				
			|||||||
    return final_dict
 | 
					    return final_dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class EventAction(Enum):
 | 
					class EventAction(models.TextChoices):
 | 
				
			||||||
    """All possible actions to save into the audit log"""
 | 
					    """All possible actions to save into the audit log"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    LOGIN = "login"
 | 
					    LOGIN = "login"
 | 
				
			||||||
    LOGIN_FAILED = "login_failed"
 | 
					    LOGIN_FAILED = "login_failed"
 | 
				
			||||||
    LOGOUT = "logout"
 | 
					    LOGOUT = "logout"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    SIGN_UP = "sign_up"
 | 
				
			||||||
    AUTHORIZE_APPLICATION = "authorize_application"
 | 
					    AUTHORIZE_APPLICATION = "authorize_application"
 | 
				
			||||||
    SUSPICIOUS_REQUEST = "suspicious_request"
 | 
					    SUSPICIOUS_REQUEST = "suspicious_request"
 | 
				
			||||||
    SIGN_UP = "sign_up"
 | 
					    PASSWORD_SET = "password_set"  # noqa # nosec
 | 
				
			||||||
    PASSWORD_RESET = "password_reset"  # noqa # nosec
 | 
					
 | 
				
			||||||
    INVITE_CREATED = "invitation_created"
 | 
					    INVITE_CREATED = "invitation_created"
 | 
				
			||||||
    INVITE_USED = "invitation_used"
 | 
					    INVITE_USED = "invitation_used"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    SOURCE_LINKED = "source_linked"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    IMPERSONATION_STARTED = "impersonation_started"
 | 
					    IMPERSONATION_STARTED = "impersonation_started"
 | 
				
			||||||
    IMPERSONATION_ENDED = "impersonation_ended"
 | 
					    IMPERSONATION_ENDED = "impersonation_ended"
 | 
				
			||||||
    CUSTOM = "custom"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    MODEL_CREATED = "model_created"
 | 
				
			||||||
    def as_choices():
 | 
					    MODEL_UPDATED = "model_updated"
 | 
				
			||||||
        """Generate choices of actions used for database"""
 | 
					    MODEL_DELETED = "model_deleted"
 | 
				
			||||||
        return tuple(
 | 
					
 | 
				
			||||||
            (x, y.value) for x, y in getattr(EventAction, "__members__").items()
 | 
					    CUSTOM_PREFIX = "custom_"
 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Event(models.Model):
 | 
					class Event(models.Model):
 | 
				
			||||||
    """An individual audit log event"""
 | 
					    """An individual audit log event"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
					    event_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 | 
				
			||||||
    user = models.ForeignKey(
 | 
					    user = models.JSONField(default=dict)
 | 
				
			||||||
        settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL
 | 
					    action = models.TextField(choices=EventAction.choices)
 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    action = models.TextField(choices=EventAction.as_choices())
 | 
					 | 
				
			||||||
    date = models.DateTimeField(auto_now_add=True)
 | 
					    date = models.DateTimeField(auto_now_add=True)
 | 
				
			||||||
    app = models.TextField()
 | 
					    app = models.TextField()
 | 
				
			||||||
    context = models.JSONField(default=dict, blank=True)
 | 
					    context = models.JSONField(default=dict, blank=True)
 | 
				
			||||||
@ -116,20 +136,18 @@ class Event(models.Model):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					    @staticmethod
 | 
				
			||||||
    def new(
 | 
					    def new(
 | 
				
			||||||
        action: EventAction,
 | 
					        action: Union[str, EventAction],
 | 
				
			||||||
        app: Optional[str] = None,
 | 
					        app: Optional[str] = None,
 | 
				
			||||||
        _inspect_offset: int = 1,
 | 
					        _inspect_offset: int = 1,
 | 
				
			||||||
        **kwargs,
 | 
					        **kwargs,
 | 
				
			||||||
    ) -> "Event":
 | 
					    ) -> "Event":
 | 
				
			||||||
        """Create new Event instance from arguments. Instance is NOT saved."""
 | 
					        """Create new Event instance from arguments. Instance is NOT saved."""
 | 
				
			||||||
        if not isinstance(action, EventAction):
 | 
					        if not isinstance(action, EventAction):
 | 
				
			||||||
            raise ValueError(
 | 
					            action = EventAction.CUSTOM_PREFIX + action
 | 
				
			||||||
                f"action must be EventAction instance but was {type(action)}"
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        if not app:
 | 
					        if not app:
 | 
				
			||||||
            app = getmodule(stack()[_inspect_offset][0]).__name__
 | 
					            app = getmodule(stack()[_inspect_offset][0]).__name__
 | 
				
			||||||
        cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
 | 
					        cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
 | 
				
			||||||
        event = Event(action=action.value, app=app, context=cleaned_kwargs)
 | 
					        event = Event(action=action, app=app, context=cleaned_kwargs)
 | 
				
			||||||
        return event
 | 
					        return event
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def from_http(
 | 
					    def from_http(
 | 
				
			||||||
@ -139,17 +157,18 @@ class Event(models.Model):
 | 
				
			|||||||
        Events independently from requests.
 | 
					        Events independently from requests.
 | 
				
			||||||
        `user` arguments optionally overrides user from requests."""
 | 
					        `user` arguments optionally overrides user from requests."""
 | 
				
			||||||
        if hasattr(request, "user"):
 | 
					        if hasattr(request, "user"):
 | 
				
			||||||
            if isinstance(request.user, AnonymousUser):
 | 
					            self.user = get_user(
 | 
				
			||||||
                self.user = get_anonymous_user()
 | 
					                request.user,
 | 
				
			||||||
            else:
 | 
					                request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None),
 | 
				
			||||||
                self.user = request.user
 | 
					            )
 | 
				
			||||||
        if user:
 | 
					        if user:
 | 
				
			||||||
            self.user = user
 | 
					            self.user = get_user(user)
 | 
				
			||||||
        # Check if we're currently impersonating, and add that user
 | 
					        # Check if we're currently impersonating, and add that user
 | 
				
			||||||
        if hasattr(request, "session"):
 | 
					        if hasattr(request, "session"):
 | 
				
			||||||
            if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
 | 
					            if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
 | 
				
			||||||
                self.context["on_behalf_of"] = model_to_dict(
 | 
					                self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER])
 | 
				
			||||||
                    request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
 | 
					                self.user["on_behalf_of"] = get_user(
 | 
				
			||||||
 | 
					                    request.session[SESSION_IMPERSONATE_USER]
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
        # User 255.255.255.255 as fallback if IP cannot be determined
 | 
					        # User 255.255.255.255 as fallback if IP cannot be determined
 | 
				
			||||||
        self.client_ip = get_client_ip(request) or "255.255.255.255"
 | 
					        self.client_ip = get_client_ip(request) or "255.255.255.255"
 | 
				
			||||||
 | 
				
			|||||||
@ -20,12 +20,12 @@ from passbook.stages.user_write.signals import user_write
 | 
				
			|||||||
class EventNewThread(Thread):
 | 
					class EventNewThread(Thread):
 | 
				
			||||||
    """Create Event in background thread"""
 | 
					    """Create Event in background thread"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    action: EventAction
 | 
					    action: str
 | 
				
			||||||
    request: HttpRequest
 | 
					    request: HttpRequest
 | 
				
			||||||
    kwargs: Dict[str, Any]
 | 
					    kwargs: Dict[str, Any]
 | 
				
			||||||
    user: Optional[User] = None
 | 
					    user: Optional[User] = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, action: EventAction, request: HttpRequest, **kwargs):
 | 
					    def __init__(self, action: str, request: HttpRequest, **kwargs):
 | 
				
			||||||
        super().__init__()
 | 
					        super().__init__()
 | 
				
			||||||
        self.action = action
 | 
					        self.action = action
 | 
				
			||||||
        self.request = request
 | 
					        self.request = request
 | 
				
			||||||
@ -57,7 +57,7 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
 | 
				
			|||||||
# pylint: disable=unused-argument
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
def on_user_write(sender, request: HttpRequest, user: User, data: Dict[str, Any], **_):
 | 
					def on_user_write(sender, request: HttpRequest, user: User, data: Dict[str, Any], **_):
 | 
				
			||||||
    """Log User write"""
 | 
					    """Log User write"""
 | 
				
			||||||
    thread = EventNewThread(EventAction.CUSTOM, request, **data)
 | 
					    thread = EventNewThread("stages/user_write", request, **data)
 | 
				
			||||||
    thread.user = user
 | 
					    thread.user = user
 | 
				
			||||||
    thread.run()
 | 
					    thread.run()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -40,12 +40,28 @@
 | 
				
			|||||||
                            </div>
 | 
					                            </div>
 | 
				
			||||||
                        </th>
 | 
					                        </th>
 | 
				
			||||||
                        <td role="cell">
 | 
					                        <td role="cell">
 | 
				
			||||||
 | 
					                            <div>
 | 
				
			||||||
 | 
					                                <div>
 | 
				
			||||||
                                    <code>{{ entry.context }}</code>
 | 
					                                    <code>{{ entry.context }}</code>
 | 
				
			||||||
 | 
					                                </div>
 | 
				
			||||||
 | 
					                                {% if entry.user.on_behalf_of %}
 | 
				
			||||||
 | 
					                                <small>
 | 
				
			||||||
 | 
					                                    {% blocktrans with username=entry.user.on_behalf_of.username %}
 | 
				
			||||||
 | 
					                                    On behalf of {{ username }}
 | 
				
			||||||
 | 
					                                    {% endblocktrans %}
 | 
				
			||||||
 | 
					                                </small>
 | 
				
			||||||
 | 
					                                {% endif %}
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
                        </td>
 | 
					                        </td>
 | 
				
			||||||
                        <td role="cell">
 | 
					                        <td role="cell">
 | 
				
			||||||
                            <span>
 | 
					                            <div>
 | 
				
			||||||
                                {{ entry.user }}
 | 
					                                <div>{{ entry.user.username }}</div>
 | 
				
			||||||
                            </span>
 | 
					                                <small>
 | 
				
			||||||
 | 
					                                    {% blocktrans with pk=entry.user.pk %}
 | 
				
			||||||
 | 
					                                    ID: {{ pk }}
 | 
				
			||||||
 | 
					                                    {% endblocktrans %}
 | 
				
			||||||
 | 
					                                </small>
 | 
				
			||||||
 | 
					                            </div>
 | 
				
			||||||
                        </td>
 | 
					                        </td>
 | 
				
			||||||
                        <td role="cell">
 | 
					                        <td role="cell">
 | 
				
			||||||
                            <span>
 | 
					                            <span>
 | 
				
			||||||
 | 
				
			|||||||
@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType
 | 
				
			|||||||
from django.test import TestCase
 | 
					from django.test import TestCase
 | 
				
			||||||
from guardian.shortcuts import get_anonymous_user
 | 
					from guardian.shortcuts import get_anonymous_user
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.audit.models import Event, EventAction
 | 
					from passbook.audit.models import Event
 | 
				
			||||||
from passbook.policies.dummy.models import DummyPolicy
 | 
					from passbook.policies.dummy.models import DummyPolicy
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -13,7 +13,7 @@ class TestAuditEvent(TestCase):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def test_new_with_model(self):
 | 
					    def test_new_with_model(self):
 | 
				
			||||||
        """Create a new Event passing a model as kwarg"""
 | 
					        """Create a new Event passing a model as kwarg"""
 | 
				
			||||||
        event = Event.new(EventAction.CUSTOM, test={"model": get_anonymous_user()})
 | 
					        event = Event.new("unittest", test={"model": get_anonymous_user()})
 | 
				
			||||||
        event.save()  # We save to ensure nothing is un-saveable
 | 
					        event.save()  # We save to ensure nothing is un-saveable
 | 
				
			||||||
        model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
 | 
					        model_content_type = ContentType.objects.get_for_model(get_anonymous_user())
 | 
				
			||||||
        self.assertEqual(
 | 
					        self.assertEqual(
 | 
				
			||||||
@ -24,7 +24,7 @@ class TestAuditEvent(TestCase):
 | 
				
			|||||||
    def test_new_with_uuid_model(self):
 | 
					    def test_new_with_uuid_model(self):
 | 
				
			||||||
        """Create a new Event passing a model (with UUID PK) as kwarg"""
 | 
					        """Create a new Event passing a model (with UUID PK) as kwarg"""
 | 
				
			||||||
        temp_model = DummyPolicy.objects.create(name="test", result=True)
 | 
					        temp_model = DummyPolicy.objects.create(name="test", result=True)
 | 
				
			||||||
        event = Event.new(EventAction.CUSTOM, model=temp_model)
 | 
					        event = Event.new("unittest", model=temp_model)
 | 
				
			||||||
        event.save()  # We save to ensure nothing is un-saveable
 | 
					        event.save()  # We save to ensure nothing is un-saveable
 | 
				
			||||||
        model_content_type = ContentType.objects.get_for_model(temp_model)
 | 
					        model_content_type = ContentType.objects.get_for_model(temp_model)
 | 
				
			||||||
        self.assertEqual(
 | 
					        self.assertEqual(
 | 
				
			||||||
 | 
				
			|||||||
@ -1,11 +1,14 @@
 | 
				
			|||||||
"""passbook admin Middleware to impersonate users"""
 | 
					"""passbook admin Middleware to impersonate users"""
 | 
				
			||||||
 | 
					from logging import Logger
 | 
				
			||||||
 | 
					from threading import local
 | 
				
			||||||
from typing import Callable
 | 
					from typing import Callable
 | 
				
			||||||
 | 
					from uuid import uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django.http import HttpRequest, HttpResponse
 | 
					from django.http import HttpRequest, HttpResponse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
 | 
					SESSION_IMPERSONATE_USER = "passbook_impersonate_user"
 | 
				
			||||||
SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
 | 
					SESSION_IMPERSONATE_ORIGINAL_USER = "passbook_impersonate_original_user"
 | 
				
			||||||
 | 
					LOCAL = local()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ImpersonateMiddleware:
 | 
					class ImpersonateMiddleware:
 | 
				
			||||||
@ -24,3 +27,30 @@ class ImpersonateMiddleware:
 | 
				
			|||||||
            request.user = request.session[SESSION_IMPERSONATE_USER]
 | 
					            request.user = request.session[SESSION_IMPERSONATE_USER]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return self.get_response(request)
 | 
					        return self.get_response(request)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class RequestIDMiddleware:
 | 
				
			||||||
 | 
					    """Add a unique ID to every request"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    get_response: Callable[[HttpRequest], HttpResponse]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
 | 
				
			||||||
 | 
					        self.get_response = get_response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __call__(self, request: HttpRequest) -> HttpResponse:
 | 
				
			||||||
 | 
					        if not hasattr(request, "request_id"):
 | 
				
			||||||
 | 
					            request_id = uuid4().hex
 | 
				
			||||||
 | 
					            setattr(request, "request_id", request_id)
 | 
				
			||||||
 | 
					            LOCAL.passbook = {"request_id": request_id}
 | 
				
			||||||
 | 
					        response = self.get_response(request)
 | 
				
			||||||
 | 
					        response["X-passbook-id"] = request.request_id
 | 
				
			||||||
 | 
					        del LOCAL.passbook["request_id"]
 | 
				
			||||||
 | 
					        return response
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def structlog_add_request_id(logger: Logger, method_name: str, event_dict):
 | 
				
			||||||
 | 
					    """If threadlocal has passbook defined, add request_id to log"""
 | 
				
			||||||
 | 
					    if hasattr(LOCAL, "passbook"):
 | 
				
			||||||
 | 
					        event_dict["request_id"] = LOCAL.passbook.get("request_id", "")
 | 
				
			||||||
 | 
					    return event_dict
 | 
				
			||||||
 | 
				
			|||||||
@ -14,7 +14,7 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			|||||||
    pbadmin, _ = User.objects.using(db_alias).get_or_create(
 | 
					    pbadmin, _ = User.objects.using(db_alias).get_or_create(
 | 
				
			||||||
        username="pbadmin", email="root@localhost", name="passbook Default Admin"
 | 
					        username="pbadmin", email="root@localhost", name="passbook Default Admin"
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    pbadmin.set_password("pbadmin")  # noqa # nosec
 | 
					    pbadmin.set_password("pbadmin", signal=False)  # noqa # nosec
 | 
				
			||||||
    pbadmin.save()
 | 
					    pbadmin.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -90,8 +90,8 @@ class User(GuardianUserMixin, AbstractUser):
 | 
				
			|||||||
        """superuser == staff user"""
 | 
					        """superuser == staff user"""
 | 
				
			||||||
        return self.is_superuser
 | 
					        return self.is_superuser
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def set_password(self, password):
 | 
					    def set_password(self, password, signal=True):
 | 
				
			||||||
        if self.pk:
 | 
					        if self.pk and signal:
 | 
				
			||||||
            password_changed.send(sender=self, user=self, password=password)
 | 
					            password_changed.send(sender=self, user=self, password=password)
 | 
				
			||||||
        self.password_change_date = now()
 | 
					        self.password_change_date = now()
 | 
				
			||||||
        return super().set_password(password)
 | 
					        return super().set_password(password)
 | 
				
			||||||
 | 
				
			|||||||
@ -31,7 +31,7 @@ class ImpersonateInitView(View):
 | 
				
			|||||||
        request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user
 | 
					        request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user
 | 
				
			||||||
        request.session[SESSION_IMPERSONATE_USER] = user_to_be
 | 
					        request.session[SESSION_IMPERSONATE_USER] = user_to_be
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Event.new(EventAction.IMPERSONATION_STARTED).from_http(request)
 | 
					        Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return redirect("passbook_core:overview")
 | 
					        return redirect("passbook_core:overview")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -48,9 +48,11 @@ class ImpersonateEndView(View):
 | 
				
			|||||||
            LOGGER.debug("Can't end impersonation", user=request.user)
 | 
					            LOGGER.debug("Can't end impersonation", user=request.user)
 | 
				
			||||||
            return redirect("passbook_core:overview")
 | 
					            return redirect("passbook_core:overview")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        del request.session[SESSION_IMPERSONATE_USER]
 | 
					        del request.session[SESSION_IMPERSONATE_USER]
 | 
				
			||||||
        del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
 | 
					        del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        Event.new(EventAction.IMPERSONATION_ENDED).from_http(request)
 | 
					        Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return redirect("passbook_core:overview")
 | 
					        return redirect("passbook_core:overview")
 | 
				
			||||||
 | 
				
			|||||||
@ -105,15 +105,10 @@ class TestFlowTransfer(TransactionTestCase):
 | 
				
			|||||||
                order=2,
 | 
					                order=2,
 | 
				
			||||||
                type=FieldTypes.PASSWORD,
 | 
					                type=FieldTypes.PASSWORD,
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            # Password checking policy
 | 
					 | 
				
			||||||
            password_policy = ExpressionPolicy.objects.create(
 | 
					 | 
				
			||||||
                name=generate_client_id(), expression="return True",
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Stages
 | 
					            # Stages
 | 
				
			||||||
            first_stage = PromptStage.objects.create(name=generate_client_id())
 | 
					            first_stage = PromptStage.objects.create(name=generate_client_id())
 | 
				
			||||||
            first_stage.fields.set([username_prompt, password, password_repeat])
 | 
					            first_stage.fields.set([username_prompt, password, password_repeat])
 | 
				
			||||||
            first_stage.validation_policies.set([password_policy])
 | 
					 | 
				
			||||||
            first_stage.save()
 | 
					            first_stage.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            flow = Flow.objects.create(
 | 
					            flow = Flow.objects.create(
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,23 @@
 | 
				
			|||||||
"""logging helpers"""
 | 
					"""logging helpers"""
 | 
				
			||||||
 | 
					from logging import Logger
 | 
				
			||||||
from os import getpid
 | 
					from os import getpid
 | 
				
			||||||
 | 
					from typing import Callable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# pylint: disable=unused-argument
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
def add_process_id(logger, method_name, event_dict):
 | 
					def add_process_id(logger: Logger, method_name: str, event_dict):
 | 
				
			||||||
    """Add the current process ID"""
 | 
					    """Add the current process ID"""
 | 
				
			||||||
    event_dict["pid"] = getpid()
 | 
					    event_dict["pid"] = getpid()
 | 
				
			||||||
    return event_dict
 | 
					    return event_dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def add_common_fields(environment: str) -> Callable:
 | 
				
			||||||
 | 
					    """Add a common field to easily search for passbook logs"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def add_common_field(logger: Logger, method_name: str, event_dict):
 | 
				
			||||||
 | 
					        """Add a common field to easily search for passbook logs"""
 | 
				
			||||||
 | 
					        event_dict["app"] = "passbook"
 | 
				
			||||||
 | 
					        event_dict["app_environment"] = environment
 | 
				
			||||||
 | 
					        return event_dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return add_common_field
 | 
				
			||||||
 | 
				
			|||||||
@ -14,7 +14,7 @@ def _get_client_ip_from_meta(meta: Dict[str, Any]) -> Optional[str]:
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
    for _header in headers:
 | 
					    for _header in headers:
 | 
				
			||||||
        if _header in meta:
 | 
					        if _header in meta:
 | 
				
			||||||
            return meta.get(_header)
 | 
					            return meta.get(_header).split(", ")[0]
 | 
				
			||||||
    return None
 | 
					    return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,36 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.1.1 on 2020-09-20 12:40
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        (
 | 
				
			||||||
 | 
					            "passbook_providers_oauth2",
 | 
				
			||||||
 | 
					            "0004_remove_oauth2provider_post_logout_redirect_uris",
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="oauth2provider",
 | 
				
			||||||
 | 
					            name="response_type",
 | 
				
			||||||
 | 
					            field=models.TextField(
 | 
				
			||||||
 | 
					                choices=[
 | 
				
			||||||
 | 
					                    ("code", "code (Authorization Code Flow)"),
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        "code#adfs",
 | 
				
			||||||
 | 
					                        "code (ADFS Compatibility Mode, sends id_token as access_token)",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    ("id_token", "id_token (Implicit Flow)"),
 | 
				
			||||||
 | 
					                    ("id_token token", "id_token token (Implicit Flow)"),
 | 
				
			||||||
 | 
					                    ("code token", "code token (Hybrid Flow)"),
 | 
				
			||||||
 | 
					                    ("code id_token", "code id_token (Hybrid Flow)"),
 | 
				
			||||||
 | 
					                    ("code id_token token", "code id_token token (Hybrid Flow)"),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					                default="code",
 | 
				
			||||||
 | 
					                help_text="Response Type required by the client.",
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -91,7 +91,7 @@ class TokenParams:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                self.refresh_token = RefreshToken.objects.get(
 | 
					                self.refresh_token = RefreshToken.objects.get(
 | 
				
			||||||
                    refresh_token=raw_token, client=self.provider
 | 
					                    refresh_token=raw_token, provider=self.provider
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            except RefreshToken.DoesNotExist:
 | 
					            except RefreshToken.DoesNotExist:
 | 
				
			||||||
@ -218,10 +218,10 @@ class TokenView(View):
 | 
				
			|||||||
        if unauthorized_scopes:
 | 
					        if unauthorized_scopes:
 | 
				
			||||||
            raise TokenError("invalid_scope")
 | 
					            raise TokenError("invalid_scope")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        refresh_token = self.params.refresh_token.provider.create_token(
 | 
					        provider: OAuth2Provider = self.params.refresh_token.provider
 | 
				
			||||||
            user=self.params.refresh_token.user,
 | 
					
 | 
				
			||||||
            provider=self.params.refresh_token.provider,
 | 
					        refresh_token: RefreshToken = provider.create_refresh_token(
 | 
				
			||||||
            scope=self.params.scope,
 | 
					            user=self.params.refresh_token.user, scope=self.params.scope,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # If the Token has an id_token it's an Authentication request.
 | 
					        # If the Token has an id_token it's an Authentication request.
 | 
				
			||||||
 | 
				
			|||||||
@ -102,11 +102,14 @@ class ASGILogger:
 | 
				
			|||||||
        await self.app(scope, receive, send_hooked)
 | 
					        await self.app(scope, receive, send_hooked)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _get_ip(self) -> str:
 | 
					    def _get_ip(self) -> str:
 | 
				
			||||||
 | 
					        client_ip = None
 | 
				
			||||||
        for header in ASGI_IP_HEADERS:
 | 
					        for header in ASGI_IP_HEADERS:
 | 
				
			||||||
            if header in self.headers:
 | 
					            if header in self.headers:
 | 
				
			||||||
                return self.headers[header].decode()
 | 
					                client_ip = self.headers[header].decode()
 | 
				
			||||||
 | 
					        if not client_ip:
 | 
				
			||||||
            client_ip, _ = self.scope.get("client", ("", 0))
 | 
					            client_ip, _ = self.scope.get("client", ("", 0))
 | 
				
			||||||
        return client_ip
 | 
					        # Check if header has multiple values, and use the first one
 | 
				
			||||||
 | 
					        return client_ip.split(", ")[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def log(self, runtime: float):
 | 
					    def log(self, runtime: float):
 | 
				
			||||||
        """Outpot access logs in a structured format"""
 | 
					        """Outpot access logs in a structured format"""
 | 
				
			||||||
 | 
				
			|||||||
@ -22,8 +22,9 @@ from sentry_sdk.integrations.celery import CeleryIntegration
 | 
				
			|||||||
from sentry_sdk.integrations.django import DjangoIntegration
 | 
					from sentry_sdk.integrations.django import DjangoIntegration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook import __version__
 | 
					from passbook import __version__
 | 
				
			||||||
 | 
					from passbook.core.middleware import structlog_add_request_id
 | 
				
			||||||
from passbook.lib.config import CONFIG
 | 
					from passbook.lib.config import CONFIG
 | 
				
			||||||
from passbook.lib.logging import add_process_id
 | 
					from passbook.lib.logging import add_common_fields, add_process_id
 | 
				
			||||||
from passbook.lib.sentry import before_send
 | 
					from passbook.lib.sentry import before_send
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -175,6 +176,8 @@ MIDDLEWARE = [
 | 
				
			|||||||
    "django_prometheus.middleware.PrometheusBeforeMiddleware",
 | 
					    "django_prometheus.middleware.PrometheusBeforeMiddleware",
 | 
				
			||||||
    "django.contrib.sessions.middleware.SessionMiddleware",
 | 
					    "django.contrib.sessions.middleware.SessionMiddleware",
 | 
				
			||||||
    "django.contrib.auth.middleware.AuthenticationMiddleware",
 | 
					    "django.contrib.auth.middleware.AuthenticationMiddleware",
 | 
				
			||||||
 | 
					    "passbook.core.middleware.RequestIDMiddleware",
 | 
				
			||||||
 | 
					    "passbook.audit.middleware.AuditMiddleware",
 | 
				
			||||||
    "django.middleware.security.SecurityMiddleware",
 | 
					    "django.middleware.security.SecurityMiddleware",
 | 
				
			||||||
    "django.middleware.common.CommonMiddleware",
 | 
					    "django.middleware.common.CommonMiddleware",
 | 
				
			||||||
    "django.middleware.csrf.CsrfViewMiddleware",
 | 
					    "django.middleware.csrf.CsrfViewMiddleware",
 | 
				
			||||||
@ -330,6 +333,8 @@ structlog.configure_once(
 | 
				
			|||||||
        structlog.stdlib.add_log_level,
 | 
					        structlog.stdlib.add_log_level,
 | 
				
			||||||
        structlog.stdlib.add_logger_name,
 | 
					        structlog.stdlib.add_logger_name,
 | 
				
			||||||
        add_process_id,
 | 
					        add_process_id,
 | 
				
			||||||
 | 
					        add_common_fields(CONFIG.y("error_reporting.environment", "customer")),
 | 
				
			||||||
 | 
					        structlog_add_request_id,
 | 
				
			||||||
        structlog.stdlib.PositionalArgumentsFormatter(),
 | 
					        structlog.stdlib.PositionalArgumentsFormatter(),
 | 
				
			||||||
        structlog.processors.TimeStamper(),
 | 
					        structlog.processors.TimeStamper(),
 | 
				
			||||||
        structlog.processors.StackInfoRenderer(),
 | 
					        structlog.processors.StackInfoRenderer(),
 | 
				
			||||||
 | 
				
			|||||||
@ -24,6 +24,7 @@ class LDAPSourceSerializer(ModelSerializer):
 | 
				
			|||||||
            "user_group_membership_field",
 | 
					            "user_group_membership_field",
 | 
				
			||||||
            "object_uniqueness_field",
 | 
					            "object_uniqueness_field",
 | 
				
			||||||
            "sync_users",
 | 
					            "sync_users",
 | 
				
			||||||
 | 
					            "sync_users_password",
 | 
				
			||||||
            "sync_groups",
 | 
					            "sync_groups",
 | 
				
			||||||
            "sync_parent_group",
 | 
					            "sync_parent_group",
 | 
				
			||||||
            "property_mappings",
 | 
					            "property_mappings",
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,12 @@
 | 
				
			|||||||
"""passbook LDAP Authentication Backend"""
 | 
					"""passbook LDAP Authentication Backend"""
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import ldap3
 | 
				
			||||||
from django.contrib.auth.backends import ModelBackend
 | 
					from django.contrib.auth.backends import ModelBackend
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from structlog import get_logger
 | 
					from structlog import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.sources.ldap.connector import Connector
 | 
					from passbook.core.models import User
 | 
				
			||||||
from passbook.sources.ldap.models import LDAPSource
 | 
					from passbook.sources.ldap.models import LDAPSource
 | 
				
			||||||
 | 
					
 | 
				
			||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
@ -18,7 +21,56 @@ class LDAPBackend(ModelBackend):
 | 
				
			|||||||
            return None
 | 
					            return None
 | 
				
			||||||
        for source in LDAPSource.objects.filter(enabled=True):
 | 
					        for source in LDAPSource.objects.filter(enabled=True):
 | 
				
			||||||
            LOGGER.debug("LDAP Auth attempt", source=source)
 | 
					            LOGGER.debug("LDAP Auth attempt", source=source)
 | 
				
			||||||
            user = Connector(source).auth_user(**kwargs)
 | 
					            user = self.auth_user(source, **kwargs)
 | 
				
			||||||
            if user:
 | 
					            if user:
 | 
				
			||||||
                return user
 | 
					                return user
 | 
				
			||||||
        return None
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def auth_user(
 | 
				
			||||||
 | 
					        self, source: LDAPSource, password: str, **filters: str
 | 
				
			||||||
 | 
					    ) -> Optional[User]:
 | 
				
			||||||
 | 
					        """Try to bind as either user_dn or mail with password.
 | 
				
			||||||
 | 
					        Returns True on success, otherwise False"""
 | 
				
			||||||
 | 
					        users = User.objects.filter(**filters)
 | 
				
			||||||
 | 
					        if not users.exists():
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        user: User = users.first()
 | 
				
			||||||
 | 
					        if "distinguishedName" not in user.attributes:
 | 
				
			||||||
 | 
					            LOGGER.debug(
 | 
				
			||||||
 | 
					                "User doesn't have DN set, assuming not LDAP imported.", user=user
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return None
 | 
				
			||||||
 | 
					        # Either has unusable password,
 | 
				
			||||||
 | 
					        # or has a password, but couldn't be authenticated by ModelBackend.
 | 
				
			||||||
 | 
					        # This means we check with a bind to see if the LDAP password has changed
 | 
				
			||||||
 | 
					        if self.auth_user_by_bind(source, user, password):
 | 
				
			||||||
 | 
					            # Password given successfully binds to LDAP, so we save it in our Database
 | 
				
			||||||
 | 
					            LOGGER.debug("Updating user's password in DB", user=user)
 | 
				
			||||||
 | 
					            user.set_password(password, signal=False)
 | 
				
			||||||
 | 
					            user.save()
 | 
				
			||||||
 | 
					            return user
 | 
				
			||||||
 | 
					        # Password doesn't match
 | 
				
			||||||
 | 
					        LOGGER.debug("Failed to bind, password invalid")
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def auth_user_by_bind(
 | 
				
			||||||
 | 
					        self, source: LDAPSource, user: User, password: str
 | 
				
			||||||
 | 
					    ) -> Optional[User]:
 | 
				
			||||||
 | 
					        """Attempt authentication by binding to the LDAP server as `user`. This
 | 
				
			||||||
 | 
					        method should be avoided as its slow to do the bind."""
 | 
				
			||||||
 | 
					        # Try to bind as new user
 | 
				
			||||||
 | 
					        LOGGER.debug("Attempting Binding as user", user=user)
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            temp_connection = ldap3.Connection(
 | 
				
			||||||
 | 
					                source.connection.server,
 | 
				
			||||||
 | 
					                user=user.attributes.get("distinguishedName"),
 | 
				
			||||||
 | 
					                password=password,
 | 
				
			||||||
 | 
					                raise_exceptions=True,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            temp_connection.bind()
 | 
				
			||||||
 | 
					            return user
 | 
				
			||||||
 | 
					        except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
 | 
				
			||||||
 | 
					            LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
 | 
				
			||||||
 | 
					        except ldap3.core.exceptions.LDAPException as exception:
 | 
				
			||||||
 | 
					            LOGGER.warning(exception)
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
				
			|||||||
@ -37,6 +37,7 @@ class LDAPSourceForm(forms.ModelForm):
 | 
				
			|||||||
            "user_group_membership_field",
 | 
					            "user_group_membership_field",
 | 
				
			||||||
            "object_uniqueness_field",
 | 
					            "object_uniqueness_field",
 | 
				
			||||||
            "sync_users",
 | 
					            "sync_users",
 | 
				
			||||||
 | 
					            "sync_users_password",
 | 
				
			||||||
            "sync_groups",
 | 
					            "sync_groups",
 | 
				
			||||||
            "sync_parent_group",
 | 
					            "sync_parent_group",
 | 
				
			||||||
            "property_mappings",
 | 
					            "property_mappings",
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,22 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.1.1 on 2020-09-21 09:02
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("passbook_sources_ldap", "0006_auto_20200915_1919"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AddField(
 | 
				
			||||||
 | 
					            model_name="ldapsource",
 | 
				
			||||||
 | 
					            name="sync_users_password",
 | 
				
			||||||
 | 
					            field=models.BooleanField(
 | 
				
			||||||
 | 
					                default=True,
 | 
				
			||||||
 | 
					                help_text="When a user changes their password, sync it back to LDAP. This can only be enabled on a single LDAP source.",
 | 
				
			||||||
 | 
					                unique=True,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -6,7 +6,7 @@ from django.core.cache import cache
 | 
				
			|||||||
from django.db import models
 | 
					from django.db import models
 | 
				
			||||||
from django.forms import ModelForm
 | 
					from django.forms import ModelForm
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from ldap3 import Connection, Server
 | 
					from ldap3 import ALL, Connection, Server
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.core.models import Group, PropertyMapping, Source
 | 
					from passbook.core.models import Group, PropertyMapping, Source
 | 
				
			||||||
from passbook.lib.models import DomainlessURLValidator
 | 
					from passbook.lib.models import DomainlessURLValidator
 | 
				
			||||||
@ -52,6 +52,16 @@ class LDAPSource(Source):
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sync_users = models.BooleanField(default=True)
 | 
					    sync_users = models.BooleanField(default=True)
 | 
				
			||||||
 | 
					    sync_users_password = models.BooleanField(
 | 
				
			||||||
 | 
					        default=True,
 | 
				
			||||||
 | 
					        help_text=_(
 | 
				
			||||||
 | 
					            (
 | 
				
			||||||
 | 
					                "When a user changes their password, sync it back to LDAP. "
 | 
				
			||||||
 | 
					                "This can only be enabled on a single LDAP source."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        unique=True,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    sync_groups = models.BooleanField(default=True)
 | 
					    sync_groups = models.BooleanField(default=True)
 | 
				
			||||||
    sync_parent_group = models.ForeignKey(
 | 
					    sync_parent_group = models.ForeignKey(
 | 
				
			||||||
        Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
 | 
					        Group, blank=True, null=True, default=None, on_delete=models.SET_DEFAULT
 | 
				
			||||||
@ -82,7 +92,7 @@ class LDAPSource(Source):
 | 
				
			|||||||
    def connection(self) -> Connection:
 | 
					    def connection(self) -> Connection:
 | 
				
			||||||
        """Get a fully connected and bound LDAP Connection"""
 | 
					        """Get a fully connected and bound LDAP Connection"""
 | 
				
			||||||
        if not self._connection:
 | 
					        if not self._connection:
 | 
				
			||||||
            server = Server(self.server_uri)
 | 
					            server = Server(self.server_uri, get_info=ALL)
 | 
				
			||||||
            self._connection = Connection(
 | 
					            self._connection = Connection(
 | 
				
			||||||
                server,
 | 
					                server,
 | 
				
			||||||
                raise_exceptions=True,
 | 
					                raise_exceptions=True,
 | 
				
			||||||
@ -112,7 +122,7 @@ class LDAPPropertyMapping(PropertyMapping):
 | 
				
			|||||||
        return LDAPPropertyMappingForm
 | 
					        return LDAPPropertyMappingForm
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __str__(self):
 | 
					    def __str__(self):
 | 
				
			||||||
        return f"LDAP Property Mapping {self.expression} -> {self.object_field}"
 | 
					        return self.name
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    class Meta:
 | 
					    class Meta:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										155
									
								
								passbook/sources/ldap/password.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										155
									
								
								passbook/sources/ldap/password.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,155 @@
 | 
				
			|||||||
 | 
					"""Help validate and update passwords in LDAP"""
 | 
				
			||||||
 | 
					from enum import IntFlag
 | 
				
			||||||
 | 
					from re import split
 | 
				
			||||||
 | 
					from typing import Optional
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import ldap3
 | 
				
			||||||
 | 
					import ldap3.core.exceptions
 | 
				
			||||||
 | 
					from structlog import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from passbook.core.models import User
 | 
				
			||||||
 | 
					from passbook.sources.ldap.models import LDAPSource
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					NON_ALPHA = r"~!@#$%^&*_-+=`|\(){}[]:;\"'<>,.?/"
 | 
				
			||||||
 | 
					RE_DISPLAYNAME_SEPARATORS = r",\.–—_\s#\t"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PwdProperties(IntFlag):
 | 
				
			||||||
 | 
					    """Possible values for the pwdProperties attribute"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    DOMAIN_PASSWORD_COMPLEX = 1
 | 
				
			||||||
 | 
					    DOMAIN_PASSWORD_NO_ANON_CHANGE = 2
 | 
				
			||||||
 | 
					    DOMAIN_PASSWORD_NO_CLEAR_CHANGE = 4
 | 
				
			||||||
 | 
					    DOMAIN_LOCKOUT_ADMINS = 8
 | 
				
			||||||
 | 
					    DOMAIN_PASSWORD_STORE_CLEARTEXT = 16
 | 
				
			||||||
 | 
					    DOMAIN_REFUSE_PASSWORD_CHANGE = 32
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class PasswordCategories(IntFlag):
 | 
				
			||||||
 | 
					    """Password categories as defined by Microsoft, a category can only be counted
 | 
				
			||||||
 | 
					    once, hence intflag."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    NONE = 0
 | 
				
			||||||
 | 
					    ALPHA_LOWER = 1
 | 
				
			||||||
 | 
					    ALPHA_UPPER = 2
 | 
				
			||||||
 | 
					    ALPHA_OTHER = 4
 | 
				
			||||||
 | 
					    NUMERIC = 8
 | 
				
			||||||
 | 
					    SYMBOL = 16
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LDAPPasswordChanger:
 | 
				
			||||||
 | 
					    """Help validate and update passwords in LDAP"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    _source: LDAPSource
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, source: LDAPSource) -> None:
 | 
				
			||||||
 | 
					        self._source = source
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_domain_root_dn(self) -> str:
 | 
				
			||||||
 | 
					        """Attempt to get root DN via MS specific fields or generic LDAP fields"""
 | 
				
			||||||
 | 
					        info = self._source.connection.server.info
 | 
				
			||||||
 | 
					        if "rootDomainNamingContext" in info.other:
 | 
				
			||||||
 | 
					            return info.other["rootDomainNamingContext"][0]
 | 
				
			||||||
 | 
					        naming_contexts = info.naming_contexts
 | 
				
			||||||
 | 
					        naming_contexts.sort(key=len)
 | 
				
			||||||
 | 
					        return naming_contexts[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def check_ad_password_complexity_enabled(self) -> bool:
 | 
				
			||||||
 | 
					        """Check if DOMAIN_PASSWORD_COMPLEX is enabled"""
 | 
				
			||||||
 | 
					        root_dn = self.get_domain_root_dn()
 | 
				
			||||||
 | 
					        root_attrs = self._source.connection.extend.standard.paged_search(
 | 
				
			||||||
 | 
					            search_base=root_dn,
 | 
				
			||||||
 | 
					            search_filter="(objectClass=*)",
 | 
				
			||||||
 | 
					            search_scope=ldap3.BASE,
 | 
				
			||||||
 | 
					            attributes=["pwdProperties"],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        root_attrs = list(root_attrs)[0]
 | 
				
			||||||
 | 
					        pwd_properties = PwdProperties(root_attrs["attributes"]["pwdProperties"])
 | 
				
			||||||
 | 
					        if PwdProperties.DOMAIN_PASSWORD_COMPLEX in pwd_properties:
 | 
				
			||||||
 | 
					            return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def change_password(self, user: User, password: str):
 | 
				
			||||||
 | 
					        """Change user's password"""
 | 
				
			||||||
 | 
					        user_dn = user.attributes.get("distinguishedName", None)
 | 
				
			||||||
 | 
					        if not user_dn:
 | 
				
			||||||
 | 
					            raise AttributeError("User has no distinguishedName set.")
 | 
				
			||||||
 | 
					        self._source.connection.extend.microsoft.modify_password(user_dn, password)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _ad_check_password_existing(self, password: str, user_dn: str) -> bool:
 | 
				
			||||||
 | 
					        """Check if a password contains sAMAccount or displayName"""
 | 
				
			||||||
 | 
					        users = list(
 | 
				
			||||||
 | 
					            self._source.connection.extend.standard.paged_search(
 | 
				
			||||||
 | 
					                search_base=user_dn,
 | 
				
			||||||
 | 
					                search_filter=self._source.user_object_filter,
 | 
				
			||||||
 | 
					                search_scope=ldap3.BASE,
 | 
				
			||||||
 | 
					                attributes=["displayName", "sAMAccountName"],
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if len(users) != 1:
 | 
				
			||||||
 | 
					            raise AssertionError()
 | 
				
			||||||
 | 
					        user_attributes = users[0]["attributes"]
 | 
				
			||||||
 | 
					        # If sAMAccountName is longer than 3 chars, check if its contained in password
 | 
				
			||||||
 | 
					        if len(user_attributes["sAMAccountName"]) >= 3:
 | 
				
			||||||
 | 
					            if password.lower() in user_attributes["sAMAccountName"].lower():
 | 
				
			||||||
 | 
					                return False
 | 
				
			||||||
 | 
					        display_name_tokens = split(
 | 
				
			||||||
 | 
					            RE_DISPLAYNAME_SEPARATORS, user_attributes["displayName"]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        for token in display_name_tokens:
 | 
				
			||||||
 | 
					            # Ignore tokens under 3 chars
 | 
				
			||||||
 | 
					            if len(token) < 3:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					            if token.lower() in password.lower():
 | 
				
			||||||
 | 
					                return False
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def ad_password_complexity(
 | 
				
			||||||
 | 
					        self, password: str, user: Optional[User] = None
 | 
				
			||||||
 | 
					    ) -> bool:
 | 
				
			||||||
 | 
					        """Check if password matches Active direcotry password policies
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        https://docs.microsoft.com/en-us/windows/security/threat-protection/
 | 
				
			||||||
 | 
					            security-policy-settings/password-must-meet-complexity-requirements
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if user:
 | 
				
			||||||
 | 
					            # Check if password contains sAMAccountName or displayNames
 | 
				
			||||||
 | 
					            if "distinguishedName" in user.attributes:
 | 
				
			||||||
 | 
					                existing_user_check = self._ad_check_password_existing(
 | 
				
			||||||
 | 
					                    password, user.attributes.get("distinguishedName")
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                if not existing_user_check:
 | 
				
			||||||
 | 
					                    LOGGER.debug("Password failed name check", user=user)
 | 
				
			||||||
 | 
					                    return existing_user_check
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Step 2, match at least 3 of 5 categories
 | 
				
			||||||
 | 
					        matched_categories = PasswordCategories.NONE
 | 
				
			||||||
 | 
					        required = 3
 | 
				
			||||||
 | 
					        for letter in password:
 | 
				
			||||||
 | 
					            # Only match one category per letter,
 | 
				
			||||||
 | 
					            if letter.islower():
 | 
				
			||||||
 | 
					                matched_categories |= PasswordCategories.ALPHA_LOWER
 | 
				
			||||||
 | 
					            elif letter.isupper():
 | 
				
			||||||
 | 
					                matched_categories |= PasswordCategories.ALPHA_UPPER
 | 
				
			||||||
 | 
					            elif not letter.isascii() and letter.isalpha():
 | 
				
			||||||
 | 
					                # Not exactly matching microsoft's policy, but count it as "Other unicode" char
 | 
				
			||||||
 | 
					                # when its alpha and not ascii
 | 
				
			||||||
 | 
					                matched_categories |= PasswordCategories.ALPHA_OTHER
 | 
				
			||||||
 | 
					            elif letter.isnumeric():
 | 
				
			||||||
 | 
					                matched_categories |= PasswordCategories.NUMERIC
 | 
				
			||||||
 | 
					            elif letter in NON_ALPHA:
 | 
				
			||||||
 | 
					                matched_categories |= PasswordCategories.SYMBOL
 | 
				
			||||||
 | 
					        if bin(matched_categories).count("1") < required:
 | 
				
			||||||
 | 
					            LOGGER.debug(
 | 
				
			||||||
 | 
					                "Password didn't match enough categories",
 | 
				
			||||||
 | 
					                has=matched_categories,
 | 
				
			||||||
 | 
					                must=required,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        LOGGER.debug(
 | 
				
			||||||
 | 
					            "Password matched categories", has=matched_categories, must=required
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
@ -1,9 +1,19 @@
 | 
				
			|||||||
"""passbook ldap source signals"""
 | 
					"""passbook ldap source signals"""
 | 
				
			||||||
 | 
					from typing import Any, Dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.core.exceptions import ValidationError
 | 
				
			||||||
from django.db.models.signals import post_save
 | 
					from django.db.models.signals import post_save
 | 
				
			||||||
from django.dispatch import receiver
 | 
					from django.dispatch import receiver
 | 
				
			||||||
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
 | 
					from ldap3.core.exceptions import LDAPException
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from passbook.core.models import User
 | 
				
			||||||
 | 
					from passbook.core.signals import password_changed
 | 
				
			||||||
 | 
					from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
 | 
				
			||||||
from passbook.sources.ldap.models import LDAPSource
 | 
					from passbook.sources.ldap.models import LDAPSource
 | 
				
			||||||
 | 
					from passbook.sources.ldap.password import LDAPPasswordChanger
 | 
				
			||||||
from passbook.sources.ldap.tasks import sync_single
 | 
					from passbook.sources.ldap.tasks import sync_single
 | 
				
			||||||
 | 
					from passbook.stages.prompt.signals import password_validate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@receiver(post_save, sender=LDAPSource)
 | 
					@receiver(post_save, sender=LDAPSource)
 | 
				
			||||||
@ -12,3 +22,38 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_):
 | 
				
			|||||||
    """Ensure that source is synced on save (if enabled)"""
 | 
					    """Ensure that source is synced on save (if enabled)"""
 | 
				
			||||||
    if instance.enabled:
 | 
					    if instance.enabled:
 | 
				
			||||||
        sync_single.delay(instance.pk)
 | 
					        sync_single.delay(instance.pk)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(password_validate)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def ldap_password_validate(sender, password: str, plan_context: Dict[str, Any], **__):
 | 
				
			||||||
 | 
					    """if there's an LDAP Source with enabled password sync, check the password"""
 | 
				
			||||||
 | 
					    sources = LDAPSource.objects.filter(sync_users_password=True)
 | 
				
			||||||
 | 
					    if not sources.exists():
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    source = sources.first()
 | 
				
			||||||
 | 
					    changer = LDAPPasswordChanger(source)
 | 
				
			||||||
 | 
					    if changer.check_ad_password_complexity_enabled():
 | 
				
			||||||
 | 
					        passing = changer.ad_password_complexity(
 | 
				
			||||||
 | 
					            password, plan_context.get(PLAN_CONTEXT_PENDING_USER, None)
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if not passing:
 | 
				
			||||||
 | 
					            raise ValidationError(
 | 
				
			||||||
 | 
					                _("Password does not match Active Direcory Complexity.")
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@receiver(password_changed)
 | 
				
			||||||
 | 
					# pylint: disable=unused-argument
 | 
				
			||||||
 | 
					def ldap_sync_password(sender, user: User, password: str, **_):
 | 
				
			||||||
 | 
					    """Connect to ldap and update password. We do this in the background to get
 | 
				
			||||||
 | 
					    automatic retries on error."""
 | 
				
			||||||
 | 
					    sources = LDAPSource.objects.filter(sync_users_password=True)
 | 
				
			||||||
 | 
					    if not sources.exists():
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					    source = sources.first()
 | 
				
			||||||
 | 
					    changer = LDAPPasswordChanger(source)
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        changer.change_password(user, password)
 | 
				
			||||||
 | 
					    except LDAPException as exc:
 | 
				
			||||||
 | 
					        raise ValidationError("Failed to set password") from exc
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,5 @@
 | 
				
			|||||||
"""Wrapper for ldap3 to easily manage user"""
 | 
					"""Sync LDAP Users and groups into passbook"""
 | 
				
			||||||
from typing import Any, Dict, Optional
 | 
					from typing import Any, Dict
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import ldap3
 | 
					import ldap3
 | 
				
			||||||
import ldap3.core.exceptions
 | 
					import ldap3.core.exceptions
 | 
				
			||||||
@ -13,19 +13,14 @@ from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
				
			|||||||
LOGGER = get_logger()
 | 
					LOGGER = get_logger()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Connector:
 | 
					class LDAPSynchronizer:
 | 
				
			||||||
    """Wrapper for ldap3 to easily manage user authentication and creation"""
 | 
					    """Sync LDAP Users and groups into passbook"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    _source: LDAPSource
 | 
					    _source: LDAPSource
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, source: LDAPSource):
 | 
					    def __init__(self, source: LDAPSource):
 | 
				
			||||||
        self._source = source
 | 
					        self._source = source
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @staticmethod
 | 
					 | 
				
			||||||
    def encode_pass(password: str) -> bytes:
 | 
					 | 
				
			||||||
        """Encodes a plain-text password so it can be used by AD"""
 | 
					 | 
				
			||||||
        return '"{}"'.format(password).encode("utf-16-le")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					    @property
 | 
				
			||||||
    def base_dn_users(self) -> str:
 | 
					    def base_dn_users(self) -> str:
 | 
				
			||||||
        """Shortcut to get full base_dn for user lookups"""
 | 
					        """Shortcut to get full base_dn for user lookups"""
 | 
				
			||||||
@ -187,48 +182,3 @@ class Connector:
 | 
				
			|||||||
            "distinguishedName"
 | 
					            "distinguishedName"
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return properties
 | 
					        return properties
 | 
				
			||||||
 | 
					 | 
				
			||||||
    def auth_user(self, password: str, **filters: str) -> Optional[User]:
 | 
					 | 
				
			||||||
        """Try to bind as either user_dn or mail with password.
 | 
					 | 
				
			||||||
        Returns True on success, otherwise False"""
 | 
					 | 
				
			||||||
        users = User.objects.filter(**filters)
 | 
					 | 
				
			||||||
        if not users.exists():
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
        user: User = users.first()
 | 
					 | 
				
			||||||
        if "distinguishedName" not in user.attributes:
 | 
					 | 
				
			||||||
            LOGGER.debug(
 | 
					 | 
				
			||||||
                "User doesn't have DN set, assuming not LDAP imported.", user=user
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            return None
 | 
					 | 
				
			||||||
        # Either has unusable password,
 | 
					 | 
				
			||||||
        # or has a password, but couldn't be authenticated by ModelBackend.
 | 
					 | 
				
			||||||
        # This means we check with a bind to see if the LDAP password has changed
 | 
					 | 
				
			||||||
        if self.auth_user_by_bind(user, password):
 | 
					 | 
				
			||||||
            # Password given successfully binds to LDAP, so we save it in our Database
 | 
					 | 
				
			||||||
            LOGGER.debug("Updating user's password in DB", user=user)
 | 
					 | 
				
			||||||
            user.set_password(password)
 | 
					 | 
				
			||||||
            user.save()
 | 
					 | 
				
			||||||
            return user
 | 
					 | 
				
			||||||
        # Password doesn't match
 | 
					 | 
				
			||||||
        LOGGER.debug("Failed to bind, password invalid")
 | 
					 | 
				
			||||||
        return None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def auth_user_by_bind(self, user: User, password: str) -> Optional[User]:
 | 
					 | 
				
			||||||
        """Attempt authentication by binding to the LDAP server as `user`. This
 | 
					 | 
				
			||||||
        method should be avoided as its slow to do the bind."""
 | 
					 | 
				
			||||||
        # Try to bind as new user
 | 
					 | 
				
			||||||
        LOGGER.debug("Attempting Binding as user", user=user)
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            temp_connection = ldap3.Connection(
 | 
					 | 
				
			||||||
                self._source.connection.server,
 | 
					 | 
				
			||||||
                user=user.attributes.get("distinguishedName"),
 | 
					 | 
				
			||||||
                password=password,
 | 
					 | 
				
			||||||
                raise_exceptions=True,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            temp_connection.bind()
 | 
					 | 
				
			||||||
            return user
 | 
					 | 
				
			||||||
        except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
 | 
					 | 
				
			||||||
            LOGGER.debug("LDAPInvalidCredentialsResult", user=user, error=exception)
 | 
					 | 
				
			||||||
        except ldap3.core.exceptions.LDAPException as exception:
 | 
					 | 
				
			||||||
            LOGGER.warning(exception)
 | 
					 | 
				
			||||||
        return None
 | 
					 | 
				
			||||||
@ -4,8 +4,8 @@ from time import time
 | 
				
			|||||||
from django.core.cache import cache
 | 
					from django.core.cache import cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.root.celery import CELERY_APP
 | 
					from passbook.root.celery import CELERY_APP
 | 
				
			||||||
from passbook.sources.ldap.connector import Connector
 | 
					 | 
				
			||||||
from passbook.sources.ldap.models import LDAPSource
 | 
					from passbook.sources.ldap.models import LDAPSource
 | 
				
			||||||
 | 
					from passbook.sources.ldap.sync import LDAPSynchronizer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@CELERY_APP.task()
 | 
					@CELERY_APP.task()
 | 
				
			||||||
@ -19,9 +19,9 @@ def sync():
 | 
				
			|||||||
def sync_single(source_pk):
 | 
					def sync_single(source_pk):
 | 
				
			||||||
    """Sync a single source"""
 | 
					    """Sync a single source"""
 | 
				
			||||||
    source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
 | 
					    source: LDAPSource = LDAPSource.objects.get(pk=source_pk)
 | 
				
			||||||
    connector = Connector(source)
 | 
					    syncer = LDAPSynchronizer(source)
 | 
				
			||||||
    connector.sync_users()
 | 
					    syncer.sync_users()
 | 
				
			||||||
    connector.sync_groups()
 | 
					    syncer.sync_groups()
 | 
				
			||||||
    connector.sync_membership()
 | 
					    syncer.sync_membership()
 | 
				
			||||||
    cache_key = source.state_cache_prefix("last_sync")
 | 
					    cache_key = source.state_cache_prefix("last_sync")
 | 
				
			||||||
    cache.set(cache_key, time(), timeout=60 * 60)
 | 
					    cache.set(cache_key, time(), timeout=60 * 60)
 | 
				
			||||||
 | 
				
			|||||||
@ -1,149 +0,0 @@
 | 
				
			|||||||
"""LDAP Source tests"""
 | 
					 | 
				
			||||||
from unittest.mock import Mock, PropertyMock, patch
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from django.test import TestCase
 | 
					 | 
				
			||||||
from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from passbook.core.models import Group, User
 | 
					 | 
				
			||||||
from passbook.providers.oauth2.generators import generate_client_secret
 | 
					 | 
				
			||||||
from passbook.sources.ldap.auth import LDAPBackend
 | 
					 | 
				
			||||||
from passbook.sources.ldap.connector import Connector
 | 
					 | 
				
			||||||
from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
					 | 
				
			||||||
from passbook.sources.ldap.tasks import sync
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def _build_mock_connection() -> Connection:
 | 
					 | 
				
			||||||
    """Create mock connection"""
 | 
					 | 
				
			||||||
    server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
 | 
					 | 
				
			||||||
    _pass = "foo"  # noqa # nosec
 | 
					 | 
				
			||||||
    connection = Connection(
 | 
					 | 
				
			||||||
        server,
 | 
					 | 
				
			||||||
        user="cn=my_user,ou=test,o=lab",
 | 
					 | 
				
			||||||
        password=_pass,
 | 
					 | 
				
			||||||
        client_strategy=MOCK_SYNC,
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    connection.strategy.add_entry(
 | 
					 | 
				
			||||||
        "cn=group1,ou=groups,ou=test,o=lab",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "name": "test-group",
 | 
					 | 
				
			||||||
            "objectSid": "unique-test-group",
 | 
					 | 
				
			||||||
            "objectCategory": "Group",
 | 
					 | 
				
			||||||
            "distinguishedName": "cn=group1,ou=groups,ou=test,o=lab",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    # Group without SID
 | 
					 | 
				
			||||||
    connection.strategy.add_entry(
 | 
					 | 
				
			||||||
        "cn=group2,ou=groups,ou=test,o=lab",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "name": "test-group",
 | 
					 | 
				
			||||||
            "objectCategory": "Group",
 | 
					 | 
				
			||||||
            "distinguishedName": "cn=group2,ou=groups,ou=test,o=lab",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    connection.strategy.add_entry(
 | 
					 | 
				
			||||||
        "cn=user0,ou=users,ou=test,o=lab",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "userPassword": LDAP_PASSWORD,
 | 
					 | 
				
			||||||
            "sAMAccountName": "user0_sn",
 | 
					 | 
				
			||||||
            "name": "user0_sn",
 | 
					 | 
				
			||||||
            "revision": 0,
 | 
					 | 
				
			||||||
            "objectSid": "user0",
 | 
					 | 
				
			||||||
            "objectCategory": "Person",
 | 
					 | 
				
			||||||
            "memberOf": "cn=group1,ou=groups,ou=test,o=lab",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    # User without SID
 | 
					 | 
				
			||||||
    connection.strategy.add_entry(
 | 
					 | 
				
			||||||
        "cn=user1,ou=users,ou=test,o=lab",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "userPassword": "test1111",
 | 
					 | 
				
			||||||
            "sAMAccountName": "user2_sn",
 | 
					 | 
				
			||||||
            "name": "user1_sn",
 | 
					 | 
				
			||||||
            "revision": 0,
 | 
					 | 
				
			||||||
            "objectCategory": "Person",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    # Duplicate users
 | 
					 | 
				
			||||||
    connection.strategy.add_entry(
 | 
					 | 
				
			||||||
        "cn=user2,ou=users,ou=test,o=lab",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "userPassword": "test2222",
 | 
					 | 
				
			||||||
            "sAMAccountName": "user2_sn",
 | 
					 | 
				
			||||||
            "name": "user2_sn",
 | 
					 | 
				
			||||||
            "revision": 0,
 | 
					 | 
				
			||||||
            "objectSid": "unique-test2222",
 | 
					 | 
				
			||||||
            "objectCategory": "Person",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    connection.strategy.add_entry(
 | 
					 | 
				
			||||||
        "cn=user3,ou=users,ou=test,o=lab",
 | 
					 | 
				
			||||||
        {
 | 
					 | 
				
			||||||
            "userPassword": "test2222",
 | 
					 | 
				
			||||||
            "sAMAccountName": "user2_sn",
 | 
					 | 
				
			||||||
            "name": "user2_sn",
 | 
					 | 
				
			||||||
            "revision": 0,
 | 
					 | 
				
			||||||
            "objectSid": "unique-test2222",
 | 
					 | 
				
			||||||
            "objectCategory": "Person",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
    connection.bind()
 | 
					 | 
				
			||||||
    return connection
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
LDAP_PASSWORD = generate_client_secret()
 | 
					 | 
				
			||||||
LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class LDAPSourceTests(TestCase):
 | 
					 | 
				
			||||||
    """LDAP Source tests"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def setUp(self):
 | 
					 | 
				
			||||||
        self.source = LDAPSource.objects.create(
 | 
					 | 
				
			||||||
            name="ldap",
 | 
					 | 
				
			||||||
            slug="ldap",
 | 
					 | 
				
			||||||
            base_dn="ou=test,o=lab",
 | 
					 | 
				
			||||||
            additional_user_dn="ou=users",
 | 
					 | 
				
			||||||
            additional_group_dn="ou=groups",
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
 | 
					 | 
				
			||||||
        self.source.save()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
					 | 
				
			||||||
    def test_sync_users(self):
 | 
					 | 
				
			||||||
        """Test user sync"""
 | 
					 | 
				
			||||||
        connector = Connector(self.source)
 | 
					 | 
				
			||||||
        connector.sync_users()
 | 
					 | 
				
			||||||
        self.assertTrue(User.objects.filter(username="user0_sn").exists())
 | 
					 | 
				
			||||||
        self.assertFalse(User.objects.filter(username="user1_sn").exists())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
					 | 
				
			||||||
    def test_sync_groups(self):
 | 
					 | 
				
			||||||
        """Test group sync"""
 | 
					 | 
				
			||||||
        connector = Connector(self.source)
 | 
					 | 
				
			||||||
        connector.sync_groups()
 | 
					 | 
				
			||||||
        connector.sync_membership()
 | 
					 | 
				
			||||||
        group = Group.objects.filter(name="test-group")
 | 
					 | 
				
			||||||
        self.assertTrue(group.exists())
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
					 | 
				
			||||||
    def test_auth(self):
 | 
					 | 
				
			||||||
        """Test Cached auth"""
 | 
					 | 
				
			||||||
        connector = Connector(self.source)
 | 
					 | 
				
			||||||
        connector.sync_users()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        user = User.objects.get(username="user0_sn")
 | 
					 | 
				
			||||||
        auth_user_by_bind = Mock(return_value=user)
 | 
					 | 
				
			||||||
        with patch(
 | 
					 | 
				
			||||||
            "passbook.sources.ldap.connector.Connector.auth_user_by_bind",
 | 
					 | 
				
			||||||
            auth_user_by_bind,
 | 
					 | 
				
			||||||
        ):
 | 
					 | 
				
			||||||
            backend = LDAPBackend()
 | 
					 | 
				
			||||||
            self.assertEqual(
 | 
					 | 
				
			||||||
                backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
 | 
					 | 
				
			||||||
                user,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
					 | 
				
			||||||
    def test_tasks(self):
 | 
					 | 
				
			||||||
        """Test Scheduled tasks"""
 | 
					 | 
				
			||||||
        sync()
 | 
					 | 
				
			||||||
							
								
								
									
										0
									
								
								passbook/sources/ldap/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								passbook/sources/ldap/tests/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										47
									
								
								passbook/sources/ldap/tests/test_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								passbook/sources/ldap/tests/test_auth.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					"""LDAP Source tests"""
 | 
				
			||||||
 | 
					from unittest.mock import Mock, PropertyMock, patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.test import TestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from passbook.core.models import User
 | 
				
			||||||
 | 
					from passbook.providers.oauth2.generators import generate_client_secret
 | 
				
			||||||
 | 
					from passbook.sources.ldap.auth import LDAPBackend
 | 
				
			||||||
 | 
					from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
				
			||||||
 | 
					from passbook.sources.ldap.sync import LDAPSynchronizer
 | 
				
			||||||
 | 
					from passbook.sources.ldap.tests.utils import _build_mock_connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LDAP_PASSWORD = generate_client_secret()
 | 
				
			||||||
 | 
					LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LDAPSyncTests(TestCase):
 | 
				
			||||||
 | 
					    """LDAP Sync tests"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self.source = LDAPSource.objects.create(
 | 
				
			||||||
 | 
					            name="ldap",
 | 
				
			||||||
 | 
					            slug="ldap",
 | 
				
			||||||
 | 
					            base_dn="DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					            additional_user_dn="ou=users",
 | 
				
			||||||
 | 
					            additional_group_dn="ou=groups",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
 | 
				
			||||||
 | 
					        self.source.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
				
			||||||
 | 
					    def test_auth_synced_user(self):
 | 
				
			||||||
 | 
					        """Test Cached auth"""
 | 
				
			||||||
 | 
					        syncer = LDAPSynchronizer(self.source)
 | 
				
			||||||
 | 
					        syncer.sync_users()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        user = User.objects.get(username="user0_sn")
 | 
				
			||||||
 | 
					        auth_user_by_bind = Mock(return_value=user)
 | 
				
			||||||
 | 
					        with patch(
 | 
				
			||||||
 | 
					            "passbook.sources.ldap.auth.LDAPBackend.auth_user_by_bind",
 | 
				
			||||||
 | 
					            auth_user_by_bind,
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					            backend = LDAPBackend()
 | 
				
			||||||
 | 
					            self.assertEqual(
 | 
				
			||||||
 | 
					                backend.authenticate(None, username="user0_sn", password=LDAP_PASSWORD),
 | 
				
			||||||
 | 
					                user,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
							
								
								
									
										54
									
								
								passbook/sources/ldap/tests/test_password.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								passbook/sources/ldap/tests/test_password.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
				
			|||||||
 | 
					"""LDAP Source tests"""
 | 
				
			||||||
 | 
					from unittest.mock import PropertyMock, patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.test import TestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from passbook.core.models import User
 | 
				
			||||||
 | 
					from passbook.providers.oauth2.generators import generate_client_secret
 | 
				
			||||||
 | 
					from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
				
			||||||
 | 
					from passbook.sources.ldap.password import LDAPPasswordChanger
 | 
				
			||||||
 | 
					from passbook.sources.ldap.tests.utils import _build_mock_connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LDAP_PASSWORD = generate_client_secret()
 | 
				
			||||||
 | 
					LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LDAPPasswordTests(TestCase):
 | 
				
			||||||
 | 
					    """LDAP Password tests"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self.source = LDAPSource.objects.create(
 | 
				
			||||||
 | 
					            name="ldap",
 | 
				
			||||||
 | 
					            slug="ldap",
 | 
				
			||||||
 | 
					            base_dn="DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					            additional_user_dn="ou=users",
 | 
				
			||||||
 | 
					            additional_group_dn="ou=groups",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
 | 
				
			||||||
 | 
					        self.source.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
				
			||||||
 | 
					    def test_password_complexity(self):
 | 
				
			||||||
 | 
					        """Test password without user"""
 | 
				
			||||||
 | 
					        pwc = LDAPPasswordChanger(self.source)
 | 
				
			||||||
 | 
					        self.assertFalse(pwc.ad_password_complexity("test"))  # 1 category
 | 
				
			||||||
 | 
					        self.assertFalse(pwc.ad_password_complexity("test1"))  # 2 categories
 | 
				
			||||||
 | 
					        self.assertTrue(pwc.ad_password_complexity("test1!"))  # 2 categories
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
				
			||||||
 | 
					    def test_password_complexity_user(self):
 | 
				
			||||||
 | 
					        """test password with user"""
 | 
				
			||||||
 | 
					        pwc = LDAPPasswordChanger(self.source)
 | 
				
			||||||
 | 
					        user = User.objects.create(
 | 
				
			||||||
 | 
					            username="test",
 | 
				
			||||||
 | 
					            attributes={"distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB"},
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.assertFalse(pwc.ad_password_complexity("test", user))  # 1 category
 | 
				
			||||||
 | 
					        self.assertFalse(pwc.ad_password_complexity("test1", user))  # 2 categories
 | 
				
			||||||
 | 
					        self.assertTrue(pwc.ad_password_complexity("test1!", user))  # 2 categories
 | 
				
			||||||
 | 
					        self.assertFalse(
 | 
				
			||||||
 | 
					            pwc.ad_password_complexity("erin!qewrqewr", user)
 | 
				
			||||||
 | 
					        )  # displayName token
 | 
				
			||||||
 | 
					        self.assertFalse(
 | 
				
			||||||
 | 
					            pwc.ad_password_complexity("hagens!qewrqewr", user)
 | 
				
			||||||
 | 
					        )  # displayName token
 | 
				
			||||||
							
								
								
									
										51
									
								
								passbook/sources/ldap/tests/test_sync.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								passbook/sources/ldap/tests/test_sync.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,51 @@
 | 
				
			|||||||
 | 
					"""LDAP Source tests"""
 | 
				
			||||||
 | 
					from unittest.mock import PropertyMock, patch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.test import TestCase
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from passbook.core.models import Group, User
 | 
				
			||||||
 | 
					from passbook.providers.oauth2.generators import generate_client_secret
 | 
				
			||||||
 | 
					from passbook.sources.ldap.models import LDAPPropertyMapping, LDAPSource
 | 
				
			||||||
 | 
					from passbook.sources.ldap.sync import LDAPSynchronizer
 | 
				
			||||||
 | 
					from passbook.sources.ldap.tasks import sync
 | 
				
			||||||
 | 
					from passbook.sources.ldap.tests.utils import _build_mock_connection
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					LDAP_PASSWORD = generate_client_secret()
 | 
				
			||||||
 | 
					LDAP_CONNECTION_PATCH = PropertyMock(return_value=_build_mock_connection(LDAP_PASSWORD))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LDAPSyncTests(TestCase):
 | 
				
			||||||
 | 
					    """LDAP Sync tests"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setUp(self):
 | 
				
			||||||
 | 
					        self.source = LDAPSource.objects.create(
 | 
				
			||||||
 | 
					            name="ldap",
 | 
				
			||||||
 | 
					            slug="ldap",
 | 
				
			||||||
 | 
					            base_dn="DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					            additional_user_dn="ou=users",
 | 
				
			||||||
 | 
					            additional_group_dn="ou=groups",
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        self.source.property_mappings.set(LDAPPropertyMapping.objects.all())
 | 
				
			||||||
 | 
					        self.source.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
				
			||||||
 | 
					    def test_sync_users(self):
 | 
				
			||||||
 | 
					        """Test user sync"""
 | 
				
			||||||
 | 
					        syncer = LDAPSynchronizer(self.source)
 | 
				
			||||||
 | 
					        syncer.sync_users()
 | 
				
			||||||
 | 
					        self.assertTrue(User.objects.filter(username="user0_sn").exists())
 | 
				
			||||||
 | 
					        self.assertFalse(User.objects.filter(username="user1_sn").exists())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
				
			||||||
 | 
					    def test_sync_groups(self):
 | 
				
			||||||
 | 
					        """Test group sync"""
 | 
				
			||||||
 | 
					        syncer = LDAPSynchronizer(self.source)
 | 
				
			||||||
 | 
					        syncer.sync_groups()
 | 
				
			||||||
 | 
					        syncer.sync_membership()
 | 
				
			||||||
 | 
					        group = Group.objects.filter(name="test-group")
 | 
				
			||||||
 | 
					        self.assertTrue(group.exists())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @patch("passbook.sources.ldap.models.LDAPSource.connection", LDAP_CONNECTION_PATCH)
 | 
				
			||||||
 | 
					    def test_tasks(self):
 | 
				
			||||||
 | 
					        """Test Scheduled tasks"""
 | 
				
			||||||
 | 
					        sync()
 | 
				
			||||||
							
								
								
									
										93
									
								
								passbook/sources/ldap/tests/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								passbook/sources/ldap/tests/utils.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,93 @@
 | 
				
			|||||||
 | 
					"""ldap testing utils"""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _build_mock_connection(password: str) -> Connection:
 | 
				
			||||||
 | 
					    """Create mock connection"""
 | 
				
			||||||
 | 
					    server = Server("my_fake_server", get_info=OFFLINE_AD_2012_R2)
 | 
				
			||||||
 | 
					    _pass = "foo"  # noqa # nosec
 | 
				
			||||||
 | 
					    connection = Connection(
 | 
				
			||||||
 | 
					        server,
 | 
				
			||||||
 | 
					        user="cn=my_user,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        password=_pass,
 | 
				
			||||||
 | 
					        client_strategy=MOCK_SYNC,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # Entry for password checking
 | 
				
			||||||
 | 
					    connection.strategy.add_entry(
 | 
				
			||||||
 | 
					        "cn=user,ou=users,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "name": "test-user",
 | 
				
			||||||
 | 
					            "objectSid": "unique-test-group",
 | 
				
			||||||
 | 
					            "objectCategory": "Person",
 | 
				
			||||||
 | 
					            "displayName": "Erin M. Hagens",
 | 
				
			||||||
 | 
					            "sAMAccountName": "sAMAccountName",
 | 
				
			||||||
 | 
					            "distinguishedName": "cn=user,ou=users,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    connection.strategy.add_entry(
 | 
				
			||||||
 | 
					        "cn=group1,ou=groups,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "name": "test-group",
 | 
				
			||||||
 | 
					            "objectSid": "unique-test-group",
 | 
				
			||||||
 | 
					            "objectCategory": "Group",
 | 
				
			||||||
 | 
					            "distinguishedName": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # Group without SID
 | 
				
			||||||
 | 
					    connection.strategy.add_entry(
 | 
				
			||||||
 | 
					        "cn=group2,ou=groups,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "name": "test-group",
 | 
				
			||||||
 | 
					            "objectCategory": "Group",
 | 
				
			||||||
 | 
					            "distinguishedName": "cn=group2,ou=groups,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    connection.strategy.add_entry(
 | 
				
			||||||
 | 
					        "cn=user0,ou=users,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "userPassword": password,
 | 
				
			||||||
 | 
					            "sAMAccountName": "user0_sn",
 | 
				
			||||||
 | 
					            "name": "user0_sn",
 | 
				
			||||||
 | 
					            "revision": 0,
 | 
				
			||||||
 | 
					            "objectSid": "user0",
 | 
				
			||||||
 | 
					            "objectCategory": "Person",
 | 
				
			||||||
 | 
					            "memberOf": "cn=group1,ou=groups,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # User without SID
 | 
				
			||||||
 | 
					    connection.strategy.add_entry(
 | 
				
			||||||
 | 
					        "cn=user1,ou=users,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "userPassword": "test1111",
 | 
				
			||||||
 | 
					            "sAMAccountName": "user2_sn",
 | 
				
			||||||
 | 
					            "name": "user1_sn",
 | 
				
			||||||
 | 
					            "revision": 0,
 | 
				
			||||||
 | 
					            "objectCategory": "Person",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    # Duplicate users
 | 
				
			||||||
 | 
					    connection.strategy.add_entry(
 | 
				
			||||||
 | 
					        "cn=user2,ou=users,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "userPassword": "test2222",
 | 
				
			||||||
 | 
					            "sAMAccountName": "user2_sn",
 | 
				
			||||||
 | 
					            "name": "user2_sn",
 | 
				
			||||||
 | 
					            "revision": 0,
 | 
				
			||||||
 | 
					            "objectSid": "unique-test2222",
 | 
				
			||||||
 | 
					            "objectCategory": "Person",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    connection.strategy.add_entry(
 | 
				
			||||||
 | 
					        "cn=user3,ou=users,DC=AD2012,DC=LAB",
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					            "userPassword": "test2222",
 | 
				
			||||||
 | 
					            "sAMAccountName": "user2_sn",
 | 
				
			||||||
 | 
					            "name": "user2_sn",
 | 
				
			||||||
 | 
					            "revision": 0,
 | 
				
			||||||
 | 
					            "objectSid": "unique-test2222",
 | 
				
			||||||
 | 
					            "objectCategory": "Person",
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    connection.bind()
 | 
				
			||||||
 | 
					    return connection
 | 
				
			||||||
@ -182,7 +182,7 @@ class OAuthCallback(OAuthClientMixin, View):
 | 
				
			|||||||
        access.save()
 | 
					        access.save()
 | 
				
			||||||
        UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
 | 
					        UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
 | 
				
			||||||
        Event.new(
 | 
					        Event.new(
 | 
				
			||||||
            EventAction.CUSTOM, message="Linked OAuth Source", source=source
 | 
					            EventAction.SOURCE_LINKED, message="Linked OAuth Source", source=source
 | 
				
			||||||
        ).from_http(self.request)
 | 
					        ).from_http(self.request)
 | 
				
			||||||
        messages.success(
 | 
					        messages.success(
 | 
				
			||||||
            self.request,
 | 
					            self.request,
 | 
				
			||||||
 | 
				
			|||||||
@ -23,6 +23,8 @@ class PostUserEnrollmentStage(StageView):
 | 
				
			|||||||
        access.save()
 | 
					        access.save()
 | 
				
			||||||
        UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
 | 
					        UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
 | 
				
			||||||
        Event.new(
 | 
					        Event.new(
 | 
				
			||||||
            EventAction.CUSTOM, message="Linked OAuth Source", source=access.source
 | 
					            EventAction.SOURCE_LINKED,
 | 
				
			||||||
 | 
					            message="Linked OAuth Source",
 | 
				
			||||||
 | 
					            source=access.source,
 | 
				
			||||||
        ).from_http(self.request)
 | 
					        ).from_http(self.request)
 | 
				
			||||||
        return self.executor.stage_ok()
 | 
					        return self.executor.stage_ok()
 | 
				
			||||||
 | 
				
			|||||||
@ -7,7 +7,7 @@ from django.views import View
 | 
				
			|||||||
from django.views.generic import TemplateView
 | 
					from django.views.generic import TemplateView
 | 
				
			||||||
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
 | 
					from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.audit.models import Event, EventAction
 | 
					from passbook.audit.models import Event
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserSettingsView(LoginRequiredMixin, TemplateView):
 | 
					class UserSettingsView(LoginRequiredMixin, TemplateView):
 | 
				
			||||||
@ -36,6 +36,6 @@ class DisableView(LoginRequiredMixin, View):
 | 
				
			|||||||
        messages.success(request, "Successfully disabled Static OTP Tokens")
 | 
					        messages.success(request, "Successfully disabled Static OTP Tokens")
 | 
				
			||||||
        # Create event with email notification
 | 
					        # Create event with email notification
 | 
				
			||||||
        Event.new(
 | 
					        Event.new(
 | 
				
			||||||
            EventAction.CUSTOM, message="User disabled Static OTP Tokens."
 | 
					            "static_otp_disable", message="User disabled Static OTP Tokens."
 | 
				
			||||||
        ).from_http(request)
 | 
					        ).from_http(request)
 | 
				
			||||||
        return redirect("passbook_stages_otp:otp-user-settings")
 | 
					        return redirect("passbook_stages_otp:otp-user-settings")
 | 
				
			||||||
 | 
				
			|||||||
@ -7,7 +7,7 @@ from django.views import View
 | 
				
			|||||||
from django.views.generic import TemplateView
 | 
					from django.views.generic import TemplateView
 | 
				
			||||||
from django_otp.plugins.otp_totp.models import TOTPDevice
 | 
					from django_otp.plugins.otp_totp.models import TOTPDevice
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from passbook.audit.models import Event, EventAction
 | 
					from passbook.audit.models import Event
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UserSettingsView(LoginRequiredMixin, TemplateView):
 | 
					class UserSettingsView(LoginRequiredMixin, TemplateView):
 | 
				
			||||||
@ -32,7 +32,7 @@ class DisableView(LoginRequiredMixin, View):
 | 
				
			|||||||
        totp.delete()
 | 
					        totp.delete()
 | 
				
			||||||
        messages.success(request, "Successfully disabled Time-based OTP")
 | 
					        messages.success(request, "Successfully disabled Time-based OTP")
 | 
				
			||||||
        # Create event with email notification
 | 
					        # Create event with email notification
 | 
				
			||||||
        Event.new(
 | 
					        Event.new("totp_disable", message="User disabled Time-based OTP.").from_http(
 | 
				
			||||||
            EventAction.CUSTOM, message="User disabled Time-based OTP."
 | 
					            request
 | 
				
			||||||
        ).from_http(request)
 | 
					        )
 | 
				
			||||||
        return redirect("passbook_stages_otp:otp-user-settings")
 | 
					        return redirect("passbook_stages_otp:otp-user-settings")
 | 
				
			||||||
 | 
				
			|||||||
@ -8,18 +8,11 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
 | 
				
			|||||||
from passbook.flows.models import FlowDesignation
 | 
					from passbook.flows.models import FlowDesignation
 | 
				
			||||||
from passbook.stages.prompt.models import FieldTypes
 | 
					from passbook.stages.prompt.models import FieldTypes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
PROMPT_POLICY_EXPRESSION = """# Check that both passwords are equal.
 | 
					 | 
				
			||||||
return request.context['password'] == request.context['password_repeat']"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
					def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
 | 
				
			||||||
    Flow = apps.get_model("passbook_flows", "Flow")
 | 
					    Flow = apps.get_model("passbook_flows", "Flow")
 | 
				
			||||||
    FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
 | 
					    FlowStageBinding = apps.get_model("passbook_flows", "FlowStageBinding")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    ExpressionPolicy = apps.get_model(
 | 
					 | 
				
			||||||
        "passbook_policies_expression", "ExpressionPolicy"
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage")
 | 
					    PromptStage = apps.get_model("passbook_stages_prompt", "PromptStage")
 | 
				
			||||||
    Prompt = apps.get_model("passbook_stages_prompt", "Prompt")
 | 
					    Prompt = apps.get_model("passbook_stages_prompt", "Prompt")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -57,15 +50,8 @@ def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchema
 | 
				
			|||||||
        },
 | 
					        },
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Policy to only trigger prompt when no username is given
 | 
					 | 
				
			||||||
    prompt_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
 | 
					 | 
				
			||||||
        name="default-password-change-password-equal",
 | 
					 | 
				
			||||||
        defaults={"expression": PROMPT_POLICY_EXPRESSION},
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    prompt_stage.fields.add(password_prompt)
 | 
					    prompt_stage.fields.add(password_prompt)
 | 
				
			||||||
    prompt_stage.fields.add(password_rep_prompt)
 | 
					    prompt_stage.fields.add(password_rep_prompt)
 | 
				
			||||||
    prompt_stage.validation_policies.add(prompt_policy)
 | 
					 | 
				
			||||||
    prompt_stage.save()
 | 
					    prompt_stage.save()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create(
 | 
					    user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create(
 | 
				
			||||||
@ -100,7 +86,6 @@ class Migration(migrations.Migration):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    dependencies = [
 | 
					    dependencies = [
 | 
				
			||||||
        ("passbook_flows", "0006_auto_20200629_0857"),
 | 
					        ("passbook_flows", "0006_auto_20200629_0857"),
 | 
				
			||||||
        ("passbook_policies_expression", "0001_initial"),
 | 
					 | 
				
			||||||
        ("passbook_stages_password", "0001_initial"),
 | 
					        ("passbook_stages_password", "0001_initial"),
 | 
				
			||||||
        ("passbook_stages_prompt", "0001_initial"),
 | 
					        ("passbook_stages_prompt", "0001_initial"),
 | 
				
			||||||
        ("passbook_stages_user_write", "0001_initial"),
 | 
					        ("passbook_stages_user_write", "0001_initial"),
 | 
				
			||||||
 | 
				
			|||||||
@ -1,9 +1,11 @@
 | 
				
			|||||||
"""Prompt forms"""
 | 
					"""Prompt forms"""
 | 
				
			||||||
from email.policy import Policy
 | 
					from email.policy import Policy
 | 
				
			||||||
from typing import Callable, Iterator, List
 | 
					from types import MethodType
 | 
				
			||||||
 | 
					from typing import Any, Callable, Iterator, List
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from django import forms
 | 
					from django import forms
 | 
				
			||||||
from django.contrib.admin.widgets import FilteredSelectMultiple
 | 
					from django.contrib.admin.widgets import FilteredSelectMultiple
 | 
				
			||||||
 | 
					from django.db.models.query import QuerySet
 | 
				
			||||||
from django.http import HttpRequest
 | 
					from django.http import HttpRequest
 | 
				
			||||||
from django.utils.translation import gettext_lazy as _
 | 
					from django.utils.translation import gettext_lazy as _
 | 
				
			||||||
from guardian.shortcuts import get_anonymous_user
 | 
					from guardian.shortcuts import get_anonymous_user
 | 
				
			||||||
@ -13,6 +15,7 @@ from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
 | 
				
			|||||||
from passbook.policies.engine import PolicyEngine
 | 
					from passbook.policies.engine import PolicyEngine
 | 
				
			||||||
from passbook.policies.models import PolicyBinding, PolicyBindingModel
 | 
					from passbook.policies.models import PolicyBinding, PolicyBindingModel
 | 
				
			||||||
from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
 | 
					from passbook.stages.prompt.models import FieldTypes, Prompt, PromptStage
 | 
				
			||||||
 | 
					from passbook.stages.prompt.signals import password_validate
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class PromptStageForm(forms.ModelForm):
 | 
					class PromptStageForm(forms.ModelForm):
 | 
				
			||||||
@ -86,12 +89,35 @@ class PromptForm(forms.Form):
 | 
				
			|||||||
                setattr(
 | 
					                setattr(
 | 
				
			||||||
                    self,
 | 
					                    self,
 | 
				
			||||||
                    f"clean_{field.field_key}",
 | 
					                    f"clean_{field.field_key}",
 | 
				
			||||||
                    username_field_cleaner_generator(field),
 | 
					                    MethodType(username_field_cleaner_factory(field), self),
 | 
				
			||||||
                )
 | 
					                )
 | 
				
			||||||
 | 
					            # Check if we have a password field, add a handler that sends a signal
 | 
				
			||||||
 | 
					            # to validate it
 | 
				
			||||||
 | 
					            if field.type == FieldTypes.PASSWORD:
 | 
				
			||||||
 | 
					                setattr(
 | 
				
			||||||
 | 
					                    self,
 | 
				
			||||||
 | 
					                    f"clean_{field.field_key}",
 | 
				
			||||||
 | 
					                    MethodType(password_single_cleaner_factory(field), self),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.field_order = sorted(fields, key=lambda x: x.order)
 | 
					        self.field_order = sorted(fields, key=lambda x: x.order)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _clean_password_fields(self, *field_names):
 | 
				
			||||||
 | 
					        """Check if the value of all password fields match by merging them into a set
 | 
				
			||||||
 | 
					        and checking the length"""
 | 
				
			||||||
 | 
					        all_passwords = {self.cleaned_data[x] for x in field_names}
 | 
				
			||||||
 | 
					        if len(all_passwords) > 1:
 | 
				
			||||||
 | 
					            raise forms.ValidationError(_("Passwords don't match."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def clean(self):
 | 
					    def clean(self):
 | 
				
			||||||
        cleaned_data = super().clean()
 | 
					        cleaned_data = super().clean()
 | 
				
			||||||
 | 
					        # Check if we have two password fields, and make sure they are the same
 | 
				
			||||||
 | 
					        password_fields: QuerySet[Prompt] = self.stage.fields.filter(
 | 
				
			||||||
 | 
					            type=FieldTypes.PASSWORD
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if password_fields.exists() and password_fields.count() == 2:
 | 
				
			||||||
 | 
					            self._clean_password_fields(*[field.field_key for field in password_fields])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user())
 | 
					        user = self.plan.context.get(PLAN_CONTEXT_PENDING_USER, get_anonymous_user())
 | 
				
			||||||
        engine = ListPolicyEngine(self.stage.validation_policies.all(), user)
 | 
					        engine = ListPolicyEngine(self.stage.validation_policies.all(), user)
 | 
				
			||||||
        engine.request.context = cleaned_data
 | 
					        engine.request.context = cleaned_data
 | 
				
			||||||
@ -101,13 +127,28 @@ class PromptForm(forms.Form):
 | 
				
			|||||||
            raise forms.ValidationError(list(result.messages))
 | 
					            raise forms.ValidationError(list(result.messages))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def username_field_cleaner_generator(field: Prompt) -> Callable:
 | 
					def username_field_cleaner_factory(field: Prompt) -> Callable:
 | 
				
			||||||
    """Return a `clean_` method for `field`. Clean method checks if username is taken already."""
 | 
					    """Return a `clean_` method for `field`. Clean method checks if username is taken already."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def username_field_cleaner(self: PromptForm):
 | 
					    def username_field_cleaner(self: PromptForm) -> Any:
 | 
				
			||||||
        """Check for duplicate usernames"""
 | 
					        """Check for duplicate usernames"""
 | 
				
			||||||
        username = self.cleaned_data.get(field.field_key)
 | 
					        username = self.cleaned_data.get(field.field_key)
 | 
				
			||||||
        if User.objects.filter(username=username).exists():
 | 
					        if User.objects.filter(username=username).exists():
 | 
				
			||||||
            raise forms.ValidationError("Username is already taken.")
 | 
					            raise forms.ValidationError("Username is already taken.")
 | 
				
			||||||
 | 
					        return username
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return username_field_cleaner
 | 
					    return username_field_cleaner
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def password_single_cleaner_factory(field: Prompt) -> Callable[[PromptForm], Any]:
 | 
				
			||||||
 | 
					    """Return a `clean_` method for `field`. Clean method checks if username is taken already."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def password_single_clean(self: PromptForm) -> Any:
 | 
				
			||||||
 | 
					        """Send password validation signals for e.g. LDAP Source"""
 | 
				
			||||||
 | 
					        password = self.cleaned_data[field.field_key]
 | 
				
			||||||
 | 
					        password_validate.send(
 | 
				
			||||||
 | 
					            sender=self, password=password, plan_context=self.plan.context
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return password
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return password_single_clean
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										42
									
								
								passbook/stages/prompt/migrations/0002_auto_20200920_1859.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								passbook/stages/prompt/migrations/0002_auto_20200920_1859.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					# Generated by Django 3.1.1 on 2020-09-20 18:59
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from django.db import migrations, models
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Migration(migrations.Migration):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    dependencies = [
 | 
				
			||||||
 | 
					        ("passbook_stages_prompt", "0001_initial"),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    operations = [
 | 
				
			||||||
 | 
					        migrations.AlterField(
 | 
				
			||||||
 | 
					            model_name="prompt",
 | 
				
			||||||
 | 
					            name="type",
 | 
				
			||||||
 | 
					            field=models.CharField(
 | 
				
			||||||
 | 
					                choices=[
 | 
				
			||||||
 | 
					                    ("text", "Text: Simple Text input"),
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        "username",
 | 
				
			||||||
 | 
					                        "Username: Same as Text input, but checks for and prevents duplicate usernames.",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    ("email", "Email: Text field with Email type."),
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        "password",
 | 
				
			||||||
 | 
					                        "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    ("number", "Number"),
 | 
				
			||||||
 | 
					                    ("checkbox", "Checkbox"),
 | 
				
			||||||
 | 
					                    ("data", "Date"),
 | 
				
			||||||
 | 
					                    ("data-time", "Date Time"),
 | 
				
			||||||
 | 
					                    ("separator", "Separator: Static Separator Line"),
 | 
				
			||||||
 | 
					                    (
 | 
				
			||||||
 | 
					                        "hidden",
 | 
				
			||||||
 | 
					                        "Hidden: Hidden field, can be used to insert data into form.",
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                    ("static", "Static: Static value, displayed as-is."),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					                max_length=100,
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
@ -31,7 +31,16 @@ class FieldTypes(models.TextChoices):
 | 
				
			|||||||
        ),
 | 
					        ),
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    EMAIL = "email", _("Email: Text field with Email type.")
 | 
					    EMAIL = "email", _("Email: Text field with Email type.")
 | 
				
			||||||
    PASSWORD = "password"  # noqa # nosec
 | 
					    PASSWORD = (
 | 
				
			||||||
 | 
					        "password",  # noqa # nosec
 | 
				
			||||||
 | 
					        _(
 | 
				
			||||||
 | 
					            (
 | 
				
			||||||
 | 
					                "Password: Masked input, password is validated against sources. Policies still "
 | 
				
			||||||
 | 
					                "have to be applied to this Stage. If two of these are used in the same stage, "
 | 
				
			||||||
 | 
					                "they are ensured to be identical."
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
    NUMBER = "number"
 | 
					    NUMBER = "number"
 | 
				
			||||||
    CHECKBOX = "checkbox"
 | 
					    CHECKBOX = "checkbox"
 | 
				
			||||||
    DATE = "data"
 | 
					    DATE = "data"
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										4
									
								
								passbook/stages/prompt/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								passbook/stages/prompt/signals.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,4 @@
 | 
				
			|||||||
 | 
					"""passbook prompt stage signals"""
 | 
				
			||||||
 | 
					from django.core.signals import Signal
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					password_validate = Signal(providing_args=["password", "plan_context"])
 | 
				
			||||||
@ -49,6 +49,13 @@
 | 
				
			|||||||
    max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height);
 | 
					    max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.pf-m-success {
 | 
				
			||||||
 | 
					    color: var(--pf-global--success-color--100);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.pf-m-danger {
 | 
				
			||||||
 | 
					    color: var(--pf-global--danger-color--100);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/* fix multiple selects height */
 | 
					/* fix multiple selects height */
 | 
				
			||||||
select[multiple] {
 | 
					select[multiple] {
 | 
				
			||||||
    height: initial;
 | 
					    height: initial;
 | 
				
			||||||
 | 
				
			|||||||
@ -51,8 +51,10 @@ func (pb *providerBundle) prepareOpts(provider *models.ProxyOutpostConfig) *opti
 | 
				
			|||||||
	providerOpts.OIDCJwksURL = *provider.OidcConfiguration.JwksURI
 | 
						providerOpts.OIDCJwksURL = *provider.OidcConfiguration.JwksURI
 | 
				
			||||||
	providerOpts.ProfileURL = *provider.OidcConfiguration.UserinfoEndpoint
 | 
						providerOpts.ProfileURL = *provider.OidcConfiguration.UserinfoEndpoint
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if provider.SkipPathRegex != "" {
 | 
				
			||||||
		skipRegexes := strings.Split(provider.SkipPathRegex, "\n")
 | 
							skipRegexes := strings.Split(provider.SkipPathRegex, "\n")
 | 
				
			||||||
		providerOpts.SkipAuthRegex = skipRegexes
 | 
							providerOpts.SkipAuthRegex = skipRegexes
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	providerOpts.UpstreamServers = []options.Upstream{
 | 
						providerOpts.UpstreamServers = []options.Upstream{
 | 
				
			||||||
		{
 | 
							{
 | 
				
			||||||
 | 
				
			|||||||
@ -1,3 +1,3 @@
 | 
				
			|||||||
package pkg
 | 
					package pkg
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const VERSION = "0.10.4-stable"
 | 
					const VERSION = "0.10.6-stable"
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										36
									
								
								swagger.yaml
									
									
									
									
									
								
							
							
						
						
									
										36
									
								
								swagger.yaml
									
									
									
									
									
								
							@ -5831,24 +5831,27 @@ definitions:
 | 
				
			|||||||
        readOnly: true
 | 
					        readOnly: true
 | 
				
			||||||
      user:
 | 
					      user:
 | 
				
			||||||
        title: User
 | 
					        title: User
 | 
				
			||||||
        type: integer
 | 
					        type: string
 | 
				
			||||||
        x-nullable: true
 | 
					 | 
				
			||||||
      action:
 | 
					      action:
 | 
				
			||||||
        title: Action
 | 
					        title: Action
 | 
				
			||||||
        type: string
 | 
					        type: string
 | 
				
			||||||
        enum:
 | 
					        enum:
 | 
				
			||||||
          - LOGIN
 | 
					          - login
 | 
				
			||||||
          - LOGIN_FAILED
 | 
					          - login_failed
 | 
				
			||||||
          - LOGOUT
 | 
					          - logout
 | 
				
			||||||
          - AUTHORIZE_APPLICATION
 | 
					          - sign_up
 | 
				
			||||||
          - SUSPICIOUS_REQUEST
 | 
					          - authorize_application
 | 
				
			||||||
          - SIGN_UP
 | 
					          - suspicious_request
 | 
				
			||||||
          - PASSWORD_RESET
 | 
					          - password_set
 | 
				
			||||||
          - INVITE_CREATED
 | 
					          - invitation_created
 | 
				
			||||||
          - INVITE_USED
 | 
					          - invitation_used
 | 
				
			||||||
          - IMPERSONATION_STARTED
 | 
					          - source_linked
 | 
				
			||||||
          - IMPERSONATION_ENDED
 | 
					          - impersonation_started
 | 
				
			||||||
          - CUSTOM
 | 
					          - impersonation_ended
 | 
				
			||||||
 | 
					          - model_created
 | 
				
			||||||
 | 
					          - model_updated
 | 
				
			||||||
 | 
					          - model_deleted
 | 
				
			||||||
 | 
					          - custom_
 | 
				
			||||||
      date:
 | 
					      date:
 | 
				
			||||||
        title: Date
 | 
					        title: Date
 | 
				
			||||||
        type: string
 | 
					        type: string
 | 
				
			||||||
@ -6945,6 +6948,11 @@ definitions:
 | 
				
			|||||||
      sync_users:
 | 
					      sync_users:
 | 
				
			||||||
        title: Sync users
 | 
					        title: Sync users
 | 
				
			||||||
        type: boolean
 | 
					        type: boolean
 | 
				
			||||||
 | 
					      sync_users_password:
 | 
				
			||||||
 | 
					        title: Sync users password
 | 
				
			||||||
 | 
					        description: When a user changes their password, sync it back to LDAP. This
 | 
				
			||||||
 | 
					          can only be enabled on a single LDAP source.
 | 
				
			||||||
 | 
					        type: boolean
 | 
				
			||||||
      sync_groups:
 | 
					      sync_groups:
 | 
				
			||||||
        title: Sync groups
 | 
					        title: Sync groups
 | 
				
			||||||
        type: boolean
 | 
					        type: boolean
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user