*(minor): small refactor

This commit is contained in:
Langhammer, Jens
2019-10-07 16:33:48 +02:00
parent d21ec6c9a5
commit f2acc154cd
300 changed files with 1420 additions and 1788 deletions

View File

View File

@ -0,0 +1,5 @@
"""Passbook LDAP Admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_sources_ldap')

View File

@ -0,0 +1,11 @@
"""Passbook ldap app config"""
from django.apps import AppConfig
class PassbookSourceLDAPConfig(AppConfig):
"""Passbook ldap app config"""
name = 'passbook.sources.ldap'
label = 'passbook_sources_ldap'
verbose_name = 'passbook Sources.LDAP'

View File

@ -0,0 +1,23 @@
"""passbook LDAP Authentication Backend"""
from django.contrib.auth.backends import ModelBackend
from structlog import get_logger
from passbook.sources.ldap.ldap_connector import LDAPConnector
from passbook.sources.ldap.models import LDAPSource
LOGGER = get_logger()
class LDAPBackend(ModelBackend):
"""Authenticate users against LDAP Server"""
def authenticate(self, **kwargs):
"""Try to authenticate a user via ldap"""
if 'password' not in kwargs:
return None
for source in LDAPSource.objects.filter(enabled=True):
_ldap = LDAPConnector(source)
user = _ldap.auth_user(**kwargs)
if user:
return user
return None

View File

@ -0,0 +1,50 @@
"""passbook LDAP Forms"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext_lazy as _
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
from passbook.core.forms.policies import GENERAL_FIELDS
from passbook.sources.ldap.models import LDAPGroupMembershipPolicy, LDAPSource
class LDAPSourceForm(forms.ModelForm):
"""LDAPSource Form"""
class Meta:
model = LDAPSource
fields = SOURCE_FORM_FIELDS + ['server_uri', 'bind_cn', 'bind_password',
'type', 'domain', 'base_dn', 'create_user',
'reset_password']
widgets = {
'name': forms.TextInput(),
'server_uri': forms.TextInput(),
'bind_cn': forms.TextInput(),
'bind_password': forms.TextInput(),
'domain': forms.TextInput(),
'base_dn': forms.TextInput(),
'policies': FilteredSelectMultiple(_('policies'), False)
}
labels = {
'server_uri': _('Server URI'),
'bind_cn': _('Bind CN'),
'base_dn': _('Base DN'),
}
class LDAPGroupMembershipPolicyForm(forms.ModelForm):
"""LDAPGroupMembershipPolicy Form"""
class Meta:
model = LDAPGroupMembershipPolicy
fields = GENERAL_FIELDS + ['dn', ]
widgets = {
'name': forms.TextInput(),
'dn': forms.TextInput(),
}
labels = {
'dn': _('DN')
}

View File

@ -0,0 +1,293 @@
"""Wrapper for ldap3 to easily manage user"""
from time import time
import ldap3
import ldap3.core.exceptions
from structlog import get_logger
from passbook.core.models import User
from passbook.lib.config import CONFIG
from passbook.sources.ldap.models import LDAPSource
LOGGER = get_logger()
USERNAME_FIELD = CONFIG.y('ldap.username_field', 'sAMAccountName')
LOGIN_FIELD = CONFIG.y('ldap.login_field', 'userPrincipalName')
class LDAPConnector:
"""Wrapper for ldap3 to easily manage user authentication and creation"""
_server = None
_connection = None
_source = None
def __init__(self, source: LDAPSource):
self._source = source
if not self._source.enabled:
LOGGER.debug("LDAP not Enabled")
# if not con_args:
# con_args = {}
# if not server_args:
# server_args = {}
# Either use mock argument or test is in argv
# if mock or any('test' in arg for arg in sys.argv):
# self.mock = True
# self.create_users_enabled = True
# con_args['client_strategy'] = ldap3.MOCK_SYNC
# server_args['get_info'] = ldap3.OFFLINE_AD_2012_R2
# if self.mock:
# json_path = os.path.join(os.path.dirname(__file__), 'tests', 'ldap_mock.json')
# self._connection.strategy.entries_from_json(json_path)
self._server = ldap3.Server(source.server_uri) # Implement URI parsing
self._connection = ldap3.Connection(self._server, raise_exceptions=True,
user=source.bind_cn,
password=source.bind_password)
self._connection.bind()
# if CONFIG.y('ldap.server.use_tls'):
# self._connection.start_tls()
# @staticmethod
# def cleanup_mock():
# """Cleanup mock files which are not this PID's"""
# pid = os.getpid()
# json_path = os.path.join(os.path.dirname(__file__), 'test', 'ldap_mock_%d.json' % pid)
# os.unlink(json_path)
# LOGGER.debug("Cleaned up LDAP Mock from PID %d", pid)
# def apply_db(self):
# """Check if any unapplied LDAPModification's are left"""
# to_apply = LDAPModification.objects.filter(_purgeable=False)
# for obj in to_apply:
# try:
# if obj.action == LDAPModification.ACTION_ADD:
# self._connection.add(obj.dn, obj.data)
# elif obj.action == LDAPModification.ACTION_MODIFY:
# self._connection.modify(obj.dn, obj.data)
# # Object has been successfully applied to LDAP
# obj.delete()
# except ldap3.core.exceptions.LDAPException as exc:
# LOGGER.error(exc)
# LOGGER.debug("Recovered %d Modifications from DB.", len(to_apply))
# @staticmethod
# def handle_ldap_error(object_dn, action, data):
# """Custom Handler for LDAP methods to write LDIF to DB"""
# LDAPModification.objects.create(
# dn=object_dn,
# action=action,
# data=data)
# @property
# def enabled(self):
# """Returns whether LDAP is enabled or not"""
# return CONFIG.y('ldap.enabled')
@staticmethod
def encode_pass(password):
"""Encodes a plain-text password so it can be used by AD"""
return '"{}"'.format(password).encode('utf-16-le')
def generate_filter(self, **fields):
"""Generate LDAP filter from **fields."""
filters = []
for item, value in fields.items():
filters.append("(%s=%s)" % (item, value))
ldap_filter = "(&%s)" % "".join(filters)
LOGGER.debug("Constructed filter: '%s'", ldap_filter)
return ldap_filter
def lookup(self, ldap_filter: str):
"""Search email in LDAP and return the DN.
Returns False if nothing was found."""
try:
self._connection.search(self._source.search_base, ldap_filter)
results = self._connection.response
if len(results) >= 1:
if 'dn' in results[0]:
return str(results[0]['dn'])
except ldap3.core.exceptions.LDAPNoSuchObjectResult as exc:
LOGGER.warning(exc)
return False
except ldap3.core.exceptions.LDAPInvalidDnError as exc:
LOGGER.warning(exc)
return False
return False
def _get_or_create_user(self, user_data):
"""Returns a Django user for the given LDAP user data.
If the user does not exist, then it will be created."""
attributes = user_data.get("attributes")
if attributes is None:
LOGGER.warning("LDAP user attributes empty")
return None
# Create the user data.
field_map = {
'username': '%(' + USERNAME_FIELD + ')s',
'name': '%(givenName)s %(sn)s',
'email': '%(mail)s',
}
user_fields = {}
for dj_field, ldap_field in field_map.items():
user_fields[dj_field] = ldap_field % attributes
# Update or create the user.
user, created = User.objects.update_or_create(
defaults=user_fields,
username=user_fields.pop('username', "")
)
# Update groups
# if 'memberOf' in attributes:
# applicable_groups = LDAPGroupMapping.objects.f
# ilter(ldap_dn__in=attributes['memberOf'])
# for group in applicable_groups:
# if group.group not in user.groups.all():
# user.groups.add(group.group)
# user.save()
# If the user was created, set them an unusable password.
if created:
user.set_unusable_password()
user.save()
# All done!
LOGGER.debug("LDAP user lookup succeeded")
return user
def auth_user(self, password, **filters):
"""Try to bind as either user_dn or mail with password.
Returns True on success, otherwise False"""
filters.pop('request')
if not self._source.enabled:
return None
# FIXME: Adapt user_uid
# email = filters.pop(CONFIG.y('passport').get('ldap').get, '')
email = filters.pop('email')
user_dn = self.lookup(self.generate_filter(**{LOGIN_FIELD: email}))
if not user_dn:
return None
# Try to bind as new user
LOGGER.debug("Binding as '%s'", user_dn)
try:
temp_connection = ldap3.Connection(self._server, user=user_dn,
password=password, raise_exceptions=True)
temp_connection.bind()
if self._connection.search(
search_base=self._source.search_base,
search_filter=self.generate_filter(**{LOGIN_FIELD: email}),
search_scope=ldap3.SUBTREE,
attributes=[ldap3.ALL_ATTRIBUTES, ldap3.ALL_OPERATIONAL_ATTRIBUTES],
get_operational_attributes=True,
size_limit=1,
):
response = self._connection.response[0]
# If user has no email set in AD, use UPN
if 'mail' not in response.get('attributes'):
response['attributes']['mail'] = response['attributes']['userPrincipalName']
return self._get_or_create_user(response)
LOGGER.warning("LDAP user lookup failed")
return None
except ldap3.core.exceptions.LDAPInvalidCredentialsResult as exception:
LOGGER.debug("User '%s' failed to login (Wrong credentials)", user_dn)
except ldap3.core.exceptions.LDAPException as exception:
LOGGER.warning(exception)
return None
def is_email_used(self, mail):
"""Checks whether an email address is already registered in LDAP"""
if self._source.create_user:
return self.lookup(self.generate_filter(mail=mail))
return False
def create_ldap_user(self, user, raw_password):
"""Creates a new LDAP User from a django user and raw_password.
Returns True on success, otherwise False"""
if self._source.create_user:
LOGGER.debug("User creation not enabled")
return False
# The dn of our new entry/object
username = user.pk.hex # UUID without dashes
# sAMAccountName is limited to 20 chars
# https://msdn.microsoft.com/en-us/library/ms679635.aspx
username_trunk = username[:20] if len(username) > 20 else username
# AD doesn't like sAMAccountName's with . at the end
username_trunk = username_trunk[:-1] if username_trunk[-1] == '.' else username_trunk
user_dn = 'cn=' + username + ',' + self._source.search_base
LOGGER.debug('New DN: %s', user_dn)
attrs = {
'distinguishedName': str(user_dn),
'cn': str(username),
'description': 't=' + str(time()),
'sAMAccountName': str(username_trunk),
'givenName': str(user.name),
'displayName': str(user.username),
'name': str(user.name),
'mail': str(user.email),
'userPrincipalName': str(username + '@' + self._source.domain),
'objectClass': ['top', 'person', 'organizationalPerson', 'user'],
}
try:
self._connection.add(user_dn, attributes=attrs)
except ldap3.core.exceptions.LDAPException as exception:
LOGGER.warning("Failed to create user ('%s'), saved to DB", exception)
# LDAPConnector.handle_ldap_error(user_dn, LDAPModification.ACTION_ADD, attrs)
LOGGER.debug("Signed up user %s", user.email)
return self.change_password(raw_password, mail=user.email)
def _do_modify(self, diff, **fields):
"""Do the LDAP modification itself"""
user_dn = self.lookup(self.generate_filter(**fields))
try:
self._connection.modify(user_dn, diff)
except ldap3.core.exceptions.LDAPException as exception:
LOGGER.warning("Failed to modify %s ('%s'), saved to DB", user_dn, exception)
# LDAPConnector.handle_ldap_error(user_dn, LDAPModification.ACTION_MODIFY, diff)
LOGGER.debug("modified account '%s' [%s]", user_dn, ','.join(diff.keys()))
return 'result' in self._connection.result and self._connection.result['result'] == 0
def disable_user(self, **fields):
"""Disables LDAP user based on mail or user_dn.
Returns True on success, otherwise False"""
diff = {
'userAccountControl': [(ldap3.MODIFY_REPLACE, [str(66050)])],
}
return self._do_modify(diff, **fields)
def enable_user(self, **fields):
"""Enables LDAP user based on mail or user_dn.
Returns True on success, otherwise False"""
diff = {
'userAccountControl': [(ldap3.MODIFY_REPLACE, [str(66048)])],
}
return self._do_modify(diff, **fields)
def change_password(self, new_password, **fields):
"""Changes LDAP user's password based on mail or user_dn.
Returns True on success, otherwise False"""
diff = {
'unicodePwd': [(ldap3.MODIFY_REPLACE, [LDAPConnector.encode_pass(new_password)])],
}
return self._do_modify(diff, **fields)
def add_to_group(self, group_dn, **fields):
"""Adds mail or user_dn to group_dn
Returns True on success, otherwise False"""
user_dn = self.lookup(**fields)
diff = {
'member': [(ldap3.MODIFY_ADD), [user_dn]]
}
return self._do_modify(diff, user_dn=group_dn)
def remove_from_group(self, group_dn, **fields):
"""Removes mail or user_dn from group_dn
Returns True on success, otherwise False"""
user_dn = self.lookup(**fields)
diff = {
'member': [(ldap3.MODIFY_DELETE), [user_dn]]
}
return self._do_modify(diff, user_dn=group_dn)

View File

@ -0,0 +1,48 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07
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='LDAPSource',
fields=[
('source_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Source')),
('server_uri', models.TextField()),
('bind_cn', models.TextField()),
('bind_password', models.TextField()),
('type', models.CharField(choices=[('ad', 'Active Directory'), ('generic', 'Generic')], max_length=20)),
('domain', models.TextField()),
('base_dn', models.TextField()),
('create_user', models.BooleanField(default=False)),
('reset_password', models.BooleanField(default=True)),
],
options={
'verbose_name': 'LDAP Source',
'verbose_name_plural': 'LDAP Sources',
},
bases=('passbook_core.source',),
),
migrations.CreateModel(
name='LDAPGroupMembershipPolicy',
fields=[
('policy_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Policy')),
('dn', models.TextField()),
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='passbook_sources_ldap.LDAPSource')),
],
options={
'verbose_name': 'LDAP Group Membership Policy',
'verbose_name_plural': 'LDAP Group Membership Policys',
},
bases=('passbook_core.policy',),
),
]

View File

@ -0,0 +1,55 @@
"""passbook LDAP Models"""
from django.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Policy, Source, User
class LDAPSource(Source):
"""LDAP Authentication source"""
TYPE_ACTIVE_DIRECTORY = 'ad'
TYPE_GENERIC = 'generic'
TYPES = (
(TYPE_ACTIVE_DIRECTORY, _('Active Directory')),
(TYPE_GENERIC, _('Generic')),
)
server_uri = models.TextField()
bind_cn = models.TextField()
bind_password = models.TextField()
type = models.CharField(max_length=20, choices=TYPES)
domain = models.TextField()
base_dn = models.TextField()
create_user = models.BooleanField(default=False)
reset_password = models.BooleanField(default=True)
form = 'passbook.sources.ldap.forms.LDAPSourceForm'
@property
def get_login_button(self):
raise NotImplementedError()
class Meta:
verbose_name = _('LDAP Source')
verbose_name_plural = _('LDAP Sources')
class LDAPGroupMembershipPolicy(Policy):
"""Policy to check if a user is in a certain LDAP Group"""
dn = models.TextField()
source = models.ForeignKey('LDAPSource', on_delete=models.CASCADE)
form = 'passbook.sources.ldap.forms.LDAPGroupMembershipPolicyForm'
def passes(self, user: User):
"""Check if user instance passes this policy"""
raise NotImplementedError()
class Meta:
verbose_name = _('LDAP Group Membership Policy')
verbose_name_plural = _('LDAP Group Membership Policys')

View File

@ -0,0 +1,5 @@
"""LDAP Settings"""
AUTHENTICATION_BACKENDS = [
'passbook.sources.ldap.auth.LDAPBackend',
]

View File

@ -0,0 +1,33 @@
{% extends "_admin/module_default.html" %}
{% load i18n %}
{% load utils %}
{% block title %}
{% title "Settings" %}
{% endblock %}
{% block module_content %}
<h2><clr-icon shape="application" size="32"></clr-icon>{% trans 'LDAP connection' %}</h2>
<div class="row">
<div class="col-md-12">
<div class="card">
<form role="form" method="POST">
<div class="card-block">
<h3><clr-icon shape="cog" size="32"></clr-icon>{% trans 'General settings' %}</h3>
{% include 'partials/form.html' with form=general %}
<h3><clr-icon shape="connect" size="32"></clr-icon>{% trans 'Connection settings' %}</h3>
{% include 'partials/form.html' with form=connection %}
<h3><clr-icon shape="certificate" size="32"></clr-icon>{% trans 'Authentication backend ' %}</h3>
{% include 'partials/form.html' with form=authentication %}
<h3><clr-icon shape="users" size="32"></clr-icon>{% trans 'Create users settings' %}</h3>
{% include 'partials/form.html' with form=create_users %}
</div>
<div class="card-footer">
<button type="submit" value="general" class="btn btn-sm btn-primary">{% trans 'Update' %}</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,9 @@
# """passbook LDAP Urls"""
# from django.conf.urls import url
# from passbook.mod.auth.ldap import views
# urlpatterns = [
# url(r'^settings/$', views.admin_settings, name='admin_settings'),
# ]

View File

@ -0,0 +1,38 @@
# """passbook LDAP Views"""
# from django.contrib import messages
# from django.contrib.auth.decorators import login_required, user_passes_test
# from django.http import HttpRequest, HttpResponse
# from django.shortcuts import redirect, render
# from django.urls import reverse
# from django.utils.translation import ugettext as _
# from passbook.sources.ldap.forms import (AuthenticationBackendSettings,
# ConnectionSettings,
# CreateUsersSettings,
# GeneralSettingsForm)
# @login_required
# @user_passes_test(lambda u: u.is_superuser)
# def admin_settings(request: HttpRequest) -> HttpResponse:
# """Default view for modules without admin view"""
# form_classes = {
# 'general': GeneralSettingsForm,
# 'connection': ConnectionSettings,
# 'authentication': AuthenticationBackendSettings,
# 'create_users': CreateUsersSettings,
# }
# render_data = {}
# for form_key, form_class in form_classes.items():
# render_data[form_key] = form_class(request.POST if request.method == 'POST' else None)
# if request.method == 'POST':
# update_count = 0
# for form_key, form_class in form_classes.items():
# form = form_class(request.POST)
# if form.is_valid():
# update_count += form.save()
# messages.success(request, _('Successfully updated %d settings.' % update_count))
# return redirect(reverse('passbook_ldap:admin_settings'))
# return render(request, 'ldap/settings.html', render_data)

View File

View File

@ -0,0 +1,5 @@
"""passbook oauth_client admin"""
from passbook.lib.admin import admin_autoregister
admin_autoregister('passbook_sources_oauth')

View File

@ -0,0 +1,25 @@
"""passbook oauth_client 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 PassbookSourceOAuthConfig(AppConfig):
"""passbook source.oauth config"""
name = 'passbook.sources.oauth'
label = 'passbook_sources_oauth'
verbose_name = 'passbook Sources.OAuth'
mountpoint = 'source/oauth/'
def ready(self):
"""Load source_types from config file"""
for source_type in settings.PASSBOOK_SOURCES_OAUTH_TYPES:
try:
import_module(source_type)
LOGGER.info("Loaded source_type", source_class=source_type)
except ImportError as exc:
LOGGER.debug(exc)

View File

@ -0,0 +1,25 @@
"""passbook oauth_client Authorization backend"""
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q
from passbook.sources.oauth.models import (OAuthSource,
UserOAuthSourceConnection)
class AuthorizedServiceBackend(ModelBackend):
"Authentication backend for users registered with remote OAuth provider."
def authenticate(self, request, source=None, identifier=None):
"Fetch user for a given source by id."
source_q = Q(source__name=source)
if isinstance(source, OAuthSource):
source_q = Q(source=source)
try:
access = UserOAuthSourceConnection.objects.filter(
source_q, identifier=identifier
).select_related('user')[0]
except IndexError:
return None
else:
return access.user

View File

@ -0,0 +1,245 @@
"""OAuth Clients"""
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
from requests.exceptions import RequestException
from requests_oauthlib import OAuth1
from structlog import get_logger
LOGGER = get_logger()
class BaseOAuthClient:
"""Base OAuth Client"""
_session = None
def __init__(self, source, token=''): # nosec
self.source = source
self.token = token
self._session = Session()
self._session.headers.update({'User-Agent': 'web:passbook:%s' % settings.VERSION})
def get_access_token(self, request, callback=None):
"Fetch access token from callback request."
raise NotImplementedError('Defined in a sub-class') # pragma: no cover
def get_profile_info(self, raw_token):
"Fetch user profile information."
try:
response = self.request('get', self.source.profile_url, token=raw_token)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch user profile: %s', exc)
return None
else:
return response.json() or response.text
def get_redirect_args(self, request, callback):
"Get request parameters for redirect url."
raise NotImplementedError('Defined in a sub-class') # pragma: no cover
def get_redirect_url(self, request, callback, parameters=None):
"Build authentication redirect url."
args = self.get_redirect_args(request, callback=callback)
additional = parameters or {}
args.update(additional)
params = urlencode(args)
LOGGER.info("Redirect args: %s", args)
return '{0}?{1}'.format(self.source.authorization_url, params)
def parse_raw_token(self, raw_token):
"Parse token and secret from raw token response."
raise NotImplementedError('Defined in a sub-class') # pragma: no cover
def request(self, method, url, **kwargs):
"Build remote url request."
return self._session.request(method, url, **kwargs)
@property
def session_key(self):
"""
Return Session Key
"""
raise NotImplementedError('Defined in a sub-class') # pragma: no cover
class OAuthClient(BaseOAuthClient):
"""OAuth1 Client"""
def get_access_token(self, request, callback=None):
"Fetch access token from callback request."
raw_token = request.session.get(self.session_key, None)
verifier = request.GET.get('oauth_verifier', None)
if raw_token is not None and verifier is not None:
data = {'oauth_verifier': verifier}
callback = request.build_absolute_uri(callback or request.path)
callback = force_text(callback)
try:
response = self.request('post', self.source.access_token_url,
token=raw_token, data=data, oauth_callback=callback)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch access token: %s', exc)
return None
else:
return response.text
return None
def get_request_token(self, request, callback):
"Fetch the OAuth request token. Only required for OAuth 1.0."
callback = force_text(request.build_absolute_uri(callback))
try:
response = self.request(
'post', self.source.request_token_url, oauth_callback=callback)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch request token: %s', exc)
return None
else:
return response.text
def get_redirect_args(self, request, callback):
"Get request parameters for redirect url."
callback = force_text(request.build_absolute_uri(callback))
raw_token = self.get_request_token(request, callback)
token, secret = self.parse_raw_token(raw_token)
if token is not None and secret is not None:
request.session[self.session_key] = raw_token
return {
'oauth_token': token,
'oauth_callback': callback,
}
def parse_raw_token(self, raw_token):
"Parse token and secret from raw token response."
if raw_token is None:
return (None, None)
query_string = parse_qs(raw_token)
token = query_string.get('oauth_token', [None])[0]
secret = query_string.get('oauth_token_secret', [None])[0]
return (token, secret)
def request(self, method, url, **kwargs):
"Build remote url request. Constructs necessary auth."
user_token = kwargs.pop('token', self.token)
token, secret = self.parse_raw_token(user_token)
callback = kwargs.pop('oauth_callback', None)
verifier = kwargs.get('data', {}).pop('oauth_verifier', None)
oauth = OAuth1(
resource_owner_key=token,
resource_owner_secret=secret,
client_key=self.source.consumer_key,
client_secret=self.source.consumer_secret,
verifier=verifier,
callback_uri=callback,
)
kwargs['auth'] = oauth
return super(OAuthClient, self).request(method, url, **kwargs)
@property
def session_key(self):
return 'oauth-client-{0}-request-token'.format(self.source.name)
class OAuth2Client(BaseOAuthClient):
"""OAuth2 Client"""
def check_application_state(self, request, callback):
"Check optional state parameter."
stored = request.session.get(self.session_key, None)
returned = request.GET.get('state', None)
check = False
if stored is not None:
if returned is not None:
check = constant_time_compare(stored, returned)
else:
LOGGER.warning('No state parameter returned by the source.')
else:
LOGGER.warning('No state stored in the sesssion.')
return check
def get_access_token(self, request, callback=None, **request_kwargs):
"Fetch access token from callback request."
callback = request.build_absolute_uri(callback or request.path)
if not self.check_application_state(request, callback):
LOGGER.warning('Application state check failed.')
return None
if 'code' in request.GET:
args = {
'client_id': self.source.consumer_key,
'redirect_uri': callback,
'client_secret': self.source.consumer_secret,
'code': request.GET['code'],
'grant_type': 'authorization_code',
}
else:
LOGGER.warning('No code returned by the source')
return None
try:
response = self.request('post', self.source.access_token_url,
data=args, **request_kwargs)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch access token: %s', exc)
return None
else:
return response.text
def get_application_state(self, request, callback):
"Generate state optional parameter."
return get_random_string(32)
def get_redirect_args(self, request, callback):
"Get request parameters for redirect url."
callback = request.build_absolute_uri(callback)
args = {
'client_id': self.source.consumer_key,
'redirect_uri': callback,
'response_type': 'code',
}
state = self.get_application_state(request, callback)
if state is not None:
args['state'] = state
request.session[self.session_key] = state
return args
def parse_raw_token(self, raw_token):
"Parse token and secret from raw token response."
if raw_token is None:
return (None, None)
# Load as json first then parse as query string
try:
token_data = json.loads(raw_token)
except ValueError:
token = parse_qs(raw_token).get('access_token', [None])[0]
else:
token = token_data.get('access_token', None)
return (token, None)
def request(self, method, url, **kwargs):
"Build remote url request. Constructs necessary auth."
user_token = kwargs.pop('token', self.token)
token, _ = self.parse_raw_token(user_token)
if token is not None:
params = kwargs.get('params', {})
params['access_token'] = token
kwargs['params'] = params
return super(OAuth2Client, self).request(method, url, **kwargs)
@property
def session_key(self):
return 'oauth-client-{0}-request-state'.format(self.source.name)
def get_client(source, token=''): # nosec
"Return the API client for the given source."
cls = OAuth2Client
if source.request_token_url:
cls = OAuthClient
return cls(source, token)

View File

@ -0,0 +1,124 @@
"""passbook oauth_client forms"""
from django import forms
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.utils.translation import gettext as _
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
from passbook.sources.oauth.models import OAuthSource
from passbook.sources.oauth.types.manager import MANAGER
class OAuthSourceForm(forms.ModelForm):
"""OAuthSource Form"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if hasattr(self.Meta, 'overrides'):
for overide_field, overide_value in getattr(self.Meta, 'overrides').items():
self.fields[overide_field].initial = overide_value
self.fields[overide_field].widget.attrs['readonly'] = 'readonly'
class Meta:
model = OAuthSource
fields = SOURCE_FORM_FIELDS + ['provider_type', 'request_token_url', 'authorization_url',
'access_token_url', 'profile_url', 'consumer_key',
'consumer_secret']
widgets = {
'name': forms.TextInput(),
'consumer_key': forms.TextInput(),
'consumer_secret': forms.TextInput(),
'provider_type': forms.Select(choices=MANAGER.get_name_tuple()),
'policies': FilteredSelectMultiple(_('policies'), False)
}
labels = {
'request_token_url': _('Request Token URL'),
'authorization_url': _('Authorization URL'),
'access_token_url': _('Access Token URL'),
'profile_url': _('Profile URL'),
}
class GitHubOAuthSourceForm(OAuthSourceForm):
"""OAuth Source form with pre-determined URL for GitHub"""
class Meta(OAuthSourceForm.Meta):
overrides = {
'provider_type': 'github',
'request_token_url': '',
'authorization_url': 'https://github.com/login/oauth/authorize',
'access_token_url': 'https://github.com/login/oauth/access_token',
'profile_url': ' https://api.github.com/user',
}
class TwitterOAuthSourceForm(OAuthSourceForm):
"""OAuth Source form with pre-determined URL for Twitter"""
class Meta(OAuthSourceForm.Meta):
overrides = {
'provider_type': 'twitter',
'request_token_url': 'https://api.twitter.com/oauth/request_token',
'authorization_url': 'https://api.twitter.com/oauth/authenticate',
'access_token_url': 'https://api.twitter.com/oauth/access_token',
'profile_url': ' https://api.twitter.com/1.1/account/verify_credentials.json',
}
class FacebookOAuthSourceForm(OAuthSourceForm):
"""OAuth Source form with pre-determined URL for Facebook"""
class Meta(OAuthSourceForm.Meta):
overrides = {
'provider_type': 'facebook',
'request_token_url': '',
'authorization_url': 'https://www.facebook.com/v2.8/dialog/oauth',
'access_token_url': 'https://graph.facebook.com/v2.8/oauth/access_token',
'profile_url': ' https://graph.facebook.com/v2.8/me?fields=name,email,short_name',
}
class DiscordOAuthSourceForm(OAuthSourceForm):
"""OAuth Source form with pre-determined URL for Discord"""
class Meta(OAuthSourceForm.Meta):
overrides = {
'provider_type': 'discord',
'request_token_url': '',
'authorization_url': 'https://discordapp.com/api/oauth2/authorize',
'access_token_url': 'https://discordapp.com/api/oauth2/token',
'profile_url': ' https://discordapp.com/api/users/@me',
}
class GoogleOAuthSourceForm(OAuthSourceForm):
"""OAuth Source form with pre-determined URL for Google"""
class Meta(OAuthSourceForm.Meta):
overrides = {
'provider_type': 'google',
'request_token_url': '',
'authorization_url': 'https://accounts.google.com/o/oauth2/auth',
'access_token_url': 'https://accounts.google.com/o/oauth2/token',
'profile_url': ' https://www.googleapis.com/oauth2/v1/userinfo',
}
class AzureADOAuthSourceForm(OAuthSourceForm):
"""OAuth Source form with pre-determined URL for AzureAD"""
class Meta(OAuthSourceForm.Meta):
overrides = {
'provider_type': 'azure_ad',
'request_token_url': '',
'authorization_url': 'https://login.microsoftonline.com/common/oauth2/authorize',
'access_token_url': 'https://login.microsoftonline.com/common/oauth2/token',
'profile_url': ' https://graph.windows.net/myorganization/me?api-version=1.6',
}

View File

@ -0,0 +1,80 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-16 18:05+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: templates/mod/auth/oauth/client/settings.html:11
msgid "OAuth2"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:16
msgid "Connected Accounts"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:23
msgid "Provider"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:24
msgid "Status"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:25
msgid "Action"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:26
msgid "ID"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:48
msgid "No Providers configured!"
msgstr ""
#: views/core.py:126
#, python-format
msgid "Provider %(name)s didn't provide an E-Mail address."
msgstr ""
#: views/core.py:184 views/core.py:225
#, python-format
msgid "Successfully authenticated with %(provider)s!"
msgstr ""
#: views/core.py:192
msgid "Authentication Failed."
msgstr ""
#: views/core.py:204
#, python-format
msgid "Linked user with OAuth Provider %s"
msgstr ""
#: views/core.py:208
#, python-format
msgid "Successfully linked %(provider)s!"
msgstr ""
#: views/core.py:221
#, python-format
msgid "Authenticated user with OAuth Provider %s"
msgstr ""
#: views/core.py:247
msgid "Connection successfully deleted"
msgstr ""

View File

@ -0,0 +1,79 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-20 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: templates/mod/auth/oauth/client/settings.html:11
msgid "OAuth2"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:16
msgid "Connected Accounts"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:23
msgid "Provider"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:24
msgid "Status"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:25
msgid "Action"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:26
msgid "ID"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:48
msgid "No Providers configured!"
msgstr ""
#: views/core.py:126
#, python-format
msgid "Provider %(name)s didn't provide an E-Mail address."
msgstr ""
#: views/core.py:184 views/core.py:225
#, python-format
msgid "Successfully authenticated with %(provider)s!"
msgstr ""
#: views/core.py:192
msgid "Authentication Failed."
msgstr ""
#: views/core.py:204
#, python-format
msgid "Linked user with OAuth Provider %s"
msgstr ""
#: views/core.py:208
#, python-format
msgid "Successfully linked %(provider)s!"
msgstr ""
#: views/core.py:221
#, python-format
msgid "Authenticated user with OAuth Provider %s"
msgstr ""
#: views/core.py:247
msgid "Connection successfully deleted"
msgstr ""

View File

@ -0,0 +1,80 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-16 18:05+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: templates/mod/auth/oauth/client/settings.html:11
msgid "OAuth2"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:16
msgid "Connected Accounts"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:23
msgid "Provider"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:24
msgid "Status"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:25
msgid "Action"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:26
msgid "ID"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:48
msgid "No Providers configured!"
msgstr ""
#: views/core.py:126
#, python-format
msgid "Provider %(name)s didn't provide an E-Mail address."
msgstr ""
#: views/core.py:184 views/core.py:225
#, python-format
msgid "Successfully authenticated with %(provider)s!"
msgstr ""
#: views/core.py:192
msgid "Authentication Failed."
msgstr ""
#: views/core.py:204
#, python-format
msgid "Linked user with OAuth Provider %s"
msgstr ""
#: views/core.py:208
#, python-format
msgid "Successfully linked %(provider)s!"
msgstr ""
#: views/core.py:221
#, python-format
msgid "Authenticated user with OAuth Provider %s"
msgstr ""
#: views/core.py:247
msgid "Connection successfully deleted"
msgstr ""

View File

@ -0,0 +1,80 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-16 18:05+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: templates/mod/auth/oauth/client/settings.html:11
msgid "OAuth2"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:16
msgid "Connected Accounts"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:23
msgid "Provider"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:24
msgid "Status"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:25
msgid "Action"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:26
msgid "ID"
msgstr ""
#: templates/mod/auth/oauth/client/settings.html:48
msgid "No Providers configured!"
msgstr ""
#: views/core.py:126
#, python-format
msgid "Provider %(name)s didn't provide an E-Mail address."
msgstr ""
#: views/core.py:184 views/core.py:225
#, python-format
msgid "Successfully authenticated with %(provider)s!"
msgstr ""
#: views/core.py:192
msgid "Authentication Failed."
msgstr ""
#: views/core.py:204
#, python-format
msgid "Linked user with OAuth Provider %s"
msgstr ""
#: views/core.py:208
#, python-format
msgid "Successfully linked %(provider)s!"
msgstr ""
#: views/core.py:221
#, python-format
msgid "Authenticated user with OAuth Provider %s"
msgstr ""
#: views/core.py:247
msgid "Connection successfully deleted"
msgstr ""

View File

@ -0,0 +1,47 @@
# Generated by Django 2.2.6 on 2019-10-07 14:07
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='OAuthSource',
fields=[
('source_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Source')),
('provider_type', models.CharField(max_length=255)),
('request_token_url', models.CharField(blank=True, max_length=255)),
('authorization_url', models.CharField(max_length=255)),
('access_token_url', models.CharField(max_length=255)),
('profile_url', models.CharField(max_length=255)),
('consumer_key', models.TextField()),
('consumer_secret', models.TextField()),
],
options={
'verbose_name': 'Generic OAuth Source',
'verbose_name_plural': 'Generic OAuth Sources',
},
bases=('passbook_core.source',),
),
migrations.CreateModel(
name='UserOAuthSourceConnection',
fields=[
('usersourceconnection_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.UserSourceConnection')),
('identifier', models.CharField(max_length=255)),
('access_token', models.TextField(blank=True, default=None, null=True)),
],
options={
'verbose_name': 'User OAuth Source Connection',
'verbose_name_plural': 'User OAuth Source Connections',
},
bases=('passbook_core.usersourceconnection',),
),
]

View File

@ -0,0 +1,149 @@
"""OAuth Client models"""
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.sources.oauth.clients import get_client
class OAuthSource(Source):
"""Configuration for OAuth provider."""
provider_type = models.CharField(max_length=255)
request_token_url = models.CharField(blank=True, max_length=255)
authorization_url = models.CharField(max_length=255)
access_token_url = models.CharField(max_length=255)
profile_url = models.CharField(max_length=255)
consumer_key = models.TextField()
consumer_secret = models.TextField()
form = 'passbook.sources.oauth.forms.OAuthSourceForm'
@property
def is_link(self):
return True
@property
def get_login_button(self):
url = reverse_lazy('passbook_sources_oauth:oauth-client-login',
kwargs={'source_slug': self.slug})
return url, self.provider_type, self.name
@property
def additional_info(self):
return "Callback URL: <pre>%s</pre>" % \
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."""
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={
'source_slug': self.slug
})
class Meta:
verbose_name = _('Generic OAuth Source')
verbose_name_plural = _('Generic OAuth Sources')
class GitHubOAuthSource(OAuthSource):
"""Abstract subclass of OAuthSource to specify GitHub Form"""
form = 'passbook.sources.oauth.forms.GitHubOAuthSourceForm'
class Meta:
abstract = True
verbose_name = _('GitHub OAuth Source')
verbose_name_plural = _('GitHub OAuth Sources')
class TwitterOAuthSource(OAuthSource):
"""Abstract subclass of OAuthSource to specify Twitter Form"""
form = 'passbook.sources.oauth.forms.TwitterOAuthSourceForm'
class Meta:
abstract = True
verbose_name = _('Twitter OAuth Source')
verbose_name_plural = _('Twitter OAuth Sources')
class FacebookOAuthSource(OAuthSource):
"""Abstract subclass of OAuthSource to specify Facebook Form"""
form = 'passbook.sources.oauth.forms.FacebookOAuthSourceForm'
class Meta:
abstract = True
verbose_name = _('Facebook OAuth Source')
verbose_name_plural = _('Facebook OAuth Sources')
class DiscordOAuthSource(OAuthSource):
"""Abstract subclass of OAuthSource to specify Discord Form"""
form = 'passbook.sources.oauth.forms.DiscordOAuthSourceForm'
class Meta:
abstract = True
verbose_name = _('Discord OAuth Source')
verbose_name_plural = _('Discord OAuth Sources')
class GoogleOAuthSource(OAuthSource):
"""Abstract subclass of OAuthSource to specify Google Form"""
form = 'passbook.sources.oauth.forms.GoogleOAuthSourceForm'
class Meta:
abstract = True
verbose_name = _('Google OAuth Source')
verbose_name_plural = _('Google OAuth Sources')
class AzureADOAuthSource(OAuthSource):
"""Abstract subclass of OAuthSource to specify AzureAD Form"""
form = 'passbook.sources.oauth.forms.AzureADOAuthSourceForm'
class Meta:
abstract = True
verbose_name = _('Azure AD OAuth Source')
verbose_name_plural = _('Azure AD OAuth Sources')
class UserOAuthSourceConnection(UserSourceConnection):
"""Authorized remote OAuth provider."""
identifier = models.CharField(max_length=255)
access_token = models.TextField(blank=True, null=True, default=None)
def save(self, *args, **kwargs):
self.access_token = self.access_token or None
super().save(*args, **kwargs)
@property
def api_client(self):
"""Get API Client"""
return get_client(self.source, self.access_token or '')
class Meta:
verbose_name = _('User OAuth Source Connection')
verbose_name_plural = _('User OAuth Source Connections')

View File

@ -0,0 +1,16 @@
"""Oauth2 Client Settings"""
AUTHENTICATION_BACKENDS = [
'passbook.sources.oauth.backends.AuthorizedServiceBackend',
]
PASSBOOK_SOURCES_OAUTH_TYPES = [
'passbook.sources.oauth.types.discord',
'passbook.sources.oauth.types.facebook',
'passbook.sources.oauth.types.github',
'passbook.sources.oauth.types.google',
'passbook.sources.oauth.types.reddit',
'passbook.sources.oauth.types.supervisr',
'passbook.sources.oauth.types.twitter',
'passbook.sources.oauth.types.azure_ad',
]

View File

@ -0,0 +1,18 @@
{% extends "user/base.html" %}
{% load i18n %}
{% block page %}
<h1>{{ source.name }}</h1>
{% if connections.exists %}
<p>{% trans 'Connected.' %}</p>
<a class="btn btn-danger" href="{% url 'passbook_oauth_client:oauth-client-disconnect' source_slug=source.slug %}">
{% trans 'Disconnect' %}
</a>
{% else %}
<p>Not connected.</p>
<a class="btn btn-primary" href="{% url 'passbook_oauth_client:oauth-client-login' source_slug=source.slug %}">
{% trans 'Connect' %}
</a>
{% endif %}
{% endblock %}

View File

View File

@ -0,0 +1,52 @@
"""AzureAD OAuth2 Views"""
import json
import uuid
from requests.exceptions import RequestException
from structlog import get_logger
from passbook.sources.oauth.clients import OAuth2Client
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback
LOGGER = get_logger()
class AzureADOAuth2Client(OAuth2Client):
"""AzureAD OAuth2 Client"""
def get_profile_info(self, raw_token):
"Fetch user profile information."
try:
token = json.loads(raw_token)['access_token']
headers = {
'Authorization': 'Bearer %s' % token
}
response = self.request('get', self.source.profile_url,
headers=headers)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch user profile: %s', exc)
return None
else:
return response.json() or response.text
@MANAGER.source(kind=RequestKind.callback, name='Azure AD')
class AzureADOAuthCallback(OAuthCallback):
"""AzureAD OAuth2 Callback"""
client_class = AzureADOAuth2Client
def get_user_id(self, source, info):
return uuid.UUID(info.get('objectId')).int
def get_or_create_user(self, source, access, info):
user_data = {
'username': info.get('displayName'),
'email': info.get('mail', None) or info.get('otherMails')[0],
'name': info.get('displayName'),
'password': None,
}
return user_get_or_create(**user_data)

View File

@ -0,0 +1,59 @@
"""Discord OAuth Views"""
import json
from requests.exceptions import RequestException
from structlog import get_logger
from passbook.sources.oauth.clients import OAuth2Client
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback, OAuthRedirect
LOGGER = get_logger()
@MANAGER.source(kind=RequestKind.redirect, name='Discord')
class DiscordOAuthRedirect(OAuthRedirect):
"""Discord OAuth2 Redirect"""
def get_additional_parameters(self, source):
return {
'scope': 'email identify',
}
class DiscordOAuth2Client(OAuth2Client):
"""Discord OAuth2 Client"""
def get_profile_info(self, raw_token):
"Fetch user profile information."
try:
token = json.loads(raw_token)
headers = {
'Authorization': '%s %s' % (token['token_type'], token['access_token'])
}
response = self.request('get', self.source.profile_url,
token=token['access_token'], headers=headers)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch user profile: %s', exc)
return None
else:
return response.json() or response.text
@MANAGER.source(kind=RequestKind.callback, name='Discord')
class DiscordOAuth2Callback(OAuthCallback):
"""Discord OAuth2 Callback"""
client_class = DiscordOAuth2Client
def get_or_create_user(self, source, access, info):
user_data = {
'username': info.get('username'),
'email': info.get('email', 'None'),
'name': info.get('username'),
'password': None,
}
discord_user = user_get_or_create(**user_data)
return discord_user

View File

@ -0,0 +1,30 @@
"""Facebook OAuth Views"""
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback, OAuthRedirect
@MANAGER.source(kind=RequestKind.redirect, name='Facebook')
class FacebookOAuthRedirect(OAuthRedirect):
"""Facebook OAuth2 Redirect"""
def get_additional_parameters(self, source):
return {
'scope': 'email',
}
@MANAGER.source(kind=RequestKind.callback, name='Facebook')
class FacebookOAuth2Callback(OAuthCallback):
"""Facebook OAuth2 Callback"""
def get_or_create_user(self, source, access, info):
user_data = {
'username': info.get('name'),
'email': info.get('email', ''),
'name': info.get('name'),
'password': None,
}
fb_user = user_get_or_create(**user_data)
return fb_user

View File

@ -0,0 +1,20 @@
"""GitHub OAuth Views"""
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback
@MANAGER.source(kind=RequestKind.callback, name='GitHub')
class GitHubOAuth2Callback(OAuthCallback):
"""GitHub OAuth2 Callback"""
def get_or_create_user(self, source, access, info):
user_data = {
'username': info.get('login'),
'email': info.get('email', ''),
'name': info.get('name'),
'password': None,
}
gh_user = user_get_or_create(**user_data)
return gh_user

View File

@ -0,0 +1,29 @@
"""Google OAuth Views"""
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback, OAuthRedirect
@MANAGER.source(kind=RequestKind.redirect, name='Google')
class GoogleOAuthRedirect(OAuthRedirect):
"""Google OAuth2 Redirect"""
def get_additional_parameters(self, source):
return {
'scope': 'email profile',
}
@MANAGER.source(kind=RequestKind.callback, name='Google')
class GoogleOAuth2Callback(OAuthCallback):
"""Google OAuth2 Callback"""
def get_or_create_user(self, source, access, info):
user_data = {
'username': info.get('email'),
'email': info.get('email', ''),
'name': info.get('name'),
'password': None,
}
google_user = user_get_or_create(**user_data)
return google_user

View File

@ -0,0 +1,51 @@
"""Source type manager"""
from enum import Enum
from structlog import get_logger
from passbook.sources.oauth.views.core import OAuthCallback, OAuthRedirect
LOGGER = get_logger()
class RequestKind(Enum):
"""Enum of OAuth Request types"""
callback = 'callback'
redirect = 'redirect'
class SourceTypeManager:
"""Manager to hold all Source types."""
__source_types = {}
__names = []
def source(self, kind, name):
"""Class decorator to register classes inline."""
def inner_wrapper(cls):
if kind not in self.__source_types:
self.__source_types[kind] = {}
self.__source_types[kind][name.lower()] = cls
self.__names.append(name)
LOGGER.debug("Registered source", source_class=cls.__name__, kind=kind)
return cls
return inner_wrapper
def get_name_tuple(self):
"""Get list of tuples of all registered names"""
return [(x.lower(), x) for x in set(self.__names)]
def find(self, source, kind):
"""Find fitting Source Type"""
if kind in self.__source_types:
if source.provider_type in self.__source_types[kind]:
return self.__source_types[kind][source.provider_type]
# Return defaults
if kind == RequestKind.callback:
return OAuthCallback
if kind == RequestKind.redirect:
return OAuthRedirect
raise KeyError
MANAGER = SourceTypeManager()

View File

@ -0,0 +1,68 @@
"""Reddit OAuth Views"""
import json
from requests.auth import HTTPBasicAuth
from requests.exceptions import RequestException
from structlog import get_logger
from passbook.sources.oauth.clients import OAuth2Client
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback, OAuthRedirect
LOGGER = get_logger()
@MANAGER.source(kind=RequestKind.redirect, name='reddit')
class RedditOAuthRedirect(OAuthRedirect):
"""Reddit OAuth2 Redirect"""
def get_additional_parameters(self, source):
return {
'scope': 'identity',
'duration': 'permanent',
}
class RedditOAuth2Client(OAuth2Client):
"""Reddit OAuth2 Client"""
def get_access_token(self, request, callback=None, **request_kwargs):
"Fetch access token from callback request."
auth = HTTPBasicAuth(
self.source.consumer_key,
self.source.consumer_secret)
return super(RedditOAuth2Client, self).get_access_token(request, callback, auth=auth)
def get_profile_info(self, raw_token):
"Fetch user profile information."
try:
token = json.loads(raw_token)
headers = {
'Authorization': '%s %s' % (token['token_type'], token['access_token'])
}
response = self.request('get', self.source.profile_url,
token=token['access_token'], headers=headers)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch user profile: %s', exc)
return None
else:
return response.json() or response.text
@MANAGER.source(kind=RequestKind.callback, name='reddit')
class RedditOAuth2Callback(OAuthCallback):
"""Reddit OAuth2 Callback"""
client_class = RedditOAuth2Client
def get_or_create_user(self, source, access, info):
user_data = {
'username': info.get('name'),
'email': None,
'name': info.get('name'),
'password': None,
}
reddit_user = user_get_or_create(**user_data)
return reddit_user

View File

@ -0,0 +1,53 @@
"""Supervisr OAuth2 Views"""
import json
from requests.exceptions import RequestException
from structlog import get_logger
from passbook.sources.oauth.clients import OAuth2Client
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback
LOGGER = get_logger()
class SupervisrOAuth2Client(OAuth2Client):
"""Supervisr OAuth2 Client"""
def get_profile_info(self, raw_token):
"Fetch user profile information."
try:
token = json.loads(raw_token)['access_token']
headers = {
'Authorization': 'Bearer:%s' % token
}
response = self.request('get', self.source.profile_url,
token=raw_token, headers=headers)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch user profile: %s', exc)
return None
else:
return response.json() or response.text
@MANAGER.source(kind=RequestKind.callback, name='supervisr')
class SupervisrOAuthCallback(OAuthCallback):
"""Supervisr OAuth2 Callback"""
client_class = SupervisrOAuth2Client
def get_user_id(self, source, info):
return info['pk']
def get_or_create_user(self, source, access, info):
user_data = {
'username': info.get('username'),
'email': info.get('email', ''),
'name': info.get('first_name'),
'password': None,
}
sv_user = user_get_or_create(**user_data)
return sv_user

View File

@ -0,0 +1,44 @@
"""Twitter OAuth Views"""
from requests.exceptions import RequestException
from structlog import get_logger
from passbook.sources.oauth.clients import OAuthClient
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
from passbook.sources.oauth.utils import user_get_or_create
from passbook.sources.oauth.views.core import OAuthCallback
LOGGER = get_logger()
class TwitterOAuthClient(OAuthClient):
"""Twitter OAuth2 Client"""
def get_profile_info(self, raw_token):
"Fetch user profile information."
try:
response = self.request('get', self.source.profile_url + "?include_email=true",
token=raw_token)
response.raise_for_status()
except RequestException as exc:
LOGGER.warning('Unable to fetch user profile: %s', exc)
return None
else:
return response.json() or response.text
@MANAGER.source(kind=RequestKind.callback, name='Twitter')
class TwitterOAuthCallback(OAuthCallback):
"""Twitter OAuth2 Callback"""
client_class = TwitterOAuthClient
def get_or_create_user(self, source, access, info):
user_data = {
'username': info.get('screen_name'),
'email': info.get('email', ''),
'name': info.get('name'),
'password': None,
}
tw_user = user_get_or_create(**user_data)
return tw_user

View File

@ -0,0 +1,17 @@
"""passbook oauth_client urls"""
from django.urls import path
from passbook.sources.oauth.types.manager import RequestKind
from passbook.sources.oauth.views import core, dispatcher, user
urlpatterns = [
path('login/<slug:source_slug>/', dispatcher.DispatcherView.as_view(
kind=RequestKind.redirect), name='oauth-client-login'),
path('callback/<slug:source_slug>/', dispatcher.DispatcherView.as_view(
kind=RequestKind.callback), name='oauth-client-callback'),
path('disconnect/<slug:source_slug>/', core.DisconnectView.as_view(),
name='oauth-client-disconnect'),
path('user/<slug:source_slug>/', user.UserSettingsView.as_view(),
name='oauth-client-user'),
]

View File

@ -0,0 +1,17 @@
"""OAuth Client User Creation Utils"""
from django.db.utils import IntegrityError
from passbook.core.models import User
def user_get_or_create(**kwargs):
"""Create user or return existing user"""
try:
new_user = User.objects.create_user(**kwargs)
except IntegrityError:
# At this point we've already checked that there is no existing connection
# to any user. Hence if we can't create the user,
kwargs['username'] = '%s_1' % kwargs['username']
new_user = User.objects.create_user(**kwargs)
return new_user

View File

View File

@ -0,0 +1,240 @@
"""Core OAauth Views"""
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import authenticate
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views.generic import RedirectView, View
from structlog import get_logger
from passbook.factors.view import AuthenticationView, _redirect_with_qs
from passbook.lib.utils.reflection import app
from passbook.sources.oauth.clients import get_client
from passbook.sources.oauth.models import (OAuthSource,
UserOAuthSourceConnection)
LOGGER = get_logger()
# pylint: disable=too-few-public-methods
class OAuthClientMixin:
"Mixin for getting OAuth client for a source."
client_class = None
def get_client(self, source):
"Get instance of the OAuth client for this source."
if self.client_class is not None:
# pylint: disable=not-callable
return self.client_class(source)
return get_client(source)
class OAuthRedirect(OAuthClientMixin, RedirectView):
"Redirect user to OAuth source to enable access."
permanent = False
params = None
# pylint: disable=unused-argument
def get_additional_parameters(self, source):
"Return additional redirect parameters for this source."
return self.params or {}
def get_callback_url(self, source):
"Return the callback url for this source."
return reverse('passbook_oauth_client:oauth-client-callback',
kwargs={'source_slug': source.slug})
def get_redirect_url(self, **kwargs):
"Build redirect url for a given source."
slug = kwargs.get('source_slug', '')
try:
source = OAuthSource.objects.get(slug=slug)
except OAuthSource.DoesNotExist:
raise Http404("Unknown OAuth source '%s'." % slug)
else:
if not source.enabled:
raise Http404('source %s is not enabled.' % slug)
client = self.get_client(source)
callback = self.get_callback_url(source)
params = self.get_additional_parameters(source)
return client.get_redirect_url(self.request, callback=callback, parameters=params)
class OAuthCallback(OAuthClientMixin, View):
"Base OAuth callback view."
source_id = None
source = None
def get(self, request, *args, **kwargs):
"""View Get handler"""
slug = kwargs.get('source_slug', '')
try:
self.source = OAuthSource.objects.get(slug=slug)
except OAuthSource.DoesNotExist:
raise Http404("Unknown OAuth source '%s'." % slug)
else:
if not self.source.enabled:
raise Http404('source %s is not enabled.' % slug)
client = self.get_client(self.source)
callback = self.get_callback_url(self.source)
# Fetch access token
raw_token = client.get_access_token(self.request, callback=callback)
if raw_token is None:
return self.handle_login_failure(self.source, "Could not retrieve token.")
# Fetch profile info
info = client.get_profile_info(raw_token)
if info is None:
return self.handle_login_failure(self.source, "Could not retrieve profile.")
identifier = self.get_user_id(self.source, info)
if identifier is None:
return self.handle_login_failure(self.source, "Could not determine id.")
# Get or create access record
defaults = {
'access_token': raw_token,
}
existing = UserOAuthSourceConnection.objects.filter(
source=self.source, identifier=identifier)
if existing.exists():
connection = existing.first()
connection.access_token = raw_token
UserOAuthSourceConnection.objects.filter(pk=connection.pk).update(**defaults)
else:
connection = UserOAuthSourceConnection(
source=self.source,
identifier=identifier,
access_token=raw_token
)
user = authenticate(source=self.source, identifier=identifier, request=request)
if user is None:
LOGGER.debug("Handling new user")
return self.handle_new_user(self.source, connection, info)
LOGGER.debug("Handling existing user")
return self.handle_existing_user(self.source, user, connection, info)
# pylint: disable=unused-argument
def get_callback_url(self, source):
"Return callback url if different than the current url."
return False
# pylint: disable=unused-argument
def get_error_redirect(self, source, reason):
"Return url to redirect on login failure."
return settings.LOGIN_URL
def get_or_create_user(self, source, access, info):
"Create a shell auth.User."
raise NotImplementedError()
# pylint: disable=unused-argument
def get_user_id(self, source, info):
"Return unique identifier from the profile info."
id_key = self.source_id or 'id'
result = info
try:
for key in id_key.split('.'):
result = result[key]
return result
except KeyError:
return None
def handle_login(self, user, source, access):
"""Prepare AuthenticationView, redirect users to remaining Factors"""
user = authenticate(source=access.source,
identifier=access.identifier, request=self.request)
self.request.session[AuthenticationView.SESSION_PENDING_USER] = user.pk
self.request.session[AuthenticationView.SESSION_USER_BACKEND] = user.backend
self.request.session[AuthenticationView.SESSION_IS_SSO_LOGIN] = True
return _redirect_with_qs('passbook_core:auth-process', self.request.GET)
# pylint: disable=unused-argument
def handle_existing_user(self, source, user, access, info):
"Login user and redirect."
messages.success(self.request, _("Successfully authenticated with %(source)s!" % {
'source': self.source.name
}))
return self.handle_login(user, source, access)
def handle_login_failure(self, source, reason):
"Message user and redirect on error."
LOGGER.warning('Authentication Failure: %s', reason)
messages.error(self.request, _('Authentication Failed.'))
return redirect(self.get_error_redirect(source, reason))
def handle_new_user(self, source, access, info):
"Create a shell auth.User and redirect."
was_authenticated = False
if self.request.user.is_authenticated:
# there's already a user logged in, just link them up
user = self.request.user
was_authenticated = True
else:
user = self.get_or_create_user(source, access, info)
access.user = user
access.save()
UserOAuthSourceConnection.objects.filter(pk=access.pk).update(user=user)
if app('passbook_audit'):
pass
# TODO: Create audit entry
# from passbook.audit.models import something
# something.event(user=user,)
# Event.create(
# user=user,
# message=_("Linked user with OAuth source %s" % self.source.name),
# request=self.request,
# hidden=True,
# current=False)
if was_authenticated:
messages.success(self.request, _("Successfully linked %(source)s!" % {
'source': self.source.name
}))
return redirect(reverse('passbook_oauth_client:oauth-client-user', kwargs={
'source_slug': self.source.slug
}))
# User was not authenticated, new user has been created
user = authenticate(source=access.source,
identifier=access.identifier, request=self.request)
messages.success(self.request, _("Successfully authenticated with %(source)s!" % {
'source': self.source.name
}))
return self.handle_login(user, source, access)
class DisconnectView(LoginRequiredMixin, View):
"""Delete connection with source"""
source = None
aas = None
def dispatch(self, request, source_slug):
self.source = get_object_or_404(OAuthSource, slug=source_slug)
self.aas = get_object_or_404(UserOAuthSourceConnection,
source=self.source, user=request.user)
return super().dispatch(request, source_slug)
def post(self, request, source_slug):
"""Delete connection object"""
if 'confirmdelete' in request.POST:
# User confirmed deletion
self.aas.delete()
messages.success(request, _('Connection successfully deleted'))
return redirect(reverse('passbook_oauth_client:oauth-client-user', kwargs={
'source_slug': self.source.slug
}))
return self.get(request, source_slug)
def get(self, request, source):
"""Show delete form"""
return render(request, 'generic/delete.html', {
'object': self.source,
'delete_url': reverse('passbook_oauth_client:oauth-client-disconnect', kwargs={
'source_slug': self.source.slug,
})
})

View File

@ -0,0 +1,22 @@
"""Dispatch OAuth views to respective views"""
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.views import View
from passbook.sources.oauth.models import OAuthSource
from passbook.sources.oauth.types.manager import MANAGER, RequestKind
class DispatcherView(View):
"""Dispatch OAuth Redirect/Callback views to their proper class based on URL parameters"""
kind = ''
def dispatch(self, *args, **kwargs):
"""Find Source by slug and forward request"""
slug = kwargs.get('source_slug', None)
if not slug:
raise Http404
source = get_object_or_404(OAuthSource, slug=slug)
view = MANAGER.find(source, kind=RequestKind(self.kind))
return view.as_view()(*args, **kwargs)

View File

@ -0,0 +1,21 @@
"""passbook oauth_client user views"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404
from django.views.generic import TemplateView
from passbook.sources.oauth.models import (OAuthSource,
UserOAuthSourceConnection)
class UserSettingsView(LoginRequiredMixin, TemplateView):
"""Show user current connection state"""
template_name = 'oauth_client/user.html'
def get_context_data(self, **kwargs):
source = get_object_or_404(OAuthSource, slug=self.kwargs.get('source_slug'))
connections = UserOAuthSourceConnection.objects.filter(user=self.request.user,
source=source)
kwargs['source'] = source
kwargs['connections'] = connections
return super().get_context_data(**kwargs)