Skip to content

Commit

Permalink
Add API token management (needs IP validation)
Browse files Browse the repository at this point in the history
  • Loading branch information
mxsasha committed May 5, 2023
1 parent b828eaa commit 231cf5f
Show file tree
Hide file tree
Showing 9 changed files with 462 additions and 33 deletions.
49 changes: 49 additions & 0 deletions irrd/storage/alembic/versions/a75d0bd6c9f2_add_api_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""add_api_tokens
Revision ID: a75d0bd6c9f2
Revises: 5bbbc2989aa6
Create Date: 2023-05-04 21:09:08.591476
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = "a75d0bd6c9f2"
down_revision = "5bbbc2989aa6"
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"auth_api_token",
sa.Column(
"pk", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column(
"token", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=True
),
sa.Column("name", sa.String(), nullable=False),
sa.Column("creator_id", postgresql.UUID(), nullable=True),
sa.Column("mntner_id", postgresql.UUID(), nullable=True),
sa.Column("ip_restriction", postgresql.CIDR(), nullable=True),
sa.Column("enabled_webapi", sa.Boolean(), nullable=False),
sa.Column("enabled_email", sa.Boolean(), nullable=False),
sa.Column("created", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["creator_id"], ["auth_user.pk"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["mntner_id"], ["auth_mntner.pk"], ondelete="RESTRICT"),
sa.PrimaryKeyConstraint("pk"),
)
op.create_index(op.f("ix_auth_api_token_creator_id"), "auth_api_token", ["creator_id"], unique=False)
op.create_index(op.f("ix_auth_api_token_mntner_id"), "auth_api_token", ["mntner_id"], unique=False)
op.create_index(op.f("ix_auth_api_token_token"), "auth_api_token", ["token"], unique=True)


def downgrade():
op.drop_index(op.f("ix_auth_api_token_token"), table_name="auth_api_token")
op.drop_index(op.f("ix_auth_api_token_mntner_id"), table_name="auth_api_token")
op.drop_index(op.f("ix_auth_api_token_creator_id"), table_name="auth_api_token")
op.drop_table("auth_api_token")
44 changes: 28 additions & 16 deletions irrd/storage/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,6 @@ class AuthPermission(Base): # type: ignore
user_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT"), index=True)
mntner_id = sa.Column(pg.UUID, sa.ForeignKey("auth_mntner.pk", ondelete="RESTRICT"), index=True)

# This may not scale well
user_management = sa.Column(sa.Boolean, default=False, nullable=False)

created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)
Expand Down Expand Up @@ -300,7 +299,6 @@ class AuthUser(Base): # type: ignore

active = sa.Column(sa.Boolean, default=False, nullable=False)
override = sa.Column(sa.Boolean, default=False, nullable=False)
# api_tokens = relationship("AuthApiToken", backref="user")

permissions = relationship(
"AuthPermission",
Expand Down Expand Up @@ -383,19 +381,34 @@ class AuthWebAuthn(Base): # type: ignore
last_used = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)


# class AuthApiToken(Base): # type: ignore
# __tablename__ = "auth_api_token"
#
# token = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True)
# user_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT"))
# # IP range?
# # submission method
#
# created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)
# updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)
#
# def __repr__(self):
# return f"<{self.pk}/{self.email}"
class AuthApiToken(Base): # type: ignore
__tablename__ = "auth_api_token"

pk = sa.Column(pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), primary_key=True)
token = sa.Column(
pg.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), unique=True, index=True
)
name = sa.Column(sa.String, nullable=False)
creator_id = sa.Column(pg.UUID, sa.ForeignKey("auth_user.pk", ondelete="RESTRICT"), index=True)
mntner_id = sa.Column(pg.UUID, sa.ForeignKey("auth_mntner.pk", ondelete="RESTRICT"), index=True)
creator = relationship(
"AuthUser",
backref=sa.orm.backref("api_tokens_created"),
)
mntner = relationship(
"AuthMntner",
backref=sa.orm.backref("api_tokens"),
)

ip_restriction = sa.Column(pg.CIDR, nullable=True)
enabled_webapi = sa.Column(sa.Boolean(), default=True, nullable=False)
enabled_email = sa.Column(sa.Boolean(), default=True, nullable=False)

created = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)
updated = sa.Column(sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)

def __repr__(self):
return f"<{self.token}/{self.name}/{self.maintainer}"


class AuthMntner(Base): # type: ignore
Expand All @@ -414,7 +427,6 @@ class AuthMntner(Base): # type: ignore

migration_token = sa.Column(sa.String, nullable=True)

# permissions = relationship("AuthPermission", backref='mntner')
permissions = relationship(
"AuthPermission",
backref=sa.orm.backref("mntner", uselist=False),
Expand Down
11 changes: 11 additions & 0 deletions irrd/utils/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from webauthn import base64url_to_bytes

from irrd.storage.models import (
AuthApiToken,
AuthMntner,
AuthPermission,
AuthUser,
Expand Down Expand Up @@ -73,3 +74,13 @@ class AuthPermissionFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = AuthPermission
sqlalchemy_session_persistence = "commit"


class AuthApiTokenFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = AuthApiToken
sqlalchemy_session_persistence = "commit"

name = factory.Sequence(lambda n: "API token %s" % n)
enabled_webapi = True
enabled_email = True
148 changes: 147 additions & 1 deletion irrd/webui/endpoints_mntners.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
import wtforms
from starlette.requests import Request
from starlette.responses import RedirectResponse, Response
from starlette_wtf import StarletteForm, csrf_protect
from starlette_wtf import StarletteForm, csrf_protect, csrf_token

from irrd.conf import get_setting
from irrd.rpsl.rpsl_objects import RPSLMntner
from irrd.storage.models import (
AuthApiToken,
AuthMntner,
AuthPermission,
AuthUser,
Expand All @@ -27,6 +28,151 @@
logger = logging.getLogger(__name__)


class ApiTokenForm(StarletteForm):
name = wtforms.StringField(
"Name of the new token",
validators=[wtforms.validators.DataRequired()],
description="For your own reference.",
)
# TODO: cidr validator
ip_restriction = wtforms.StringField("Restrict to IP range (CIDR, optional)")
enabled_webapi = wtforms.BooleanField("Enable this token for HTTPS API submissions")
enabled_email = wtforms.BooleanField("Enable this token for email submissions")
submit = wtforms.SubmitField("Save API token")

def validate_ip_restriction(form, field):
if not field.data:
field.data = None


@csrf_protect
@session_provider_manager
@authentication_required
async def api_token_add(request: Request, session_provider: ORMSessionProvider) -> Response:
"""
Create a new API token.
"""
query = session_provider.session.query(AuthMntner).join(AuthPermission)
query = query.filter(
AuthMntner.pk == request.path_params["mntner"],
AuthPermission.user_id == str(request.auth.user.pk),
)
mntner = await session_provider.run(query.one)

if not mntner or not mntner.migration_complete:
return Response(status_code=404)

form = await ApiTokenForm.from_formdata(request=request)
if not form.is_submitted() or not await form.validate():
form_html = render_form(form)
return template_context_render(
"api_token_form.html", request, {"form_html": form_html, "api_token": None, "mntner": mntner}
)

new_token = AuthApiToken(
mntner_id=str(mntner.pk),
creator_id=str(request.auth.user.pk),
name=form.data["name"],
ip_restriction=form.data["ip_restriction"],
enabled_webapi=form.data["enabled_webapi"],
enabled_email=form.data["enabled_email"],
)
session_provider.session.add(new_token)
message_text = f"An API key for '{new_token.name}' on {mntner.rpsl_mntner_pk} has been added."
message(request, message_text)
await notify_mntner(session_provider, request.auth.user, mntner, explanation=message_text)
logger.info(
f"{client_ip(request)}{request.auth.user.email}: added API token {new_token.pk} on mntner"
f" {mntner.rpsl_mntner_pk}"
)

return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302)


@csrf_protect
@session_provider_manager
@authentication_required
async def api_token_edit(request: Request, session_provider: ORMSessionProvider) -> Response:
"""
Edit an existing API token
"""
query = session_provider.session.query(AuthApiToken)
query = query.filter(
AuthApiToken.pk == request.path_params["token_pk"],
AuthApiToken.mntner_id.in_([perm.mntner_id for perm in request.auth.user.permissions]),
)
api_token = await session_provider.run(query.one)

if not api_token:
return Response(status_code=404)

form = await ApiTokenForm.from_formdata(request=request, obj=api_token)
if not form.is_submitted() or not await form.validate():
form_html = render_form(form)
return template_context_render(
"api_token_form.html",
request,
{"form_html": form_html, "api_token": api_token, "mntner": api_token.mntner},
)

api_token.name = form.data["name"]
api_token.ip_restriction = form.data["ip_restriction"]
api_token.enabled_webapi = form.data["enabled_webapi"]
api_token.enabled_email = form.data["enabled_email"]
session_provider.session.add(api_token)
message_text = f"The API key for '{api_token.name}' on {api_token.mntner.rpsl_mntner_pk} was modified."
message(request, message_text)
logger.info(
f"{client_ip(request)}{request.auth.user.email}: updated API token {api_token.pk} on mntner"
f" {api_token.mntner.rpsl_mntner_pk}"
)

return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302)


@csrf_protect
@session_provider_manager
@authentication_required
async def api_token_delete(request: Request, session_provider: ORMSessionProvider) -> Response:
"""
Delete an API token
"""
query = session_provider.session.query(AuthApiToken)
query = query.filter(
AuthApiToken.pk == request.path_params["token_pk"],
AuthApiToken.mntner_id.in_([perm.mntner_id for perm in request.auth.user.permissions]),
)
api_token = await session_provider.run(query.one)

if not api_token:
return Response(status_code=404)

if request.method == "GET":
return template_context_render(
"api_token_delete.html",
request,
{
"api_token": api_token,
"csrf_token": csrf_token(request),
},
)

session_provider.session.query(AuthApiToken).filter(
AuthApiToken.pk == request.path_params["token_pk"]
).delete()
message_text = (
f"The API token named '{api_token.name}' on {api_token.mntner.rpsl_mntner_pk} has been deleted."
)
message(request, message_text)
await notify_mntner(session_provider, request.auth.user, api_token.mntner, explanation=message_text)
logger.info(
f"{client_ip(request)}{request.auth.user.email}: removed API token {api_token.pk} on mntner"
f" {api_token.mntner.rpsl_mntner_pk}"
)

return RedirectResponse(request.url_for("ui:user_permissions"), status_code=302)


class PermissionAddForm(CurrentPasswordForm):
def __init__(self, *args, session_provider: ORMSessionProvider, **kwargs):
super().__init__(*args, **kwargs)
Expand Down
23 changes: 13 additions & 10 deletions irrd/webui/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
user_permissions,
)
from irrd.webui.endpoints_mntners import (
api_token_add,
api_token_delete,
api_token_edit,
mntner_migrate_complete,
mntner_migrate_initiate,
permission_add,
Expand All @@ -27,10 +30,7 @@
Route("/rpsl/update/", rpsl_update, name="rpsl_update", methods=["GET", "POST"]),
Route("/rpsl/{source}/{object_class}/{rpsl_pk:path}/", rpsl_detail, name="rpsl_detail"),
Route(
"/migrate-mntner/",
mntner_migrate_initiate,
name="mntner_migrate_initiate",
methods=["GET", "POST"],
"/migrate-mntner/", mntner_migrate_initiate, name="mntner_migrate_initiate", methods=["GET", "POST"]
),
Route(
"/migrate-mntner/complete/{pk:uuid}/{token}/",
Expand All @@ -39,17 +39,20 @@
methods=["GET", "POST"],
),
Route("/user/", user_permissions, name="user_permissions"),
Route(
"/permission/add/{mntner:uuid}/",
permission_add,
name="permission_add",
methods=["GET", "POST"],
),
Route("/permission/add/{mntner:uuid}/", permission_add, name="permission_add", methods=["GET", "POST"]),
Route(
"/permission/delete/{permission:uuid}/",
permission_delete,
name="permission_delete",
methods=["GET", "POST"],
),
Route("/api_token/add/{mntner:uuid}/", api_token_add, name="api_token_add", methods=["GET", "POST"]),
Route("/api_token/edit/{token_pk:uuid}/", api_token_edit, name="api_token_edit", methods=["GET", "POST"]),
Route(
"/api_token/delete/{token_pk:uuid}/",
api_token_delete,
name="api_token_delete",
methods=["GET", "POST"],
),
Mount("/auth", name="auth", routes=AUTH_ROUTES),
]
11 changes: 11 additions & 0 deletions irrd/webui/templates/api_token_delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block content %}
<h2>Remove API token '{{ api_token.name }}' on {{ api_token.mntner.rpsl_mntner_pk }}</h2>
<div class="row">
<form method="POST">
<input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token }}">
<input class="btn btn-primary" id="submit" name="submit" type="submit" value="Confirm removal of this API token">
</form>
</div>
</div>
{% endblock %}
15 changes: 15 additions & 0 deletions irrd/webui/templates/api_token_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block content %}
{% if api_token %}
<h2>Update API '{{ api_token.name }}' token on {{ mntner.rpsl_mntner_pk }}</h2>
{% else %}
<h2>Add an API token on {{ mntner.rpsl_mntner_pk }}</h2>
{% endif %}
<div class="row">
<div class="col-lg-9">
{{ form_html }}
</div>
<div class="col-lg-3 alert alert-info">
</div>
</div>
{% endblock %}
Loading

0 comments on commit 231cf5f

Please sign in to comment.