Skip to content
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

Improved API key handling #3094

Merged
merged 29 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1ac583f
Allow users to reset their API key
tillprochaska May 25, 2023
a2a94ae
Only return API key once after it has been generated
tillprochaska Apr 11, 2024
399e85f
Add new UI to reset and display API key
tillprochaska Apr 15, 2024
5d96021
Refactor existing settings screen
tillprochaska Apr 15, 2024
b13425c
Ensure that toasts are always visible, even when scrolling
tillprochaska Apr 16, 2024
8065afa
Do not display password setting when password auth is disabled
tillprochaska Apr 16, 2024
688bb96
Use session tokens for authentication in API tests
tillprochaska Apr 30, 2024
b3eac7f
Do not generate API tokens for new roles
tillprochaska May 2, 2024
d7420be
Handle users without an API key properly in the settings UI
tillprochaska May 1, 2024
0622c84
Update wording to clarify that API keys are secrets
tillprochaska May 1, 2024
6f03791
Rename "reset_api_key" to "generate_api_key"
tillprochaska May 1, 2024
be5f029
Send email notification when API key is (re-)generated
tillprochaska May 2, 2024
f30757f
Extract logic to regenerate API keys into separate module
tillprochaska May 2, 2024
e1ed9b6
Let API keys expire after 90 days
tillprochaska May 6, 2024
a506ebe
Extract `generate_api_key` method from role model
tillprochaska May 6, 2024
6cc615b
Send notification when API keys are about to expire/have expired
tillprochaska May 6, 2024
6d2d89b
Display API key expiration date in UI
tillprochaska May 6, 2024
87ce636
Add CLI command to reset API key expiration of non-expiring API keys
tillprochaska May 6, 2024
5e52e2d
Replace use of deprecated `utcnow` method
tillprochaska May 8, 2024
c091223
Remove unnecessary keys from API JSON response
tillprochaska May 8, 2024
9a42b22
Add note to remind us to remove/update logic handling legacy API keys
tillprochaska May 8, 2024
b9ad42e
Send API key expiration notifications on a daily basis
tillprochaska May 8, 2024
c0ad86d
Fix Alembic migration order
tillprochaska Oct 22, 2024
1e74da4
Reauthenticate user in test to update session cache
tillprochaska Oct 23, 2024
f95572a
Update wording of API key notification emails based on feedback
tillprochaska Oct 23, 2024
de6eeff
Use strict equality check
tillprochaska Oct 23, 2024
9b9efa9
Clarify that API keys expire when generating a new key
tillprochaska Oct 23, 2024
50b689e
Display different UI messages in case the API key has expired
tillprochaska Nov 4, 2024
987b907
Fix Alembic migration order
tillprochaska Nov 4, 2024
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
Prev Previous commit
Next Next commit
Send notification when API keys are about to expire/have expired
  • Loading branch information
tillprochaska committed Nov 4, 2024
commit 6cc615b0cc51cbebcff11458a35381e33011e430
62 changes: 62 additions & 0 deletions aleph/logic/api_keys.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import datetime

from flask import render_template
from sqlalchemy import and_, or_, func

from aleph.core import db
from aleph.model import Role
from aleph.model.common import make_token
from aleph.logic.mail import email_role
from aleph.logic.roles import update_role
from aleph.logic.util import ui_url

# Number of days after which API keys expire
API_KEY_EXPIRATION_DAYS = 90

# Number of days before an API key expires
API_KEY_EXPIRES_SOON_DAYS = 7


def generate_user_api_key(role):
event = "regenerated" if role.has_api_key else "generated"
Expand All @@ -21,9 +28,64 @@ def generate_user_api_key(role):
now = datetime.datetime.utcnow()
role.api_key = make_token()
role.api_key_expires_at = now + datetime.timedelta(days=API_KEY_EXPIRATION_DAYS)
role.api_key_expiration_notification_sent = None

db.session.add(role)
db.session.commit()
update_role(role)

return role.api_key


def send_api_key_expiration_notifications():
_send_api_key_expiration_notification(
days=7,
subject="Your API key will expire in 7 days",
plain_template="email/api_key_expires_soon.txt",
html_template="email/api_key_expires_soon.html",
)

_send_api_key_expiration_notification(
days=0,
subject="Your API key has expired",
plain_template="email/api_key_expired.txt",
html_template="email/api_key_expired.html",
)


def _send_api_key_expiration_notification(
days,
subject,
plain_template,
html_template,
):
now = datetime.date.today()
threshold = now + datetime.timedelta(days=days)

query = Role.all_users()
query = query.yield_per(1000)
query = query.where(
and_(
and_(
Role.api_key != None, # noqa: E711
func.date(Role.api_key_expires_at) <= threshold,
),
or_(
Role.api_key_expiration_notification_sent == None, # noqa: E711
Role.api_key_expiration_notification_sent > days,
),
)
)

for role in query:
params = {
"role": role,
"expires_at": role.api_key_expires_at,
"settings_url": ui_url("settings"),
}
plain = render_template(plain_template, **params)
html = render_template(html_template, **params)
email_role(role, subject, html=html, plain=plain)

query.update({Role.api_key_expiration_notification_sent: days})
db.session.commit()
9 changes: 9 additions & 0 deletions aleph/migrate/versions/d46fc882ec6b_api_key_expiration.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@

def upgrade():
op.add_column("role", sa.Column("api_key_expires_at", sa.DateTime()))
op.add_column(
"role", sa.Column("api_key_expiration_notification_sent", sa.Integer())
)
op.create_index(
index_name="ix_role_api_key_expires_at",
table_name="role",
columns=["api_key_expires_at"],
)


def downgrade():
op.drop_column("role", "api_key_expires_at")
op.drop_column("role", "api_key_expiration_notification_sent")
1 change: 1 addition & 0 deletions aleph/model/role.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class Role(db.Model, IdModel, SoftDeleteModel):
type = db.Column(db.Enum(*TYPES, name="role_type"), nullable=False)
api_key = db.Column(db.Unicode, nullable=True)
api_key_expires_at = db.Column(db.DateTime, nullable=True)
api_key_expiration_notification_sent = db.Column(db.Integer, nullable=True)
is_admin = db.Column(db.Boolean, nullable=False, default=False)
is_muted = db.Column(db.Boolean, nullable=False, default=False)
is_tester = db.Column(db.Boolean, nullable=False, default=False)
Expand Down
15 changes: 15 additions & 0 deletions aleph/templates/email/api_key_expired.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "email/layout.html" %}

{% block content -%}
<p>
{% trans expires_at=(expires_at | datetimeformat) -%}
Your API key has expired on {{expires_at}} UTC.
{%- endtrans %}
</p>

<p>
{% trans settings_url=settings_url -%}
If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to <a href="{{settings_url}}">regenerate your API key</a> and update any applications or scripts that use your API key.
{%- endtrans %}
</p>
{%- endblock %}
11 changes: 11 additions & 0 deletions aleph/templates/email/api_key_expired.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% extends "email/layout.txt" %}

{% block content -%}
{% trans expires_at=(expires_at | datetimeformat) -%}
Your API key has expired on {{expires_at}} UTC.
{%- endtrans %}

{% trans settings_url=settings_url -%}
If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key ({{settings_url}}) and update any applications or scripts that use your API key.
{%- endtrans %}
{%- endblock %}
15 changes: 15 additions & 0 deletions aleph/templates/email/api_key_expires_soon.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "email/layout.html" %}

{% block content -%}
<p>
{% trans expires_at=(expires_at | datetimeformat) -%}
Your API key will expire in 7 days, on {{expires_at}} UTC.
{%- endtrans %}
</p>

<p>
{% trans settings_url=settings_url -%}
If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to <a href="{{settings_url}}">regenerate your API key</a> and update any applications or scripts that use your API key.
{%- endtrans %}
</p>
{%- endblock %}
11 changes: 11 additions & 0 deletions aleph/templates/email/api_key_expires_soon.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% extends "email/layout.txt" %}

{% block content -%}
{% trans expires_at=(expires_at | datetimeformat) -%}
Your API key will expire in 7 days, on {{expires_at}} UTC.
{%- endtrans %}

{% trans -%}
If you do not use the Aleph API you can ignore this email. If you use the Aleph API you will need to regenerate your API key ({{settings_url}}) and update any applications or scripts that use your API key.
{%- endtrans %}
{%- endblock %}
144 changes: 143 additions & 1 deletion aleph/tests/test_api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
import time_machine

from aleph.core import db, mail
from aleph.logic.api_keys import generate_user_api_key
from aleph.logic.api_keys import (
generate_user_api_key,
send_api_key_expiration_notifications,
)
from aleph.tests.util import TestCase


Expand Down Expand Up @@ -51,3 +54,142 @@ def test_generate_user_api_key_notification(self):
assert msg.subject == "[Aleph] API key regenerated"
assert "Your API key has been regenerated" in msg.body
assert "Your 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 API key will expire in 7 days, on Mar 31, 2024, 12:00:00\u202fAM UTC."
in msg.body
)
assert (
"Your 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 API key has expired on Mar 31, 2024, 12:00:00\u202fAM UTC."
in msg.body
)
assert (
"Your 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"