Compare commits

...

24 Commits

Author SHA1 Message Date
48a04744e0 new release: 0.6.4-beta 2019-10-10 16:09:38 +02:00
6446ca8bb2 Merge branch '19-lockout-prevention' into 'master'
add lockout prevention

See merge request BeryJu.org/passbook!27
2019-10-10 12:37:14 +00:00
b9991465ee recovery(new): add recovery app to create recovery links 2019-10-10 14:05:16 +02:00
3d8242be06 core(minor): add new, optional description field to nonce 2019-10-10 14:04:58 +02:00
ca3bcc565d ui(minor): simplify top navigation 2019-10-10 10:02:48 +02:00
432176ea2f docker(minor): give user a fixed UID, use --chown flag for docker COPY 2019-10-10 09:36:28 +02:00
c1dae0b599 sources/oauth(minor): fix wrong settings reference 2019-10-09 19:46:23 +02:00
e70d3b6286 new release: 0.6.3-beta 2019-10-09 14:44:50 +02:00
17e6bc921b core(minor): fix import order 2019-10-09 14:37:40 +02:00
46111e7cac deploy(minor): downgrade kombu to fix redis error
https://github.com/celery/kombu/issues/1063
2019-10-09 14:32:20 +02:00
3b7e47dbe2 settings(minor): use cached_db for session, use localhost as domain 2019-10-09 14:30:53 +02:00
fff99f0e3d deploy(minor): use SERVER_TAG, fix static container 2019-10-09 14:29:44 +02:00
2e15b24f0a *(minor): switch has_user_settings to return Optional dataclass instead of tuple 2019-10-09 12:47:14 +02:00
088b9592cd core(minor): remove unused code 2019-10-08 15:04:38 +02:00
b1e4e32b83 providers/oidc(minor): correctly create audit entry on authz 2019-10-08 14:34:59 +02:00
d91a852eda factors/email(minor): start rebuilding email integration as factor 2019-10-08 14:30:17 +02:00
171c5b9759 factors/password(minor): remove form from core 2019-10-08 14:23:02 +02:00
64290b2a37 admin(minor): add view to create user 2019-10-08 11:27:19 +02:00
72769b8a0a lib(minor): cleanup default settings 2019-10-08 10:44:44 +02:00
1018309413 helm(minor): cleanup configmap, move secret_key to k8s secret 2019-10-08 10:44:25 +02:00
6d0ecd228e new release: 0.6.2-beta 2019-10-07 21:24:56 +02:00
40a651e66c docker(minor): ensure passbook user can write 2019-10-07 21:23:38 +02:00
a390bb7b59 factors/otp(minor): fix old URLs 2019-10-07 21:23:25 +02:00
245ec65cbb helm(minor): remove default postgres password 2019-10-07 21:23:15 +02:00
67 changed files with 663 additions and 464 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.6.1-beta current_version = 0.6.4-beta
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>.*)

View File

@ -27,7 +27,7 @@ create-base-image:
before_script: before_script:
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
script: script:
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/base.Dockerfile --destination docker.beryju.org/passbook/base:latest --destination docker.beryju.org/passbook/base:0.6.1-beta - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/base.Dockerfile --destination docker.beryju.org/passbook/base:latest --destination docker.beryju.org/passbook/base:0.6.4-beta
stage: build-base-image stage: build-base-image
only: only:
refs: refs:
@ -41,7 +41,7 @@ build-dev-image:
before_script: before_script:
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
script: script:
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/dev.Dockerfile --destination docker.beryju.org/passbook/dev:latest --destination docker.beryju.org/passbook/dev:0.6.1-beta - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/dev.Dockerfile --destination docker.beryju.org/passbook/dev:latest --destination docker.beryju.org/passbook/dev:0.6.4-beta
stage: build-dev-image stage: build-dev-image
only: only:
refs: refs:
@ -95,7 +95,7 @@ build-passbook-server:
before_script: before_script:
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
script: script:
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination docker.beryju.org/passbook/server:latest --destination docker.beryju.org/passbook/server:0.6.1-beta - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination docker.beryju.org/passbook/server:latest --destination docker.beryju.org/passbook/server:0.6.4-beta
only: only:
- tags - tags
- /^version/.*$/ - /^version/.*$/
@ -107,7 +107,7 @@ build-passbook-static:
before_script: before_script:
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
script: script:
- /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/static.Dockerfile --destination docker.beryju.org/passbook/static:latest --destination docker.beryju.org/passbook/static:0.6.1-beta - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/static.Dockerfile --destination docker.beryju.org/passbook/static:latest --destination docker.beryju.org/passbook/static:0.6.4-beta
only: only:
- tags - tags
- /^version/.*$/ - /^version/.*$/

View File

@ -1,9 +1,9 @@
FROM docker.beryju.org/passbook/base:latest FROM docker.beryju.org/passbook/base:latest
COPY ./passbook/ /app/passbook COPY --chown=passbook:passbook ./passbook/ /app/passbook
COPY ./manage.py /app/ COPY ./manage.py /app/
COPY ./docker/uwsgi.ini /app/ COPY ./docker/uwsgi.ini /app/
USER passbook
WORKDIR /app/ WORKDIR /app/
USER passbook

View File

@ -8,6 +8,7 @@ celery = "*"
cherrypy = "*" cherrypy = "*"
defusedxml = "*" defusedxml = "*"
django = "*" django = "*"
kombu = "==4.5.0"
django-cors-middleware = "*" django-cors-middleware = "*"
django-filters = "*" django-filters = "*"
django-ipware = "*" django-ipware = "*"
@ -18,7 +19,6 @@ django-otp = "*"
django-recaptcha = "*" django-recaptcha = "*"
django-redis = "*" django-redis = "*"
django-rest-framework = "*" django-rest-framework = "*"
djangorestframework = "==3.9.4"
drf-yasg = "*" drf-yasg = "*"
ldap3 = "*" ldap3 = "*"
lxml = "*" lxml = "*"

60
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "d03d1e494d28a90b39edd1d489afdb5e39ec09bceb18daa2a54b2cc7de61d83c" "sha256": "53d7190ea62f504dc1a36eae952a273e0b2d9f313f23031099d039c3146235b7"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -18,17 +18,17 @@
"default": { "default": {
"amqp": { "amqp": {
"hashes": [ "hashes": [
"sha256:19a917e260178b8d410122712bac69cb3e6db010d68f6101e7307508aded5e68", "sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
"sha256:19d851b879a471fcfdcf01df9936cff924f422baa77653289f7095dedd5fb26a" "sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
], ],
"version": "==2.5.1" "version": "==2.5.2"
}, },
"asn1crypto": { "asn1crypto": {
"hashes": [ "hashes": [
"sha256:d02bf8ea1b964a5ff04ac7891fe3a39150045d1e5e4fe99273ba677d11b92a04", "sha256:0b199f211ae690df3db4fd6c1c4ff976497fb1da689193e368eedbadc53d9292",
"sha256:f822954b90c4c44f002e2cd46d636ab630f1fe4df22c816a82b66505c404eb2a" "sha256:bca90060bd995c3f62c4433168eab407e44bdbdb567b3f3a396a676c1a4c4a3f"
], ],
"version": "==1.0.0" "version": "==1.0.1"
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
@ -242,11 +242,10 @@
}, },
"djangorestframework": { "djangorestframework": {
"hashes": [ "hashes": [
"sha256:376f4b50340a46c15ae15ddd0c853085f4e66058f97e4dbe7d43ed62f5e60651", "sha256:5488aed8f8df5ec1d70f04b2114abc52ae6729748a176c453313834a9ee179c8",
"sha256:c12869cfd83c33d579b17b3cb28a2ae7322a53c3ce85580c2a2ebe4e3f56c4fb" "sha256:dc81cbf9775c6898a580f6f1f387c4777d12bd87abf0f5406018d32ccae71090"
], ],
"index": "pypi", "version": "==3.10.3"
"version": "==3.9.4"
}, },
"drf-yasg": { "drf-yasg": {
"hashes": [ "hashes": [
@ -276,13 +275,6 @@
], ],
"version": "==2.8" "version": "==2.8"
}, },
"importlib-metadata": {
"hashes": [
"sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26",
"sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"
],
"version": "==0.23"
},
"inflection": { "inflection": {
"hashes": [ "hashes": [
"sha256:18ea7fb7a7d152853386523def08736aa8c32636b047ade55f7578c4edeb16ca" "sha256:18ea7fb7a7d152853386523def08736aa8c32636b047ade55f7578c4edeb16ca"
@ -304,17 +296,18 @@
}, },
"jinja2": { "jinja2": {
"hashes": [ "hashes": [
"sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f",
"sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"
], ],
"version": "==2.10.1" "version": "==2.10.3"
}, },
"kombu": { "kombu": {
"hashes": [ "hashes": [
"sha256:31edb84947996fdda065b6560c128d5673bb913ff34aa19e7b84755217a24deb", "sha256:389ba09e03b15b55b1a7371a441c894fd8121d174f5583bbbca032b9ea8c9edd",
"sha256:c9078124ce2616b29cf6607f0ac3db894c59154252dee6392cdbbe15e5c4b566" "sha256:7b92303af381ef02fad6899fd5f5a9a96031d781356cd8e505fa54ae5ddee181"
], ],
"version": "==4.6.5" "index": "pypi",
"version": "==4.5.0"
}, },
"ldap3": { "ldap3": {
"hashes": [ "hashes": [
@ -566,10 +559,10 @@
}, },
"pytz": { "pytz": {
"hashes": [ "hashes": [
"sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
"sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
], ],
"version": "==2019.2" "version": "==2019.3"
}, },
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
@ -736,13 +729,6 @@
"sha256:cc33599b549f0c8a248cb72f3bf32d77712de1ff7ee8814312eb6456b42c015f" "sha256:cc33599b549f0c8a248cb72f3bf32d77712de1ff7ee8814312eb6456b42c015f"
], ],
"version": "==2.0" "version": "==2.0"
},
"zipp": {
"hashes": [
"sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e",
"sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"
],
"version": "==0.6.0"
} }
}, },
"develop": { "develop": {
@ -976,10 +962,10 @@
}, },
"pytz": { "pytz": {
"hashes": [ "hashes": [
"sha256:26c0b32e437e54a18161324a2fca3c4b9846b74a8dccddd843113109e1116b32", "sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
"sha256:c894d57500a4cd2d5c71114aaab77dbab5eabd9022308ce5ac9bb93a60a6f0c7" "sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
], ],
"version": "==2019.2" "version": "==2019.3"
}, },
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [

View File

@ -3,7 +3,9 @@
## Quick instance ## Quick instance
``` ```
export PASSBOOK_DOMAIN=domain.tld
docker-compose pull docker-compose pull
docker-compose up -d docker-compose up -d
docker-compose exec server ./manage.py migrate
docker-compose exec server ./manage.py createsuperuser docker-compose exec server ./manage.py createsuperuser
``` ```

View File

@ -15,5 +15,4 @@ RUN apt-get update && \
RUN pipenv lock -r > requirements.txt && \ RUN pipenv lock -r > requirements.txt && \
pipenv --rm && \ pipenv --rm && \
pip install -r requirements.txt --no-cache-dir && \ pip install -r requirements.txt --no-cache-dir && \
adduser --system --no-create-home passbook && \ adduser --system --no-create-home --uid 1000 --group --home /app passbook
chown -R passbook /app

View File

@ -20,28 +20,15 @@ services:
- internal - internal
labels: labels:
- traefik.enable=false - traefik.enable=false
database-migrate:
build:
context: .
image: docker.beryju.org/passbook/server:${TAG:-test}
command:
- ./manage.py
- migrate
networks:
- internal
restart: 'no'
environment:
- PASSBOOK_REDIS__HOST=redis
- PASSBOOK_POSTGRESQL__HOST=postgresql
- PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword}
server: server:
build: build:
context: . context: .
image: docker.beryju.org/passbook/server:${TAG:-test} image: docker.beryju.org/passbook/server:${SERVER_TAG:-latest}
command: command:
- uwsgi - uwsgi
- uwsgi.ini - uwsgi.ini
environment: environment:
- PASSBOOK_DOMAIN=${PASSBOOK_DOMAIN}
- PASSBOOK_REDIS__HOST=redis - PASSBOOK_REDIS__HOST=redis
- PASSBOOK_POSTGRESQL__HOST=postgresql - PASSBOOK_POSTGRESQL__HOST=postgresql
- PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword} - PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword}
@ -54,15 +41,20 @@ services:
- traefik.docker.network=internal - traefik.docker.network=internal
- traefik.frontend.rule=PathPrefix:/ - traefik.frontend.rule=PathPrefix:/
worker: worker:
image: docker.beryju.org/passbook/server:${TAG:-test} image: docker.beryju.org/passbook/server:${SERVER_TAG:-latest}
command: command:
- ./manage.py - celery
- worker - worker
- --autoscale=10,3
- -E
- -B
- -A=passbook.root.celery
networks: networks:
- internal - internal
labels: labels:
- traefik.enable=false - traefik.enable=false
environment: environment:
- PASSBOOK_DOMAIN=${PASSBOOK_DOMAIN}
- PASSBOOK_REDIS__HOST=redis - PASSBOOK_REDIS__HOST=redis
- PASSBOOK_POSTGRESQL__HOST=postgresql - PASSBOOK_POSTGRESQL__HOST=postgresql
- PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword} - PASSBOOK_POSTGRESQL__PASSWORD=${PG_PASS:-thisisnotagoodpassword}
@ -70,7 +62,7 @@ services:
build: build:
context: . context: .
dockerfile: static.Dockerfile dockerfile: static.Dockerfile
image: docker.beryju.org/passbook/static:${TAG:-test} image: docker.beryju.org/passbook/static:latest
networks: networks:
- internal - internal
labels: labels:

View File

@ -39,7 +39,7 @@ http {
gzip on; gzip on;
gzip_types application/javascript image/* text/css; gzip_types application/javascript image/* text/css;
gunzip on; gunzip on;
add_header X-passbook-Version 0.6.1-beta; add_header X-passbook-Version 0.6.4-beta;
add_header Vary X-passbook-Version; add_header Vary X-passbook-Version;
root /data/; root /data/;

View File

@ -1,6 +1,6 @@
apiVersion: v1 apiVersion: v1
appVersion: "0.6.1-beta" appVersion: "0.6.4-beta"
description: A Helm chart for passbook. description: A Helm chart for passbook.
name: passbook name: passbook
version: "0.6.1-beta" version: "0.6.4-beta"
icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png icon: https://git.beryju.org/uploads/-/system/project/avatar/108/logo.png

View File

@ -12,87 +12,5 @@ data:
host: "{{ .Release.Name }}-redis-master" host: "{{ .Release.Name }}-redis-master"
cache_db: 0 cache_db: 0
message_queue_db: 1 message_queue_db: 1
# Error reporting, sends stacktrace to sentry.beryju.org
error_report_enabled: {{ .Values.config.error_reporting }} error_report_enabled: {{ .Values.config.error_reporting }}
domain: ".{{ .Values.ingress.hosts[0] }}"
{{- if .Values.config.secret_key }}
secret_key: {{ .Values.config.secret_key }}
{{- else }}
secret_key: {{ randAlphaNum 50 }}
{{- end }}
primary_domain: {{ .Values.primary_domain }}
domains:
{{- range .Values.ingress.hosts }}
- {{ . | quote }}
{{- end }}
- kubernetes-healthcheck-host
passbook:
sign_up:
# Enables signup, created users are stored in internal Database and created in LDAP if ldap.create_users is true
enabled: true
password_reset:
# Enable password reset, passwords are reset in internal Database and in LDAP if ldap.reset_password is true
enabled: true
# Verification the user has to provide in order to be able to reset passwords. Can be any combination of `email`, `2fa`, `security_questions`
verification:
- email
# Text used in title, on login page and multiple other places
branding: passbook
login:
# Override URL used for logo
logo_url: null
# Override URL used for Background on Login page
bg_url: null
# Optionally add a subtext, placed below logo on the login page
subtext: null
footer:
links:
# Optionally add links to the footer on the login page
# - name: test
# href: https://test
# Specify which fields can be used to authenticate. Can be any combination of `username` and `email`
uid_fields:
- username
- email
session:
remember_age: 2592000 # 60 * 60 * 24 * 30, one month
# Provider-specific settings
ldap:
# # Completely enable or disable LDAP provider
# enabled: false
# # AD Domain, used to generate `userPrincipalName`
# domain: corp.contoso.com
# # Base DN in which passbook should look for users
# base_dn: dn=corp,dn=contoso,dn=com
# # LDAP field which is used to set the django username
# username_field: sAMAccountName
# # LDAP server to connect to, can be set to `<domain_name>`
# server:
# name: corp.contoso.com
# use_tls: false
# # Bind credentials, used for account creation
# bind:
# username: Administraotr@corp.contoso.com
# password: VerySecurePassword!
# Which field from `uid_fields` maps to which LDAP Attribute
login_field_map:
username: sAMAccountName
email: mail # or userPrincipalName
user_attribute_map:
active_directory:
username: "%(sAMAccountName)s"
email: "%(mail)s"
name: "%(displayName)"
# # Create new users in LDAP upon sign-up
# create_users: true
# # Reset LDAP password when user reset their password
# reset_password: true
saml_idp:
signing: true
autosubmit: false
issuer: passbook
assertion_valid_for: 86400
# List of python packages with provider types to load.

View File

@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: {{ include "passbook.fullname" . }}-secret-key
data:
{{- if .Values.config.secret_key }}
secret_key: {{ .Values.config.secret_key | b64enc | quote }}
{{- else }}
secret_key: {{ randAlphaNum 50 | b64enc | quote}}
{{- end }}

View File

@ -39,6 +39,11 @@ spec:
name: {{ include "passbook.fullname" . }}-config name: {{ include "passbook.fullname" . }}-config
prefix: PASSBOOK_ prefix: PASSBOOK_
env: env:
- name: PASSBOOK_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "passbook.fullname" . }}-secret-key
key: secret_key
- name: PASSBOOK_REDIS__PASSWORD - name: PASSBOOK_REDIS__PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
@ -65,6 +70,11 @@ spec:
name: {{ include "passbook.fullname" . }}-config name: {{ include "passbook.fullname" . }}-config
prefix: PASSBOOK_ prefix: PASSBOOK_
env: env:
- name: PASSBOOK_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "passbook.fullname" . }}-secret-key
key: secret_key
- name: PASSBOOK_REDIS__PASSWORD - name: PASSBOOK_REDIS__PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

View File

@ -44,6 +44,11 @@ spec:
name: {{ include "passbook.fullname" . }}-config name: {{ include "passbook.fullname" . }}-config
prefix: PASSBOOK_ prefix: PASSBOOK_
env: env:
- name: PASSBOOK_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "passbook.fullname" . }}-secret-key
key: secret_key
- name: PASSBOOK_REDIS__PASSWORD - name: PASSBOOK_REDIS__PASSWORD
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

View File

@ -2,7 +2,7 @@
# This is a YAML-formatted file. # This is a YAML-formatted file.
# Declare variables to be passed into your templates. # Declare variables to be passed into your templates.
image: image:
tag: 0.6.1-beta tag: 0.6.4-beta
nameOverride: "" nameOverride: ""
@ -16,7 +16,6 @@ config:
postgresql: postgresql:
postgresqlDatabase: passbook postgresqlDatabase: passbook
postgresqlPassword: foo
redis: redis:
cluster: cluster:

View File

@ -1,2 +1,2 @@
"""passbook""" """passbook"""
__version__ = '0.6.1-beta' __version__ = '0.6.4-beta'

View File

@ -7,6 +7,10 @@
<div class="container"> <div class="container">
<h1><span class="pficon-users"></span> {% trans "Users" %}</h1> <h1><span class="pficon-users"></span> {% trans "Users" %}</h1>
<hr> <hr>
<a href="{% url 'passbook_admin:user-create' %}?back={{ request.get_full_path }}" class="btn btn-primary">
{% trans 'Create...' %}
</a>
<hr>
<table class="table table-striped table-bordered"> <table class="table table-striped table-bordered">
<thead> <thead>
<tr> <tr>

View File

@ -61,6 +61,7 @@ urlpatterns = [
# Users # Users
path('users/', users.UserListView.as_view(), path('users/', users.UserListView.as_view(),
name='users'), name='users'),
path('users/create/', users.UserCreateView.as_view(), name='user-create'),
path('users/<int:pk>/update/', path('users/<int:pk>/update/',
users.UserUpdateView.as_view(), name='user-update'), users.UserUpdateView.as_view(), name='user-update'),
path('users/<int:pk>/delete/', path('users/<int:pk>/delete/',

View File

@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views import View from django.views import View
from django.views.generic import DeleteView, ListView, UpdateView from django.views.generic import CreateView, DeleteView, ListView, UpdateView
from passbook.admin.forms.users import UserForm from passbook.admin.forms.users import UserForm
from passbook.admin.mixins import AdminRequiredMixin from passbook.admin.mixins import AdminRequiredMixin
@ -19,6 +19,17 @@ class UserListView(AdminRequiredMixin, ListView):
template_name = 'administration/user/list.html' template_name = 'administration/user/list.html'
class UserCreateView(SuccessMessageMixin, AdminRequiredMixin, CreateView):
"""Create user"""
model = User
form_class = UserForm
template_name = 'generic/create.html'
success_url = reverse_lazy('passbook_admin:users')
success_message = _('Successfully created User')
class UserUpdateView(SuccessMessageMixin, AdminRequiredMixin, UpdateView): class UserUpdateView(SuccessMessageMixin, AdminRequiredMixin, UpdateView):
"""Update user""" """Update user"""

View File

@ -1,11 +1,5 @@
"""passbook core app config""" """passbook core app config"""
from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from structlog import get_logger
LOGGER = get_logger()
class PassbookCoreConfig(AppConfig): class PassbookCoreConfig(AppConfig):
@ -15,11 +9,3 @@ class PassbookCoreConfig(AppConfig):
label = 'passbook_core' label = 'passbook_core'
verbose_name = 'passbook Core' verbose_name = 'passbook Core'
mountpoint = '' mountpoint = ''
def ready(self):
for factors_to_load in settings.PASSBOOK_CORE_FACTORS:
try:
import_module(factors_to_load)
LOGGER.info("Loaded factor", factor_class=factors_to_load)
except ImportError as exc:
LOGGER.debug(exc)

View File

@ -81,13 +81,3 @@ class SignUpForm(forms.Form):
if password != password_repeat: if password != password_repeat:
raise ValidationError(_("Passwords don't match")) raise ValidationError(_("Passwords don't match"))
return self.cleaned_data.get('password_repeat') return self.cleaned_data.get('password_repeat')
class PasswordFactorForm(forms.Form):
"""Password authentication form"""
password = forms.CharField(widget=forms.PasswordInput(attrs={
'placeholder': _('Password'),
'autofocus': 'autofocus',
'autocomplete': 'current-password'
}))

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.6 on 2019-10-10 11:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='nonce',
name='description',
field=models.TextField(blank=True, default=''),
),
]

View File

@ -2,6 +2,7 @@
from datetime import timedelta from datetime import timedelta
from random import SystemRandom from random import SystemRandom
from time import sleep from time import sleep
from typing import Optional
from uuid import uuid4 from uuid import uuid4
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
@ -56,6 +57,7 @@ class User(AbstractUser):
self.password_change_date = now() self.password_change_date = now()
return super().set_password(password) return super().set_password(password)
class Provider(models.Model): class Provider(models.Model):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application""" """Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
@ -69,11 +71,26 @@ class Provider(models.Model):
return getattr(self, 'name') return getattr(self, 'name')
return super().__str__() return super().__str__()
class PolicyModel(UUIDModel, CreatedUpdatedModel): class PolicyModel(UUIDModel, CreatedUpdatedModel):
"""Base model which can have policies applied to it""" """Base model which can have policies applied to it"""
policies = models.ManyToManyField('Policy', blank=True) policies = models.ManyToManyField('Policy', blank=True)
class UserSettings:
"""Dataclass for Factor and Source's user_settings"""
name: str
icon: str
view_name: str
def __init__(self, name: str, icon: str, view_name: str):
self.name = name
self.icon = icon
self.view_name = view_name
class Factor(PolicyModel): class Factor(PolicyModel):
"""Authentication factor, multiple instances of the same Factor can be used""" """Authentication factor, multiple instances of the same Factor can be used"""
@ -86,11 +103,10 @@ class Factor(PolicyModel):
type = '' type = ''
form = '' form = ''
def has_user_settings(self): def user_settings(self) -> Optional[UserSettings]:
"""Entrypoint to integrate with User settings. Can either return False if no """Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or a tuple or string, string, string where the first string user settings are available, or an instanace of UserSettings."""
is the name the item has, the second string is the icon and the third is the view-name.""" return None
return False
def __str__(self): def __str__(self):
return f"Factor {self.slug}" return f"Factor {self.slug}"
@ -147,11 +163,10 @@ class Source(PolicyModel):
"""Return additional Info, such as a callback URL. Show in the administration interface.""" """Return additional Info, such as a callback URL. Show in the administration interface."""
return None return None
def has_user_settings(self): def user_settings(self) -> Optional[UserSettings]:
"""Entrypoint to integrate with User settings. Can either return False if no """Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or a tuple or string, string, string where the first string user settings are available, or an instanace of UserSettings."""
is the name the item has, the second string is the icon and the third is the view-name.""" return None
return False
def __str__(self): def __str__(self):
return self.name return self.name
@ -242,21 +257,29 @@ class Invitation(UUIDModel):
verbose_name = _('Invitation') verbose_name = _('Invitation')
verbose_name_plural = _('Invitations') verbose_name_plural = _('Invitations')
class Nonce(UUIDModel): class Nonce(UUIDModel):
"""One-time link for password resets/sign-up-confirmations""" """One-time link for password resets/sign-up-confirmations"""
expires = models.DateTimeField(default=default_nonce_duration) expires = models.DateTimeField(default=default_nonce_duration)
user = models.ForeignKey('User', on_delete=models.CASCADE) user = models.ForeignKey('User', on_delete=models.CASCADE)
expiring = models.BooleanField(default=True) expiring = models.BooleanField(default=True)
description = models.TextField(default='', blank=True)
@property
def is_expired(self) -> bool:
"""Check if nonce is expired yet."""
return now() > self.expires
def __str__(self): def __str__(self):
return f"Nonce f{self.uuid.hex} (expires={self.expires})" return f"Nonce f{self.uuid.hex} {self.description} (expires={self.expires})"
class Meta: class Meta:
verbose_name = _('Nonce') verbose_name = _('Nonce')
verbose_name_plural = _('Nonces') verbose_name_plural = _('Nonces')
class PropertyMapping(UUIDModel): class PropertyMapping(UUIDModel):
"""User-defined key -> x mapping which can be used by providers to expose extra data.""" """User-defined key -> x mapping which can be used by providers to expose extra data."""

View File

@ -1,5 +0,0 @@
"""core settings"""
PASSBOOK_CORE_FACTORS = [
]

View File

@ -1,28 +1,15 @@
"""passbook core tasks""" """passbook core tasks"""
from datetime import datetime from datetime import datetime
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from structlog import get_logger from structlog import get_logger
from passbook.core.models import Nonce from passbook.core.models import Nonce
from passbook.lib.config import CONFIG
from passbook.root.celery import CELERY_APP from passbook.root.celery import CELERY_APP
LOGGER = get_logger() LOGGER = get_logger()
@CELERY_APP.task()
def send_email(to_address, subject, template, context):
"""Send Email to user(s)"""
html_content = render_to_string(template, context=context)
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(subject, text_content, CONFIG.y('email.from'), [to_address])
msg.attach_alternative(html_content, "text/html")
msg.send()
@CELERY_APP.task() @CELERY_APP.task()
def clean_nonces(): def clean_nonces():
"""Remove expired nonces""" """Remove expired nonces"""
amount, _ = Nonce.objects.filter(expires__lt=datetime.now(), expiring=True).delete() amount, _ = Nonce.objects.filter(expires__lt=datetime.now(), expiring=True).delete()
LOGGER.debug("Deleted expired %d nonces", amount) LOGGER.debug("Deleted expired nonces", amount=amount)

View File

@ -46,9 +46,6 @@
<script src="{% static 'js/passbook.js' %}"></script> <script src="{% static 'js/passbook.js' %}"></script>
{% block scripts %} {% block scripts %}
{% endblock %} {% endblock %}
<div class="modals">
{% include 'partials/about_modal.html' %}
</div>
</body> </body>
</html> </html>

View File

@ -46,9 +46,6 @@
<script src="{% static 'js/passbook.js' %}"></script> <script src="{% static 'js/passbook.js' %}"></script>
{% block scripts %} {% block scripts %}
{% endblock %} {% endblock %}
<div class="modals">
{% include 'partials/about_modal.html' %}
</div>
</body> </body>
</html> </html>

View File

@ -23,37 +23,18 @@
</div> </div>
<nav class="collapse navbar-collapse"> <nav class="collapse navbar-collapse">
<ul class="nav navbar-nav navbar-right navbar-iconic navbar-utility"> <ul class="nav navbar-nav navbar-right navbar-iconic navbar-utility">
<li class="dropdown"> <a href="{% url 'passbook_core:auth-logout' %}" class="btn btn-link nav-item-iconic" aria-haspopup="true" aria-expanded="true">
<button class="btn btn-link dropdown-toggle nav-item-iconic" id="dropdownMenu1" data-toggle="dropdown" <span title="Username" class="fa fa-sign-out"></span>
aria-haspopup="true" aria-expanded="true"> <span class="dropdown-title">
<span title="Help" class="fa pficon-help"></span> {% trans 'Logout' %}
</button> </span>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu1"> </a>
{% comment %} <li><a href="#0">Help</a></li> {% endcomment %} <a href="{% url 'passbook_core:user-settings' %}" class="btn btn-link nav-item-iconic" aria-haspopup="true" aria-expanded="true">
<li><a data-toggle="modal" data-target="#about-modal" href="#0">{% trans 'About' %}</a></li> <span title="Username" class="fa pficon-user"></span>
</ul> <span class="dropdown-title">
</li> {{ user.username }}
<li class="dropdown"> </span>
<button class="btn btn-link dropdown-toggle nav-item-iconic" id="dropdownMenu2" data-toggle="dropdown" </a>
aria-haspopup="true" aria-expanded="true">
<span title="Username" class="fa pficon-user"></span>
<span class="dropdown-title">
{{ user.username }} <span class="caret"></span>
</span>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu2">
<li>
<a href="{% url 'passbook_core:user-settings' %}">{% trans 'User Settings' %}</a>
</li>
<li>
<a href="{% url 'passbook_core:user-change-password' %}">{% trans 'Change Password' %}</a>
</li>
<li class="divider"></li>
<li>
<a href="{% url 'passbook_core:auth-logout' %}">{% trans 'Logout' %}</a>
</li>
</ul>
</li>
</ul> </ul>
</nav> </nav>
</nav> </nav>
@ -141,18 +122,6 @@
<span class="list-group-item-value">{% trans 'Audit Log' %}</span> <span class="list-group-item-value">{% trans 'Audit Log' %}</span>
</a> </a>
</li> </li>
<li class="list-group-item {% is_active_app 'admin' %}">
<a href="{% url 'admin:index' %}">
<span class="fa fa-database" data-toggle="tooltip" title="{% trans 'Django' %}"></span>
<span class="list-group-item-value">{% trans 'Django' %}</span>
</a>
</li>
<li class="list-group-item {% is_active 'passbook_admin:debug-request' %}">
<a href="{% url 'passbook_admin:debug-request' %}">
<span class="fa fa-bug" data-toggle="tooltip" title="{% trans 'Debug' %}"></span>
<span class="list-group-item-value">{% trans 'Debug' %}</span>
</a>
</li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>

View File

@ -1,36 +0,0 @@
{% load static %}
{% load i18n %}
{% load cache %}
{% load utils %}
<div class="modal fade" id="about-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content about-modal-pf">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">
<span class="pficon pficon-close"></span>
</button>
</div>
<div class="modal-body">
<h1>{% trans 'passbook' %}</h1>
<div class="product-versions-pf">
<ul class="list-unstyled">
{% app_versions as vers %}
{% cache 600 versions %}
{% for app, ver in vers.items %}
<li><strong>{{ app }}</strong> {{ ver }}</li>
{% endfor %}
{% endcache %}
</ul>
</div>
<div class="trademark-pf">
Trademark and Copyright Information
</div>
</div>
<div class="modal-footer">
<img style="max-height:64px;" src="{% static 'img/logo.png' %}" alt=" Symbol">
</div>
</div>
</div>
</div>

View File

@ -16,23 +16,27 @@
<i class="fa pficon-edit"></i> {% trans 'Details' %} <i class="fa pficon-edit"></i> {% trans 'Details' %}
</a> </a>
</li> </li>
<li class="nav-divider"></li>
{% user_factors as uf %} {% user_factors as uf %}
{% for name, icon, link in uf %} {% if uf %}
<li class="{% is_active link %}"> <li class="nav-divider"></li>
<a href="{% url link %}"> {% endif %}
<i class="{{ icon }}"></i> {{ name }} {% for user_settings in uf %}
</a> <li class="{% is_active user_settings.view_name %}">
</li> <a href="{% url user_settings.view_name %}">
<i class="{{ user_settings.icon }}"></i> {{ user_settings.name }}
</a>
</li>
{% endfor %} {% endfor %}
<li class="nav-divider"></li>
{% user_sources as us %} {% user_sources as us %}
{% for name, icon, link in us %} {% if us %}
<li class="{% if link == request.get_full_path %} active {% endif %}"> <li class="nav-divider"></li>
<a href="{{ link }}"> {% endif %}
<i class="{{ icon }}"></i> {{ name }} {% for user_settings in us %}
</a> <li class="{% if user_settings.view_name == request.get_full_path %} active {% endif %}">
</li> <a href="{{ user_settings.view_name }}">
<i class="{{ user_settings.icon }}"></i> {{ user_settings.name }}
</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>

View File

@ -1,37 +1,38 @@
"""passbook user settings template tags""" """passbook user settings template tags"""
from typing import List
from django import template from django import template
from django.template.context import RequestContext from django.template.context import RequestContext
from passbook.core.models import Factor, Source from passbook.core.models import Factor, Source, UserSettings
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
register = template.Library() register = template.Library()
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def user_factors(context: RequestContext): def user_factors(context: RequestContext) -> List[UserSettings]:
"""Return list of all factors which apply to user""" """Return list of all factors which apply to user"""
user = context.get('request').user user = context.get('request').user
_all_factors = Factor.objects.filter(enabled=True).order_by('order').select_subclasses() _all_factors = Factor.objects.filter(enabled=True).order_by('order').select_subclasses()
matching_factors = [] matching_factors: List[UserSettings] = []
for factor in _all_factors: for factor in _all_factors:
_link = factor.has_user_settings() user_settings = factor.user_settings()
policy_engine = PolicyEngine(factor.policies.all()) policy_engine = PolicyEngine(factor.policies.all())
policy_engine.for_user(user).with_request(context.get('request')).build() policy_engine.for_user(user).with_request(context.get('request')).build()
if policy_engine.passing and _link: if policy_engine.passing and user_settings:
matching_factors.append(_link) matching_factors.append(user_settings)
return matching_factors return matching_factors
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def user_sources(context: RequestContext): def user_sources(context: RequestContext) -> List[UserSettings]:
"""Return a list of all sources which are enabled for the user""" """Return a list of all sources which are enabled for the user"""
user = context.get('request').user user = context.get('request').user
_all_sources = Source.objects.filter(enabled=True).select_subclasses() _all_sources = Source.objects.filter(enabled=True).select_subclasses()
matching_sources = [] matching_sources: List[UserSettings] = []
for factor in _all_sources: for factor in _all_sources:
_link = factor.has_user_settings() user_settings = factor.user_settings()
policy_engine = PolicyEngine(factor.policies.all()) policy_engine = PolicyEngine(factor.policies.all())
policy_engine.for_user(user).with_request(context.get('request')).build() policy_engine.for_user(user).with_request(context.get('request')).build()
if policy_engine.passing and _link: if policy_engine.passing and user_settings:
matching_sources.append(_link) matching_sources.append(user_settings)
return matching_sources return matching_sources

View File

@ -15,7 +15,6 @@ from structlog import get_logger
from passbook.core.forms.authentication import LoginForm, SignUpForm from passbook.core.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import Invitation, Nonce, Source, User from passbook.core.models import Invitation, Nonce, Source, User
from passbook.core.signals import invitation_used, user_signed_up from passbook.core.signals import invitation_used, user_signed_up
from passbook.core.tasks import send_email
from passbook.factors.password.exceptions import PasswordPolicyInvalid from passbook.factors.password.exceptions import PasswordPolicyInvalid
from passbook.factors.view import AuthenticationView, _redirect_with_qs from passbook.factors.view import AuthenticationView, _redirect_with_qs
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
@ -97,7 +96,7 @@ class SignUpView(UserPassesTestMixin, FormView):
template_name = 'login/form.html' template_name = 'login/form.html'
form_class = SignUpForm form_class = SignUpForm
success_url = '.' success_url = '.'
# Invitation insatnce, if invitation link was used # Invitation instance, if invitation link was used
_invitation = None _invitation = None
# Instance of newly created user # Instance of newly created user
_user = None _user = None
@ -152,23 +151,23 @@ class SignUpView(UserPassesTestMixin, FormView):
for error in exc.messages: for error in exc.messages:
errors.append(error) errors.append(error)
return self.form_invalid(form) return self.form_invalid(form)
needs_confirmation = True # needs_confirmation = True
if self._invitation and not self._invitation.needs_confirmation: # if self._invitation and not self._invitation.needs_confirmation:
needs_confirmation = False # needs_confirmation = False
if needs_confirmation: # if needs_confirmation:
nonce = Nonce.objects.create(user=self._user) # nonce = Nonce.objects.create(user=self._user)
LOGGER.debug(str(nonce.uuid)) # LOGGER.debug(str(nonce.uuid))
# Send email to user # # Send email to user
send_email.delay(self._user.email, _('Confirm your account.'), # send_email.delay(self._user.email, _('Confirm your account.'),
'email/account_confirm.html', { # 'email/account_confirm.html', {
'url': self.request.build_absolute_uri( # 'url': self.request.build_absolute_uri(
reverse('passbook_core:auth-sign-up-confirm', kwargs={ # reverse('passbook_core:auth-sign-up-confirm', kwargs={
'nonce': nonce.uuid # 'nonce': nonce.uuid
}) # })
) # )
}) # })
self._user.is_active = False # self._user.is_active = False
self._user.save() # self._user.save()
self.consume_invitation() self.consume_invitation()
messages.success(self.request, _("Successfully signed up!")) messages.success(self.request, _("Successfully signed up!"))
LOGGER.debug("Successfully signed up %s", LOGGER.debug("Successfully signed up %s",

View File

@ -14,13 +14,14 @@ class AuthenticationFactor(TemplateView):
form: ModelForm = None form: ModelForm = None
required: bool = True required: bool = True
authenticator: AuthenticationView = None authenticator: AuthenticationView
pending_user: User = None pending_user: User
request: HttpRequest = None request: HttpRequest = None
template_name = 'login/form_with_user.html' template_name = 'login/form_with_user.html'
def __init__(self, authenticator: AuthenticationView): def __init__(self, authenticator: AuthenticationView):
self.authenticator = authenticator self.authenticator = authenticator
self.pending_user = None
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs['config'] = CONFIG.y('passbook') kwargs['config'] = CONFIG.y('passbook')

View File

@ -0,0 +1,5 @@
"""captcha factor admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_factors_captcha')

View File

@ -1,6 +1,8 @@
"""passbook captcha factor forms""" """passbook captcha factor forms"""
from captcha.fields import ReCaptchaField from captcha.fields import ReCaptchaField
from django import forms from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.factors.captcha.models import CaptchaFactor from passbook.factors.captcha.models import CaptchaFactor
from passbook.factors.forms import GENERAL_FIELDS from passbook.factors.forms import GENERAL_FIELDS
@ -21,6 +23,7 @@ class CaptchaFactorForm(forms.ModelForm):
widgets = { widgets = {
'name': forms.TextInput(), 'name': forms.TextInput(),
'order': forms.NumberInput(), 'order': forms.NumberInput(),
'policies': FilteredSelectMultiple(_('policies'), False),
'public_key': forms.TextInput(), 'public_key': forms.TextInput(),
'private_key': forms.TextInput(), 'private_key': forms.TextInput(),
} }

View File

View File

@ -0,0 +1,5 @@
"""email factor admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_factors_email')

View File

@ -0,0 +1,15 @@
"""passbook email factor config"""
from importlib import import_module
from django.apps import AppConfig
class PassbookFactorEmailConfig(AppConfig):
"""passbook email factor config"""
name = 'passbook.factors.email'
label = 'passbook_factors_email'
verbose_name = 'passbook Factors.Email'
def ready(self):
import_module('passbook.factors.email.tasks')

View File

@ -0,0 +1,45 @@
"""passbook multi-factor authentication engine"""
from django.contrib import messages
from django.http import HttpRequest
from django.shortcuts import redirect, reverse
from django.utils.translation import gettext as _
from structlog import get_logger
from passbook.core.models import Nonce
from passbook.factors.base import AuthenticationFactor
from passbook.factors.email.tasks import send_mails
from passbook.factors.email.utils import TemplateEmailMessage
from passbook.lib.config import CONFIG
LOGGER = get_logger()
class EmailFactorView(AuthenticationFactor):
"""Dummy factor for testing with multiple factors"""
def get_context_data(self, **kwargs):
kwargs['show_password_forget_notice'] = CONFIG.y('passbook.password_reset.enabled')
return super().get_context_data(**kwargs)
def get(self, request, *args, **kwargs):
nonce = Nonce.objects.create(user=self.pending_user)
LOGGER.debug("DEBUG %s", str(nonce.uuid))
# Send mail to user
message = TemplateEmailMessage(
subject=_('Forgotten password'),
template_name='email/account_password_reset.html',
template_context={
'url': self.request.build_absolute_uri(
reverse('passbook_core:auth-password-reset',
kwargs={
'nonce': nonce.uuid
})
)})
send_mails(self.authenticator.current_factor, message)
self.authenticator.cleanup()
messages.success(request, _('Check your E-Mails for a password reset link.'))
return redirect('passbook_core:auth-login')
def post(self, request: HttpRequest):
"""Just redirect to next factor"""
return self.authenticator.user_ok()

View File

@ -0,0 +1,43 @@
"""passbook administration forms"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.factors.email.models import EmailFactor
from passbook.factors.forms import GENERAL_FIELDS
class EmailFactorForm(forms.ModelForm):
"""Form to create/edit Dummy Factor"""
class Meta:
model = EmailFactor
fields = GENERAL_FIELDS + [
'host',
'port',
'username',
'password',
'use_tls',
'use_ssl',
'timeout',
'from_address',
'ssl_keyfile',
'ssl_certfile',
]
widgets = {
'name': forms.TextInput(),
'order': forms.NumberInput(),
'policies': FilteredSelectMultiple(_('policies'), False),
'host': forms.TextInput(),
'username': forms.TextInput(),
'password': forms.TextInput(),
'ssl_keyfile': forms.TextInput(),
'ssl_certfile': forms.TextInput(),
}
labels = {
'use_tls': _('Use TLS'),
'use_ssl': _('Use SSL'),
'ssl_keyfile': _('SSL Keyfile (optional)'),
'ssl_certfile': _('SSL Certfile (optional)'),
}

View File

@ -0,0 +1,37 @@
# Generated by Django 2.2.6 on 2019-10-08 12:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('passbook_core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='EmailFactor',
fields=[
('factor_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Factor')),
('host', models.TextField(default='localhost')),
('port', models.IntegerField(default=25)),
('username', models.TextField(blank=True, default='')),
('password', models.TextField(blank=True, default='')),
('use_tls', models.BooleanField(default=False)),
('use_ssl', models.BooleanField(default=False)),
('timeout', models.IntegerField(default=0)),
('ssl_keyfile', models.TextField(blank=True, default=None, null=True)),
('ssl_certfile', models.TextField(blank=True, default=None, null=True)),
('from_address', models.EmailField(default='system@passbook.local', max_length=254)),
],
options={
'verbose_name': 'Email Factor',
'verbose_name_plural': 'Email Factors',
},
bases=('passbook_core.factor',),
),
]

View File

@ -0,0 +1,48 @@
"""email factor models"""
from django.core.mail.backends.smtp import EmailBackend
from django.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Factor
class EmailFactor(Factor):
"""email factor"""
host = models.TextField(default='localhost')
port = models.IntegerField(default=25)
username = models.TextField(default='', blank=True)
password = models.TextField(default='', blank=True)
use_tls = models.BooleanField(default=False)
use_ssl = models.BooleanField(default=False)
timeout = models.IntegerField(default=0)
ssl_keyfile = models.TextField(default=None, blank=True, null=True)
ssl_certfile = models.TextField(default=None, blank=True, null=True)
from_address = models.EmailField(default='system@passbook.local')
type = 'passbook.factors.email.factor.EmailFactorView'
form = 'passbook.factors.email.forms.EmailFactorForm'
@property
def backend(self) -> EmailBackend:
"""Get fully configured EMail Backend instance"""
return EmailBackend(
host=self.host,
port=self.port,
username=self.username,
password=self.password,
use_tls=self.use_tls,
use_ssl=self.use_ssl,
timeout=self.timeout,
ssl_certfile=self.ssl_certfile,
ssl_keyfile=self.ssl_keyfile)
def __str__(self):
return f"Email Factor {self.slug}"
class Meta:
verbose_name = _('Email Factor')
verbose_name_plural = _('Email Factors')

View File

@ -0,0 +1,39 @@
"""email factor tasks"""
from smtplib import SMTPException
from typing import Any, Dict, List
from celery import group
from django.core.mail import EmailMessage
from passbook.factors.email.models import EmailFactor
from passbook.root.celery import CELERY_APP
def send_mails(factor: EmailFactor, *messages: List[EmailMessage]):
"""Wrapper to convert EmailMessage to dict and send it from worker"""
tasks = []
for message in messages:
tasks.append(_send_mail_task.s(factor.pk, message.__dict__))
lazy_group = group(*tasks)
promise = lazy_group()
return promise
@CELERY_APP.task(bind=True)
def _send_mail_task(self, email_factor_pk: int, message: Dict[Any, Any]):
"""Send E-Mail according to EmailFactor parameters from background worker.
Automatically retries if message couldn't be sent."""
factor: EmailFactor = EmailFactor.objects.get(pk=email_factor_pk)
backend = factor.backend
backend.open()
# Since django's EmailMessage objects are not JSON serialisable,
# we need to rebuild them from a dict
message_object = EmailMessage()
for key, value in message.items():
setattr(message_object, key, value)
message_object.from_email = factor.from_address
try:
num_sent = factor.backend.send_messages([message_object])
except SMTPException as exc:
raise self.retry(exc=exc)
if num_sent != 1:
raise self.retry()

View File

@ -0,0 +1,28 @@
"""email utils"""
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
class TemplateEmailMessage(EmailMultiAlternatives):
"""Wrapper around EmailMultiAlternatives with integrated template rendering"""
# pylint: disable=too-many-arguments
def __init__(self, subject='', body=None, from_email=None, to=None, bcc=None,
connection=None, attachments=None, headers=None, cc=None,
reply_to=None, template_name=None, template_context=None):
html_content = render_to_string(template_name, template_context)
if not body:
body = strip_tags(html_content)
super().__init__(
subject=subject,
body=body,
from_email=from_email,
to=to,
bcc=bcc,
connection=connection,
attachments=attachments,
headers=headers,
cc=cc,
reply_to=reply_to)
self.attach_alternative(html_content, "text/html")

View File

@ -3,7 +3,7 @@
from django.db import models from django.db import models
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from passbook.core.models import Factor from passbook.core.models import Factor, UserSettings
class OTPFactor(Factor): class OTPFactor(Factor):
@ -15,8 +15,8 @@ class OTPFactor(Factor):
type = 'passbook.factors.otp.factors.OTPFactor' type = 'passbook.factors.otp.factors.OTPFactor'
form = 'passbook.factors.otp.forms.OTPFactorForm' form = 'passbook.factors.otp.forms.OTPFactorForm'
def has_user_settings(self): def user_settings(self) -> UserSettings:
return _('OTP'), 'pficon-locked', 'passbook_otp:otp-user-settings' return UserSettings(_('OTP'), 'pficon-locked', 'passbook_factors_otp:otp-user-settings')
def __str__(self): def __str__(self):
return f"OTP Factor {self.slug}" return f"OTP Factor {self.slug}"

View File

@ -26,10 +26,10 @@
</p> </p>
<p> <p>
{% if not state %} {% if not state %}
<a href="{% url 'passbook_otp:otp-enable' %}" <a href="{% url 'passbook_factors_otp:otp-enable' %}"
class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a> class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a>
{% else %} {% else %}
<a href="{% url 'passbook_otp:otp-disable' %}" <a href="{% url 'passbook_factors_otp:otp-disable' %}"
class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a> class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a>
{% endif %} {% endif %}
</p> </p>

View File

@ -21,8 +21,8 @@ from passbook.factors.otp.utils import otpauth_url
from passbook.lib.boilerplate import NeverCacheMixin from passbook.lib.boilerplate import NeverCacheMixin
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
OTP_SESSION_KEY = 'passbook_otp_key' OTP_SESSION_KEY = 'passbook_factors_otp_key'
OTP_SETTING_UP_KEY = 'passbook_otp_setup' OTP_SETTING_UP_KEY = 'passbook_factors_otp_setup'
LOGGER = get_logger() LOGGER = get_logger()
class UserSettingsView(LoginRequiredMixin, TemplateView): class UserSettingsView(LoginRequiredMixin, TemplateView):
@ -61,7 +61,7 @@ class DisableView(LoginRequiredMixin, View):
# current=True, # current=True,
# request=request, # request=request,
# send_notification=True) # send_notification=True)
return redirect(reverse('passbook_otp:otp-user-settings')) return redirect(reverse('passbook_factors_otp:otp-user-settings'))
class EnableView(LoginRequiredMixin, FormView): class EnableView(LoginRequiredMixin, FormView):
"""View to set up OTP""" """View to set up OTP"""
@ -88,7 +88,7 @@ class EnableView(LoginRequiredMixin, FormView):
if finished_totp_devices.exists() and finished_static_devices.exists(): if finished_totp_devices.exists() and finished_static_devices.exists():
messages.error(request, _('You already have TOTP enabled!')) messages.error(request, _('You already have TOTP enabled!'))
del request.session[OTP_SETTING_UP_KEY] del request.session[OTP_SETTING_UP_KEY]
return redirect('passbook_otp:otp-user-settings') return redirect('passbook_factors_otp:otp-user-settings')
request.session[OTP_SETTING_UP_KEY] = True request.session[OTP_SETTING_UP_KEY] = True
# Check if there's an unconfirmed device left to set up # Check if there's an unconfirmed device left to set up
totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=False) totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=False)
@ -121,7 +121,7 @@ class EnableView(LoginRequiredMixin, FormView):
def get_form(self, form_class=None): def get_form(self, form_class=None):
form = super().get_form(form_class=form_class) form = super().get_form(form_class=form_class)
form.device = self.totp_device form.device = self.totp_device
form.fields['qr_code'].initial = reverse('passbook_otp:otp-qr') form.fields['qr_code'].initial = reverse('passbook_factors_otp:otp-qr')
tokens = [(x.token, x.token) for x in self.static_device.token_set.all()] tokens = [(x.token, x.token) for x in self.static_device.token_set.all()]
form.fields['tokens'].choices = tokens form.fields['tokens'].choices = tokens
return form return form
@ -142,7 +142,7 @@ class EnableView(LoginRequiredMixin, FormView):
# current=True, # current=True,
# request=self.request, # request=self.request,
# send_notification=True) # send_notification=True)
return redirect('passbook_otp:otp-user-settings') return redirect('passbook_factors_otp:otp-user-settings')
class QRView(NeverCacheMixin, View): class QRView(NeverCacheMixin, View):
"""View returns an SVG image with the OTP token information""" """View returns an SVG image with the OTP token information"""

View File

@ -1,20 +1,18 @@
"""passbook multi-factor authentication engine""" """passbook multi-factor authentication engine"""
from inspect import Signature from inspect import Signature
from typing import Optional
from django.contrib import messages
from django.contrib.auth import _clean_credentials from django.contrib.auth import _clean_credentials
from django.contrib.auth.signals import user_login_failed from django.contrib.auth.signals import user_login_failed
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.forms.utils import ErrorList from django.forms.utils import ErrorList
from django.shortcuts import redirect, reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import FormView from django.views.generic import FormView
from structlog import get_logger from structlog import get_logger
from passbook.core.forms.authentication import PasswordFactorForm from passbook.core.models import User
from passbook.core.models import Nonce
from passbook.core.tasks import send_email
from passbook.factors.base import AuthenticationFactor from passbook.factors.base import AuthenticationFactor
from passbook.factors.password.forms import PasswordForm
from passbook.factors.view import AuthenticationView from passbook.factors.view import AuthenticationView
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
from passbook.lib.utils.reflection import path_to_class from passbook.lib.utils.reflection import path_to_class
@ -22,7 +20,7 @@ from passbook.lib.utils.reflection import path_to_class
LOGGER = get_logger() LOGGER = get_logger()
def authenticate(request, backends, **credentials): def authenticate(request, backends, **credentials) -> Optional[User]:
"""If the given credentials are valid, return a User object. """If the given credentials are valid, return a User object.
Customized version of django's authenticate, which accepts a list of backends""" Customized version of django's authenticate, which accepts a list of backends"""
@ -55,32 +53,9 @@ def authenticate(request, backends, **credentials):
class PasswordFactor(FormView, AuthenticationFactor): class PasswordFactor(FormView, AuthenticationFactor):
"""Authentication factor which authenticates against django's AuthBackend""" """Authentication factor which authenticates against django's AuthBackend"""
form_class = PasswordFactorForm form_class = PasswordForm
template_name = 'login/factors/backend.html' template_name = 'login/factors/backend.html'
def get_context_data(self, **kwargs):
kwargs['show_password_forget_notice'] = CONFIG.y('passbook.password_reset.enabled')
return super().get_context_data(**kwargs)
def get(self, request, *args, **kwargs):
if 'password-forgotten' in request.GET:
nonce = Nonce.objects.create(user=self.pending_user)
LOGGER.debug("DEBUG %s", str(nonce.uuid))
# Send mail to user
send_email.delay(self.pending_user.email, _('Forgotten password'),
'email/account_password_reset.html', {
'url': self.request.build_absolute_uri(
reverse('passbook_core:auth-password-reset',
kwargs={
'nonce': nonce.uuid
})
)
})
self.authenticator.cleanup()
messages.success(request, _('Check your E-Mails for a password reset link.'))
return redirect('passbook_core:auth-login')
return super().get(request, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
"""Authenticate against django's authentication backend""" """Authenticate against django's authentication backend"""
uid_fields = CONFIG.y('passbook.uid_fields') uid_fields = CONFIG.y('passbook.uid_fields')

View File

@ -16,13 +16,23 @@ def get_authentication_backends():
yield backend, getattr(klass(), 'name', '%s (%s)' % (klass.__name__, klass.__module__)) yield backend, getattr(klass(), 'name', '%s (%s)' % (klass.__name__, klass.__module__))
class PasswordForm(forms.Form):
"""Password authentication form"""
password = forms.CharField(widget=forms.PasswordInput(attrs={
'placeholder': _('Password'),
'autofocus': 'autofocus',
'autocomplete': 'current-password'
}))
class PasswordFactorForm(forms.ModelForm): class PasswordFactorForm(forms.ModelForm):
"""Form to create/edit Password Factors""" """Form to create/edit Password Factors"""
class Meta: class Meta:
model = PasswordFactor model = PasswordFactor
fields = GENERAL_FIELDS + ['backends', 'password_policies'] fields = GENERAL_FIELDS + ['backends', 'password_policies', 'reset_factors']
widgets = { widgets = {
'name': forms.TextInput(), 'name': forms.TextInput(),
'order': forms.NumberInput(), 'order': forms.NumberInput(),
@ -30,4 +40,5 @@ class PasswordFactorForm(forms.ModelForm):
'backends': FilteredSelectMultiple(_('backends'), False, 'backends': FilteredSelectMultiple(_('backends'), False,
choices=get_authentication_backends()), choices=get_authentication_backends()),
'password_policies': FilteredSelectMultiple(_('password policies'), False), 'password_policies': FilteredSelectMultiple(_('password policies'), False),
'reset_factors': FilteredSelectMultiple(_('reset factors'), False),
} }

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2.6 on 2019-10-08 09:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('passbook_core', '0001_initial'),
('passbook_factors_password', '0002_auto_20191007_1411'),
]
operations = [
migrations.AddField(
model_name='passwordfactor',
name='reset_factors',
field=models.ManyToManyField(blank=True, related_name='reset_factors', to='passbook_core.Factor'),
),
]

View File

@ -3,7 +3,7 @@ from django.contrib.postgres.fields import ArrayField
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.core.models import Factor, Policy, User from passbook.core.models import Factor, Policy, User, UserSettings
class PasswordFactor(Factor): class PasswordFactor(Factor):
@ -11,12 +11,14 @@ class PasswordFactor(Factor):
backends = ArrayField(models.TextField()) backends = ArrayField(models.TextField())
password_policies = models.ManyToManyField(Policy, blank=True) password_policies = models.ManyToManyField(Policy, blank=True)
reset_factors = models.ManyToManyField(Factor, blank=True, related_name='reset_factors')
type = 'passbook.factors.password.factor.PasswordFactor' type = 'passbook.factors.password.factor.PasswordFactor'
form = 'passbook.factors.password.forms.PasswordFactorForm' form = 'passbook.factors.password.forms.PasswordFactorForm'
def has_user_settings(self): def user_settings(self):
return _('Change Password'), 'pficon-key', 'passbook_core:user-change-password' return UserSettings(_('Change Password'), 'pficon-key',
'passbook_core:user-change-password')
def password_passes(self, user: User) -> bool: def password_passes(self, user: User) -> bool:
"""Return true if user's password passes, otherwise False or raise Exception""" """Return true if user's password passes, otherwise False or raise Exception"""

View File

@ -16,9 +16,7 @@ debug: false
# Error reporting, sends stacktrace to sentry.services.beryju.org # Error reporting, sends stacktrace to sentry.services.beryju.org
error_report_enabled: true error_report_enabled: true
domains: domain: localhost
- passbook.local
primary_domain: 'localhost'
passbook: passbook:
sign_up: sign_up:
@ -48,8 +46,6 @@ passbook:
uid_fields: uid_fields:
- username - username
- email - email
session:
remember_age: 2592000 # 60 * 60 * 24 * 30, one month
# Provider-specific settings # Provider-specific settings
ldap: ldap:
# Which field from `uid_fields` maps to which LDAP Attribute # Which field from `uid_fields` maps to which LDAP Attribute
@ -61,6 +57,3 @@ ldap:
username: "%(sAMAccountName)s" username: "%(sAMAccountName)s"
email: "%(mail)s" email: "%(mail)s"
name: "%(displayName)" name: "%(displayName)"
app_gw:
listen: 0.0.0.0
port: 8000

View File

@ -1,55 +0,0 @@
import time
from django.conf import settings
from django.contrib.sessions.middleware import SessionMiddleware
from django.utils.cache import patch_vary_headers
from django.utils.http import cookie_date
from structlog import get_logger
from passbook.factors.view import AuthenticationView
LOGGER = get_logger()
class SessionHostDomainMiddleware(SessionMiddleware):
def process_request(self, request):
session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
request.session = self.SessionStore(session_key)
def process_response(self, request, response):
"""
If request.session was modified, or if the configuration is to save the
session every time, save the changes and set a session cookie.
"""
try:
accessed = request.session.accessed
modified = request.session.modified
except AttributeError:
pass
else:
if accessed:
patch_vary_headers(response, ('Cookie',))
if modified or settings.SESSION_SAVE_EVERY_REQUEST:
if request.session.get_expire_at_browser_close():
max_age = None
expires = None
else:
max_age = request.session.get_expiry_age()
expires_time = time.time() + max_age
expires = cookie_date(expires_time)
# Save the session data and refresh the client cookie.
# Skip session save for 500 responses, refs #3881.
if response.status_code != 500:
request.session.save()
hosts = [request.get_host().split(':')[0]]
if AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME in request.session:
hosts.append(request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME])
LOGGER.debug("Setting hosts for session", hosts=hosts)
for host in hosts:
response.set_cookie(settings.SESSION_COOKIE_NAME,
request.session.session_key, max_age=max_age,
expires=expires, domain=host,
path=settings.SESSION_COOKIE_PATH,
secure=settings.SESSION_COOKIE_SECURE or None,
httponly=settings.SESSION_COOKIE_HTTPONLY or None)
return response

View File

@ -1,7 +1,8 @@
"""passbook app_gw views""" """passbook app_gw views"""
from pprint import pprint
from urllib.parse import urlparse from urllib.parse import urlparse
from django.conf import settings
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.views import View from django.views import View
from structlog import get_logger from structlog import get_logger
@ -12,15 +13,26 @@ from passbook.providers.app_gw.models import ApplicationGatewayProvider
ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL' ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL'
LOGGER = get_logger() LOGGER = get_logger()
def cache_key(session_cookie: str, request: HttpRequest) -> str:
"""Cache Key for request fingerprinting"""
fprint = '_'.join([
session_cookie,
request.META.get('HTTP_HOST'),
request.META.get('PATH_INFO'),
])
return f"app_gw_{fprint}"
class NginxCheckView(AccessMixin, View): class NginxCheckView(AccessMixin, View):
"""View used by nginx's auth_request module"""
def dispatch(self, request: HttpRequest) -> HttpResponse: def dispatch(self, request: HttpRequest) -> HttpResponse:
pprint(request.META) session_cookie = request.COOKIES.get(settings.SESSION_COOKIE_NAME, '')
_cache_key = cache_key(session_cookie, request)
if cache.get(_cache_key):
return HttpResponse(status=202)
parsed_url = urlparse(request.META.get(ORIGINAL_URL)) parsed_url = urlparse(request.META.get(ORIGINAL_URL))
# request.session[AuthenticationView.SESSION_ALLOW_ABSOLUTE_NEXT] = True # request.session[AuthenticationView.SESSION_ALLOW_ABSOLUTE_NEXT] = True
# request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME] = parsed_url.hostname # request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME] = parsed_url.hostname
print(request.user)
if not request.user.is_authenticated: if not request.user.is_authenticated:
return HttpResponse(status=401) return HttpResponse(status=401)
matching = ApplicationGatewayProvider.objects.filter( matching = ApplicationGatewayProvider.objects.filter(
@ -31,6 +43,7 @@ class NginxCheckView(AccessMixin, View):
application = self.provider_to_application(matching.first()) application = self.provider_to_application(matching.first())
has_access, _ = self.user_has_access(application, request.user) has_access, _ = self.user_has_access(application, request.user)
if has_access: if has_access:
cache.set(_cache_key, True)
return HttpResponse(status=202) return HttpResponse(status=202)
LOGGER.debug("User not passing", user=request.user) LOGGER.debug("User not passing", user=request.user)
return HttpResponse(status=401) return HttpResponse(status=401)

View File

@ -3,6 +3,7 @@ from django.contrib import messages
from django.shortcuts import redirect from django.shortcuts import redirect
from structlog import get_logger from structlog import get_logger
from passbook.audit.models import AuditEntry
from passbook.core.models import Application from passbook.core.models import Application
from passbook.policies.engine import PolicyEngine from passbook.policies.engine import PolicyEngine
@ -26,4 +27,10 @@ def check_permissions(request, user, client):
for policy_message in policy_messages: for policy_message in policy_messages:
messages.error(request, policy_message) messages.error(request, policy_message)
return redirect('passbook_providers_oauth:oauth2-permission-denied') return redirect('passbook_providers_oauth:oauth2-permission-denied')
AuditEntry.create(
action=AuditEntry.ACTION_AUTHORIZE_APPLICATION,
request=request,
app=application.name,
skipped_authorization=False)
return None return None

View File

@ -38,7 +38,7 @@ class SAMLProvider(Provider):
if not self._processor: if not self._processor:
try: try:
self._processor = path_to_class(self.processor_path)(self) self._processor = path_to_class(self.processor_path)(self)
except ModuleNotFoundError as exc: except ImportError as exc:
LOGGER.warning(exc) LOGGER.warning(exc)
self._processor = None self._processor = None
return self._processor return self._processor

View File

11
passbook/recovery/apps.py Normal file
View File

@ -0,0 +1,11 @@
"""passbook Recovery app config"""
from django.apps import AppConfig
class PassbookRecoveryConfig(AppConfig):
"""passbook Recovery app config"""
name = 'passbook.recovery'
label = 'passbook_recovery'
verbose_name = 'passbook Recovery'
mountpoint = 'recovery/'

View File

View File

@ -0,0 +1,46 @@
"""passbook recovery createkey command"""
from datetime import timedelta
from getpass import getuser
from django.core.management.base import BaseCommand
from django.urls import reverse
from django.utils.timezone import now
from django.utils.translation import gettext as _
from structlog import get_logger
from passbook.core.models import Nonce, User
from passbook.lib.config import CONFIG
LOGGER = get_logger()
class Command(BaseCommand):
"""Create Nonce used to recover access"""
help = _('Create a Key which can be used to restore access to passbook.')
def add_arguments(self, parser):
parser.add_argument('duration', default=1, action='store',
help='How long the token is valid for (in years).')
parser.add_argument('user', action='store',
help='Which user the Token gives access to.')
def get_url(self, nonce: Nonce) -> str:
"""Get full recovery link"""
path = reverse('passbook_recovery:use-nonce', kwargs={'uuid': str(nonce.uuid)})
return f"https://{CONFIG.y('domain')}{path}"
def handle(self, *args, **options):
"""Create Nonce used to recover access"""
duration = int(options.get('duration', 1))
delta = timedelta(days=duration * 365.2425)
_now = now()
expiry = _now + delta
user = User.objects.get(username=options.get('user'))
nonce = Nonce.objects.create(
expires=expiry,
user=user,
description=f'Recovery Nonce generated by {getuser()} on {_now}')
self.stdout.write((f"Store this link safely, as it will allow"
f" anyone to access passbook as {user}."))
self.stdout.write(self.get_url(nonce))

View File

@ -0,0 +1,9 @@
"""recovery views"""
from django.urls import path
from passbook.recovery.views import UseNonceView
urlpatterns = [
path('use-nonce/<uuid:uuid>/', UseNonceView.as_view(), name='use-nonce'),
]

View File

@ -0,0 +1,24 @@
"""recovery views"""
from django.contrib import messages
from django.contrib.auth import login
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils.translation import gettext as _
from django.views import View
from passbook.core.models import Nonce
class UseNonceView(View):
"""Use nonce to login"""
def get(self, request: HttpRequest, uuid: str) -> HttpResponse:
"""Check if nonce exists, log user in and delete nonce."""
nonce: Nonce = get_object_or_404(Nonce, pk=uuid)
if nonce.is_expired:
nonce.delete()
raise Http404
login(request, nonce.user, backend='django.contrib.auth.backends.ModelBackend')
nonce.delete()
messages.warning(request, _("Used recovery-link to authenticate."))
return redirect('passbook_core:overview')

View File

@ -15,6 +15,7 @@ import os
import sys import sys
import structlog import structlog
from celery.schedules import crontab
from sentry_sdk import init as sentry_init from sentry_sdk import init as sentry_init
from sentry_sdk.integrations.celery import CeleryIntegration from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
@ -47,6 +48,7 @@ AUTH_USER_MODEL = 'passbook_core.User'
CSRF_COOKIE_NAME = 'passbook_csrf' CSRF_COOKIE_NAME = 'passbook_csrf'
SESSION_COOKIE_NAME = 'passbook_session' SESSION_COOKIE_NAME = 'passbook_session'
SESSION_COOKIE_DOMAIN = CONFIG.y('domain', None)
LANGUAGE_COOKIE_NAME = 'passbook_language' LANGUAGE_COOKIE_NAME = 'passbook_language'
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
@ -70,6 +72,7 @@ INSTALLED_APPS = [
'passbook.api.apps.PassbookAPIConfig', 'passbook.api.apps.PassbookAPIConfig',
'passbook.lib.apps.PassbookLibConfig', 'passbook.lib.apps.PassbookLibConfig',
'passbook.audit.apps.PassbookAuditConfig', 'passbook.audit.apps.PassbookAuditConfig',
'passbook.recovery.apps.PassbookRecoveryConfig',
'passbook.sources.ldap.apps.PassbookSourceLDAPConfig', 'passbook.sources.ldap.apps.PassbookSourceLDAPConfig',
'passbook.sources.oauth.apps.PassbookSourceOAuthConfig', 'passbook.sources.oauth.apps.PassbookSourceOAuthConfig',
@ -83,6 +86,7 @@ INSTALLED_APPS = [
'passbook.factors.captcha.apps.PassbookFactorCaptchaConfig', 'passbook.factors.captcha.apps.PassbookFactorCaptchaConfig',
'passbook.factors.password.apps.PassbookFactorPasswordConfig', 'passbook.factors.password.apps.PassbookFactorPasswordConfig',
'passbook.factors.dummy.apps.PassbookFactorDummyConfig', 'passbook.factors.dummy.apps.PassbookFactorDummyConfig',
'passbook.factors.email.apps.PassbookFactorEmailConfig',
'passbook.policies.expiry.apps.PassbookPolicyExpiryConfig', 'passbook.policies.expiry.apps.PassbookPolicyExpiryConfig',
'passbook.policies.reputation.apps.PassbookPolicyReputationConfig', 'passbook.policies.reputation.apps.PassbookPolicyReputationConfig',
@ -114,7 +118,7 @@ CACHES = {
} }
DJANGO_REDIS_IGNORE_EXCEPTIONS = True DJANGO_REDIS_IGNORE_EXCEPTIONS = True
DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True DJANGO_REDIS_LOG_IGNORED_EXCEPTIONS = True
SESSION_ENGINE = "django.contrib.sessions.backends.cache" SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
SESSION_CACHE_ALIAS = "default" SESSION_CACHE_ALIAS = "default"
MIDDLEWARE = [ MIDDLEWARE = [
@ -196,7 +200,12 @@ USE_TZ = True
# Celery settings # Celery settings
# Add a 10 minute timeout to all Celery tasks. # Add a 10 minute timeout to all Celery tasks.
CELERY_TASK_SOFT_TIME_LIMIT = 600 CELERY_TASK_SOFT_TIME_LIMIT = 600
CELERY_BEAT_SCHEDULE = {} CELERY_BEAT_SCHEDULE = {
'clean_nonces': {
'task': 'passbook.core.tasks.clean_nonces',
'schedule': crontab(minute='*/5') # Run every 5 minutes
}
}
CELERY_CREATE_MISSING_QUEUES = True CELERY_CREATE_MISSING_QUEUES = True
CELERY_TASK_DEFAULT_QUEUE = 'passbook' CELERY_TASK_DEFAULT_QUEUE = 'passbook'
CELERY_BROKER_URL = (f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}" CELERY_BROKER_URL = (f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"

View File

@ -3,7 +3,6 @@
import json import json
from urllib.parse import parse_qs, urlencode from urllib.parse import parse_qs, urlencode
from django.conf import settings
from django.utils.crypto import constant_time_compare, get_random_string from django.utils.crypto import constant_time_compare, get_random_string
from django.utils.encoding import force_text from django.utils.encoding import force_text
from requests import Session from requests import Session
@ -11,6 +10,8 @@ from requests.exceptions import RequestException
from requests_oauthlib import OAuth1 from requests_oauthlib import OAuth1
from structlog import get_logger from structlog import get_logger
from passbook import __version__
LOGGER = get_logger() LOGGER = get_logger()
@ -23,7 +24,7 @@ class BaseOAuthClient:
self.source = source self.source = source
self.token = token self.token = token
self._session = Session() self._session = Session()
self._session.headers.update({'User-Agent': 'web:passbook:%s' % settings.VERSION}) self._session.headers.update({'User-Agent': 'web:passbook:%s' % __version__})
def get_access_token(self, request, callback=None): def get_access_token(self, request, callback=None):
"Fetch access token from callback request." "Fetch access token from callback request."

View File

@ -4,7 +4,7 @@ from django.db import models
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from passbook.core.models import Source, UserSourceConnection from passbook.core.models import Source, UserSettings, UserSourceConnection
from passbook.sources.oauth.clients import get_client from passbook.sources.oauth.clients import get_client
@ -37,18 +37,15 @@ class OAuthSource(Source):
reverse_lazy('passbook_sources_oauth:oauth-client-callback', reverse_lazy('passbook_sources_oauth:oauth-client-callback',
kwargs={'source_slug': self.slug}) kwargs={'source_slug': self.slug})
def has_user_settings(self): def user_settings(self) -> UserSettings:
"""Entrypoint to integrate with User settings. Can either return False if no
user settings are available, or a tuple or string, string, string where the first string
is the name the item has, the second string is the icon and the third is the view-name."""
icon_type = self.provider_type icon_type = self.provider_type
if icon_type == 'azure ad': if icon_type == 'azure ad':
icon_type = 'windows' icon_type = 'windows'
icon_class = 'fa fa-%s' % icon_type icon_class = 'fa fa-%s' % icon_type
view_name = 'passbook_sources_oauth:oauth-client-user' view_name = 'passbook_sources_oauth:oauth-client-user'
return self.name, icon_class, reverse((view_name), kwargs={ return UserSettings(self.name, icon_class, reverse((view_name), kwargs={
'source_slug': self.slug 'source_slug': self.slug
}) }))
class Meta: class Meta: