mirror of
https://github.com/postgres/pgweb.git
synced 2025-07-29 11:59:36 +00:00

During oauth logins we need to store some temporary data related to the users session. Previously we did this in the django session, but thanks to AI bots trying millions of logins every day (and never completing the process) we end up with many abandoned sessions in the db. To work around this, instead store the temporary data in an encrypted cookie passed to the browser. Since this cookie can be limited in scope to just the auth part of the site, the slightly larger cookie size doesn't matter, and we don't need to store any data at all server-side.
901 lines
36 KiB
Python
901 lines
36 KiB
Python
from django.contrib.auth.models import User
|
|
from django.contrib.auth import login as django_login
|
|
import django.contrib.auth.views as authviews
|
|
from django.http import HttpResponseRedirect, Http404, HttpResponse
|
|
from django.core.exceptions import PermissionDenied
|
|
from django.shortcuts import get_object_or_404
|
|
from pgweb.util.decorators import login_required, script_sources, frame_sources, content_sources, queryparams
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.utils.encoding import force_bytes
|
|
from django.utils.http import urlsafe_base64_encode
|
|
from django.contrib.auth.tokens import default_token_generator
|
|
from django.contrib.auth import logout as django_logout
|
|
from django.conf import settings
|
|
from django.db import transaction, connection
|
|
from django.db.models import Q, Prefetch
|
|
|
|
import base64
|
|
import urllib.parse
|
|
from Cryptodome.Cipher import AES
|
|
from Cryptodome import Random
|
|
import time
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
import itertools
|
|
import hmac
|
|
|
|
from pgweb.util.contexts import render_pgweb
|
|
from pgweb.util.misc import send_template_mail, generate_random_token, get_client_ip
|
|
from pgweb.util.helpers import HttpSimpleResponse, simple_form
|
|
from pgweb.util.moderation import ModerationState
|
|
from pgweb.util.markup import pgmarkdown
|
|
|
|
from pgweb.news.models import NewsArticle
|
|
from pgweb.events.models import Event
|
|
from pgweb.core.models import Organisation, UserProfile, ModerationNotification
|
|
from pgweb.core.models import OrganisationEmail
|
|
from pgweb.contributors.models import Contributor
|
|
from pgweb.downloads.models import Product
|
|
from pgweb.profserv.models import ProfessionalService
|
|
|
|
from .models import CommunityAuthSite, CommunityAuthConsent, SecondaryEmail
|
|
from .forms import PgwebAuthenticationForm, ConfirmSubmitForm
|
|
from .forms import CommunityAuthConsentForm
|
|
from .forms import SignupForm, SignupOauthForm
|
|
from .forms import UserForm, UserProfileForm, ContributorForm
|
|
from .forms import AddEmailForm, PgwebPasswordResetForm
|
|
from .oauthclient import get_encrypted_oauth_cookie, delete_encrypted_oauth_cookie_on
|
|
|
|
import logging
|
|
|
|
from pgweb.util.moderation import get_moderation_model_from_suburl
|
|
from pgweb.mailqueue.util import send_simple_mail
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# The value we store in user.password for oauth logins. This is
|
|
# a value that must not match any hashers.
|
|
OAUTH_PASSWORD_STORE = 'oauth_signin_account_no_password'
|
|
|
|
|
|
def _modobjs(qs):
|
|
l = list(qs)
|
|
if l:
|
|
return {
|
|
'title': l[0]._meta.verbose_name_plural.capitalize(),
|
|
'objects': l,
|
|
'editurl': l[0].account_edit_suburl,
|
|
}
|
|
else:
|
|
return None
|
|
|
|
|
|
@login_required
|
|
def home(request):
|
|
modobjects = [
|
|
{
|
|
'title': 'not submitted yet',
|
|
'objects': [
|
|
_modobjs(NewsArticle.objects.filter(org__managers=request.user, modstate=ModerationState.CREATED)),
|
|
],
|
|
},
|
|
{
|
|
'title': 'waiting for moderator approval',
|
|
'objects': [
|
|
_modobjs(NewsArticle.objects.filter(org__managers=request.user, modstate=ModerationState.PENDING)),
|
|
_modobjs(Event.objects.filter(org__managers=request.user, approved=False)),
|
|
_modobjs(Organisation.objects.filter(managers=request.user, approved=False)),
|
|
_modobjs(Product.objects.filter(org__managers=request.user, approved=False)),
|
|
_modobjs(ProfessionalService.objects.filter(org__managers=request.user, approved=False))
|
|
],
|
|
},
|
|
]
|
|
|
|
return render_pgweb(request, 'account', 'account/index.html', {
|
|
'modobjects': filter(lambda x: any(x['objects']), modobjects),
|
|
})
|
|
|
|
|
|
objtypes = {
|
|
'news': {
|
|
'title': 'news article',
|
|
'objects': lambda u: NewsArticle.objects.filter(org__managers=u),
|
|
'tristate': True,
|
|
'editapproved': False,
|
|
},
|
|
'events': {
|
|
'title': 'event',
|
|
'objects': lambda u: Event.objects.filter(org__managers=u),
|
|
'editapproved': True,
|
|
},
|
|
'products': {
|
|
'title': 'product',
|
|
'objects': lambda u: Product.objects.filter(org__managers=u),
|
|
'editapproved': True,
|
|
},
|
|
'services': {
|
|
'title': 'professional service',
|
|
'objects': lambda u: ProfessionalService.objects.filter(org__managers=u),
|
|
'editapproved': True,
|
|
},
|
|
'organisations': {
|
|
'title': 'organisation',
|
|
'objects': lambda u: Organisation.objects.filter(managers=u),
|
|
'submit_header': '<h3>Submit organisation</h3>Before submitting a new Organisation, please verify on the list of <a href="/account/orglist/">current organisations</a> if the organisation already exists. If it does, please contact the manager of the organisation to gain permissions.',
|
|
'editapproved': True,
|
|
},
|
|
}
|
|
|
|
|
|
@login_required
|
|
@transaction.atomic
|
|
def profile(request):
|
|
# We always have the user, but not always the profile. And we need a bit
|
|
# of a hack around the normal forms code since we have two different
|
|
# models on a single form.
|
|
(profile, created) = UserProfile.objects.get_or_create(pk=request.user.pk)
|
|
|
|
# Don't allow users whose accounts were created via oauth to change
|
|
# their email, since that would kill the connection between the
|
|
# accounts.
|
|
can_change_email = (request.user.password != OAUTH_PASSWORD_STORE)
|
|
|
|
# We may have a contributor record - and we only show that part of the
|
|
# form if we have it for this user.
|
|
try:
|
|
contrib = Contributor.objects.get(user=request.user.pk)
|
|
except Contributor.DoesNotExist:
|
|
contrib = None
|
|
|
|
contribform = None
|
|
|
|
secondaryaddresses = SecondaryEmail.objects.filter(user=request.user)
|
|
|
|
if request.method == 'POST':
|
|
# Process this form
|
|
userform = UserForm(can_change_email, secondaryaddresses, data=request.POST, instance=request.user)
|
|
profileform = UserProfileForm(data=request.POST, instance=profile)
|
|
secondaryemailform = AddEmailForm(request.user, data=request.POST)
|
|
if contrib:
|
|
contribform = ContributorForm(data=request.POST, instance=contrib)
|
|
|
|
if userform.is_valid() and profileform.is_valid() and secondaryemailform.is_valid() and (not contrib or contribform.is_valid()):
|
|
user = userform.save()
|
|
|
|
# Email takes some magic special handling, since we only allow picking of existing secondary emails, but it's
|
|
# not a foreign key (due to how the django auth model works).
|
|
if can_change_email and userform.cleaned_data['primaryemail'] != user.email:
|
|
# Changed it!
|
|
oldemail = user.email
|
|
# Create a secondary email for the old primary one
|
|
SecondaryEmail(user=user, email=oldemail, confirmed=True, token='').save()
|
|
# Flip the main email
|
|
user.email = userform.cleaned_data['primaryemail']
|
|
user.save(update_fields=['email', ])
|
|
# Finally remove the old secondary address, since it can`'t be both primary and secondary at the same time
|
|
SecondaryEmail.objects.filter(user=user, email=user.email).delete()
|
|
log.info("User {} changed primary email from {} to {}".format(user.username, oldemail, user.email))
|
|
|
|
profileform.save()
|
|
if contrib:
|
|
contribform.save()
|
|
if secondaryemailform.cleaned_data.get('email1', ''):
|
|
sa = SecondaryEmail(user=request.user, email=secondaryemailform.cleaned_data['email1'], token=generate_random_token())
|
|
sa.save()
|
|
send_template_mail(
|
|
settings.ACCOUNTS_NOREPLY_FROM,
|
|
sa.email,
|
|
'Your postgresql.org community account',
|
|
'account/email_add_email.txt',
|
|
{'secondaryemail': sa, 'user': request.user, }
|
|
)
|
|
|
|
for k, v in request.POST.items():
|
|
if k.startswith('deladdr_') and v == '1':
|
|
ii = int(k[len('deladdr_'):])
|
|
SecondaryEmail.objects.filter(user=request.user, id=ii).delete()
|
|
|
|
return HttpResponseRedirect(".")
|
|
else:
|
|
# Generate form
|
|
userform = UserForm(can_change_email, secondaryaddresses, instance=request.user)
|
|
profileform = UserProfileForm(instance=profile)
|
|
secondaryemailform = AddEmailForm(request.user)
|
|
if contrib:
|
|
contribform = ContributorForm(instance=contrib)
|
|
|
|
return render_pgweb(request, 'account', 'account/userprofileform.html', {
|
|
'userform': userform,
|
|
'profileform': profileform,
|
|
'secondaryemailform': secondaryemailform,
|
|
'secondaryaddresses': secondaryaddresses,
|
|
'secondarypending': any(not a.confirmed for a in secondaryaddresses),
|
|
'contribform': contribform,
|
|
})
|
|
|
|
|
|
@login_required
|
|
@transaction.atomic
|
|
def confirm_add_email(request, tokenhash):
|
|
addr = get_object_or_404(SecondaryEmail, user=request.user, token=tokenhash)
|
|
|
|
# Valid token found, so mark the address as confirmed.
|
|
addr.confirmed = True
|
|
addr.token = ''
|
|
addr.save()
|
|
return HttpResponseRedirect('/account/profile/')
|
|
|
|
|
|
@login_required
|
|
def listobjects(request, objtype):
|
|
if objtype not in objtypes:
|
|
raise Http404("Object type not found")
|
|
o = objtypes[objtype]
|
|
|
|
if o.get('tristate', False):
|
|
objects = {
|
|
'approved': o['objects'](request.user).filter(modstate=ModerationState.APPROVED),
|
|
'unapproved': o['objects'](request.user).filter(modstate=ModerationState.PENDING),
|
|
'inprogress': o['objects'](request.user).filter(modstate=ModerationState.CREATED),
|
|
}
|
|
else:
|
|
objects = {
|
|
'approved': o['objects'](request.user).filter(approved=True),
|
|
'unapproved': o['objects'](request.user).filter(approved=False),
|
|
}
|
|
|
|
return render_pgweb(request, 'account', 'account/objectlist.html', {
|
|
'objects': objects,
|
|
'title': o['title'],
|
|
'editapproved': o['editapproved'],
|
|
'submit_header': o.get('submit_header', None),
|
|
'suburl': objtype,
|
|
'tristate': o.get('tristate', False),
|
|
})
|
|
|
|
|
|
@login_required
|
|
def orglist(request):
|
|
orgs = Organisation.objects.prefetch_related('managers').filter(approved=True)
|
|
|
|
return render_pgweb(request, 'account', 'account/orglist.html', {
|
|
'orgs': orgs,
|
|
})
|
|
|
|
|
|
@login_required
|
|
@transaction.atomic
|
|
def submitted_item_form(request, objtype, item):
|
|
model = get_moderation_model_from_suburl(objtype)
|
|
|
|
if item == 'new':
|
|
extracontext = {}
|
|
else:
|
|
extracontext = {
|
|
'notices': ModerationNotification.objects.filter(
|
|
objecttype=model.__name__,
|
|
objectid=item,
|
|
).order_by('-date')
|
|
}
|
|
|
|
return simple_form(model, item, request, model.get_formclass(),
|
|
redirect='/account/edit/{}/'.format(objtype),
|
|
formtemplate='account/submit_form.html',
|
|
extracontext=extracontext)
|
|
|
|
|
|
@login_required
|
|
@transaction.atomic
|
|
def confirm_org_email(request, token):
|
|
try:
|
|
email = OrganisationEmail.objects.get(token=token)
|
|
except OrganisationEmail.DoesNotExist:
|
|
raise Http404()
|
|
|
|
if not email.org.managers.filter(pk=request.user.pk).exists():
|
|
raise PermissionDenied("You are not a manager of the associated organisation")
|
|
|
|
email.confirmed = True
|
|
email.token = None
|
|
email.save()
|
|
|
|
return render_pgweb(request, 'account', 'account/orgemail_confirmed.html', {
|
|
'org': email.org,
|
|
'email': email.address,
|
|
})
|
|
|
|
|
|
@content_sources('style', "'unsafe-inline'")
|
|
def _submitted_item_submit(request, objtype, model, obj):
|
|
if obj.modstate != ModerationState.CREATED:
|
|
# Can only submit if state is created
|
|
return HttpResponseRedirect("/account/edit/{}/".format(objtype))
|
|
|
|
if request.method == 'POST':
|
|
form = ConfirmSubmitForm(obj._meta.verbose_name, data=request.POST)
|
|
if form.is_valid():
|
|
with transaction.atomic():
|
|
obj.modstate = ModerationState.PENDING
|
|
obj.send_notification = False
|
|
obj.save()
|
|
|
|
send_simple_mail(settings.NOTIFICATION_FROM,
|
|
settings.NOTIFICATION_EMAIL,
|
|
"{} '{}' submitted for moderation".format(obj._meta.verbose_name.capitalize(), obj.title),
|
|
"{} {} with title '{}' submitted for moderation by {}\n\nModerate at: {}\n".format(
|
|
obj._meta.verbose_name.capitalize(),
|
|
obj.id,
|
|
obj.title,
|
|
request.user.username,
|
|
'{}/admin/_moderate/{}/{}/'.format(settings.SITE_ROOT, obj._meta.model_name, obj.pk),
|
|
),
|
|
)
|
|
return HttpResponseRedirect("/account/edit/{}/".format(objtype))
|
|
else:
|
|
form = ConfirmSubmitForm(obj._meta.verbose_name)
|
|
|
|
return render_pgweb(request, 'account', 'account/submit_preview.html', {
|
|
'obj': obj,
|
|
'form': form,
|
|
'objtype': obj._meta.verbose_name,
|
|
'preview': obj.get_preview_fields(),
|
|
})
|
|
|
|
|
|
def _submitted_item_withdraw(request, objtype, model, obj):
|
|
if obj.modstate != ModerationState.PENDING:
|
|
# Can only withdraw if it's in pending state
|
|
return HttpResponseRedirect("/account/edit/{}/".format(objtype))
|
|
|
|
obj.modstate = ModerationState.CREATED
|
|
obj.send_notification = False
|
|
if obj.twomoderators:
|
|
obj.firstmoderator = None
|
|
obj.save(update_fields=['modstate', 'firstmoderator'])
|
|
else:
|
|
obj.save(update_fields=['modstate', ])
|
|
|
|
send_simple_mail(
|
|
settings.NOTIFICATION_FROM,
|
|
settings.NOTIFICATION_EMAIL,
|
|
"{} '{}' withdrawn from moderation".format(model._meta.verbose_name.capitalize(), obj.title),
|
|
"{} {} with title {} withdrawn from moderation by {}".format(
|
|
model._meta.verbose_name.capitalize(),
|
|
obj.id,
|
|
obj.title,
|
|
request.user.username
|
|
),
|
|
)
|
|
return HttpResponseRedirect("/account/edit/{}/".format(objtype))
|
|
|
|
|
|
@login_required
|
|
@transaction.atomic
|
|
def submitted_item_submitwithdraw(request, objtype, item, what):
|
|
model = get_moderation_model_from_suburl(objtype)
|
|
|
|
obj = get_object_or_404(model, pk=item)
|
|
if not obj.verify_submitter(request.user):
|
|
raise PermissionDenied("You are not the owner of this item!")
|
|
|
|
if what == 'submit':
|
|
return _submitted_item_submit(request, objtype, model, obj)
|
|
else:
|
|
return _submitted_item_withdraw(request, objtype, model, obj)
|
|
|
|
|
|
@login_required
|
|
@csrf_exempt
|
|
def markdown_preview(request):
|
|
if request.method != 'POST':
|
|
return HttpResponse("POST only please", status=405)
|
|
|
|
if request.headers.get('x-preview', None) != 'md':
|
|
raise Http404()
|
|
|
|
return HttpResponse(pgmarkdown(request.body.decode('utf8', 'ignore')))
|
|
|
|
|
|
def login(request):
|
|
return authviews.LoginView.as_view(template_name='account/login.html',
|
|
authentication_form=PgwebAuthenticationForm,
|
|
extra_context={
|
|
'oauth_providers': [(k, v) for k, v in sorted(settings.OAUTH.items())],
|
|
})(request)
|
|
|
|
|
|
def logout(request):
|
|
return authviews.logout_then_login(request, login_url='/')
|
|
|
|
|
|
def changepwd(request):
|
|
if hasattr(request.user, 'password') and request.user.password == OAUTH_PASSWORD_STORE:
|
|
return HttpSimpleResponse(request, "Account error", "This account cannot change password as it's connected to a third party login site.")
|
|
|
|
log.info("Initiating password change from {0}".format(get_client_ip(request)))
|
|
return authviews.PasswordChangeView.as_view(template_name='account/password_change.html',
|
|
success_url='/account/changepwd/done/')(request)
|
|
|
|
|
|
def resetpwd(request):
|
|
# Basic django password reset feature is completely broken. For example, it does not support
|
|
# resetting passwords for users with "old hashes", which means they have no way to ever
|
|
# recover. So implement our own, since it's quite the trivial feature.
|
|
if request.method == "POST":
|
|
try:
|
|
u = User.objects.get(email__iexact=request.POST['email'])
|
|
if u.password == OAUTH_PASSWORD_STORE:
|
|
return HttpSimpleResponse(request, "Account error", "This account cannot change password as it's connected to a third party login site.")
|
|
except User.DoesNotExist:
|
|
log.info("Attempting to reset password of {0}, user not found".format(request.POST['email']))
|
|
return HttpResponseRedirect('/account/reset/done/')
|
|
|
|
form = PgwebPasswordResetForm(data=request.POST)
|
|
if form.is_valid():
|
|
log.info("Initiating password set from {0} for {1}".format(get_client_ip(request), form.cleaned_data['email']))
|
|
token = default_token_generator.make_token(u)
|
|
send_template_mail(
|
|
settings.ACCOUNTS_NOREPLY_FROM,
|
|
u.email,
|
|
'Password reset for your postgresql.org account',
|
|
'account/password_reset_email.txt',
|
|
{
|
|
'user': u,
|
|
'uid': urlsafe_base64_encode(force_bytes(u.pk)),
|
|
'token': token,
|
|
},
|
|
)
|
|
return HttpResponseRedirect('/account/reset/done/')
|
|
else:
|
|
form = PgwebPasswordResetForm()
|
|
|
|
return render_pgweb(request, 'account', 'account/password_reset.html', {
|
|
'form': form,
|
|
})
|
|
|
|
|
|
def change_done(request):
|
|
log.info("Password change done from {0}".format(get_client_ip(request)))
|
|
return authviews.PasswordChangeDoneView.as_view(template_name='account/password_change_done.html')(request)
|
|
|
|
|
|
def reset_done(request):
|
|
log.info("Password reset done from {0}".format(get_client_ip(request)))
|
|
return authviews.PasswordResetDoneView.as_view(template_name='account/password_reset_done.html')(request)
|
|
|
|
|
|
def reset_confirm(request, uidb64, token):
|
|
log.info("Confirming password reset for uidb {0}, token {1} from {2}".format(uidb64, token, get_client_ip(request)))
|
|
return authviews.PasswordResetConfirmView.as_view(template_name='account/password_reset_confirm.html',
|
|
success_url='/account/reset/complete/')(
|
|
request, uidb64=uidb64, token=token)
|
|
|
|
|
|
def reset_complete(request):
|
|
log.info("Password reset completed for user from {0}".format(get_client_ip(request)))
|
|
return authviews.PasswordResetCompleteView.as_view(template_name='account/password_reset_complete.html')(request)
|
|
|
|
|
|
@script_sources('https://www.google.com/recaptcha/')
|
|
@script_sources('https://www.gstatic.com/recaptcha/')
|
|
@frame_sources('https://www.google.com/')
|
|
def signup(request):
|
|
if request.user.is_authenticated:
|
|
return HttpSimpleResponse(request, "Account error", "You must log out before you can sign up for a new account")
|
|
|
|
if request.method == 'POST':
|
|
# Attempt to create user then, eh?
|
|
form = SignupForm(get_client_ip(request), data=request.POST)
|
|
if form.is_valid():
|
|
# Attempt to create the user here
|
|
# XXX: Do we need to validate something else?
|
|
log.info("Creating user for {0} from {1}".format(form.cleaned_data['username'], get_client_ip(request)))
|
|
|
|
user = User.objects.create_user(form.cleaned_data['username'].lower(), form.cleaned_data['email'].lower(), last_login=datetime.now())
|
|
user.first_name = form.cleaned_data['first_name']
|
|
user.last_name = form.cleaned_data['last_name']
|
|
|
|
# generate a random value for password. It won't be possible to log in with it, but
|
|
# it creates more entropy for the token generator (I think).
|
|
user.password = generate_random_token()
|
|
user.save()
|
|
|
|
# Now generate a token
|
|
token = default_token_generator.make_token(user)
|
|
log.info("Generated token {0} for user {1} from {2}".format(token, form.cleaned_data['username'], get_client_ip(request)))
|
|
|
|
# Generate an outgoing email
|
|
send_template_mail(settings.ACCOUNTS_NOREPLY_FROM,
|
|
form.cleaned_data['email'],
|
|
'Your new postgresql.org community account',
|
|
'account/new_account_email.txt',
|
|
{'uid': urlsafe_base64_encode(force_bytes(user.id)), 'token': token, 'user': user}
|
|
)
|
|
|
|
return HttpResponseRedirect('/account/signup/complete/')
|
|
else:
|
|
form = SignupForm(get_client_ip(request))
|
|
|
|
return render_pgweb(request, 'account', 'base/form.html', {
|
|
'form': form,
|
|
'formitemtype': 'Account',
|
|
'form_intro': """
|
|
To sign up for a free community account, enter your preferred userid and email address.
|
|
Note that a community account is only needed if you want to submit information - all
|
|
content is available for reading without an account. A confirmation email will be sent
|
|
to the specified address, and once confirmed a password for the new account can be specified.
|
|
""",
|
|
'savebutton': 'Sign up',
|
|
'operation': 'New',
|
|
'recaptcha': True,
|
|
})
|
|
|
|
|
|
def signup_complete(request):
|
|
return render_pgweb(request, 'account', 'account/signup_complete.html', {
|
|
})
|
|
|
|
|
|
@script_sources('https://www.google.com/recaptcha/')
|
|
@script_sources('https://www.gstatic.com/recaptcha/')
|
|
@frame_sources('https://www.google.com/')
|
|
@transaction.atomic
|
|
@queryparams('do_abort')
|
|
def signup_oauth(request):
|
|
cookiedata = get_encrypted_oauth_cookie(request)
|
|
|
|
if 'oauth_email' not in cookiedata \
|
|
or 'oauth_firstname' not in cookiedata \
|
|
or 'oauth_lastname' not in cookiedata:
|
|
return HttpSimpleResponse(request, "OAuth error", 'Invalid redirect received')
|
|
|
|
# Is this email already on a different account as a secondary one?
|
|
if SecondaryEmail.objects.filter(email=cookiedata['oauth_email'].lower()).exists():
|
|
return HttpSimpleResponse(request, "OAuth error", 'This email address is already attached to a different account')
|
|
|
|
if request.method == 'POST':
|
|
# Second stage, so create the account. But verify that the
|
|
# nonce matches.
|
|
data = request.POST.copy()
|
|
data['email'] = cookiedata['oauth_email'].lower()
|
|
data['first_name'] = cookiedata['oauth_firstname']
|
|
data['last_name'] = cookiedata['oauth_lastname']
|
|
form = SignupOauthForm(data=data)
|
|
if form.is_valid():
|
|
log.info("Creating user for {0} from {1} from oauth signin of email {2}".format(form.cleaned_data['username'], get_client_ip(request), cookiedata['oauth_email']))
|
|
|
|
user = User.objects.create_user(form.cleaned_data['username'].lower(),
|
|
cookiedata['oauth_email'].lower(),
|
|
last_login=datetime.now())
|
|
user.first_name = cookiedata['oauth_firstname']
|
|
user.last_name = cookiedata['oauth_lastname']
|
|
user.password = OAUTH_PASSWORD_STORE
|
|
user.save()
|
|
|
|
# We can immediately log the user in because their email
|
|
# is confirmed.
|
|
user.backend = settings.AUTHENTICATION_BACKENDS[0]
|
|
django_login(request, user)
|
|
|
|
# Redirect to the page stored in the cookie, or to the account page
|
|
# if none was given.
|
|
return delete_encrypted_oauth_cookie_on(
|
|
HttpResponseRedirect(cookiedata.get('login_next', '/account/'))
|
|
)
|
|
elif 'do_abort' in request.GET:
|
|
return delete_encrypted_oauth_cookie_on(HttpResponseRedirect(cookiedata.get('login_next', '/')))
|
|
else:
|
|
# Generate possible new username
|
|
suggested_username = cookiedata['oauth_email'].replace('@', '.')[:30]
|
|
|
|
# Auto generation requires firstname and lastname to be specified
|
|
f = cookiedata['oauth_firstname'].lower()
|
|
l = cookiedata['oauth_lastname'].lower()
|
|
if f and l:
|
|
for u in itertools.chain([
|
|
"{0}{1}".format(f, l[0]),
|
|
"{0}{1}".format(f[0], l),
|
|
], ("{0}{1}{2}".format(f, l[0], n) for n in range(100))):
|
|
if not User.objects.filter(username=u[:30]).exists():
|
|
suggested_username = u[:30]
|
|
break
|
|
|
|
form = SignupOauthForm(initial={
|
|
'username': suggested_username,
|
|
'email': cookiedata['oauth_email'].lower(),
|
|
'first_name': cookiedata['oauth_firstname'][:30],
|
|
'last_name': cookiedata['oauth_lastname'][:30],
|
|
})
|
|
|
|
return render_pgweb(request, 'account', 'account/signup_oauth.html', {
|
|
'form': form,
|
|
'operation': 'New account',
|
|
'savebutton': 'Sign up for new account',
|
|
'recaptcha': True,
|
|
})
|
|
|
|
|
|
####
|
|
# Community authentication endpoint
|
|
####
|
|
@queryparams('d', 'su')
|
|
def communityauth(request, siteid):
|
|
# Get whatever site the user is trying to log in to.
|
|
site = get_object_or_404(CommunityAuthSite, pk=siteid)
|
|
|
|
# "suburl" - old style way of passing parameters
|
|
# deprecated - will be removed once all sites have migrated
|
|
if 'su' in request.GET:
|
|
su = request.GET['su']
|
|
if not su.startswith('/'):
|
|
su = None
|
|
else:
|
|
su = None
|
|
|
|
# "data" - new style way of passing parameter, where we only
|
|
# care that it's characters are what's in base64.
|
|
if 'd' in request.GET:
|
|
d = request.GET['d']
|
|
if d != urllib.parse.quote_plus(d, '=$'):
|
|
# Invalid character, so drop it
|
|
d = None
|
|
else:
|
|
d = None
|
|
|
|
if d:
|
|
urldata = "?d=%s" % d
|
|
elif su:
|
|
urldata = "?su=%s" % su
|
|
else:
|
|
urldata = ""
|
|
|
|
# Verify if the user is authenticated, and if he/she is not, generate
|
|
# a login form that has information about which site is being logged
|
|
# in to, and basic information about how the community login system
|
|
# works.
|
|
if not request.user.is_authenticated:
|
|
if request.method == "POST" and 'next' in request.POST and 'this_is_the_login_form' in request.POST:
|
|
# This is a postback of the login form. So pick the next filed
|
|
# from that one, so we keep it across invalid password entries.
|
|
nexturl = request.POST['next']
|
|
else:
|
|
nexturl = '/account/auth/%s/%s' % (siteid, urldata)
|
|
return authviews.LoginView.as_view(
|
|
template_name='account/login.html',
|
|
authentication_form=PgwebAuthenticationForm,
|
|
extra_context={
|
|
'sitename': site.name,
|
|
'next': nexturl,
|
|
'oauth_providers': [(k, v) for k, v in sorted(settings.OAUTH.items())],
|
|
},
|
|
)(request)
|
|
|
|
# When we reach this point, the user *has* already been authenticated.
|
|
# The request variable "su" *may* contain a suburl and should in that
|
|
# case be passed along to the site we're authenticating for. And of
|
|
# course, we fill a structure with information about the user.
|
|
|
|
if request.user.first_name == '' or request.user.last_name == '' or request.user.email == '':
|
|
return render_pgweb(request, 'account', 'account/communityauth_noinfo.html', {
|
|
})
|
|
|
|
# Check for cooloff period
|
|
if site.cooloff_hours > 0:
|
|
if (datetime.now() - request.user.date_joined) < timedelta(hours=site.cooloff_hours):
|
|
log.warning("User {0} tried to log in to {1} before cooloff period ended.".format(
|
|
request.user.username, site.name))
|
|
return render_pgweb(request, 'account', 'account/communityauth_cooloff.html', {
|
|
'site': site,
|
|
})
|
|
|
|
if site.org.require_consent:
|
|
if not CommunityAuthConsent.objects.filter(org=site.org, user=request.user).exists():
|
|
return HttpResponseRedirect('/account/auth/{0}/consent/?{1}'.format(siteid,
|
|
urllib.parse.urlencode({'next': '/account/auth/{0}/{1}'.format(siteid, urldata)})))
|
|
|
|
# Record the login as the last login to this site. Django doesn't support tables with
|
|
# multi-column PK, so we have to do this in a raw query.
|
|
with connection.cursor() as curs:
|
|
curs.execute("INSERT INTO account_communityauthlastlogin (user_id, site_id, lastlogin, logincount) VALUES (%(userid)s, %(siteid)s, CURRENT_TIMESTAMP, 1) ON CONFLICT (user_id, site_id) DO UPDATE SET lastlogin=CURRENT_TIMESTAMP, logincount=account_communityauthlastlogin.logincount+1", {
|
|
'userid': request.user.id,
|
|
'siteid': site.id,
|
|
})
|
|
|
|
info = {
|
|
'u': request.user.username.encode('utf-8'),
|
|
'f': request.user.first_name.encode('utf-8'),
|
|
'l': request.user.last_name.encode('utf-8'),
|
|
'e': request.user.email.encode('utf-8'),
|
|
'se': ','.join([a.email for a in SecondaryEmail.objects.filter(user=request.user, confirmed=True).order_by('email')]).encode('utf8'),
|
|
}
|
|
if d:
|
|
info['d'] = d.encode('utf-8')
|
|
elif su:
|
|
info['su'] = su.encode('utf-8')
|
|
|
|
# Turn this into an URL. Make sure the timestamp is always first, that makes
|
|
# the first block more random..
|
|
s = "t=%s&%s" % (int(time.time()), urllib.parse.urlencode(info))
|
|
|
|
if site.version == 3:
|
|
# v3 = authenticated encryption
|
|
r = Random.new()
|
|
nonce = r.read(16)
|
|
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_SIV, nonce=nonce)
|
|
cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))
|
|
redirparams = {
|
|
'd': base64.urlsafe_b64encode(cipher),
|
|
'n': base64.urlsafe_b64encode(nonce),
|
|
't': base64.urlsafe_b64encode(tag),
|
|
}
|
|
else:
|
|
# v2 = plain AES
|
|
# Encrypt it with the shared key (and IV!)
|
|
r = Random.new()
|
|
iv = r.read(16) # Always 16 bytes for AES
|
|
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_CBC, iv)
|
|
cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16))) # Pad to even 16 bytes
|
|
redirparams = {
|
|
'i': base64.urlsafe_b64encode(iv),
|
|
'd': base64.urlsafe_b64encode(cipher),
|
|
}
|
|
|
|
# Generate redirect
|
|
return HttpResponseRedirect("%s?%s" % (
|
|
site.redirecturl,
|
|
urllib.parse.urlencode(redirparams),
|
|
))
|
|
|
|
|
|
def communityauth_logout(request, siteid):
|
|
# Get whatever site the user is trying to log in to.
|
|
site = get_object_or_404(CommunityAuthSite, pk=siteid)
|
|
|
|
if request.user.is_authenticated:
|
|
django_logout(request)
|
|
|
|
# Redirect user back to the specified suburl
|
|
return HttpResponseRedirect("%s?s=logout" % site.redirecturl)
|
|
|
|
|
|
@login_required
|
|
@queryparams('next')
|
|
def communityauth_consent(request, siteid):
|
|
org = get_object_or_404(CommunityAuthSite, id=siteid).org
|
|
if request.method == 'POST':
|
|
form = CommunityAuthConsentForm(org.orgname, data=request.POST)
|
|
if form.is_valid():
|
|
CommunityAuthConsent.objects.get_or_create(user=request.user, org=org,
|
|
defaults={'consentgiven': datetime.now()},
|
|
)
|
|
return HttpResponseRedirect(form.cleaned_data['next'])
|
|
else:
|
|
form = CommunityAuthConsentForm(org.orgname, initial={'next': request.GET.get('next', '')})
|
|
|
|
return render_pgweb(request, 'account', 'base/form.html', {
|
|
'form': form,
|
|
'operation': 'Authentication',
|
|
'form_intro': 'The site you are about to log into is run by {0}. If you choose to proceed with this authentication, your name and email address will be shared with <em>{1}</em>.</p><p>Please confirm that you consent to this sharing.'.format(org.orgname, org.orgname),
|
|
'savebutton': 'Proceed with login',
|
|
})
|
|
|
|
|
|
def _encrypt_site_response(site, s, version):
|
|
if version == 3:
|
|
# Use authenticated encryption
|
|
r = Random.new()
|
|
nonce = r.read(16)
|
|
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_SIV, nonce=nonce)
|
|
cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))
|
|
|
|
return "&".join((
|
|
base64.urlsafe_b64encode(nonce).decode('ascii'),
|
|
base64.urlsafe_b64encode(cipher).decode('ascii'),
|
|
base64.urlsafe_b64encode(tag).decode('ascii'),
|
|
))
|
|
else:
|
|
# Encrypt it with the shared key (and IVs)
|
|
r = Random.new()
|
|
iv = r.read(16) # Always 16 bytes for AES
|
|
encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_CBC, iv)
|
|
cipher = encryptor.encrypt(s.encode('ascii') + b' ' * (16 - (len(s) % 16))) # Pad to even 16 bytes
|
|
|
|
return "&".join((
|
|
base64.urlsafe_b64encode(iv).decode('ascii'),
|
|
base64.urlsafe_b64encode(cipher).decode('ascii'),
|
|
))
|
|
|
|
|
|
@queryparams('s', 'e', 'n', 'u')
|
|
def communityauth_search(request, siteid):
|
|
# Perform a search for users. The response will be encrypted with the site
|
|
# key to prevent abuse, therefor we need the site.
|
|
site = get_object_or_404(CommunityAuthSite, pk=siteid)
|
|
|
|
q = Q(is_active=True)
|
|
if 's' in request.GET and request.GET['s']:
|
|
# General search term, match both name and email
|
|
q = q & (Q(email__icontains=request.GET['s']) | Q(first_name__icontains=request.GET['s']) | Q(last_name__icontains=request.GET['s']))
|
|
elif 'e' in request.GET and request.GET['e']:
|
|
q = q & Q(email__icontains=request.GET['e'])
|
|
elif 'n' in request.GET and request.GET['n']:
|
|
q = q & (Q(first_name__icontains=request.GET['n']) | Q(last_name__icontains=request.GET['n']))
|
|
elif 'u' in request.GET and request.GET['u']:
|
|
q = q & Q(username=request.GET['u'])
|
|
else:
|
|
raise Http404('No search term specified')
|
|
|
|
users = User.objects.prefetch_related(Prefetch('secondaryemail_set', queryset=SecondaryEmail.objects.filter(confirmed=True))).filter(q)[:100]
|
|
|
|
j = json.dumps([{
|
|
'u': u.username,
|
|
'e': u.email,
|
|
'f': u.first_name,
|
|
'l': u.last_name,
|
|
'se': [a.email for a in u.secondaryemail_set.all()],
|
|
} for u in users])
|
|
|
|
return HttpResponse(_encrypt_site_response(site, j, site.version))
|
|
|
|
|
|
def communityauth_getkeys(request, siteid, since=None):
|
|
# Get any updated ssh keys for community accounts.
|
|
# The response will be encrypted with the site key to prevent abuse,
|
|
# therefor we need the site.
|
|
site = get_object_or_404(CommunityAuthSite, pk=siteid)
|
|
|
|
if since:
|
|
keys = UserProfile.objects.select_related('user').filter(lastmodified__gte=datetime.fromtimestamp(int(since.replace('/', '')))).exclude(sshkey='')
|
|
else:
|
|
keys = UserProfile.objects.select_related('user').all().exclude(sshkey='')
|
|
|
|
j = json.dumps([{'u': k.user.username, 's': k.sshkey.replace("\r", "\n")} for k in keys])
|
|
|
|
return HttpResponse(_encrypt_site_response(site, j, site.version))
|
|
|
|
|
|
@csrf_exempt
|
|
def communityauth_subscribe(request, siteid):
|
|
if 'X-pgauth-sig' not in request.headers:
|
|
return HttpResponse("Missing signature header!", status=400)
|
|
|
|
try:
|
|
sig = base64.b64decode(request.headers['X-pgauth-sig'])
|
|
except Exception:
|
|
return HttpResponse("Invalid signature header!", status=400)
|
|
|
|
site = get_object_or_404(CommunityAuthSite, pk=siteid)
|
|
|
|
h = hmac.digest(
|
|
base64.b64decode(site.cryptkey),
|
|
msg=request.body,
|
|
digest='sha512',
|
|
)
|
|
if not hmac.compare_digest(h, sig):
|
|
return HttpResponse("Invalid signature!", status=401)
|
|
|
|
try:
|
|
j = json.loads(request.body)
|
|
except Exception:
|
|
return HttpResponse("Invalid JSON!", status=400)
|
|
|
|
if 'u' not in j:
|
|
return HttpResponse("Missing parameter", status=400)
|
|
|
|
u = get_object_or_404(User, username=j['u'])
|
|
|
|
with connection.cursor() as curs:
|
|
# We handle the subscription by recording a fake login on this site
|
|
curs.execute("INSERT INTO account_communityauthlastlogin (user_id, site_id, lastlogin, logincount) VALUES (%(userid)s, %(siteid)s, CURRENT_TIMESTAMP, 1) ON CONFLICT (user_id, site_id) DO UPDATE SET lastlogin=CURRENT_TIMESTAMP, logincount=account_communityauthlastlogin.logincount+1", {
|
|
'userid': u.id,
|
|
'siteid': site.id,
|
|
})
|
|
|
|
# And when we've done that, we also trigger a sync on this particular site
|
|
curs.execute("INSERT INTO account_communityauthchangelog (user_id, site_id, changedat) VALUES (%(userid)s, %(siteid)s, CURRENT_TIMESTAMP) ON CONFLICT (user_id, site_id) DO UPDATE SET changedat=greatest(account_communityauthchangelog.changedat, CURRENT_TIMESTAMP)", {
|
|
'userid': u.id,
|
|
'siteid': site.id,
|
|
})
|
|
|
|
return HttpResponse(status=201)
|