feat: user management — admin create, password reset, archive/delete, quick invite
Phase 1: must_change_password enforcement + change password endpoint/page Phase 2: Admin user creation (M365-style) with temp password Phase 3: Password reset (self-service forgot + admin-triggered) Phase 4: User archive (soft delete) + hard delete with precheck Phase 5: Quick invite from admin Users page Also fixes: - Auto-create subscription for accounts missing one - Hard delete precheck ignores sole-member personal accounts - Seed script patches tree nodes for validation compliance Migrations: 031 (must_change_password), 032 (password_reset_tokens), 033 (user soft delete) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ from app.core.security import (
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
create_password_reset_token,
|
||||
decode_token,
|
||||
hash_token,
|
||||
)
|
||||
@@ -25,7 +26,17 @@ from app.models.subscription import Subscription
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.schemas.user import UserCreate, UserResponse, UserLogin
|
||||
from app.schemas.token import Token
|
||||
from app.schemas.auth_password import (
|
||||
ChangePasswordRequest,
|
||||
ForgotPasswordRequest,
|
||||
VerifyResetTokenRequest,
|
||||
VerifyResetTokenResponse,
|
||||
ResetPasswordRequest,
|
||||
)
|
||||
from app.models.password_reset_token import PasswordResetToken
|
||||
from app.core.email import EmailService
|
||||
from app.api.deps import get_current_active_user, get_refresh_token_payload
|
||||
from app.core.audit import log_audit
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
@@ -241,7 +252,8 @@ async def login(
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_str,
|
||||
token_type="bearer"
|
||||
token_type="bearer",
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@@ -274,7 +286,8 @@ async def login_json(
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_str,
|
||||
token_type="bearer"
|
||||
token_type="bearer",
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@@ -356,3 +369,177 @@ async def logout(
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
@router.post("/password/change")
|
||||
@limiter.limit("5/minute")
|
||||
async def change_password(
|
||||
request: Request,
|
||||
data: ChangePasswordRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Change the current user's password."""
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Current password is incorrect"
|
||||
)
|
||||
|
||||
if data.current_password == data.new_password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="New password must be different from current password"
|
||||
)
|
||||
|
||||
current_user.password_hash = get_password_hash(data.new_password)
|
||||
current_user.must_change_password = False
|
||||
|
||||
# Revoke all refresh tokens for this user
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.user_id == current_user.id,
|
||||
RefreshToken.revoked_at.is_(None)
|
||||
)
|
||||
)
|
||||
active_tokens = result.scalars().all()
|
||||
for token in active_tokens:
|
||||
token.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
await log_audit(db, current_user.id, "auth.password_change", "user", current_user.id)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
@limiter.limit("3/minute")
|
||||
async def forgot_password(
|
||||
request: Request,
|
||||
data: ForgotPasswordRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Request a password reset email. Always returns success (anti-enumeration)."""
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
# Create reset token JWT
|
||||
raw_token = create_password_reset_token(str(user.id))
|
||||
payload = decode_token(raw_token)
|
||||
if payload and payload.get("jti"):
|
||||
token_record = PasswordResetToken(
|
||||
token_hash=hash_token(payload["jti"]),
|
||||
user_id=user.id,
|
||||
expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
|
||||
)
|
||||
db.add(token_record)
|
||||
await db.commit()
|
||||
|
||||
# Send email (best-effort)
|
||||
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}"
|
||||
await EmailService.send_password_reset_email(
|
||||
to_email=user.email,
|
||||
reset_url=reset_url,
|
||||
)
|
||||
|
||||
await log_audit(db, user.id, "auth.password_reset.request", "user", user.id)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "If an account with that email exists, a reset link has been sent."}
|
||||
|
||||
|
||||
@router.post("/password/verify-reset-token", response_model=VerifyResetTokenResponse)
|
||||
async def verify_reset_token(
|
||||
data: VerifyResetTokenRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Verify a password reset token is valid."""
|
||||
payload = decode_token(data.token)
|
||||
if not payload or payload.get("type") != "password_reset":
|
||||
return VerifyResetTokenResponse(valid=False)
|
||||
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
return VerifyResetTokenResponse(valid=False)
|
||||
|
||||
result = await db.execute(
|
||||
select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_token(jti))
|
||||
)
|
||||
token_record = result.scalar_one_or_none()
|
||||
|
||||
if not token_record or not token_record.is_valid:
|
||||
return VerifyResetTokenResponse(valid=False)
|
||||
|
||||
# Get user email for display
|
||||
user_result = await db.execute(select(User.email).where(User.id == token_record.user_id))
|
||||
email = user_result.scalar_one_or_none()
|
||||
|
||||
return VerifyResetTokenResponse(valid=True, email=email)
|
||||
|
||||
|
||||
@router.post("/password/reset")
|
||||
@limiter.limit("5/minute")
|
||||
async def reset_password(
|
||||
request: Request,
|
||||
data: ResetPasswordRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)]
|
||||
):
|
||||
"""Reset password using a valid reset token."""
|
||||
payload = decode_token(data.token)
|
||||
if not payload or payload.get("type") != "password_reset":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid or expired reset token"
|
||||
)
|
||||
|
||||
jti = payload.get("jti")
|
||||
user_id = payload.get("sub")
|
||||
if not jti or not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid reset token"
|
||||
)
|
||||
|
||||
# Validate token in DB (single-use)
|
||||
result = await db.execute(
|
||||
select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_token(jti))
|
||||
)
|
||||
token_record = result.scalar_one_or_none()
|
||||
|
||||
if not token_record or not token_record.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Reset token has already been used or has expired"
|
||||
)
|
||||
|
||||
# Get user
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid reset token"
|
||||
)
|
||||
|
||||
# Update password
|
||||
user.password_hash = get_password_hash(data.new_password)
|
||||
user.must_change_password = False
|
||||
|
||||
# Mark token as used
|
||||
token_record.used_at = datetime.now(timezone.utc)
|
||||
|
||||
# Revoke all refresh tokens
|
||||
rt_result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.user_id == user.id,
|
||||
RefreshToken.revoked_at.is_(None)
|
||||
)
|
||||
)
|
||||
for rt in rt_result.scalars().all():
|
||||
rt.revoked_at = datetime.now(timezone.utc)
|
||||
|
||||
await log_audit(db, user.id, "auth.password_reset.complete", "user", user.id)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Password has been reset successfully"}
|
||||
|
||||
Reference in New Issue
Block a user