Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion backend/app/api/endpoints/admin/system_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

"""Admin system configuration endpoints."""

from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session

from app.api.dependencies import get_db
Expand Down Expand Up @@ -229,9 +229,21 @@ async def mark_admin_setup_complete(
Mark admin setup wizard as completed.
This will prevent the wizard from showing on subsequent admin logins.

Requires that the admin password has been changed from default.

Returns:
AdminSetupCompleteResponse: Contains success status and message
"""
# Verify admin password has been changed from default
from app.core.yaml_init import DEFAULT_ADMIN_Password_HASH

admin_user = db.query(User).filter(User.user_name == "admin").first()
if admin_user and admin_user.password_hash == DEFAULT_ADMIN_Password_HASH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Please change the default password before completing setup",
)

config = (
db.query(SystemConfig)
.filter(SystemConfig.config_key == ADMIN_SETUP_CONFIG_KEY)
Expand Down
12 changes: 12 additions & 0 deletions backend/app/api/endpoints/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,24 @@ def health_check(db: Session = Depends(get_db)):
result = db.execute(text("SELECT COUNT(*) FROM users"))
user_count = result.scalar()

# Check if admin password has been changed from default
from app.core.yaml_init import DEFAULT_ADMIN_Password_HASH
from app.models.user import User

admin_password_changed = True
admin_user = db.query(User).filter(User.user_name == "admin").first()
if admin_user:
admin_password_changed = (
admin_user.password_hash != DEFAULT_ADMIN_Password_HASH
)

return {
"status": "healthy",
"database": "connected",
"users_initialized": user_count > 0,
"user_count": user_count,
"shutting_down": shutdown_manager.is_shutting_down,
"admin_password_changed": admin_password_changed,
}
Comment on lines +38 to 50
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that /health is indeed unauthenticated by checking dependencies
rg -n 'def health_check|@router.get\("/health"\)' backend/app/api/endpoints/health.py -A 5

Repository: wecode-ai/Wegent

Length of output: 355


🏁 Script executed:

#!/bin/bash
# Find the default admin password hash
rg -n 'DEFAULT_ADMIN_Password_HASH' backend/ -A 2 -B 2

Repository: wecode-ai/Wegent

Length of output: 3051


🏁 Script executed:

#!/bin/bash
# Search for uses of the health endpoint in frontend code
fd -e tsx -e ts -e jsx -e js . | xargs rg -l '/health' 2>/dev/null | head -20

Repository: wecode-ai/Wegent

Length of output: 287


🏁 Script executed:

#!/bin/bash
# Check if there are other endpoints that expose similar information
rg -n '@router.get\(' backend/app/api/endpoints/health.py -A 3

Repository: wecode-ai/Wegent

Length of output: 670


🏁 Script executed:

#!/bin/bash
# Check how LoginForm.tsx uses the /health endpoint
cat frontend/src/features/login/components/LoginForm.tsx | grep -n -A 10 -B 5 '/health'

Repository: wecode-ai/Wegent

Length of output: 756


🏁 Script executed:

#!/bin/bash
# Check if admin_password_changed is used in LoginForm
cat frontend/src/features/login/components/LoginForm.tsx | grep -n -A 5 -B 5 'admin_password_changed'

Repository: wecode-ai/Wegent

Length of output: 548


Security & code duplication: Exposing admin_password_changed on unauthenticated endpoint with duplicated logic across endpoints.

The /health endpoint is unauthenticated, exposing admin_password_changed publicly. This allows attackers to probe whether default credentials (admin/Wegent2025!) are still active. While this is an intentional design choice for the login page to auto-fill credentials (verified in frontend/src/features/login/components/LoginForm.tsx), it creates an information disclosure vector that should be explicitly documented as an approved security trade-off.

Additionally, the admin password check logic is duplicated across at least three endpoints:

  • backend/app/api/endpoints/health.py (lines 38-47)
  • backend/app/api/endpoints/users.py (lines 346-355)
  • backend/app/api/endpoints/admin/system_config.py (lines 238-241)

Extract this into a shared utility function to eliminate duplication and ensure consistent behavior:

# backend/app/core/admin_password.py
def is_admin_password_default(db: Session) -> bool:
    """Check if admin user still uses default password."""
    from app.core.yaml_init import DEFAULT_ADMIN_Password_HASH
    from app.models.user import User
    
    admin_user = db.query(User).filter(User.user_name == "admin").first()
    if admin_user:
        return admin_user.password_hash == DEFAULT_ADMIN_Password_HASH
    return True  # No admin user exists

Then replace duplicated logic in all three endpoints with calls to this utility.

🧰 Tools
🪛 Ruff (0.15.4)

[warning] 49-56: Consider moving this statement to an else block

(TRY300)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/endpoints/health.py` around lines 38 - 56, The health
endpoint currently computes admin_password_changed inline
(admin_password_changed) and duplicates that same logic in other places; extract
the check into a shared utility function named is_admin_password_default(db:
Session) that returns True/False based on comparing User.password_hash for
user_name "admin" against DEFAULT_ADMIN_Password_HASH (and returns True if no
admin user exists), then replace the inline logic in the health endpoint
(admin_password_changed assignment) and the duplicated checks in users.py and
admin/system_config.py with calls to is_admin_password_default(db) (negating as
needed to preserve the existing admin_password_changed boolean) so all three
locations use the single utility.

except Exception as e:
return {"status": "unhealthy", "database": "error", "error": str(e)}
Expand Down
36 changes: 35 additions & 1 deletion backend/app/api/endpoints/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
QuickAccessTeam,
WelcomeConfigResponse,
)
from app.schemas.user import UserCreate, UserInDB, UserUpdate
from app.schemas.user import ChangePasswordRequest, UserCreate, UserInDB, UserUpdate
from app.services.kind import kind_service
from app.services.user import user_service

Expand Down Expand Up @@ -80,6 +80,26 @@ async def update_current_user_endpoint(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@router.put("/me/password", response_model=UserInDB)
async def change_password(
request: ChangePasswordRequest,
db: Session = Depends(get_db),
current_user: User = Depends(security.get_current_user),
):
"""Change current user's password"""
if request.new_password != request.confirm_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Passwords do not match",
)

# Hash and update the password
current_user.password_hash = security.get_password_hash(request.new_password)
db.commit()
db.refresh(current_user)
return current_user
Comment on lines +83 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Best practices for password change endpoint security requirements

💡 Result:

Password change endpoint (authenticated user) — security best practices

1) Require strong proof it’s really the user

  • Require the current password + new password + confirmation (and verify the current password server-side). [2], [5]
  • Use step-up / re-authentication for “sensitive actions” (password change, email/MFA changes), especially if sessions can be long-lived (mitigates stolen-cookie/session hijack). [6]
  • If MFA is enabled, consider requiring MFA for the change (step-up), not just for login. [6]

2) Prevent abuse (brute force/DoS)

  • Apply rate limiting and lockout / throttling on the current-password verification attempts (treat like an authentication surface). [7]
  • Add monitoring/alerting for repeated failures and unusual patterns (IP/device). [7]

3) Make password selection resistant to guessing and breaches

  • On password set/change, check the new password against a blocklist of common/expected/compromised passwords. [1]
  • Avoid outdated rules like mandatory periodic rotation; prefer event-driven changes (e.g., compromise) and encourage MFA. [3], [1]

4) Protect the endpoint itself

  • Enforce TLS end-to-end (never accept password changes over plaintext).
  • If cookie-based browser session: require CSRF protection (e.g., same-site cookies + CSRF token).
  • Return generic error messages (don’t reveal whether “current password” was close/valid beyond a simple failure).

5) Secure lifecycle after the change

  • Invalidate existing sessions/refresh tokens (at least all other sessions; often also the current one depending on risk). [6]
  • Send out-of-band notification (“Your password was changed”) to help detect account takeover. [3]
  • Write audit logs for password-change attempts/success (who/when/IP/device), protected from tampering.

6) Avoid authorization mistakes

  • Ensure the endpoint can only change the password for the authenticated account (no user ID in the request that could enable IDOR).
  • For admin-initiated resets vs. user-initiated changes: separate flows and controls; treat reset/forgot-password as an auth mechanism with equal rigor. [7]

Sources

  • NIST Digital Identity Guidelines (SP 800-63-4 / 63B successor): blocklist checks and guidance around password changes. [1]
  • OWASP ASVS: secure credential change requirements (include old password, confirmation). [2], [5]
  • OWASP Cheat Sheets / WSTG / API Security Top 10: re-authentication for sensitive changes and treating reset flows as auth surfaces. [3], [6], [7]

🏁 Script executed:

# Find the ChangePasswordRequest model definition
rg "class ChangePasswordRequest" --type py

Repository: wecode-ai/Wegent

Length of output: 127


🏁 Script executed:

# Search for where the change_password endpoint is called or documented
rg "change_password|/me/password" --type py -B 2 -A 2

Repository: wecode-ai/Wegent

Length of output: 422


🏁 Script executed:

# Check for rate limiting or security middleware
rg "rate_limit|RateLimiter|throttle" --type py

Repository: wecode-ai/Wegent

Length of output: 8207


🏁 Script executed:

# Search for session invalidation logic after password change
rg "session|Session|refresh_token|clear_cookies" backend/app/api/endpoints/users.py -B 2 -A 2

Repository: wecode-ai/Wegent

Length of output: 1480


🏁 Script executed:

# Check for audit logging or security event tracking
rg "audit|log_event|security_log" --type py

Repository: wecode-ai/Wegent

Length of output: 847


🏁 Script executed:

# Look at the full users.py file to understand the context of the endpoint
wc -l backend/app/api/endpoints/users.py

Repository: wecode-ai/Wegent

Length of output: 98


🏁 Script executed:

# View the ChangePasswordRequest model to see what fields it requires
cat -n backend/app/schemas/user.py | grep -A 10 "class ChangePasswordRequest"

Repository: wecode-ai/Wegent

Length of output: 277


🏁 Script executed:

# View the complete change_password endpoint implementation
sed -n '83,120p' backend/app/api/endpoints/users.py

Repository: wecode-ai/Wegent

Length of output: 1397


🏁 Script executed:

# Check if rate limiting is applied to the password change endpoint
sed -n '75,105p' backend/app/api/endpoints/users.py

Repository: wecode-ai/Wegent

Length of output: 1028


🏁 Script executed:

# Check for session/token invalidation after password change
rg "invalidate|revoke|token|session" backend/app/api/endpoints/users.py -B 2 -A 2

Repository: wecode-ai/Wegent

Length of output: 648


🏁 Script executed:

# Look at security module to understand what get_current_user provides
fd "security" --type f backend/app | head -10

Repository: wecode-ai/Wegent

Length of output: 135


🏁 Script executed:

# View the security module to understand authentication and token handling
cat backend/app/core/security.py | head -100

Repository: wecode-ai/Wegent

Length of output: 3592


🏁 Script executed:

# Check if there's any token refresh or invalidation logic after password change
rg "password|token|refresh" backend/app/core/security.py -B 2 -A 2

Repository: wecode-ai/Wegent

Length of output: 10906


🏁 Script executed:

# Look for any password change event handlers or callbacks
rg "change_password|password.*change" --type py

Repository: wecode-ai/Wegent

Length of output: 1546


🏁 Script executed:

# Check if rate limiting is defined anywhere for sensitive endpoints
cat backend/app/core/rate_limit.py

Repository: wecode-ai/Wegent

Length of output: 3168


Add current password verification to password change endpoint.

The endpoint currently allows password changes without verifying the current password, which violates OWASP ASVS security standards. This creates security risks:

  • Session hijacking or unattended devices could result in unauthorized password changes
  • No protection against brute force attempts (endpoint lacks rate limiting)
  • Existing tokens remain valid after password change, extending attack window

Add an old_password field to ChangePasswordRequest and verify it before allowing the change. Additionally:

  • Apply rate limiting to this endpoint using the @limiter decorator (framework already exists)
  • Consider invalidating existing tokens after a password change
  • Add audit logging for password change events

If this endpoint is only for initial admin setup (where the default password is well-known), document this constraint and consider a separate, more restrictive flow for general user password changes.

🧰 Tools
🪛 Ruff (0.15.4)

[warning] 86-86: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)


[warning] 87-87: Do not perform function call Depends in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable

(B008)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/endpoints/users.py` around lines 83 - 100, The
change_password endpoint currently allows password updates without verifying the
current password; update the ChangePasswordRequest model to include an
old_password field and, inside change_password, call the existing password
verification helper (e.g., security.verify_password or equivalent) to check
request.old_password against current_user.password_hash and raise HTTP 400/401
on mismatch before hashing the new password; add the `@limiter`(...) decorator to
the change_password route to apply rate limiting; after successful password
update (before/after db.commit()) trigger token invalidation for the user (e.g.,
increment a token_version on User or call
security.invalidate_user_tokens(current_user)) and write an audit log entry for
the password change event (using the app logger/processLogger) noting user id
and timestamp; ensure db.commit() persists both the new password_hash and any
token_version change and db.refresh(current_user).



@router.delete("/me/git-token/{git_domain:path}", response_model=UserInDB)
async def delete_git_token(
git_domain: str,
Expand Down Expand Up @@ -312,6 +332,7 @@ async def get_welcome_config(

# Check admin setup status for admin users
admin_setup_completed = None
admin_password_changed = None
if current_user.role == "admin":
setup_config = (
db.query(SystemConfig)
Expand All @@ -323,6 +344,17 @@ async def get_welcome_config(
else:
admin_setup_completed = False

# Check if admin password has been changed from default
from app.core.yaml_init import DEFAULT_ADMIN_Password_HASH

admin_user = db.query(User).filter(User.user_name == "admin").first()
if admin_user:
admin_password_changed = (
admin_user.password_hash != DEFAULT_ADMIN_Password_HASH
)
else:
admin_password_changed = True

if not config:
# Return default configuration
return WelcomeConfigResponse(
Expand All @@ -331,6 +363,7 @@ async def get_welcome_config(
],
tips=[ChatTipItem(**tip) for tip in DEFAULT_SLOGAN_TIPS_CONFIG["tips"]],
admin_setup_completed=admin_setup_completed,
admin_password_changed=admin_password_changed,
)

config_value = config.config_value or {}
Expand All @@ -344,6 +377,7 @@ async def get_welcome_config(
for tip in config_value.get("tips", DEFAULT_SLOGAN_TIPS_CONFIG["tips"])
],
admin_setup_completed=admin_setup_completed,
admin_password_changed=admin_password_changed,
)


Expand Down
7 changes: 6 additions & 1 deletion backend/app/core/yaml_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@

logger = logging.getLogger(__name__)

# Default admin password hash for "Wegent2025!" - used to detect if admin has changed default password
DEFAULT_ADMIN_Password_HASH = (
"$2b$12$5jQMrJGO8NMXmF90f/xnKeLtM/Deh912k4GRPx.q3nTGOg1e1IJzW"
)


def load_yaml_documents(file_path: Path) -> List[Dict[str, Any]]:
"""
Expand Down Expand Up @@ -64,7 +69,7 @@ def ensure_default_user(db: Session) -> tuple[int, bool]:
# Default admin user (admin/Wegent2025!)
admin_user = User(
user_name="admin",
password_hash="$2b$12$5jQMrJGO8NMXmF90f/xnKeLtM/Deh912k4GRPx.q3nTGOg1e1IJzW",
password_hash=DEFAULT_ADMIN_Password_HASH,
email="admin@example.com",
git_info=[],
is_active=True,
Expand Down
4 changes: 4 additions & 0 deletions backend/app/schemas/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ class WelcomeConfigResponse(BaseModel):
default=None,
description="Whether admin setup wizard has been completed (only returned for admin users)",
)
admin_password_changed: Optional[bool] = Field(
default=None,
description="Whether admin password has been changed from default (only returned for admin users)",
)


# Public Retriever Management Schemas
Expand Down
9 changes: 8 additions & 1 deletion backend/app/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from datetime import datetime
from typing import Any, Dict, List, Literal, Optional

from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator


class MCPProviderKeys(BaseModel):
Expand Down Expand Up @@ -167,3 +167,10 @@ class CLIPollResponse(BaseModel):
access_token: Optional[str] = None
username: Optional[str] = None
error: Optional[str] = None


class ChangePasswordRequest(BaseModel):
"""Password change request model"""

new_password: str = Field(..., min_length=6)
confirm_password: str = Field(..., min_length=6)
7 changes: 7 additions & 0 deletions frontend/src/apis/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ export const userApis = {
return apiClient.get('/users/features')
},

/**
* Change current user's password
*/
async changePassword(data: { new_password: string; confirm_password: string }): Promise<User> {
return apiClient.put('/users/me/password', data)
},

isAuthenticated(): boolean {
return isAuthenticated()
},
Expand Down
53 changes: 45 additions & 8 deletions frontend/src/features/admin/components/GlobalAdminSetupWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ import { adminApis } from '@/apis/admin'
import { userApis } from '@/apis/user'
import { useUser } from '@/features/common/UserContext'
import { useSetupWizard } from '../contexts/SetupWizardContext'
import SetupPasswordStep from './SetupPasswordStep'
import SetupModelStep from './SetupModelStep'
import SetupSkillStep from './SetupSkillStep'

const TOTAL_STEPS = 2
const TOTAL_STEPS = 3

/**
* Global Admin Setup Wizard component that shows on any page when:
Expand All @@ -45,6 +46,11 @@ const TOTAL_STEPS = 2
* This component should be placed in the root layout to ensure it shows
* regardless of which page the admin first lands on.
*
* Setup steps:
* - Step 1: Change default password (mandatory, cannot be skipped)
* - Step 2: Configure public models (can be skipped)
* - Step 3: Configure public skills (can be skipped)
*
* Setup status is fetched from the welcome-config API to avoid extra API calls.
*/
const GlobalAdminSetupWizard: React.FC = () => {
Expand All @@ -58,6 +64,7 @@ const GlobalAdminSetupWizard: React.FC = () => {
const [currentStep, setCurrentStep] = useState(1)
const [isSkipDialogOpen, setIsSkipDialogOpen] = useState(false)
const [completing, setCompleting] = useState(false)
const [passwordChanged, setPasswordChanged] = useState(false)

// Sync open state with context so other components can know when wizard is open
useEffect(() => {
Expand All @@ -84,6 +91,16 @@ const GlobalAdminSetupWizard: React.FC = () => {
// admin_setup_completed is only returned for admin users
if (response.admin_setup_completed === false) {
setOpen(true)

// Check if password has already been changed
if (response.admin_password_changed === true) {
setPasswordChanged(true)
// Skip password step, start from step 2
setCurrentStep(2)
} else {
setPasswordChanged(false)
setCurrentStep(1)
}
}
} catch (error) {
console.error('Failed to check setup status:', error)
Expand All @@ -104,10 +121,12 @@ const GlobalAdminSetupWizard: React.FC = () => {
}, [currentStep])

const handlePrevious = useCallback(() => {
if (currentStep > 1) {
// Don't go back to password step if already changed
const minStep = passwordChanged ? 2 : 1
if (currentStep > minStep) {
setCurrentStep(prev => prev - 1)
}
}, [currentStep])
}, [currentStep, passwordChanged])

const handleComplete = useCallback(async () => {
setCompleting(true)
Expand Down Expand Up @@ -150,6 +169,15 @@ const GlobalAdminSetupWizard: React.FC = () => {
}
}, [toast, t])

const handlePasswordChanged = useCallback(() => {
setPasswordChanged(true)
// Automatically move to next step after password change
setCurrentStep(2)
}, [])

// Whether the current step allows skipping/closing
const canSkipOrClose = passwordChanged || currentStep > 1

// Don't render anything while checking status or if not admin
if (loading || userLoading || !user || user.role !== 'admin') {
return null
Expand All @@ -175,8 +203,10 @@ const GlobalAdminSetupWizard: React.FC = () => {
const renderStepContent = () => {
switch (currentStep) {
case 1:
return <SetupModelStep />
return <SetupPasswordStep onPasswordChanged={handlePasswordChanged} />
case 2:
return <SetupModelStep />
case 3:
return <SetupSkillStep />
default:
return null
Expand All @@ -189,7 +219,10 @@ const GlobalAdminSetupWizard: React.FC = () => {
open={open}
onOpenChange={nextOpen => {
if (!nextOpen && !completing) {
setIsSkipDialogOpen(true)
if (canSkipOrClose) {
setIsSkipDialogOpen(true)
}
// If password not changed, don't allow closing
}
}}
>
Expand Down Expand Up @@ -222,18 +255,22 @@ const GlobalAdminSetupWizard: React.FC = () => {
<Button
variant="ghost"
onClick={() => setIsSkipDialogOpen(true)}
disabled={completing}
disabled={completing || !canSkipOrClose}
>
{t('setup_wizard.skip')}
</Button>
<div className="flex gap-2">
{currentStep > 1 && (
{currentStep > 1 && (passwordChanged ? currentStep > 2 : true) && (
<Button variant="outline" onClick={handlePrevious} disabled={completing}>
{t('setup_wizard.previous')}
</Button>
)}
{currentStep < TOTAL_STEPS ? (
<Button variant="primary" onClick={handleNext} disabled={completing}>
<Button
variant="primary"
onClick={handleNext}
disabled={completing || (currentStep === 1 && !passwordChanged)}
>
{t('setup_wizard.next')}
</Button>
) : (
Expand Down
Loading
Loading