From 472ba80d0b56e300350d82158ee6426e222a69ff Mon Sep 17 00:00:00 2001 From: Jack Pollock Date: Thu, 27 Nov 2025 22:43:44 +0000 Subject: [PATCH 1/3] Adding support for API Tokens for REST API interactions after the move to JWT auth Since the move to JWT, the tokens regenerate so you cannot simply nab the cookie content anymore to use via an external application interacting with the REST API. Adding functionality to add an API Token to a user's account to authenticate in place of JWTs. --- client/src/store/modules/user/user.js | 43 +++ client/src/views/user/Settings.vue | 8 +- .../vue_components/user/settings/ApiToken.vue | 249 ++++++++++++++++++ .../e1a2b3c4d5e6_add_user_api_token.py | 34 +++ server/controllers/api/auth.py | 74 ++++++ server/models/user.py | 1 + server/schemas/schemas.py | 2 +- server/test/test_auth_api.py | 186 +++++++++++++ server/utils/web/base_controller.py | 42 ++- 9 files changed, 630 insertions(+), 9 deletions(-) create mode 100644 client/src/vue_components/user/settings/ApiToken.vue create mode 100644 server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py diff --git a/client/src/store/modules/user/user.js b/client/src/store/modules/user/user.js index 1d8028a3..c28b8c62 100644 --- a/client/src/store/modules/user/user.js +++ b/client/src/store/modules/user/user.js @@ -207,6 +207,49 @@ export default { await context.commit('SET_TOKEN_REFRESH_INTERVAL', refreshInterval); }, + async GENERATE_API_TOKEN(context) { + const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (response.ok) { + const data = await response.json(); + Vue.$toast.success('API token generated successfully!'); + return data; + } + const responseBody = await response.json(); + log.error('Unable to generate API token'); + Vue.$toast.error(`Unable to generate API token: ${responseBody.message || 'Unknown error'}`); + return null; + }, + async REVOKE_API_TOKEN(context) { + const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (response.ok) { + Vue.$toast.success('API token revoked successfully!'); + return true; + } + const responseBody = await response.json(); + log.error('Unable to revoke API token'); + Vue.$toast.error(`Unable to revoke API token: ${responseBody.message || 'Unknown error'}`); + return false; + }, + async GET_API_TOKEN(context) { + const response = await fetch(makeURL('/api/v1/auth/api-token'), { + method: 'GET', + }); + if (response.ok) { + const data = await response.json(); + return data; + } + log.error('Unable to get API token'); + Vue.$toast.error('Unable to get API token!'); + return null; + }, }, getters: { CURRENT_USER(state) { diff --git a/client/src/views/user/Settings.vue b/client/src/views/user/Settings.vue index 0c87e900..e1738647 100644 --- a/client/src/views/user/Settings.vue +++ b/client/src/views/user/Settings.vue @@ -22,6 +22,9 @@ > + + + @@ -30,9 +33,12 @@ import StageDirectionStyles from '@/vue_components/user/settings/StageDirectionStyles.vue'; import AboutUser from '@/vue_components/user/settings/AboutUser.vue'; import UserSettingsConfig from '@/vue_components/user/settings/Settings.vue'; +import ApiToken from '@/vue_components/user/settings/ApiToken.vue'; export default { name: 'UserSettings', - components: { UserSettingsConfig, AboutUser, StageDirectionStyles }, + components: { + UserSettingsConfig, AboutUser, StageDirectionStyles, ApiToken, + }, }; diff --git a/client/src/vue_components/user/settings/ApiToken.vue b/client/src/vue_components/user/settings/ApiToken.vue new file mode 100644 index 00000000..d51926fd --- /dev/null +++ b/client/src/vue_components/user/settings/ApiToken.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py new file mode 100644 index 00000000..2a4e0e48 --- /dev/null +++ b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py @@ -0,0 +1,34 @@ +"""Add user API token + +Revision ID: e1a2b3c4d5e6 +Revises: 8c78b9c89ee6 +Create Date: 2025-11-27 20:45:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e1a2b3c4d5e6" +down_revision: Union[str, None] = "8c78b9c89ee6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.add_column(sa.Column("api_token", sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_column("api_token") + + # ### end Alembic commands ### diff --git a/server/controllers/api/auth.py b/server/controllers/api/auth.py index 0556a041..20a79ba9 100644 --- a/server/controllers/api/auth.py +++ b/server/controllers/api/auth.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +import secrets import bcrypt from tornado import escape, gen @@ -272,3 +273,76 @@ class AuthHandler(BaseAPIController): def get(self): self.set_status(200) self.finish(self.current_user if self.current_user else {}) + + +@ApiRoute("auth/api-token/generate", ApiVersion.V1) +class ApiTokenGenerateController(BaseAPIController): + @api_authenticated + async def post(self): + """Generate a new API token for the authenticated user""" + with self.make_session() as session: + user = session.query(User).get(self.current_user["id"]) + if not user: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + # Generate a secure random token (plain text to return to user) + new_token = secrets.token_urlsafe(32) + + # Hash the token before storing (like passwords) + hashed_token = await IOLoop.current().run_in_executor( + None, bcrypt.hashpw, escape.utf8(new_token), bcrypt.gensalt() + ) + hashed_token = escape.to_unicode(hashed_token) + + user.api_token = hashed_token + session.commit() + + self.set_status(200) + await self.finish( + { + "message": "API token generated successfully", + "api_token": new_token, + } + ) + + +@ApiRoute("auth/api-token/revoke", ApiVersion.V1) +class ApiTokenRevokeController(BaseAPIController): + @api_authenticated + async def post(self): + """Revoke the API token for the authenticated user""" + with self.make_session() as session: + user = session.query(User).get(self.current_user["id"]) + if not user: + self.set_status(404) + await self.finish({"message": "User not found"}) + return + + if not user.api_token: + self.set_status(400) + await self.finish({"message": "No API token to revoke"}) + return + + user.api_token = None + session.commit() + + self.set_status(200) + await self.finish({"message": "API token revoked successfully"}) + + +@ApiRoute("auth/api-token", ApiVersion.V1) +class ApiTokenController(BaseAPIController): + @api_authenticated + def get(self): + """Check if the authenticated user has an API token""" + with self.make_session() as session: + user = session.query(User).get(self.current_user["id"]) + if not user: + self.set_status(404) + self.finish({"message": "User not found"}) + return + + self.set_status(200) + self.finish({"has_token": user.api_token is not None}) diff --git a/server/models/user.py b/server/models/user.py index 03727c87..f07b3544 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -18,6 +18,7 @@ class User(db.Model): is_admin = Column(Boolean()) last_login = Column(DateTime()) last_seen = Column(DateTime()) + api_token = Column(String(), nullable=True, index=True) class UserSettings(db.Model): diff --git a/server/schemas/schemas.py b/server/schemas/schemas.py index 9d6df407..aac3af25 100644 --- a/server/schemas/schemas.py +++ b/server/schemas/schemas.py @@ -37,7 +37,7 @@ class Meta: model = User load_instance = True include_fk = True - exclude = ("password",) + exclude = ("password", "api_token") @schema diff --git a/server/test/test_auth_api.py b/server/test/test_auth_api.py index 971d11c7..d5e7cfcf 100644 --- a/server/test/test_auth_api.py +++ b/server/test/test_auth_api.py @@ -167,3 +167,189 @@ def test_jwt_logout_revoke(self): "/api/v1/auth", method="GET", headers={"Authorization": f"Bearer {token}"} ) self.assertEqual(401, response.code) + + def test_api_token_generate(self): + # Create and login a user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + { + "username": "foobar", + "password": "password", + "is_admin": True, + } + ), + ) + + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "foobar", "password": "password"}), + ) + response_body = escape.json_decode(response.body) + token = response_body["access_token"] + + # Generate API token + response = self.fetch( + "/api/v1/auth/api-token/generate", + method="POST", + body=escape.json_encode({}), + headers={"Authorization": f"Bearer {token}"}, + ) + response_body = escape.json_decode(response.body) + self.assertEqual(200, response.code) + self.assertTrue("api_token" in response_body) + self.assertTrue(len(response_body["api_token"]) > 0) + + def test_api_token_authentication(self): + # Create and login a user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + { + "username": "foobar", + "password": "password", + "is_admin": True, + } + ), + ) + + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "foobar", "password": "password"}), + ) + response_body = escape.json_decode(response.body) + token = response_body["access_token"] + + # Generate API token + response = self.fetch( + "/api/v1/auth/api-token/generate", + method="POST", + body=escape.json_encode({}), + headers={"Authorization": f"Bearer {token}"}, + ) + response_body = escape.json_decode(response.body) + api_token = response_body["api_token"] + + # Use API token to authenticate + response = self.fetch( + "/api/v1/auth", method="GET", headers={"X-API-Key": api_token} + ) + self.assertEqual(200, response.code) + response_body = escape.json_decode(response.body) + self.assertEqual("foobar", response_body["username"]) + + def test_api_token_invalid(self): + # Try to use an invalid API token + response = self.fetch( + "/api/v1/auth", method="GET", headers={"X-API-Key": "invalid-token"} + ) + self.assertEqual(401, response.code) + + def test_api_token_revoke(self): + # Create and login a user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + { + "username": "foobar", + "password": "password", + "is_admin": True, + } + ), + ) + + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "foobar", "password": "password"}), + ) + response_body = escape.json_decode(response.body) + token = response_body["access_token"] + + # Generate API token + response = self.fetch( + "/api/v1/auth/api-token/generate", + method="POST", + body=escape.json_encode({}), + headers={"Authorization": f"Bearer {token}"}, + ) + response_body = escape.json_decode(response.body) + api_token = response_body["api_token"] + + # Verify API token works + response = self.fetch( + "/api/v1/auth", method="GET", headers={"X-API-Key": api_token} + ) + self.assertEqual(200, response.code) + + # Revoke API token + response = self.fetch( + "/api/v1/auth/api-token/revoke", + method="POST", + body=escape.json_encode({}), + headers={"Authorization": f"Bearer {token}"}, + ) + self.assertEqual(200, response.code) + + # Verify API token no longer works + response = self.fetch( + "/api/v1/auth", method="GET", headers={"X-API-Key": api_token} + ) + self.assertEqual(401, response.code) + + def test_api_token_get(self): + # Create and login a user + self.fetch( + "/api/v1/auth/create", + method="POST", + body=escape.json_encode( + { + "username": "foobar", + "password": "password", + "is_admin": True, + } + ), + ) + + response = self.fetch( + "/api/v1/auth/login", + method="POST", + body=escape.json_encode({"username": "foobar", "password": "password"}), + ) + response_body = escape.json_decode(response.body) + token = response_body["access_token"] + + # Check no token exists initially + response = self.fetch( + "/api/v1/auth/api-token", + method="GET", + headers={"Authorization": f"Bearer {token}"}, + ) + response_body = escape.json_decode(response.body) + self.assertEqual(200, response.code) + self.assertFalse(response_body["has_token"]) + + # Generate API token + response = self.fetch( + "/api/v1/auth/api-token/generate", + method="POST", + body=escape.json_encode({}), + headers={"Authorization": f"Bearer {token}"}, + ) + response_body = escape.json_decode(response.body) + api_token = response_body["api_token"] + + # Check that token exists (but can't retrieve it) + response = self.fetch( + "/api/v1/auth/api-token", + method="GET", + headers={"Authorization": f"Bearer {token}"}, + ) + response_body = escape.json_decode(response.body) + self.assertEqual(200, response.code) + self.assertTrue(response_body["has_token"]) diff --git a/server/utils/web/base_controller.py b/server/utils/web/base_controller.py index 2c757071..77b8378a 100644 --- a/server/utils/web/base_controller.py +++ b/server/utils/web/base_controller.py @@ -2,7 +2,9 @@ from typing import TYPE_CHECKING, Any, Awaitable, Optional +import bcrypt from tornado import escape, httputil +from tornado.ioloop import IOLoop from tornado.web import HTTPError, RequestHandler from tornado_sqlalchemy import SessionMixin @@ -36,14 +38,13 @@ async def prepare( show_schema = ShowSchema() user_schema = UserSchema() - # Extract JWT token from header - auth_header = self.request.headers.get("Authorization", "") - token = self.application.jwt_service.get_token_from_authorization_header( - auth_header - ) - with self.make_session() as session: - # If we have a token, try to authenticate with it + # First, try JWT authentication + auth_header = self.request.headers.get("Authorization", "") + token = self.application.jwt_service.get_token_from_authorization_header( + auth_header + ) + if token: is_revoked = await self.application.jwt_service.is_token_revoked(token) if is_revoked: @@ -55,6 +56,33 @@ async def prepare( if user: self.current_user = user_schema.dump(user) + # If not authenticated via JWT, try API token authentication + if not self.current_user: + api_key = self.request.headers.get("X-API-Key", "") + if api_key: + # Get all users with API tokens and check each one + users_with_tokens = ( + session.query(User).filter(User.api_token.isnot(None)).all() + ) + + authenticated_user = None + for user in users_with_tokens: + # Compare the provided token with the hashed token + token_matches = await IOLoop.current().run_in_executor( + None, + bcrypt.checkpw, + escape.utf8(api_key), + escape.utf8(user.api_token), + ) + if token_matches: + authenticated_user = user + break + + if authenticated_user: + self.current_user = user_schema.dump(authenticated_user) + else: + raise HTTPError(401, log_message="Invalid API key") + current_show = await self.application.digi_settings.get("current_show") if current_show: show = session.query(Show).get(current_show) From c14cdaff535ecc78fa94516fa03ec729b708a7fc Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 28 Nov 2025 20:09:23 +0000 Subject: [PATCH 2/3] Fix formatting and database migration missing index --- .gitignore | 1 + .../alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py | 2 ++ server/controllers/api/auth.py | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b76a2508..72110578 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ **/.DS_Store .claude +CLAUDE.md # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py index 2a4e0e48..ce1e13cc 100644 --- a/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py +++ b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py @@ -22,6 +22,7 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table("user", schema=None) as batch_op: batch_op.add_column(sa.Column("api_token", sa.String(), nullable=True)) + batch_op.create_index("ix_user_api_token", ["api_token"], unique=False) # ### end Alembic commands ### @@ -29,6 +30,7 @@ def upgrade() -> None: def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.drop_index("ix_user_api_token") batch_op.drop_column("api_token") # ### end Alembic commands ### diff --git a/server/controllers/api/auth.py b/server/controllers/api/auth.py index 20a79ba9..32ca5ab4 100644 --- a/server/controllers/api/auth.py +++ b/server/controllers/api/auth.py @@ -1,5 +1,5 @@ -from datetime import datetime, timezone import secrets +from datetime import datetime, timezone import bcrypt from tornado import escape, gen From e182d5b0c79d259c6de7a00d4889dbbbc17d49af Mon Sep 17 00:00:00 2001 From: Tim Bradgate Date: Fri, 28 Nov 2025 20:48:39 +0000 Subject: [PATCH 3/3] test: fix database isolation in test teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added database engine disposal to DigiScriptTestCase.tearDown() to ensure proper test isolation. Previously, in-memory SQLite database connections persisted across test methods, causing data leakage that resulted in 2 test failures when tests were run together: - test_create_admin failed due to username conflicts from previous tests - test_api_token_get failed because user already had API token from previous test execution The fix ensures each test method gets a fresh database instance by calling models.db.engine.dispose() in tearDown, forcing the creation of a new engine for the next test. Test results improved from 11 passed/2 failed to 31 passed/0 failed across all test suites. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- server/test/test_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/test/test_utils.py b/server/test/test_utils.py index f5ec36df..f2f5d942 100644 --- a/server/test/test_utils.py +++ b/server/test/test_utils.py @@ -39,4 +39,8 @@ def tearDown(self): table_inspect = inspect(table) models.db.metadata.remove(table_inspect.mapped_table) + # Dispose the database engine to ensure proper test isolation + # This forces a new in-memory database for each test + models.db.engine.dispose() + super().tearDown()