diff --git a/alembic/versions/81e6babf5136_add_token_to_invitation.py b/alembic/versions/81e6babf5136_add_token_to_invitation.py new file mode 100644 index 00000000..7206efc2 --- /dev/null +++ b/alembic/versions/81e6babf5136_add_token_to_invitation.py @@ -0,0 +1,30 @@ +"""add token to invitation + +Revision ID: 81e6babf5136 +Revises: 67955a4abaef +Create Date: 2018-10-29 09:26:30.728348 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '81e6babf5136' +down_revision = '67955a4abaef' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('invitations', sa.Column('token', sa.String(), nullable=True)) + op.create_index(op.f('ix_invitations_token'), 'invitations', ['token'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invitations_token'), table_name='invitations') + op.drop_column('invitations', 'token') + # ### end Alembic commands ### diff --git a/atst/domain/invitations.py b/atst/domain/invitations.py index 166bc841..9181d682 100644 --- a/atst/domain/invitations.py +++ b/atst/domain/invitations.py @@ -21,9 +21,9 @@ class Invitations(object): EXPIRATION_LIMIT_MINUTES = 360 @classmethod - def _get(cls, invite_id): + def _get(cls, token): try: - invite = db.session.query(Invitation).filter_by(id=invite_id).one() + invite = db.session.query(Invitation).filter_by(token=token).one() except NoResultFound: raise NotFoundError("invite") @@ -58,8 +58,8 @@ class Invitations(object): return invite @classmethod - def accept(cls, invite_id): - invite = Invitations._get(invite_id) + def accept(cls, token): + invite = Invitations._get(token) if invite.is_expired: invite.status = InvitationStatus.REJECTED diff --git a/atst/models/invitation.py b/atst/models/invitation.py index 4f68e332..50ae3351 100644 --- a/atst/models/invitation.py +++ b/atst/models/invitation.py @@ -1,7 +1,8 @@ import datetime from enum import Enum +import secrets -from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP +from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum, TIMESTAMP, String from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -34,6 +35,8 @@ class Invitation(Base, TimestampsMixin): expiration_time = Column(TIMESTAMP(timezone=True)) + token = Column(String(), index=True, default=lambda: secrets.token_urlsafe()) + def __repr__(self): return "".format( self.user.id, self.workspace.id, self.id diff --git a/atst/routes/workspaces.py b/atst/routes/workspaces.py index 64ad272c..0a6604e9 100644 --- a/atst/routes/workspaces.py +++ b/atst/routes/workspaces.py @@ -220,10 +220,8 @@ def new_member(workspace_id): ) -def send_invite_email(owner_name, invite_id, new_member_email): - body = render_template( - "emails/invitation.txt", owner=owner_name, invite_id=invite_id - ) +def send_invite_email(owner_name, token, new_member_email): + body = render_template("emails/invitation.txt", owner=owner_name, token=token) queue.send_mail( [new_member_email], "{} has invited you to a JEDI Cloud Workspace".format(owner_name), @@ -241,7 +239,7 @@ def create_member(workspace_id): new_member = Workspaces.create_member(g.current_user, workspace, form.data) invite = Invitations.create(workspace, g.current_user, new_member.user) send_invite_email( - g.current_user.full_name, invite.id, new_member.user.email + g.current_user.full_name, invite.token, new_member.user.email ) return redirect( @@ -338,11 +336,11 @@ def update_member(workspace_id, member_id): ) -@bp.route("/workspaces/invitation/", methods=["GET"]) -def accept_invitation(invite_id): +@bp.route("/workspaces/invitation/", methods=["GET"]) +def accept_invitation(token): # TODO: check that the current_user DOD ID matches the user associated with # the invitation - invite = Invitations.accept(invite_id) + invite = Invitations.accept(token) return redirect( url_for("workspaces.show_workspace", workspace_id=invite.workspace.id) diff --git a/templates/emails/invitation.txt b/templates/emails/invitation.txt index 789da574..02fdd49d 100644 --- a/templates/emails/invitation.txt +++ b/templates/emails/invitation.txt @@ -1,7 +1,7 @@ Join this JEDI Cloud Workspace {{ owner }} has invited you to join a JEDI Cloud Workspace. Login now to view or use your JEDI Cloud resources. -{{ url_for("workspaces.accept_invitation", invite_id=invite_id, _external=True) }} +{{ url_for("workspaces.accept_invitation", token=token, _external=True) }} What is JEDI Cloud? JEDI Cloud is a DoD enterprise-wide solution for commercial cloud services. diff --git a/tests/domain/test_invitations.py b/tests/domain/test_invitations.py index cbcb43d4..42f61e1e 100644 --- a/tests/domain/test_invitations.py +++ b/tests/domain/test_invitations.py @@ -1,5 +1,6 @@ import datetime import pytest +import re from atst.domain.invitations import Invitations, InvitationError from atst.models.invitation import Status @@ -15,6 +16,7 @@ def test_create_invitation(): assert invite.workspace == workspace assert invite.inviter == workspace.owner assert invite.status == Status.PENDING + assert re.match(r"^[\w\-_]+$", invite.token) def test_accept_invitation(): @@ -22,7 +24,7 @@ def test_accept_invitation(): user = UserFactory.create() invite = Invitations.create(workspace, workspace.owner, user) assert invite.is_pending - accepted_invite = Invitations.accept(invite.id) + accepted_invite = Invitations.accept(invite.token) assert accepted_invite.is_accepted @@ -38,7 +40,7 @@ def test_accept_expired_invitation(): status=Status.PENDING, ) with pytest.raises(InvitationError): - Invitations.accept(invite.id) + Invitations.accept(invite.token) assert invite.is_rejected @@ -50,7 +52,7 @@ def test_accept_rejected_invite(): workspace_id=workspace.id, user_id=user.id, status=Status.REJECTED ) with pytest.raises(InvitationError): - Invitations.accept(invite.id) + Invitations.accept(invite.token) def test_accept_revoked_invite(): @@ -60,4 +62,4 @@ def test_accept_revoked_invite(): workspace_id=workspace.id, user_id=user.id, status=Status.REVOKED ) with pytest.raises(InvitationError): - Invitations.accept(invite.id) + Invitations.accept(invite.token) diff --git a/tests/routes/test_workspaces.py b/tests/routes/test_workspaces.py index cfc56f6f..1cd0e4c5 100644 --- a/tests/routes/test_workspaces.py +++ b/tests/routes/test_workspaces.py @@ -308,7 +308,7 @@ def test_new_member_accepts_valid_invite(client, user_session): assert len(Workspaces.for_user(user)) == 0 user_session(user) - response = client.get(url_for("workspaces.accept_invitation", invite_id=invite.id)) + response = client.get(url_for("workspaces.accept_invitation", token=invite.token)) # user is redirected to the workspace view assert response.status_code == 302 @@ -333,7 +333,7 @@ def test_new_member_accept_invalid_invite(client, user_session): status=InvitationStatus.REJECTED, ) user_session(user) - response = client.get(url_for("workspaces.accept_invitation", invite_id=invite.id)) + response = client.get(url_for("workspaces.accept_invitation", token=invite.token)) assert response.status_code == 404