Add option for hosting each realm on its own subdomain.

This adds support for running a Zulip production server with each
realm on its own unique subdomain, e.g. https://realm_name.example.com.

This patch includes a ton of important features:
* Configuring the Zulip sesion middleware to issue cookier correctly
  for the subdomains case.
* Throwing an error if the user tries to visit an invalid subdomain.
* Runs a portion of the Casper tests with REALMS_HAVE_SUBDOMAINS
  enabled to test the subdomain signup process.
* Updating our integrations documentation to refer to the current subdomain.
* Enforces that users can only login to the subdomain of their realm
  (but does not restrict the API; that will be tightened in a future commit).

Note that toggling settings.REALMS_HAVE_SUBDOMAINS on a live server is
not supported without manual intervention (the main problem will be
adding "subdomain" values for all the existing realms).

[substantially modified by tabbott as part of merging]
This commit is contained in:
hackerkid
2016-07-19 18:05:08 +05:30
committed by Tim Abbott
parent 15f6cc7c84
commit ea39fb2556
25 changed files with 442 additions and 82 deletions

View File

@@ -1,3 +1,4 @@
var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
var common = (function () { var common = (function () {
var exports = {}; var exports = {};
@@ -83,7 +84,13 @@ exports.then_log_in = function (credentials) {
}; };
exports.start_and_log_in = function (credentials, viewport) { exports.start_and_log_in = function (credentials, viewport) {
casper.start('http://127.0.0.1:9981/accounts/login', function () { var log_in_url = "";
if (REALMS_HAVE_SUBDOMAINS) {
log_in_url = "http://zulip.zulipdev.com:9981/accounts/login";
} else {
log_in_url = "http://localhost:9981/accounts/login";
}
casper.start(log_in_url, function () {
exports.initialize_casper(viewport); exports.initialize_casper(viewport);
log_in(credentials); log_in(credentials);
}); });

View File

@@ -2,9 +2,20 @@ var common = require('../casper_lib/common.js').common;
var email = 'alice@test.example.com'; var email = 'alice@test.example.com';
var domain = 'test.example.com'; var domain = 'test.example.com';
var subdomain = 'testsubdomain';
var organization_name = 'Awesome Organization'; var organization_name = 'Awesome Organization';
var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
var host;
var realm_host;
casper.start('http://127.0.0.1:9981/create_realm/'); if (REALMS_HAVE_SUBDOMAINS) {
host = 'zulipdev.com:9981';
realm_host = subdomain + '.' + host;
} else {
host = realm_host = 'localhost:9981';
}
casper.start('http://' + host + '/create_realm/');
casper.then(function () { casper.then(function () {
// Submit the email for realm creation // Submit the email for realm creation
@@ -21,12 +32,12 @@ casper.then(function () {
}); });
// Special endpoint enabled only during tests for extracting confirmation key // Special endpoint enabled only during tests for extracting confirmation key
casper.thenOpen('http://127.0.0.1:9981/confirmation_key/'); casper.thenOpen('http://' + host + '/confirmation_key/');
// Open the confirmation URL // Open the confirmation URL
casper.then(function () { casper.then(function () {
var confirmation_key = JSON.parse(this.getPageContent()).confirmation_key; var confirmation_key = JSON.parse(this.getPageContent()).confirmation_key;
var confirmation_url = 'http://127.0.0.1:9981/accounts/do_confirm/' + confirmation_key; var confirmation_url = 'http://' + host + '/accounts/do_confirm/' + confirmation_key;
this.thenOpen(confirmation_url); this.thenOpen(confirmation_url);
}); });
@@ -47,16 +58,26 @@ casper.then(function () {
casper.then(function () { casper.then(function () {
this.waitForSelector('form[action^="/accounts/register/"]', function () { this.waitForSelector('form[action^="/accounts/register/"]', function () {
this.fill('form[action^="/accounts/register/"]', { if (REALMS_HAVE_SUBDOMAINS) {
full_name: 'Alice', this.fill('form[action^="/accounts/register/"]', {
realm_name: organization_name, full_name: 'Alice',
password: 'password', realm_name: organization_name,
terms: true realm_subdomain: subdomain,
}, true); password: 'password',
terms: true
}, true);
} else {
this.fill('form[action^="/accounts/register/"]', {
full_name: 'Alice',
realm_name: organization_name,
password: 'password',
terms: true
}, true);
}
}); });
this.waitWhileSelector('form[action^="/accounts/register/"]', function () { this.waitWhileSelector('form[action^="/accounts/register/"]', function () {
casper.test.assertUrlMatch('http://127.0.0.1:9981/invite/', 'Invite more users page loaded'); casper.test.assertUrlMatch(realm_host + '/invite/', 'Invite more users page loaded');
}); });
}); });
@@ -75,7 +96,7 @@ casper.then(function () {
}); });
this.waitWhileSelector('#submit_invitation', function () { this.waitWhileSelector('#submit_invitation', function () {
this.test.assertUrlMatch('http://127.0.0.1:9981/', 'Realm created and logged in'); this.test.assertUrlMatch(realm_host, 'Realm created and logged in');
}); });
}); });

View File

@@ -1,7 +1,14 @@
var common = require('../casper_lib/common.js').common; var common = require('../casper_lib/common.js').common;
var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
var realm_url = "";
if (REALMS_HAVE_SUBDOMAINS) {
realm_url = "http://zulip.zulipdev.com:9981/";
} else {
realm_url = "http://localhost:9981/";
}
// Start of test script. // Start of test script.
casper.start('http://127.0.0.1:9981/', common.initialize_casper); casper.start(realm_url, common.initialize_casper);
casper.then(function () { casper.then(function () {
casper.test.assertHttpStatus(302); casper.test.assertHttpStatus(302);

View File

@@ -1,5 +1,6 @@
var common = require('../casper_lib/common.js').common; var common = require('../casper_lib/common.js').common;
var test_credentials = require('../../var/casper/test_credentials.js').test_credentials; var test_credentials = require('../../var/casper/test_credentials.js').test_credentials;
var REALMS_HAVE_SUBDOMAINS = casper.cli.get('subdomains');
common.start_and_log_in(); common.start_and_log_in();
@@ -166,7 +167,14 @@ casper.waitForSelector("#default_language", function () {
casper.test.info("Opening German page through i18n url."); casper.test.info("Opening German page through i18n url.");
}); });
casper.thenOpen('http://127.0.0.1:9981/de/#settings'); var settings_url = "";
if (REALMS_HAVE_SUBDOMAINS) {
settings_url = 'http://zulip.zulipdev.com:9981/de/#settings';
} else {
settings_url = 'http://localhost:9981/de/#settings';
}
casper.thenOpen(settings_url);
casper.waitForSelector("#settings-change-box", function check_url_preference() { casper.waitForSelector("#settings-change-box", function check_url_preference() {
casper.test.info("Checking the i18n url language precedence."); casper.test.info("Checking the i18n url language precedence.");

View File

@@ -144,7 +144,7 @@ casper.then(function () {
casper.waitForSelector('.admin-emoji-form', function () { casper.waitForSelector('.admin-emoji-form', function () {
casper.fill('form.admin-emoji-form', { casper.fill('form.admin-emoji-form', {
'name': 'MouseFace', 'name': 'MouseFace',
'url': 'http://127.0.0.1:9991/static/images/integrations/logos/jenkins.png' 'url': 'http://localhost:9991/static/images/integrations/logos/jenkins.png'
}); });
casper.click('form.admin-emoji-form input.button'); casper.click('form.admin-emoji-form input.button');
}); });
@@ -159,7 +159,7 @@ casper.then(function () {
casper.then(function () { casper.then(function () {
casper.waitForSelector('.emoji_row', function () { casper.waitForSelector('.emoji_row', function () {
casper.test.assertSelectorHasText('.emoji_row .emoji_name', 'MouseFace'); casper.test.assertSelectorHasText('.emoji_row .emoji_name', 'MouseFace');
casper.test.assertExists('.emoji_row img[src="http://127.0.0.1:9991/static/images/integrations/logos/jenkins.png"]'); casper.test.assertExists('.emoji_row img[src="http://localhost:9991/static/images/integrations/logos/jenkins.png"]');
casper.click('.emoji_row button.delete'); casper.click('.emoji_row button.delete');
}); });
}); });

View File

@@ -64,8 +64,8 @@ def server_is_up(server):
except: except:
return False return False
def run_tests(files): def run_tests(realms_have_subdomains, files):
# type: (Iterable[str]) -> None # type: (bool, Iterable[str]) -> None
test_files = [] test_files = []
for file in files: for file in files:
if not os.path.exists(file): if not os.path.exists(file):
@@ -78,7 +78,7 @@ def run_tests(files):
if options.remote_debug: if options.remote_debug:
remote_debug = "--remote-debugger-port=7777 --remote-debugger-autorun=yes" remote_debug = "--remote-debugger-port=7777 --remote-debugger-autorun=yes"
cmd = "frontend_tests/casperjs/bin/casperjs %s test " % (remote_debug,) cmd = "frontend_tests/casperjs/bin/casperjs %s test --subdomains=%s " % (remote_debug, realms_have_subdomains,)
if test_files: if test_files:
cmd += ' '.join(test_files) cmd += ' '.join(test_files)
else: else:
@@ -115,5 +115,13 @@ Oops, the frontend tests failed. Tips for debugging:
sys.exit(ret) sys.exit(ret)
run_tests(args) # Run tests with REALMS_HAVE_SUBDOMAINS set to True
run_tests(False, args)
os.environ["EXTERNAL_HOST"] = "zulipdev.com:9981"
os.environ["REALMS_HAVE_SUBDOMAINS"] = "True"
# Run tests with REALMS_HAVE_SUBDOMAINS set to True
if len(args) == 0:
run_tests(True, ["00-realm-creation.js", "01-login.js", "02-site.js"])
else:
run_tests(True, args)
sys.exit(0) sys.exit(0)

View File

@@ -0,0 +1,9 @@
{% extends "zerver/portico.html" %}
{% block portico_content %}
<h3>{{ _('Organization Does Not Exist') }}</h3>
<p>{{ _('Hi there! Thank you for your interest in Zulip') }}.</p>
<p>{% trans %}There is no Zulip organization hosted at this subdomain{% endtrans %}.</p>
{% endblock %}

View File

@@ -33,6 +33,7 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
<input type='text' disabled="true" placeholder="{{ email }}" /> <input type='text' disabled="true" placeholder="{{ email }}" />
</div> </div>
</div> </div>
<div class="control-group"> <div class="control-group">
<label for="id_full_name" class="control-label">{{ _('Full name') }}</label> <label for="id_full_name" class="control-label">{{ _('Full name') }}</label>
<div class="controls"> <div class="controls">
@@ -51,7 +52,7 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
</div> </div>
</div> </div>
{% if creating_new_team %} {% if creating_new_team %}
<div class="control-group"> <div class="control-group">
<label for="id_team_name" class="control-label">{{ _('Organization name') }}</label> <label for="id_team_name" class="control-label">{{ _('Organization name') }}</label>
<div class="controls"> <div class="controls">
@@ -65,7 +66,24 @@ Form is validated both client-side using jquery-validate (see signup.js) and ser
{% endif %} {% endif %}
<br /><span class="small">{{ _('You can change this later on the admin page.') }}</span> <br /><span class="small">{{ _('You can change this later on the admin page.') }}</span>
</div> </div>
</div>
{% if realms_have_subdomains %}
<div class="control-group">
<label for="id_team_subdomain" class="control-label">{{ _('Subdomain') }}</label>
<div class="controls">
<input id="id_team_subdomain" class="required" type="text"
placeholder="{{ _("E.g. acme") }}"
name="realm_subdomain" maxlength="40" /><b> .{{ external_host }}</b>
{% if form.realm_subdomain.errors %}
{% for error in form.realm_subdomain.errors %}
<div class="alert alert-error">{{ error }}</div>
{% endfor %}
{% endif %}
<br /><span class="small"><p>{% trans %}The address you'll use to sign in to your organization.{% endtrans %}.</p></span>
</div> </div>
</div>
{% endif %}
{% endif %} {% endif %}
{% if password_auth_enabled %} {% if password_auth_enabled %}

View File

@@ -14,7 +14,7 @@ from django.utils.timezone import now
from django.conf import settings from django.conf import settings
from zerver.lib.queue import queue_json_publish from zerver.lib.queue import queue_json_publish
from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.timestamp import datetime_to_timestamp
from zerver.lib.utils import statsd from zerver.lib.utils import statsd, get_subdomain, check_subdomain
from zerver.exceptions import RateLimited from zerver.exceptions import RateLimited
from zerver.lib.rate_limiter import incr_ratelimit, is_ratelimited, \ from zerver.lib.rate_limiter import incr_ratelimit, is_ratelimited, \
api_calls_left api_calls_left
@@ -279,7 +279,7 @@ def logged_in_and_active(request):
return False return False
if request.user.realm.deactivated: if request.user.realm.deactivated:
return False return False
return True return check_subdomain(get_subdomain(request), request.user.realm.subdomain)
# Based on Django 1.8's @login_required # Based on Django 1.8's @login_required
def zulip_login_required(function=None, def zulip_login_required(function=None,

View File

@@ -10,6 +10,8 @@ from django.db.models.query import QuerySet
from jinja2 import Markup as mark_safe from jinja2 import Markup as mark_safe
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from zerver.models import resolve_subdomain_to_realm
from zerver.lib.utils import get_subdomain, check_subdomain
import logging import logging
@@ -23,6 +25,11 @@ from six import text_type
SIGNUP_STRING = u'Your e-mail does not match any existing open organization. ' + \ SIGNUP_STRING = u'Your e-mail does not match any existing open organization. ' + \
u'Use a different e-mail address, or contact %s with questions.' % (settings.ZULIP_ADMINISTRATOR,) u'Use a different e-mail address, or contact %s with questions.' % (settings.ZULIP_ADMINISTRATOR,)
def subdomain_unavailable(subdomain):
# type: (text_type) -> text_type
return u"The subdomain '%s' is not available. Please choose another one." % (subdomain)
if settings.SHOW_OSS_ANNOUNCEMENT: if settings.SHOW_OSS_ANNOUNCEMENT:
SIGNUP_STRING = u'Your e-mail does not match any existing organization. <br />' + \ SIGNUP_STRING = u'Your e-mail does not match any existing organization. <br />' + \
u"The zulip.com service is not taking new customer teams. <br /> " + \ u"The zulip.com service is not taking new customer teams. <br /> " + \
@@ -32,7 +39,10 @@ if settings.SHOW_OSS_ANNOUNCEMENT:
MIT_VALIDATION_ERROR = u'That user does not exist at MIT or is a ' + \ MIT_VALIDATION_ERROR = u'That user does not exist at MIT or is a ' + \
u'<a href="https://ist.mit.edu/email-lists">mailing list</a>. ' + \ u'<a href="https://ist.mit.edu/email-lists">mailing list</a>. ' + \
u'If you want to sign up an alias for Zulip, ' + \ u'If you want to sign up an alias for Zulip, ' + \
u'<a href="mailto:"' + settings.ZULIP_ADMINISTRATOR + '">contact us</a>.' u'<a href="mailto:support@zulipchat.com">contact us</a>.'
WRONG_SUBDOMAIN_ERROR = "Your Zulip account is not a member of the " + \
"organization associated with this subdomain. " + \
"Please contact %s with any questions!" % (settings.ZULIP_ADMINISTRATOR,)
def get_registration_string(domain): def get_registration_string(domain):
# type: (text_type) -> text_type # type: (text_type) -> text_type
@@ -73,10 +83,18 @@ class RegistrationForm(forms.Form):
password = forms.CharField(widget=forms.PasswordInput, max_length=100, password = forms.CharField(widget=forms.PasswordInput, max_length=100,
required=False) required=False)
realm_name = forms.CharField(max_length=100, required=False) realm_name = forms.CharField(max_length=100, required=False)
realm_subdomain = forms.CharField(max_length=40, required=False)
if settings.TERMS_OF_SERVICE: if settings.TERMS_OF_SERVICE:
terms = forms.BooleanField(required=True) terms = forms.BooleanField(required=True)
def clean_realm_subdomain(self):
# type: () -> str
data = self.cleaned_data['realm_subdomain']
realm = resolve_subdomain_to_realm(data)
if realm is not None:
raise ValidationError(subdomain_unavailable(data))
return data
class ToSForm(forms.Form): class ToSForm(forms.Form):
terms = forms.BooleanField(required=True) terms = forms.BooleanField(required=True)
@@ -89,8 +107,11 @@ class HomepageForm(forms.Form):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# type: (*Any, **Any) -> None # type: (*Any, **Any) -> None
self.domain = kwargs.get("domain") self.domain = kwargs.get("domain")
self.subdomain = kwargs.get("subdomain")
if "domain" in kwargs: if "domain" in kwargs:
del kwargs["domain"] del kwargs["domain"]
if "subdomain" in kwargs:
del kwargs["subdomain"]
super(HomepageForm, self).__init__(*args, **kwargs) super(HomepageForm, self).__init__(*args, **kwargs)
def clean_email(self): def clean_email(self):
@@ -106,6 +127,12 @@ class HomepageForm(forms.Form):
if completely_open(self.domain): if completely_open(self.domain):
return data return data
# If the subdomain encodes a complete open realm, pass
subdomain_realm = resolve_subdomain_to_realm(self.subdomain)
if (subdomain_realm is not None and
completely_open(subdomain_realm.domain)):
return data
# If no realm is specified, fail # If no realm is specified, fail
realm = get_valid_realm(data) realm = get_valid_realm(data)
if realm is None: if realm is None:
@@ -191,4 +218,8 @@ Please contact %s to reactivate this group.""" % (
settings.ZULIP_ADMINISTRATOR) settings.ZULIP_ADMINISTRATOR)
raise ValidationError(mark_safe(error_msg)) raise ValidationError(mark_safe(error_msg))
if not check_subdomain(get_subdomain(self.request), user_profile.realm.subdomain):
logging.warning("User %s attempted to password login to wrong subdomain %s" %
(user_profile.email, get_subdomain(self.request)))
raise ValidationError(mark_safe(WRONG_SUBDOMAIN_ERROR))
return email return email

View File

@@ -1929,12 +1929,12 @@ def do_change_stream_description(realm, stream_name, new_description):
value=new_description) value=new_description)
send_event(event, stream_user_ids(stream)) send_event(event, stream_user_ids(stream))
def do_create_realm(domain, name, restricted_to_domain=True): def do_create_realm(domain, name, restricted_to_domain=True, subdomain=None):
# type: (text_type, text_type, bool) -> Tuple[Realm, bool] # type: (text_type, text_type, bool, Optional[text_type]) -> Tuple[Realm, bool]
realm = get_realm(domain) realm = get_realm(domain)
created = not realm created = not realm
if created: if created:
realm = Realm(domain=domain, name=name, realm = Realm(domain=domain, name=name, subdomain=subdomain,
restricted_to_domain=restricted_to_domain) restricted_to_domain=restricted_to_domain)
realm.save() realm.save()

View File

@@ -384,18 +384,24 @@ class ZulipTestCase(TestCase):
{'email': username + "@" + domain}) {'email': username + "@" + domain})
return self.submit_reg_form_for_user(username, password, domain=domain) return self.submit_reg_form_for_user(username, password, domain=domain)
def submit_reg_form_for_user(self, username, password, domain="zulip.com"): def submit_reg_form_for_user(self, username, password, domain="zulip.com",
# type: (text_type, text_type, text_type) -> HttpResponse realm_name=None, realm_subdomain=None, **kwargs):
# type: (text_type, text_type, text_type, Optional[text_type], Optional[text_type], **Any) -> HttpResponse
""" """
Stage two of the two-step registration process. Stage two of the two-step registration process.
If things are working correctly the account should be fully If things are working correctly the account should be fully
registered after this call. registered after this call.
You can pass the HTTP_HOST variable for subdomains via kwargs.
""" """
return self.client_post('/accounts/register/', return self.client_post('/accounts/register/',
{'full_name': username, 'password': password, {'full_name': username, 'password': password,
'realm_name': realm_name,
'realm_subdomain': realm_subdomain,
'key': find_key_by_email(username + '@' + domain), 'key': find_key_by_email(username + '@' + domain),
'terms': True}) 'terms': True},
**kwargs)
def get_confirmation_url_from_outbox(self, email_address, path_pattern="(\S+)>"): def get_confirmation_url_from_outbox(self, email_address, path_pattern="(\S+)>"):
# type: (text_type, text_type) -> text_type # type: (text_type, text_type) -> text_type

View File

@@ -13,6 +13,7 @@ import os
from time import sleep from time import sleep
from django.conf import settings from django.conf import settings
from django.http import HttpRequest
from six.moves import range from six.moves import range
from zerver.lib.str_utils import force_text from zerver.lib.str_utils import force_text
@@ -184,3 +185,21 @@ def query_chunker(queries, id_collector=None, chunk_size=1000, db_chunk_size=Non
id_collector.update(tup_ids) id_collector.update(tup_ids)
yield [row for row_id, i, row in tup_chunk] yield [row for row_id, i, row in tup_chunk]
def get_subdomain(request):
# type: (HttpRequest) -> text_type
domain = request.get_host().lower()
index = domain.find("." + settings.EXTERNAL_HOST)
if index == -1:
return ""
subdomain = domain[0:index]
return subdomain
def check_subdomain(realm_subdomain, user_subdomain):
# type: (text_type, text_type) -> bool
if settings.REALMS_HAVE_SUBDOMAINS and realm_subdomain is not None:
if (realm_subdomain == "" and user_subdomain is None):
return True
if realm_subdomain != user_subdomain:
return False
return True

View File

@@ -10,16 +10,18 @@ from zerver.lib.response import json_error
from zerver.lib.request import JsonableError from zerver.lib.request import JsonableError
from django.db import connection from django.db import connection
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from zerver.lib.utils import statsd from zerver.lib.utils import statsd, get_subdomain
from zerver.lib.queue import queue_json_publish from zerver.lib.queue import queue_json_publish
from zerver.lib.cache import get_remote_cache_time, get_remote_cache_requests from zerver.lib.cache import get_remote_cache_time, get_remote_cache_requests
from zerver.lib.bugdown import get_bugdown_time, get_bugdown_requests from zerver.lib.bugdown import get_bugdown_time, get_bugdown_requests
from zerver.models import flush_per_request_caches from zerver.models import flush_per_request_caches, resolve_subdomain_to_realm
from zerver.exceptions import RateLimited from zerver.exceptions import RateLimited
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from django.views.csrf import csrf_failure as html_csrf_failure from django.views.csrf import csrf_failure as html_csrf_failure
from django.utils.cache import patch_vary_headers from django.utils.cache import patch_vary_headers
from django.utils.http import cookie_date from django.utils.http import cookie_date
from zproject.jinja2 import render_to_response
from django.shortcuts import redirect
import logging import logging
import time import time
@@ -346,6 +348,17 @@ class FlushDisplayRecipientCache(object):
class SessionHostDomainMiddleware(SessionMiddleware): class SessionHostDomainMiddleware(SessionMiddleware):
def process_response(self, request, response): def process_response(self, request, response):
# type: (HttpRequest, HttpResponse) -> HttpResponse # type: (HttpRequest, HttpResponse) -> HttpResponse
if settings.REALMS_HAVE_SUBDOMAINS:
if (not request.path.startswith("/static/") and not request.path.startswith("/api/")
and not request.path.startswith("/json/")):
subdomain = get_subdomain(request)
if (request.get_host() == "127.0.0.1:9991" or request.get_host() == "localhost:9991"):
return redirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
settings.EXTERNAL_HOST))
if subdomain != "":
realm = resolve_subdomain_to_realm(subdomain)
if (realm is None):
return render_to_response("zerver/invalid_realm.html")
""" """
If request.session was modified, or if the configuration is to save the If request.session was modified, or if the configuration is to save the
session every time, save the changes and set a session cookie. session every time, save the changes and set a session cookie.
@@ -371,9 +384,15 @@ class SessionHostDomainMiddleware(SessionMiddleware):
if response.status_code != 500: if response.status_code != 500:
request.session.save() request.session.save()
host = request.get_host().split(':')[0] host = request.get_host().split(':')[0]
session_cookie_domain = settings.SESSION_COOKIE_DOMAIN session_cookie_domain = settings.SESSION_COOKIE_DOMAIN
if host.endswith(".e.zulip.com"): # The subdomains feature overrides the
session_cookie_domain = ".e.zulip.com" # SESSION_COOKIE_DOMAIN setting, since the setting
# is a fixed value and with subdomains enabled,
# the session cookie domain has to vary with the
# subdomain.
if settings.REALMS_HAVE_SUBDOMAINS:
session_cookie_domain = host
response.set_cookie(settings.SESSION_COOKIE_NAME, response.set_cookie(settings.SESSION_COOKIE_NAME,
request.session.session_key, max_age=max_age, request.session.session_key, max_age=max_age,
expires=expires, domain=session_cookie_domain, expires=expires, domain=session_cookie_domain,

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.db.backends.postgresql_psycopg2.schema import DatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
def set_subdomain_of_default_realm(apps, schema_editor):
# type: (StateApps, DatabaseSchemaEditor) -> None
if settings.DEVELOPMENT:
Realm = apps.get_model('zerver', 'Realm')
try:
default_realm = Realm.objects.get(domain="zulip.com")
except ObjectDoesNotExist:
default_realm = None
if default_realm is not None:
default_realm.subdomain = "zulip"
default_realm.save()
class Migration(migrations.Migration):
dependencies = [
('zerver', '0028_userprofile_tos_version'),
]
operations = [
migrations.AddField(
model_name='realm',
name='subdomain',
field=models.CharField(max_length=40, unique=True, null=True),
),
migrations.RunPython(set_subdomain_of_default_realm)
]

View File

@@ -140,6 +140,7 @@ class Realm(ModelReprMixin, models.Model):
# name is the user-visible identifier for the realm. It has no required # name is the user-visible identifier for the realm. It has no required
# structure. # structure.
name = models.CharField(max_length=40, null=True) # type: Optional[text_type] name = models.CharField(max_length=40, null=True) # type: Optional[text_type]
subdomain = models.CharField(max_length=40, null=True, unique=True) # type: Optional[text_type]
restricted_to_domain = models.BooleanField(default=True) # type: bool restricted_to_domain = models.BooleanField(default=True) # type: bool
invite_required = models.BooleanField(default=False) # type: bool invite_required = models.BooleanField(default=False) # type: bool
invite_by_admins_only = models.BooleanField(default=False) # type: bool invite_by_admins_only = models.BooleanField(default=False) # type: bool
@@ -199,11 +200,16 @@ class Realm(ModelReprMixin, models.Model):
@property @property
def uri(self): def uri(self):
# type: () -> str # type: () -> str
if settings.REALMS_HAVE_SUBDOMAINS and self.subdomain is not None:
return '%s%s.%s' % (settings.EXTERNAL_URI_SCHEME,
self.subdomain, settings.EXTERNAL_HOST)
return settings.SERVER_URI return settings.SERVER_URI
@property @property
def host(self): def host(self):
# type: () -> str # type: () -> str
if settings.REALMS_HAVE_SUBDOMAINS and self.subdomain is not None:
return "%s.%s" % (self.subdomain, settings.EXTERNAL_HOST)
return settings.EXTERNAL_HOST return settings.EXTERNAL_HOST
@property @property
@@ -258,6 +264,13 @@ def resolve_email_to_domain(email):
domain = alias.realm.domain domain = alias.realm.domain
return domain return domain
def resolve_subdomain_to_realm(subdomain):
# type: (text_type) -> Optional[Realm]
try:
return Realm.objects.get(subdomain=subdomain)
except Realm.DoesNotExist:
return None
# Is a user with the given email address allowed to be in the given realm? # Is a user with the given email address allowed to be in the given realm?
# (This function does not check whether the user has been invited to the realm. # (This function does not check whether the user has been invited to the realm.
# So for invite-only realms, this is the test for whether a user can be invited, # So for invite-only realms, this is the test for whether a user can be invited,

View File

@@ -3,7 +3,8 @@ from __future__ import absolute_import
import os import os
from django.test import TestCase from django.conf import settings
from django.test import TestCase, override_settings
from typing import Any from typing import Any
from zproject.settings import DEPLOY_ROOT from zproject.settings import DEPLOY_ROOT
@@ -17,9 +18,27 @@ class IntegrationTest(TestCase):
for integration in INTEGRATIONS.values(): for integration in INTEGRATIONS.values():
self.assertTrue(os.path.isfile(os.path.join(DEPLOY_ROOT, integration.logo))) self.assertTrue(os.path.isfile(os.path.join(DEPLOY_ROOT, integration.logo)))
@override_settings(REALMS_HAVE_SUBDOMAINS=False)
def test_api_url_view_base(self): def test_api_url_view_base(self):
# type: () -> None # type: () -> None
context = dict() # type: Dict[str, Any] context = dict() # type: Dict[str, Any]
add_api_uri_context(context, HostRequestMock()) add_api_uri_context(context, HostRequestMock())
self.assertEqual(context["external_api_path_subdomain"], "localhost:9991/api") self.assertEqual(context["external_api_path_subdomain"], "zulipdev.com:9991/api")
self.assertEqual(context["external_api_uri_subdomain"], "http://localhost:9991/api") self.assertEqual(context["external_api_uri_subdomain"], "http://zulipdev.com:9991/api")
@override_settings(REALMS_HAVE_SUBDOMAINS=True)
def test_api_url_view_subdomains_base(self):
# type: () -> None
context = dict() # type: Dict[str, Any]
add_api_uri_context(context, HostRequestMock())
self.assertEqual(context["external_api_path_subdomain"], "yourZulipDomain.zulipdev.com:9991/api")
self.assertEqual(context["external_api_uri_subdomain"], "http://yourZulipDomain.zulipdev.com:9991/api")
@override_settings(REALMS_HAVE_SUBDOMAINS=True, EXTERNAL_HOST="zulipdev.com")
def test_api_url_view_subdomains_full(self):
# type: () -> None
context = dict() # type: Dict[str, Any]
request = HostRequestMock(host="mysubdomain.zulipdev.com")
add_api_uri_context(context, request)
self.assertEqual(context["external_api_path_subdomain"], "mysubdomain.zulipdev.com:9991/api")
self.assertEqual(context["external_api_uri_subdomain"], "http://mysubdomain.zulipdev.com:9991/api")

View File

@@ -734,3 +734,54 @@ class UserSignUpTest(ZulipTestCase):
self.assertEqual(user_profile.default_language, realm.default_language) self.assertEqual(user_profile.default_language, realm.default_language)
from django.core.mail import outbox from django.core.mail import outbox
outbox.pop() outbox.pop()
def test_create_realm_with_subdomain(self):
# type: () -> None
username = "user1"
password = "test"
domain = "test.com"
email = "user1@test.com"
subdomain = "test"
realm_name = "Test"
# Make sure the realm does not exist
self.assertIsNone(get_realm(domain))
with self.settings(REALMS_HAVE_SUBDOMAINS=True), self.settings(OPEN_REALM_CREATION=True):
# Create new realm with the email
result = self.client_post('/create_realm/', {'email': email})
self.assertEquals(result.status_code, 302)
self.assertTrue(result["Location"].endswith(
"/accounts/send_confirm/%s@%s" % (username, domain)))
result = self.client_get(result["Location"])
self.assert_in_response("Check your email so we can get started.", result)
# Visit the confirmation link.
from django.core.mail import outbox
for message in reversed(outbox):
if email in message.to:
confirmation_link_pattern = re.compile(settings.EXTERNAL_HOST + "(\S+)>")
confirmation_url = confirmation_link_pattern.search(
message.body).groups()[0]
break
else:
raise ValueError("Couldn't find a confirmation email.")
result = self.client_get(confirmation_url)
self.assertEquals(result.status_code, 200)
result = self.submit_reg_form_for_user(username,
password,
domain=domain,
realm_name=realm_name,
realm_subdomain=subdomain,
# Pass HTTP_HOST for the target subdomain
HTTP_HOST=subdomain + ".testserver")
self.assertEquals(result.status_code, 302)
# Make sure the realm is created
realm = get_realm(domain)
self.assertIsNotNone(realm)
self.assertEqual(realm.domain, domain)
self.assertEqual(realm.name, realm_name)
self.assertEqual(realm.subdomain, subdomain)
self.assertEqual(get_user_profile_by_email(email).realm, realm)

View File

@@ -25,7 +25,7 @@ from zerver.models import Message, UserProfile, Stream, Subscription, Huddle, \
get_stream, UserPresence, get_recipient, \ get_stream, UserPresence, get_recipient, \
split_email_to_domain, resolve_email_to_domain, email_to_username, get_realm, \ split_email_to_domain, resolve_email_to_domain, email_to_username, get_realm, \
completely_open, get_unique_open_realm, remote_user_to_email, email_allowed_for_realm, \ completely_open, get_unique_open_realm, remote_user_to_email, email_allowed_for_realm, \
get_cross_realm_users get_cross_realm_users, resolve_subdomain_to_realm
from zerver.lib.actions import do_change_password, do_change_full_name, do_change_is_admin, \ from zerver.lib.actions import do_change_password, do_change_full_name, do_change_is_admin, \
do_activate_user, do_create_user, do_create_realm, set_default_streams, \ do_activate_user, do_create_user, do_create_realm, set_default_streams, \
internal_send_message, update_user_presence, do_events_register, \ internal_send_message, update_user_presence, do_events_register, \
@@ -49,7 +49,7 @@ from zerver.lib.avatar import avatar_url
from zerver.lib.i18n import get_language_list, get_language_name, \ from zerver.lib.i18n import get_language_list, get_language_name, \
get_language_list_for_templates get_language_list_for_templates
from zerver.lib.response import json_success, json_error from zerver.lib.response import json_success, json_error
from zerver.lib.utils import statsd from zerver.lib.utils import statsd, get_subdomain
from version import ZULIP_VERSION from version import ZULIP_VERSION
from zproject.backends import password_auth_enabled, dev_auth_enabled, google_auth_enabled from zproject.backends import password_auth_enabled, dev_auth_enabled, google_auth_enabled
@@ -198,7 +198,12 @@ def accounts_register(request):
if realm_creation: if realm_creation:
domain = split_email_to_domain(email) domain = split_email_to_domain(email)
realm = do_create_realm(domain, form.cleaned_data['realm_name'])[0] if settings.REALMS_HAVE_SUBDOMAINS:
realm = do_create_realm(domain, form.cleaned_data['realm_name'],
subdomain=form.cleaned_data['realm_subdomain'])[0]
else:
realm = do_create_realm(domain, form.cleaned_data['realm_name'])[0]
set_default_streams(realm, settings.DEFAULT_NEW_REALM_STREAMS) set_default_streams(realm, settings.DEFAULT_NEW_REALM_STREAMS)
full_name = form.cleaned_data['full_name'] full_name = form.cleaned_data['full_name']
@@ -225,11 +230,21 @@ def accounts_register(request):
# This logs you in using the ZulipDummyBackend, since honestly nothing # This logs you in using the ZulipDummyBackend, since honestly nothing
# more fancy than this is required. # more fancy than this is required.
login(request, authenticate(username=user_profile.email, use_dummy_backend=True)) login(request, authenticate(username=user_profile.email,
realm_subdomain=realm.subdomain,
use_dummy_backend=True))
if first_in_realm: if first_in_realm:
do_change_is_admin(user_profile, True) do_change_is_admin(user_profile, True)
return HttpResponseRedirect(reverse('zerver.views.initial_invite_page')) invite_url = reverse('zerver.views.initial_invite_page')
if (realm_creation and settings.REALMS_HAVE_SUBDOMAINS):
invite_url = "%s%s.%s%s" % (
settings.EXTERNAL_URI_SCHEME,
form.cleaned_data['realm_subdomain'],
settings.EXTERNAL_HOST,
reverse('zerver.views.initial_invite_page')
)
return HttpResponseRedirect(invite_url)
else: else:
return HttpResponseRedirect(reverse('zerver.views.home')) return HttpResponseRedirect(reverse('zerver.views.home'))
@@ -244,6 +259,7 @@ def accounts_register(request):
# but for the registration form, there is no logged in user yet, so # but for the registration form, there is no logged in user yet, so
# we have to set it here. # we have to set it here.
'creating_new_team': realm_creation, 'creating_new_team': realm_creation,
'realms_have_subdomains': settings.REALMS_HAVE_SUBDOMAINS,
'password_auth_enabled': password_auth_enabled(realm), 'password_auth_enabled': password_auth_enabled(realm),
}, },
request=request) request=request)
@@ -316,10 +332,11 @@ def get_invitee_emails_set(invitee_emails_raw):
def create_homepage_form(request, user_info=None): def create_homepage_form(request, user_info=None):
# type: (HttpRequest, Optional[Dict[str, Any]]) -> HomepageForm # type: (HttpRequest, Optional[Dict[str, Any]]) -> HomepageForm
if user_info: if user_info:
return HomepageForm(user_info, domain=request.session.get("domain")) return HomepageForm(user_info, domain=request.session.get("domain"),
subdomain=get_subdomain(request))
# An empty fields dict is not treated the same way as not # An empty fields dict is not treated the same way as not
# providing it. # providing it.
return HomepageForm(domain=request.session.get("domain")) return HomepageForm(domain=request.session.get("domain"), subdomain=get_subdomain(request))
def maybe_send_to_registration(request, email, full_name=''): def maybe_send_to_registration(request, email, full_name=''):
# type: (HttpRequest, text_type, text_type) -> HttpResponse # type: (HttpRequest, text_type, text_type) -> HttpResponse
@@ -362,6 +379,11 @@ def login_or_register_remote_user(request, remote_username, user_profile, full_n
return maybe_send_to_registration(request, remote_user_to_email(remote_username), full_name) return maybe_send_to_registration(request, remote_user_to_email(remote_username), full_name)
else: else:
login(request, user_profile) login(request, user_profile)
if settings.OPEN_REALM_CREATION and user_profile.realm.subdomain is not None:
return HttpResponseRedirect("%s%s.%s" % (settings.EXTERNAL_URI_SCHEME,
user_profile.realm.subdomain,
settings.EXTERNAL_HOST))
return HttpResponseRedirect("%s%s" % (settings.EXTERNAL_URI_SCHEME, return HttpResponseRedirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
request.get_host())) request.get_host()))
@@ -372,7 +394,7 @@ def remote_user_sso(request):
except KeyError: except KeyError:
raise JsonableError(_("No REMOTE_USER set.")) raise JsonableError(_("No REMOTE_USER set."))
user_profile = authenticate(remote_user=remote_user) user_profile = authenticate(remote_user=remote_user, realm_subdomain=get_subdomain(request))
return login_or_register_remote_user(request, remote_user, user_profile) return login_or_register_remote_user(request, remote_user, user_profile)
@csrf_exempt @csrf_exempt
@@ -401,7 +423,9 @@ def remote_user_jwt(request):
# We do all the authentication we need here (otherwise we'd have to # We do all the authentication we need here (otherwise we'd have to
# duplicate work), but we need to call authenticate with some backend so # duplicate work), but we need to call authenticate with some backend so
# that the request.backend attribute gets set. # that the request.backend attribute gets set.
user_profile = authenticate(username=email, use_dummy_backend=True) user_profile = authenticate(username=email,
realm_subdomain=get_subdomain(request),
use_dummy_backend=True)
except (jwt.DecodeError, jwt.ExpiredSignature): except (jwt.DecodeError, jwt.ExpiredSignature):
raise JsonableError(_("Bad JSON web token signature")) raise JsonableError(_("Bad JSON web token signature"))
except KeyError: except KeyError:
@@ -503,7 +527,9 @@ def finish_google_oauth2(request):
logging.error('Google oauth2 account email not found: %s' % (body,)) logging.error('Google oauth2 account email not found: %s' % (body,))
return HttpResponse(status=400) return HttpResponse(status=400)
email_address = email['value'] email_address = email['value']
user_profile = authenticate(username=email_address, use_dummy_backend=True) user_profile = authenticate(username=email_address,
realm_subdomain=get_subdomain(request),
use_dummy_backend=True)
return login_or_register_remote_user(request, email_address, user_profile, full_name) return login_or_register_remote_user(request, email_address, user_profile, full_name)
def login_page(request, **kwargs): def login_page(request, **kwargs):
@@ -536,10 +562,16 @@ def dev_direct_login(request, **kwargs):
# This check is probably not required, since authenticate would fail without an enabled DevAuthBackend. # This check is probably not required, since authenticate would fail without an enabled DevAuthBackend.
raise Exception('Direct login not supported.') raise Exception('Direct login not supported.')
email = request.POST['direct_email'] email = request.POST['direct_email']
user_profile = authenticate(username=email) user_profile = authenticate(username=email, realm_subdomain=get_subdomain(request))
if user_profile is None: if user_profile is None:
raise Exception("User cannot login") raise Exception("User cannot login")
login(request, user_profile) login(request, user_profile)
if settings.OPEN_REALM_CREATION and settings.DEVELOPMENT:
if user_profile.realm.subdomain is not None:
return HttpResponseRedirect("%s%s.%s" % (settings.EXTERNAL_URI_SCHEME,
user_profile.realm.subdomain,
settings.EXTERNAL_HOST))
return HttpResponseRedirect("%s%s" % (settings.EXTERNAL_URI_SCHEME, return HttpResponseRedirect("%s%s" % (settings.EXTERNAL_URI_SCHEME,
request.get_host())) request.get_host()))
@@ -555,7 +587,9 @@ def api_dev_fetch_api_key(request, username=REQ()):
if not dev_auth_enabled() or settings.PRODUCTION: if not dev_auth_enabled() or settings.PRODUCTION:
return json_error(_("Dev environment not enabled.")) return json_error(_("Dev environment not enabled."))
return_data = {} # type: Dict[str, bool] return_data = {} # type: Dict[str, bool]
user_profile = authenticate(username=username, return_data=return_data) user_profile = authenticate(username=username,
realm_subdomain=get_subdomain(request),
return_data=return_data)
if return_data.get("inactive_realm") == True: if return_data.get("inactive_realm") == True:
return json_error(_("Your realm has been deactivated."), return json_error(_("Your realm has been deactivated."),
data={"reason": "realm deactivated"}, status=403) data={"reason": "realm deactivated"}, status=403)
@@ -660,7 +694,8 @@ def send_registration_completion_email(email, request, realm_creation=False):
context = {'support_email': settings.ZULIP_ADMINISTRATOR, context = {'support_email': settings.ZULIP_ADMINISTRATOR,
'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS} 'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS}
return Confirmation.objects.send_confirmation(prereg_user, email, return Confirmation.objects.send_confirmation(prereg_user, email,
additional_context=context) additional_context=context,
host=request.get_host())
def redirect_to_email_login_url(email): def redirect_to_email_login_url(email):
# type: (str) -> HttpResponseRedirect # type: (str) -> HttpResponseRedirect
@@ -1003,9 +1038,14 @@ def api_fetch_api_key(request, username=REQ(), password=REQ()):
# type: (HttpRequest, str, str) -> HttpResponse # type: (HttpRequest, str, str) -> HttpResponse
return_data = {} # type: Dict[str, bool] return_data = {} # type: Dict[str, bool]
if username == "google-oauth2-token": if username == "google-oauth2-token":
user_profile = authenticate(google_oauth2_token=password, return_data=return_data) user_profile = authenticate(google_oauth2_token=password,
realm_subdomain=get_subdomain(request),
return_data=return_data)
else: else:
user_profile = authenticate(username=username, password=password, return_data=return_data) user_profile = authenticate(username=username,
password=password,
realm_subdomain=get_subdomain(request),
return_data=return_data)
if return_data.get("inactive_user") == True: if return_data.get("inactive_user") == True:
return json_error(_("Your account has been disabled."), return json_error(_("Your account has been disabled."),
data={"reason": "user disable"}, status=403) data={"reason": "user disable"}, status=403)
@@ -1039,7 +1079,8 @@ def api_get_auth_backends(request):
def json_fetch_api_key(request, user_profile, password=REQ(default='')): def json_fetch_api_key(request, user_profile, password=REQ(default='')):
# type: (HttpRequest, UserProfile, str) -> HttpResponse # type: (HttpRequest, UserProfile, str) -> HttpResponse
if password_auth_enabled(user_profile.realm): if password_auth_enabled(user_profile.realm):
if not authenticate(username=user_profile.email, password=password): if not authenticate(username=user_profile.email, password=password,
realm_subdomain=get_subdomain(request)):
return json_error(_("Your username or password is incorrect.")) return json_error(_("Your username or password is incorrect."))
return json_success({"api_key": user_profile.api_key}) return json_success({"api_key": user_profile.api_key})

View File

@@ -9,15 +9,31 @@ import ujson
from zerver.lib import bugdown from zerver.lib import bugdown
from zerver.lib.integrations import INTEGRATIONS from zerver.lib.integrations import INTEGRATIONS
from zerver.lib.utils import get_subdomain
from zproject.jinja2 import render_to_response from zproject.jinja2 import render_to_response
def add_api_uri_context(context, request): def add_api_uri_context(context, request):
# type: (Dict[str, Any], HttpRequest) -> None # type: (Dict[str, Any], HttpRequest) -> None
external_api_path_subdomain = settings.EXTERNAL_API_PATH if settings.REALMS_HAVE_SUBDOMAINS:
external_api_uri_subdomain = settings.EXTERNAL_API_URI subdomain = get_subdomain(request)
if subdomain:
display_subdomain = subdomain
html_settings_links = True
else:
display_subdomain = 'yourZulipDomain'
html_settings_links = False
external_api_path_subdomain = '%s.%s' % (display_subdomain,
settings.EXTERNAL_API_PATH)
else:
external_api_path_subdomain = settings.EXTERNAL_API_PATH
html_settings_links = True
external_api_uri_subdomain = '%s%s' % (settings.EXTERNAL_URI_SCHEME,
external_api_path_subdomain)
context['external_api_path_subdomain'] = external_api_path_subdomain context['external_api_path_subdomain'] = external_api_path_subdomain
context['external_api_uri_subdomain'] = external_api_uri_subdomain context['external_api_uri_subdomain'] = external_api_uri_subdomain
context["html_settings_links"] = html_settings_links
class ApiURLView(TemplateView): class ApiURLView(TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -26,7 +42,6 @@ class ApiURLView(TemplateView):
add_api_uri_context(context, self.request) add_api_uri_context(context, self.request)
return context return context
class APIView(ApiURLView): class APIView(ApiURLView):
template_name = 'zerver/api.html' template_name = 'zerver/api.html'
@@ -40,8 +55,12 @@ class IntegrationView(ApiURLView):
alphabetical_sorted_integration = OrderedDict(sorted(INTEGRATIONS.items())) alphabetical_sorted_integration = OrderedDict(sorted(INTEGRATIONS.items()))
context['integrations_dict'] = alphabetical_sorted_integration context['integrations_dict'] = alphabetical_sorted_integration
settings_html = '<a href="../#settings">Zulip settings page</a>' if context["html_settings_links"]:
subscriptions_html = '<a target="_blank" href="../#subscriptions">subscriptions page</a>' settings_html = '<a href="../#settings">Zulip settings page</a>'
subscriptions_html = '<a target="_blank" href="../#subscriptions">subscriptions page</a>'
else:
settings_html = 'Zulip settings page'
subscriptions_html = 'subscriptions page'
context['settings_html'] = settings_html context['settings_html'] = settings_html
context['subscriptions_html'] = subscriptions_html context['subscriptions_html'] = subscriptions_html

View File

@@ -123,7 +123,7 @@ class Command(BaseCommand):
clear_database() clear_database()
# Create our two default realms # Create our two default realms
zulip_realm = Realm.objects.create(domain="zulip.com", name="Zulip Dev") zulip_realm = Realm.objects.create(domain="zulip.com", name="Zulip Dev", subdomain="zulip")
if options["test_suite"]: if options["test_suite"]:
Realm.objects.create(domain="mit.edu") Realm.objects.create(domain="mit.edu")
realms = {} # type: Dict[text_type, Realm] realms = {} # type: Dict[text_type, Realm]

View File

@@ -22,6 +22,7 @@ from social.backends.github import GithubOAuth2, GithubOrganizationOAuth2, \
GithubTeamOAuth2 GithubTeamOAuth2
from social.exceptions import AuthFailed from social.exceptions import AuthFailed
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from zerver.lib.utils import check_subdomain
def password_auth_enabled(realm): def password_auth_enabled(realm):
# type: (Realm) -> bool # type: (Realm) -> bool
@@ -115,6 +116,11 @@ class SocialAuthMixin(ZulipAuthMixin):
return_data["inactive_realm"] = True return_data["inactive_realm"] = True
return None return None
if not check_subdomain(kwargs.get("realm_subdomain"),
user_profile.realm.subdomain):
return_data["invalid_subdomain"] = True
return None
return user_profile return user_profile
def process_do_auth(self, user_profile, *args, **kwargs): def process_do_auth(self, user_profile, *args, **kwargs):
@@ -142,10 +148,14 @@ class ZulipDummyBackend(ZulipAuthMixin):
""" """
Used when we want to log you in but we don't know which backend to use. Used when we want to log you in but we don't know which backend to use.
""" """
def authenticate(self, username=None, use_dummy_backend=False): def authenticate(self, username=None, realm_subdomain=None, use_dummy_backend=False):
# type: (Optional[str], bool) -> Optional[UserProfile] # type: (Optional[text_type], Optional[text_type], bool) -> Optional[UserProfile]
if use_dummy_backend: if use_dummy_backend:
return common_get_active_user_by_email(username) user_profile = common_get_active_user_by_email(username)
if user_profile is None:
return None
if check_subdomain(realm_subdomain, user_profile.realm.subdomain):
return user_profile
return None return None
class EmailAuthBackend(ZulipAuthMixin): class EmailAuthBackend(ZulipAuthMixin):
@@ -155,9 +165,8 @@ class EmailAuthBackend(ZulipAuthMixin):
Allows a user to sign in using an email/password pair rather than Allows a user to sign in using an email/password pair rather than
a username/password pair. a username/password pair.
""" """
def authenticate(self, username=None, password=None, realm_subdomain=None, return_data=None):
def authenticate(self, username=None, password=None, return_data=None): # type: (Optional[text_type], Optional[str], Optional[text_type], Optional[Dict[str, Any]]) -> Optional[UserProfile]
# type: (Optional[text_type], Optional[str], Optional[Dict[str, Any]]) -> Optional[UserProfile]
""" Authenticate a user based on email address as the user name. """ """ Authenticate a user based on email address as the user name. """
if username is None or password is None: if username is None or password is None:
# Return immediately. Otherwise we will look for a SQL row with # Return immediately. Otherwise we will look for a SQL row with
@@ -173,7 +182,11 @@ class EmailAuthBackend(ZulipAuthMixin):
return_data['password_auth_disabled'] = True return_data['password_auth_disabled'] = True
return None return None
if user_profile.check_password(password): if user_profile.check_password(password):
if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
return_data["invalid_subdomain"] = True
return None
return user_profile return user_profile
return None
class GoogleMobileOauth2Backend(ZulipAuthMixin): class GoogleMobileOauth2Backend(ZulipAuthMixin):
""" """
@@ -186,8 +199,8 @@ class GoogleMobileOauth2Backend(ZulipAuthMixin):
https://developers.google.com/accounts/docs/CrossClientAuth#offlineAccess https://developers.google.com/accounts/docs/CrossClientAuth#offlineAccess
""" """
def authenticate(self, google_oauth2_token=None, return_data=dict()): def authenticate(self, google_oauth2_token=None, realm_subdomain=None, return_data={}):
# type: (Optional[str], Dict[str, Any]) -> Optional[UserProfile] # type: (Optional[str], Optional[text_type], Dict[str, Any]) -> Optional[UserProfile]
try: try:
token_payload = googleapiclient.verify_id_token(google_oauth2_token, settings.GOOGLE_CLIENT_ID) token_payload = googleapiclient.verify_id_token(google_oauth2_token, settings.GOOGLE_CLIENT_ID)
except AppIdentityError: except AppIdentityError:
@@ -204,20 +217,25 @@ class GoogleMobileOauth2Backend(ZulipAuthMixin):
if user_profile.realm.deactivated: if user_profile.realm.deactivated:
return_data["inactive_realm"] = True return_data["inactive_realm"] = True
return None return None
if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
return_data["invalid_subdomain"] = True
return None
return user_profile return user_profile
else: else:
return_data["valid_attestation"] = False return_data["valid_attestation"] = False
class ZulipRemoteUserBackend(RemoteUserBackend): class ZulipRemoteUserBackend(RemoteUserBackend):
create_unknown_user = False create_unknown_user = False
def authenticate(self, remote_user, realm_subdomain=None):
def authenticate(self, remote_user): # type: (str, Optional[text_type]) -> Optional[UserProfile]
# type: (str) -> Optional[UserProfile]
if not remote_user: if not remote_user:
return None return None
email = remote_user_to_email(remote_user) email = remote_user_to_email(remote_user)
return common_get_active_user_by_email(email) user = common_get_active_user_by_email(email)
if user is not None and check_subdomain(realm_subdomain, user.realm.subdomain):
return user
return None
class ZulipLDAPException(Exception): class ZulipLDAPException(Exception):
pass pass
@@ -257,11 +275,16 @@ class ZulipLDAPAuthBackendBase(ZulipAuthMixin, LDAPBackend):
return username return username
class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase): class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
def authenticate(self, username, password, return_data=None): def authenticate(self, username, password, realm_subdomain=None, return_data=None):
# type: (text_type, str, Optional[Dict[str, Any]]) -> Optional[str] # type: (text_type, str, Optional[text_type], Optional[Dict[str, Any]]) -> Optional[str]
try: try:
username = self.django_to_ldap_username(username) username = self.django_to_ldap_username(username)
return ZulipLDAPAuthBackendBase.authenticate(self, username, password) user_profile = ZulipLDAPAuthBackendBase.authenticate(self, username, password)
if user_profile is None:
return None
if not check_subdomain(realm_subdomain, user_profile.realm.subdomain):
return None
return user_profile
except Realm.DoesNotExist: except Realm.DoesNotExist:
return None return None
except ZulipLDAPException: except ZulipLDAPException:
@@ -292,16 +315,15 @@ class ZulipLDAPAuthBackend(ZulipLDAPAuthBackendBase):
# Just like ZulipLDAPAuthBackend, but doesn't let you log in. # Just like ZulipLDAPAuthBackend, but doesn't let you log in.
class ZulipLDAPUserPopulator(ZulipLDAPAuthBackendBase): class ZulipLDAPUserPopulator(ZulipLDAPAuthBackendBase):
def authenticate(self, username, password): def authenticate(self, username, password, realm_subdomain=None):
# type: (text_type, str) -> None # type: (text_type, str, Optional[text_type]) -> None
return None return None
class DevAuthBackend(ZulipAuthMixin): class DevAuthBackend(ZulipAuthMixin):
# Allow logging in as any user without a password. # Allow logging in as any user without a password.
# This is used for convenience when developing Zulip. # This is used for convenience when developing Zulip.
def authenticate(self, username, realm_subdomain=None, return_data=None):
def authenticate(self, username, return_data=None): # type: (text_type, Optional[text_type], Optional[Dict[str, Any]]) -> UserProfile
# type: (text_type, Optional[Dict[str, Any]]) -> UserProfile
return common_get_active_user_by_email(username, return_data=return_data) return common_get_active_user_by_email(username, return_data=return_data)
class GitHubAuthBackend(SocialAuthMixin, GithubOAuth2): class GitHubAuthBackend(SocialAuthMixin, GithubOAuth2):

View File

@@ -4,8 +4,8 @@
from .prod_settings_template import * from .prod_settings_template import *
LOCAL_UPLOADS_DIR = 'var/uploads' LOCAL_UPLOADS_DIR = 'var/uploads'
EXTERNAL_HOST = 'localhost:9991' EXTERNAL_HOST = 'zulipdev.com:9991'
ALLOWED_HOSTS = ['localhost'] ALLOWED_HOSTS = ['*']
AUTHENTICATION_BACKENDS = ('zproject.backends.DevAuthBackend',) AUTHENTICATION_BACKENDS = ('zproject.backends.DevAuthBackend',)
# Add some of the below if you're testing other backends # Add some of the below if you're testing other backends
# AUTHENTICATION_BACKENDS = ('zproject.backends.EmailAuthBackend', # AUTHENTICATION_BACKENDS = ('zproject.backends.EmailAuthBackend',
@@ -20,6 +20,9 @@ EXTRA_INSTALLED_APPS = ["zilencer", "analytics"]
# Disable Camo in development # Disable Camo in development
CAMO_URI = '' CAMO_URI = ''
OPEN_REALM_CREATION = True OPEN_REALM_CREATION = True
# Default to subdomains disabled in development until we can update
# the development documentation to make sense with subdomains.
REALMS_HAVE_SUBDOMAINS = False
TERMS_OF_SERVICE = 'zproject/terms.md.template' TERMS_OF_SERVICE = 'zproject/terms.md.template'
SAVE_FRONTEND_STACKTRACES = True SAVE_FRONTEND_STACKTRACES = True

View File

@@ -158,6 +158,7 @@ DEFAULT_SETTINGS = {'TWITTER_CONSUMER_KEY': '',
'VERBOSE_SUPPORT_OFFERS': False, 'VERBOSE_SUPPORT_OFFERS': False,
'STATSD_HOST': '', 'STATSD_HOST': '',
'OPEN_REALM_CREATION': False, 'OPEN_REALM_CREATION': False,
'REALMS_HAVE_SUBDOMAINS': False,
'REMOTE_POSTGRES_HOST': '', 'REMOTE_POSTGRES_HOST': '',
'REMOTE_POSTGRES_SSLMODE': '', 'REMOTE_POSTGRES_SSLMODE': '',
# Default GOOGLE_CLIENT_ID to the value needed for Android auth to work # Default GOOGLE_CLIENT_ID to the value needed for Android auth to work

View File

@@ -82,6 +82,8 @@ LOCAL_UPLOADS_DIR = 'var/test_uploads'
S3_KEY = 'test-key' S3_KEY = 'test-key'
S3_SECRET_KEY = 'test-secret-key' S3_SECRET_KEY = 'test-secret-key'
S3_AUTH_UPLOADS_BUCKET = 'test-authed-bucket' S3_AUTH_UPLOADS_BUCKET = 'test-authed-bucket'
EXTERNAL_HOST = os.getenv('EXTERNAL_HOST', "testserver")
REALMS_HAVE_SUBDOMAINS = bool(os.getenv('REALMS_HAVE_SUBDOMAINS', False))
# Test Custom TOS template rendering # Test Custom TOS template rendering
TERMS_OF_SERVICE = 'corporate/terms.md' TERMS_OF_SERVICE = 'corporate/terms.md'