Skip to content

Commit

Permalink
HTTP Basic auth mode, closes #15
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Mar 19, 2021
1 parent 277bace commit 62000ff
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 1 deletion.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ You can customize the actor that will be used for a username by including an `"a
}
}
```
### HTTP Basic authentication option

This plugin defaults to implementing login using an HTML form that sets a signed authentication cookie.

You can alternatively configure it to use [HTTP Basic authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#basic_authentication_scheme) instead.

Do this by adding `"http_basic_auth": true` to the `datasette-auth-passwords` block in your plugin configuration.

This option introduces the following behaviour:

- Account usernames and passwords are configured in the same way as form-based authentication
- Every page within Datasette - even pages that normally do not use authentication, such as static assets - will display a browser login prompt
- Users will be unable to log out without closing their browser entirely

### Using with datasette publish

Expand Down
36 changes: 35 additions & 1 deletion datasette_auth_passwords/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datasette import hookimpl
from datasette.utils.asgi import Response
from .utils import hash_password, verify_password
from .utils import hash_password, verify_password, scope_has_valid_authorization
from functools import wraps


async def password_tool(request, datasette):
Expand Down Expand Up @@ -58,3 +59,36 @@ def register_routes():
(r"^/-/password-tool$", password_tool),
(r"^/-/login$", password_login),
]


@hookimpl
def asgi_wrapper(datasette):
config = datasette.plugin_config("datasette-auth-passwords") or {}
if not config.get("http_basic_auth"):
return lambda asgi: asgi

def wrap(app):
@wraps(app)
async def require_authorization(scope, recieve, send):
if scope["type"] == "http":
actor = scope_has_valid_authorization(scope, datasette)
if actor is None:
return await Response.text(
"401 Authorization Required",
headers={
"www-authenticate": 'Basic realm="Datasette", charset="UTF-8"'
},
status=401,
).asgi_send(send)
await app(scope, recieve, send)

return require_authorization

return wrap


@hookimpl
def actor_from_request(datasette, request):
actor = scope_has_valid_authorization(request.scope, datasette)
if actor is not None:
return actor
24 changes: 24 additions & 0 deletions datasette_auth_passwords/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,27 @@ def verify_password(password, password_hash):
assert algorithm == ALGORITHM
compare_hash = hash_password(password, salt, iterations)
return secrets.compare_digest(password_hash, compare_hash)


def scope_has_valid_authorization(scope, datasette):
config = datasette.plugin_config("datasette-auth-passwords") or {}
accounts = {
key.split("_password_hash")[0]: value
for key, value in config.items()
if key.endswith("_password_hash")
}
actors = config.get("actors") or {}
headers = dict(scope.get("headers") or {})
authorization = headers.get(b"authorization") or b""
if not authorization.startswith(b"Basic "):
return None
credentials = authorization.split(b"Basic ")[1]
decoded = base64.b64decode(credentials).decode("ascii")
username, _, password = decoded.partition(":")
password_hash = accounts.get(username)
if password_hash and verify_password(password, password_hash):
print("verified")
return actors.get(username) or {"id": username}
else:
print("no match", accounts, username, password, password_hash)
return None
43 changes: 43 additions & 0 deletions tests/test_http_basic_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from datasette.app import Datasette
from datasette_auth_passwords import utils
import pytest
import httpx

# "password!"
PASSWORD_HASH = "pbkdf2_sha256$260000$a9bb87a3e9d968847a36c50cf1a4ac3d$UO1DUqulWhRLj8UZrnViiu6KaKn0C5M9IZKWB4R9JX4="

TEST_METADATA = {
"plugins": {
"datasette-auth-passwords": {
"actors": {"user1": {"id": "userone", "name": "User 1"}},
"user1_password_hash": PASSWORD_HASH,
"user2_password_hash": PASSWORD_HASH,
"http_basic_auth": True,
}
}
}


@pytest.mark.asyncio
@pytest.mark.parametrize(
"username,password,should_login",
[
("user1", "password!", True),
("user1", "password", False),
("user2", "", False),
("user2", "password!", True),
("user3", "password!", False),
],
)
@pytest.mark.parametrize("path", ("/", "/-/404"))
async def test_basic_auth_login(path, username, password, should_login):
ds = Datasette([], memory=True, metadata=TEST_METADATA)
# Anonymous should 401
anon_response = await ds.client.get(path)
assert anon_response.status_code == 401
# Try again with authorization header
auth_response = await ds.client.get(path, auth=(username, password))
if should_login:
assert auth_response.status_code in (200, 404)
else:
assert auth_response.status_code == 401

0 comments on commit 62000ff

Please sign in to comment.