Skip to content

Commit 8f9c712

Browse files
committed
feat: Force users to change password when using default password (#1282)
Add password change enforcement for users using default passwords or when password_change_required flag is set: - Add password_change_required field to EmailUser model with migration - Check if user is using default password on login and force change - Add /admin/change-password-required page with form for password change - Add admin endpoint to force password change for specific users - Set password_change_required=true for bootstrap admin user - Return 403 with X-Password-Change-Required header for API login - Add Force Password Change button to admin user management UI Closes #1282 Signed-off-by: Mihai Criveti <[email protected]>
1 parent 2840f43 commit 8f9c712

File tree

11 files changed

+1280
-32
lines changed

11 files changed

+1280
-32
lines changed

mcpgateway/admin.py

Lines changed: 451 additions & 23 deletions
Large diffs are not rendered by default.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Add password_change_required field to EmailUser
2+
3+
Revision ID: z1a2b3c4d5e6
4+
Revises: 191a2def08d7
5+
Create Date: 2025-11-21 14:16:30.000000
6+
7+
"""
8+
9+
# Third-Party
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "z1a2b3c4d5e6"
15+
down_revision = "191a2def08d7"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
"""Add password_change_required field to email_users table."""
22+
op.add_column("email_users", sa.Column("password_change_required", sa.Boolean(), nullable=False, server_default="false"))
23+
24+
25+
def downgrade():
26+
"""Remove password_change_required field from email_users table."""
27+
op.drop_column("email_users", "password_change_required")

mcpgateway/bootstrap_db.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,12 @@ async def bootstrap_admin_user() -> None:
8383
is_admin=True,
8484
)
8585

86-
# Mark admin user as email verified
86+
# Mark admin user as email verified and require password change on first login
8787
# First-Party
8888
from mcpgateway.db import utc_now # pylint: disable=import-outside-toplevel
8989

9090
admin_user.email_verified_at = utc_now()
91+
admin_user.password_change_required = True # Force admin to change default password
9192
db.commit()
9293

9394
# Personal team is automatically created during user creation if enabled

mcpgateway/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ class Settings(BaseSettings):
290290
email_auth_enabled: bool = Field(default=True, description="Enable email-based authentication")
291291
platform_admin_email: str = Field(default="[email protected]", description="Platform administrator email address")
292292
platform_admin_password: SecretStr = Field(default=SecretStr("changeme"), description="Platform administrator password")
293+
default_user_password: SecretStr = Field(default=SecretStr("changeme"), description="Default password for new users") # nosec B105
293294
platform_admin_full_name: str = Field(default="Platform Administrator", description="Platform administrator full name")
294295

295296
# Argon2id Password Hashing Configuration

mcpgateway/db.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ class EmailUser(Base):
482482
password_hash_type: Mapped[str] = mapped_column(String(20), default="argon2id", nullable=False)
483483
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
484484
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
485+
password_change_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
485486

486487
# Timestamps
487488
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)

mcpgateway/routers/email_auth.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,28 @@ async def login(login_request: EmailLoginRequest, request: Request, db: Session
225225
if not user:
226226
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
227227

228+
# Check if password change is required OR if user is using default password
229+
needs_password_change = user.password_change_required
230+
231+
# Also check if user is using the default password
232+
if not needs_password_change:
233+
# First-Party
234+
from mcpgateway.services.argon2_service import Argon2PasswordService
235+
236+
password_service = Argon2PasswordService()
237+
is_using_default_password = password_service.verify_password(settings.default_user_password.get_secret_value(), user.password_hash) # nosec B105
238+
if is_using_default_password:
239+
needs_password_change = True
240+
# Set the flag in database for future reference
241+
user.password_change_required = True
242+
db.commit()
243+
244+
if needs_password_change:
245+
# For API login, return a specific error indicating password change is required
246+
raise HTTPException(
247+
status_code=status.HTTP_403_FORBIDDEN, detail="Password change required. Please change your password before continuing.", headers={"X-Password-Change-Required": "true"}
248+
)
249+
228250
# Create access token
229251
access_token, expires_in = await create_access_token(user)
230252

@@ -502,6 +524,11 @@ async def create_user(user_request: EmailRegistrationRequest, current_user: Emai
502524
auth_provider="local",
503525
)
504526

527+
# If the user was created with the default password, force password change
528+
if user_request.password == settings.default_user_password.get_secret_value(): # nosec B105
529+
user.password_change_required = True
530+
db.commit()
531+
505532
logger.info(f"Admin {current_user.email} created user: {user.email}")
506533

507534
return EmailUserResponse.from_email_user(user)
@@ -578,13 +605,19 @@ async def update_user(user_email: str, user_request: EmailRegistrationRequest, c
578605

579606
# Update password if provided
580607
if user_request.password:
581-
await auth_service.change_password(
582-
email=user_email,
583-
old_password=None, # Admin can change without old password
584-
new_password=user_request.password,
585-
ip_address="admin_update",
586-
user_agent="admin_panel",
587-
)
608+
# For admin updates, we need to directly update the password hash
609+
# since we don't have the old password to verify
610+
# First-Party
611+
from mcpgateway.services.argon2_service import Argon2PasswordService
612+
613+
password_service = Argon2PasswordService()
614+
615+
# Validate the new password meets requirements
616+
auth_service.validate_password(user_request.password)
617+
618+
# Update password hash directly
619+
user.password_hash = password_service.hash_password(user_request.password)
620+
user.password_change_required = False # Clear password change requirement
588621

589622
db.commit()
590623
db.refresh(user)

mcpgateway/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4865,6 +4865,7 @@ class EmailUserResponse(BaseModel):
48654865
created_at: datetime = Field(..., description="Account creation timestamp")
48664866
last_login: Optional[datetime] = Field(None, description="Last successful login")
48674867
email_verified: bool = Field(False, description="Whether email is verified")
4868+
password_change_required: bool = Field(False, description="Whether user must change password on next login")
48684869

48694870
@classmethod
48704871
def from_email_user(cls, user) -> "EmailUserResponse":
@@ -4885,6 +4886,7 @@ def from_email_user(cls, user) -> "EmailUserResponse":
48854886
created_at=user.created_at,
48864887
last_login=user.last_login,
48874888
email_verified=user.is_email_verified(),
4889+
password_change_required=user.password_change_required,
48884890
)
48894891

48904892

0 commit comments

Comments
 (0)