Skip to content

Allow some users (but not all) to not need admin approval #145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 72 commits into from
Sep 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
d6aec8f
first tests to dip the toes
Apr 10, 2021
e797261
removing name clobbering
Apr 20, 2021
a635e42
syntax error
Apr 20, 2021
e8aa0bf
confirm URL
Apr 20, 2021
a1e4f93
adding email-confirm authenticator to the list of so-called 'tested' …
Apr 23, 2021
46f3f43
boilerplate and documentation to allow self-approval based on email
Apr 23, 2021
3e95f46
Send email for users who can self-approve
Apr 23, 2021
411491b
use a method to send email (rather than sending it inline)
Apr 23, 2021
d2353a1
test consistency of settings
Apr 23, 2021
92ddc16
TDD-based URL generation
Apr 23, 2021
3507610
static method need signature from context
Apr 23, 2021
2222bfb
sign/usign the validation URL
Apr 23, 2021
0b3d5a8
adding time expiration to the validation URL
Apr 23, 2021
eae707e
whitespace fix
Apr 23, 2021
98a544f
error message about open signup (achieves same result as self validat…
Apr 23, 2021
463cd5e
authorize users who navigate to proper URL
Apr 23, 2021
470c852
must authenticate user, not slug
Apr 26, 2021
311f71c
better example of regexp for the email address
Apr 26, 2021
a9b6104
clarifying documentation
Apr 26, 2021
c66d435
further clarifications
Apr 26, 2021
3156fdf
documenting the secret key
Apr 26, 2021
589fb52
small formattings
Apr 26, 2021
b97d707
default arguments are evaluated at import time
Apr 28, 2021
7bbf43d
validate expiration too
Apr 28, 2021
92f7126
tests to ensure expiration time works as expected -- and needed code …
Apr 28, 2021
a6661b5
the correct fromisoformat is provided in a different class
Apr 28, 2021
7181473
flake8
Apr 28, 2021
3d300e3
Last fluke8 formatting request
Apr 28, 2021
d0931bd
using timezone to make sure the datetime can be always correctly comp…
Apr 29, 2021
ff9ad5e
allowing SMTP server with SSL and athentication
May 4, 2021
e6d6b58
Merge branch 'recaptcha'
May 10, 2021
580eb9f
Merge branch 'TOS'
May 10, 2021
5d3ee4b
Merge branch 'domain-restrictions'
May 10, 2021
8e6cfbb
typo
May 10, 2021
ef6e4a6
fixing a merge error
May 10, 2021
c228f00
small mistake
May 10, 2021
dc8224b
fixed mistake in SMTP login invocation
May 10, 2021
8c5e2cb
nicer self-authorization page
May 10, 2021
0fd8a86
Merge branch 'TOS'
May 10, 2021
d5a8f39
switching back to Unicode/str rather than re.Pattern to support
Jun 16, 2021
c628e10
datetime.fromisoformat is not supported in earlier versions of python
Jun 30, 2021
fa895e0
removing unused class to make fluke8 happy
Jul 2, 2021
0de2276
manual date parsing to work with Python v3.6
Jul 2, 2021
51ee519
wqMerge branch 'domain-restrictions'
Sep 20, 2021
ab6cc60
Merge branch 'recaptcha'
Sep 20, 2021
79fccfb
merging
Sep 22, 2021
2eac8c0
Merge branch 'python3.6' into domain-restrictions
Sep 22, 2021
bc430df
backporting tests to python3.6
Sep 22, 2021
29a5cb5
Removing explicit Django dependency by manually including the
Sep 22, 2021
798afbd
removed unnecessary django imports
Sep 22, 2021
223d2b2
adjusting Django style to nativeauthenticator one to make flake8 happy
Sep 22, 2021
d1d0aab
removing the django settings dependency
Sep 22, 2021
5b0a1a9
removing unnecessary import from a test
Sep 22, 2021
4d1127b
utilizing internal crypto modules instead of Django
Sep 22, 2021
e8b862e
Adding django.utils.crypto from the Django project (same version as b…
Sep 22, 2021
11e731c
removed dependency from settings, adjusted style
Sep 22, 2021
8f20e49
made the key non-optional, to avoid dependency on settings
Sep 22, 2021
0969ca1
using locally included crypto module
Sep 22, 2021
66351c7
local copy of django.utils.encoding
Sep 22, 2021
4a63d5c
use local copy of django.utils.encoding
Sep 22, 2021
2198e0b
removed unnecessary parts of Django encodings which tried to pull in …
Sep 22, 2021
ad42b27
fully backporting to python v3.6
Sep 22, 2021
1cf0466
fixing the case in which there is a colon in the timezone specification
Sep 22, 2021
47bd863
removing a leftover index from the variable and the + sign from the t…
Sep 22, 2021
b3821e0
needs to preserve the timezone when creating the time object
Sep 22, 2021
3a94604
splitting and clarifying comments, per flake8 suggestion
Sep 22, 2021
1e0e51f
documenting the server option
Sep 22, 2021
1d9f566
more sensible message in case of self-auth from special domain
Sep 23, 2021
d1f897f
more clear documentation for the self-approval via email
Sep 23, 2021
805f8a2
meaningful error instead of 500 in case the email cannot be sent
Sep 28, 2021
6d83c5a
flake8 styling
Sep 28, 2021
8dd02c3
Merge branch 'master' of https://github.com/jupyterhub/nativeauthenti…
Sep 28, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions docs/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,78 @@ To enable reCAPTCHA on signup, add the following two lines to the configuration
c.Authenticator.recaptcha_secret = "your secret"


Allow self-serve approval
-------------------------

By default all users who sign up on Native Authenticator need an admin approval so
they can actually log in the system. Or you can allow anybody without approval as described
above with `open_signup`. Alternatively, you may want something like `open_signup` but
only for users in your own organization. This is what this option permits.
New users are still created in non-authorized mode, but they can self-authorize by
navigating to a (cryptographic) URL which will be e-mailed to them *only* if the
provided email address matches the specified pattern.
For example, to allow any users who have an mit.edu email address,
you may do the following:

.. code-block:: python

import re
c.Authenticator.allow_self_approval_for = re.compile('[^@]+@mit\.edu$')

Note that this setting automatically enables `ask_email_on_signup`.

To use the code, you must also provide a secret key to cryptographically sign the URL.
To prevents attacks, it is mandatory that this key stays secret.

.. code-block:: python

c.Authenticator.secret_key = "your-key"

You should customize the email sent to users with something like

.. code-block:: python

c.Authenticator.self_approval_email = ("from", "subject", "email body, including https://example.com{approval_url}")

Note that you need to specify the domain where JupyterHub is running (example.com in the example above) and
the port too, if you are using a non-standard one (e.g. 8000). Also the protocol must be the correct one
you are serving your connections from (https in the example).

Moreover, you may specify the SMTP server to use for sending the email. You can do that with

.. code-block:: python

c.Authenticator.self_approval_server = {'url': 'smtp.gmail.com', 'usr': 'myself', 'pwd': 'mypassword'}

If you do not specify a `self_approval_server`, it will attempt to use `localhost` without authentication.

If you wish to use gmail as your SMTP server as in the example above, you must also allow
"less secure apps" for this to work, as described at
https://support.google.com/accounts/answer/6010255 and if you have 2FA enabled you should disable it for
JupyterHub to be able to send emails, as described at https://support.google.com/accounts/answer/185833
See https://stackoverflow.com/questions/16512592/login-credentials-not-working-with-gmail-smtp for additional
gmail-specific SMTP details.

Finally, all of this will correctly create and enable JupyterHub users. However the people wishing to
login as this users, will need to have **also** accounts on the system. If the system where JupyterHub
is running is one of the most common Linux distributions, adding the following to the config file
will automatically create their Linux account the first time they log in JupyterHub. If the system
where JupyterHub is running is another OS, such as BSD or Windows, the corresponding user
creation command must be invoked instead of useradd with the appropriate arguments.

.. code-block:: python

def pre_spawn_hook(spawner):
username = spawner.user.name
try:
import pwd
pwd.getpwnam(username)
except KeyError:
import subprocess
subprocess.check_call(['useradd', '-ms', '/bin/bash', username])
c.Spawner.pre_spawn_hook = pre_spawn_hook


Mandatory acceptance of Terms of Service before SignUp
------------------------------------------------------

Expand Down
27 changes: 27 additions & 0 deletions nativeauthenticator/crypto/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Copyright (c) Django Software Foundation and individual contributors.
All rights reserved.

Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.

3. Neither the name of Django nor the names of its contributors may be used
to endorse or promote products derived from this software without
specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
78 changes: 78 additions & 0 deletions nativeauthenticator/crypto/crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
Django's standard crypto functions and utilities.
"""
import hashlib
import hmac
import secrets

from .encoding import force_bytes


class InvalidAlgorithm(ValueError):
"""Algorithm is not supported by hashlib."""
pass


def salted_hmac(key_salt, value, secret, *, algorithm='sha1'):
"""
Return the HMAC of 'value', using a key generated from key_salt and a
secret. Default algorithm is SHA1,
but any algorithm name supported by hashlib can be passed.

A different key_salt should be passed in for every application of HMAC.
"""

key_salt = force_bytes(key_salt)
secret = force_bytes(secret)
try:
hasher = getattr(hashlib, algorithm)
except AttributeError as e:
raise InvalidAlgorithm(
'%r is not an algorithm accepted by the hashlib module.'
% algorithm
) from e
# We need to generate a derived key from our base key. We can do this by
# passing the key_salt and our base key through a pseudo-random function.
key = hasher(key_salt + secret).digest()
# If len(key_salt + secret) > block size of the hash algorithm, the above
# line is redundant and could be replaced by key = key_salt + secret, since
# the hmac module does the same thing for keys longer than the block size.
# However, we need to ensure that we *always* do this.
return hmac.new(key, msg=force_bytes(value), digestmod=hasher)


RANDOM_STRING_CHARS = \
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'


def get_random_string(length, allowed_chars=RANDOM_STRING_CHARS):
"""
Return a securely generated random string.

The bit length of the returned value can be calculated with the formula:
log_2(len(allowed_chars)^length)

For example, with default `allowed_chars` (26+26+10), this gives:
* length: 12, bit length =~ 71 bits
* length: 22, bit length =~ 131 bits
"""
return ''.join(secrets.choice(allowed_chars) for i in range(length))


def constant_time_compare(val1, val2):
"""Return True if the two strings are equal, False otherwise."""
return secrets.compare_digest(force_bytes(val1), force_bytes(val2))


def pbkdf2(password, salt, iterations, dklen=0, digest=None):
"""Return the hash of password using pbkdf2."""
if digest is None:
digest = hashlib.sha256
dklen = dklen or None
password = force_bytes(password)
salt = force_bytes(salt)
return hashlib.pbkdf2_hmac(digest().name,
password,
salt,
iterations,
dklen)
204 changes: 204 additions & 0 deletions nativeauthenticator/crypto/encoding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import codecs
import datetime
import locale
from decimal import Decimal
from urllib.parse import quote


class DjangoUnicodeDecodeError(UnicodeDecodeError):
def __init__(self, obj, *args):
self.obj = obj
super().__init__(*args)

def __str__(self):
return '%s. You passed in %r (%s)' % (
super().__str__(),
self.obj,
type(self.obj))


_PROTECTED_TYPES = (
type(None),
int, float, Decimal,
datetime.datetime,
datetime.date,
datetime.time,
)


def is_protected_type(obj):
"""Determine if the object instance is of a protected type.

Objects of protected types are preserved as-is when passed to
force_str(strings_only=True).
"""
return isinstance(obj, _PROTECTED_TYPES)


def force_str(s, encoding='utf-8', strings_only=False, errors='strict'):
"""
Similar to smart_str(), except that lazy instances are resolved to
strings, rather than kept as lazy objects.

If strings_only is True, don't convert (some) non-string-like objects.
"""
# Handle the common case first for performance reasons.
if issubclass(type(s), str):
return s
if strings_only and is_protected_type(s):
return s
try:
if isinstance(s, bytes):
s = str(s, encoding, errors)
else:
s = str(s)
except UnicodeDecodeError as e:
raise DjangoUnicodeDecodeError(s, *e.args)
return s


def force_bytes(s, encoding='utf-8', strings_only=False, errors='strict'):
"""
Similar to smart_bytes, except that lazy instances are resolved to
strings, rather than kept as lazy objects.

If strings_only is True, don't convert (some) non-string-like objects.
"""
# Handle the common case first for performance reasons.
if isinstance(s, bytes):
if encoding == 'utf-8':
return s
else:
return s.decode('utf-8', errors).encode(encoding, errors)
if strings_only and is_protected_type(s):
return s
if isinstance(s, memoryview):
return bytes(s)
return str(s).encode(encoding, errors)


# List of byte values that uri_to_iri() decodes from percent encoding.
# First, the unreserved characters from RFC 3986:
_ascii_ranges = [[45, 46, 95, 126], range(65, 91), range(97, 123)]
_hextobyte = {
(fmt % char).encode(): bytes((char,))
for ascii_range in _ascii_ranges
for char in ascii_range
for fmt in ['%02x', '%02X']
}
# And then everything above 128, because bytes ≥ 128 are part of multibyte
# Unicode characters.
_hexdig = '0123456789ABCDEFabcdef'
_hextobyte.update({
(a + b).encode(): bytes.fromhex(a + b)
for a in _hexdig[8:] for b in _hexdig
})


def uri_to_iri(uri):
"""
Convert a Uniform Resource Identifier(URI) into an Internationalized
Resource Identifier(IRI).

This is the algorithm from section 3.2 of RFC 3987, excluding step 4.

Take an URI in ASCII bytes (e.g. '/I%20%E2%99%A5%20Django/') and return
a string containing the encoded result (e.g. '/I%20♥%20Django/').
"""
if uri is None:
return uri
uri = force_bytes(uri)
# Fast selective unquote: First, split on '%' and then starting with the
# second block, decode the first 2 bytes if they represent a hex code to
# decode. The rest of the block is the part after '%AB', not containing
# any '%'. Add that to the output without further processing.
bits = uri.split(b'%')
if len(bits) == 1:
iri = uri
else:
parts = [bits[0]]
append = parts.append
hextobyte = _hextobyte
for item in bits[1:]:
hex = item[:2]
if hex in hextobyte:
append(hextobyte[item[:2]])
append(item[2:])
else:
append(b'%')
append(item)
iri = b''.join(parts)
return repercent_broken_unicode(iri).decode()


def escape_uri_path(path):
"""
Escape the unsafe characters from the path portion of a Uniform Resource
Identifier (URI).
"""
# These are the "reserved" and "unreserved" characters specified in
# sections 2.2 and 2.3 of RFC 2396:
# reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","
# unreserved = alphanum | mark
# mark = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")"
# The list of safe characters here is constructed subtracting ";", "=",
# and "?" according to section 3.3 of RFC 2396.
# The reason for not subtracting and escaping "/" is that we are escaping
# the entire path, not a path segment.
return quote(path, safe="/:@&+$,-_.!~*'()")


def punycode(domain):
"""Return the Punycode of the given domain if it's non-ASCII."""
return domain.encode('idna').decode('ascii')


def repercent_broken_unicode(path):
"""
As per section 3.2 of RFC 3987, step three of converting a URI into an IRI,
repercent-encode any octet produced that is not part of a strictly legal
UTF-8 octet sequence.
"""
while True:
try:
path.decode()
except UnicodeDecodeError as e:
# CVE-2019-14235: A recursion shouldn't be used since the exception
# handling uses massive amounts of memory
repercent = quote(path[e.start:e.end],
safe=b"/#%[]=:;$&()+,!?*@'~")
path = path[:e.start] + repercent.encode() + path[e.end:]
else:
return path


def filepath_to_uri(path):
"""Convert a file system path to a URI portion that is suitable for
inclusion in a URL.

Encode certain chars that would normally be recognized as special chars
for URIs. Do not encode the ' character, as it is a valid character
within URIs. See the encodeURIComponent() JavaScript function for details.
"""
if path is None:
return path
# I know about `os.sep` and `os.altsep` but I want to leave
# some flexibility for hardcoding separators.
return quote(str(path).replace("\\", "/"), safe="/~!*()'")


def get_system_encoding():
"""
The encoding of the default system locale. Fallback to 'ascii' if the
#encoding is unsupported by Python or could not be determined. See tickets
#10335 and #5846.
"""
try:
encoding = locale.getdefaultlocale()[1] or 'ascii'
codecs.lookup(encoding)
except Exception:
encoding = 'ascii'
return encoding


DEFAULT_LOCALE_ENCODING = get_system_encoding()
Loading