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
from .oauthclient import OAuthException
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': '
Submit organisation
Before submitting a new Organisation, please verify on the list of current organisations 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):
try:
cookiedata = get_encrypted_oauth_cookie(request)
except OAuthException as e:
return HttpResponse(e, status=400)
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 {1}.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)