@cryptotaxi247 / CoPilot / commits / 1677c317

feat: add SSO authentication (Azure / Google / Cloudflare Access) and opt-in TOTP 2FA (#778)

* feat: add SSO authentication and TOTP two-factor authentication SSO Providers: - Azure Entra ID (OAuth2 + OIDC, RS256 JWT validation, JWKS caching) - Google OAuth2/OIDC (RS256 JWT validation, JWKS caching) - Cloudflare Access (JWT assertion validation) Security: - Stateless HMAC-signed OAuth2 state tokens (multi-worker safe) - Email allowlist — only pre-approved addresses can authenticate via SSO - Auto-provisioning of SSO users with configurable role assignment - JWT_SECRET moved to environment variable (was hardcoded) - Dedicated SSO_STATE_SECRET and TOTP_ENCRYPTION_KEY (backward compatible) Two-Factor Authentication (TOTP): - RFC 6238 TOTP with QR code enrollment flow - Fernet-encrypted TOTP secrets at rest - 8 single-use backup codes per user - 2FA is opt-in per user — only enforced for accounts where enrollment has been completed DB migrations (Alembic, non-destructive, additive only): - sso_config + sso_allowed_emails tables - Google SSO fields on existing SSO config - user_totp table All changes are backward compatible — existing deployments require no configuration changes to continue working. * fix: add Alembic merge migration to resolve SSO/upstream head conflict * fix: register SSO and TOTP API endpoints in frontend api/index.ts * feat: add documentation for Single Sign-On (SSO) and Two-Factor Authentication (2FA) * precommit fixes * feat: add Office 365 summary and cloud integration dashboard templates * Add new dashboard templates for Office 365 integrations - Introduced O365_DEFENDER_FOR_IDENTITY.json for Microsoft Defender for Identity alerts and events. - Added O365_DLP.json for Data Loss Prevention policy events and metrics. - Created O365_ENDPOINT.json for Microsoft Defender for Endpoint activity monitoring. - Implemented O365_EXCHANGE.json for Exchange Online activity tracking. - Developed O365_FORMS.json for Microsoft Forms activity insights. - Added O365_MITRE.json for MITRE ATT&CK enrichment across Office 365 workloads. - Created O365_ONEDRIVE.json for OneDrive for Business activity analysis. - Implemented O365_POWERBI.json for Power BI activity monitoring. - Developed O365_SHAREPOINT.json for SharePoint Online activity tracking. - Added O365_TEAMS.json for Microsoft Teams activity insights. - Created O365_THREAT_INTELLIGENCE.json for threat intelligence monitoring in Office 365. * feat: integrate 2FA support into SSO and refactor authentication flow - Centralize 2FA verification logic within the auth store - Extract SSO provider handling to a dedicated helper module - Update API endpoint interfaces to handle 2FA requirements and standard base responses - Unify TOTP and backup code validation paths in the sign-in component * feat: extract SSO logic and UI into a dedicated component - Move SSO provider buttons and authentication handling from `SignIn.vue` to `SsoOptions.vue` - Relocate SSO callback URL parsing and token extraction logic to the new component - Simplify the main sign-in flow by using events to handle 2FA triggers and successful logins - Remove the redundant `sso.ts` helper module as logic is now encapsulated within the component * feat: improve 2FA transition and refine SSO provider UI - Wrap TOTP verification step in a collapse transition to smoothly toggle with backup code input - Simplify SSO provider button rendering by replacing individual transitions with conditional rendering - Update SSO provider labels to "Microsoft Azure" and "Google Cloud" - Ensure SSO section visibility logic consistently returns a boolean value * feat: enhance error handling in SSO callbacks with redirects * feat: extract TOTP verification into a dedicated component - Move 2FA and backup code logic from `SignIn.vue` to the new `TotpForm.vue` component - Replace the standard text input with `n-input-otp` for a specialized 2FA entry experience - Simplify the main sign-in flow by delegating multi-factor authentication states and validation to the sub-component * feat: improve SSO error handling and refine provider UI - Handle and display error messages passed via URL from SSO callbacks - Update Google provider branding by changing the label and icon - Migrate URL parameter processing to the `onBeforeMount` hook for earlier detection and cleaner URL state management * feat: implement user resolution for SSO login and enhance error handling in Cloudflare authentication * feat: enable allowedHosts configuration in Vite server settings * feat: add SentinelOne routes to Stack Provisioning * feat: improve SSO configuration UI and user management layout - Increase Tag RBAC settings modal width for better visibility. - Reformat Azure, Google, and Cloudflare setup instructions using utility classes to improve readability. - Clean up unused component references and remove deprecated custom styles. - Add a TODO for transitioning tag assignments to a modal-based flow. * feat: extract SSO allowlist and configuration into dedicated components - Modularize SSO email allowlisting into a standalone `AllowedEmails.vue` component. - Relocate SSO provider settings from the main user management view to a separate `Config.vue` component. - Refactor `Users.vue` to act as a clean layout container for user listing and access control management. - Improve table styling and UI consistency across user management views. * feat: add dedicated route and navigation for SSO configuration - Register a new `/sso-config` route and create a corresponding view. - Add an "SSO Config" entry to the user avatar toolbar menu for easier access. - Adjust the SSO configuration form layout by removing fixed width constraints. * feat: add navigation link to SSO configuration from allowed emails view - Add an "SSO Configuration" button to the `AllowedEmails` component header to facilitate navigation between SSO settings. - Implement a new `routeSSOConfig` helper in the `useNavigation` composable. * feat: extract TOTP 2FA management into a dedicated component - Relocate 2FA setup, verification, and management logic from the Profile view to a new `TotpToggle` component. - Refactor the Profile page to use a tabbed interface, separating general settings from security options. - Improve the 2FA setup experience with dedicated OTP input handling and better backup code display. * feat: extract 2FA backup codes into a dedicated component - Create `BackupCodesPanel` to centralize the logic for displaying, copying, and downloading backup codes. - Refactor `TotpToggle` to use the new component, reducing boilerplate and improving maintainability. - Ensure consistent styling and behavior for backup code presentation across setup and regeneration flows. * feat: reorganize profile security and refactor change password component - Create a card-based `ChangePassword` wrapper component for the profile security tab. - Move the change password functionality from the profile header to the dedicated security section. - Refactor the base `ChangePassword` component to support dynamic styling and button types, maintaining consistent behavior in the user management list. * feat: restrict SSO configuration menu access to administrators - Conditionally render the "SSO Config" entry in the avatar toolbar based on the user's admin status. - Ensure administrative configuration links are hidden from regular users to prevent unauthorized access attempts and improve UI clarity. * feat: clarify SSO login behavior and allowed emails usage - Update the instructional text in the `AllowedEmails` component to specify that existing users are automatically enabled for SSO. - Clarify that the allowed emails list is primarily for permitting new users to sign in and create accounts. * precommit-fixes --------- Co-authored-by: Maciek <maciek@Mac.lan> Co-authored-by: taylorwalton <taylor.walton@socfortress.co> Co-authored-by: Davide Di Modica <webmaster.ddm@gmail.com>

maciekaz committed Apr 1, 2026 at 22:18 UTC 1677c3171c9236381ed80e48840d0e7101768e11
60 files changed +5542 -67
.env.example
+19
@@ -1,5 +1,22 @@
1 # Leave this as is if connecting from a remote machine
2 SERVER_IP=0.0.0.0
3 +
4 +# SECURITY: JWT secret for token signing.
5 +# ⚠️ The default value below is publicly known — change it before any deployment!
6 +# Generate a unique secret with: openssl rand -base64 32
7 +JWT_SECRET=bL4unrkoxtFs1MT6A7Ns2yMLkduyuqrkTxDV9CjlbNc=
8 +
9 +# SECURITY: Dedicated secret for signing OAuth2 state tokens (SSO flows).
10 +# If not set, falls back to JWT_SECRET — set this to isolate SSO state signing.
11 +# Generate with: openssl rand -base64 32
12 +SSO_STATE_SECRET=REPLACE_ME
13 +
14 +# SECURITY: Fernet encryption key for TOTP secrets stored in the database.
15 +# If not set, falls back to a key derived from JWT_SECRET.
16 +# ⚠️ Once set and users have enrolled 2FA, do NOT change this key — existing
17 +# TOTP secrets in the database will become unreadable.
18 +# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
19 +TOTP_ENCRYPTION_KEY=REPLACE_ME
20 GRAYLOG_API_HEADER_VALUE=ab73de7a-6f61-4dde-87cd-3af5175a7281
21 VELOCIRAPTOR_API_HEADER_VALUE=ab73de7a-6f61-4dde-87cd-3af5175a7281
22
@@ -85,6 +102,8 @@ OPENAI_MODEL=gpt-4o
102
103 # Application Configuration
104 LOG_LEVEL=INFO
105 +# JWT token expiry in minutes (default: 1440 = 24h)
106 +ACCESS_TOKEN_EXPIRE_MINUTES=1440
107
108 # MCP Server Process Management
109 MCP_SERVER_ENABLED=true
.vscode/settings.json
+1
@@ -67,6 +67,7 @@
67 "timefield",
68 "timerange",
69 "timesec",
70 + "totp",
71 "tsbuildinfo",
72 "unocss",
73 "uvicorn",
backend/alembic/versions/a1b2c3d4e5f6_add_sso_tables.py new
+59
@@ -0,0 +1,59 @@
1 +"""add SSO tables
2 +
3 +Revision ID: a1b2c3d4e5f6
4 +Revises: 72635705c067
5 +Create Date: 2026-03-22 00:00:00.000000
6 +
7 +"""
8 +import sqlalchemy as sa
9 +
10 +from alembic import op
11 +
12 +# revision identifiers, used by Alembic.
13 +revision = "a1b2c3d4e5f6"
14 +down_revision = "85ea2970828c"
15 +branch_labels = None
16 +depends_on = None
17 +
18 +
19 +def upgrade() -> None:
20 + op.create_table(
21 + "sso_config",
22 + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
23 + sa.Column("sso_enabled", sa.Boolean(), server_default="0", nullable=False),
24 + # Azure Entra ID
25 + sa.Column("azure_enabled", sa.Boolean(), server_default="0", nullable=False),
26 + sa.Column("azure_tenant_id", sa.String(256), nullable=True),
27 + sa.Column("azure_client_id", sa.String(256), nullable=True),
28 + sa.Column("azure_client_secret", sa.String(512), nullable=True),
29 + sa.Column("azure_redirect_uri", sa.String(512), nullable=True),
30 + # Cloudflare Access
31 + sa.Column("cf_enabled", sa.Boolean(), server_default="0", nullable=False),
32 + sa.Column("cf_team_domain", sa.String(256), nullable=True),
33 + sa.Column("cf_audience", sa.String(512), nullable=True),
34 + sa.Column(
35 + "updated_at",
36 + sa.DateTime(),
37 + server_default=sa.func.now(),
38 + onupdate=sa.func.now(),
39 + nullable=False,
40 + ),
41 + )
42 +
43 + op.create_table(
44 + "sso_allowed_email",
45 + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
46 + sa.Column("email", sa.String(256), nullable=False, index=True),
47 + sa.Column("role_id", sa.Integer(), server_default="2", nullable=False),
48 + sa.Column(
49 + "created_at",
50 + sa.DateTime(),
51 + server_default=sa.func.now(),
52 + nullable=False,
53 + ),
54 + )
55 +
56 +
57 +def downgrade() -> None:
58 + op.drop_table("sso_allowed_email")
59 + op.drop_table("sso_config")
backend/alembic/versions/b2c3d4e5f6a7_add_google_sso_fields.py new
+30
@@ -0,0 +1,30 @@
1 +"""Add Google OAuth2 SSO fields to sso_config table.
2 +
3 +Revision ID: b2c3d4e5f6a7
4 +Revises: a1b2c3d4e5f6
5 +Create Date: 2026-03-23 00:00:00.000000
6 +"""
7 +
8 +import sqlalchemy as sa
9 +
10 +from alembic import op
11 +
12 +# revision identifiers, used by Alembic.
13 +revision = "b2c3d4e5f6a7"
14 +down_revision = "a1b2c3d4e5f6"
15 +branch_labels = None
16 +depends_on = None
17 +
18 +
19 +def upgrade() -> None:
20 + op.add_column("sso_config", sa.Column("google_enabled", sa.Boolean(), nullable=False, server_default="0"))
21 + op.add_column("sso_config", sa.Column("google_client_id", sa.String(256), nullable=True))
22 + op.add_column("sso_config", sa.Column("google_client_secret", sa.String(512), nullable=True))
23 + op.add_column("sso_config", sa.Column("google_redirect_uri", sa.String(512), nullable=True))
24 +
25 +
26 +def downgrade() -> None:
27 + op.drop_column("sso_config", "google_redirect_uri")
28 + op.drop_column("sso_config", "google_client_secret")
29 + op.drop_column("sso_config", "google_client_id")
30 + op.drop_column("sso_config", "google_enabled")
backend/alembic/versions/c3d4e5f6a7b8_add_user_totp_table.py new
+35
@@ -0,0 +1,35 @@
1 +"""Add user_totp table for TOTP 2FA.
2 +
3 +Revision ID: c3d4e5f6a7b8
4 +Revises: b2c3d4e5f6a7
5 +Create Date: 2026-03-23 00:00:00.000000
6 +"""
7 +
8 +import sqlalchemy as sa
9 +
10 +from alembic import op
11 +
12 +# revision identifiers, used by Alembic.
13 +revision = "c3d4e5f6a7b8"
14 +down_revision = "b2c3d4e5f6a7"
15 +branch_labels = None
16 +depends_on = None
17 +
18 +
19 +def upgrade() -> None:
20 + op.create_table(
21 + "user_totp",
22 + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
23 + sa.Column("user_id", sa.Integer(), nullable=False, unique=True),
24 + sa.Column("secret_enc", sa.Text(), nullable=False),
25 + sa.Column("enabled", sa.Boolean(), nullable=False, server_default="0"),
26 + sa.Column("backup_codes", sa.JSON(), nullable=False),
27 + sa.Column("last_used_at", sa.Integer(), nullable=True),
28 + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
29 + )
30 + op.create_index("ix_user_totp_user_id", "user_totp", ["user_id"], unique=True)
31 +
32 +
33 +def downgrade() -> None:
34 + op.drop_index("ix_user_totp_user_id", table_name="user_totp")
35 + op.drop_table("user_totp")
backend/alembic/versions/d000000000000_merge_sso_with_upstream.py new
+22
@@ -0,0 +1,22 @@
1 +"""merge SSO/TOTP migrations with upstream head
2 +
3 +Revision ID: d000000000000
4 +Revises: c3d4e5f6a7b8, 895843b96397
5 +Create Date: 2026-03-25 00:00:00.000000
6 +
7 +"""
8 +from typing import Union
9 +
10 +# revision identifiers, used by Alembic.
11 +revision: str = "d000000000000"
12 +down_revision: Union[str, None] = ("c3d4e5f6a7b8", "895843b96397")
13 +branch_labels = None
14 +depends_on = None
15 +
16 +
17 +def upgrade() -> None:
18 + pass
19 +
20 +
21 +def downgrade() -> None:
22 + pass
backend/app/auth/models/sso.py new
+124
@@ -0,0 +1,124 @@
1 +"""SSO configuration and allowed email models."""
2 +
3 +import datetime
4 +from typing import Optional
5 +
6 +from pydantic import BaseModel
7 +from pydantic import EmailStr
8 +from sqlmodel import Field
9 +from sqlmodel import SQLModel
10 +
11 +
12 +class SSOConfig(SQLModel, table=True):
13 + """Stores SSO provider configuration. Only one row should exist (id=1)."""
14 +
15 + __tablename__ = "sso_config"
16 +
17 + id: Optional[int] = Field(default=None, primary_key=True)
18 +
19 + # Global toggle
20 + sso_enabled: bool = Field(default=False)
21 +
22 + # --- Azure Entra ID (OAuth2 / OIDC) ---
23 + azure_enabled: bool = Field(default=False)
24 + azure_tenant_id: Optional[str] = Field(default=None, max_length=256)
25 + azure_client_id: Optional[str] = Field(default=None, max_length=256)
26 + azure_client_secret: Optional[str] = Field(default=None, max_length=512)
27 + azure_redirect_uri: Optional[str] = Field(default=None, max_length=512)
28 +
29 + # --- Google OAuth2 / OIDC ---
30 + google_enabled: bool = Field(default=False)
31 + google_client_id: Optional[str] = Field(default=None, max_length=256)
32 + google_client_secret: Optional[str] = Field(default=None, max_length=512)
33 + google_redirect_uri: Optional[str] = Field(default=None, max_length=512)
34 +
35 + # --- Cloudflare Access (JWT assertion) ---
36 + cf_enabled: bool = Field(default=False)
37 + cf_team_domain: Optional[str] = Field(default=None, max_length=256)
38 + cf_audience: Optional[str] = Field(default=None, max_length=512)
39 +
40 + updated_at: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
41 +
42 +
43 +class SSOAllowedEmail(SQLModel, table=True):
44 + """Allowlist of emails permitted to authenticate via SSO."""
45 +
46 + __tablename__ = "sso_allowed_email"
47 +
48 + id: Optional[int] = Field(default=None, primary_key=True)
49 + email: str = Field(max_length=256, index=True)
50 + role_id: int = Field(default=2) # default to analyst
51 + created_at: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
52 +
53 +
54 +# ── Pydantic request / response schemas ──────────────────────────────────────
55 +
56 +
57 +class SSOConfigUpdate(BaseModel):
58 + """Request body for updating SSO settings."""
59 +
60 + sso_enabled: bool = False
61 +
62 + azure_enabled: bool = False
63 + azure_tenant_id: Optional[str] = None
64 + azure_client_id: Optional[str] = None
65 + azure_client_secret: Optional[str] = None
66 + azure_redirect_uri: Optional[str] = None
67 +
68 + google_enabled: bool = False
69 + google_client_id: Optional[str] = None
70 + google_client_secret: Optional[str] = None
71 + google_redirect_uri: Optional[str] = None
72 +
73 + cf_enabled: bool = False
74 + cf_team_domain: Optional[str] = None
75 + cf_audience: Optional[str] = None
76 +
77 +
78 +class SSOConfigResponse(BaseModel):
79 + """Response body — never exposes the client_secret in full."""
80 +
81 + sso_enabled: bool
82 + azure_enabled: bool
83 + azure_tenant_id: Optional[str] = None
84 + azure_client_id: Optional[str] = None
85 + azure_client_secret_set: bool = False # True if a secret is stored
86 + azure_redirect_uri: Optional[str] = None
87 + google_enabled: bool = False
88 + google_client_id: Optional[str] = None
89 + google_client_secret_set: bool = False # True if a secret is stored
90 + google_redirect_uri: Optional[str] = None
91 + cf_enabled: bool
92 + cf_team_domain: Optional[str] = None
93 + cf_audience: Optional[str] = None
94 + message: str = "SSO configuration retrieved"
95 + success: bool = True
96 +
97 +
98 +class SSOAllowedEmailInput(BaseModel):
99 + email: EmailStr
100 + role_id: int = 2 # analyst by default
101 +
102 +
103 +class SSOAllowedEmailOut(BaseModel):
104 + id: int
105 + email: str
106 + role_id: int
107 + created_at: datetime.datetime
108 +
109 +
110 +class SSOAllowedEmailListResponse(BaseModel):
111 + emails: list
112 + message: str = "Allowed emails retrieved"
113 + success: bool = True
114 +
115 +
116 +class SSOPublicStatusResponse(BaseModel):
117 + """Public endpoint — tells the login page which SSO providers are active."""
118 +
119 + sso_enabled: bool
120 + azure_enabled: bool
121 + google_enabled: bool = False
122 + cf_enabled: bool
123 + azure_authorization_url: Optional[str] = None
124 + google_authorization_url: Optional[str] = None
backend/app/auth/models/totp.py new
+76
@@ -0,0 +1,76 @@
1 +"""TOTP two-factor authentication models and schemas."""
2 +
3 +import datetime
4 +from typing import Optional
5 +
6 +from pydantic import BaseModel
7 +from sqlmodel import JSON
8 +from sqlmodel import Column
9 +from sqlmodel import Field
10 +from sqlmodel import SQLModel
11 +from sqlmodel import Text
12 +
13 +
14 +class UserTOTP(SQLModel, table=True):
15 + """Stores per-user TOTP 2FA configuration."""
16 +
17 + __tablename__ = "user_totp"
18 +
19 + id: Optional[int] = Field(default=None, primary_key=True)
20 + user_id: int = Field(unique=True, index=True)
21 +
22 + # Fernet-encrypted TOTP secret
23 + secret_enc: str = Field(sa_column=Column(Text, nullable=False))
24 +
25 + # Not enabled until user verifies with a valid code
26 + enabled: bool = Field(default=False)
27 +
28 + # List of {hash: str, used: bool} — bcrypt hashed backup codes
29 + backup_codes: list = Field(default=[], sa_column=Column(JSON, nullable=False))
30 +
31 + # Last TOTP counter used — prevents replay of same code
32 + last_used_at: Optional[int] = Field(default=None)
33 +
34 + created_at: datetime.datetime = Field(default_factory=datetime.datetime.utcnow)
35 +
36 +
37 +# ── Pydantic request / response schemas ──────────────────────────────────────
38 +
39 +
40 +class TOTPSetupResponse(BaseModel):
41 + """Returned on setup — contains secret, QR data, and one-time backup codes."""
42 +
43 + secret: str
44 + otpauth_url: str
45 + qr_data_uri: str
46 + backup_codes: list[str]
47 + message: str = "Scan the QR code with your authenticator app, then verify with a code."
48 +
49 +
50 +class TOTPVerifyRequest(BaseModel):
51 + code: str
52 +
53 +
54 +class TOTPDisableRequest(BaseModel):
55 + code: Optional[str] = None
56 + backup_code: Optional[str] = None
57 +
58 +
59 +class TOTPValidateRequest(BaseModel):
60 + """Used during login — temp_token + TOTP code or backup code."""
61 +
62 + temp_token: str
63 + code: Optional[str] = None
64 + backup_code: Optional[str] = None
65 +
66 +
67 +class TOTPStatusResponse(BaseModel):
68 + enabled: bool
69 + message: str = "2FA status retrieved"
70 + success: bool = True
71 +
72 +
73 +class TOTPBackupCodesResponse(BaseModel):
74 + backup_codes: list[str]
75 + message: str = "New backup codes generated. Save them — they are shown only once."
76 + success: bool = True
backend/app/auth/models/users.py
+12 -12
@@ -1,6 +1,6 @@
1 import datetime
2 -import random
2 import re
3 +import secrets
4 import string
5 from enum import Enum
6 from typing import List
@@ -160,23 +160,23 @@ class Password(BaseModel):
160 lowercase = string.ascii_lowercase
161 uppercase = string.ascii_uppercase
162 digits = string.digits
163 + special = "@$!%*?&#"
164
164 - # Ensure the password has at least one lowercase, one uppercase, one digit, and one symbol
165 + # Ensure the password has at least one lowercase, one uppercase, one digit, and one special char
166 password_chars = [
166 - random.choice(lowercase),
167 - random.choice(uppercase),
168 - random.choice(digits),
167 + secrets.choice(lowercase),
168 + secrets.choice(uppercase),
169 + secrets.choice(digits),
170 + secrets.choice(special),
171 ]
172
171 - # Fill the rest of the password length with a random mix of characters
173 + # Fill the rest of the password length with a cryptographically secure random mix
174 + alphabet = lowercase + uppercase + digits + special
175 if length > 4:
173 - password_chars += random.choices(
174 - lowercase + uppercase + digits,
175 - k=length - 4,
176 - )
176 + password_chars += [secrets.choice(alphabet) for _ in range(length - 4)]
177
178 - # Shuffle the resulting password list to avoid predictable patterns
179 - random.shuffle(password_chars)
178 + # Shuffle using secrets-backed SystemRandom to avoid predictable patterns
179 + secrets.SystemRandom().shuffle(password_chars)
180
181 # Convert the list of characters into a string
182 password = "".join(password_chars)
backend/app/auth/routes/auth.py
+18 -3
@@ -1,3 +1,4 @@
1 +import os
2 from datetime import timedelta
3
4 from fastapi import APIRouter
@@ -20,19 +21,20 @@ from app.auth.schema.auth import UpdateUserRoleRequest
21 from app.auth.schema.auth import UserLoginResponse
22 from app.auth.schema.auth import UserResponse
23 from app.auth.schema.user import UserBaseResponse
24 +from app.auth.services.totp import is_2fa_enabled
25 from app.auth.services.universal import delete_user
26 from app.auth.services.universal import find_user
27 from app.auth.services.universal import select_all_users
28 from app.auth.utils import AuthHandler
29 from app.db.db_session import get_db
30
29 -ACCESS_TOKEN_EXPIRE_MINUTES = 1440
31 +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "1440"))
32
33 auth_router = APIRouter()
34 auth_handler = AuthHandler()
35
36
35 -@auth_router.post("/token", response_model=Token)
37 +@auth_router.post("/token")
38 async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), session: AsyncSession = Depends(get_db)):
39 """
40 Authenticates a user and generates an access token.
@@ -64,6 +66,18 @@ async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(
66 headers={"WWW-Authenticate": "Bearer"},
67 )
68
69 + # Check if user has 2FA enabled
70 + if await is_2fa_enabled(user.id):
71 + from app.auth.routes.totp import _create_temp_token
72 +
73 + temp_token = _create_temp_token(user.username)
74 + logger.info(f"User {user.username} requires 2FA verification")
75 + return {
76 + "access_token": temp_token,
77 + "token_type": "bearer",
78 + "requires_2fa": True,
79 + }
80 +
81 access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
82 access_token = await auth_handler.encode_token(user.username, access_token_expires)
83 logger.info(f"User {user.username} logged in successfully")
@@ -199,7 +213,7 @@ async def login(user: UserLogin, session: AsyncSession = Depends(get_db)):
213 detail="This account is registered for the Customer Portal only. Please log in at the Customer Portal to access your account.",
214 )
215
202 - token = auth_handler.encode_token(user_found.username)
216 + token = await auth_handler.encode_token(user_found.username)
217 return {"token": token, "success": True, "message": "Login successful"}
218
219
@@ -251,6 +265,7 @@ async def get_users(session: AsyncSession = Depends(get_db)):
265 status_code=200,
266 description="Request password reset",
267 include_in_schema=False,
268 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
269 )
270 async def request_password_reset(
271 password_reset_request: PasswordResetToken,
backend/app/auth/routes/sso.py new
+410
@@ -0,0 +1,410 @@
1 +"""SSO routes — settings management + Azure, Google & Cloudflare login flows."""
2 +
3 +import os
4 +from datetime import timedelta
5 +from urllib.parse import quote
6 +
7 +from fastapi import APIRouter
8 +from fastapi import HTTPException
9 +from fastapi import Request
10 +from fastapi import Security
11 +from fastapi.responses import RedirectResponse
12 +from loguru import logger
13 +
14 +from app.auth.models.sso import SSOAllowedEmailInput
15 +from app.auth.models.sso import SSOAllowedEmailListResponse
16 +from app.auth.models.sso import SSOAllowedEmailOut
17 +from app.auth.models.sso import SSOConfigResponse
18 +from app.auth.models.sso import SSOConfigUpdate
19 +from app.auth.models.sso import SSOPublicStatusResponse
20 +from app.auth.routes.totp import _create_temp_token
21 +from app.auth.services.sso import add_allowed_email
22 +from app.auth.services.sso import build_azure_auth_url
23 +from app.auth.services.sso import build_google_auth_url
24 +from app.auth.services.sso import delete_allowed_email
25 +from app.auth.services.sso import exchange_azure_code
26 +from app.auth.services.sso import exchange_google_code
27 +from app.auth.services.sso import find_allowed_email
28 +from app.auth.services.sso import find_user_by_email
29 +from app.auth.services.sso import get_or_create_sso_user
30 +from app.auth.services.sso import get_sso_config
31 +from app.auth.services.sso import list_allowed_emails
32 +from app.auth.services.sso import upsert_sso_config
33 +from app.auth.services.sso import validate_cf_jwt
34 +from app.auth.services.totp import is_2fa_enabled
35 +from app.auth.utils import AuthHandler
36 +
37 +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "1440"))
38 +
39 +sso_router = APIRouter()
40 +auth_handler = AuthHandler()
41 +
42 +
43 +def _error_redirect(detail: str) -> RedirectResponse:
44 + """Redirect to the login page with an error message in the query string."""
45 + return RedirectResponse(url=f"/login?error_message={quote(detail)}")
46 +
47 +
48 +async def _resolve_sso_user(email: str):
49 + """
50 + Resolve an SSO login to a user account.
51 + - Existing users log in directly (no allowlist check).
52 + - New users require an allowlist entry for auto-provisioning.
53 + """
54 + user = await find_user_by_email(email)
55 + if user:
56 + return user
57 +
58 + # New user — require allowlist entry for provisioning
59 + allowed = await find_allowed_email(email)
60 + if allowed is None:
61 + raise ValueError(
62 + f"Email {email} is not authorized for SSO access. Contact your administrator.",
63 + )
64 +
65 + return await get_or_create_sso_user(email, role_id=allowed.role_id)
66 +
67 +
68 +async def _issue_token_or_2fa(user, auth_handler) -> dict:
69 + """
70 + Issue a full CoPilot JWT, or — if the user has 2FA enabled — a short-lived
71 + temp token that the frontend must exchange via /auth/2fa/validate first.
72 + Returns a dict with at minimum {token, is_2fa}.
73 + """
74 + if await is_2fa_enabled(user.id):
75 + temp_token = _create_temp_token(user.username)
76 + return {"token": temp_token, "is_2fa": True}
77 +
78 + expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
79 + token = await auth_handler.encode_token(user.username, expires)
80 + return {"token": token, "is_2fa": False}
81 +
82 +
83 +# ── Public: SSO status (used by login page) ──────────────────────────────────
84 +
85 +
86 +@sso_router.get("/sso/status", response_model=SSOPublicStatusResponse)
87 +async def sso_public_status():
88 + """
89 + Public endpoint — returns which SSO providers are active.
90 + The login page uses this to decide whether to show SSO buttons.
91 + """
92 + cfg = await get_sso_config()
93 + if cfg is None or not cfg.sso_enabled:
94 + return SSOPublicStatusResponse(
95 + sso_enabled=False,
96 + azure_enabled=False,
97 + cf_enabled=False,
98 + )
99 +
100 + return SSOPublicStatusResponse(
101 + sso_enabled=True,
102 + azure_enabled=cfg.azure_enabled,
103 + google_enabled=cfg.google_enabled,
104 + cf_enabled=cfg.cf_enabled,
105 + )
106 +
107 +
108 +# ── Admin: SSO settings CRUD ─────────────────────────────────────────────────
109 +
110 +
111 +@sso_router.get(
112 + "/sso/settings",
113 + response_model=SSOConfigResponse,
114 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
115 +)
116 +async def get_sso_settings():
117 + """Retrieve current SSO configuration. Admin only."""
118 + cfg = await get_sso_config()
119 + if cfg is None:
120 + return SSOConfigResponse(
121 + sso_enabled=False,
122 + azure_enabled=False,
123 + google_enabled=False,
124 + cf_enabled=False,
125 + message="No SSO configuration found",
126 + )
127 + return SSOConfigResponse(
128 + sso_enabled=cfg.sso_enabled,
129 + azure_enabled=cfg.azure_enabled,
130 + azure_tenant_id=cfg.azure_tenant_id,
131 + azure_client_id=cfg.azure_client_id,
132 + azure_client_secret_set=bool(cfg.azure_client_secret),
133 + azure_redirect_uri=cfg.azure_redirect_uri,
134 + google_enabled=cfg.google_enabled,
135 + google_client_id=cfg.google_client_id,
136 + google_client_secret_set=bool(cfg.google_client_secret),
137 + google_redirect_uri=cfg.google_redirect_uri,
138 + cf_enabled=cfg.cf_enabled,
139 + cf_team_domain=cfg.cf_team_domain,
140 + cf_audience=cfg.cf_audience,
141 + )
142 +
143 +
144 +@sso_router.put(
145 + "/sso/settings",
146 + response_model=SSOConfigResponse,
147 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
148 +)
149 +async def update_sso_settings(body: SSOConfigUpdate):
150 + """Update SSO configuration. Admin only."""
151 + data = body.dict(exclude_none=False)
152 +
153 + # Don't overwrite secrets if empty/None
154 + if not data.get("azure_client_secret"):
155 + data.pop("azure_client_secret", None)
156 + if not data.get("google_client_secret"):
157 + data.pop("google_client_secret", None)
158 +
159 + cfg = await upsert_sso_config(data)
160 + logger.info(
161 + f"SSO config updated: sso_enabled={cfg.sso_enabled}, "
162 + f"azure={cfg.azure_enabled}, google={cfg.google_enabled}, cf={cfg.cf_enabled}",
163 + )
164 +
165 + return SSOConfigResponse(
166 + sso_enabled=cfg.sso_enabled,
167 + azure_enabled=cfg.azure_enabled,
168 + azure_tenant_id=cfg.azure_tenant_id,
169 + azure_client_id=cfg.azure_client_id,
170 + azure_client_secret_set=bool(cfg.azure_client_secret),
171 + azure_redirect_uri=cfg.azure_redirect_uri,
172 + google_enabled=cfg.google_enabled,
173 + google_client_id=cfg.google_client_id,
174 + google_client_secret_set=bool(cfg.google_client_secret),
175 + google_redirect_uri=cfg.google_redirect_uri,
176 + cf_enabled=cfg.cf_enabled,
177 + cf_team_domain=cfg.cf_team_domain,
178 + cf_audience=cfg.cf_audience,
179 + message="SSO configuration updated successfully",
180 + )
181 +
182 +
183 +# ── Admin: Allowed emails CRUD ───────────────────────────────────────────────
184 +
185 +
186 +@sso_router.get(
187 + "/sso/allowed-emails",
188 + response_model=SSOAllowedEmailListResponse,
189 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
190 +)
191 +async def get_allowed_emails():
192 + """List all SSO-allowed emails. Admin only."""
193 + emails = await list_allowed_emails()
194 + return SSOAllowedEmailListResponse(
195 + emails=[
196 + SSOAllowedEmailOut(
197 + id=e.id,
198 + email=e.email,
199 + role_id=e.role_id,
200 + created_at=e.created_at,
201 + )
202 + for e in emails
203 + ],
204 + )
205 +
206 +
207 +@sso_router.post(
208 + "/sso/allowed-emails",
209 + status_code=201,
210 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
211 +)
212 +async def create_allowed_email(body: SSOAllowedEmailInput):
213 + """Add an email to the SSO allowlist. Admin only."""
214 + try:
215 + entry = await add_allowed_email(body.email, body.role_id)
216 + except ValueError as e:
217 + raise HTTPException(status_code=400, detail=str(e))
218 + return {
219 + "message": f"Email {body.email} added to SSO allowlist",
220 + "success": True,
221 + "id": entry.id,
222 + }
223 +
224 +
225 +@sso_router.delete(
226 + "/sso/allowed-emails/{email_id}",
227 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
228 +)
229 +async def remove_allowed_email(email_id: int):
230 + """Remove an email from the SSO allowlist. Admin only."""
231 + ok = await delete_allowed_email(email_id)
232 + if not ok:
233 + raise HTTPException(status_code=404, detail="Email entry not found")
234 + return {"message": "Email removed from SSO allowlist", "success": True}
235 +
236 +
237 +# ── Azure Entra ID: OAuth2 flow ──────────────────────────────────────────────
238 +
239 +
240 +@sso_router.get("/sso/azure/login")
241 +async def azure_login():
242 + """Redirect user to Azure Entra ID authorization page."""
243 + cfg = await get_sso_config()
244 + if cfg is None or not cfg.sso_enabled or not cfg.azure_enabled:
245 + raise HTTPException(status_code=400, detail="Azure SSO is not enabled")
246 + url = build_azure_auth_url(cfg)
247 + return RedirectResponse(url=url)
248 +
249 +
250 +@sso_router.get("/sso/azure/callback")
251 +async def azure_callback(code: str = None, state: str = None, error: str = None):
252 + """
253 + Azure Entra ID OAuth2 callback.
254 + Exchanges the authorization code for tokens, validates the ID token,
255 + checks the email allowlist, and returns a CoPilot JWT.
256 + """
257 + if error:
258 + return _error_redirect(f"Azure auth error: {error}")
259 + if not code or not state:
260 + return _error_redirect("Missing code or state parameter")
261 +
262 + cfg = await get_sso_config()
263 + if cfg is None or not cfg.sso_enabled or not cfg.azure_enabled:
264 + return _error_redirect("Azure SSO is not enabled")
265 +
266 + try:
267 + claims = await exchange_azure_code(code, state, cfg)
268 + except ValueError as e:
269 + return _error_redirect(str(e))
270 + except Exception as e:
271 + logger.error(f"Azure SSO error: {e}")
272 + return _error_redirect("Azure authentication failed")
273 +
274 + email = claims.get("email") or claims.get("preferred_username")
275 + if not email:
276 + return _error_redirect("No email claim in Azure ID token")
277 +
278 + if not claims.get("email_verified", True):
279 + return _error_redirect("Azure account email is not verified")
280 +
281 + # Existing users log in directly; new users require allowlist entry
282 + try:
283 + user = await _resolve_sso_user(email.lower())
284 + except ValueError as e:
285 + return _error_redirect(str(e))
286 +
287 + # Issue token (full or 2FA-pending)
288 + result = await _issue_token_or_2fa(user, auth_handler)
289 + logger.info(f"SSO Azure login: {user.username} ({email}), 2fa={result['is_2fa']}")
290 +
291 + redirect_url = f"/sso-callback#token={result['token']}"
292 + if result["is_2fa"]:
293 + redirect_url += "&requires_2fa=true"
294 + return RedirectResponse(url=redirect_url)
295 +
296 +
297 +# ── Google OAuth2: authorization code flow ───────────────────────────────────
298 +
299 +
300 +@sso_router.get("/sso/google/login")
301 +async def google_login():
302 + """Redirect user to Google authorization page."""
303 + cfg = await get_sso_config()
304 + if cfg is None or not cfg.sso_enabled or not cfg.google_enabled:
305 + raise HTTPException(status_code=400, detail="Google SSO is not enabled")
306 + url = build_google_auth_url(cfg)
307 + return RedirectResponse(url=url)
308 +
309 +
310 +@sso_router.get("/sso/google/callback")
311 +async def google_callback(code: str = None, state: str = None, error: str = None):
312 + """
313 + Google OAuth2 callback.
314 + Exchanges the authorization code for tokens, validates the ID token,
315 + checks the email allowlist, and returns a CoPilot JWT.
316 + """
317 + if error:
318 + return _error_redirect(f"Google auth error: {error}")
319 + if not code or not state:
320 + return _error_redirect("Missing code or state parameter")
321 +
322 + cfg = await get_sso_config()
323 + if cfg is None or not cfg.sso_enabled or not cfg.google_enabled:
324 + return _error_redirect("Google SSO is not enabled")
325 +
326 + try:
327 + claims = await exchange_google_code(code, state, cfg)
328 + except ValueError as e:
329 + return _error_redirect(str(e))
330 + except Exception as e:
331 + logger.error(f"Google SSO error: {e}")
332 + return _error_redirect("Google authentication failed")
333 +
334 + email = claims.get("email")
335 + if not email:
336 + return _error_redirect("No email claim in Google ID token")
337 +
338 + if not claims.get("email_verified", False):
339 + return _error_redirect("Google account email is not verified")
340 +
341 + # Existing users log in directly; new users require allowlist entry
342 + try:
343 + user = await _resolve_sso_user(email.lower())
344 + except ValueError as e:
345 + return _error_redirect(str(e))
346 +
347 + # Issue token (full or 2FA-pending)
348 + result = await _issue_token_or_2fa(user, auth_handler)
349 + logger.info(f"SSO Google login: {user.username} ({email}), 2fa={result['is_2fa']}")
350 +
351 + redirect_url = f"/sso-callback#token={result['token']}"
352 + if result["is_2fa"]:
353 + redirect_url += "&requires_2fa=true"
354 + return RedirectResponse(url=redirect_url)
355 +
356 +
357 +# ── Cloudflare Access: JWT validation flow ───────────────────────────────────
358 +
359 +
360 +@sso_router.post("/sso/cloudflare/verify")
361 +async def cloudflare_verify(request: Request):
362 + """
363 + Validate the Cf-Access-Jwt-Assertion header from Cloudflare Access.
364 + Returns a CoPilot JWT if the email is in the allowlist.
365 + """
366 + cfg = await get_sso_config()
367 + if cfg is None or not cfg.sso_enabled or not cfg.cf_enabled:
368 + raise HTTPException(status_code=400, detail="Cloudflare Access SSO is not enabled")
369 +
370 + # Extract JWT from header (preferred) or cookie
371 + cf_token = request.headers.get("Cf-Access-Jwt-Assertion")
372 + if not cf_token:
373 + cf_token = request.cookies.get("CF_Authorization")
374 + if not cf_token:
375 + raise HTTPException(
376 + status_code=401,
377 + detail="No Cloudflare Access JWT found in request headers or cookies",
378 + )
379 +
380 + try:
381 + claims = await validate_cf_jwt(cf_token, cfg)
382 + except ValueError as e:
383 + raise HTTPException(status_code=401, detail=str(e))
384 + except Exception as e:
385 + logger.error(f"Cloudflare Access SSO error: {e}")
386 + raise HTTPException(status_code=500, detail="Cloudflare authentication failed")
387 +
388 + email = claims.get("email")
389 + if not email:
390 + raise HTTPException(status_code=400, detail="No email claim in Cloudflare JWT")
391 +
392 + # Existing users log in directly; new users require allowlist entry
393 + try:
394 + user = await _resolve_sso_user(email.lower())
395 + except ValueError as e:
396 + raise HTTPException(status_code=403, detail=str(e))
397 +
398 + # Issue token (full or 2FA-pending)
399 + result = await _issue_token_or_2fa(user, auth_handler)
400 + logger.info(f"SSO Cloudflare login: {user.username} ({email}), 2fa={result['is_2fa']}")
401 +
402 + response = {
403 + "access_token": result["token"],
404 + "token_type": "bearer",
405 + "message": "Cloudflare Access authentication successful",
406 + "success": True,
407 + }
408 + if result["is_2fa"]:
409 + response["requires_2fa"] = True
410 + return response
backend/app/auth/routes/totp.py new
+224
@@ -0,0 +1,224 @@
1 +"""TOTP 2FA routes — setup, verify, validate at login, disable, regenerate backup codes."""
2 +
3 +import os
4 +from datetime import datetime
5 +from datetime import timedelta
6 +
7 +import jwt
8 +from fastapi import APIRouter
9 +from fastapi import Depends
10 +from fastapi import HTTPException
11 +from fastapi import Security
12 +from loguru import logger
13 +
14 +from app.auth.models.totp import TOTPBackupCodesResponse
15 +from app.auth.models.totp import TOTPDisableRequest
16 +from app.auth.models.totp import TOTPSetupResponse
17 +from app.auth.models.totp import TOTPStatusResponse
18 +from app.auth.models.totp import TOTPValidateRequest
19 +from app.auth.models.totp import TOTPVerifyRequest
20 +from app.auth.services.totp import disable_totp
21 +from app.auth.services.totp import is_2fa_enabled
22 +from app.auth.services.totp import regenerate_backup_codes
23 +from app.auth.services.totp import setup_totp
24 +from app.auth.services.totp import validate_totp
25 +from app.auth.services.totp import verify_setup
26 +from app.auth.services.universal import find_user
27 +from app.auth.utils import AuthHandler
28 +
29 +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "1440"))
30 +
31 +totp_router = APIRouter()
32 +auth_handler = AuthHandler()
33 +
34 +_jwt_secret = auth_handler.secret
35 +
36 +
37 +# ── Helper: temp token for 2FA pending state ─────────────────────────────────
38 +
39 +
40 +def _create_temp_token(username: str) -> str:
41 + """Create a short-lived JWT for 2FA verification step (5 min)."""
42 + payload = {
43 + "sub": username,
44 + "exp": datetime.utcnow() + timedelta(minutes=5),
45 + "iat": datetime.utcnow(),
46 + "type": "2fa_pending",
47 + }
48 + return jwt.encode(payload, _jwt_secret, algorithm="HS256")
49 +
50 +
51 +def _decode_temp_token(token: str) -> str:
52 + """Decode a 2FA temp token and return the username. Raises ValueError on failure."""
53 + try:
54 + payload = jwt.decode(token, _jwt_secret, algorithms=["HS256"])
55 + if payload.get("type") != "2fa_pending":
56 + raise ValueError("Invalid token type")
57 + username = payload.get("sub")
58 + if not username:
59 + raise ValueError("No username in token")
60 + return username
61 + except jwt.ExpiredSignatureError:
62 + raise ValueError("2FA verification token has expired. Please log in again.")
63 + except jwt.InvalidTokenError:
64 + raise ValueError("Invalid 2FA verification token.")
65 +
66 +
67 +# ── Status ───────────────────────────────────────────────────────────────────
68 +
69 +
70 +@totp_router.get(
71 + "/2fa/status",
72 + response_model=TOTPStatusResponse,
73 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
74 +)
75 +async def get_2fa_status(
76 + token: str = Depends(AuthHandler().security),
77 +):
78 + """Check if 2FA is enabled for the current user."""
79 + username, _ = auth_handler.decode_token(token)
80 + user = await find_user(username)
81 + if not user:
82 + raise HTTPException(status_code=401, detail="User not found")
83 +
84 + enabled = await is_2fa_enabled(user.id)
85 + return TOTPStatusResponse(enabled=enabled)
86 +
87 +
88 +# ── Setup ────────────────────────────────────────────────────────────────────
89 +
90 +
91 +@totp_router.post(
92 + "/2fa/setup",
93 + response_model=TOTPSetupResponse,
94 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
95 +)
96 +async def start_2fa_setup(
97 + token: str = Depends(AuthHandler().security),
98 +):
99 + """Generate a new TOTP secret, QR code, and backup codes. Does NOT activate until verified."""
100 + username, _ = auth_handler.decode_token(token)
101 + user = await find_user(username)
102 + if not user:
103 + raise HTTPException(status_code=401, detail="User not found")
104 +
105 + try:
106 + result = await setup_totp(user.id, user.username)
107 + except ValueError as e:
108 + raise HTTPException(status_code=400, detail=str(e))
109 +
110 + return TOTPSetupResponse(**result)
111 +
112 +
113 +# ── Verify setup (activate) ─────────────────────────────────────────────────
114 +
115 +
116 +@totp_router.post(
117 + "/2fa/verify-setup",
118 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
119 +)
120 +async def verify_2fa_setup(
121 + body: TOTPVerifyRequest,
122 + token: str = Depends(AuthHandler().security),
123 +):
124 + """Verify a TOTP code to activate 2FA."""
125 + username, _ = auth_handler.decode_token(token)
126 + user = await find_user(username)
127 + if not user:
128 + raise HTTPException(status_code=401, detail="User not found")
129 +
130 + try:
131 + await verify_setup(user.id, body.code)
132 + except ValueError as e:
133 + raise HTTPException(status_code=400, detail=str(e))
134 +
135 + return {"message": "Two-factor authentication is now enabled.", "success": True}
136 +
137 +
138 +# ── Disable ──────────────────────────────────────────────────────────────────
139 +
140 +
141 +@totp_router.delete(
142 + "/2fa/disable",
143 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
144 +)
145 +async def disable_2fa(
146 + body: TOTPDisableRequest,
147 + token: str = Depends(AuthHandler().security),
148 +):
149 + """Disable 2FA. Requires a valid TOTP code or backup code."""
150 + username, _ = auth_handler.decode_token(token)
151 + user = await find_user(username)
152 + if not user:
153 + raise HTTPException(status_code=401, detail="User not found")
154 +
155 + if not body.code and not body.backup_code:
156 + raise HTTPException(status_code=400, detail="Provide a TOTP code or backup code.")
157 +
158 + try:
159 + await disable_totp(user.id, code=body.code, backup_code=body.backup_code)
160 + except ValueError as e:
161 + raise HTTPException(status_code=400, detail=str(e))
162 +
163 + return {"message": "Two-factor authentication has been disabled.", "success": True}
164 +
165 +
166 +# ── Validate at login ────────────────────────────────────────────────────────
167 +
168 +
169 +@totp_router.post("/2fa/validate")
170 +async def validate_2fa_login(body: TOTPValidateRequest):
171 + """
172 + Validate a TOTP code or backup code during login.
173 + Accepts the temp_token issued by /auth/token when 2FA is required.
174 + Returns a full access token on success.
175 + """
176 + try:
177 + username = _decode_temp_token(body.temp_token)
178 + except ValueError as e:
179 + raise HTTPException(status_code=401, detail=str(e))
180 +
181 + user = await find_user(username)
182 + if not user:
183 + raise HTTPException(status_code=401, detail="User not found")
184 +
185 + if not body.code and not body.backup_code:
186 + raise HTTPException(status_code=400, detail="Provide a TOTP code or backup code.")
187 +
188 + try:
189 + await validate_totp(user.id, code=body.code, backup_code=body.backup_code)
190 + except ValueError as e:
191 + raise HTTPException(status_code=401, detail=str(e))
192 +
193 + # Issue full access token
194 + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
195 + access_token = await auth_handler.encode_token(user.username, access_token_expires)
196 + logger.info(f"2FA login completed for {user.username}")
197 +
198 + return {"access_token": access_token, "token_type": "bearer"}
199 +
200 +
201 +# ── Regenerate backup codes ──────────────────────────────────────────────────
202 +
203 +
204 +@totp_router.post(
205 + "/2fa/backup-codes/regenerate",
206 + response_model=TOTPBackupCodesResponse,
207 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
208 +)
209 +async def regenerate_2fa_backup_codes(
210 + body: TOTPVerifyRequest,
211 + token: str = Depends(AuthHandler().security),
212 +):
213 + """Regenerate backup codes. Requires a valid TOTP code. Old codes are invalidated."""
214 + username, _ = auth_handler.decode_token(token)
215 + user = await find_user(username)
216 + if not user:
217 + raise HTTPException(status_code=401, detail="User not found")
218 +
219 + try:
220 + codes = await regenerate_backup_codes(user.id, body.code)
221 + except ValueError as e:
222 + raise HTTPException(status_code=400, detail=str(e))
223 +
224 + return TOTPBackupCodesResponse(backup_codes=codes)
backend/app/auth/services/sso.py new
+421
@@ -0,0 +1,421 @@
1 +"""SSO business logic — Azure Entra ID, Google & Cloudflare Access."""
2 +
3 +import datetime
4 +import hashlib
5 +import hmac
6 +import os
7 +import re
8 +import secrets
9 +from typing import Optional
10 +from urllib.parse import urlencode
11 +
12 +import httpx
13 +import jwt
14 +from jwt.algorithms import RSAAlgorithm
15 +from loguru import logger
16 +from sqlalchemy.ext.asyncio import AsyncSession
17 +from sqlmodel import select
18 +
19 +from app.auth.models.sso import SSOAllowedEmail
20 +from app.auth.models.sso import SSOConfig
21 +from app.auth.models.users import User
22 +from app.auth.utils import AuthHandler
23 +from app.db.db_session import async_engine
24 +
25 +# ── HMAC-signed OAuth2 state (stateless, multi-worker safe) ──────────────────
26 +# State format: "<nonce>:<unix_ts>:<hmac_hex>"
27 +# Self-validating — no in-memory or DB store needed. Works across multiple
28 +# uvicorn workers and survives process restarts.
29 +_STATE_TTL_SECONDS = 600
30 +# Use a dedicated SSO_STATE_SECRET if provided; fall back to JWT_SECRET so
31 +# existing deployments that haven't set this variable continue to work.
32 +_STATE_SECRET = os.environ.get("SSO_STATE_SECRET") or AuthHandler().secret
33 +
34 +# Allowed pattern for Cloudflare team domains — must end in cloudflareaccess.com
35 +_CF_DOMAIN_RE = re.compile(r"^[a-zA-Z0-9-]+\.cloudflareaccess\.com$")
36 +
37 +
38 +def _generate_state() -> str:
39 + """Generate a self-validating HMAC-signed OAuth2 state token."""
40 + nonce = secrets.token_urlsafe(16)
41 + ts = str(int(datetime.datetime.utcnow().timestamp()))
42 + msg = f"{nonce}:{ts}".encode()
43 + mac = hmac.new(_STATE_SECRET.encode(), msg, hashlib.sha256).hexdigest()
44 + return f"{nonce}:{ts}:{mac}"
45 +
46 +
47 +def _validate_state(state: str) -> bool:
48 + """Validate a self-signed state token. Returns True if valid and not expired."""
49 + try:
50 + nonce, ts_str, mac = state.rsplit(":", 2)
51 + msg = f"{nonce}:{ts_str}".encode()
52 + expected = hmac.new(_STATE_SECRET.encode(), msg, hashlib.sha256).hexdigest()
53 + if not hmac.compare_digest(mac, expected):
54 + return False
55 + age = datetime.datetime.utcnow().timestamp() - float(ts_str)
56 + return 0 <= age <= _STATE_TTL_SECONDS
57 + except Exception:
58 + return False
59 +
60 +
61 +# ── SSO Config CRUD ──────────────────────────────────────────────────────────
62 +
63 +
64 +async def get_sso_config() -> Optional[SSOConfig]:
65 + async with AsyncSession(async_engine) as session:
66 + result = await session.execute(select(SSOConfig).where(SSOConfig.id == 1))
67 + return result.scalars().first()
68 +
69 +
70 +async def upsert_sso_config(data: dict) -> SSOConfig:
71 + async with AsyncSession(async_engine) as session:
72 + result = await session.execute(select(SSOConfig).where(SSOConfig.id == 1))
73 + cfg = result.scalars().first()
74 + if cfg is None:
75 + cfg = SSOConfig(id=1)
76 + session.add(cfg)
77 + for key, value in data.items():
78 + if not hasattr(cfg, key):
79 + continue
80 + # Booleans: always set (False is a valid value)
81 + # Strings/None: set if key present in payload — allows explicit clearing
82 + # Skip provider secrets if None (don't overwrite existing secrets)
83 + if key in ("azure_client_secret", "google_client_secret") and value is None:
84 + continue
85 + setattr(cfg, key, value)
86 + cfg.updated_at = datetime.datetime.utcnow()
87 + session.add(cfg)
88 + await session.commit()
89 + await session.refresh(cfg)
90 + return cfg
91 +
92 +
93 +# ── Allowed Emails CRUD ──────────────────────────────────────────────────────
94 +
95 +
96 +async def list_allowed_emails() -> list[SSOAllowedEmail]:
97 + async with AsyncSession(async_engine) as session:
98 + result = await session.execute(select(SSOAllowedEmail).order_by(SSOAllowedEmail.id))
99 + return list(result.scalars().all())
100 +
101 +
102 +async def add_allowed_email(email: str, role_id: int = 2) -> SSOAllowedEmail:
103 + email = email.lower().strip()
104 + async with AsyncSession(async_engine) as session:
105 + # Check duplicate
106 + result = await session.execute(select(SSOAllowedEmail).where(SSOAllowedEmail.email == email))
107 + existing = result.scalars().first()
108 + if existing:
109 + raise ValueError(f"Email {email} is already in the allowlist")
110 + entry = SSOAllowedEmail(email=email, role_id=role_id)
111 + session.add(entry)
112 + await session.commit()
113 + await session.refresh(entry)
114 + return entry
115 +
116 +
117 +async def delete_allowed_email(email_id: int) -> bool:
118 + async with AsyncSession(async_engine) as session:
119 + result = await session.execute(select(SSOAllowedEmail).where(SSOAllowedEmail.id == email_id))
120 + entry = result.scalars().first()
121 + if entry is None:
122 + return False
123 + await session.delete(entry)
124 + await session.commit()
125 + return True
126 +
127 +
128 +async def find_allowed_email(email: str) -> Optional[SSOAllowedEmail]:
129 + async with AsyncSession(async_engine) as session:
130 + result = await session.execute(select(SSOAllowedEmail).where(SSOAllowedEmail.email == email))
131 + return result.scalars().first()
132 +
133 +
134 +async def find_user_by_email(email: str) -> Optional[User]:
135 + """Look up an existing user by email address."""
136 + async with AsyncSession(async_engine) as session:
137 + result = await session.execute(select(User).where(User.email == email))
138 + return result.scalars().first()
139 +
140 +
141 +# ── Auto‑provision SSO user ──────────────────────────────────────────────────
142 +
143 +
144 +async def get_or_create_sso_user(email: str, role_id: int = 2) -> User:
145 + """Find existing user by email or create a new SSO‑managed user."""
146 + from passlib.context import CryptContext
147 +
148 + async with AsyncSession(async_engine) as session:
149 + result = await session.execute(select(User).where(User.email == email))
150 + user = result.scalars().first()
151 + if user:
152 + return user
153 +
154 + # Create a new user with a random unusable password
155 + pwd_ctx = CryptContext(schemes=["bcrypt"])
156 + random_pw = secrets.token_urlsafe(64)
157 + hashed = pwd_ctx.hash(random_pw)
158 +
159 + username = email.split("@")[0]
160 + # Ensure unique username
161 + base = username
162 + counter = 1
163 + while True:
164 + result = await session.execute(select(User).where(User.username == username))
165 + if result.scalars().first() is None:
166 + break
167 + username = f"{base}{counter}"
168 + counter += 1
169 +
170 + user = User(
171 + username=username,
172 + password=hashed,
173 + email=email,
174 + role_id=role_id,
175 + )
176 + session.add(user)
177 + await session.commit()
178 + await session.refresh(user)
179 + logger.info(f"SSO: Auto‑provisioned user '{username}' ({email}) with role_id={role_id}")
180 + return user
181 +
182 +
183 +# ── Azure Entra ID helpers ───────────────────────────────────────────────────
184 +
185 +_azure_keys_cache: dict[str, tuple[datetime.datetime, list]] = {}
186 +
187 +
188 +def build_azure_auth_url(cfg: SSOConfig) -> str:
189 + """Build the Azure OAuth2 authorization URL."""
190 + state = _generate_state()
191 + params = {
192 + "client_id": cfg.azure_client_id,
193 + "response_type": "code",
194 + "redirect_uri": cfg.azure_redirect_uri,
195 + "response_mode": "query",
196 + "scope": "openid profile email",
197 + "state": state,
198 + "nonce": secrets.token_urlsafe(16),
199 + "prompt": "select_account",
200 + }
201 + base = f"https://login.microsoftonline.com/{cfg.azure_tenant_id}/oauth2/v2.0/authorize"
202 + return f"{base}?{urlencode(params)}"
203 +
204 +
205 +async def exchange_azure_code(code: str, state: str, cfg: SSOConfig) -> dict:
206 + """Exchange an authorization code for tokens and return the ID token claims."""
207 + if not _validate_state(state):
208 + raise ValueError("Invalid or expired OAuth2 state parameter")
209 +
210 + token_url = f"https://login.microsoftonline.com/{cfg.azure_tenant_id}/oauth2/v2.0/token"
211 + data = {
212 + "client_id": cfg.azure_client_id,
213 + "client_secret": cfg.azure_client_secret,
214 + "code": code,
215 + "redirect_uri": cfg.azure_redirect_uri,
216 + "grant_type": "authorization_code",
217 + "scope": "openid profile email",
218 + }
219 +
220 + async with httpx.AsyncClient(timeout=15) as client:
221 + resp = await client.post(token_url, data=data)
222 + resp.raise_for_status()
223 + token_data = resp.json()
224 +
225 + id_token = token_data.get("id_token")
226 + if not id_token:
227 + raise ValueError("No id_token in Azure response")
228 +
229 + # Fetch JWKS for signature validation (cached, with retry on key miss)
230 + jwks_url = f"https://login.microsoftonline.com/{cfg.azure_tenant_id}/discovery/v2.0/keys"
231 + header = jwt.get_unverified_header(id_token)
232 + kid = header.get("kid")
233 +
234 + async def _get_azure_keys() -> list:
235 + now = datetime.datetime.utcnow()
236 + cache_key = cfg.azure_tenant_id
237 + if cache_key in _azure_keys_cache:
238 + cached_at, keys = _azure_keys_cache[cache_key]
239 + if (now - cached_at).total_seconds() < 3600:
240 + return keys
241 + async with httpx.AsyncClient(timeout=10) as client:
242 + resp = await client.get(jwks_url)
243 + resp.raise_for_status()
244 + jwks = resp.json()
245 + keys = [{"kid": k.get("kid"), "key": RSAAlgorithm.from_jwk(k)} for k in jwks.get("keys", [])]
246 + _azure_keys_cache[cache_key] = (now, keys)
247 + return keys
248 +
249 + public_key = None
250 + for attempt in range(2):
251 + for k in await _get_azure_keys():
252 + if k["kid"] == kid:
253 + public_key = k["key"]
254 + break
255 + if public_key:
256 + break
257 + _azure_keys_cache.pop(cfg.azure_tenant_id, None)
258 +
259 + if public_key is None:
260 + raise ValueError("Unable to find matching signing key in Azure JWKS")
261 +
262 + claims = jwt.decode(
263 + id_token,
264 + key=public_key,
265 + algorithms=["RS256"],
266 + audience=cfg.azure_client_id,
267 + issuer=f"https://login.microsoftonline.com/{cfg.azure_tenant_id}/v2.0",
268 + options={"verify_exp": True, "verify_aud": True, "verify_iss": True},
269 + )
270 +
271 + return claims
272 +
273 +
274 +# ── Google OAuth2 / OIDC helpers ────────────────────────────────────────────
275 +
276 +_GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
277 +_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
278 +_GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
279 +_GOOGLE_ISSUER = "https://accounts.google.com"
280 +
281 +# Cache for Google public keys (same pattern as Cloudflare)
282 +_google_keys_cache: dict[str, tuple[datetime.datetime, list]] = {}
283 +
284 +
285 +def build_google_auth_url(cfg: SSOConfig) -> str:
286 + """Build the Google OAuth2 authorization URL."""
287 + state = _generate_state()
288 + params = {
289 + "client_id": cfg.google_client_id,
290 + "response_type": "code",
291 + "redirect_uri": cfg.google_redirect_uri,
292 + "scope": "openid email",
293 + "state": state,
294 + "access_type": "online",
295 + "prompt": "select_account",
296 + }
297 + return f"{_GOOGLE_AUTH_URL}?{urlencode(params)}"
298 +
299 +
300 +async def _get_google_public_keys() -> list:
301 + """Fetch and cache Google's public JWKS keys (TTL 1 hour)."""
302 + now = datetime.datetime.utcnow()
303 + if "google" in _google_keys_cache:
304 + cached_at, keys = _google_keys_cache["google"]
305 + if (now - cached_at).total_seconds() < 3600:
306 + return keys
307 +
308 + async with httpx.AsyncClient(timeout=10) as client:
309 + resp = await client.get(_GOOGLE_JWKS_URL)
310 + resp.raise_for_status()
311 + data = resp.json()
312 +
313 + keys = []
314 + for jwk in data.get("keys", []):
315 + public_key = RSAAlgorithm.from_jwk(jwk)
316 + keys.append({"kid": jwk.get("kid"), "key": public_key})
317 +
318 + _google_keys_cache["google"] = (now, keys)
319 + return keys
320 +
321 +
322 +async def exchange_google_code(code: str, state: str, cfg: SSOConfig) -> dict:
323 + """Exchange a Google authorization code for tokens and return ID token claims."""
324 + if not _validate_state(state):
325 + raise ValueError("Invalid or expired OAuth2 state parameter")
326 +
327 + data = {
328 + "client_id": cfg.google_client_id,
329 + "client_secret": cfg.google_client_secret,
330 + "code": code,
331 + "redirect_uri": cfg.google_redirect_uri,
332 + "grant_type": "authorization_code",
333 + }
334 +
335 + async with httpx.AsyncClient(timeout=15) as client:
336 + resp = await client.post(_GOOGLE_TOKEN_URL, data=data)
337 + resp.raise_for_status()
338 + token_data = resp.json()
339 +
340 + id_token = token_data.get("id_token")
341 + if not id_token:
342 + raise ValueError("No id_token in Google response")
343 +
344 + # Fetch JWKS and find matching key; retry once on miss (handles key rotation)
345 + header = jwt.get_unverified_header(id_token)
346 + kid = header.get("kid")
347 +
348 + for attempt in range(2):
349 + keys = await _get_google_public_keys()
350 + for k in keys:
351 + if k["kid"] == kid:
352 + claims = jwt.decode(
353 + id_token,
354 + key=k["key"],
355 + algorithms=["RS256"],
356 + audience=cfg.google_client_id,
357 + issuer=_GOOGLE_ISSUER,
358 + options={"verify_exp": True, "verify_aud": True, "verify_iss": True},
359 + )
360 + return claims
361 + # Key not found — bust the cache and retry once
362 + _google_keys_cache.pop("google", None)
363 +
364 + raise ValueError("Unable to find matching signing key in Google JWKS")
365 +
366 +
367 +# ── Cloudflare Access helpers ────────────────────────────────────────────────
368 +
369 +# Cache for Cloudflare public keys
370 +_cf_keys_cache: dict[str, tuple[datetime.datetime, list]] = {}
371 +
372 +
373 +async def _get_cf_public_keys(team_domain: str) -> list:
374 + """Fetch and cache Cloudflare Access public keys."""
375 + cache_key = team_domain
376 + now = datetime.datetime.utcnow()
377 + if cache_key in _cf_keys_cache:
378 + cached_at, keys = _cf_keys_cache[cache_key]
379 + if (now - cached_at).total_seconds() < 3600: # cache for 1 hour
380 + return keys
381 +
382 + certs_url = f"https://{team_domain}/cdn-cgi/access/certs"
383 + async with httpx.AsyncClient(timeout=10) as client:
384 + resp = await client.get(certs_url)
385 + resp.raise_for_status()
386 + data = resp.json()
387 +
388 + keys = []
389 + for jwk in data.get("keys", []):
390 + public_key = RSAAlgorithm.from_jwk(jwk)
391 + keys.append({"kid": jwk.get("kid"), "key": public_key})
392 +
393 + _cf_keys_cache[cache_key] = (now, keys)
394 + return keys
395 +
396 +
397 +async def validate_cf_jwt(token: str, cfg: SSOConfig) -> dict:
398 + """Validate a Cloudflare Access JWT assertion and return its claims."""
399 + if not cfg.cf_team_domain or not _CF_DOMAIN_RE.match(cfg.cf_team_domain):
400 + raise ValueError(f"Invalid Cloudflare team domain '{cfg.cf_team_domain}'. " "Must match *.cloudflareaccess.com")
401 +
402 + header = jwt.get_unverified_header(token)
403 + kid = header.get("kid")
404 +
405 + keys = await _get_cf_public_keys(cfg.cf_team_domain)
406 +
407 + expected_issuer = f"https://{cfg.cf_team_domain}"
408 +
409 + for k in keys:
410 + if k["kid"] == kid:
411 + claims = jwt.decode(
412 + token,
413 + key=k["key"],
414 + algorithms=["RS256"],
415 + audience=cfg.cf_audience,
416 + issuer=expected_issuer,
417 + options={"verify_exp": True, "verify_aud": True, "verify_iss": True},
418 + )
419 + return claims
420 +
421 + raise ValueError("Unable to find matching signing key in Cloudflare JWKS")
backend/app/auth/services/totp.py new
+301
@@ -0,0 +1,301 @@
1 +"""TOTP 2FA business logic — setup, verification, backup codes, brute-force protection."""
2 +
3 +import base64
4 +
5 +# ── Encryption key for TOTP secrets ──────────────────────────────────────────
6 +import hashlib
7 +import io
8 +import os
9 +import secrets
10 +import string
11 +import time
12 +from typing import Optional
13 +
14 +import pyotp
15 +import qrcode
16 +from cryptography.fernet import Fernet
17 +from loguru import logger
18 +from passlib.context import CryptContext
19 +from sqlalchemy.ext.asyncio import AsyncSession
20 +from sqlalchemy.orm.attributes import flag_modified
21 +from sqlmodel import select
22 +
23 +from app.auth.models.totp import UserTOTP
24 +from app.db.db_session import async_engine
25 +
26 +# Prefer a dedicated TOTP_ENCRYPTION_KEY (proper Fernet key, separate from JWT).
27 +# Falls back to a key derived from JWT_SECRET so existing deployments that
28 +# haven't set TOTP_ENCRYPTION_KEY continue to decrypt stored TOTP secrets.
29 +_totp_enc_key = os.environ.get("TOTP_ENCRYPTION_KEY")
30 +if _totp_enc_key:
31 + _fernet_key = _totp_enc_key.encode()
32 +else:
33 + _raw_secret = os.environ.get("JWT_SECRET", "bL4unrkoxtFs1MT6A7Ns2yMLkduyuqrkTxDV9CjlbNc=")
34 + _fernet_key = base64.urlsafe_b64encode(hashlib.sha256(_raw_secret.encode()).digest())
35 +_fernet = Fernet(_fernet_key)
36 +
37 +_pwd_ctx = CryptContext(schemes=["bcrypt"])
38 +
39 +# ── Brute-force protection ───────────────────────────────────────────────────
40 +# Per-user: {user_id: (fail_count, first_fail_time)}
41 +_2fa_attempts: dict[int, tuple[int, float]] = {}
42 +_MAX_ATTEMPTS = 5
43 +_LOCKOUT_SECONDS = 900 # 15 min
44 +
45 +
46 +def _check_rate_limit(user_id: int) -> None:
47 + """Raise ValueError if user has too many recent 2FA failures."""
48 + entry = _2fa_attempts.get(user_id)
49 + if entry is None:
50 + return
51 + fail_count, first_fail_time = entry
52 + if time.time() - first_fail_time > _LOCKOUT_SECONDS:
53 + # Window expired, reset
54 + _2fa_attempts.pop(user_id, None)
55 + return
56 + if fail_count >= _MAX_ATTEMPTS:
57 + remaining = int(_LOCKOUT_SECONDS - (time.time() - first_fail_time))
58 + raise ValueError(f"Too many failed 2FA attempts. Try again in {remaining} seconds.")
59 +
60 +
61 +def _record_failure(user_id: int) -> None:
62 + entry = _2fa_attempts.get(user_id)
63 + if entry is None or time.time() - entry[1] > _LOCKOUT_SECONDS:
64 + _2fa_attempts[user_id] = (1, time.time())
65 + else:
66 + _2fa_attempts[user_id] = (entry[0] + 1, entry[1])
67 +
68 +
69 +def _clear_failures(user_id: int) -> None:
70 + _2fa_attempts.pop(user_id, None)
71 +
72 +
73 +# ── Encryption helpers ───────────────────────────────────────────────────────
74 +
75 +
76 +def _encrypt_secret(secret: str) -> str:
77 + return _fernet.encrypt(secret.encode()).decode()
78 +
79 +
80 +def _decrypt_secret(enc: str) -> str:
81 + return _fernet.decrypt(enc.encode()).decode()
82 +
83 +
84 +# ── Backup codes ─────────────────────────────────────────────────────────────
85 +
86 +_BACKUP_CODE_COUNT = 8
87 +_BACKUP_CODE_LENGTH = 10
88 +
89 +
90 +def _generate_backup_codes() -> tuple[list[str], list[dict]]:
91 + """Generate backup codes. Returns (plaintext_list, hashed_list_for_db)."""
92 + alphabet = string.ascii_uppercase + string.digits
93 + codes = []
94 + hashed = []
95 + for _ in range(_BACKUP_CODE_COUNT):
96 + code = "".join(secrets.choice(alphabet) for _ in range(_BACKUP_CODE_LENGTH))
97 + codes.append(code)
98 + hashed.append({"hash": _pwd_ctx.hash(code), "used": False})
99 + return codes, hashed
100 +
101 +
102 +def _verify_backup_code(backup_code: str, stored_codes: list[dict]) -> int:
103 + """Verify a backup code. Returns the index if valid, -1 otherwise."""
104 + for i, entry in enumerate(stored_codes):
105 + if entry.get("used"):
106 + continue
107 + if _pwd_ctx.verify(backup_code.upper().strip(), entry["hash"]):
108 + return i
109 + return -1
110 +
111 +
112 +# ── QR code generation ───────────────────────────────────────────────────────
113 +
114 +
115 +def _generate_qr_data_uri(otpauth_url: str) -> str:
116 + """Generate a QR code as a base64 data URI."""
117 + img = qrcode.make(otpauth_url, box_size=6, border=2)
118 + buf = io.BytesIO()
119 + img.save(buf, format="PNG")
120 + b64 = base64.b64encode(buf.getvalue()).decode()
121 + return f"data:image/png;base64,{b64}"
122 +
123 +
124 +# ── DB operations ────────────────────────────────────────────────────────────
125 +
126 +
127 +async def get_user_totp(user_id: int) -> Optional[UserTOTP]:
128 + async with AsyncSession(async_engine) as session:
129 + result = await session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
130 + return result.scalars().first()
131 +
132 +
133 +async def is_2fa_enabled(user_id: int) -> bool:
134 + totp = await get_user_totp(user_id)
135 + return totp is not None and totp.enabled
136 +
137 +
138 +async def setup_totp(user_id: int, username: str) -> dict:
139 + """Generate a new TOTP secret and backup codes. Does NOT activate until verified."""
140 + secret = pyotp.random_base32()
141 + enc_secret = _encrypt_secret(secret)
142 + plaintext_codes, hashed_codes = _generate_backup_codes()
143 +
144 + totp = pyotp.TOTP(secret)
145 + issuer = "CoPilot"
146 + otpauth_url = totp.provisioning_uri(name=username, issuer_name=issuer)
147 + qr_data_uri = _generate_qr_data_uri(otpauth_url)
148 +
149 + async with AsyncSession(async_engine) as session:
150 + result = await session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
151 + existing = result.scalars().first()
152 + if existing and existing.enabled:
153 + raise ValueError("2FA is already enabled. Disable it first to reconfigure.")
154 +
155 + if existing:
156 + existing.secret_enc = enc_secret
157 + existing.backup_codes = hashed_codes
158 + existing.enabled = False
159 + existing.last_used_at = None
160 + else:
161 + entry = UserTOTP(
162 + user_id=user_id,
163 + secret_enc=enc_secret,
164 + enabled=False,
165 + backup_codes=hashed_codes,
166 + last_used_at=None,
167 + )
168 + session.add(entry)
169 +
170 + await session.commit()
171 +
172 + logger.info(f"2FA setup initiated for user_id={user_id}")
173 + return {
174 + "secret": secret,
175 + "otpauth_url": otpauth_url,
176 + "qr_data_uri": qr_data_uri,
177 + "backup_codes": plaintext_codes,
178 + }
179 +
180 +
181 +async def verify_setup(user_id: int, code: str) -> bool:
182 + """Verify a TOTP code to activate 2FA."""
183 + _check_rate_limit(user_id)
184 +
185 + async with AsyncSession(async_engine) as session:
186 + result = await session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
187 + entry = result.scalars().first()
188 + if entry is None:
189 + raise ValueError("No 2FA setup found. Call setup first.")
190 + if entry.enabled:
191 + raise ValueError("2FA is already enabled.")
192 +
193 + secret = _decrypt_secret(entry.secret_enc)
194 + totp = pyotp.TOTP(secret)
195 +
196 + # valid_window=1 → accept ±1 step (±30s drift tolerance)
197 + if not totp.verify(code, valid_window=1):
198 + _record_failure(user_id)
199 + raise ValueError("Invalid verification code. Check your authenticator app and device clock.")
200 +
201 + entry.enabled = True
202 + entry.last_used_at = int(time.time()) // 30 # current TOTP counter
203 + await session.commit()
204 +
205 + _clear_failures(user_id)
206 + logger.info(f"2FA activated for user_id={user_id}")
207 + return True
208 +
209 +
210 +async def validate_totp(user_id: int, code: Optional[str] = None, backup_code: Optional[str] = None) -> bool:
211 + """Validate a TOTP code or backup code during login."""
212 + _check_rate_limit(user_id)
213 +
214 + if not code and not backup_code:
215 + raise ValueError("Provide either a TOTP code or a backup code.")
216 +
217 + async with AsyncSession(async_engine) as session:
218 + result = await session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
219 + entry = result.scalars().first()
220 + if entry is None or not entry.enabled:
221 + raise ValueError("2FA is not enabled for this user.")
222 +
223 + # Try TOTP code first
224 + if code:
225 + secret = _decrypt_secret(entry.secret_enc)
226 + totp = pyotp.TOTP(secret)
227 + current_counter = int(time.time()) // 30
228 +
229 + if not totp.verify(code, valid_window=1):
230 + _record_failure(user_id)
231 + raise ValueError("Invalid authentication code.")
232 +
233 + # Replay prevention: reject if same counter as last use
234 + if entry.last_used_at is not None and current_counter <= entry.last_used_at:
235 + _record_failure(user_id)
236 + raise ValueError("This code has already been used. Wait for a new code.")
237 +
238 + entry.last_used_at = current_counter
239 + await session.commit()
240 + _clear_failures(user_id)
241 + return True
242 +
243 + # Try backup code
244 + if backup_code:
245 + codes = list(entry.backup_codes)
246 + idx = _verify_backup_code(backup_code, codes)
247 + if idx < 0:
248 + _record_failure(user_id)
249 + raise ValueError("Invalid backup code.")
250 +
251 + codes[idx]["used"] = True
252 + entry.backup_codes = codes
253 + flag_modified(entry, "backup_codes")
254 + await session.commit()
255 + _clear_failures(user_id)
256 + logger.info(f"2FA backup code used for user_id={user_id} (code index {idx})")
257 + return True
258 +
259 + raise ValueError("Provide either a TOTP code or a backup code.")
260 +
261 +
262 +async def disable_totp(user_id: int, code: Optional[str] = None, backup_code: Optional[str] = None) -> bool:
263 + """Disable 2FA. Requires a valid TOTP code or backup code."""
264 + # Validate the code first
265 + await validate_totp(user_id, code=code, backup_code=backup_code)
266 +
267 + async with AsyncSession(async_engine) as session:
268 + result = await session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
269 + entry = result.scalars().first()
270 + if entry:
271 + await session.delete(entry)
272 + await session.commit()
273 +
274 + logger.info(f"2FA disabled for user_id={user_id}")
275 + return True
276 +
277 +
278 +async def regenerate_backup_codes(user_id: int, code: str) -> list[str]:
279 + """Regenerate backup codes. Requires a valid TOTP code."""
280 + _check_rate_limit(user_id)
281 +
282 + async with AsyncSession(async_engine) as session:
283 + result = await session.execute(select(UserTOTP).where(UserTOTP.user_id == user_id))
284 + entry = result.scalars().first()
285 + if entry is None or not entry.enabled:
286 + raise ValueError("2FA is not enabled.")
287 +
288 + # Verify current TOTP code
289 + secret = _decrypt_secret(entry.secret_enc)
290 + totp = pyotp.TOTP(secret)
291 + if not totp.verify(code, valid_window=1):
292 + _record_failure(user_id)
293 + raise ValueError("Invalid authentication code.")
294 +
295 + plaintext_codes, hashed_codes = _generate_backup_codes()
296 + entry.backup_codes = hashed_codes
297 + await session.commit()
298 +
299 + _clear_failures(user_id)
300 + logger.info(f"2FA backup codes regenerated for user_id={user_id}")
301 + return plaintext_codes
backend/app/auth/utils.py
+11 -1
@@ -1,3 +1,4 @@
1 +import os
2 from datetime import datetime
3 from datetime import timedelta
4
@@ -24,7 +25,7 @@ class AuthHandler:
25 },
26 )
27 pwd_context = CryptContext(schemes=["bcrypt"])
27 - secret = "bL4unrkoxtFs1MT6A7Ns2yMLkduyuqrkTxDV9CjlbNc="
28 + secret = os.environ.get("JWT_SECRET", "bL4unrkoxtFs1MT6A7Ns2yMLkduyuqrkTxDV9CjlbNc=")
29
30 def get_password_hash(self, password):
31 return self.pwd_context.hash(password)
@@ -287,6 +288,15 @@ class AuthHandler:
288 headers={"WWW-Authenticate": "Bearer"},
289 )
290
291 + # Verify user still exists in DB — prevents ghost-user token abuse
292 + user = await find_user(username)
293 + if user is None:
294 + raise HTTPException(
295 + status_code=401,
296 + detail="User not found",
297 + headers={"WWW-Authenticate": "Bearer"},
298 + )
299 +
300 if not any(scope in token_scopes for scope in required_scopes):
301 raise HTTPException(
302 status_code=403,
backend/app/routers/auth.py
+4
@@ -2,9 +2,13 @@ from fastapi import APIRouter
2
3 from app.auth.routes.auth import auth_router
4 from app.auth.routes.customer_users import customer_users_router
5 +from app.auth.routes.sso import sso_router
6 +from app.auth.routes.totp import totp_router
7
8 # Instantiate the APIRouter
9 router = APIRouter()
10
11 router.include_router(auth_router, prefix="/auth", tags=["auth"])
12 router.include_router(customer_users_router, prefix="/auth", tags=["customer_users"])
13 +router.include_router(sso_router, prefix="/auth", tags=["sso"])
14 +router.include_router(totp_router, prefix="/auth", tags=["2fa"])
backend/app/routers/stack_provisioning.py
+10
@@ -9,6 +9,9 @@ from app.stack_provisioning.graylog.routes.fortinet import (
9 from app.stack_provisioning.graylog.routes.provision import (
10 stack_provisioning_graylog_router,
11 )
12 +from app.stack_provisioning.graylog.routes.sentinelone import (
13 + stack_provisioning_graylog_sentinelone_router,
14 +)
15 from app.stack_provisioning.graylog.routes.sonicwall import (
16 stack_provisioning_graylog_sonicwall_router,
17 )
@@ -43,3 +46,10 @@ router.include_router(
46 prefix="/stack_provisioning",
47 tags=["Stack Provisioning"],
48 )
49 +
50 +# Include the Stack Provisioning related routes
51 +router.include_router(
52 + stack_provisioning_graylog_sentinelone_router,
53 + prefix="/stack_provisioning",
54 + tags=["Stack Provisioning"],
55 +)
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_ACTIVE_DIRECTORY.json new
+195
@@ -0,0 +1,195 @@
1 +{
2 + "id": "O365_ACTIVE_DIRECTORY",
3 + "title": "Office 365 - Active Directory",
4 + "description": "Azure Active Directory audit and sign-in activity: alerts, device events, user additions, external logins, non-MFA logins, failed authentications, auth by country, operations breakdown, and user agent analysis.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:AzureActiveDirectory AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Events (Total)",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:AzureActiveDirectory"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Severity (0 - 15)",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:AzureActiveDirectory"
29 + },
30 +
31 + {
32 + "id": "operations_over_time",
33 + "title": "Top 10 Operations - Histogram",
34 + "type": "histogram",
35 + "w": 12,
36 + "h": 250,
37 + "lucene": "data_office365_Workload:AzureActiveDirectory"
38 + },
39 +
40 + {
41 + "id": "total_auths",
42 + "title": "Azure AD - Total Auths",
43 + "type": "stat",
44 + "w": 3,
45 + "h": 100,
46 + "lucene": "data_office365_Operation:UserLoggedIn"
47 + },
48 + {
49 + "id": "failed_auths",
50 + "title": "Azure AD - Failed Auths",
51 + "type": "stat",
52 + "w": 3,
53 + "h": 100,
54 + "lucene": "data_office365_Operation:UserLoginFailed"
55 + },
56 + {
57 + "id": "external_logins",
58 + "title": "External Users Login",
59 + "type": "stat",
60 + "w": 3,
61 + "h": 100,
62 + "lucene": "data_office365_Operation:UserLoggedIn AND (data_office365_UserId:*#EXT# OR data_office365_UserId:*gmail.com)"
63 + },
64 + {
65 + "id": "non_mfa_logins",
66 + "title": "Non-MFA Logins",
67 + "type": "stat",
68 + "w": 3,
69 + "h": 100,
70 + "lucene": "data_office365_Operation:UserLoggedIn AND data_office365_ResultStatus:Success AND data_office365_ExtendedProperties_UserAuthenticationMethod:1"
71 + },
72 +
73 + {
74 + "id": "device_events",
75 + "title": "Device Events (Total)",
76 + "type": "stat",
77 + "w": 6,
78 + "h": 100,
79 + "lucene": "data_office365_Operation:\"Add device.\" OR data_office365_Operation:\"Add registered owner to device.\" OR data_office365_Operation:\"Add registered users to device.\""
80 + },
81 + {
82 + "id": "users_added",
83 + "title": "Users Added Events (Total)",
84 + "type": "stat",
85 + "w": 6,
86 + "h": 100,
87 + "lucene": "data_office365_Operation:\"Add user.\""
88 + },
89 +
90 + {
91 + "id": "by_operation",
92 + "title": "Events by Operation (Top 10)",
93 + "type": "pie",
94 + "w": 6,
95 + "h": 300,
96 + "lucene": "data_office365_Workload:AzureActiveDirectory",
97 + "field": "data_office365_Operation",
98 + "size": 10
99 + },
100 + {
101 + "id": "users_added_by_result",
102 + "title": "Users Added Events (Results)",
103 + "type": "pie",
104 + "w": 6,
105 + "h": 300,
106 + "lucene": "data_office365_Operation:\"Add user.\"",
107 + "field": "data_office365_ResultStatus",
108 + "size": 10
109 + },
110 +
111 + {
112 + "id": "top_operations",
113 + "title": "Events by Operation",
114 + "type": "bar_h",
115 + "w": 6,
116 + "h": 320,
117 + "lucene": "data_office365_Workload:AzureActiveDirectory",
118 + "field": "data_office365_Operation",
119 + "size": 15
120 + },
121 + {
122 + "id": "auth_by_country",
123 + "title": "All Auth Events by Country",
124 + "type": "bar_h",
125 + "w": 6,
126 + "h": 320,
127 + "lucene": "data_office365_Workload:AzureActiveDirectory",
128 + "field": "data_office365_ActorIpAddress_country_code",
129 + "size": 15
130 + },
131 +
132 + {
133 + "id": "failed_auths_by_error",
134 + "title": "Failed Auths by Error (Top 15)",
135 + "type": "bar_h",
136 + "w": 6,
137 + "h": 320,
138 + "lucene": "data_office365_Operation:UserLoginFailed",
139 + "field": "data_office365_LogonError",
140 + "size": 15
141 + },
142 + {
143 + "id": "failed_auths_by_account",
144 + "title": "Failed Auths by Account",
145 + "type": "bar_h",
146 + "w": 6,
147 + "h": 320,
148 + "lucene": "data_office365_Operation:UserLoginFailed",
149 + "field": "data_office365_UserId",
150 + "size": 15
151 + },
152 +
153 + {
154 + "id": "external_login_users",
155 + "title": "External Users Login (Accounts)",
156 + "type": "bar_h",
157 + "w": 6,
158 + "h": 320,
159 + "lucene": "data_office365_Operation:UserLoggedIn AND (data_office365_UserId:*#EXT# OR data_office365_UserId:*gmail.com)",
160 + "field": "data_office365_UserId",
161 + "size": 15
162 + },
163 + {
164 + "id": "non_mfa_users",
165 + "title": "Non-MFA Login Accounts",
166 + "type": "bar_h",
167 + "w": 6,
168 + "h": 320,
169 + "lucene": "data_office365_Operation:UserLoggedIn AND data_office365_ResultStatus:Success AND data_office365_ExtendedProperties_UserAuthenticationMethod:1",
170 + "field": "data_office365_UserId",
171 + "size": 15
172 + },
173 +
174 + {
175 + "id": "device_events_by_operation",
176 + "title": "Device Events by Operation",
177 + "type": "bar_h",
178 + "w": 6,
179 + "h": 320,
180 + "lucene": "data_office365_Operation:\"Add device.\" OR data_office365_Operation:\"Add registered owner to device.\" OR data_office365_Operation:\"Add registered users to device.\"",
181 + "field": "data_office365_Operation",
182 + "size": 10
183 + },
184 + {
185 + "id": "successful_auths_by_user_agent",
186 + "title": "Successful Auths by User Agent (Top 15)",
187 + "type": "bar_h",
188 + "w": 6,
189 + "h": 320,
190 + "lucene": "data_office365_Operation:UserLoggedIn AND data_office365_ResultStatus:Success",
191 + "field": "data_office365_ExtendedProperties_UserAgent",
192 + "size": 15
193 + }
194 + ]
195 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_APPLICATIONS.json new
+99
@@ -0,0 +1,99 @@
1 +{
2 + "id": "O365_APPLICATIONS",
3 + "title": "Office 365 - Applications",
4 + "description": "Cross-workload O365 application activity: events by workload, operations, user access patterns, source country analysis, and alert tracking (excludes Azure Active Directory).",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Severity (0 - 15)",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory"
29 + },
30 + {
31 + "id": "by_workload",
32 + "title": "Events by Workload (Top 10)",
33 + "type": "pie",
34 + "w": 6,
35 + "h": 300,
36 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory",
37 + "field": "data_office365_Workload",
38 + "size": 10
39 + },
40 + {
41 + "id": "by_operation",
42 + "title": "Events by Operation (Top 10)",
43 + "type": "pie",
44 + "w": 6,
45 + "h": 300,
46 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory",
47 + "field": "data_office365_Operation",
48 + "size": 10
49 + },
50 + {
51 + "id": "operations_over_time",
52 + "title": "Top 10 Operations - Histogram",
53 + "type": "histogram",
54 + "w": 12,
55 + "h": 200,
56 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory"
57 + },
58 + {
59 + "id": "top_workloads",
60 + "title": "Events by Workload",
61 + "type": "bar_h",
62 + "w": 6,
63 + "h": 320,
64 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory",
65 + "field": "data_office365_Workload",
66 + "size": 15
67 + },
68 + {
69 + "id": "top_operations",
70 + "title": "Events by Operation",
71 + "type": "bar_h",
72 + "w": 6,
73 + "h": 320,
74 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory",
75 + "field": "data_office365_Operation",
76 + "size": 15
77 + },
78 + {
79 + "id": "by_country",
80 + "title": "Applications Access by Source Country",
81 + "type": "bar_h",
82 + "w": 6,
83 + "h": 320,
84 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory",
85 + "field": "data_office365_ClientIP_country_code",
86 + "size": 15
87 + },
88 + {
89 + "id": "top_users",
90 + "title": "Apps Access by Account",
91 + "type": "bar_h",
92 + "w": 6,
93 + "h": 320,
94 + "lucene": "NOT data_office365_Workload:AzureActiveDirectory",
95 + "field": "data_office365_UserId",
96 + "size": 15
97 + }
98 + ]
99 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_COMPLIANCE_CENTER.json new
+89
@@ -0,0 +1,89 @@
1 +{
2 + "id": "O365_COMPLIANCE_CENTER",
3 + "title": "Office 365 - Compliance Center",
4 + "description": "Microsoft Compliance Center activity: compliance operations, content-level events, policy actions, and audit log searches across the compliance workload.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:Compliance AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:Compliance"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Severity",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:Compliance"
29 + },
30 + {
31 + "id": "operations_over_time",
32 + "title": "Operations Over Time",
33 + "type": "histogram",
34 + "w": 12,
35 + "h": 200,
36 + "lucene": "data_office365_Workload:Compliance"
37 + },
38 + {
39 + "id": "by_operation",
40 + "title": "Events by Operation (Top 10)",
41 + "type": "pie",
42 + "w": 6,
43 + "h": 300,
44 + "lucene": "data_office365_Workload:Compliance",
45 + "field": "data_office365_Operation",
46 + "size": 10
47 + },
48 + {
49 + "id": "by_content",
50 + "title": "Events by Content",
51 + "type": "pie",
52 + "w": 6,
53 + "h": 300,
54 + "lucene": "data_office365_Workload:Compliance",
55 + "field": "data_office365_ItemName",
56 + "size": 10
57 + },
58 + {
59 + "id": "top_operations",
60 + "title": "Top Operations",
61 + "type": "bar_h",
62 + "w": 6,
63 + "h": 320,
64 + "lucene": "data_office365_Workload:Compliance",
65 + "field": "data_office365_Operation",
66 + "size": 15
67 + },
68 + {
69 + "id": "top_content",
70 + "title": "Top Content Items",
71 + "type": "bar_h",
72 + "w": 6,
73 + "h": 320,
74 + "lucene": "data_office365_Workload:Compliance",
75 + "field": "data_office365_ItemName",
76 + "size": 15
77 + },
78 + {
79 + "id": "top_rules",
80 + "title": "Top Rules Triggered",
81 + "type": "bar_h",
82 + "w": 6,
83 + "h": 320,
84 + "lucene": "data_office365_Workload:Compliance AND rule_level:>=12",
85 + "field": "rule_description",
86 + "size": 10
87 + }
88 + ]
89 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_DEFENDER_FOR_IDENTITY.json new
+89
@@ -0,0 +1,89 @@
1 +{
2 + "id": "O365_DEFENDER_FOR_IDENTITY",
3 + "title": "Office 365 - Defender for Identity",
4 + "description": "Microsoft Defender for Identity detections: identity-related alerts, operation categories, and security events across the Defender for Identity workload.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:* AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:*"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Severity",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:*"
29 + },
30 + {
31 + "id": "operations_over_time",
32 + "title": "Operations Over Time",
33 + "type": "histogram",
34 + "w": 12,
35 + "h": 200,
36 + "lucene": "data_office365_Workload:*"
37 + },
38 + {
39 + "id": "by_operation",
40 + "title": "Events by Operation (Top 10)",
41 + "type": "pie",
42 + "w": 6,
43 + "h": 300,
44 + "lucene": "data_office365_Workload:*",
45 + "field": "data_office365_Operation",
46 + "size": 10
47 + },
48 + {
49 + "id": "by_category",
50 + "title": "Events by Category",
51 + "type": "pie",
52 + "w": 6,
53 + "h": 300,
54 + "lucene": "data_office365_Workload:*",
55 + "field": "data_office365_Category",
56 + "size": 10
57 + },
58 + {
59 + "id": "top_operations",
60 + "title": "Top Operations",
61 + "type": "bar_h",
62 + "w": 6,
63 + "h": 320,
64 + "lucene": "data_office365_Workload:*",
65 + "field": "data_office365_Operation",
66 + "size": 15
67 + },
68 + {
69 + "id": "top_categories",
70 + "title": "Top Categories",
71 + "type": "bar_h",
72 + "w": 6,
73 + "h": 320,
74 + "lucene": "data_office365_Workload:*",
75 + "field": "data_office365_Category",
76 + "size": 15
77 + },
78 + {
79 + "id": "top_rules",
80 + "title": "Top Rules Triggered",
81 + "type": "bar_h",
82 + "w": 6,
83 + "h": 320,
84 + "lucene": "data_office365_Workload:* AND rule_level:>=12",
85 + "field": "rule_description",
86 + "size": 10
87 + }
88 + ]
89 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_DLP.json new
+89
@@ -0,0 +1,89 @@
1 +{
2 + "id": "O365_DLP",
3 + "title": "Office 365 - Data Loss Prevention",
4 + "description": "Data Loss Prevention (DLP) policy events: rule matches, policy violations, DLP operations, and affected content across the DLP workload.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:DLP AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:DLP"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Severity",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:DLP"
29 + },
30 + {
31 + "id": "operations_over_time",
32 + "title": "Operations Over Time",
33 + "type": "histogram",
34 + "w": 12,
35 + "h": 200,
36 + "lucene": "data_office365_Workload:DLP"
37 + },
38 + {
39 + "id": "by_operation",
40 + "title": "Events by Operation (Top 10)",
41 + "type": "pie",
42 + "w": 6,
43 + "h": 300,
44 + "lucene": "data_office365_Workload:DLP",
45 + "field": "data_office365_Operation",
46 + "size": 10
47 + },
48 + {
49 + "id": "by_rule",
50 + "title": "Events by DLP Rule",
51 + "type": "pie",
52 + "w": 6,
53 + "h": 300,
54 + "lucene": "data_office365_Workload:DLP",
55 + "field": "data_office365_RuleName",
56 + "size": 10
57 + },
58 + {
59 + "id": "top_operations",
60 + "title": "Top Operations",
61 + "type": "bar_h",
62 + "w": 6,
63 + "h": 320,
64 + "lucene": "data_office365_Workload:DLP",
65 + "field": "data_office365_Operation",
66 + "size": 15
67 + },
68 + {
69 + "id": "top_rules_dlp",
70 + "title": "Top DLP Rules",
71 + "type": "bar_h",
72 + "w": 6,
73 + "h": 320,
74 + "lucene": "data_office365_Workload:DLP",
75 + "field": "data_office365_RuleName",
76 + "size": 15
77 + },
78 + {
79 + "id": "top_rules",
80 + "title": "Top Detection Rules Triggered",
81 + "type": "bar_h",
82 + "w": 6,
83 + "h": 320,
84 + "lucene": "data_office365_Workload:DLP AND rule_level:>=12",
85 + "field": "rule_description",
86 + "size": 10
87 + }
88 + ]
89 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_ENDPOINT.json new
+89
@@ -0,0 +1,89 @@
1 +{
2 + "id": "O365_ENDPOINT",
3 + "title": "Office 365 - Endpoint (Defender)",
4 + "description": "Microsoft Defender for Endpoint activity: endpoint alerts, detection titles, operations, and security events from the MicrosoftDefender workload.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:MicrosoftDefender AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:MicrosoftDefender"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Severity",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:MicrosoftDefender"
29 + },
30 + {
31 + "id": "operations_over_time",
32 + "title": "Operations Over Time",
33 + "type": "histogram",
34 + "w": 12,
35 + "h": 200,
36 + "lucene": "data_office365_Workload:MicrosoftDefender"
37 + },
38 + {
39 + "id": "by_operation",
40 + "title": "Events by Operation (Top 10)",
41 + "type": "pie",
42 + "w": 6,
43 + "h": 300,
44 + "lucene": "data_office365_Workload:MicrosoftDefender",
45 + "field": "data_office365_Operation",
46 + "size": 10
47 + },
48 + {
49 + "id": "by_title",
50 + "title": "Events by Alert Title",
51 + "type": "pie",
52 + "w": 6,
53 + "h": 300,
54 + "lucene": "data_office365_Workload:MicrosoftDefender",
55 + "field": "data_office365_Title",
56 + "size": 10
57 + },
58 + {
59 + "id": "top_operations",
60 + "title": "Top Operations",
61 + "type": "bar_h",
62 + "w": 6,
63 + "h": 320,
64 + "lucene": "data_office365_Workload:MicrosoftDefender",
65 + "field": "data_office365_Operation",
66 + "size": 15
67 + },
68 + {
69 + "id": "top_titles",
70 + "title": "Top Alert Titles",
71 + "type": "bar_h",
72 + "w": 6,
73 + "h": 320,
74 + "lucene": "data_office365_Workload:MicrosoftDefender",
75 + "field": "data_office365_Title",
76 + "size": 15
77 + },
78 + {
79 + "id": "top_rules",
80 + "title": "Top Rules Triggered",
81 + "type": "bar_h",
82 + "w": 6,
83 + "h": 320,
84 + "lucene": "data_office365_Workload:MicrosoftDefender AND rule_level:>=12",
85 + "field": "rule_description",
86 + "size": 10
87 + }
88 + ]
89 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_EXCHANGE.json new
+120
@@ -0,0 +1,120 @@
1 +{
2 + "id": "O365_EXCHANGE",
3 + "title": "Office 365 - Exchange",
4 + "description": "Exchange Online activity: mailbox operations, messages containing links, sessions started, user activity, source country analysis, and operation breakdowns.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:Exchange AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Events (Total)",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:Exchange"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Severity (0 - 15)",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:Exchange"
29 + },
30 +
31 + {
32 + "id": "messages_with_links",
33 + "title": "Messages Containing Link",
34 + "type": "stat",
35 + "w": 6,
36 + "h": 100,
37 + "lucene": "data_office365_Workload:Exchange AND data_office365_Operation:MessageCreatedHasLink"
38 + },
39 + {
40 + "id": "sessions_started",
41 + "title": "Sessions Started",
42 + "type": "stat",
43 + "w": 6,
44 + "h": 100,
45 + "lucene": "data_office365_Workload:Exchange AND data_office365_Operation:TeamsSessionStarted"
46 + },
47 +
48 + {
49 + "id": "by_operation",
50 + "title": "Events by Operation (Top 10)",
51 + "type": "pie",
52 + "w": 6,
53 + "h": 300,
54 + "lucene": "data_office365_Workload:Exchange",
55 + "field": "data_office365_Operation",
56 + "size": 10
57 + },
58 + {
59 + "id": "top_operations",
60 + "title": "Events by Operation",
61 + "type": "bar_h",
62 + "w": 6,
63 + "h": 300,
64 + "lucene": "data_office365_Workload:Exchange",
65 + "field": "data_office365_Operation",
66 + "size": 15
67 + },
68 +
69 + {
70 + "id": "operations_over_time",
71 + "title": "Top 10 Operations - Histogram",
72 + "type": "histogram",
73 + "w": 12,
74 + "h": 250,
75 + "lucene": "data_office365_Workload:Exchange"
76 + },
77 +
78 + {
79 + "id": "top_users",
80 + "title": "Exchange Events by Account",
81 + "type": "bar_h",
82 + "w": 6,
83 + "h": 320,
84 + "lucene": "data_office365_Workload:Exchange",
85 + "field": "data_office365_UserId",
86 + "size": 15
87 + },
88 + {
89 + "id": "by_country",
90 + "title": "All Events by Source Country",
91 + "type": "bar_h",
92 + "w": 6,
93 + "h": 320,
94 + "lucene": "data_office365_Workload:Exchange",
95 + "field": "data_office365_ClientIP_country_code",
96 + "size": 15
97 + },
98 +
99 + {
100 + "id": "phishing_senders",
101 + "title": "Top Phishing Senders",
102 + "type": "bar_h",
103 + "w": 6,
104 + "h": 320,
105 + "lucene": "data_office365_Workload:ThreatIntelligence AND data_office365_Verdict:Phish",
106 + "field": "data_office365_P1Sender",
107 + "size": 15
108 + },
109 + {
110 + "id": "phishing_receivers",
111 + "title": "Top Phishing Receivers",
112 + "type": "bar_h",
113 + "w": 6,
114 + "h": 320,
115 + "lucene": "data_office365_Workload:ThreatIntelligence AND data_office365_Verdict:Phish",
116 + "field": "data_office365_Recipients",
117 + "size": 15
118 + }
119 + ]
120 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_FORMS.json new
+99
@@ -0,0 +1,99 @@
1 +{
2 + "id": "O365_FORMS",
3 + "title": "Office 365 - Forms",
4 + "description": "Microsoft Forms activity: form creation, responses, sharing events, per-form breakdowns, user activity, and source country tracking.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:MicrosoftForms AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:MicrosoftForms"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Severity",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:MicrosoftForms"
29 + },
30 + {
31 + "id": "operations_over_time",
32 + "title": "Operations Over Time",
33 + "type": "histogram",
34 + "w": 12,
35 + "h": 200,
36 + "lucene": "data_office365_Workload:MicrosoftForms"
37 + },
38 + {
39 + "id": "by_operation",
40 + "title": "Events by Operation (Top 10)",
41 + "type": "pie",
42 + "w": 6,
43 + "h": 300,
44 + "lucene": "data_office365_Workload:MicrosoftForms",
45 + "field": "data_office365_Operation",
46 + "size": 10
47 + },
48 + {
49 + "id": "by_form_name",
50 + "title": "Events by Form Name",
51 + "type": "pie",
52 + "w": 6,
53 + "h": 300,
54 + "lucene": "data_office365_Workload:MicrosoftForms",
55 + "field": "data_office365_FormName",
56 + "size": 10
57 + },
58 + {
59 + "id": "top_operations",
60 + "title": "Top Operations",
61 + "type": "bar_h",
62 + "w": 6,
63 + "h": 320,
64 + "lucene": "data_office365_Workload:MicrosoftForms",
65 + "field": "data_office365_Operation",
66 + "size": 15
67 + },
68 + {
69 + "id": "top_users",
70 + "title": "Top Users",
71 + "type": "bar_h",
72 + "w": 6,
73 + "h": 320,
74 + "lucene": "data_office365_Workload:MicrosoftForms",
75 + "field": "data_office365_UserId",
76 + "size": 15
77 + },
78 + {
79 + "id": "by_country",
80 + "title": "Events by Source Country",
81 + "type": "bar_h",
82 + "w": 6,
83 + "h": 320,
84 + "lucene": "data_office365_Workload:MicrosoftForms",
85 + "field": "data_office365_ClientIP_country_code",
86 + "size": 15
87 + },
88 + {
89 + "id": "top_form_names",
90 + "title": "Top Form Names",
91 + "type": "bar_h",
92 + "w": 6,
93 + "h": 320,
94 + "lucene": "data_office365_Workload:MicrosoftForms",
95 + "field": "data_office365_FormName",
96 + "size": 15
97 + }
98 + ]
99 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_MITRE.json new
+109
@@ -0,0 +1,109 @@
1 +{
2 + "id": "O365_MITRE",
3 + "title": "Office 365 - MITRE ATT&CK",
4 + "description": "MITRE ATT&CK enrichment across all Office 365 workloads: tactic and technique distributions, affected accounts, operations mapped to MITRE, and enriched alert telemetry.",
5 + "panels": [
6 + {
7 + "id": "mitre_events",
8 + "title": "MITRE ATT&CK Enriched Events",
9 + "type": "stat",
10 + "w": 4,
11 + "h": 100,
12 + "lucene": "_exists_:rule_mitre_id AND data_office365_Workload:*"
13 + },
14 + {
15 + "id": "mitre_alerts",
16 + "title": "MITRE ATT&CK Alerts (≥ 12)",
17 + "type": "stat",
18 + "w": 4,
19 + "h": 100,
20 + "lucene": "_exists_:rule_mitre_tactic AND data_office365_Workload:* AND rule_level:>=12"
21 + },
22 + {
23 + "id": "accounts_affected",
24 + "title": "Accounts Affected",
25 + "type": "stat",
26 + "w": 4,
27 + "h": 100,
28 + "lucene": "_exists_:rule_mitre_tactic AND data_office365_Workload:* AND rule_level:>=12"
29 + },
30 + {
31 + "id": "mitre_over_time",
32 + "title": "MITRE Events Over Time",
33 + "type": "histogram",
34 + "w": 12,
35 + "h": 200,
36 + "lucene": "_exists_:rule_mitre_tactic AND data_office365_Workload:*"
37 + },
38 + {
39 + "id": "by_tactic",
40 + "title": "MITRE ATT&CK Tactics (Top 10)",
41 + "type": "pie",
42 + "w": 4,
43 + "h": 300,
44 + "lucene": "data_office365_Workload:* AND _exists_:rule_mitre_tactic",
45 + "field": "rule_mitre_tactic",
46 + "size": 10
47 + },
48 + {
49 + "id": "by_technique",
50 + "title": "MITRE ATT&CK Techniques (Top 10)",
51 + "type": "pie",
52 + "w": 4,
53 + "h": 300,
54 + "lucene": "data_office365_Workload:* AND _exists_:rule_mitre_tactic",
55 + "field": "rule_mitre_technique",
56 + "size": 10
57 + },
58 + {
59 + "id": "by_workload",
60 + "title": "By Workload",
61 + "type": "pie",
62 + "w": 4,
63 + "h": 300,
64 + "lucene": "_exists_:rule_mitre_tactic AND data_office365_Workload:*",
65 + "field": "data_office365_Workload",
66 + "size": 10
67 + },
68 + {
69 + "id": "top_tactics",
70 + "title": "Top MITRE Tactics",
71 + "type": "bar_h",
72 + "w": 6,
73 + "h": 320,
74 + "lucene": "data_office365_Workload:* AND _exists_:rule_mitre_tactic",
75 + "field": "rule_mitre_tactic",
76 + "size": 15
77 + },
78 + {
79 + "id": "top_technique_ids",
80 + "title": "Top MITRE Technique IDs",
81 + "type": "bar_h",
82 + "w": 6,
83 + "h": 320,
84 + "lucene": "data_office365_Workload:* AND _exists_:rule_mitre_tactic",
85 + "field": "rule_mitre_id",
86 + "size": 15
87 + },
88 + {
89 + "id": "top_techniques",
90 + "title": "Top MITRE Techniques (by name)",
91 + "type": "bar_h",
92 + "w": 6,
93 + "h": 320,
94 + "lucene": "data_office365_Workload:* AND _exists_:rule_mitre_tactic",
95 + "field": "rule_mitre_technique",
96 + "size": 15
97 + },
98 + {
99 + "id": "top_accounts",
100 + "title": "Top Affected Accounts",
101 + "type": "bar_h",
102 + "w": 6,
103 + "h": 320,
104 + "lucene": "data_office365_Workload:* AND _exists_:rule_mitre_tactic",
105 + "field": "data_office365_UserId",
106 + "size": 10
107 + }
108 + ]
109 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_ONEDRIVE.json new
+107
@@ -0,0 +1,107 @@
1 +{
2 + "id": "O365_ONEDRIVE",
3 + "title": "Office 365 - OneDrive",
4 + "description": "OneDrive for Business activity: file operations, sharing events, link-containing messages, session tracking, user activity, and source country analysis.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 3,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:OneDrive AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 3,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:OneDrive"
21 + },
22 + {
23 + "id": "messages_with_links",
24 + "title": "Messages Containing Links",
25 + "type": "stat",
26 + "w": 3,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:OneDrive AND data_office365_Operation:MessageCreatedHasLink"
29 + },
30 + {
31 + "id": "sessions_started",
32 + "title": "Sessions Started",
33 + "type": "stat",
34 + "w": 3,
35 + "h": 100,
36 + "lucene": "data_office365_Workload:OneDrive AND data_office365_Operation:TeamsSessionStarted"
37 + },
38 + {
39 + "id": "operations_over_time",
40 + "title": "Operations Over Time",
41 + "type": "histogram",
42 + "w": 12,
43 + "h": 200,
44 + "lucene": "data_office365_Workload:OneDrive"
45 + },
46 + {
47 + "id": "by_operation",
48 + "title": "Events by Operation (Top 10)",
49 + "type": "pie",
50 + "w": 6,
51 + "h": 300,
52 + "lucene": "data_office365_Workload:OneDrive",
53 + "field": "data_office365_Operation",
54 + "size": 10
55 + },
56 + {
57 + "id": "by_user",
58 + "title": "Events by User",
59 + "type": "pie",
60 + "w": 6,
61 + "h": 300,
62 + "lucene": "data_office365_Workload:OneDrive",
63 + "field": "data_office365_UserId",
64 + "size": 10
65 + },
66 + {
67 + "id": "top_operations",
68 + "title": "Top Operations",
69 + "type": "bar_h",
70 + "w": 6,
71 + "h": 320,
72 + "lucene": "data_office365_Workload:OneDrive",
73 + "field": "data_office365_Operation",
74 + "size": 15
75 + },
76 + {
77 + "id": "top_users",
78 + "title": "Top Users",
79 + "type": "bar_h",
80 + "w": 6,
81 + "h": 320,
82 + "lucene": "data_office365_Workload:OneDrive",
83 + "field": "data_office365_UserId",
84 + "size": 15
85 + },
86 + {
87 + "id": "by_country",
88 + "title": "Events by Source Country",
89 + "type": "bar_h",
90 + "w": 6,
91 + "h": 320,
92 + "lucene": "data_office365_Workload:OneDrive",
93 + "field": "data_office365_ClientIP_country_code",
94 + "size": 15
95 + },
96 + {
97 + "id": "top_rules",
98 + "title": "Top Rules Triggered",
99 + "type": "bar_h",
100 + "w": 6,
101 + "h": 320,
102 + "lucene": "data_office365_Workload:OneDrive AND rule_level:>=12",
103 + "field": "rule_description",
104 + "size": 10
105 + }
106 + ]
107 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_POWERBI.json new
+107
@@ -0,0 +1,107 @@
1 +{
2 + "id": "O365_POWERBI",
3 + "title": "Office 365 - Power BI",
4 + "description": "Power BI activity monitoring: report access, dashboard views, dataset operations, user activity, source country analysis, and alert tracking.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 3,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:PowerBI AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 3,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:PowerBI"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Rule Severity",
25 + "type": "stat",
26 + "w": 3,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:PowerBI"
29 + },
30 + {
31 + "id": "unique_users",
32 + "title": "Unique Users",
33 + "type": "stat",
34 + "w": 3,
35 + "h": 100,
36 + "lucene": "data_office365_Workload:PowerBI"
37 + },
38 + {
39 + "id": "activity_over_time",
40 + "title": "Activity Over Time",
41 + "type": "histogram",
42 + "w": 12,
43 + "h": 200,
44 + "lucene": "data_office365_Workload:PowerBI"
45 + },
46 + {
47 + "id": "by_activity",
48 + "title": "Events by Activity (Top 10)",
49 + "type": "pie",
50 + "w": 6,
51 + "h": 300,
52 + "lucene": "data_office365_Workload:PowerBI",
53 + "field": "data_office365_Activity",
54 + "size": 10
55 + },
56 + {
57 + "id": "by_user",
58 + "title": "Events by User",
59 + "type": "pie",
60 + "w": 6,
61 + "h": 300,
62 + "lucene": "data_office365_Workload:PowerBI",
63 + "field": "data_office365_UserId",
64 + "size": 10
65 + },
66 + {
67 + "id": "top_activities",
68 + "title": "Top Activities",
69 + "type": "bar_h",
70 + "w": 6,
71 + "h": 320,
72 + "lucene": "data_office365_Workload:PowerBI",
73 + "field": "data_office365_Activity",
74 + "size": 15
75 + },
76 + {
77 + "id": "top_artifacts",
78 + "title": "Top Artifact Names",
79 + "type": "bar_h",
80 + "w": 6,
81 + "h": 320,
82 + "lucene": "data_office365_Workload:PowerBI",
83 + "field": "data_office365_ArtifactName",
84 + "size": 15
85 + },
86 + {
87 + "id": "top_users",
88 + "title": "Top Users",
89 + "type": "bar_h",
90 + "w": 6,
91 + "h": 320,
92 + "lucene": "data_office365_Workload:PowerBI",
93 + "field": "data_office365_UserId",
94 + "size": 15
95 + },
96 + {
97 + "id": "by_country",
98 + "title": "Events by Source Country",
99 + "type": "bar_h",
100 + "w": 6,
101 + "h": 320,
102 + "lucene": "data_office365_Workload:PowerBI",
103 + "field": "data_office365_ClientIP_country_code",
104 + "size": 15
105 + }
106 + ]
107 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_SHAREPOINT.json new
+127
@@ -0,0 +1,127 @@
1 +{
2 + "id": "O365_SHAREPOINT",
3 + "title": "Office 365 - SharePoint",
4 + "description": "SharePoint Online activity: file operations, sharing events, site administration, user agents, authentication types, user activity, and source country analysis.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 3,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:SharePoint AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 3,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:SharePoint"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Rule Severity",
25 + "type": "stat",
26 + "w": 3,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:SharePoint"
29 + },
30 + {
31 + "id": "unique_users",
32 + "title": "Unique Users",
33 + "type": "stat",
34 + "w": 3,
35 + "h": 100,
36 + "lucene": "data_office365_Workload:SharePoint"
37 + },
38 + {
39 + "id": "operations_over_time",
40 + "title": "Operations Over Time",
41 + "type": "histogram",
42 + "w": 12,
43 + "h": 200,
44 + "lucene": "data_office365_Workload:SharePoint"
45 + },
46 + {
47 + "id": "by_operation",
48 + "title": "Events by Operation (Top 10)",
49 + "type": "pie",
50 + "w": 6,
51 + "h": 300,
52 + "lucene": "data_office365_Workload:SharePoint",
53 + "field": "data_office365_Operation",
54 + "size": 10
55 + },
56 + {
57 + "id": "by_user",
58 + "title": "Events by User",
59 + "type": "pie",
60 + "w": 6,
61 + "h": 300,
62 + "lucene": "data_office365_Workload:SharePoint",
63 + "field": "data_office365_UserId",
64 + "size": 10
65 + },
66 + {
67 + "id": "top_operations",
68 + "title": "Top Operations",
69 + "type": "bar_h",
70 + "w": 6,
71 + "h": 320,
72 + "lucene": "data_office365_Workload:SharePoint",
73 + "field": "data_office365_Operation",
74 + "size": 15
75 + },
76 + {
77 + "id": "top_users",
78 + "title": "Top Users",
79 + "type": "bar_h",
80 + "w": 6,
81 + "h": 320,
82 + "lucene": "data_office365_Workload:SharePoint",
83 + "field": "data_office365_UserId",
84 + "size": 15
85 + },
86 + {
87 + "id": "by_user_agent",
88 + "title": "Top User Agents",
89 + "type": "bar_h",
90 + "w": 6,
91 + "h": 320,
92 + "lucene": "data_office365_Workload:SharePoint",
93 + "field": "data_office365_UserAgent",
94 + "size": 15
95 + },
96 + {
97 + "id": "by_auth_type",
98 + "title": "By Authentication Type",
99 + "type": "bar_h",
100 + "w": 6,
101 + "h": 320,
102 + "lucene": "data_office365_Workload:SharePoint",
103 + "field": "data_office365_AuthenticationType",
104 + "size": 10
105 + },
106 + {
107 + "id": "by_country",
108 + "title": "Events by Source Country",
109 + "type": "bar_h",
110 + "w": 6,
111 + "h": 320,
112 + "lucene": "data_office365_Workload:SharePoint",
113 + "field": "data_office365_ClientIP_country_code",
114 + "size": 15
115 + },
116 + {
117 + "id": "top_rules",
118 + "title": "Top Rules Triggered",
119 + "type": "bar_h",
120 + "w": 6,
121 + "h": 320,
122 + "lucene": "data_office365_Workload:SharePoint AND rule_level:>=12",
123 + "field": "rule_description",
124 + "size": 10
125 + }
126 + ]
127 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_SUMMARY.json new
+137
@@ -0,0 +1,137 @@
1 +{
2 + "id": "O365_SUMMARY",
3 + "title": "Office 365 - Summary",
4 + "description": "High-level overview of all Office 365 activity: alert counts, workload distribution, operations breakdown, Azure AD authentication events (successes and failures), failed auth accounts, and a full event log with MITRE ATT&CK context.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 3,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:* AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 3,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:*"
21 + },
22 + {
23 + "id": "azure_ad_total_auths",
24 + "title": "Azure AD - Total Auths",
25 + "type": "stat",
26 + "w": 3,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:AzureActiveDirectory AND data_office365_Operation:UserLoggedIn"
29 + },
30 + {
31 + "id": "azure_ad_failed_auths",
32 + "title": "Azure AD - Failed Auths",
33 + "type": "stat",
34 + "w": 3,
35 + "h": 100,
36 + "lucene": "data_office365_Workload:AzureActiveDirectory AND data_office365_Operation:UserLoginFailed"
37 + },
38 + {
39 + "id": "events_over_time",
40 + "title": "Events Over Time by Workload",
41 + "type": "histogram",
42 + "w": 12,
43 + "h": 200,
44 + "lucene": "data_office365_Workload:*"
45 + },
46 + {
47 + "id": "by_workload",
48 + "title": "Events by Workload",
49 + "type": "pie",
50 + "w": 4,
51 + "h": 300,
52 + "lucene": "data_office365_Workload:*",
53 + "field": "data_office365_Workload",
54 + "size": 10
55 + },
56 + {
57 + "id": "by_operation",
58 + "title": "Events by Operation",
59 + "type": "pie",
60 + "w": 4,
61 + "h": 300,
62 + "lucene": "data_office365_Workload:*",
63 + "field": "data_office365_Operation",
64 + "size": 10
65 + },
66 + {
67 + "id": "by_rule_level",
68 + "title": "By Rule Severity Level",
69 + "type": "pie",
70 + "w": 4,
71 + "h": 300,
72 + "lucene": "data_office365_Workload:*",
73 + "field": "rule_level",
74 + "size": 10
75 + },
76 + {
77 + "id": "top_operations",
78 + "title": "Top Operations",
79 + "type": "bar_h",
80 + "w": 6,
81 + "h": 320,
82 + "lucene": "data_office365_Workload:*",
83 + "field": "data_office365_Operation",
84 + "size": 15
85 + },
86 + {
87 + "id": "top_workloads",
88 + "title": "Top Workloads",
89 + "type": "bar_h",
90 + "w": 6,
91 + "h": 320,
92 + "lucene": "data_office365_Workload:*",
93 + "field": "data_office365_Workload",
94 + "size": 10
95 + },
96 + {
97 + "id": "failed_auth_by_account",
98 + "title": "Failed Auths by Account",
99 + "type": "bar_h",
100 + "w": 6,
101 + "h": 320,
102 + "lucene": "data_office365_Workload:AzureActiveDirectory AND data_office365_Operation:UserLoginFailed",
103 + "field": "data_office365_UserId",
104 + "size": 15
105 + },
106 + {
107 + "id": "auth_by_country",
108 + "title": "Azure AD Auth Events by Country",
109 + "type": "bar_h",
110 + "w": 6,
111 + "h": 320,
112 + "lucene": "data_office365_Workload:AzureActiveDirectory",
113 + "field": "data_office365_ActorIpAddress_country_code",
114 + "size": 15
115 + },
116 + {
117 + "id": "top_users",
118 + "title": "Top Users",
119 + "type": "bar_h",
120 + "w": 6,
121 + "h": 320,
122 + "lucene": "data_office365_Workload:*",
123 + "field": "data_office365_UserId",
124 + "size": 10
125 + },
126 + {
127 + "id": "top_rules",
128 + "title": "Top Rules Triggered",
129 + "type": "bar_h",
130 + "w": 6,
131 + "h": 320,
132 + "lucene": "data_office365_Workload:* AND rule_level:>=12",
133 + "field": "rule_description",
134 + "size": 10
135 + }
136 + ]
137 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_TEAMS.json new
+107
@@ -0,0 +1,107 @@
1 +{
2 + "id": "O365_TEAMS",
3 + "title": "Office 365 - Microsoft Teams",
4 + "description": "Microsoft Teams activity: messaging, meetings, session tracking, link-containing messages, user activity, and source country analysis.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 3,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:MicrosoftTeams AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 3,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:MicrosoftTeams"
21 + },
22 + {
23 + "id": "messages_with_links",
24 + "title": "Messages Containing Links",
25 + "type": "stat",
26 + "w": 3,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:MicrosoftTeams AND data_office365_Operation:MessageCreatedHasLink"
29 + },
30 + {
31 + "id": "sessions_started",
32 + "title": "Sessions Started",
33 + "type": "stat",
34 + "w": 3,
35 + "h": 100,
36 + "lucene": "data_office365_Workload:MicrosoftTeams AND data_office365_Operation:TeamsSessionStarted"
37 + },
38 + {
39 + "id": "operations_over_time",
40 + "title": "Operations Over Time",
41 + "type": "histogram",
42 + "w": 12,
43 + "h": 200,
44 + "lucene": "data_office365_Workload:MicrosoftTeams"
45 + },
46 + {
47 + "id": "by_operation",
48 + "title": "Events by Operation (Top 10)",
49 + "type": "pie",
50 + "w": 6,
51 + "h": 300,
52 + "lucene": "data_office365_Workload:MicrosoftTeams",
53 + "field": "data_office365_Operation",
54 + "size": 10
55 + },
56 + {
57 + "id": "by_user",
58 + "title": "Events by User",
59 + "type": "pie",
60 + "w": 6,
61 + "h": 300,
62 + "lucene": "data_office365_Workload:MicrosoftTeams",
63 + "field": "data_office365_UserId",
64 + "size": 10
65 + },
66 + {
67 + "id": "top_operations",
68 + "title": "Top Operations",
69 + "type": "bar_h",
70 + "w": 6,
71 + "h": 320,
72 + "lucene": "data_office365_Workload:MicrosoftTeams",
73 + "field": "data_office365_Operation",
74 + "size": 15
75 + },
76 + {
77 + "id": "top_users",
78 + "title": "Top Users",
79 + "type": "bar_h",
80 + "w": 6,
81 + "h": 320,
82 + "lucene": "data_office365_Workload:MicrosoftTeams",
83 + "field": "data_office365_UserId",
84 + "size": 15
85 + },
86 + {
87 + "id": "by_country",
88 + "title": "Events by Source Country",
89 + "type": "bar_h",
90 + "w": 6,
91 + "h": 320,
92 + "lucene": "data_office365_Workload:MicrosoftTeams",
93 + "field": "data_office365_ClientIP_country_code",
94 + "size": 15
95 + },
96 + {
97 + "id": "top_rules",
98 + "title": "Top Rules Triggered",
99 + "type": "bar_h",
100 + "w": 6,
101 + "h": 320,
102 + "lucene": "data_office365_Workload:MicrosoftTeams AND rule_level:>=12",
103 + "field": "rule_description",
104 + "size": 10
105 + }
106 + ]
107 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/O365_THREAT_INTELLIGENCE.json new
+117
@@ -0,0 +1,117 @@
1 +{
2 + "id": "O365_THREAT_INTELLIGENCE",
3 + "title": "Office 365 - Threat Intelligence",
4 + "description": "Microsoft Defender for Office 365 threat intelligence: phish and malware detections, delivery actions, detection methods, mail directionality, sender analysis, and source country tracking.",
5 + "panels": [
6 + {
7 + "id": "alerts",
8 + "title": "Alerts (rule level ≥ 12)",
9 + "type": "stat",
10 + "w": 3,
11 + "h": 100,
12 + "lucene": "data_office365_Workload:ThreatIntelligence AND rule_level:>=12"
13 + },
14 + {
15 + "id": "total_events",
16 + "title": "Total Events",
17 + "type": "stat",
18 + "w": 3,
19 + "h": 100,
20 + "lucene": "data_office365_Workload:ThreatIntelligence"
21 + },
22 + {
23 + "id": "max_severity",
24 + "title": "Max Rule Severity",
25 + "type": "stat",
26 + "w": 3,
27 + "h": 100,
28 + "lucene": "data_office365_Workload:ThreatIntelligence"
29 + },
30 + {
31 + "id": "unique_senders",
32 + "title": "Unique Senders",
33 + "type": "stat",
34 + "w": 3,
35 + "h": 100,
36 + "lucene": "data_office365_Workload:ThreatIntelligence"
37 + },
38 + {
39 + "id": "events_over_time",
40 + "title": "Threat Events Over Time",
41 + "type": "histogram",
42 + "w": 12,
43 + "h": 200,
44 + "lucene": "data_office365_Workload:ThreatIntelligence"
45 + },
46 + {
47 + "id": "by_detection_method",
48 + "title": "By Detection Method",
49 + "type": "pie",
50 + "w": 6,
51 + "h": 300,
52 + "lucene": "data_office365_Workload:ThreatIntelligence",
53 + "field": "data_office365_DetectionMethod",
54 + "size": 10
55 + },
56 + {
57 + "id": "by_delivery_action",
58 + "title": "By Delivery Action",
59 + "type": "pie",
60 + "w": 6,
61 + "h": 300,
62 + "lucene": "data_office365_Workload:ThreatIntelligence",
63 + "field": "data_office365_DeliveryAction",
64 + "size": 10
65 + },
66 + {
67 + "id": "by_directionality",
68 + "title": "By Directionality",
69 + "type": "bar_h",
70 + "w": 6,
71 + "h": 320,
72 + "lucene": "data_office365_Workload:ThreatIntelligence",
73 + "field": "data_office365_Directionality",
74 + "size": 10
75 + },
76 + {
77 + "id": "top_senders",
78 + "title": "Top Senders (P1Sender)",
79 + "type": "bar_h",
80 + "w": 6,
81 + "h": 320,
82 + "lucene": "data_office365_Workload:ThreatIntelligence",
83 + "field": "data_office365_P1Sender",
84 + "size": 15
85 + },
86 + {
87 + "id": "top_operations",
88 + "title": "Top Operations",
89 + "type": "bar_h",
90 + "w": 6,
91 + "h": 320,
92 + "lucene": "data_office365_Workload:ThreatIntelligence",
93 + "field": "data_office365_Operation",
94 + "size": 15
95 + },
96 + {
97 + "id": "by_country",
98 + "title": "Events by Sender Country",
99 + "type": "bar_h",
100 + "w": 6,
101 + "h": 320,
102 + "lucene": "data_office365_Workload:ThreatIntelligence",
103 + "field": "data_office365_SenderIp_country_code",
104 + "size": 15
105 + },
106 + {
107 + "id": "top_rules",
108 + "title": "Top Rules Triggered",
109 + "type": "bar_h",
110 + "w": 6,
111 + "h": 320,
112 + "lucene": "data_office365_Workload:ThreatIntelligence AND rule_level:>=12",
113 + "field": "rule_description",
114 + "size": 10
115 + }
116 + ]
117 +}
backend/app/siem/dashboard_templates/office365_cloudintegration/_card.json new
+11
@@ -0,0 +1,11 @@
1 +{
2 + "id": "office365_cloudintegration",
3 + "title": "Office 365 - Cloud Integration",
4 + "description": "Cloud integration dashboards for Microsoft Office 365. Covers Exchange Online, SharePoint, OneDrive, Teams, Azure AD sign-in and audit logs, DLP policy matches, and security & compliance events.",
5 + "vendor": "Microsoft",
6 + "product": "Office 365",
7 + "event_type": "Cloud",
8 + "tags": ["office365", "microsoft", "cloud", "exchange", "sharepoint", "azure-ad", "teams"],
9 + "color": "#D83B01",
10 + "icon": "cloud"
11 +}
backend/requirements.txt
+3
@@ -59,6 +59,7 @@ h11==0.14.0
59 httpcore==0.18.0
60 httptools==0.6.1
61 httpx==0.25.0
62 +httpx[http2]==0.25.0
63 idna==3.4
64 influxdb-client==1.38.0
65 iniconfig==2.0.0
@@ -120,6 +121,7 @@ pycparser==2.21
121 Pygments==2.16.1
122 PyJWT==2.8.0
123 PyMySQL==1.1.0
124 +pyotp==2.9.0
125 pyparsing==3.1.1
126 pyrsistent==0.19.3
127 pySigma==0.11.9
@@ -133,6 +135,7 @@ python-multipart==0.0.6
135 pytz==2023.3.post1
136 pyvelociraptor==0.1.8
137 PyYAML==6.0.1
138 +qrcode==7.4.2
139 reactivex==4.0.4
140 redis==4.6.0
141 regex==2023.10.3
docs/docs.json
+3 -1
@@ -129,8 +129,10 @@
129 ]
130 },
131 {
132 - "group": "Access Control",
132 + "group": "Authentication & Access",
133 "pages": [
134 + "user/ui/sso",
135 + "user/ui/two-factor-authentication",
136 "user/ui/tag-access"
137 ]
138 },
docs/user/ui/sso.md new
+231
@@ -0,0 +1,231 @@
1 +---
2 +title: Single Sign-On (SSO)
3 +description: Configure SSO with Azure Entra ID, Google, or Cloudflare Access so users can log in via their identity provider.
4 +---
5 +
6 +**Menu:** Users → Single Sign-On (SSO) Configuration
7 +
8 +**Best for:** Admin
9 +
10 +CoPilot supports three SSO providers out of the box. Once configured, users see **Sign in with …** buttons on the login page alongside the standard username/password form.
11 +
12 +| Provider | Protocol | How it works |
13 +|---|---|---|
14 +| **Azure Entra ID** | OAuth 2.0 / OIDC | User is redirected to Microsoft, then back to CoPilot with an authorization code. |
15 +| **Google** | OAuth 2.0 / OIDC | Same redirect flow via Google accounts. |
16 +| **Cloudflare Access** | JWT assertion | Cloudflare sits in front of CoPilot and injects a signed header; no redirect needed from the user's perspective. |
17 +
18 +---
19 +
20 +## How SSO login works
21 +
22 +```
23 +┌──────────┐ ┌───────────────────┐ ┌──────────────┐
24 +│ Browser │─────▶│ Identity Provider │─────▶│ CoPilot │
25 +│ (login │ │ (Azure / Google / │ │ backend │
26 +│ page) │◀─────│ Cloudflare) │◀─────│ │
27 +└──────────┘ └───────────────────┘ └──────────────┘
28 +
29 +1. User clicks an SSO button on the login page.
30 +2. Browser redirects to the identity provider.
31 +3. User authenticates with their corporate credentials.
32 +4. Provider redirects back to CoPilot with a signed token.
33 +5. CoPilot verifies the token, checks the email allowlist,
34 + auto-provisions a local account if needed, and issues a session.
35 +```
36 +
37 +> **Auto-provisioning:** The first time an allowed email logs in via SSO, CoPilot automatically creates a local user account with the role you assigned in the allowlist. No manual user creation is required.
38 +
39 +---
40 +
41 +## Prerequisites
42 +
43 +- Your CoPilot instance must be reachable at a stable URL (HTTPS recommended).
44 +- You need **admin** access in CoPilot.
45 +- You need admin access to the identity provider you want to configure (Azure portal, Google Cloud Console, or Cloudflare Zero Trust dashboard).
46 +
47 +---
48 +
49 +## Step 1 — Enable SSO globally
50 +
51 +1. Go to **Users** in the top navigation.
52 +2. Scroll to the **Single Sign-On (SSO) Configuration** card.
53 +3. Toggle **Enable SSO** to on.
54 +
55 +> This toggle controls whether SSO login buttons appear on the login page. You can enable the global toggle and then selectively enable individual providers.
56 +
57 +---
58 +
59 +## Step 2 — Configure a provider
60 +
61 +### Azure Entra ID
62 +
63 +<Steps>
64 +
65 +<Step title="Register an application in Azure">
66 + Go to [portal.azure.com](https://portal.azure.com) → **Azure Active Directory** → **App registrations** → **New registration**.
67 +
68 + - **Name:** e.g. `CoPilot SSO`
69 + - **Supported account types:** *Accounts in this organizational directory only*
70 + - Click **Register**.
71 +</Step>
72 +
73 +<Step title="Copy IDs">
74 + From the app's **Overview** page:
75 + - Copy the **Application (client) ID** → you will paste this as **Client ID** in CoPilot.
76 + - Copy the **Directory (tenant) ID** → you will paste this as **Tenant ID** in CoPilot.
77 +</Step>
78 +
79 +<Step title="Create a client secret">
80 + Go to **Certificates & secrets** → **New client secret**. Copy the **Value** (not the Secret ID) → you will paste this as **Client Secret** in CoPilot.
81 +</Step>
82 +
83 +<Step title="Set the redirect URI">
84 + Go to **Authentication** → **Add a platform** → **Web**. Set the redirect URI to:
85 +
86 + ```
87 + https://<your-copilot-domain>/api/auth/sso/azure/callback
88 + ```
89 +
90 + > **Tip:** In CoPilot, the **Auto-fill** button next to the Redirect URI field will populate this automatically based on your current domain.
91 +</Step>
92 +
93 +<Step title="Add the email claim">
94 + Go to **Token configuration** → **Add optional claim** → select **ID** token type → check **email** → **Add**.
95 +</Step>
96 +
97 +<Step title="Fill in CoPilot">
98 + Back in CoPilot under the **Azure Entra ID** section:
99 + 1. Toggle **Enable Azure SSO** to on.
100 + 2. Paste the **Tenant ID**, **Client ID**, and **Client Secret**.
101 + 3. Confirm the **Redirect URI** matches what you set in Azure.
102 + 4. Click **Save SSO Settings**.
103 +</Step>
104 +
105 +</Steps>
106 +
107 +---
108 +
109 +### Google
110 +
111 +<Steps>
112 +
113 +<Step title="Create OAuth credentials">
114 + Go to [Google Cloud Console](https://console.cloud.google.com) → **APIs & Services** → **Credentials** → **Create Credentials** → **OAuth client ID**.
115 +
116 + - **Application type:** Web application
117 + - **Authorized redirect URIs:** add:
118 +
119 + ```
120 + https://<your-copilot-domain>/api/auth/sso/google/callback
121 + ```
122 +</Step>
123 +
124 +<Step title="Copy credentials">
125 + Copy the **Client ID** and **Client Secret** shown after creation.
126 +</Step>
127 +
128 +<Step title="Enable People API">
129 + In the Cloud Console go to **APIs & Services** → **Library** → search for **People API** → **Enable**. This is required for CoPilot to retrieve the user's email and profile.
130 +</Step>
131 +
132 +<Step title="Fill in CoPilot">
133 + Under the **Google (OAuth2 / OIDC)** section:
134 + 1. Toggle **Enable Google SSO** to on.
135 + 2. Paste the **Client ID** and **Client Secret**.
136 + 3. Confirm the **Redirect URI**.
137 + 4. Click **Save SSO Settings**.
138 +</Step>
139 +
140 +</Steps>
141 +
142 +---
143 +
144 +### Cloudflare Access
145 +
146 +Cloudflare Access works differently from the other providers. Instead of redirecting the user, Cloudflare sits **in front** of CoPilot as a reverse proxy and injects a cryptographically signed JWT header (`Cf-Access-Jwt-Assertion`) into every request. CoPilot verifies this header — it is impossible to forge without Cloudflare's private key.
147 +
148 +<Steps>
149 +
150 +<Step title="Create a Cloudflare Access application">
151 + In the **Cloudflare Zero Trust** dashboard go to **Access** → **Applications** → **Add an application** → **Self-hosted**.
152 +
153 + - Set the domain to your CoPilot URL (e.g. `copilot.example.com`).
154 + - Connect your identity provider (e.g. Entra ID, Google, GitHub) under **Identity providers**.
155 +</Step>
156 +
157 +<Step title="Copy the Application Audience">
158 + After creating the app open it → **Overview** → copy the **Application Audience (AUD) Tag**.
159 +</Step>
160 +
161 +<Step title="Copy your Team Domain">
162 + Go to **Settings** → **Custom Pages** and copy your team domain (e.g. `myteam.cloudflareaccess.com`).
163 +</Step>
164 +
165 +<Step title="Fill in CoPilot">
166 + Under the **Cloudflare Access (JWT Assertion)** section:
167 + 1. Toggle **Enable Cloudflare Access** to on.
168 + 2. Paste the **Team Domain** and **Application Audience (AUD)**.
169 + 3. Click **Save SSO Settings**.
170 +</Step>
171 +
172 +</Steps>
173 +
174 +> With Cloudflare Access there is no client secret — authentication is handled entirely by the signed JWT header.
175 +
176 +---
177 +
178 +## Step 3 — Manage the email allowlist
179 +
180 +SSO users can only log in if their email address is in the **SSO Allowed Emails** list. This prevents unauthorized accounts in your identity provider from accessing CoPilot.
181 +
182 +1. Scroll to the **SSO Allowed Emails** card (below the SSO configuration card).
183 +2. Click **Add Email**.
184 +3. Enter the user's email address and select a **role**:
185 + - **Admin** — full platform access
186 + - **Analyst** (default) — standard operator access
187 +4. Click **Add Email**.
188 +
189 +| Column | Description |
190 +|---|---|
191 +| **Email** | The email address permitted to log in via SSO. |
192 +| **Role** | The CoPilot role assigned on first login. |
193 +| **Added** | Timestamp of when the entry was created. |
194 +
195 +To remove an email, click the delete button in the row.
196 +
197 +> **Important:** Only emails in this list can log in via SSO. If a user's email is not listed, they will be denied access even if they authenticate successfully with the identity provider.
198 +
199 +---
200 +
201 +## How SSO interacts with 2FA
202 +
203 +If a user has [two-factor authentication](/user/ui/two-factor-authentication) enabled on their CoPilot account, SSO login will still require the second factor:
204 +
205 +1. User authenticates via the identity provider.
206 +2. CoPilot verifies the SSO token and finds that the user has 2FA enabled.
207 +3. The user is prompted for their TOTP code (or a backup code) before the session is created.
208 +
209 +This means SSO and 2FA are **complementary** — SSO simplifies *how* users authenticate with their identity, while 2FA adds a second verification step.
210 +
211 +---
212 +
213 +## Environment variables
214 +
215 +| Variable | Purpose | Default |
216 +|---|---|---|
217 +| `SSO_STATE_SECRET` | HMAC key used to sign OAuth2 state tokens (prevents CSRF). | Falls back to `JWT_SECRET` |
218 +
219 +> The state token is stateless — it encodes a timestamp and is signed with HMAC-SHA256. It expires after 10 minutes.
220 +
221 +---
222 +
223 +## Troubleshooting
224 +
225 +| Symptom | Likely cause | Fix |
226 +|---|---|---|
227 +| SSO buttons don't appear on the login page | Global SSO toggle is off, or no individual provider is enabled. | Enable SSO globally **and** enable at least one provider. |
228 +| "Email not in allowed list" after SSO login | The user's email is not in the SSO Allowed Emails list. | Add the email address to the allowlist. |
229 +| Azure callback fails with "invalid redirect URI" | Redirect URI in Azure app registration doesn't match CoPilot. | Copy the redirect URI from CoPilot (use **Auto-fill**) and paste it exactly into Azure. |
230 +| Google callback fails | People API not enabled or redirect URI mismatch. | Enable the People API in Google Cloud Console and verify the redirect URI. |
231 +| Cloudflare "authentication failed" | CoPilot is not behind Cloudflare Access, or the AUD/team domain is wrong. | Ensure traffic goes through Cloudflare Access and double-check the AUD tag and team domain. |
docs/user/ui/two-factor-authentication.md new
+166
@@ -0,0 +1,166 @@
1 +---
2 +title: Two-Factor Authentication (2FA)
3 +description: Enable TOTP-based two-factor authentication on your CoPilot account using an authenticator app.
4 +---
5 +
6 +**Menu:** Profile → Security tab → Two-Factor Authentication
7 +
8 +**Best for:** All users
9 +
10 +Two-factor authentication (2FA) adds a second verification step every time you log in. After entering your password (or completing SSO), you must also provide a 6-digit code from an authenticator app on your phone or computer.
11 +
12 +CoPilot uses the **TOTP** (Time-based One-Time Password) standard, which is supported by all major authenticator apps:
13 +
14 +- [Google Authenticator](https://support.google.com/accounts/answer/1066447)
15 +- [Microsoft Authenticator](https://www.microsoft.com/en-us/security/mobile-authenticator-app)
16 +- [Authy](https://authy.com/)
17 +- [1Password](https://1password.com/)
18 +- Any TOTP-compatible app
19 +
20 +---
21 +
22 +## Enable 2FA on your account
23 +
24 +<Steps>
25 +
26 +<Step title="Open your profile">
27 + Click your username or avatar in the top-right corner and select **Profile**, then switch to the **Security** tab.
28 +</Step>
29 +
30 +<Step title="Start setup">
31 + Click **Enable Two-Factor Authentication**. CoPilot generates a unique secret and displays a QR code.
32 +</Step>
33 +
34 +<Step title="Scan the QR code">
35 + Open your authenticator app and scan the QR code displayed on screen.
36 +
37 + > **Can't scan?** Click **Show manual entry key** to reveal the secret as a text string. Enter it manually in your authenticator app.
38 +
39 + <Warning>
40 + Make sure your device clock is accurate. TOTP codes are time-sensitive and allow only a **±30 second** tolerance window. If your clock is off, codes will be rejected.
41 + </Warning>
42 +</Step>
43 +
44 +<Step title="Verify the code">
45 + Enter the 6-digit code currently shown in your authenticator app and click **Verify & Enable**.
46 +
47 + This confirms that your app is configured correctly and activates 2FA on your account.
48 +</Step>
49 +
50 +<Step title="Save your backup codes">
51 + After verification, CoPilot displays **8 one-time backup codes**. These are your emergency access method if you lose your authenticator device.
52 +
53 + - Click **Copy all** to copy the codes to your clipboard.
54 + - Click **Download .txt** to save them as a text file.
55 + - Store them somewhere safe (e.g. a password manager or a printed sheet in a secure location).
56 +
57 + <Warning>
58 + Backup codes are shown **only once** during setup. If you lose them and lose access to your authenticator app, you will be locked out of your account.
59 + </Warning>
60 +</Step>
61 +
62 +</Steps>
63 +
64 +---
65 +
66 +## Log in with 2FA
67 +
68 +Once 2FA is enabled, the login flow changes:
69 +
70 +```
71 +┌──────────────┐ ┌──────────────┐ ┌──────────────┐
72 +│ Enter │────▶│ Enter │────▶│ Session │
73 +│ username & │ │ 6-digit │ │ created │
74 +│ password │ │ TOTP code │ │ │
75 +└──────────────┘ └──────────────┘ └──────────────┘
76 +```
77 +
78 +1. Enter your username and password as usual, then click **Sign in**.
79 +2. CoPilot recognizes that your account has 2FA enabled and shows a verification form.
80 +3. Open your authenticator app and enter the current 6-digit code, then click **Verify**.
81 +4. You are logged in.
82 +
83 +> **SSO + 2FA:** If you sign in via SSO (Azure, Google, or Cloudflare Access), you will still be prompted for the TOTP code after the identity provider authenticates you.
84 +
85 +---
86 +
87 +## Use a backup code
88 +
89 +If you cannot access your authenticator app (lost phone, new device, etc.), you can use one of your backup codes instead:
90 +
91 +1. On the 2FA verification screen, click **Use a backup code instead**.
92 +2. Enter one of your saved backup codes (e.g. `ABCD1234EF`).
93 +3. Click **Use backup code**.
94 +
95 +Each backup code can only be used **once**. After use, it is permanently invalidated.
96 +
97 +---
98 +
99 +## Regenerate backup codes
100 +
101 +If you have used some of your backup codes or suspect they have been compromised:
102 +
103 +1. Go to **Profile** → **Security** tab.
104 +2. Click **Regenerate backup codes**.
105 +3. Enter your current 6-digit TOTP code to confirm your identity.
106 +4. CoPilot generates 8 **new** backup codes. All previous codes are invalidated immediately.
107 +5. Save the new codes (copy or download).
108 +
109 +---
110 +
111 +## Disable 2FA
112 +
113 +1. Go to **Profile** → **Security** tab.
114 +2. Click **Disable 2FA**.
115 +3. Confirm by entering your current TOTP code **or** a backup code.
116 +4. 2FA is removed from your account. Future logins will only require a password.
117 +
118 +---
119 +
120 +## Brute-force protection
121 +
122 +CoPilot enforces rate limiting on 2FA verification:
123 +
124 +- After **5 failed attempts**, your account is locked out of 2FA verification for **15 minutes**.
125 +- This applies to both TOTP code entry and backup code entry.
126 +- The lockout resets automatically after the timeout or on a successful verification.
127 +
128 +---
129 +
130 +## FAQ
131 +
132 +<AccordionGroup>
133 +
134 +<Accordion title="What happens if I lose my phone?">
135 + Use one of your backup codes to log in, then either regenerate new backup codes or disable 2FA and set it up again with your new device.
136 +</Accordion>
137 +
138 +<Accordion title="Can an admin disable 2FA for a user?">
139 + Currently, each user manages their own 2FA. If a user is locked out with no backup codes, an admin can reset their account through the database.
140 +</Accordion>
141 +
142 +<Accordion title="Does 2FA apply to API access?">
143 + 2FA is enforced during interactive login only. API tokens issued after a successful login (including 2FA) remain valid for their normal lifetime.
144 +</Accordion>
145 +
146 +<Accordion title="Which authenticator app should I use?">
147 + Any TOTP-compatible app works. Popular choices include Google Authenticator, Microsoft Authenticator, Authy, and 1Password. They are all interchangeable — pick whichever you prefer.
148 +</Accordion>
149 +
150 +<Accordion title="My codes are always rejected">
151 + Check that your device clock is accurate. TOTP codes depend on precise time synchronization (within ±30 seconds). On mobile, enable automatic time in your device settings.
152 +</Accordion>
153 +
154 +</AccordionGroup>
155 +
156 +---
157 +
158 +## Environment variables (admin reference)
159 +
160 +| Variable | Purpose | Default |
161 +|---|---|---|
162 +| `TOTP_ENCRYPTION_KEY` | Fernet key used to encrypt TOTP secrets at rest in the database. | Derived from `JWT_SECRET` |
163 +
164 +<Warning>
165 + Once users have enrolled in 2FA, **do not change** `TOTP_ENCRYPTION_KEY`. Changing it will make all existing TOTP secrets unreadable, locking enrolled users out of 2FA verification.
166 +</Warning>
frontend/src/api/endpoints/auth.ts
+3 -4
@@ -6,10 +6,9 @@ import { HttpClient } from "../httpClient"
6 export default {
7 login(payload: LoginPayload) {
8 const formData = jsonToFormData(payload)
9 - return HttpClient.post<FlaskBaseResponse & { access_token: string; token_type: string }>(
10 - "/auth/token",
11 - formData
12 - )
9 + return HttpClient.post<
10 + FlaskBaseResponse & { access_token: string; token_type: string; requires_2fa?: boolean }
11 + >("/auth/token", formData)
12 },
13 register(payload: RegisterPayload) {
14 return HttpClient.post<FlaskBaseResponse>("/auth/register", payload)
frontend/src/api/endpoints/sso.ts new
+98
@@ -0,0 +1,98 @@
1 +import type { FlaskBaseResponse } from "@/types/flask.d"
2 +import { HttpClient } from "../httpClient"
3 +
4 +export interface SSOConfigResponse {
5 + sso_enabled: boolean
6 + azure_enabled: boolean
7 + azure_tenant_id: string | null
8 + azure_client_id: string | null
9 + azure_client_secret_set: boolean
10 + azure_redirect_uri: string | null
11 + google_enabled: boolean
12 + google_client_id: string | null
13 + google_client_secret_set: boolean
14 + google_redirect_uri: string | null
15 + cf_enabled: boolean
16 + cf_team_domain: string | null
17 + cf_audience: string | null
18 +}
19 +
20 +export interface SSOConfigUpdate {
21 + sso_enabled: boolean
22 + azure_enabled: boolean
23 + azure_tenant_id?: string | null
24 + azure_client_id?: string | null
25 + azure_client_secret?: string | null
26 + azure_redirect_uri?: string | null
27 + google_enabled: boolean
28 + google_client_id?: string | null
29 + google_client_secret?: string | null
30 + google_redirect_uri?: string | null
31 + cf_enabled: boolean
32 + cf_team_domain?: string | null
33 + cf_audience?: string | null
34 +}
35 +
36 +export interface SSOAllowedEmail {
37 + id: number
38 + email: string
39 + role_id: number
40 + created_at: string
41 +}
42 +
43 +export interface SSOAllowedEmailInput {
44 + email: string
45 + role_id: number
46 +}
47 +
48 +export interface SSOPublicStatus {
49 + sso_enabled: boolean
50 + azure_enabled: boolean
51 + google_enabled: boolean
52 + cf_enabled: boolean
53 + azure_authorization_url: string | null
54 + google_authorization_url: string | null
55 +}
56 +
57 +export interface SSOCloudflareVerifyResponse {
58 + access_token: string
59 + token_type: string
60 + requires_2fa?: boolean
61 +}
62 +
63 +export default {
64 + /** Public — which SSO providers are active */
65 + getStatus() {
66 + return HttpClient.get<FlaskBaseResponse & SSOPublicStatus>("/auth/sso/status")
67 + },
68 +
69 + /** Admin — get SSO settings */
70 + getSettings() {
71 + return HttpClient.get<FlaskBaseResponse & SSOConfigResponse>("/auth/sso/settings")
72 + },
73 +
74 + /** Admin — update SSO settings */
75 + updateSettings(payload: SSOConfigUpdate) {
76 + return HttpClient.put<FlaskBaseResponse & SSOConfigResponse>("/auth/sso/settings", payload)
77 + },
78 +
79 + /** Admin — list allowed emails */
80 + getAllowedEmails() {
81 + return HttpClient.get<FlaskBaseResponse & { emails: SSOAllowedEmail[] }>("/auth/sso/allowed-emails")
82 + },
83 +
84 + /** Admin — add allowed email */
85 + addAllowedEmail(payload: SSOAllowedEmailInput) {
86 + return HttpClient.post<FlaskBaseResponse & { id: number }>("/auth/sso/allowed-emails", payload)
87 + },
88 +
89 + /** Admin — remove allowed email */
90 + removeAllowedEmail(emailId: number) {
91 + return HttpClient.delete<FlaskBaseResponse>(`/auth/sso/allowed-emails/${emailId}`)
92 + },
93 +
94 + /** Cloudflare Access — verify JWT from header */
95 + cloudflareVerify() {
96 + return HttpClient.post<FlaskBaseResponse & SSOCloudflareVerifyResponse>("/auth/sso/cloudflare/verify")
97 + }
98 +}
frontend/src/api/endpoints/totp.ts new
+67
@@ -0,0 +1,67 @@
1 +import type { FlaskBaseResponse } from "@/types/flask.d"
2 +import { HttpClient } from "../httpClient"
3 +
4 +export interface TOTPSetupResponse {
5 + secret: string
6 + otpauth_url: string
7 + qr_data_uri: string
8 + backup_codes: string[]
9 +}
10 +
11 +export interface TOTPStatusResponse {
12 + enabled: boolean
13 +}
14 +
15 +export interface TOTPBackupCodesResponse {
16 + backup_codes: string[]
17 +}
18 +
19 +export interface TOTPValidateResponse {
20 + access_token: string
21 + token_type: string
22 +}
23 +
24 +export interface TOTPValidateRequest {
25 + temp_token: string
26 + code?: string
27 + backup_code?: string
28 +}
29 +
30 +export interface TOTPDeleteRequest {
31 + code?: string
32 + backup_code?: string
33 +}
34 +
35 +export default {
36 + /** Get 2FA status for current user */
37 + getStatus() {
38 + return HttpClient.get<FlaskBaseResponse & TOTPStatusResponse>("/auth/2fa/status")
39 + },
40 +
41 + /** Start 2FA setup — get QR code and backup codes */
42 + setup() {
43 + return HttpClient.post<FlaskBaseResponse & TOTPSetupResponse>("/auth/2fa/setup")
44 + },
45 +
46 + /** Verify setup with a TOTP code to activate 2FA */
47 + verifySetup(code: string) {
48 + return HttpClient.post<FlaskBaseResponse>("/auth/2fa/verify-setup", { code })
49 + },
50 +
51 + /** Disable 2FA (requires TOTP code or backup code) */
52 + disable(payload: TOTPDeleteRequest) {
53 + return HttpClient.delete<FlaskBaseResponse>("/auth/2fa/disable", { data: payload })
54 + },
55 +
56 + /** Validate 2FA code during login (uses temp_token, no auth header) */
57 + validate(payload: TOTPValidateRequest) {
58 + return HttpClient.post<FlaskBaseResponse & TOTPValidateResponse>("/auth/2fa/validate", payload)
59 + },
60 +
61 + /** Regenerate backup codes (requires TOTP code) */
62 + regenerateBackupCodes(code: string) {
63 + return HttpClient.post<FlaskBaseResponse & TOTPBackupCodesResponse>("/auth/2fa/backup-codes/regenerate", {
64 + code
65 + })
66 + }
67 +}
frontend/src/api/index.ts
+5 -1
@@ -32,10 +32,12 @@ import siem from "./endpoints/siem"
32 import sigma from "./endpoints/sigma"
33 import snapshots from "./endpoints/snapshots"
34 import soc from "./endpoints/soc"
35 +import sso from "./endpoints/sso"
36 import stackProvisioning from "./endpoints/stackProvisioning"
37 import sysmonConfig from "./endpoints/sysmonConfig"
38 import tagRbac from "./endpoints/tagRbac"
39 import threatIntel from "./endpoints/threatIntel"
40 +import totp from "./endpoints/totp"
41 import users from "./endpoints/users"
42 import version from "./endpoints/version"
43 import vulnerabilities from "./endpoints/vulnerabilities"
@@ -87,5 +89,7 @@ export default {
89 customerPortal,
90 version,
91 snapshots,
90 - tagRbac
92 + tagRbac,
93 + sso,
94 + totp
95 }
frontend/src/app-layouts/common/Toolbar/Avatar.vue
+10
@@ -18,6 +18,7 @@ const LogsIcon = "carbon:cloud-logging"
18 const ContactIcon = "ic:outline-alternate-email"
19 const DocsIcon = "carbon:document"
20 const UsersIcon = "carbon:group-security"
21 +const SSOConfigIcon = "carbon:rule-locked"
22
23 const router = useRouter()
24 const authStore = useAuthStore()
@@ -40,6 +41,15 @@ const options = ref([
41 key: "route-Users",
42 icon: renderIcon(UsersIcon)
43 },
44 + ...(authStore.isAdmin
45 + ? [
46 + {
47 + label: "SSO Config",
48 + key: "route-SSOConfig",
49 + icon: renderIcon(SSOConfigIcon)
50 + }
51 + ]
52 + : []),
53 {
54 label: "Logs",
55 key: "route-Logs",
frontend/src/components/auth/BackupCodesPanel.vue new
+59
@@ -0,0 +1,59 @@
1 +<template>
2 + <div class="flex flex-col gap-2">
3 + <n-alert type="info">Backup codes generated. Save them — they are shown only once.</n-alert>
4 +
5 + <div class="grid max-w-140 grid-cols-2 gap-2">
6 + <n-code v-for="code in codes" :key="code" class="p-2 text-center">
7 + {{ code }}
8 + </n-code>
9 + </div>
10 +
11 + <div class="flex gap-2">
12 + <n-button v-if="isCopySupported" size="small" secondary @click="copyBackupCodes(codes)">
13 + <template #icon>
14 + <Icon name="carbon:copy" />
15 + </template>
16 + Copy all
17 + </n-button>
18 + <n-button size="small" secondary @click="downloadBackupCodes(codes)">
19 + <template #icon>
20 + <Icon name="carbon:download" />
21 + </template>
22 + Download .txt
23 + </n-button>
24 + </div>
25 + </div>
26 +</template>
27 +
28 +<script lang="ts" setup>
29 +import { useClipboard } from "@vueuse/core"
30 +import { saveAs } from "file-saver"
31 +import { NAlert, NButton, NCode, useMessage } from "naive-ui"
32 +import { watch } from "vue"
33 +import Icon from "@/components/common/Icon.vue"
34 +
35 +defineProps<{
36 + codes: string[]
37 +}>()
38 +
39 +const message = useMessage()
40 +const { copy, copied, isSupported: isCopySupported } = useClipboard()
41 +
42 +function copyBackupCodes(codes: string[]) {
43 + copy(codes.join("\n"))
44 +}
45 +
46 +function downloadBackupCodes(codes: string[]) {
47 + const text = `CoPilot — 2FA Backup Codes\n${"=".repeat(30)}\n\n${codes.join(
48 + "\n"
49 + )}\n\nKeep these codes safe. Each code can only be used once.\n`
50 +
51 + saveAs(new Blob([text], { type: "text/plain" }), "copilot-2fa-backup-codes.txt")
52 +}
53 +
54 +watch(copied, newVal => {
55 + if (newVal) {
56 + message.success("Backup codes copied to clipboard")
57 + }
58 +})
59 +</script>
frontend/src/components/auth/ChangePassword.vue new
+16
@@ -0,0 +1,16 @@
1 +<template>
2 + <n-card title="Change Password">
3 + <p class="text-secondary mb-4">Update your account password to keep your profile secure.</p>
4 + <ChangePassword :user="{ username: userName, id: 0, email: '' }" type="primary" />
5 + </n-card>
6 +</template>
7 +
8 +<script lang="ts" setup>
9 +import { NCard } from "naive-ui"
10 +import ChangePassword from "@/components/users/ChangePassword.vue"
11 +import { useAuthStore } from "@/stores/auth"
12 +
13 +const authStore = useAuthStore()
14 +
15 +const userName = authStore.userName
16 +</script>
frontend/src/components/auth/SignIn.vue
+94 -34
@@ -1,59 +1,82 @@
1 <template>
2 <div>
3 - <n-form ref="formRef" :model :rules>
4 - <n-form-item path="username" label="Username">
5 - <n-input
6 - v-model:value="model.username"
7 - placeholder="Insert your Username"
8 - :input-props="{ autocomplete: 'username' }"
9 - size="large"
10 - @keydown.enter="signIn"
11 - />
12 - </n-form-item>
13 - <n-form-item path="password" label="Password">
14 - <n-input
15 - v-model:value="model.password"
16 - type="password"
17 - show-password-on="click"
18 - placeholder="Insert your password"
19 - :input-props="{ autocomplete: 'password' }"
20 - size="large"
21 - @keydown.enter="signIn"
22 - />
23 - </n-form-item>
24 - <div class="flex flex-col items-end gap-6">
25 - <div class="w-full">
26 - <n-button type="primary" class="w-full!" size="large" :loading :disabled="!isValid" @click="signIn">
27 - Sign in
28 - </n-button>
29 - </div>
3 + <!-- ── Normal login form ── -->
4 + <n-collapse-transition :show="!show2fa">
5 + <div class="flex flex-col">
6 + <n-form ref="formRef" :model :rules>
7 + <n-form-item path="username" label="Username">
8 + <n-input
9 + v-model:value="model.username"
10 + placeholder="Insert your username"
11 + :input-props="{ autocomplete: 'username' }"
12 + size="large"
13 + @keydown.enter="signIn"
14 + />
15 + </n-form-item>
16 + <n-form-item path="password" label="Password">
17 + <n-input
18 + v-model:value="model.password"
19 + type="password"
20 + show-password-on="click"
21 + placeholder="Insert your password"
22 + :input-props="{ autocomplete: 'password' }"
23 + size="large"
24 + @keydown.enter="signIn"
25 + />
26 + </n-form-item>
27 + <div class="flex flex-col items-end gap-6">
28 + <div class="w-full">
29 + <n-button
30 + type="primary"
31 + class="w-full!"
32 + size="large"
33 + :loading
34 + :disabled="!isValid"
35 + @click="signIn"
36 + >
37 + Sign in
38 + </n-button>
39 + </div>
40 + </div>
41 + </n-form>
42 +
43 + <SsoOptions @show2fa-form="handleShow2faForm" @login-success="handleLoginSuccess" />
44 </div>
31 - </n-form>
45 + </n-collapse-transition>
46 +
47 + <n-collapse-transition :show="show2fa">
48 + <TotpForm v-model:two-fa-temp-token="twoFaTempToken" @cancel="cancel2fa" />
49 + </n-collapse-transition>
50 </div>
51 </template>
52
53 <script lang="ts" setup>
54 import type { FormInst, FormRules, FormValidationError } from "naive-ui"
55 import type { LoginPayload } from "@/types/auth.d"
38 -import { NButton, NForm, NFormItem, NInput, useMessage } from "naive-ui"
39 -import { computed, ref, watch } from "vue"
56 +import { NButton, NCollapseTransition, NForm, NFormItem, NInput, useMessage } from "naive-ui"
57 +import { computed, onBeforeMount, ref, watch } from "vue"
58 import { useRouter } from "vue-router"
59 import { useAuthStore } from "@/stores/auth"
60 +import SsoOptions from "./SsoOptions.vue"
61 +import TotpForm from "./TotpForm.vue"
62
63 interface ModelType {
64 username: string | null
65 password: string | null
66 }
67
48 -const loading = ref(false)
68 const router = useRouter()
50 -const formRef = ref<FormInst | null>(null)
69 +const authStore = useAuthStore()
70 const message = useMessage()
71 +
72 +const loading = ref(false)
73 +const formRef = ref<FormInst | null>(null)
74 const model = ref<ModelType>({
75 username: null,
76 password: null
77 })
56 -const authStore = useAuthStore()
78 +const show2fa = ref(false)
79 +const twoFaTempToken = ref("")
80
81 const rules: FormRules = {
82 username: [
@@ -76,8 +99,24 @@ const isValid = computed(() => {
99 return model.value.username && model.value.password
100 })
101
102 +function cancel2fa() {
103 + show2fa.value = false
104 + twoFaTempToken.value = ""
105 +}
106 +
107 +function handleShow2faForm(token: string) {
108 + twoFaTempToken.value = token
109 + show2fa.value = true
110 +}
111 +
112 +function handleLoginSuccess(token: string) {
113 + authStore.setLogged(token)
114 + router.push({ path: "/", replace: true })
115 +}
116 +
117 function signIn(e: Event) {
118 e.preventDefault()
119 +
120 formRef.value?.validate((errors: Array<FormValidationError> | undefined) => {
121 if (!errors) {
122 loading.value = true
@@ -89,7 +128,12 @@ function signIn(e: Event) {
128
129 authStore
130 .login(payload)
92 - .then(() => {
131 + .then(res => {
132 + // Check if 2FA is required
133 + if (res?.requires_2fa) {
134 + handleShow2faForm(res.access_token)
135 + return
136 + }
137 router.push({ path: "/", replace: true })
138 })
139 .catch(err => {
@@ -109,4 +153,20 @@ watch(isValid, val => {
153 formRef.value?.validate()
154 }
155 })
156 +
157 +onBeforeMount(() => {
158 + // Check if we're returning from SSO callback (token in URL fragment, not query)
159 + const params = new URLSearchParams(window.location.hash.substring(1) || window.location.search)
160 + const error_message = params.get("error_message")
161 +
162 + if (params.has("error_message")) {
163 + params.delete("error_message")
164 + }
165 +
166 + if (error_message) {
167 + message.error(error_message, { duration: 6_000 })
168 + }
169 +
170 + history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`)
171 +})
172 </script>
frontend/src/components/auth/SsoOptions.vue new
+127
@@ -0,0 +1,127 @@
1 +<template>
2 + <n-collapse-transition :show>
3 + <div class="flex flex-col">
4 + <n-divider>
5 + <span class="text-secondary text-xs">or sign in with</span>
6 + </n-divider>
7 + <div class="flex flex-col gap-3">
8 + <n-button v-if="ssoStatus?.azure_enabled" size="large" class="w-full!" @click="loginWithAzure">
9 + <template #icon>
10 + <Icon :name="AzureIcon" :size="15" />
11 + </template>
12 + Microsoft Azure
13 + </n-button>
14 + <n-button v-if="ssoStatus?.google_enabled" size="large" class="w-full!" @click="loginWithGoogle">
15 + <template #icon>
16 + <Icon :name="GoogleIcon" :size="16" />
17 + </template>
18 + Google
19 + </n-button>
20 + <n-button
21 + v-if="ssoStatus?.cf_enabled"
22 + size="large"
23 + class="w-full!"
24 + :loading="cfLoading"
25 + @click="loginWithCloudflare"
26 + >
27 + <template #icon>
28 + <Icon :name="CloudflareIcon" :size="18" />
29 + </template>
30 + Cloudflare Access
31 + </n-button>
32 + </div>
33 + </div>
34 + </n-collapse-transition>
35 +</template>
36 +
37 +<script lang="ts" setup>
38 +import type { SSOPublicStatus } from "@/api/endpoints/sso"
39 +import { NButton, NCollapseTransition, NDivider, useMessage } from "naive-ui"
40 +import { computed, onBeforeMount, ref } from "vue"
41 +import Api from "@/api"
42 +import Icon from "@/components/common/Icon.vue"
43 +
44 +const emit = defineEmits<{
45 + (e: "show2faForm", value: string): void
46 + (e: "loginSuccess", value: string): void
47 +}>()
48 +
49 +const AzureIcon = "devicon-plain:azure"
50 +const GoogleIcon = "devicon-plain:google"
51 +const CloudflareIcon = "simple-icons:cloudflare"
52 +
53 +const cfLoading = ref(false)
54 +const message = useMessage()
55 +const ssoStatus = ref<SSOPublicStatus | null>(null)
56 +
57 +const show = computed(() => {
58 + return (
59 + (ssoStatus.value?.sso_enabled &&
60 + (ssoStatus.value.azure_enabled || ssoStatus.value.google_enabled || ssoStatus.value.cf_enabled)) ||
61 + false
62 + )
63 +})
64 +
65 +function loginWithAzure() {
66 + window.location.href = "/api/auth/sso/azure/login"
67 +}
68 +
69 +function loginWithGoogle() {
70 + window.location.href = "/api/auth/sso/google/login"
71 +}
72 +
73 +async function loginWithCloudflare() {
74 + cfLoading.value = true
75 +
76 + try {
77 + const res = await Api.sso.cloudflareVerify()
78 + if (res.data?.requires_2fa && res.data?.access_token) {
79 + // Cloudflare auth OK but user has 2FA — show verification step
80 + emit("show2faForm", res.data.access_token)
81 + } else if (res.data?.access_token) {
82 + emit("loginSuccess", res.data.access_token)
83 + }
84 + } catch (err: any) {
85 + message.error(
86 + err.response?.data?.message || err.response?.data?.detail
87 + ? (err.response?.data?.message || err.response?.data?.detail)?.toString()
88 + : "Cloudflare Access authentication failed. Make sure you are behind Cloudflare Access."
89 + )
90 + } finally {
91 + cfLoading.value = false
92 + }
93 +}
94 +
95 +async function loadSSOStatus() {
96 + try {
97 + const res = await Api.sso.getStatus()
98 + ssoStatus.value = res.data
99 + } catch {
100 + ssoStatus.value = null
101 + }
102 +}
103 +
104 +onBeforeMount(() => {
105 + loadSSOStatus()
106 +
107 + // Check if we're returning from SSO callback (token in URL fragment, not query)
108 + const params = new URLSearchParams(window.location.hash.substring(1) || window.location.search)
109 + const token = params.get("token")
110 + const requires2fa = params.get("requires_2fa")?.toLowerCase() === "true"
111 +
112 + if (params.has("token")) {
113 + params.delete("token")
114 + }
115 + if (params.has("requires_2fa")) {
116 + params.delete("requires_2fa")
117 + }
118 +
119 + if (token && requires2fa) {
120 + emit("show2faForm", token)
121 + } else if (token) {
122 + // Remove fragment from history so the token isn't stored in browser history
123 + history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`)
124 + emit("loginSuccess", token)
125 + }
126 +})
127 +</script>
frontend/src/components/auth/TotpForm.vue new
+111
@@ -0,0 +1,111 @@
1 +<template>
2 + <div class="flex flex-col">
3 + <div class="min-h-17">
4 + <div class="mb-2 text-sm">Two-Factor Authentication</div>
5 +
6 + <n-collapse-transition :show="!showBackupInput">
7 + <n-input-otp
8 + v-model:value="twoFaCode"
9 + block
10 + size="large"
11 + :input-props="{ autocomplete: 'one-time-code', inputmode: 'numeric' }"
12 + @finish="verify2fa()"
13 + @keydown.enter="verify2fa()"
14 + />
15 + </n-collapse-transition>
16 + <n-collapse-transition :show="showBackupInput">
17 + <n-input
18 + v-model:value="backupCode"
19 + placeholder="Backup code (e.g. ABCD1234EF)"
20 + size="large"
21 + @keydown.enter="verify2fa({ useBackupCode: true })"
22 + />
23 + </n-collapse-transition>
24 + </div>
25 +
26 + <div class="h-6 w-full"></div>
27 +
28 + <n-button
29 + type="primary"
30 + class="w-full!"
31 + size="large"
32 + :loading="twoFaLoading"
33 + :disabled="!isValid"
34 + @click="verify2fa({ useBackupCode: showBackupInput })"
35 + >
36 + {{ showBackupInput ? "Use backup code" : "Verify" }}
37 + </n-button>
38 +
39 + <div class="mt-4 flex items-center justify-between">
40 + <n-button text size="small" @click="cancel2fa()">← Back to login</n-button>
41 + <n-button text size="small" @click="showBackupInput = !showBackupInput">
42 + {{ showBackupInput ? "Use authenticator code" : "Use a backup code instead" }}
43 + </n-button>
44 + </div>
45 + </div>
46 +</template>
47 +
48 +<script lang="ts" setup>
49 +import type { TOTPValidateRequest } from "@/api/endpoints/totp"
50 +import { NButton, NCollapseTransition, NInput, NInputOtp, useMessage } from "naive-ui"
51 +import { computed, ref, watch } from "vue"
52 +import { useRouter } from "vue-router"
53 +import { useAuthStore } from "@/stores/auth"
54 +
55 +const emit = defineEmits<{
56 + (e: "cancel"): void
57 +}>()
58 +
59 +const twoFaTempToken = defineModel<string>("twoFaTempToken", { default: "" })
60 +
61 +const router = useRouter()
62 +const message = useMessage()
63 +const authStore = useAuthStore()
64 +
65 +const twoFaCode = ref<string[]>([])
66 +const backupCode = ref("")
67 +const twoFaLoading = ref(false)
68 +const showBackupInput = ref(false)
69 +
70 +watch(showBackupInput, () => {
71 + twoFaCode.value = []
72 + backupCode.value = ""
73 +})
74 +
75 +const isValid = computed(() => {
76 + if (showBackupInput.value) {
77 + return !!backupCode.value
78 + }
79 + return twoFaCode.value.join("").length === 6
80 +})
81 +
82 +function cancel2fa() {
83 + twoFaTempToken.value = ""
84 + twoFaCode.value = []
85 + backupCode.value = ""
86 + showBackupInput.value = false
87 + emit("cancel")
88 +}
89 +
90 +async function verify2fa(params?: { useBackupCode?: boolean }) {
91 + twoFaLoading.value = true
92 +
93 + const payload: TOTPValidateRequest = {
94 + temp_token: twoFaTempToken.value,
95 + code: params?.useBackupCode ? undefined : twoFaCode.value.join(""),
96 + backup_code: params?.useBackupCode ? backupCode.value : undefined
97 + }
98 +
99 + authStore
100 + .verify2fa(payload)
101 + .then(() => {
102 + router.push({ path: "/", replace: true })
103 + })
104 + .catch(err => {
105 + message.error(err?.message || err.response?.data?.detail || "An error occurred. Please try again later.")
106 + })
107 + .finally(() => {
108 + twoFaLoading.value = false
109 + })
110 +}
111 +</script>
frontend/src/components/auth/TotpToggle.vue new
+312
@@ -0,0 +1,312 @@
1 +<template>
2 + <div>
3 + <!-- 2FA Section -->
4 + <n-card title="Two-Factor Authentication (TOTP)">
5 + <template #header-extra>
6 + <n-tag :type="twoFaEnabled ? 'success' : 'default'" size="small">
7 + {{ twoFaEnabled ? "Enabled" : "Disabled" }}
8 + </n-tag>
9 + </template>
10 +
11 + <n-spin :show="twoFaLoading">
12 + <!-- ── 2FA disabled — show enable button ── -->
13 + <div v-if="!twoFaEnabled && !setupData">
14 + <p class="text-secondary mb-4">
15 + Add an extra layer of security to your account. You will need an authenticator app like Google
16 + Authenticator, Authy, or Microsoft Authenticator.
17 + </p>
18 + <n-button type="primary" @click="startSetup">
19 + <template #icon>
20 + <Icon name="carbon:locked" />
21 + </template>
22 + Enable Two-Factor Authentication
23 + </n-button>
24 + </div>
25 +
26 + <!-- ── Setup flow: QR + verify ── -->
27 + <div v-if="setupData && (!twoFaEnabled || setupStep === 3)" class="flex flex-col gap-6">
28 + <n-steps :current="setupStep" size="small">
29 + <n-step title="Scan QR code" />
30 + <n-step title="Verify code" />
31 + <n-step title="Save backup codes" />
32 + </n-steps>
33 +
34 + <!-- Step 1: QR -->
35 + <div v-if="setupStep === 1" class="flex flex-col gap-4">
36 + <p>Scan this QR code with your authenticator app:</p>
37 +
38 + <img :src="setupData.qr_data_uri" alt="TOTP QR Code" class="rounded border" width="200" />
39 +
40 + <div>
41 + <strong>Manual entry key:</strong>
42 + <n-code class="ml-2">{{ setupData.secret }}</n-code>
43 + </div>
44 +
45 + <n-alert type="warning">
46 + <template #icon>
47 + <Icon name="carbon:time" />
48 + </template>
49 + <strong>Important:</strong>
50 + Make sure your device clock is accurate. TOTP codes are time-sensitive and allow only a
51 + &plusmn;30 second tolerance window.
52 + </n-alert>
53 +
54 + <div class="flex justify-between">
55 + <n-button @click="finishSetup()">Cancel</n-button>
56 + <n-button type="primary" @click="setupStep = 2">Next</n-button>
57 + </div>
58 + </div>
59 +
60 + <!-- Step 2: Verify -->
61 + <div v-if="setupStep === 2" class="flex flex-col gap-4">
62 + <p>Enter the 6-digit code from your authenticator app to confirm:</p>
63 +
64 + <n-input-otp
65 + v-model:value="verifyCode"
66 + block
67 + size="large"
68 + :input-props="{ autocomplete: 'one-time-code', inputmode: 'numeric' }"
69 + class="max-w-100"
70 + @finish="confirmSetup()"
71 + @keydown.enter="confirmSetup()"
72 + />
73 +
74 + <div class="flex justify-between gap-2">
75 + <n-button @click="setupStep = 1">Back</n-button>
76 + <n-button
77 + type="primary"
78 + :loading="verifying"
79 + :disabled="verifyCode.length < 6"
80 + @click="confirmSetup"
81 + >
82 + Verify &amp; Enable
83 + </n-button>
84 + </div>
85 + </div>
86 +
87 + <!-- Step 3: Backup codes -->
88 + <div v-if="setupStep === 3" class="flex flex-col gap-4">
89 + <n-alert type="success">
90 + <template #icon>
91 + <Icon name="carbon:checkmark-filled" />
92 + </template>
93 + Two-factor authentication is now enabled!
94 + </n-alert>
95 +
96 + <BackupCodesPanel :codes="setupData?.backup_codes || []" />
97 +
98 + <n-button type="primary" class="ml-auto!" @click="finishSetup">Done</n-button>
99 + </div>
100 + </div>
101 +
102 + <!-- ── 2FA enabled — show disable + regenerate ── -->
103 + <div v-if="twoFaEnabled && !setupStep" class="flex flex-col gap-4">
104 + <p class="text-secondary">
105 + Two-factor authentication is active. You will be asked for a code from your authenticator app
106 + every time you log in.
107 + </p>
108 + <div class="flex flex-wrap gap-3">
109 + <n-popconfirm @positive-click="showDisableModal = true">
110 + <template #trigger>
111 + <n-button type="error">
112 + <template #icon>
113 + <Icon name="carbon:unlocked" />
114 + </template>
115 + Disable 2FA
116 + </n-button>
117 + </template>
118 + Are you sure you want to disable two-factor authentication?
119 + </n-popconfirm>
120 + <n-button @click="showRegenModal = true">
121 + <template #icon>
122 + <Icon name="carbon:renew" />
123 + </template>
124 + Regenerate backup codes
125 + </n-button>
126 + </div>
127 + </div>
128 + </n-spin>
129 + </n-card>
130 +
131 + <!-- Disable 2FA Modal -->
132 + <n-modal v-model:show="showDisableModal" preset="card" title="Disable 2FA" :style="{ maxWidth: '400px' }">
133 + <p class="mb-4">Enter your TOTP code or a backup code to disable 2FA:</p>
134 + <n-input
135 + v-model:value="disableCode"
136 + placeholder="6-digit code or backup code"
137 + class="mb-4"
138 + @keydown.enter="disableTwoFa"
139 + />
140 + <div class="flex justify-end gap-2">
141 + <n-button @click="showDisableModal = false">Cancel</n-button>
142 + <n-button type="error" :loading="disabling" :disabled="!disableCode" @click="disableTwoFa">
143 + Disable
144 + </n-button>
145 + </div>
146 + </n-modal>
147 +
148 + <!-- Regenerate Backup Codes Modal -->
149 + <n-modal
150 + v-model:show="showRegenModal"
151 + preset="card"
152 + title="Regenerate Backup Codes"
153 + :style="{ maxWidth: '500px' }"
154 + >
155 + <div v-if="!regenCodes" class="flex flex-col gap-4">
156 + <p>Enter your TOTP code to generate new backup codes. Old codes will be invalidated.</p>
157 + <n-input-otp
158 + v-model:value="regenCode"
159 + block
160 + size="large"
161 + :input-props="{ autocomplete: 'one-time-code', inputmode: 'numeric' }"
162 + @finish="regenBackupCodes()"
163 + @keydown.enter="regenBackupCodes()"
164 + />
165 + <div class="flex justify-end gap-2">
166 + <n-button @click="showRegenModal = false">Cancel</n-button>
167 + <n-button
168 + type="primary"
169 + :loading="regenerating"
170 + :disabled="regenCode.length < 6"
171 + @click="regenBackupCodes"
172 + >
173 + Regenerate
174 + </n-button>
175 + </div>
176 + </div>
177 + <div v-else class="flex flex-col gap-4">
178 + <BackupCodesPanel :codes="regenCodes || []" />
179 + <n-button type="primary" class="ml-auto!" @click="closeRegenModal">Done</n-button>
180 + </div>
181 + </n-modal>
182 + </div>
183 +</template>
184 +
185 +<script lang="ts" setup>
186 +import type { TOTPSetupResponse } from "@/api/endpoints/totp"
187 +import {
188 + NAlert,
189 + NButton,
190 + NCard,
191 + NInput,
192 + NInputOtp,
193 + NModal,
194 + NPopconfirm,
195 + NSpin,
196 + NStep,
197 + NSteps,
198 + NTag,
199 + useMessage
200 +} from "naive-ui"
201 +import { onBeforeMount, ref } from "vue"
202 +import Api from "@/api"
203 +import BackupCodesPanel from "@/components/auth/BackupCodesPanel.vue"
204 +import Icon from "@/components/common/Icon.vue"
205 +
206 +const message = useMessage()
207 +
208 +// ── 2FA state ────────────────────────────────────────────────────────────────
209 +const twoFaLoading = ref(false)
210 +const twoFaEnabled = ref(false)
211 +const setupData = ref<TOTPSetupResponse | null>(null)
212 +const setupStep = ref(0)
213 +const verifyCode = ref<string[]>([])
214 +const verifying = ref(false)
215 +
216 +const showDisableModal = ref(false)
217 +const disableCode = ref("")
218 +const disabling = ref(false)
219 +
220 +const showRegenModal = ref(false)
221 +const regenCode = ref<string[]>([])
222 +const regenCodes = ref<string[] | null>(null)
223 +const regenerating = ref(false)
224 +
225 +async function load2faStatus() {
226 + twoFaLoading.value = true
227 +
228 + try {
229 + const res = await Api.totp.getStatus()
230 + twoFaEnabled.value = res.data.enabled
231 + } finally {
232 + twoFaLoading.value = false
233 + }
234 +}
235 +
236 +async function startSetup() {
237 + twoFaLoading.value = true
238 +
239 + try {
240 + const res = await Api.totp.setup()
241 + setupData.value = res.data
242 + setupStep.value = 1
243 + } catch (err: any) {
244 + message.error(err.response?.data?.detail || "Failed to start 2FA setup")
245 + } finally {
246 + twoFaLoading.value = false
247 + }
248 +}
249 +
250 +async function confirmSetup() {
251 + verifying.value = true
252 +
253 + try {
254 + await Api.totp.verifySetup(verifyCode.value.join(""))
255 + twoFaEnabled.value = true
256 + setupStep.value = 3
257 + message.success("Two-factor authentication enabled!")
258 + } catch (err: any) {
259 + message.error(err.response?.data?.message || err.response?.data?.detail || "Invalid code. Try again.")
260 + } finally {
261 + verifying.value = false
262 + }
263 +}
264 +
265 +function finishSetup() {
266 + setupData.value = null
267 + setupStep.value = 0
268 + verifyCode.value = []
269 +}
270 +
271 +async function disableTwoFa() {
272 + disabling.value = true
273 + const isBackup = disableCode.value.length > 6
274 +
275 + try {
276 + await Api.totp.disable(isBackup ? { backup_code: disableCode.value } : { code: disableCode.value })
277 + twoFaEnabled.value = false
278 + showDisableModal.value = false
279 + disableCode.value = ""
280 + message.success("Two-factor authentication disabled")
281 + } catch (err: any) {
282 + message.error(err.response?.data?.message || err.response?.data?.detail || "Failed to disable 2FA")
283 + } finally {
284 + disabling.value = false
285 + }
286 +}
287 +
288 +async function regenBackupCodes() {
289 + regenerating.value = true
290 +
291 + try {
292 + const res = await Api.totp.regenerateBackupCodes(regenCode.value.join(""))
293 + regenCodes.value = res.data.backup_codes
294 + regenCode.value = []
295 + message.success("Backup codes regenerated")
296 + } catch (err: any) {
297 + message.error(err.response?.data?.message || err.response?.data?.detail || "Failed to regenerate codes")
298 + } finally {
299 + regenerating.value = false
300 + }
301 +}
302 +
303 +function closeRegenModal() {
304 + showRegenModal.value = false
305 + regenCodes.value = null
306 + regenCode.value = []
307 +}
308 +
309 +onBeforeMount(() => {
310 + load2faStatus()
311 +})
312 +</script>
frontend/src/components/sso/AllowedEmails.vue new
+190
@@ -0,0 +1,190 @@
1 +<template>
2 + <div v-if="isAdmin">
3 + <!-- Allowed Emails Card -->
4 + <n-card title="SSO Allowed Emails" size="small">
5 + <template #header-extra>
6 + <div class="flex justify-end gap-2">
7 + <n-button size="small" @click="routeSSOConfig().navigate()">
8 + <template #icon>
9 + <Icon :name="SSOConfigIcon" />
10 + </template>
11 + SSO Configuration
12 + </n-button>
13 +
14 + <n-button size="small" type="primary" @click="showAddEmail = true">
15 + <template #icon>
16 + <Icon :name="AddIcon" />
17 + </template>
18 + Add Email
19 + </n-button>
20 + </div>
21 + </template>
22 +
23 + <n-text class="text-secondary mb-4 block text-sm">
24 + Existing users are automatically enabled for SSO login. Add email addresses to allow new users to sign
25 + in and create an account via SSO.
26 + </n-text>
27 +
28 + <n-spin :show="loadingEmails">
29 + <p v-if="!allowedEmails.length" class="text-secondary py-4 text-center">
30 + No allowed emails configured. Add an email to allow access via SSO.
31 + </p>
32 + <n-table v-else>
33 + <thead>
34 + <tr>
35 + <th>Email</th>
36 + <th>Role</th>
37 + <th>Added</th>
38 + <th class="w-16"></th>
39 + </tr>
40 + </thead>
41 + <tbody>
42 + <tr v-for="entry of allowedEmails" :key="entry.id">
43 + <td>{{ entry.email }}</td>
44 + <td>
45 + <n-tag :type="getRoleTagType(entry.role_id)" size="small">
46 + {{ getRoleName(entry.role_id) }}
47 + </n-tag>
48 + </td>
49 + <td>{{ formatDate(entry.created_at, dFormats.datetime) }}</td>
50 + <td>
51 + <n-button text type="error" size="small" @click="removeEmail(entry.id)">
52 + <template #icon>
53 + <Icon :name="DeleteIcon" />
54 + </template>
55 + </n-button>
56 + </td>
57 + </tr>
58 + </tbody>
59 + </n-table>
60 + </n-spin>
61 + </n-card>
62 +
63 + <!-- Add Email Modal -->
64 + <n-modal v-model:show="showAddEmail" preset="card" title="Add SSO Allowed Email" :style="{ maxWidth: '450px' }">
65 + <n-form :model="newEmail" label-placement="top">
66 + <n-form-item label="Email Address">
67 + <n-input v-model:value="newEmail.email" placeholder="user@company.com" />
68 + </n-form-item>
69 + <n-form-item label="Assigned Role" class="w-full">
70 + <div class="flex w-full flex-col gap-2">
71 + <n-select v-model:value="newEmail.role_id" :options="roleOptions" />
72 + <span class="text-secondary text-xs">
73 + Role assigned when the user logs in for the first time via SSO.
74 + </span>
75 + </div>
76 + </n-form-item>
77 + <div class="flex justify-end gap-2 pt-2">
78 + <n-button @click="showAddEmail = false">Cancel</n-button>
79 + <n-button type="primary" :loading="addingEmail" :disabled="!newEmail.email" @click="addEmail">
80 + Add Email
81 + </n-button>
82 + </div>
83 + </n-form>
84 + </n-modal>
85 + </div>
86 +</template>
87 +
88 +<script setup lang="ts">
89 +import type { SSOAllowedEmail } from "@/api/endpoints/sso"
90 +import {
91 + NButton,
92 + NCard,
93 + NForm,
94 + NFormItem,
95 + NInput,
96 + NModal,
97 + NSelect,
98 + NSpin,
99 + NTable,
100 + NTag,
101 + NText,
102 + useMessage
103 +} from "naive-ui"
104 +import { computed, onBeforeMount, ref } from "vue"
105 +import Api from "@/api"
106 +import Icon from "@/components/common/Icon.vue"
107 +import { useNavigation } from "@/composables/useNavigation"
108 +import { useAuthStore } from "@/stores/auth"
109 +import { useSettingsStore } from "@/stores/settings"
110 +import { formatDate } from "@/utils/format"
111 +
112 +const { routeSSOConfig } = useNavigation()
113 +
114 +const SSOConfigIcon = "carbon:rule-locked"
115 +const AddIcon = "carbon:add"
116 +const DeleteIcon = "carbon:trash-can"
117 +
118 +const authStore = useAuthStore()
119 +const message = useMessage()
120 +const dFormats = useSettingsStore().dateFormat
121 +
122 +const isAdmin = computed(() => authStore.isAdmin)
123 +
124 +const loadingEmails = ref(false)
125 +const addingEmail = ref(false)
126 +const showAddEmail = ref(false)
127 +
128 +const allowedEmails = ref<SSOAllowedEmail[]>([])
129 +const newEmail = ref({ email: "", role_id: 2 })
130 +
131 +const roleOptions = [
132 + { label: "Admin", value: 1 },
133 + { label: "Analyst (default)", value: 2 }
134 +]
135 +
136 +function getRoleName(roleId: number) {
137 + return roleId === 1 ? "admin" : roleId === 2 ? "analyst" : roleId === 3 ? "scheduler" : "customer_user"
138 +}
139 +
140 +function getRoleTagType(roleId: number) {
141 + return roleId === 1 ? "error" : roleId === 2 ? "warning" : "default"
142 +}
143 +
144 +async function loadEmails() {
145 + loadingEmails.value = true
146 +
147 + try {
148 + const res = await Api.sso.getAllowedEmails()
149 + allowedEmails.value = res.data.emails
150 + } catch {
151 + allowedEmails.value = []
152 + } finally {
153 + loadingEmails.value = false
154 + }
155 +}
156 +
157 +async function addEmail() {
158 + if (!newEmail.value.email) return
159 +
160 + addingEmail.value = true
161 +
162 + try {
163 + await Api.sso.addAllowedEmail({ email: newEmail.value.email, role_id: newEmail.value.role_id })
164 + message.success(`Email ${newEmail.value.email} added to allowlist`)
165 + newEmail.value = { email: "", role_id: 2 }
166 + showAddEmail.value = false
167 + loadEmails()
168 + } catch (err: any) {
169 + message.error(err.response?.data?.message || err.response?.data?.detail || "Failed to add email")
170 + } finally {
171 + addingEmail.value = false
172 + }
173 +}
174 +
175 +async function removeEmail(id: number) {
176 + try {
177 + await Api.sso.removeAllowedEmail(id)
178 + message.success("Email removed from allowlist")
179 + loadEmails()
180 + } catch (err: any) {
181 + message.error(err.response?.data?.message || err.response?.data?.detail || "Failed to remove email")
182 + }
183 +}
184 +
185 +onBeforeMount(() => {
186 + if (isAdmin.value) {
187 + loadEmails()
188 + }
189 +})
190 +</script>
frontend/src/components/sso/Config.vue new
+484
@@ -0,0 +1,484 @@
1 +<template>
2 + <!-- SSO Configuration — admin only -->
3 + <n-card v-if="isAdmin" title="Single Sign-On (SSO) Configuration" class="mb-4">
4 + <template #header-extra>
5 + <n-tag :type="ssoEnabled ? 'success' : 'default'" size="small">
6 + {{ ssoEnabled ? "Enabled" : "Disabled" }}
7 + </n-tag>
8 + </template>
9 +
10 + <n-spin :show="loading">
11 + <n-form :model="form" label-placement="left" label-width="220">
12 + <!-- Global toggle -->
13 + <n-form-item label="Enable SSO">
14 + <n-switch v-model:value="form.sso_enabled" />
15 + <n-text class="text-secondary ml-4 text-sm">
16 + When enabled, SSO login buttons will appear on the login page.
17 + </n-text>
18 + </n-form-item>
19 +
20 + <n-divider />
21 +
22 + <!-- ── Azure Entra ID ────────────────────────────────────── -->
23 + <div class="mb-4 flex items-center gap-3">
24 + <Icon :name="AzureIcon" :size="22" />
25 + <h3 class="text-lg font-semibold">Azure Entra ID (OAuth2 / OIDC)</h3>
26 + <n-button text size="small" @click="showAzureGuide = !showAzureGuide">
27 + <template #icon>
28 + <Icon :name="showAzureGuide ? 'carbon:chevron-up' : 'carbon:information'" />
29 + </template>
30 + {{ showAzureGuide ? "Hide guide" : "Setup guide" }}
31 + </n-button>
32 + </div>
33 +
34 + <n-collapse-transition :show="showAzureGuide">
35 + <n-alert type="info" class="mb-5" :show-icon="false">
36 + <div class="text-xs">
37 + <p class="mb-2 font-semibold">How to configure Azure Entra ID:</p>
38 + <ol>
39 + <li>
40 + Go to
41 + <n-button text tag="a" href="https://portal.azure.com" target="_blank" type="info">
42 + portal.azure.com
43 + </n-button>
44 + →
45 + <strong>Azure Active Directory</strong>
46 + →
47 + <strong>App registrations</strong>
48 + →
49 + <strong>New registration</strong>
50 + </li>
51 + <li>
52 + Set a name (e.g.
53 + <code>CoPilot SSO</code>
54 + ), choose
55 + <em>Accounts in this organizational directory only</em>
56 + , then click
57 + <strong>Register</strong>
58 + .
59 + </li>
60 + <li>
61 + Copy the
62 + <strong>Application (client) ID</strong>
63 + → paste as
64 + <em>Client ID</em>
65 + below.
66 + </li>
67 + <li>
68 + Copy the
69 + <strong>Directory (tenant) ID</strong>
70 + → paste as
71 + <em>Tenant ID</em>
72 + below.
73 + </li>
74 + <li>
75 + Go to
76 + <strong>Certificates &amp; secrets</strong>
77 + →
78 + <strong>New client secret</strong>
79 + → copy the value → paste as
80 + <em>Client Secret</em>
81 + below.
82 + </li>
83 + <li>
84 + Go to
85 + <strong>Authentication</strong>
86 + →
87 + <strong>Add a platform</strong>
88 + →
89 + <strong>Web</strong>
90 + → set Redirect URI to:
91 + <br />
92 + <code>https://&lt;your-domain&gt;/api/auth/sso/azure/callback</code>
93 + </li>
94 + <li>
95 + Under
96 + <strong>Token configuration</strong>
97 + add optional claim
98 + <code>email</code>
99 + (ID token).
100 + </li>
101 + <li>Fill in the fields below, save, then add allowed emails in the section below.</li>
102 + </ol>
103 + </div>
104 + </n-alert>
105 + </n-collapse-transition>
106 +
107 + <n-form-item label="Enable Azure SSO">
108 + <n-switch v-model:value="form.azure_enabled" :disabled="!form.sso_enabled" />
109 + </n-form-item>
110 + <n-form-item label="Tenant ID">
111 + <n-input
112 + v-model:value="form.azure_tenant_id"
113 + placeholder="e.g. 12345678-abcd-1234-efgh-123456789012"
114 + :disabled="!form.azure_enabled || !form.sso_enabled"
115 + />
116 + </n-form-item>
117 + <n-form-item label="Client ID (Application ID)">
118 + <n-input
119 + v-model:value="form.azure_client_id"
120 + placeholder="e.g. 87654321-dcba-4321-hgfe-210987654321"
121 + :disabled="!form.azure_enabled || !form.sso_enabled"
122 + />
123 + </n-form-item>
124 + <n-form-item label="Client Secret">
125 + <n-input
126 + v-model:value="form.azure_client_secret"
127 + type="password"
128 + show-password-on="click"
129 + :placeholder="azureSecretSet ? '••••••• (saved — leave empty to keep)' : 'Enter client secret'"
130 + :disabled="!form.azure_enabled || !form.sso_enabled"
131 + />
132 + </n-form-item>
133 + <n-form-item label="Redirect URI">
134 + <n-input-group>
135 + <n-input
136 + v-model:value="form.azure_redirect_uri"
137 + placeholder="https://your-domain.com/api/auth/sso/azure/callback"
138 + :disabled="!form.azure_enabled || !form.sso_enabled"
139 + />
140 + <n-button :disabled="!form.azure_enabled || !form.sso_enabled" @click="prefillRedirectUri">
141 + Auto-fill
142 + </n-button>
143 + </n-input-group>
144 + </n-form-item>
145 + <n-divider />
146 +
147 + <!-- ── Google OAuth2 / OIDC ──────────────────────────────────── -->
148 + <div class="mb-4 flex items-center gap-3">
149 + <Icon :name="GoogleIcon" :size="22" />
150 + <h3 class="text-lg font-semibold">Google (OAuth2 / OIDC)</h3>
151 + <n-button text size="small" @click="showGoogleGuide = !showGoogleGuide">
152 + <template #icon>
153 + <Icon :name="showGoogleGuide ? 'carbon:chevron-up' : 'carbon:information'" />
154 + </template>
155 + {{ showGoogleGuide ? "Hide guide" : "Setup guide" }}
156 + </n-button>
157 + </div>
158 +
159 + <n-collapse-transition :show="showGoogleGuide">
160 + <n-alert type="info" class="mb-5" :show-icon="false">
161 + <div class="text-xs">
162 + <p class="mb-2 font-semibold">How to configure Google OAuth2:</p>
163 + <ol>
164 + <li>
165 + Go to
166 + <n-button
167 + text
168 + tag="a"
169 + href="https://console.cloud.google.com/apis/credentials"
170 + target="_blank"
171 + type="info"
172 + >
173 + Google Cloud Console &rarr; Credentials
174 + </n-button>
175 + and click
176 + <strong>Create Credentials &rarr; OAuth client ID</strong>
177 + .
178 + </li>
179 + <li>
180 + Choose
181 + <strong>Web application</strong>
182 + as the application type.
183 + </li>
184 + <li>
185 + Under
186 + <strong>Authorized redirect URIs</strong>
187 + , add:
188 + <code>https://&lt;your-domain&gt;/api/auth/sso/google/callback</code>
189 + </li>
190 + <li>
191 + Copy the
192 + <strong>Client ID</strong>
193 + &rarr; paste as
194 + <em>Client ID</em>
195 + below.
196 + </li>
197 + <li>
198 + Copy the
199 + <strong>Client Secret</strong>
200 + &rarr; paste as
201 + <em>Client Secret</em>
202 + below.
203 + </li>
204 + <li>
205 + Make sure the
206 + <strong>People API</strong>
207 + is enabled in your project (required for
208 + <code>email</code>
209 + and
210 + <code>profile</code>
211 + scopes).
212 + </li>
213 + <li>Fill in the fields below, save, then add allowed emails in the section below.</li>
214 + </ol>
215 + </div>
216 + </n-alert>
217 + </n-collapse-transition>
218 +
219 + <n-form-item label="Enable Google SSO">
220 + <n-switch v-model:value="form.google_enabled" :disabled="!form.sso_enabled" />
221 + </n-form-item>
222 + <n-form-item label="Client ID">
223 + <n-input
224 + v-model:value="form.google_client_id"
225 + placeholder="e.g. 123456789-abc...xyz.apps.googleusercontent.com"
226 + :disabled="!form.google_enabled || !form.sso_enabled"
227 + />
228 + </n-form-item>
229 + <n-form-item label="Client Secret">
230 + <n-input
231 + v-model:value="form.google_client_secret"
232 + type="password"
233 + show-password-on="click"
234 + :placeholder="
235 + googleSecretSet
236 + ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (saved \u2014 leave empty to keep)'
237 + : 'Enter client secret'
238 + "
239 + :disabled="!form.google_enabled || !form.sso_enabled"
240 + />
241 + </n-form-item>
242 + <n-form-item label="Redirect URI">
243 + <n-input-group>
244 + <n-input
245 + v-model:value="form.google_redirect_uri"
246 + placeholder="https://your-domain.com/api/auth/sso/google/callback"
247 + :disabled="!form.google_enabled || !form.sso_enabled"
248 + />
249 + <n-button
250 + :disabled="!form.google_enabled || !form.sso_enabled"
251 + @click="prefillGoogleRedirectUri"
252 + >
253 + Auto-fill
254 + </n-button>
255 + </n-input-group>
256 + </n-form-item>
257 +
258 + <n-divider />
259 +
260 + <!-- ── Cloudflare Access ───────────────────────────────────── -->
261 + <div class="mb-4 flex items-center gap-3">
262 + <Icon :name="CloudflareIcon" :size="22" />
263 + <h3 class="text-lg font-semibold">Cloudflare Access (JWT Assertion)</h3>
264 + <n-button text size="small" @click="showCFGuide = !showCFGuide">
265 + <template #icon>
266 + <Icon :name="showCFGuide ? 'carbon:chevron-up' : 'carbon:information'" />
267 + </template>
268 + {{ showCFGuide ? "Hide guide" : "Setup guide" }}
269 + </n-button>
270 + </div>
271 +
272 + <n-collapse-transition :show="showCFGuide">
273 + <n-alert type="info" class="mb-5" :show-icon="false">
274 + <div class="text-xs">
275 + <p class="mb-2 font-semibold">How to configure Cloudflare Access:</p>
276 + <ol>
277 + <li>
278 + In
279 + <n-button
280 + text
281 + tag="a"
282 + href="https://one.dash.cloudflare.com"
283 + target="_blank"
284 + type="info"
285 + >
286 + Cloudflare Zero Trust dashboard
287 + </n-button>
288 + go to
289 + <strong>Access → Applications → Add an application</strong>
290 + .
291 + </li>
292 + <li>
293 + Choose
294 + <strong>Self-hosted</strong>
295 + . Set the domain to your CoPilot URL (e.g.
296 + <code>copilot.example.com</code>
297 + ).
298 + </li>
299 + <li>
300 + Under
301 + <strong>Identity providers</strong>
302 + connect your IdP (e.g. Entra ID, Google, GitHub).
303 + </li>
304 + <li>
305 + After creating the app, open it →
306 + <strong>Overview</strong>
307 + → copy the
308 + <strong>Application Audience (AUD) Tag</strong>
309 + → paste as
310 + <em>Application Audience</em>
311 + below.
312 + </li>
313 + <li>
314 + Copy your
315 + <strong>Team Domain</strong>
316 + from
317 + <strong>Settings → Custom Pages</strong>
318 + (e.g.
319 + <code>myteam.cloudflareaccess.com</code>
320 + ) → paste as
321 + <em>Team Domain</em>
322 + below.
323 + </li>
324 + <li>
325 + <strong>How it works:</strong>
326 + Cloudflare injects a signed
327 + <code>Cf-Access-Jwt-Assertion</code>
328 + header into every request. CoPilot verifies the JWT signature against Cloudflare's
329 + public JWKS — it is cryptographically impossible to forge without Cloudflare's
330 + private key.
331 + </li>
332 + <li>
333 + On the CoPilot login page click
334 + <strong>"Sign in with Cloudflare Access"</strong>
335 + — the backend reads the header automatically.
336 + </li>
337 + </ol>
338 + </div>
339 + </n-alert>
340 + </n-collapse-transition>
341 +
342 + <n-form-item label="Enable Cloudflare Access">
343 + <n-switch v-model:value="form.cf_enabled" :disabled="!form.sso_enabled" />
344 + </n-form-item>
345 + <n-form-item label="Team Domain">
346 + <n-input
347 + v-model:value="form.cf_team_domain"
348 + placeholder="e.g. myteam.cloudflareaccess.com"
349 + :disabled="!form.cf_enabled || !form.sso_enabled"
350 + />
351 + </n-form-item>
352 + <n-form-item label="Application Audience (AUD)">
353 + <n-input
354 + v-model:value="form.cf_audience"
355 + placeholder="AUD tag from Cloudflare Access dashboard"
356 + :disabled="!form.cf_enabled || !form.sso_enabled"
357 + />
358 + </n-form-item>
359 +
360 + <n-divider />
361 +
362 + <div class="flex justify-end">
363 + <n-button type="primary" :loading="saving" @click="saveSettings">Save SSO Settings</n-button>
364 + </div>
365 + </n-form>
366 + </n-spin>
367 + </n-card>
368 +</template>
369 +
370 +<script setup lang="ts">
371 +import type { SSOConfigUpdate } from "@/api/endpoints/sso"
372 +import {
373 + NAlert,
374 + NButton,
375 + NCard,
376 + NCollapseTransition,
377 + NDivider,
378 + NForm,
379 + NFormItem,
380 + NInput,
381 + NInputGroup,
382 + NSpin,
383 + NSwitch,
384 + NTag,
385 + NText,
386 + useMessage
387 +} from "naive-ui"
388 +import { computed, onBeforeMount, ref } from "vue"
389 +import Api from "@/api"
390 +import Icon from "@/components/common/Icon.vue"
391 +import { useAuthStore } from "@/stores/auth"
392 +
393 +const AzureIcon = "devicon-plain:azure"
394 +const GoogleIcon = "devicon-plain:google"
395 +const CloudflareIcon = "simple-icons:cloudflare"
396 +
397 +const authStore = useAuthStore()
398 +const message = useMessage()
399 +
400 +const isAdmin = computed(() => authStore.isAdmin)
401 +
402 +const loading = ref(false)
403 +const saving = ref(false)
404 +const showAzureGuide = ref(false)
405 +const showGoogleGuide = ref(false)
406 +const showCFGuide = ref(false)
407 +const azureSecretSet = ref(false)
408 +const googleSecretSet = ref(false)
409 +const ssoEnabled = ref(false)
410 +
411 +const form = ref<SSOConfigUpdate>({
412 + sso_enabled: false,
413 + azure_enabled: false,
414 + azure_tenant_id: null,
415 + azure_client_id: null,
416 + azure_client_secret: null,
417 + azure_redirect_uri: null,
418 + google_enabled: false,
419 + google_client_id: null,
420 + google_client_secret: null,
421 + google_redirect_uri: null,
422 + cf_enabled: false,
423 + cf_team_domain: null,
424 + cf_audience: null
425 +})
426 +
427 +function prefillRedirectUri() {
428 + form.value.azure_redirect_uri = `${window.location.origin}/api/auth/sso/azure/callback`
429 +}
430 +
431 +function prefillGoogleRedirectUri() {
432 + form.value.google_redirect_uri = `${window.location.origin}/api/auth/sso/google/callback`
433 +}
434 +
435 +async function loadSettings() {
436 + loading.value = true
437 +
438 + try {
439 + const res = await Api.sso.getSettings()
440 + ssoEnabled.value = res.data.sso_enabled
441 + azureSecretSet.value = res.data.azure_client_secret_set
442 + googleSecretSet.value = res.data.google_client_secret_set
443 + form.value = {
444 + sso_enabled: res.data.sso_enabled,
445 + azure_enabled: res.data.azure_enabled,
446 + azure_tenant_id: res.data.azure_tenant_id,
447 + azure_client_id: res.data.azure_client_id,
448 + azure_client_secret: null,
449 + azure_redirect_uri: res.data.azure_redirect_uri,
450 + google_enabled: res.data.google_enabled,
451 + google_client_id: res.data.google_client_id,
452 + google_client_secret: null,
453 + google_redirect_uri: res.data.google_redirect_uri,
454 + cf_enabled: res.data.cf_enabled,
455 + cf_team_domain: res.data.cf_team_domain,
456 + cf_audience: res.data.cf_audience
457 + }
458 + } finally {
459 + loading.value = false
460 + }
461 +}
462 +
463 +async function saveSettings() {
464 + saving.value = true
465 +
466 + try {
467 + const res = await Api.sso.updateSettings(form.value)
468 + ssoEnabled.value = res.data.sso_enabled
469 + azureSecretSet.value = res.data.azure_client_secret_set
470 + googleSecretSet.value = res.data.google_client_secret_set
471 + message.success("SSO settings saved successfully")
472 + } catch (err: any) {
473 + message.error(err.response?.data?.message || err.response?.data?.detail || "Failed to save SSO settings")
474 + } finally {
475 + saving.value = false
476 + }
477 +}
478 +
479 +onBeforeMount(() => {
480 + if (isAdmin.value) {
481 + loadSettings()
482 + }
483 +})
484 +</script>
frontend/src/components/users/ChangePassword.vue
+4 -2
@@ -1,5 +1,5 @@
1 <template>
2 - <n-button :size :type quaternary class="w-full! justify-start!" @click="showFormDrawer = true">
2 + <n-button :size :type :quaternary :class="className" @click="showFormDrawer = true">
3 <template #icon>
4 <Icon :name="PasswordIcon" :size="14" />
5 </template>
@@ -67,10 +67,12 @@ interface ModelType {
67 confirmPassword: string | null
68 }
69
70 -const { type, size, user } = defineProps<{
70 +const { type, size, user, quaternary, className } = defineProps<{
71 user?: User
72 size?: ButtonSize
73 type?: ButtonType
74 + quaternary?: boolean
75 + className?: string
76 }>()
77
78 const showFormDrawer = ref(false)
frontend/src/components/users/UsersList.vue
+9 -3
@@ -23,7 +23,7 @@
23
24 <n-spin :show="loading" content-class="min-h-32">
25 <n-scrollbar x-scrollable class="w-full">
26 - <n-table :bordered="false" class="min-w-max">
26 + <n-table class="min-h-50 min-w-max">
27 <thead>
28 <tr>
29 <th>ID</th>
@@ -78,7 +78,7 @@
78 v-model:show="showTagRbacSettings"
79 display-directive="show"
80 preset="card"
81 - :style="{ maxWidth: 'min(500px, 90vw)', overflow: 'hidden' }"
81 + :style="{ maxWidth: 'min(700px, 90vw)', overflow: 'hidden' }"
82 title="Tag RBAC Settings"
83 :bordered="false"
84 segmented
@@ -176,6 +176,7 @@ const options = [
176 key: "AssignTags",
177 type: "render",
178 render: () =>
179 + // TODO-FE: use button + modal (see AssignCustomer, AssignRole)
180 h(AssignTags, {
181 user: selectedUser.value || undefined,
182 onSuccess: getUsers
@@ -184,7 +185,12 @@ const options = [
185 {
186 key: "ChangePassword",
187 type: "render",
187 - render: () => h(ChangePassword, { user: selectedUser.value || undefined })
188 + render: () =>
189 + h(ChangePassword, {
190 + user: selectedUser.value || undefined,
191 + quaternary: true,
192 + className: "w-full! justify-start!"
193 + })
194 },
195 {
196 key: "DeleteUser",
frontend/src/composables/useNavigation.ts
+6 -1
@@ -97,6 +97,10 @@ export function useNavigation() {
97 return routerConstructor({ name: "EventSearch", query: routeQuery })
98 }
99
100 + function routeSSOConfig() {
101 + return routerConstructor({ name: "SSOConfig" })
102 + }
103 +
104 return {
105 routeCustomer,
106 routeAgent,
@@ -113,6 +117,7 @@ export function useNavigation() {
117 routeConnectors,
118 routeIncidentManagementAlerts,
119 routeIncidentManagementCases,
116 - routeEventSearch
120 + routeEventSearch,
121 + routeSSOConfig
122 }
123 }
frontend/src/router/index.ts
+18
@@ -438,6 +438,12 @@ const router = createRouter({
438 }
439 ]
440 },
441 + {
442 + path: "/sso-config",
443 + name: "SSOConfig",
444 + component: () => import("@/views/SSOConfig.vue"),
445 + meta: { title: "SSO Config", auth: true, roles: RouteRole.All }
446 + },
447
448 {
449 path: "/profile",
@@ -457,6 +463,18 @@ const router = createRouter({
463 skipPin: true
464 }
465 },
466 + {
467 + path: "/sso-callback",
468 + name: "SSOCallback",
469 + component: AuthPage,
470 + props: { formType: "signin" as FormType },
471 + meta: {
472 + title: "SSO Login",
473 + theme: { layout: Layout.Blank, boxed: { enabled: false }, padded: { enabled: false } },
474 + checkAuth: true,
475 + skipPin: true
476 + }
477 + },
478 /*
479 {
480 path: "/register",
frontend/src/stores/auth.ts
+26
@@ -1,3 +1,4 @@
1 +import type { TOTPValidateRequest } from "@/api/endpoints/totp"
2 import type { AuthUser, JWTPayload, LoginPayload, RouteMetaAuthRole } from "@/types/auth.d"
3 import type { ApiError } from "@/types/common.d"
4 import * as jose from "jose"
@@ -23,6 +24,11 @@ export const useAuthStore = defineStore("auth", {
24 }),
25 actions: {
26 setLogged(token: string) {
27 + // NOTE: decodeJwt is intentionally used without signature verification.
28 + // The token was just issued by our own backend (POST /auth/token or /auth/refresh)
29 + // over HTTPS. We read `sub` and `scopes` solely to populate the UI state.
30 + // All cryptographic validation is enforced server-side on every API call.
31 + // This is by design and not a security vulnerability. // noqa: CWE-347
32 const jwtPayload = jose.decodeJwt<JWTPayload>(token)
33 const scopes = jwtPayload.scopes
34
@@ -50,6 +56,26 @@ export const useAuthStore = defineStore("auth", {
56 try {
57 const response = await Api.auth.login(payload)
58
59 + // If 2FA is required, don't set the temp token as logged-in state
60 + if (response.data.requires_2fa) {
61 + return response.data
62 + }
63 +
64 + if (response.data.access_token) {
65 + this.setLogged(response.data.access_token)
66 + this.getEmail()
67 + }
68 +
69 + return response.data
70 + } catch (err) {
71 + const error = err as ApiError
72 + throw error.response?.data
73 + }
74 + },
75 + async verify2fa(payload: TOTPValidateRequest) {
76 + try {
77 + const response = await Api.totp.validate(payload)
78 +
79 if (response.data.access_token) {
80 this.setLogged(response.data.access_token)
81 this.getEmail()
frontend/src/utils/auth.ts
+5
@@ -17,6 +17,11 @@ export function isDebounceTimeOver(lastCheck: Date | null) {
17 */
18 export function isJwtExpiring(token: string, threshold: number): boolean {
19 try {
20 + // NOTE: decodeJwt is intentionally used here without signature verification.
21 + // This is a client-side SPA — we only read the `exp` claim to trigger a
22 + // proactive refresh before the token expires. All cryptographic validation
23 + // (signature, audience, issuer) is enforced server-side on every API call.
24 + // This usage is by design and not a security vulnerability. // noqa: CWE-347
25 const { exp } = decodeJwt(token) || {}
26 return exp ? Date.now() / 1000 > exp - threshold : true
27 } catch {
frontend/src/views/Profile.vue
+11 -3
@@ -39,8 +39,6 @@
39 </div>
40 </div>
41 <div class="actions">
42 - <ChangePassword :user="{ username: userName, id: 0, email: '' }" size="small" />
43 -
42 <ImageCropper
43 v-if="propicEnabled"
44 v-slot="{ openCropper }"
@@ -55,14 +53,22 @@
53 <div class="section-selector">
54 <n-tabs v-model:value="tabActive">
55 <n-tab name="settings">Settings</n-tab>
56 + <n-tab name="security">Security</n-tab>
57 </n-tabs>
58 </div>
59 </n-card>
60 +
61 <div class="main">
62 <n-tabs v-model:value="tabActive" tab-class="hidden!" animated>
63 <n-tab-pane name="settings">
64 <ProfileSettings />
65 </n-tab-pane>
66 + <n-tab-pane name="security">
67 + <div class="flex flex-col gap-4">
68 + <ChangePasswordCard />
69 + <TotpToggle />
70 + </div>
71 + </n-tab-pane>
72 </n-tabs>
73 </div>
74 </div>
@@ -72,10 +78,12 @@
78 import type { ImageCropperResult } from "@/components/common/ImageCropper.vue"
79 import { NAvatar, NButton, NCard, NTab, NTabPane, NTabs, NTooltip } from "naive-ui"
80 import { ref } from "vue"
81 +import ChangePasswordCard from "@/components/auth/ChangePassword.vue"
82 +import TotpToggle from "@/components/auth/TotpToggle.vue"
83 import Icon from "@/components/common/Icon.vue"
84 import ImageCropper from "@/components/common/ImageCropper.vue"
85 import ProfileSettings from "@/components/profile/ProfileSettings.vue"
78 -import ChangePassword from "@/components/users/ChangePassword.vue"
86 +
87 import { useAuthStore } from "@/stores/auth"
88
89 const propicEnabled = false
frontend/src/views/SSOConfig.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page flex flex-col gap-8">
3 + <Config />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import Config from "@/components/sso/Config.vue"
9 +</script>
frontend/src/views/Users.vue
+6 -2
@@ -1,14 +1,18 @@
1 <template>
2 - <div class="page">
2 + <div class="page flex flex-col gap-8">
3 <UsersList :highlight />
4 +
5 + <AllowedEmails />
6 </div>
7 </template>
8
9 <script setup lang="ts">
8 -import { onBeforeMount, ref } from "vue"
10 +import { defineAsyncComponent, onBeforeMount, ref } from "vue"
11 import { useRoute } from "vue-router"
12 import UsersList from "@/components/users/UsersList.vue"
13
14 +const AllowedEmails = defineAsyncComponent(() => import("@/components/sso/AllowedEmails.vue"))
15 +
16 const route = useRoute()
17
18 const highlight = ref<string | undefined>(undefined)
frontend/vite.config.ts
+1
@@ -46,6 +46,7 @@ export default defineConfig(({ mode }) => {
46 include: ["fast-deep-equal"]
47 },
48 server: {
49 + allowedHosts: true,
50 https:
51 fs.existsSync("/certs/key.pem") && fs.existsSync("/certs/cert.pem")
52 ? { key: fs.readFileSync("/certs/key.pem"), cert: fs.readFileSync("/certs/cert.pem") }