diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py index 574fe9897..112570d56 100644 --- a/apps/api/plane/authentication/adapter/base.py +++ b/apps/api/plane/authentication/adapter/base.py @@ -330,6 +330,19 @@ class Adapter: payload={"email": email}, ) + # Reject bot service accounts (BOT_USER_LOGIN_FORBIDDEN). Bots (is_bot=True, + # e.g. the WORKSPACE_SEED bot) are internal identities that act only through + # API tokens; they must never be assumable via the interactive login/signup + # flow (email/password, magic code, or any OAuth provider). A brand-new + # signup can never be a bot — bots are provisioned internally, never through + # this path — so guarding on an existing `user` record is sufficient. + if user and user.is_bot: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["BOT_USER_LOGIN_FORBIDDEN"], + error_message="BOT_USER_LOGIN_FORBIDDEN", + payload={"email": email}, + ) + # True = new user (signup), False = returning user (login) is_signup = not bool(user) # If user is not present, create a new user diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index f91565df2..b813f2cf2 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -9,6 +9,7 @@ AUTHENTICATION_ERROR_CODES = { "EMAIL_REQUIRED": 5010, "SIGNUP_DISABLED": 5015, "MAGIC_LINK_LOGIN_DISABLED": 5016, + "BOT_USER_LOGIN_FORBIDDEN": 5017, "PASSWORD_LOGIN_DISABLED": 5018, "USER_ACCOUNT_DEACTIVATED": 5019, # Password strength diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py index b0d42cbb1..aa5ed3680 100644 --- a/apps/api/plane/license/api/views/admin.py +++ b/apps/api/plane/license/api/views/admin.py @@ -333,6 +333,25 @@ class InstanceAdminSignInEndpoint(View): ) return HttpResponseRedirect(url) + # Reject bot service accounts (defense-in-depth for the same intent as + # BOT_USER_LOGIN_FORBIDDEN on the app/space flow). Bots are internal + # identities that act only via API tokens and must never sign in to the + # admin console. A bot is never registered as an InstanceAdmin, so this + # is not reachable today, but the guard closes the path regardless. + # Reuse ADMIN_AUTHENTICATION_FAILED so no bot-specific admin error code + # is disclosed to the caller. + if user.is_bot: + exc = AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["ADMIN_AUTHENTICATION_FAILED"], + error_message="ADMIN_AUTHENTICATION_FAILED", + payload={"email": email}, + ) + url = urljoin( + base_host(request=request, is_admin=True), + "?" + urlencode(exc.get_error_dict()), + ) + return HttpResponseRedirect(url) + # is_active if not user.is_active: exc = AuthenticationException( diff --git a/apps/api/plane/tests/contract/app/test_authentication.py b/apps/api/plane/tests/contract/app/test_authentication.py index 300d862b8..87b871cac 100644 --- a/apps/api/plane/tests/contract/app/test_authentication.py +++ b/apps/api/plane/tests/contract/app/test_authentication.py @@ -626,3 +626,130 @@ class TestAuthenticationThrottle: response = django_client.post(url, {"email": "throttle-up@plane.so", "code": "000000"}, follow=False) assert "RATE_LIMIT_EXCEEDED" in response.url + + +@pytest.mark.contract +class TestBotUserLoginBlocked: + """Bot service accounts (is_bot=True) must never authenticate through the + interactive login flow. + + Bots are internal identities (e.g. the WORKSPACE_SEED bot) that act only via + API tokens. Every interactive provider funnels through + Adapter.complete_login_or_signup(), which rejects bots with + BOT_USER_LOGIN_FORBIDDEN (5017). These are regression guards for that block. + """ + + BOT_EMAIL = "bot-login@plane.so" + HUMAN_EMAIL = "human-login@plane.so" + PASSWORD = "Str0ng-Pass!42" + + @pytest.fixture(autouse=True) + def _clear_state(self): + """Reset throttle cache and the bot's magic-link redis state around each test.""" + cache.clear() + ri = redis_instance() + ri.delete(f"magic_{self.BOT_EMAIL}") + ri.delete(f"magic_{self.BOT_EMAIL}:verify_attempts") + yield + cache.clear() + ri.delete(f"magic_{self.BOT_EMAIL}") + ri.delete(f"magic_{self.BOT_EMAIL}:verify_attempts") + + @pytest.fixture + def bot_user(self, db): + """An active bot account with a known password so the credential check + passes and execution reaches the login chokepoint.""" + user = User.objects.create(email=self.BOT_EMAIL, is_bot=True, is_active=True) + user.set_password(self.PASSWORD) + user.save() + return user + + @pytest.fixture + def human_user(self, db): + """A normal (non-bot) account, identical apart from is_bot, as a control.""" + user = User.objects.create(email=self.HUMAN_EMAIL, is_active=True) + user.set_password(self.PASSWORD) + user.save() + return user + + @pytest.mark.django_db + def test_bot_password_sign_in_blocked(self, django_client, bot_user, setup_instance): + """Password sign-in with a bot's *correct* credentials is still rejected: + the block happens after credential verification, so no session is created.""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": self.BOT_EMAIL, "password": self.PASSWORD}, follow=False + ) + assert response.status_code == 302 + assert "BOT_USER_LOGIN_FORBIDDEN" in response.url + # The block must prevent authentication. + assert "_auth_user_id" not in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_bot_magic_sign_in_blocked( + self, mock_magic_link, django_client, api_client, bot_user, setup_instance + ): + """The same block applies via a second provider (magic code), proving the + guard sits at the shared chokepoint rather than in one provider.""" + token = _generate_magic_token(api_client, self.BOT_EMAIL) + url = reverse("magic-sign-in") + response = django_client.post(url, {"email": self.BOT_EMAIL, "code": token}, follow=False) + assert response.status_code == 302 + assert "BOT_USER_LOGIN_FORBIDDEN" in response.url + assert "_auth_user_id" not in django_client.session + + @pytest.mark.django_db + def test_human_password_sign_in_allowed(self, django_client, human_user, setup_instance): + """Control: a normal user with the identical setup still signs in — the + guard is scoped strictly to is_bot and does not regress human logins.""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": self.HUMAN_EMAIL, "password": self.PASSWORD}, follow=False + ) + assert response.status_code == 302 + assert "BOT_USER_LOGIN_FORBIDDEN" not in response.url + assert "error_code" not in response.url + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestBotUserAdminSignInBlocked: + """A bot must not sign in to the instance-admin console either. + + InstanceAdminSignInEndpoint mints its own session via user_login() outside + Adapter.complete_login_or_signup(), so it carries an independent is_bot + guard that rejects bots with ADMIN_AUTHENTICATION_FAILED before the admin + membership check. (Uses the literal path because license/urls.py reuses the + name "instance-admin-sign-in" for both sign-in and sign-up.) + """ + + ADMIN_SIGN_IN_PATH = "/api/instances/admins/sign-in/" + BOT_EMAIL = "admin-bot@plane.so" + PASSWORD = "Str0ng-Pass!42" + + @pytest.fixture(autouse=True) + def _clear_state(self): + cache.clear() + yield + cache.clear() + + @pytest.fixture + def bot_user(self, db): + user = User.objects.create(email=self.BOT_EMAIL, is_bot=True, is_active=True) + user.set_password(self.PASSWORD) + user.save() + return user + + @pytest.mark.django_db + def test_bot_admin_sign_in_blocked(self, django_client, bot_user, setup_instance): + """A bot is rejected at the admin sign-in endpoint and no session is created, + even though it is active and the password is correct.""" + response = django_client.post( + self.ADMIN_SIGN_IN_PATH, + {"email": self.BOT_EMAIL, "password": self.PASSWORD}, + follow=False, + ) + assert response.status_code == 302 + assert "ADMIN_AUTHENTICATION_FAILED" in response.url + assert "_auth_user_id" not in django_client.session diff --git a/apps/space/helpers/authentication.helper.tsx b/apps/space/helpers/authentication.helper.tsx index 8104a1d8f..6e11d61bd 100644 --- a/apps/space/helpers/authentication.helper.tsx +++ b/apps/space/helpers/authentication.helper.tsx @@ -42,6 +42,7 @@ export enum EAuthenticationErrorCodes { INVALID_EMAIL_MAGIC_SIGN_UP = "5050", MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED = "5055", // Sign In + BOT_USER_LOGIN_FORBIDDEN = "5017", USER_ACCOUNT_DEACTIVATED = "5019", USER_DOES_NOT_EXIST = "5060", AUTHENTICATION_FAILED_SIGN_IN = "5065", @@ -160,6 +161,10 @@ const errorCodeMessages: { }, // sign in + [EAuthenticationErrorCodes.BOT_USER_LOGIN_FORBIDDEN]: { + title: `Sign in not allowed`, + message: () => `This account cannot be used to sign in. Please use a personal account.`, + }, [EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED]: { title: `User account deactivated`, message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, @@ -384,6 +389,7 @@ export const authErrorHandler = (errorCode: EAuthenticationErrorCodes, email?: s EAuthenticationErrorCodes.ADMIN_AUTHENTICATION_FAILED, EAuthenticationErrorCodes.ADMIN_USER_ALREADY_EXIST, EAuthenticationErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAuthenticationErrorCodes.BOT_USER_LOGIN_FORBIDDEN, EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED, ]; diff --git a/apps/web/helpers/authentication.helper.tsx b/apps/web/helpers/authentication.helper.tsx index 5db2e1aa9..977f09406 100644 --- a/apps/web/helpers/authentication.helper.tsx +++ b/apps/web/helpers/authentication.helper.tsx @@ -43,6 +43,7 @@ export enum EAuthenticationErrorCodes { EMAIL_REQUIRED = "5010", SIGNUP_DISABLED = "5015", MAGIC_LINK_LOGIN_DISABLED = "5016", + BOT_USER_LOGIN_FORBIDDEN = "5017", PASSWORD_LOGIN_DISABLED = "5018", USER_ACCOUNT_DEACTIVATED = "5019", // Password strength @@ -141,6 +142,10 @@ const errorCodeMessages: { title: `User account deactivated`, message: () => `User account deactivated. Please contact ${SUPPORT_EMAIL ? SUPPORT_EMAIL : "administrator"}.`, }, + [EAuthenticationErrorCodes.BOT_USER_LOGIN_FORBIDDEN]: { + title: `Sign in not allowed`, + message: () => `This account cannot be used to sign in. Please use a personal account.`, + }, [EAuthenticationErrorCodes.INVALID_PASSWORD]: { title: `Invalid password`, message: () => `Invalid password. Please try again.`, @@ -380,6 +385,7 @@ export const authErrorHandler = (errorCode: EAuthenticationErrorCodes, email?: s EAuthenticationErrorCodes.SIGNUP_DISABLED, EAuthenticationErrorCodes.MAGIC_LINK_LOGIN_DISABLED, EAuthenticationErrorCodes.PASSWORD_LOGIN_DISABLED, + EAuthenticationErrorCodes.BOT_USER_LOGIN_FORBIDDEN, EAuthenticationErrorCodes.USER_ACCOUNT_DEACTIVATED, EAuthenticationErrorCodes.INVALID_PASSWORD, EAuthenticationErrorCodes.SMTP_NOT_CONFIGURED, diff --git a/packages/constants/src/auth/index.ts b/packages/constants/src/auth/index.ts index 32a7d5eee..9d29181f0 100644 --- a/packages/constants/src/auth/index.ts +++ b/packages/constants/src/auth/index.ts @@ -110,6 +110,7 @@ export enum EAuthErrorCodes { EMAIL_REQUIRED = "5010", SIGNUP_DISABLED = "5015", MAGIC_LINK_LOGIN_DISABLED = "5016", + BOT_USER_LOGIN_FORBIDDEN = "5017", PASSWORD_LOGIN_DISABLED = "5018", USER_ACCOUNT_DEACTIVATED = "5019", // Password strength diff --git a/packages/utils/src/auth.ts b/packages/utils/src/auth.ts index fd8b80ad0..cd0646af0 100644 --- a/packages/utils/src/auth.ts +++ b/packages/utils/src/auth.ts @@ -142,6 +142,10 @@ const errorCodeMessages: { message: () => `Invalid email. Please try again.`, }, // sign in + [EAuthErrorCodes.BOT_USER_LOGIN_FORBIDDEN]: { + title: `Sign in not allowed`, + message: () => `This account cannot be used to sign in. Please use a personal account.`, + }, [EAuthErrorCodes.USER_ACCOUNT_DEACTIVATED]: { title: `User account deactivated`, message: () => `User account deactivated. Please contact administrator.`, @@ -349,6 +353,7 @@ export const authErrorHandler = (errorCode: EAuthErrorCodes, email?: string): TA EAuthErrorCodes.ADMIN_AUTHENTICATION_FAILED, EAuthErrorCodes.ADMIN_USER_ALREADY_EXIST, EAuthErrorCodes.ADMIN_USER_DOES_NOT_EXIST, + EAuthErrorCodes.BOT_USER_LOGIN_FORBIDDEN, EAuthErrorCodes.USER_ACCOUNT_DEACTIVATED, ];