+ {% trans expires_at=(expires_at | datetimeformat) -%}
+ Your Aleph API key has expired on {{expires_at}} UTC.
+ {%- endtrans %}
+
+
+
+ {% trans settings_url=settings_url -%}
+ If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key to maintain access.
+ {%- endtrans %}
+
+{%- endblock %}
\ No newline at end of file
diff --git a/aleph/templates/email/api_key_expired.txt b/aleph/templates/email/api_key_expired.txt
new file mode 100644
index 0000000000..c2b96ab6d3
--- /dev/null
+++ b/aleph/templates/email/api_key_expired.txt
@@ -0,0 +1,11 @@
+{% extends "email/layout.txt" %}
+
+{% block content -%}
+{% trans expires_at=(expires_at | datetimeformat) -%}
+Your Aleph API key has expired on {{expires_at}} UTC.
+{%- endtrans %}
+
+{% trans settings_url=settings_url -%}
+If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key ({{settings_url}}) to maintain access.
+{%- endtrans %}
+{%- endblock %}
\ No newline at end of file
diff --git a/aleph/templates/email/api_key_expires_soon.html b/aleph/templates/email/api_key_expires_soon.html
new file mode 100644
index 0000000000..d6163e6fd5
--- /dev/null
+++ b/aleph/templates/email/api_key_expires_soon.html
@@ -0,0 +1,15 @@
+{% extends "email/layout.html" %}
+
+{% block content -%}
+
+ {% trans expires_at=(expires_at | datetimeformat) -%}
+ Your Aleph API key will expire in 7 days, on {{expires_at}} UTC.
+ {%- endtrans %}
+
+
+
+ {% trans settings_url=settings_url -%}
+ If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key to maintain access.
+ {%- endtrans %}
+
+{%- endblock %}
\ No newline at end of file
diff --git a/aleph/templates/email/api_key_expires_soon.txt b/aleph/templates/email/api_key_expires_soon.txt
new file mode 100644
index 0000000000..589fcf11f6
--- /dev/null
+++ b/aleph/templates/email/api_key_expires_soon.txt
@@ -0,0 +1,11 @@
+{% extends "email/layout.txt" %}
+
+{% block content -%}
+{% trans expires_at=(expires_at | datetimeformat) -%}
+Your Aleph API key will expire in 7 days, on {{expires_at}} UTC.
+{%- endtrans %}
+
+{% trans -%}
+If you do not use the Aleph API, or only use the API to access public data you can ignore this email. If you use the Aleph API to access data that is not publicly accessible then you’ll need to regenerate your API key ({{settings_url}}) to maintain access.
+{%- endtrans %}
+{%- endblock %}
\ No newline at end of file
diff --git a/aleph/templates/email/api_key_generated.html b/aleph/templates/email/api_key_generated.html
new file mode 100644
index 0000000000..813e82bd5d
--- /dev/null
+++ b/aleph/templates/email/api_key_generated.html
@@ -0,0 +1,13 @@
+{% extends "email/layout.html" %}
+
+{% block content -%}
+{% if event == "regenerated" -%}
+{% trans -%}
+Your Aleph API key has been regenerated. If that wasn’t you, please contact an administrator.
+{%- endtrans %}
+{% else -%}
+{% trans -%}
+An Aleph API key has been generated for your account. If that wasn’t you, please contact an administrator.
+{%- endtrans %}
+{%- endif %}
+{%- endblock %}
\ No newline at end of file
diff --git a/aleph/templates/email/api_key_generated.txt b/aleph/templates/email/api_key_generated.txt
new file mode 100644
index 0000000000..861474ba98
--- /dev/null
+++ b/aleph/templates/email/api_key_generated.txt
@@ -0,0 +1,13 @@
+{% extends "email/layout.txt" %}
+
+{% block content -%}
+{% if event == "regenerated" -%}
+{% trans -%}
+Your Aleph API key has been regenerated. If that wasn’t you, please contact an administrator.
+{%- endtrans %}
+{% else -%}
+{% trans -%}
+An Aleph API key has been generated for your account. If that wasn’t you, please contact an administrator.
+{%- endtrans %}
+{%- endif %}
+{%- endblock %}
\ No newline at end of file
diff --git a/aleph/tests/test_api_keys.py b/aleph/tests/test_api_keys.py
new file mode 100644
index 0000000000..62c494a25f
--- /dev/null
+++ b/aleph/tests/test_api_keys.py
@@ -0,0 +1,195 @@
+import datetime
+import time_machine
+
+from aleph.core import db, mail
+from aleph.logic.api_keys import (
+ generate_user_api_key,
+ send_api_key_expiration_notifications,
+)
+from aleph.tests.util import TestCase
+
+
+class ApiKeysTestCase(TestCase):
+ def test_generate_user_api_key(self):
+ role = self.create_user()
+ assert role.api_key is None
+ assert role.api_key_expires_at is None
+
+ with time_machine.travel("2024-01-01T00:00:00Z"):
+ generate_user_api_key(role)
+ db.session.refresh(role)
+ assert role.api_key is not None
+ assert role.api_key_expires_at.date() == datetime.date(2024, 3, 31)
+
+ old_key = role.api_key
+
+ with time_machine.travel("2024-02-01T00:00:00Z"):
+ generate_user_api_key(role)
+ db.session.refresh(role)
+ assert role.api_key != old_key
+ assert role.api_key_expires_at.date() == datetime.date(2024, 5, 1)
+
+ def test_generate_user_api_key_notification(self):
+ role = self.create_user(email="john.doe@example.org")
+ assert role.api_key is None
+
+ with mail.record_messages() as outbox:
+ assert len(outbox) == 0
+ generate_user_api_key(role)
+ assert len(outbox) == 1
+
+ msg = outbox[0]
+ assert msg.recipients == ["john.doe@example.org"]
+ assert msg.subject == "[Aleph] API key generated"
+ assert "An Aleph API key has been generated for your account" in msg.body
+ assert "An Aleph API key has been generated for your account" in msg.html
+
+ with mail.record_messages() as outbox:
+ assert len(outbox) == 0
+ generate_user_api_key(role)
+ assert len(outbox) == 1
+
+ msg = outbox[0]
+ assert msg.recipients == ["john.doe@example.org"]
+ assert msg.subject == "[Aleph] API key regenerated"
+ assert "Your Aleph API key has been regenerated" in msg.body
+ assert "Your Aleph API key has been regenerated" in msg.html
+
+ def test_send_api_key_expiration_notifications(self):
+ role = self.create_user(email="john.doe@example.org")
+
+ with mail.record_messages() as outbox:
+ with time_machine.travel("2024-01-01T00:00:00Z"):
+ assert len(outbox) == 0
+ generate_user_api_key(role)
+ assert len(outbox) == 1
+ assert outbox[0].subject == "[Aleph] API key generated"
+
+ assert role.api_key is not None
+ assert role.api_key_expires_at.date() == datetime.date(2024, 3, 31)
+
+ assert len(outbox) == 1
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 1
+
+ # A notification is sent 7 days before the notification date
+ with time_machine.travel("2024-03-24T00:00:00Z"):
+ assert len(outbox) == 1
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 2
+
+ msg = outbox[-1]
+ assert msg.recipients == ["john.doe@example.org"]
+ assert msg.subject == "[Aleph] Your API key will expire in 7 days"
+ assert (
+ "Your Aleph API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC."
+ in msg.body
+ )
+ assert (
+ "Your Aleph API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC."
+ in msg.html
+ )
+
+ # The notification is sent only once
+ with time_machine.travel("2024-03-25T00:00:00Z"):
+ assert len(outbox) == 2
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 2
+
+ # Another notification is sent when the key has expired
+ with time_machine.travel("2024-03-31T00:00:00Z"):
+ assert len(outbox) == 2
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 3
+
+ msg = outbox[-1]
+ assert msg.recipients == ["john.doe@example.org"]
+ assert msg.subject == "[Aleph] Your API key has expired"
+ assert (
+ "Your Aleph API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC."
+ in msg.body
+ )
+ assert (
+ "Your Aleph API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC."
+ in msg.html
+ )
+
+ # The notification is sent only once
+ with time_machine.travel("2024-03-31T00:00:00Z"):
+ assert len(outbox) == 3
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 3
+
+ def test_send_api_key_expiration_notifications_no_key(self):
+ role = self.create_user(email="john.doe@example.org")
+ assert role.api_key is None
+
+ with mail.record_messages() as outbox:
+ assert len(outbox) == 0
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 0
+
+ def test_send_api_key_expiration_notifications_delay(self):
+ role = self.create_user(email="john.doe@example.org")
+
+ with mail.record_messages() as outbox:
+ with time_machine.travel("2024-01-01T00:00:00Z"):
+ assert len(outbox) == 0
+ generate_user_api_key(role)
+ assert len(outbox) == 1
+ assert outbox[0].subject == "[Aleph] API key generated"
+
+ # Notifications are sent even if the task that sends them is executed with a delay,
+ # for example 6 days before the key expires
+ with time_machine.travel("2024-03-26T00:00:00Z"):
+ assert len(outbox) == 1
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 2
+
+ msg = outbox[-1]
+ assert msg.recipients == ["john.doe@example.org"]
+ assert msg.subject == "[Aleph] Your API key will expire in 7 days"
+
+ # 1 day after the key has expired
+ with time_machine.travel("2024-04-01T00:00:00Z"):
+ assert len(outbox) == 2
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 3
+
+ msg = outbox[-1]
+ assert msg.recipients == ["john.doe@example.org"]
+ assert msg.subject == "[Aleph] Your API key has expired"
+
+ def test_send_api_key_expiration_notifications_regenerate(self):
+ role = self.create_user(email="john.doe@example.org")
+
+ with mail.record_messages() as outbox:
+ with time_machine.travel("2024-01-01T00:00:00Z"):
+ assert len(outbox) == 0
+ generate_user_api_key(role)
+ assert len(outbox) == 1
+ assert outbox[0].subject == "[Aleph] API key generated"
+
+ # 90 days after generating the initial API key
+ with time_machine.travel("2024-03-31T00:00:00Z"):
+ assert len(outbox) == 1
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 3
+
+ assert outbox[1].subject == "[Aleph] Your API key will expire in 7 days"
+ assert outbox[2].subject == "[Aleph] Your API key has expired"
+
+ # Regenerate the key after it has expired
+ assert len(outbox) == 3
+ generate_user_api_key(role)
+ assert len(outbox) == 4
+ assert outbox[3].subject == "[Aleph] API key regenerated"
+
+ # 90 days after regenerating the API key
+ with time_machine.travel("2024-06-29T00:00:00Z"):
+ assert len(outbox) == 4
+ send_api_key_expiration_notifications()
+ assert len(outbox) == 6
+
+ assert outbox[4].subject == "[Aleph] Your API key will expire in 7 days"
+ assert outbox[5].subject == "[Aleph] Your API key has expired"
diff --git a/aleph/tests/test_groups_api.py b/aleph/tests/test_groups_api.py
index 7287823aa0..a00e4880fc 100644
--- a/aleph/tests/test_groups_api.py
+++ b/aleph/tests/test_groups_api.py
@@ -15,11 +15,24 @@ def setUp(self):
def test_index(self):
res = self.client.get("/api/2/groups")
assert res.status_code == 403, res
+
_, headers = self.login(foreign_id="user_1")
res = self.client.get("/api/2/groups", headers=headers)
assert res.status_code == 200, res
assert res.json["total"] == 2, res.json
validate(res.json["results"][0], "Role")
+ assert set(res.json["results"][0].keys()) == {
+ "id",
+ "created_at",
+ "updated_at",
+ "type",
+ "name",
+ "label",
+ "writeable",
+ "shallow",
+ "links",
+ }
+
_, headers = self.login(foreign_id="other")
res = self.client.get("/api/2/groups", headers=headers)
assert res.status_code == 200, res
diff --git a/aleph/tests/test_permissions_api.py b/aleph/tests/test_permissions_api.py
index 6b3229afc2..113d8936f1 100644
--- a/aleph/tests/test_permissions_api.py
+++ b/aleph/tests/test_permissions_api.py
@@ -94,6 +94,16 @@ def test_update_groups(self):
assert res.json["results"][0]["role"]["id"] == str(self.role.id)
self.role.add_role(group)
+
+ # The user's group memberships are cached in Redis when the user signs in.
+ # As we've updated the group memberships, we need to reauthenticate the
+ # user to update the cache.
+ self.role, self.headers = self.login(
+ foreign_id="john",
+ name="John Doe",
+ email="john.doe@example.org",
+ )
+
res = self.client.put(url, headers=self.headers, json=data)
assert res.status_code == 200
assert len(res.json["results"]) == 2
diff --git a/aleph/tests/test_role_model.py b/aleph/tests/test_role_model.py
index 80e6aa8946..5ad24cc1e9 100644
--- a/aleph/tests/test_role_model.py
+++ b/aleph/tests/test_role_model.py
@@ -1,3 +1,6 @@
+import datetime
+import time_machine
+
from aleph.core import db
from aleph.model import Role
from aleph.tests.factories.models import RoleFactory
@@ -77,3 +80,53 @@ def test_remove_role(self):
# Remove the user from the group
group_role.remove_role(user_role)
assert user_role not in group_role.roles
+
+ def test_role_by_api_key(self):
+ role_ = self.create_user()
+ role_.api_key = "1234567890"
+ db.session.add(role_)
+ db.session.commit()
+
+ role = Role.by_api_key("1234567890")
+ assert role is not None
+ assert role.id == role_.id
+
+ def test_role_by_api_key_empty(self):
+ role_ = self.create_user()
+ assert role_.api_key is None
+
+ role = Role.by_api_key(None)
+ assert role is None
+
+ role = Role.by_api_key("")
+ assert role is None
+
+ def test_role_by_api_key_expired(self):
+ role_ = self.create_user()
+ role_.api_key = "1234567890"
+ role_.api_key_expires_at = datetime.datetime(2024, 3, 31, 0, 0, 0)
+ db.session.add(role_)
+ db.session.commit()
+
+ with time_machine.travel("2024-03-30T23:59:59Z"):
+ print(role_.api_key_expires_at)
+ role = Role.by_api_key(role_.api_key)
+ assert role is not None
+ assert role.id == role_.id
+
+ with time_machine.travel("2024-03-31T00:00:00Z"):
+ role = Role.by_api_key(role_.api_key)
+ assert role is None
+
+ def test_role_by_api_key_legacy_without_expiration(self):
+ # Ensure that legacy API keys that were created without an expiration
+ # date continue to work.
+ role_ = self.create_user()
+ role_.api_key = "1234567890"
+ role_.api_key_expires_at = None
+ db.session.add(role_)
+ db.session.commit()
+
+ role = Role.by_api_key("1234567890")
+ assert role is not None
+ assert role.id == role_.id
diff --git a/aleph/tests/test_roles_api.py b/aleph/tests/test_roles_api.py
index 1ca37f43fa..8a24d21529 100644
--- a/aleph/tests/test_roles_api.py
+++ b/aleph/tests/test_roles_api.py
@@ -1,5 +1,7 @@
import json
+import time_machine
+
from aleph.core import db, mail
from aleph.settings import SETTINGS
from aleph.model import Role
@@ -67,13 +69,80 @@ def test_suggest(self):
assert res.json["total"] == 0
assert len(res.json["results"]) == 0
+ def test_view_auth(self):
+ _, other_headers = self.login(foreign_id="other")
+ role, _ = self.login(
+ foreign_id="john",
+ name="John Doe",
+ email="john@example.org",
+ )
+
+ # Unauthenticated request
+ res = self.client.get(f"/api/2/roles/{role.id}")
+ assert res.status_code == 403
+
+ # Authenticated but unauthorized request
+ res = self.client.get(f"/api/2/roles/{role.id}", headers=other_headers)
+ assert res.status_code == 403
+
def test_view(self):
- res = self.client.get("/api/2/roles/%s" % self.rolex)
- assert res.status_code == 404, res
- role, headers = self.login()
- res = self.client.get("/api/2/roles/%s" % role.id, headers=headers)
- assert res.status_code == 200, res
- # assert res.json['total'] >= 6, res.json
+ role, headers = self.login(
+ foreign_id="john",
+ name="John Doe",
+ email="john@example.org",
+ )
+
+ # Authenticated and authorized request
+ res = self.client.get(f"/api/2/roles/{role.id}", headers=headers)
+ assert res.status_code == 200
+
+ assert set(res.json.keys()) == {
+ "id",
+ "type",
+ "name",
+ "email",
+ "label",
+ "created_at",
+ "updated_at",
+ "is_admin",
+ "is_muted",
+ "is_tester",
+ "has_password",
+ "has_api_key",
+ "counts",
+ "shallow",
+ "writeable",
+ "links",
+ }
+
+ assert res.json["id"] == str(role.id)
+ assert res.json["type"] == "user"
+ assert res.json["name"] == "John Doe"
+ assert res.json["email"] == "john@example.org"
+ assert res.json["label"] == "John Doe "
+ assert res.json["is_admin"] is False
+ assert res.json["shallow"] is False
+ assert res.json["writeable"] is True
+
+ def test_view_api_key(self):
+ role, headers = self.login(foreign_id="john", email="john.doe@example.org")
+
+ res = self.client.get(f"/api/2/roles/{role.id}", headers=headers)
+ assert res.status_code == 200
+ assert res.json["has_api_key"] is False
+ assert "api_key_expires_at" not in res.json
+
+ with time_machine.travel("2024-01-01T00:00:00Z"):
+ res = self.client.post(
+ f"/api/2/roles/{role.id}/generate_api_key",
+ headers=headers,
+ )
+ assert res.status_code == 200
+
+ res = self.client.get(f"/api/2/roles/{role.id}", headers=headers)
+ assert res.status_code == 200
+ assert res.json["has_api_key"] is True
+ assert res.json["api_key_expires_at"] == "2024-03-31T00:00:00"
def test_update(self):
res = self.client.post("/api/2/roles/%s" % self.rolex)
@@ -183,3 +252,65 @@ def test_create_on_existing_email(self):
res = self.client.post("/api/2/roles", data=payload)
self.assertEqual(res.status_code, 409)
+
+ def test_generate_api_key_auth(self):
+ url = f"/api/2/roles/{self.rolex.id}/generate_api_key"
+
+ # Anonymous request
+ res = self.client.post(url)
+ self.assertEqual(res.status_code, 403)
+
+ # Authenticated request, but for a different role
+ _, headers = self.login()
+ res = self.client.post(url, headers=headers)
+ self.assertEqual(res.status_code, 403)
+
+ def test_generate_api_key(self):
+ role, headers = self.login()
+ assert role.api_key is None
+
+ # Generate initial API key for new user
+ url = f"/api/2/roles/{role.id}/generate_api_key"
+ res = self.client.post(url, headers=headers)
+ new_key = res.json["api_key"]
+ assert res.status_code == 200
+ assert new_key is not None
+
+ # The new API key can be used for authentication
+ url = f"/api/2/roles/{role.id}"
+ res = self.client.get(url, headers={"Authorization": new_key})
+ assert res.status_code == 200
+
+ old_key = new_key
+
+ # Regenerate API key
+ url = f"/api/2/roles/{role.id}/generate_api_key"
+ res = self.client.post(url, headers=headers)
+ new_key = res.json["api_key"]
+
+ assert res.status_code == 200
+ assert new_key is not None
+ assert new_key != old_key
+
+ # Old key cannot be used for authentication anymore
+ url = f"/api/2/roles/{role.id}"
+ res = self.client.get(url, headers={"Authorization": old_key})
+ self.assertEqual(res.status_code, 403)
+
+ # New key can be used for authentication
+ res = self.client.get(url, headers={"Authorization": new_key})
+ self.assertEqual(res.status_code, 200)
+
+ def test_new_roles_no_api_key(self):
+ SETTINGS.PASSWORD_LOGIN = True
+ email = "john.doe@example.org"
+ data = {
+ "password": "12345678",
+ "code": Role.SIGNATURE.dumps(email),
+ }
+ res = self.client.post("/api/2/roles", data=data)
+ assert res.status_code == 201
+
+ role = Role.by_email(email)
+ assert role is not None
+ assert role.api_key is None
diff --git a/aleph/tests/test_sessions_api.py b/aleph/tests/test_sessions_api.py
index fcef587cb3..7a36321609 100644
--- a/aleph/tests/test_sessions_api.py
+++ b/aleph/tests/test_sessions_api.py
@@ -11,14 +11,13 @@
from aleph.logic.collections import update_collection
from aleph.views.base_api import _metadata_locale
from aleph.tests.util import TestCase
-from aleph.tests.factories.models import RoleFactory
from aleph.oauth import oauth
class SessionsApiTestCase(TestCase):
def setUp(self):
super().setUp()
- self.role = RoleFactory.create()
+ self.role = self.create_user()
def test_admin_all_access(self):
self.wl = Collection()
@@ -122,6 +121,22 @@ def test_password_login_post_blocked_user(self):
assert res.status_code == 403, res
assert res.json["message"] == "Your account has been blocked."
+ def test_password_login_no_api_key(self):
+ SETTINGS.PASSWORD_LOGIN = True
+ secret = self.fake.password()
+ self.role.set_password(secret)
+ data = {
+ "email": self.role.email,
+ "password": secret,
+ }
+ assert self.role.api_key is None
+
+ res = self.client.post("/api/2/sessions/login", data=data)
+ assert res.status_code == 200
+
+ db.session.refresh(self.role)
+ assert self.role.api_key is None
+
class SessionsApiOAuthTestCase(TestCase):
def setUp(self):
diff --git a/aleph/tests/test_view_context.py b/aleph/tests/test_view_context.py
new file mode 100644
index 0000000000..f6aa460c7a
--- /dev/null
+++ b/aleph/tests/test_view_context.py
@@ -0,0 +1,98 @@
+from aleph.core import db
+from aleph.tests.util import TestCase
+
+
+class ViewContextTest(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.role = self.create_user(email="john.doe@example.org")
+ self.role.set_password("12345678")
+ self.role.api_key = "1234567890"
+
+ self.other_role = self.create_user(
+ foreign_id="other",
+ email="jane.doe@example.org",
+ )
+ assert self.other_role.api_key is None
+
+ db.session.add(self.role)
+ db.session.add(self.other_role)
+ db.session.commit()
+
+ def test_authz_header_session_token(self):
+ data = {
+ "email": "john.doe@example.org",
+ "password": "12345678",
+ }
+ res = self.client.post("/api/2/sessions/login", data=data)
+ assert res.status_code == 200
+ token = res.json["token"]
+
+ headers = {"Authorization": f"Token {token}"}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 200
+ assert res.json["email"] == "john.doe@example.org"
+
+ def test_authz_header_session_token_invalid(self):
+ headers = {"Authorization": "Token INVALID"}
+ res = self.client.get("/api/2/metadata", headers=headers)
+ assert res.status_code == 401
+
+ headers = {"Authorization": "Token INVALID"}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 401
+
+ headers = {"Authorization": "Token "}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 401
+
+ def test_authz_header_api_key(self):
+ headers = {"Authorization": f"ApiKey {self.role.api_key}"}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 200
+ assert res.json["email"] == "john.doe@example.org"
+
+ headers = {"Authorization": self.role.api_key}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 200
+ assert res.json["email"] == "john.doe@example.org"
+
+ def test_authz_header_api_key_invalid(self):
+ # The API behavior is a little weird in this case. When passing an invalid API key we do
+ # not immediately raise an auth error. Instead, we treat the request as an unauthenticated/
+ # anonymous request. Only when trying to access a protected resource, we raise a 403 error.
+ # Keeping this behavior for backwards compatibility.
+ headers = {"Authorization": "ApiKey INVALID"}
+ res = self.client.get("/api/2/metadata", headers=headers)
+ assert res.status_code == 200
+
+ headers = {"Authorization": "ApiKey INVALID"}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 403
+
+ headers = {"Authorization": "ApiKey "}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 403
+
+ headers = {"Authorization": ""}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 403
+
+ headers = {"Authorization": "INVALID"}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", headers=headers)
+ assert res.status_code == 403
+
+ def test_authz_url_param_api_key(self):
+ query_string = {"api_key": self.role.api_key}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string)
+ assert res.status_code == 200
+ assert res.json["email"] == "john.doe@example.org"
+
+ def test_authz_url_params_api_key_invalid(self):
+ query_string = {"api_key": "INVALID"}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string)
+ assert res.status_code == 403
+
+ query_string = {"api_key": ""}
+ res = self.client.get(f"/api/2/roles/{self.role.id}", query_string=query_string)
+ assert res.status_code == 403
diff --git a/aleph/tests/util.py b/aleph/tests/util.py
index ed4bfced58..878fb77661 100644
--- a/aleph/tests/util.py
+++ b/aleph/tests/util.py
@@ -137,7 +137,11 @@ def login(self, foreign_id="tester", name=None, email=None, is_admin=False):
role = self.create_user(
foreign_id=foreign_id, name=name, email=email, is_admin=is_admin
)
- headers = {"Authorization": role.api_key}
+
+ authz = Authz.from_role(role)
+ token = authz.to_token()
+
+ headers = {"Authorization": f"Token {token}"}
return role, headers
def create_collection(self, creator=None, **kwargs):
diff --git a/aleph/views/roles_api.py b/aleph/views/roles_api.py
index a22546ad9c..b15c662d24 100644
--- a/aleph/views/roles_api.py
+++ b/aleph/views/roles_api.py
@@ -11,6 +11,7 @@
from aleph.search import QueryParser, DatabaseQueryResult
from aleph.model import Role
from aleph.logic.roles import challenge_role, update_role, create_user, get_deep_role
+from aleph.logic.api_keys import generate_user_api_key
from aleph.util import is_auto_admin
from aleph.views.serializers import RoleSerializer
from aleph.views.util import require, jsonify, parse_request, obj_or_404
@@ -244,3 +245,40 @@ def update(id):
db.session.commit()
update_role(role)
return RoleSerializer.jsonify(role)
+
+
+@blueprint.route("/api/2/roles//generate_api_key", methods=["POST"])
+def generate_api_key(id):
+ """Reset the role’s API key.
+ ---
+ post:
+ summary: Reset API key
+ description: >
+ Reset the role’s API key. This will invalidate the current
+ API key and generate a new one.
+ parameters:
+ - in: path
+ name: id
+ required: true
+ description: role ID
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Role'
+ tags:
+ - Role
+ """
+ role = obj_or_404(Role.by_id(id))
+ require(request.authz.can_write_role(role.id))
+ api_key = generate_user_api_key(role)
+ data = RoleSerializer().serialize(role)
+ # The API key usually isn’t included in API responses, but we return it
+ # exactly once after it has been (re-)generated.
+ data["api_key"] = api_key
+
+ return jsonify(data)
diff --git a/aleph/views/serializers.py b/aleph/views/serializers.py
index fb9e7856dd..2b3e3f0aa7 100644
--- a/aleph/views/serializers.py
+++ b/aleph/views/serializers.py
@@ -118,19 +118,27 @@ def _serialize(self, obj):
obj["shallow"] = obj.get("shallow", True)
if self.nested or not obj["writeable"]:
obj.pop("has_password", None)
+ obj.pop("has_api_key", None)
+ obj.pop("api_key_expires_at", None)
obj.pop("is_admin", None)
obj.pop("is_muted", None)
obj.pop("is_tester", None)
obj.pop("is_blocked", None)
- obj.pop("api_key", None)
obj.pop("email", None)
obj.pop("locale", None)
obj.pop("created_at", None)
obj.pop("updated_at", None)
if obj["type"] != Role.USER:
- obj.pop("api_key", None)
+ obj.pop("has_password", None)
+ obj.pop("has_api_key", None)
+ obj.pop("api_key_expires_at", None)
+ obj.pop("is_admin", None)
+ obj.pop("is_muted", None)
+ obj.pop("is_tester", None)
+ obj.pop("is_blocked", None)
obj.pop("email", None)
obj.pop("locale", None)
+ obj.pop("api_key", None)
obj.pop("password", None)
return obj
diff --git a/aleph/worker.py b/aleph/worker.py
index 5818e5ad02..220ccaabe7 100644
--- a/aleph/worker.py
+++ b/aleph/worker.py
@@ -18,6 +18,7 @@
from aleph.model import Collection
from aleph.queues import get_rate_limit
from aleph.logic.alerts import check_alerts
+from aleph.logic.api_keys import send_api_key_expiration_notifications
from aleph.logic.collections import reingest_collection, reindex_collection
from aleph.logic.collections import compute_collections, refresh_collection
from aleph.logic.notifications import generate_digest, delete_old_notifications
@@ -152,6 +153,7 @@ def periodic(self):
update_roles()
check_alerts()
generate_digest()
+ send_api_key_expiration_notifications()
delete_expired_exports()
delete_old_notifications()
diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js
index 48b2fb67e2..3e8ed2f550 100644
--- a/ui/src/actions/index.js
+++ b/ui/src/actions/index.js
@@ -1,6 +1,12 @@
import { createAction } from 'redux-act';
-export { queryRoles, fetchRole, suggestRoles, updateRole } from './roleActions';
+export {
+ queryRoles,
+ fetchRole,
+ suggestRoles,
+ updateRole,
+ generateApiKey,
+} from './roleActions';
export { createAlert, deleteAlert, queryAlerts } from './alertActions';
export { queryNotifications } from './notificationActions';
export { setConfigValue, dismissHint } from './configActions';
diff --git a/ui/src/actions/roleActions.js b/ui/src/actions/roleActions.js
index fd0c93d378..7ba2c30728 100644
--- a/ui/src/actions/roleActions.js
+++ b/ui/src/actions/roleActions.js
@@ -32,3 +32,11 @@ export const updateRole = asyncActionCreator(
},
{ name: 'UPDATE_ROLE' }
);
+
+export const generateApiKey = asyncActionCreator(
+ (role) => async () => {
+ const response = await endpoint.post(`roles/${role.id}/generate_api_key`);
+ return { id: role.id, data: response.data };
+ },
+ { name: 'GENERATE_API_KEY' }
+);
diff --git a/ui/src/app/App.scss b/ui/src/app/App.scss
index 064f045dfb..cb82bce4f1 100644
--- a/ui/src/app/App.scss
+++ b/ui/src/app/App.scss
@@ -141,8 +141,11 @@ a {
);
}
-/* This causes toasts to appear *below* the navbar instead of on top of it */
.aleph-toaster {
+ /* Hacky workaround to ensure toasts are visible even when scrolling */
+ position: fixed !important;
+
+ /* This causes toasts to appear *below* the navbar instead of on top of it */
margin-top: $aleph-grid-size * 5;
}
diff --git a/ui/src/components/Settings/ApiKeySettings.tsx b/ui/src/components/Settings/ApiKeySettings.tsx
new file mode 100644
index 0000000000..0c07a61c12
--- /dev/null
+++ b/ui/src/components/Settings/ApiKeySettings.tsx
@@ -0,0 +1,254 @@
+import { useReducer } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import { Button, Intent, Dialog, DialogBody, Card } from '@blueprintjs/core';
+import { ClipboardInput } from 'components/common';
+import { selectCurrentRole } from 'selectors';
+import { generateApiKey as generateApiKeyAction } from 'actions';
+import { FormattedMessage, FormattedDate, FormattedTime } from 'react-intl';
+import convertUTCDateToLocalDate from 'util/convertUTCDateToLocalDate';
+
+const GENERATE_MESSAGE = (
+
+);
+
+const REGENERATE_MESSAGE = (
+
+);
+
+type State = {
+ state:
+ | 'START'
+ | 'REGENERATE_CONFIRMATION'
+ | 'REGENERATE_LOADING'
+ | 'REGENERATE_DONE'
+ | 'GENERATE_LOADING'
+ | 'GENERATE_DONE';
+ apiKey?: string;
+};
+
+type RegenerateConfirmAction = { type: 'REGENERATE_CONFIRM' };
+type RegenerateStartAction = { type: 'REGENERATE_START' };
+type RegenerateEndAction = { type: 'REGENERATE_END'; apiKey: string };
+type GenerateStartAction = { type: 'GENERATE_START' };
+type GenerateEndAction = { type: 'GENERATE_END'; apiKey: string };
+type CloseAction = { type: 'CLOSE' };
+
+type Action =
+ | RegenerateConfirmAction
+ | RegenerateStartAction
+ | RegenerateEndAction
+ | GenerateStartAction
+ | GenerateEndAction
+ | CloseAction;
+
+const STATE_TRANSITIONS: Record = {
+ REGENERATE_CONFIRM: 'REGENERATE_CONFIRMATION',
+ REGENERATE_START: 'REGENERATE_LOADING',
+ REGENERATE_END: 'REGENERATE_DONE',
+ GENERATE_START: 'GENERATE_LOADING',
+ GENERATE_END: 'GENERATE_DONE',
+ CLOSE: 'START',
+};
+
+function reducer(current: State, action: Action): State {
+ const next = {
+ state: STATE_TRANSITIONS[action.type],
+ apiKey: current.apiKey,
+ };
+
+ if (action.type === 'REGENERATE_END' || action.type === 'GENERATE_END') {
+ next.apiKey = action.apiKey;
+ }
+
+ if (action.type === 'CLOSE') {
+ next.apiKey = undefined;
+ }
+
+ return next;
+}
+
+export default function ApiKeySettings() {
+ const reduxDispatch = useDispatch();
+
+ const role = useSelector(selectCurrentRole);
+ const [{ state, apiKey }, dispatch] = useReducer(reducer, { state: 'START' });
+
+ const generateApiKey = async () => {
+ const { data } = await reduxDispatch(generateApiKeyAction(role));
+ return data.api_key;
+ };
+
+ const onGenerate = async () => {
+ dispatch({ type: 'GENERATE_START' });
+ const apiKey = await generateApiKey();
+ dispatch({ type: 'GENERATE_END', apiKey });
+ };
+
+ const onRegenerate = () => {
+ dispatch({ type: 'REGENERATE_CONFIRM' });
+ };
+
+ const onConfirm = async () => {
+ dispatch({ type: 'REGENERATE_START' });
+ const apiKey = await generateApiKey();
+ dispatch({ type: 'REGENERATE_END', apiKey });
+ };
+
+ const onClose = async () => {
+ dispatch({ type: 'CLOSE' });
+ };
+
+ const expiresAt =
+ role.api_key_expires_at &&
+ convertUTCDateToLocalDate(new Date(role.api_key_expires_at));
+ const now = new Date();
+ const hasExpired = expiresAt <= now;
+
+ return (
+ <>
+
+