Skip to content

Commit ff03c2a

Browse files
Improved API key handling (#3094)
* Allow users to reset their API key Fix #1027 * Only return API key once after it has been generated * Add new UI to reset and display API key How API keys are reset and displayed has changed since the initial version of API keys: Users will be able to view an API key exactly once after it has been created/reset. This requires a slightly different user interface. We’re also planning a few more changes to API keys in the future, and these UI changes prepare for that. * Refactor existing settings screen The existing settings UI was a little cluttered and unstructured. We’re going to add new settings in this PR and in follow-up PRs, so I took the time to clean up the UI (both visually and implementation-wise). * Ensure that toasts are always visible, even when scrolling This is a hacky workaround, but a proper fix would require quite some refactoring. Considering that this hack is pretty isolated and not going to affect any other parts of the UI and that we will need to upgrade to Blueprint 5 at some point anyway, I’ve opted for the quick-and-dirty solution for now. * Do not display password setting when password auth is disabled * Use session tokens for authentication in API tests In the future, roles won’t have an API key by default anymore. As an alternative, we generate session tokens explicitly. * Do not generate API tokens for new roles Most users do not need API access so there’s no reason to generate an API key for them by default. * Handle users without an API key properly in the settings UI Previously, an API was generate automatically for new users, i.e. every user had an API key. This has now changed, and the settings UI needs to properly handle situations where a user doesn’t yet have an API key. As this increases the complexity of the UI state, I’ve refactored the component to make use of a local reducer. * Update wording to clarify that API keys are secrets * Rename "reset_api_key" to "generate_api_key" This method is now also used to generate an initial key for users who do not yet have an API key. * Send email notification when API key is (re-)generated * Extract logic to regenerate API keys into separate module While the logic initially was quite simply, there will be more business logic related to API keys, e.g. sending notifications ahead of and when an API key has expired. * Let API keys expire after 90 days * Extract `generate_api_key` method from role model Initially, I added this to the role model as the model to be consistent with the model's `set_password` method. However, as the logic to generate an API token has become more complex, it is clear that it shouldn't live in the model. * Send notification when API keys are about to expire/have expired * Display API key expiration date in UI * Add CLI command to reset API key expiration of non-expiring API keys * Replace use of deprecated `utcnow` method https://docs.python.org/3/library/datetime.html#datetime.datetime.utcnow * Remove unnecessary keys from API JSON response Aleph represents both users and groups using the role model. However, some API keys (such as `has_password` or `has_api_key` are not relevant for groups). * Add note to remind us to remove/update logic handling legacy API keys * Send API key expiration notifications on a daily basis * Fix Alembic migration order We merged a different migration in the meantime (8adf50) and as a result Alembic wasn’t able to figure out how to upgrade the database unambiguously. * Reauthenticate user in test to update session cache 452e46 changes the way we handle authentication in tests. Previously, we used API keys to authenticate users. Now, as we don’t generate API keys for users by default anymore, we use session tokens (the same mechanism the UI uses as well). In general, authentication using session tokens and API keys is quite similar: In both cases, the token is passed as a request header. However, there’s one significant difference between session tokens and API keys: When using API keys, data about the user (including group memberships) is loaded from the database on every request. When using session tokens, this data is cached in Redis for the lifetime of the session. For end users, this means they have to sign out and in again after their group memberships have been updated. When I originally implemented this change in our tests, it didn’t affect any of the tests as none of them did rely on updating group memberships. However, I’ve since implemented and merged additional tests in #3865, including one test that covers updating the user’s group memberships after the initial sign in. When I rebased this branch to include these new tests, this particular test failed. The solution for this is the same as for end users: In the test case, we need to reauthenticate the test user after updating the group memberships. * Update wording of API key notification emails based on feedback * Use strict equality check * Clarify that API keys expire when generating a new key * Display different UI messages in case the API key has expired * Fix Alembic migration order We merged other migrations in the meantime and as a result Alembic wasn’t able to figure out how to upgrade the database unambiguously.
1 parent d93a04b commit ff03c2a

34 files changed

+1566
-415
lines changed

aleph/logic/api_keys.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import datetime
2+
3+
import structlog
4+
from flask import render_template
5+
from sqlalchemy import and_, or_, func
6+
7+
from aleph.core import db
8+
from aleph.model import Role
9+
from aleph.model.common import make_token
10+
from aleph.logic.mail import email_role
11+
from aleph.logic.roles import update_role
12+
from aleph.logic.util import ui_url
13+
14+
# Number of days after which API keys expire
15+
API_KEY_EXPIRATION_DAYS = 90
16+
17+
# Number of days before an API key expires
18+
API_KEY_EXPIRES_SOON_DAYS = 7
19+
20+
log = structlog.get_logger(__name__)
21+
22+
23+
def generate_user_api_key(role):
24+
event = "regenerated" if role.has_api_key else "generated"
25+
params = {"role": role, "event": event}
26+
plain = render_template("email/api_key_generated.txt", **params)
27+
html = render_template("email/api_key_generated.html", **params)
28+
subject = f"API key {event}"
29+
email_role(role, subject, html=html, plain=plain)
30+
31+
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
32+
role.api_key = make_token()
33+
role.api_key_expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS)
34+
role.api_key_expiration_notification_sent = None
35+
36+
db.session.add(role)
37+
db.session.commit()
38+
update_role(role)
39+
40+
return role.api_key
41+
42+
43+
def send_api_key_expiration_notifications():
44+
_send_api_key_expiration_notification(
45+
days=7,
46+
subject="Your API key will expire in 7 days",
47+
plain_template="email/api_key_expires_soon.txt",
48+
html_template="email/api_key_expires_soon.html",
49+
)
50+
51+
_send_api_key_expiration_notification(
52+
days=0,
53+
subject="Your API key has expired",
54+
plain_template="email/api_key_expired.txt",
55+
html_template="email/api_key_expired.html",
56+
)
57+
58+
59+
def _send_api_key_expiration_notification(
60+
days,
61+
subject,
62+
plain_template,
63+
html_template,
64+
):
65+
now = datetime.date.today()
66+
threshold = now + datetime.timedelta(days=days)
67+
68+
query = Role.all_users()
69+
query = query.yield_per(1000)
70+
query = query.where(
71+
and_(
72+
and_(
73+
Role.api_key != None, # noqa: E711
74+
func.date(Role.api_key_expires_at) <= threshold,
75+
),
76+
or_(
77+
Role.api_key_expiration_notification_sent == None, # noqa: E711
78+
Role.api_key_expiration_notification_sent > days,
79+
),
80+
)
81+
)
82+
83+
for role in query:
84+
expires_at = role.api_key_expires_at
85+
params = {
86+
"role": role,
87+
"expires_at": expires_at,
88+
"settings_url": ui_url("settings"),
89+
}
90+
plain = render_template(plain_template, **params)
91+
html = render_template(html_template, **params)
92+
log.info(f"Sending API key expiration notification: {role} at {expires_at}")
93+
email_role(role, subject, html=html, plain=plain)
94+
95+
query.update({Role.api_key_expiration_notification_sent: days})
96+
db.session.commit()
97+
98+
99+
def reset_api_key_expiration():
100+
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
101+
expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS)
102+
103+
query = Role.all_users()
104+
query = query.yield_per(500)
105+
query = query.where(
106+
and_(
107+
Role.api_key != None, # noqa: E711
108+
Role.api_key_expires_at == None, # noqa: E711
109+
)
110+
)
111+
112+
query.update({Role.api_key_expires_at: expires_at})
113+
db.session.commit()

aleph/manage.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from aleph.queues import get_status, cancel_queue
2121
from aleph.queues import get_active_dataset_status
2222
from aleph.index.admin import delete_index
23+
from aleph.logic.api_keys import reset_api_key_expiration as _reset_api_key_expiration
2324
from aleph.index.entities import iter_proxies
2425
from aleph.index.util import AlephOperationalException
2526
from aleph.logic.collections import create_collection, update_collection
@@ -566,3 +567,9 @@ def evilshit():
566567
delete_index()
567568
destroy_db()
568569
upgrade()
570+
571+
572+
@cli.command()
573+
def reset_api_key_expiration():
574+
"""Reset the expiration date of all legacy, non-expiring API keys."""
575+
_reset_api_key_expiration()

aleph/migrate/versions/131674bde902_add_primary_key_constraint_to_role_membership.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
"""add primary key constraint to role_membership table
22
33
Revision ID: 131674bde902
4-
Revises: c52a1f469ac7
4+
Revises: 8adf50aadcb0
55
Create Date: 2024-07-17 14:37:25.269913
66
77
"""
88

99
# revision identifiers, used by Alembic.
1010
revision = "131674bde902"
11-
down_revision = "c52a1f469ac7"
11+
down_revision = "8adf50aadcb0"
1212

1313
from alembic import op
1414
import sqlalchemy as sa
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""API key expiration
2+
3+
Revision ID: d46fc882ec6b
4+
Revises: 131674bde902
5+
Create Date: 2024-05-02 11:43:50.993948
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
# revision identifiers, used by Alembic.
12+
revision = "d46fc882ec6b"
13+
down_revision = "131674bde902"
14+
15+
16+
def upgrade():
17+
op.add_column("role", sa.Column("api_key_expires_at", sa.DateTime()))
18+
op.add_column(
19+
"role", sa.Column("api_key_expiration_notification_sent", sa.Integer())
20+
)
21+
op.create_index(
22+
index_name="ix_role_api_key_expires_at",
23+
table_name="role",
24+
columns=["api_key_expires_at"],
25+
)
26+
27+
28+
def downgrade():
29+
op.drop_column("role", "api_key_expires_at")
30+
op.drop_column("role", "api_key_expiration_notification_sent")

aleph/model/role.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import logging
2-
from datetime import datetime
2+
from datetime import datetime, timezone
33
from normality import stringify
44
from sqlalchemy import or_, not_, func
55
from itsdangerous import URLSafeTimedSerializer
66
from werkzeug.security import generate_password_hash, check_password_hash
77

88
from aleph.core import db
99
from aleph.settings import SETTINGS
10-
from aleph.model.common import SoftDeleteModel, IdModel, make_token, query_like
10+
from aleph.model.common import SoftDeleteModel, IdModel, query_like
1111
from aleph.util import anonymize_email
1212

1313
log = logging.getLogger(__name__)
@@ -52,6 +52,8 @@ class Role(db.Model, IdModel, SoftDeleteModel):
5252
email = db.Column(db.Unicode, nullable=True)
5353
type = db.Column(db.Enum(*TYPES, name="role_type"), nullable=False)
5454
api_key = db.Column(db.Unicode, nullable=True)
55+
api_key_expires_at = db.Column(db.DateTime, nullable=True)
56+
api_key_expiration_notification_sent = db.Column(db.Integer, nullable=True)
5557
is_admin = db.Column(db.Boolean, nullable=False, default=False)
5658
is_muted = db.Column(db.Boolean, nullable=False, default=False)
5759
is_tester = db.Column(db.Boolean, nullable=False, default=False)
@@ -68,6 +70,10 @@ class Role(db.Model, IdModel, SoftDeleteModel):
6870
def has_password(self):
6971
return self.password_digest is not None
7072

73+
@property
74+
def has_api_key(self):
75+
return self.api_key is not None
76+
7177
@property
7278
def is_public(self):
7379
return self.id in self.public_roles()
@@ -160,11 +166,12 @@ def to_dict(self):
160166
"label": self.label,
161167
"email": self.email,
162168
"locale": self.locale,
163-
"api_key": self.api_key,
164169
"is_admin": self.is_admin,
165170
"is_muted": self.is_muted,
166171
"is_tester": self.is_tester,
167172
"has_password": self.has_password,
173+
"has_api_key": self.has_api_key,
174+
"api_key_expires_at": self.api_key_expires_at,
168175
# 'notified_at': self.notified_at
169176
}
170177
)
@@ -192,6 +199,17 @@ def by_api_key(cls, api_key):
192199
return None
193200
q = cls.all()
194201
q = q.filter_by(api_key=api_key)
202+
utcnow = datetime.now(timezone.utc)
203+
204+
# TODO: Exclude API keys without expiration date after deadline
205+
# See https://github.com/alephdata/aleph/issues/3729
206+
q = q.filter(
207+
or_(
208+
cls.api_key_expires_at == None, # noqa: E711
209+
utcnow < cls.api_key_expires_at,
210+
)
211+
)
212+
195213
q = q.filter(cls.type == cls.USER)
196214
q = q.filter(cls.is_blocked == False) # noqa
197215
return q.first()
@@ -211,9 +229,6 @@ def load_or_create(cls, foreign_id, type_, name, email=None, is_admin=False):
211229
role.is_blocked = False
212230
role.notified_at = datetime.utcnow()
213231

214-
if role.api_key is None:
215-
role.api_key = make_token()
216-
217232
if email is not None:
218233
role.email = email
219234

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{% extends "email/layout.html" %}
2+
3+
{% block content -%}
4+
<p>
5+
{% trans expires_at=(expires_at | datetimeformat) -%}
6+
Your Aleph API key has expired on {{expires_at}} UTC.
7+
{%- endtrans %}
8+
</p>
9+
10+
<p>
11+
{% trans settings_url=settings_url -%}
12+
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 <a href="{{settings_url}}">regenerate your API key</a> to maintain access.
13+
{%- endtrans %}
14+
</p>
15+
{%- endblock %}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{% extends "email/layout.txt" %}
2+
3+
{% block content -%}
4+
{% trans expires_at=(expires_at | datetimeformat) -%}
5+
Your Aleph API key has expired on {{expires_at}} UTC.
6+
{%- endtrans %}
7+
8+
{% trans settings_url=settings_url -%}
9+
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.
10+
{%- endtrans %}
11+
{%- endblock %}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{% extends "email/layout.html" %}
2+
3+
{% block content -%}
4+
<p>
5+
{% trans expires_at=(expires_at | datetimeformat) -%}
6+
Your Aleph API key will expire in 7 days, on {{expires_at}} UTC.
7+
{%- endtrans %}
8+
</p>
9+
10+
<p>
11+
{% trans settings_url=settings_url -%}
12+
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 <a href="{{settings_url}}">regenerate your API key</a> to maintain access.
13+
{%- endtrans %}
14+
</p>
15+
{%- endblock %}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{% extends "email/layout.txt" %}
2+
3+
{% block content -%}
4+
{% trans expires_at=(expires_at | datetimeformat) -%}
5+
Your Aleph API key will expire in 7 days, on {{expires_at}} UTC.
6+
{%- endtrans %}
7+
8+
{% trans -%}
9+
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.
10+
{%- endtrans %}
11+
{%- endblock %}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{% extends "email/layout.html" %}
2+
3+
{% block content -%}
4+
{% if event == "regenerated" -%}
5+
{% trans -%}
6+
Your Aleph API key has been regenerated. If that wasn’t you, please contact an administrator.
7+
{%- endtrans %}
8+
{% else -%}
9+
{% trans -%}
10+
An Aleph API key has been generated for your account. If that wasn’t you, please contact an administrator.
11+
{%- endtrans %}
12+
{%- endif %}
13+
{%- endblock %}

0 commit comments

Comments
 (0)