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/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..ce1e13cc --- /dev/null +++ b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py @@ -0,0 +1,36 @@ +"""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)) + batch_op.create_index("ix_user_api_token", ["api_token"], unique=False) + + # ### 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_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 0556a041..32ca5ab4 100644 --- a/server/controllers/api/auth.py +++ b/server/controllers/api/auth.py @@ -1,3 +1,4 @@ +import secrets from datetime import datetime, timezone import bcrypt @@ -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/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() 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)