Commit 70125eb7 authored by Linus Lewandowski's avatar Linus Lewandowski

Refactor aiakos and extauth. E-mails are now external identities.

parent c348bc03
......@@ -4,27 +4,11 @@ from django.contrib.auth.backends import ModelBackend
UserModel = get_user_model()
class BetterAuthBackend(ModelBackend):
def authenticate(self, request=None, user_id=None, username=None, email=None, password=None, **kwargs):
def authenticate(self, request=None, user=None, user_id=None, username=None, password=None, **kwargs):
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
if email is None:
if username and "@" in username:
email = username
username = None
user = None
# We always want to do all queries (if everything is provided) so timing attacks won't be possible.
# Do email first, so that username will override it if user is found.
if email:
try:
user = UserModel._default_manager.get(email=email)
except UserModel.DoesNotExist:
pass
except UserModel.MultipleObjectsReturned: # email is not unique in standard Django
pass
if username:
try:
user = UserModel._default_manager.get_by_natural_key(username)
......
from django.contrib.auth.models import User
def can_reset_password(self):
return self.has_usable_password() or self.externalidentity_set.count() == 0
User.can_reset_password = property(can_reset_password)
......@@ -59,7 +59,7 @@
<li class="{% if request.resolver_match.url_name == 'identities' %}active{% endif %}"><a href="{% url 'extauth:identities' %}">{% trans "External identites" %}</a></li>
<li class="{% if request.resolver_match.url_name == 'change-password' %}active{% endif %}"><a href="{% url 'change-password' %}">{% trans "Change password" %}</a></li>
<li role="separator" class="divider"></li>
<li><a href="{% url 'logout' %}">{% trans "Sign out" %}</a></li>
<li><a href="{% url 'extauth:logout' %}">{% trans "Sign out" %}</a></li>
</ul>
</li>
{% endif %}
......
......@@ -4,13 +4,17 @@
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
{% for provider in identity_providers %}
<a href="{{provider.login_url}}?next={{next|urlencode}}" class="btn btn-default btn-block">
{% blocktrans trimmed %}
Sign in with {{provider}}
{% endblocktrans %}
</a>
{% endfor %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="method" value="oauth">
{% for provider in identity_providers %}
<button type="submit" name="provider" value="{{provider.domain}}" class="btn btn-default btn-block">
{% blocktrans trimmed %}
Sign in with {{provider}}
{% endblocktrans %}
</button>
{% endfor %}
</form>
{% if identity_providers %}
<hr/>
......
......@@ -2,11 +2,12 @@
{% load i18n static crispy_forms_tags %}
{% block content %}
<form method="post">
{% csrf_token %}
<div class="row">
<div class="col-md-6">
<form method="post">
{% csrf_token %}
<h2>Your external identities</h2>
<h2>{% trans "Your external identities" %}</h2>
{% for ei in user.externalidentity_set.all %}
<div class="media">
<div class="media-left">
......@@ -20,25 +21,40 @@
</div>
<div class="media-body">
<h4 class="media-heading">{{ei.external_name}} @ {{ei.provider}}</h4>
<button type="submit" name="disconnect" value="{{ei.id}}" class="btn btn-default btn-xs">Disconnect</button>
<button type="submit" name="disconnect" value="{{ei.id}}" class="btn btn-default btn-xs">{% trans "Disconnect" %}</button>
{% if ei.provider.protocol %}
{% if ei.trusted %}
<button type="submit" name="untrust" value="{{ei.id}}" class="btn btn-default btn-xs">{% trans "Disable login" %}</button>
{% else %}
<button type="submit" name="trust" value="{{ei.id}}" class="btn btn-default btn-xs">{% trans "Enable login" %}</button>
{% endif %}
{% else %}
{% if ei.trusted %}
<button type="submit" name="untrust" value="{{ei.id}}" class="btn btn-default btn-xs">{% trans "Disable password reset" %}</button>
{% else %}
<button type="submit" name="trust" value="{{ei.id}}" class="btn btn-default btn-xs">{% trans "Enable password reset" %}</button>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</form>
</div>
<div class="col-md-6">
<h2>Connect another identity</h2>
<div class="row">
<div class="col-md-8">
{% for provider in identity_providers %}
<a href="{{provider.login_url}}" class="btn btn-default btn-block">
{% blocktrans trimmed %}
Sign in with {{provider}}
{% endblocktrans %}
</a>
{% endfor %}
<div class="col-md-8">
{% for provider in identity_providers %}
<button type="submit" name="connect" value="{{provider.domain}}" class="btn btn-default btn-block">
{% blocktrans trimmed %}
Sign in with {{provider}}
{% endblocktrans %}
</button>
{% endfor %}
</div>
</div>
</div>
</div>
</form>
{% endblock %}
......@@ -6,7 +6,7 @@
<div class="col-md-6 col-md-offset-3">
<h2>{% trans "You are now logged out." %}</h2>
<a href="{% url 'login' %}" class="btn btn-secondary btn-block">{% trans 'Log in again' %}</a>
<a href="{% url 'extauth:login' %}" class="btn btn-secondary btn-block">{% trans 'Log in again' %}</a>
</div>
</div>
{% endblock %}
{% extends 'base.html' %}
{% load i18n static crispy_forms_tags %}
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>{% trans "Your password has been changed." %}</h2>
<p>{% trans "Your password has been set. You may go ahead and log in now." %}</p>
<a href="{% url 'login' %}" class="btn btn-primary btn-block">{% trans 'Log in' %}</a>
</div>
</div>
{% endblock %}
{% extends 'base.html' %}
{% load i18n static crispy_forms_tags %}
{% block content %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h2>{% trans "Change password" %}</h2>
<div class="row">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary btn-block btn-lg">{% trans "Change password" %}</button>
</form>
</div>
</div>
</div>
{% endblock %}
from .emailconfirmation_jwt import makeEmailConfirmationToken, expandEmailConfirmationToken
import logging
from datetime import timedelta
from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils import timezone
from jose import JOSEError, jwt
User = get_user_model()
logger = logging.getLogger(__name__)
def makeEmailConfirmationToken(user, email):
return jwt.encode({
'user': str(user.id),
'email': email,
'exp': int((timezone.now() + timedelta(days=3)).timestamp()),
'aiakosauth.com/use': 'aiakos.emailconfirmation',
}, settings.SECRET_KEY, algorithm='HS256')
class EmailConfirmationToken:
pass
def expandEmailConfirmationToken(token):
try:
data = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
if data['aiakosauth.com/use'] != 'aiakos.emailconfirmation':
return None
ret = EmailConfirmationToken()
ret.user = User.objects.get(id=data['user'])
ret.email = data['email']
return ret
except (JOSEError, KeyError):
return None
except:
logger.exception("Cannot decode email confirmation token.")
return None
......@@ -17,7 +17,6 @@ from django.conf import settings
from django.conf.urls import include, url
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as django_auth_views
from django.contrib.auth.decorators import login_required
from django.views.generic import RedirectView, TemplateView
......@@ -32,12 +31,6 @@ urlpatterns = [
url(r'^admin/', admin.site.urls),
url(r'^accounts/', include('django_extauth.urls', namespace='extauth')),
url(r'^accounts/login/$', views.AuthView.as_view(), name='login'),
url(r'^accounts/logout/$', django_auth_views.logout, name='logout'),
url(r'^accounts/confirm-email/(?P<token>[^/]+)/$', views.EmailConfirmationView.as_view(), name='confirm_email'),
url(r'^accounts/reset/(?P<uidb64>[0-9A-Za-z_\-]+)/(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$',
django_auth_views.password_reset_confirm, name='password_reset_confirm'),
url(r'^accounts/reset/done/$', django_auth_views.password_reset_complete, name='password_reset_complete'),
url(r'^accounts/change-password/$', login_required(views.password_change), name='change-password'),
url(r'^oauth/', include('aiakos.openid_provider.urls', namespace='openid_provider')),
......
from .auth import AuthView
from .confirmemail import EmailConfirmationView
from .password_management import password_change
from .health import health
from django.contrib.auth.tokens import default_token_generator
from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from ..tokens import makeEmailConfirmationToken
def password_reset_link(user, site):
return 'https://' + site.domain + reverse('password_reset_confirm', kwargs={
'uidb64': urlsafe_base64_encode(force_bytes(user.pk)).decode(),
'token': default_token_generator.make_token(user),
})
def email_confirmation_link(user, email, site):
token = makeEmailConfirmationToken(user, email)
return 'https://' + site.domain + reverse('confirm_email', args=[token])
from django.contrib.auth import login as auth_login
from django.contrib.auth.forms import AuthenticationForm
class AuthLoginForm(AuthenticationForm):
def process(self, request):
auth_login(request, self.get_user())
......@@ -26,101 +26,96 @@ class User:
email = models.EmailField(unique=True)
class ExternalIdentity:
provider = models.ForeignKey(Provider)
sub = models.CharField()
user = models.ForeignKey(User)
sub = models.CharField()
provider = models.ForeignKey(Provider)
@property
def email(self):
return self.sub + '@' + self.provider.url.replace(scheme='')
class Meta:
unique_together = [[provider, sub]]
def LogInWithEmailAndPassword(email, password):
try:
user = User.get(email=email)
ei = ExternalIdentity.get(email=email)
except:
pass
else:
if user.check_password(password):
if ei.user.check_password(password):
request.user = user
Show("You've logged in")
return
Show("Bad email/password")
@property
def can_reset_password(user):
if not settings.ENABLE_PASSWORD_RESET or user.disabled_password_reset:
return False
return user.has_password or not user.external_identities
# The second clause is for cases when somebody deletes a provider, but wants to preserve users.
def ResetForgottenPassword(email):
try:
user = User.get(email=email)
except:
pass
else:
if user.can_reset_password:
Send(user.email, password_reset_link(user))
else:
Send(user.email, "You already have an account; log in with: " + str(user.external_identities) + "or" + ("password" if user.has_password))
Show("Check e-mail")
def Register(email, password):
def RegisterWithEmailAndPassword(email, password):
if not settings.ENABLE_REGISTRATION:
raise SuspiciousOperation()
try:
user = User.get(email=email)
ei = ExternalIdentity.get(email=email)
except:
user = User.create(password=password)
Send(email, email_confirmation_link(user, email))
Send(email, email_confirmation_link(user, email, True))
# Note: We can't log in here, as we can't log in in the "except" case,
# and it would tell the attacker if this e-mail is in the database
else:
if user.can_reset_password:
Send(user.email, password_change_confirmation_link(user, password))
if ei.trusted:
Send(ei.email, password_change_confirmation_link(user, password))
else:
Send(user.email, "You already have an account; log in with: " + str(user.external_identities) + "or" + ("password" if user.has_password))
Send(ei.email, "You already have an account; log in with: " + str(user.external_identities) + "or" + ("password" if user.has_password))
Show("Registered, check e-mail")
def ExternalLogIn(ei: ExternalIdentity):
def ResetForgottenPassword(email):
try:
user = ei.user
else:
request.user = user
ei = ExternalIdentity.get(email=email)
except:
pass
else:
if ei.trusted:
Send(ei.email, password_reset_link(user))
else:
Send(ei.email, "You already have an account; log in with: " + str(ei.user.external_identities) + "or" + ("password" if ei.user.has_password))
Show("Check e-mail")
def ExternalLogIn(ei: ExternalIdentity):
if ei.trusted and ei.exists:
request.user = ei.user
else:
existing_ai = [ai for ai in ei.additional_identites if ai.exists]
if TODO: # This is complicated, will be insecure if configured incorrectly, and I'm not sure if we really need this.
for ai in existing_ai:
if ei.provider.trusted_for(ai) # Like Google for @gmail.com
and ai.trusted:
ei.user = user
ei.save()
request.user = user
return
if existing_ai:
Ask user if he wants to merge accounts (=> log in with another) or create a new one
# Note: In this case we can expose the fact that we have
# this EI in the database, as the user has shown
# quite a strong relationship with this EI.
return
if not ei.provider.allow_registration:
raise SuspiciousOperation()
try:
user = User.get(email=ei.email)
else:
if ei.provider.owns_emails # Like Google
and user.can_reset_password:
ei.user = user
request.user = user
else: # Like GitHub
Ask user if he wants to merge accounts (=> log in with another first) or create a new one with no e-mail
# Why?
# Cause if user's GitHub account got hacked,
# and he didn't authorize it in Aiakos,
# we shouldn't let the hacker in.
# Note: In this case we can expose the fact that we have
# this email in the database, as the user has shown
# quite a strong relationship with this email.
except:
user = User.create()
if ei.email_verified:
user.email = ei.email
else:
Send(email, email_confirmation_link(user, ei.email))
request.user = user
ei.user = User.create()
ei.save()
request.user = ei.user
Optionally ask user about trusting ei.additional_identites.
def ConfirmEmail(user, email, trusted): # assumes that link authenticity was checked before
ei = ExternalIdentity.create(email=email, user=user, trusted=trusted)
def ConfirmEmail(user, email): # assumes that link authenticity was checked before
user.email = email
if user.can_reset_password:
if ei.trusted:
request.user = user # Because he can reset password and then log in anyway
......@@ -2,9 +2,9 @@ from django.contrib import admin
from .models import *
class IdentityProviderAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
list_display = ('name', 'slug', 'url', 'client_id', 'legacy_protocol')
list_display = ('domain', 'name', 'client_id', 'protocol')
admin.site.register(IdentityProvider, IdentityProviderAdmin)
......
from .models import *
def identity_providers(request):
return {
'identity_providers': IdentityProvider.objects.all()
......
......@@ -2,9 +2,9 @@
# Generated by Django 1.10.5 on 2017-02-19 16:53
from __future__ import unicode_literals
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
......
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-03-11 15:56
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('django_extauth', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='identityprovider',
old_name='legacy_protocol',
new_name='protocol',
),
migrations.RenameField(
model_name='identityprovider',
old_name='legacy_settings_yaml',
new_name='protocol_settings_yaml',
),
migrations.RenameField(
model_name='identityprovider',
old_name='url',
new_name='domain',
),
migrations.AddField(
model_name='externalidentity',
name='trusted',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='identityprovider',
name='domain',
field=models.CharField(default='', help_text='Example: accounts.google.com; may include a path', max_length=200, unique=True, verbose_name='domain'),
preserve_default=False,
),
migrations.AlterField(
model_name='identityprovider',
name='protocol',
field=models.CharField(blank=True, choices=[('openid_connect', 'OpenID Connect'), ('github', 'github'), ('gitlab', 'gitlab')], default='openid_connect', max_length=50, verbose_name='protocol'),
),
migrations.AlterField(
model_name='identityprovider',
name='protocol_settings_yaml',
field=models.TextField(blank=True, help_text='In YAML. Usually empty.', verbose_name='protocol-specific settings'),
),
migrations.AlterField(
model_name='identityprovider',
name='client_id',
field=models.CharField(blank=True, max_length=200),
),
migrations.AlterField(
model_name='identityprovider',
name='client_secret',
field=models.CharField(blank=True, max_length=200),
),
migrations.AlterField(
model_name='identityprovider',
name='name',
field=models.CharField(blank=True, max_length=200),
),
migrations.RemoveField(
model_name='identityprovider',
name='slug',
),
migrations.AlterUniqueTogether(
name='externalidentity',
unique_together=set([('sub', 'provider')]),
),
]
from django.db import models
import random
from importlib import import_module
from django.conf import settings
from django.contrib import auth
from django.db import IntegrityError, models
from django.shortcuts import reverse
from django.utils.translation import ugettext_lazy as _
import yaml
from openid_connect import connect
from openid_connect.legacy import PROTOCOLS
from importlib import import_module
import yaml
PROTOCOL_CHOICES = tuple((protocol, protocol) for protocol in PROTOCOLS)
PROTOCOL_CHOICES = (('openid_connect', "OpenID Connect"),) + tuple((protocol, protocol) for protocol in PROTOCOLS)
class IdentityProviderManager(models.Manager):
def all(self):
return self.exclude(protocol="")
class IdentityProvider(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField()
objects = IdentityProviderManager()
domain = models.CharField(max_length=200, verbose_name=_("domain"), help_text=_("Example: accounts.google.com; may include a path"), unique=True)
url = models.URLField(max_length=200, verbose_name='URL')
client_id = models.CharField(max_length=200)
client_secret = models.CharField(max_length=200)
name = models.CharField(max_length=200, blank=True)
legacy_protocol = models.CharField(max_length=50, choices=PROTOCOL_CHOICES, blank=True)
legacy_settings_yaml = models.TextField(blank=True, verbose_name="Legacy protocol settings", help_text="Legacy protocol specific settings, in YAML. Usually empty.")
client_id = models.CharField(max_length=200, blank=True)
client_secret = models.CharField(max_length=200, blank=True)
protocol = models.CharField(max_length=50, verbose_name=_("protocol"), choices=PROTOCOL_CHOICES, default='openid_connect', blank=True)
protocol_settings_yaml = models.TextField(blank=True, verbose_name=_("protocol-specific settings"), help_text=_("In YAML. Usually empty."))
inherit_admin_status = models.BooleanField(default=False, help_text="Grant superuser status to the provider's admins.")
def __str__(self):
return self.name
if self.name:
return self.name
else:
return self.domain
@property
def legacy_settings(self):
if self.legacy_settings_yaml:
kwargs = yaml.load(self.legacy_settings_yaml)
def protocol_settings(self):
if self.protocol_settings_yaml:
kwargs = yaml.load(self.protocol_settings_yaml)
else:
return {}
@property
def client(self):
return connect(self.url, self.client_id, self.client_secret, protocol=self.legacy_protocol, **self.legacy_settings)
if self.protocol:
return connect('https://' + self.domain + ('/' if not '/' in self.domain else ''), self.client_id, self.client_secret, protocol=self.protocol if self.protocol != 'openid_connect' else None, **self.protocol_settings)
else:
return None
def save(self, *args, **kwargs):
self.client
super().save(*args, **kwargs)
@property
def login_url(self):
return reverse('extauth:begin', args=[self.slug])
def redirect_uri(self, request):
return request.build_absolute_uri(reverse('extauth:oauth-done'))
class ExternalIdentityManager(models.Manager):
def forced_get(self, email=None, sub=None, provider=None):
try:
if email:
return self.get(email=email)
else:
return self.get(sub=sub, provider=provider)
except ExternalIdentity.DoesNotExist:
ei = ExternalIdentity()
if email:
ei.email = email
else:
ei.sub = sub
ei.provider = provider
return ei
def get(self, email=None, **kwargs):
if email:
sub, domain = email.split('@', 1)
return super().get(sub=sub, provider__domain=domain, **kwargs)
return super().get(**kwargs)
def create(self, sub=None, provider=None, email=None, **kwargs):
if not (sub and provider) and email:
sub, domain = email.split('@', 1)
provider, created = IdentityProvider.objects.get_or_create(domain=domain, defaults={'protocol': ''})
return super().create(sub=sub, provider=provider, **kwargs)
def get_or_create(self, sub=None, provider=None, email=None, **kwargs):
if not (sub and provider) and email:
sub, domain = email.split('@', 1)
provider, created = IdentityProvider.objects.get_or_create(domain=domain, defaults={'protocol': ''})
return super().get_or_create(sub=sub, provider=provider, **kwargs)
class ExternalIdentity(models.Model):
objects = ExternalIdentityManager()
class Meta:
verbose_name_plural = 'External identities'
unique_together = (('provider', 'sub',),)
unique_together = (('sub', 'provider'),)
user = models.ForeignKey(settings.AUTH_USER_MODEL)
provider = models.ForeignKey(IdentityProvider)
trusted = models.BooleanField(default=False)
sub = models.CharField(max_length=200)
provider = models.ForeignKey(IdentityProvider)
userinfo_yaml = models.TextField(default="", verbose_name="User information")
@property
def exists(self):
try:
return self.user
except ExternalIdentity.user.RelatedObjectDoesNotExist:
return None
@property
def email(self):
return self.sub + '@' + self.provider.domain