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]
current_version = 0.6.1-beta
current_version = 0.6.4-beta
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-(?P<release>.*)

View File

@ -27,7 +27,7 @@ create-base-image:
before_script:
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
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
only:
refs:
@ -41,7 +41,7 @@ build-dev-image:
before_script:
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
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
only:
refs:
@ -95,7 +95,7 @@ build-passbook-server:
before_script:
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
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:
- tags
- /^version/.*$/
@ -107,7 +107,7 @@ build-passbook-static:
before_script:
- echo "{\"auths\":{\"docker.beryju.org\":{\"auth\":\"$DOCKER_AUTH\"}}}" > /kaniko/.docker/config.json
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:
- tags
- /^version/.*$/

View File

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

View File

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

60
Pipfile.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,7 @@ http {
gzip on;
gzip_types application/javascript image/* text/css;
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;
root /data/;

View File

@ -1,6 +1,6 @@
apiVersion: v1
appVersion: "0.6.1-beta"
appVersion: "0.6.4-beta"
description: A Helm chart for 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

View File

@ -12,87 +12,5 @@ data:
host: "{{ .Release.Name }}-redis-master"
cache_db: 0
message_queue_db: 1
# Error reporting, sends stacktrace to sentry.beryju.org
error_report_enabled: {{ .Values.config.error_reporting }}
{{- 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.
domain: ".{{ .Values.ingress.hosts[0] }}"

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
prefix: PASSBOOK_
env:
- name: PASSBOOK_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "passbook.fullname" . }}-secret-key
key: secret_key
- name: PASSBOOK_REDIS__PASSWORD
valueFrom:
secretKeyRef:
@ -65,6 +70,11 @@ spec:
name: {{ include "passbook.fullname" . }}-config
prefix: PASSBOOK_
env:
- name: PASSBOOK_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ include "passbook.fullname" . }}-secret-key
key: secret_key
- name: PASSBOOK_REDIS__PASSWORD
valueFrom:
secretKeyRef:

View File

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

View File

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

View File

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

View File

@ -7,6 +7,10 @@
<div class="container">
<h1><span class="pficon-users"></span> {% trans "Users" %}</h1>
<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">
<thead>
<tr>

View File

@ -61,6 +61,7 @@ urlpatterns = [
# Users
path('users/', users.UserListView.as_view(),
name='users'),
path('users/create/', users.UserCreateView.as_view(), name='user-create'),
path('users/<int:pk>/update/',
users.UserUpdateView.as_view(), name='user-update'),
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.utils.translation import ugettext as _
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.mixins import AdminRequiredMixin
@ -19,6 +19,17 @@ class UserListView(AdminRequiredMixin, ListView):
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):
"""Update user"""

View File

@ -1,11 +1,5 @@
"""passbook core app config"""
from importlib import import_module
from django.apps import AppConfig
from django.conf import settings
from structlog import get_logger
LOGGER = get_logger()
class PassbookCoreConfig(AppConfig):
@ -15,11 +9,3 @@ class PassbookCoreConfig(AppConfig):
label = 'passbook_core'
verbose_name = 'passbook Core'
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:
raise ValidationError(_("Passwords don't match"))
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 random import SystemRandom
from time import sleep
from typing import Optional
from uuid import uuid4
from django.contrib.auth.models import AbstractUser
@ -56,6 +57,7 @@ class User(AbstractUser):
self.password_change_date = now()
return super().set_password(password)
class Provider(models.Model):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
@ -69,11 +71,26 @@ class Provider(models.Model):
return getattr(self, 'name')
return super().__str__()
class PolicyModel(UUIDModel, CreatedUpdatedModel):
"""Base model which can have policies applied to it"""
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):
"""Authentication factor, multiple instances of the same Factor can be used"""
@ -86,11 +103,10 @@ class Factor(PolicyModel):
type = ''
form = ''
def has_user_settings(self):
"""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."""
return False
def user_settings(self) -> Optional[UserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UserSettings."""
return None
def __str__(self):
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 None
def has_user_settings(self):
"""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."""
return False
def user_settings(self) -> Optional[UserSettings]:
"""Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or an instanace of UserSettings."""
return None
def __str__(self):
return self.name
@ -242,21 +257,29 @@ class Invitation(UUIDModel):
verbose_name = _('Invitation')
verbose_name_plural = _('Invitations')
class Nonce(UUIDModel):
"""One-time link for password resets/sign-up-confirmations"""
expires = models.DateTimeField(default=default_nonce_duration)
user = models.ForeignKey('User', on_delete=models.CASCADE)
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):
return f"Nonce f{self.uuid.hex} (expires={self.expires})"
return f"Nonce f{self.uuid.hex} {self.description} (expires={self.expires})"
class Meta:
verbose_name = _('Nonce')
verbose_name_plural = _('Nonces')
class PropertyMapping(UUIDModel):
"""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"""
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 passbook.core.models import Nonce
from passbook.lib.config import CONFIG
from passbook.root.celery import CELERY_APP
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()
def clean_nonces():
"""Remove expired nonces"""
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>
{% block scripts %}
{% endblock %}
<div class="modals">
{% include 'partials/about_modal.html' %}
</div>
</body>
</html>

View File

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

View File

@ -23,37 +23,18 @@
</div>
<nav class="collapse navbar-collapse">
<ul class="nav navbar-nav navbar-right navbar-iconic navbar-utility">
<li class="dropdown">
<button class="btn btn-link dropdown-toggle nav-item-iconic" id="dropdownMenu1" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="true">
<span title="Help" class="fa pficon-help"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu1">
{% comment %} <li><a href="#0">Help</a></li> {% endcomment %}
<li><a data-toggle="modal" data-target="#about-modal" href="#0">{% trans 'About' %}</a></li>
</ul>
</li>
<li class="dropdown">
<button class="btn btn-link dropdown-toggle nav-item-iconic" id="dropdownMenu2" data-toggle="dropdown"
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>
<a href="{% url 'passbook_core:auth-logout' %}" class="btn btn-link nav-item-iconic" aria-haspopup="true" aria-expanded="true">
<span title="Username" class="fa fa-sign-out"></span>
<span class="dropdown-title">
{% trans 'Logout' %}
</span>
</a>
<a href="{% url 'passbook_core:user-settings' %}" class="btn btn-link nav-item-iconic" aria-haspopup="true" aria-expanded="true">
<span title="Username" class="fa pficon-user"></span>
<span class="dropdown-title">
{{ user.username }}
</span>
</a>
</ul>
</nav>
</nav>
@ -141,18 +122,6 @@
<span class="list-group-item-value">{% trans 'Audit Log' %}</span>
</a>
</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 %}
</ul>
</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' %}
</a>
</li>
<li class="nav-divider"></li>
{% user_factors as uf %}
{% for name, icon, link in uf %}
<li class="{% is_active link %}">
<a href="{% url link %}">
<i class="{{ icon }}"></i> {{ name }}
</a>
</li>
{% if uf %}
<li class="nav-divider"></li>
{% endif %}
{% for user_settings in uf %}
<li class="{% is_active user_settings.view_name %}">
<a href="{% url user_settings.view_name %}">
<i class="{{ user_settings.icon }}"></i> {{ user_settings.name }}
</a>
</li>
{% endfor %}
<li class="nav-divider"></li>
{% user_sources as us %}
{% for name, icon, link in us %}
<li class="{% if link == request.get_full_path %} active {% endif %}">
<a href="{{ link }}">
<i class="{{ icon }}"></i> {{ name }}
</a>
</li>
{% if us %}
<li class="nav-divider"></li>
{% endif %}
{% for user_settings in us %}
<li class="{% if user_settings.view_name == request.get_full_path %} active {% endif %}">
<a href="{{ user_settings.view_name }}">
<i class="{{ user_settings.icon }}"></i> {{ user_settings.name }}
</a>
</li>
{% endfor %}
</ul>
</div>

View File

@ -1,37 +1,38 @@
"""passbook user settings template tags"""
from typing import List
from django import template
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
register = template.Library()
@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"""
user = context.get('request').user
_all_factors = Factor.objects.filter(enabled=True).order_by('order').select_subclasses()
matching_factors = []
matching_factors: List[UserSettings] = []
for factor in _all_factors:
_link = factor.has_user_settings()
user_settings = factor.user_settings()
policy_engine = PolicyEngine(factor.policies.all())
policy_engine.for_user(user).with_request(context.get('request')).build()
if policy_engine.passing and _link:
matching_factors.append(_link)
if policy_engine.passing and user_settings:
matching_factors.append(user_settings)
return matching_factors
@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"""
user = context.get('request').user
_all_sources = Source.objects.filter(enabled=True).select_subclasses()
matching_sources = []
matching_sources: List[UserSettings] = []
for factor in _all_sources:
_link = factor.has_user_settings()
user_settings = factor.user_settings()
policy_engine = PolicyEngine(factor.policies.all())
policy_engine.for_user(user).with_request(context.get('request')).build()
if policy_engine.passing and _link:
matching_sources.append(_link)
if policy_engine.passing and user_settings:
matching_sources.append(user_settings)
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.models import Invitation, Nonce, Source, User
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.view import AuthenticationView, _redirect_with_qs
from passbook.lib.config import CONFIG
@ -97,7 +96,7 @@ class SignUpView(UserPassesTestMixin, FormView):
template_name = 'login/form.html'
form_class = SignUpForm
success_url = '.'
# Invitation insatnce, if invitation link was used
# Invitation instance, if invitation link was used
_invitation = None
# Instance of newly created user
_user = None
@ -152,23 +151,23 @@ class SignUpView(UserPassesTestMixin, FormView):
for error in exc.messages:
errors.append(error)
return self.form_invalid(form)
needs_confirmation = True
if self._invitation and not self._invitation.needs_confirmation:
needs_confirmation = False
if needs_confirmation:
nonce = Nonce.objects.create(user=self._user)
LOGGER.debug(str(nonce.uuid))
# Send email to user
send_email.delay(self._user.email, _('Confirm your account.'),
'email/account_confirm.html', {
'url': self.request.build_absolute_uri(
reverse('passbook_core:auth-sign-up-confirm', kwargs={
'nonce': nonce.uuid
})
)
})
self._user.is_active = False
self._user.save()
# needs_confirmation = True
# if self._invitation and not self._invitation.needs_confirmation:
# needs_confirmation = False
# if needs_confirmation:
# nonce = Nonce.objects.create(user=self._user)
# LOGGER.debug(str(nonce.uuid))
# # Send email to user
# send_email.delay(self._user.email, _('Confirm your account.'),
# 'email/account_confirm.html', {
# 'url': self.request.build_absolute_uri(
# reverse('passbook_core:auth-sign-up-confirm', kwargs={
# 'nonce': nonce.uuid
# })
# )
# })
# self._user.is_active = False
# self._user.save()
self.consume_invitation()
messages.success(self.request, _("Successfully signed up!"))
LOGGER.debug("Successfully signed up %s",

View File

@ -14,13 +14,14 @@ class AuthenticationFactor(TemplateView):
form: ModelForm = None
required: bool = True
authenticator: AuthenticationView = None
pending_user: User = None
authenticator: AuthenticationView
pending_user: User
request: HttpRequest = None
template_name = 'login/form_with_user.html'
def __init__(self, authenticator: AuthenticationView):
self.authenticator = authenticator
self.pending_user = None
def get_context_data(self, **kwargs):
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"""
from captcha.fields import ReCaptchaField
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.forms import GENERAL_FIELDS
@ -21,6 +23,7 @@ class CaptchaFactorForm(forms.ModelForm):
widgets = {
'name': forms.TextInput(),
'order': forms.NumberInput(),
'policies': FilteredSelectMultiple(_('policies'), False),
'public_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.utils.translation import gettext as _
from passbook.core.models import Factor
from passbook.core.models import Factor, UserSettings
class OTPFactor(Factor):
@ -15,8 +15,8 @@ class OTPFactor(Factor):
type = 'passbook.factors.otp.factors.OTPFactor'
form = 'passbook.factors.otp.forms.OTPFactorForm'
def has_user_settings(self):
return _('OTP'), 'pficon-locked', 'passbook_otp:otp-user-settings'
def user_settings(self) -> UserSettings:
return UserSettings(_('OTP'), 'pficon-locked', 'passbook_factors_otp:otp-user-settings')
def __str__(self):
return f"OTP Factor {self.slug}"

View File

@ -26,10 +26,10 @@
</p>
<p>
{% 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>
{% 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>
{% endif %}
</p>

View File

@ -21,8 +21,8 @@ from passbook.factors.otp.utils import otpauth_url
from passbook.lib.boilerplate import NeverCacheMixin
from passbook.lib.config import CONFIG
OTP_SESSION_KEY = 'passbook_otp_key'
OTP_SETTING_UP_KEY = 'passbook_otp_setup'
OTP_SESSION_KEY = 'passbook_factors_otp_key'
OTP_SETTING_UP_KEY = 'passbook_factors_otp_setup'
LOGGER = get_logger()
class UserSettingsView(LoginRequiredMixin, TemplateView):
@ -61,7 +61,7 @@ class DisableView(LoginRequiredMixin, View):
# current=True,
# request=request,
# send_notification=True)
return redirect(reverse('passbook_otp:otp-user-settings'))
return redirect(reverse('passbook_factors_otp:otp-user-settings'))
class EnableView(LoginRequiredMixin, FormView):
"""View to set up OTP"""
@ -88,7 +88,7 @@ class EnableView(LoginRequiredMixin, FormView):
if finished_totp_devices.exists() and finished_static_devices.exists():
messages.error(request, _('You already have TOTP enabled!'))
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
# Check if there's an unconfirmed device left to set up
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):
form = super().get_form(form_class=form_class)
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()]
form.fields['tokens'].choices = tokens
return form
@ -142,7 +142,7 @@ class EnableView(LoginRequiredMixin, FormView):
# current=True,
# request=self.request,
# send_notification=True)
return redirect('passbook_otp:otp-user-settings')
return redirect('passbook_factors_otp:otp-user-settings')
class QRView(NeverCacheMixin, View):
"""View returns an SVG image with the OTP token information"""

View File

@ -1,20 +1,18 @@
"""passbook multi-factor authentication engine"""
from inspect import Signature
from typing import Optional
from django.contrib import messages
from django.contrib.auth import _clean_credentials
from django.contrib.auth.signals import user_login_failed
from django.core.exceptions import PermissionDenied
from django.forms.utils import ErrorList
from django.shortcuts import redirect, reverse
from django.utils.translation import gettext as _
from django.views.generic import FormView
from structlog import get_logger
from passbook.core.forms.authentication import PasswordFactorForm
from passbook.core.models import Nonce
from passbook.core.tasks import send_email
from passbook.core.models import User
from passbook.factors.base import AuthenticationFactor
from passbook.factors.password.forms import PasswordForm
from passbook.factors.view import AuthenticationView
from passbook.lib.config import CONFIG
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()
def authenticate(request, backends, **credentials):
def authenticate(request, backends, **credentials) -> Optional[User]:
"""If the given credentials are valid, return a User object.
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):
"""Authentication factor which authenticates against django's AuthBackend"""
form_class = PasswordFactorForm
form_class = PasswordForm
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):
"""Authenticate against django's authentication backend"""
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__))
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):
"""Form to create/edit Password Factors"""
class Meta:
model = PasswordFactor
fields = GENERAL_FIELDS + ['backends', 'password_policies']
fields = GENERAL_FIELDS + ['backends', 'password_policies', 'reset_factors']
widgets = {
'name': forms.TextInput(),
'order': forms.NumberInput(),
@ -30,4 +40,5 @@ class PasswordFactorForm(forms.ModelForm):
'backends': FilteredSelectMultiple(_('backends'), False,
choices=get_authentication_backends()),
'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.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):
@ -11,12 +11,14 @@ class PasswordFactor(Factor):
backends = ArrayField(models.TextField())
password_policies = models.ManyToManyField(Policy, blank=True)
reset_factors = models.ManyToManyField(Factor, blank=True, related_name='reset_factors')
type = 'passbook.factors.password.factor.PasswordFactor'
form = 'passbook.factors.password.forms.PasswordFactorForm'
def has_user_settings(self):
return _('Change Password'), 'pficon-key', 'passbook_core:user-change-password'
def user_settings(self):
return UserSettings(_('Change Password'), 'pficon-key',
'passbook_core:user-change-password')
def password_passes(self, user: User) -> bool:
"""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_report_enabled: true
domains:
- passbook.local
primary_domain: 'localhost'
domain: localhost
passbook:
sign_up:
@ -48,8 +46,6 @@ passbook:
uid_fields:
- username
- email
session:
remember_age: 2592000 # 60 * 60 * 24 * 30, one month
# Provider-specific settings
ldap:
# Which field from `uid_fields` maps to which LDAP Attribute
@ -61,6 +57,3 @@ ldap:
username: "%(sAMAccountName)s"
email: "%(mail)s"
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"""
from pprint import pprint
from urllib.parse import urlparse
from django.conf import settings
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from django.views import View
from structlog import get_logger
@ -12,15 +13,26 @@ from passbook.providers.app_gw.models import ApplicationGatewayProvider
ORIGINAL_URL = 'HTTP_X_ORIGINAL_URL'
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):
"""View used by nginx's auth_request module"""
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))
# request.session[AuthenticationView.SESSION_ALLOW_ABSOLUTE_NEXT] = True
# request.session[AuthenticationView.SESSION_FORCE_COOKIE_HOSTNAME] = parsed_url.hostname
print(request.user)
if not request.user.is_authenticated:
return HttpResponse(status=401)
matching = ApplicationGatewayProvider.objects.filter(
@ -31,6 +43,7 @@ class NginxCheckView(AccessMixin, View):
application = self.provider_to_application(matching.first())
has_access, _ = self.user_has_access(application, request.user)
if has_access:
cache.set(_cache_key, True)
return HttpResponse(status=202)
LOGGER.debug("User not passing", user=request.user)
return HttpResponse(status=401)

View File

@ -3,6 +3,7 @@ from django.contrib import messages
from django.shortcuts import redirect
from structlog import get_logger
from passbook.audit.models import AuditEntry
from passbook.core.models import Application
from passbook.policies.engine import PolicyEngine
@ -26,4 +27,10 @@ def check_permissions(request, user, client):
for policy_message in policy_messages:
messages.error(request, policy_message)
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

View File

@ -38,7 +38,7 @@ class SAMLProvider(Provider):
if not self._processor:
try:
self._processor = path_to_class(self.processor_path)(self)
except ModuleNotFoundError as exc:
except ImportError as exc:
LOGGER.warning(exc)
self._processor = None
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 structlog
from celery.schedules import crontab
from sentry_sdk import init as sentry_init
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
@ -47,6 +48,7 @@ AUTH_USER_MODEL = 'passbook_core.User'
CSRF_COOKIE_NAME = 'passbook_csrf'
SESSION_COOKIE_NAME = 'passbook_session'
SESSION_COOKIE_DOMAIN = CONFIG.y('domain', None)
LANGUAGE_COOKIE_NAME = 'passbook_language'
AUTHENTICATION_BACKENDS = [
@ -70,6 +72,7 @@ INSTALLED_APPS = [
'passbook.api.apps.PassbookAPIConfig',
'passbook.lib.apps.PassbookLibConfig',
'passbook.audit.apps.PassbookAuditConfig',
'passbook.recovery.apps.PassbookRecoveryConfig',
'passbook.sources.ldap.apps.PassbookSourceLDAPConfig',
'passbook.sources.oauth.apps.PassbookSourceOAuthConfig',
@ -83,6 +86,7 @@ INSTALLED_APPS = [
'passbook.factors.captcha.apps.PassbookFactorCaptchaConfig',
'passbook.factors.password.apps.PassbookFactorPasswordConfig',
'passbook.factors.dummy.apps.PassbookFactorDummyConfig',
'passbook.factors.email.apps.PassbookFactorEmailConfig',
'passbook.policies.expiry.apps.PassbookPolicyExpiryConfig',
'passbook.policies.reputation.apps.PassbookPolicyReputationConfig',
@ -114,7 +118,7 @@ CACHES = {
}
DJANGO_REDIS_IGNORE_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"
MIDDLEWARE = [
@ -196,7 +200,12 @@ USE_TZ = True
# Celery settings
# Add a 10 minute timeout to all Celery tasks.
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_TASK_DEFAULT_QUEUE = 'passbook'
CELERY_BROKER_URL = (f"redis://:{CONFIG.y('redis.password')}@{CONFIG.y('redis.host')}"

View File

@ -3,7 +3,6 @@
import json
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.encoding import force_text
from requests import Session
@ -11,6 +10,8 @@ from requests.exceptions import RequestException
from requests_oauthlib import OAuth1
from structlog import get_logger
from passbook import __version__
LOGGER = get_logger()
@ -23,7 +24,7 @@ class BaseOAuthClient:
self.source = source
self.token = token
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):
"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.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
@ -37,18 +37,15 @@ class OAuthSource(Source):
reverse_lazy('passbook_sources_oauth:oauth-client-callback',
kwargs={'source_slug': self.slug})
def has_user_settings(self):
"""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."""
def user_settings(self) -> UserSettings:
icon_type = self.provider_type
if icon_type == 'azure ad':
icon_type = 'windows'
icon_class = 'fa fa-%s' % icon_type
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
})
}))
class Meta: