Remove HIBP breached password warnings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
197181f6f9
commit
695392f022
6 changed files with 10 additions and 90 deletions
|
|
@ -1,8 +1,6 @@
|
|||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -22,25 +20,6 @@ from app.utils.auth import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_pwned_password(password: str) -> int | None:
|
||||
"""Check if password appears in Have I Been Pwned breach database.
|
||||
Uses k-anonymity: only first 5 chars of SHA-1 hash are sent.
|
||||
Returns breach count, 0 if clean, or None if check failed."""
|
||||
try:
|
||||
sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
|
||||
prefix, suffix = sha1[:5], sha1[5:]
|
||||
resp = httpx.get(f"https://api.pwnedpasswords.com/range/{prefix}", timeout=3)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
for line in resp.text.splitlines():
|
||||
hash_suffix, count = line.split(":")
|
||||
if hash_suffix == suffix:
|
||||
return int(count)
|
||||
return 0
|
||||
except Exception:
|
||||
return None # fail open — don't block registration if API is down
|
||||
|
||||
|
||||
def _check_login_rate_limit(client_ip: str):
|
||||
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
|
||||
try:
|
||||
|
|
@ -129,15 +108,6 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
|||
if len(user_data.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
|
||||
# Check breached passwords (warn, don't block)
|
||||
pwned_count = _check_pwned_password(user_data.password)
|
||||
password_warning = None
|
||||
if pwned_count and pwned_count > 0:
|
||||
password_warning = (
|
||||
f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
||||
"Consider changing it to something unique."
|
||||
)
|
||||
|
||||
email_normalized = user_data.email.lower().strip()
|
||||
existing = db.query(User).filter(User.email == email_normalized).first()
|
||||
if existing:
|
||||
|
|
@ -166,17 +136,11 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
|
|||
if is_first_user:
|
||||
# Auto-verified admin — log them in immediately
|
||||
access_token = create_access_token(data={"sub": user.email})
|
||||
resp = {"access_token": access_token, "token_type": "bearer"}
|
||||
if password_warning:
|
||||
resp["password_warning"] = password_warning
|
||||
return resp
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
# Regular user — must verify email before logging in
|
||||
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token)
|
||||
resp = {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
|
||||
if password_warning:
|
||||
resp["password_warning"] = password_warning
|
||||
return resp
|
||||
return {"requires_verification": True, "message": "Account created. Please check your email to verify your account before logging in."}
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
|
|
@ -289,17 +253,10 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
|
|||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
pwned_count = _check_pwned_password(data.new_password)
|
||||
user.hashed_password = get_password_hash(data.new_password)
|
||||
record.used = True
|
||||
db.commit()
|
||||
resp = {"message": "Password reset successfully. You can now log in."}
|
||||
if pwned_count and pwned_count > 0:
|
||||
resp["password_warning"] = (
|
||||
f"Warning: this password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
||||
"Consider changing it to something more unique."
|
||||
)
|
||||
return resp
|
||||
return {"message": "Password reset successfully. You can now log in."}
|
||||
|
||||
|
||||
@router.get("/me/settings")
|
||||
|
|
@ -339,7 +296,6 @@ def get_me(current_user: User = Depends(get_current_user)):
|
|||
|
||||
@router.put("/me")
|
||||
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
password_warning = None
|
||||
if data.new_password:
|
||||
if not data.current_password:
|
||||
raise HTTPException(status_code=400, detail="Current password required to set a new one")
|
||||
|
|
@ -347,12 +303,6 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
|
|||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
if len(data.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||
pwned_count = _check_pwned_password(data.new_password)
|
||||
if pwned_count and pwned_count > 0:
|
||||
password_warning = (
|
||||
f"This password has appeared in {pwned_count:,} data breach{'es' if pwned_count > 1 else ''}. "
|
||||
"Consider changing it to something more unique."
|
||||
)
|
||||
current_user.hashed_password = get_password_hash(data.new_password)
|
||||
|
||||
if data.name:
|
||||
|
|
@ -360,10 +310,7 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
|
|||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
resp = {
|
||||
return {
|
||||
"id": current_user.id, "email": current_user.email,
|
||||
"name": current_user.name, "role": current_user.role,
|
||||
}
|
||||
if password_warning:
|
||||
resp["password_warning"] = password_warning
|
||||
return resp
|
||||
|
|
|
|||
|
|
@ -41,10 +41,7 @@ export default function AccountPage() {
|
|||
return
|
||||
}
|
||||
|
||||
const res = await api.put('/auth/me', payload)
|
||||
if (res.data.password_warning) {
|
||||
setError(res.data.password_warning)
|
||||
}
|
||||
await api.put('/auth/me', payload)
|
||||
setSuccess('Account updated successfully')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
|
|
|
|||
|
|
@ -178,7 +178,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
const [resendSent, setResendSent] = useState(false)
|
||||
const [resending, setResending] = useState(false)
|
||||
const [registered, setRegistered] = useState(false)
|
||||
const [passwordWarning, setPasswordWarning] = useState('')
|
||||
|
||||
const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) }
|
||||
const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) }
|
||||
|
|
@ -203,9 +202,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
|
||||
if (res.data.password_warning) {
|
||||
setPasswordWarning(res.data.password_warning)
|
||||
}
|
||||
if (res.data.requires_verification) {
|
||||
setRegistered(true)
|
||||
} else {
|
||||
|
|
@ -266,11 +262,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
|
|||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 16 }}>
|
||||
We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
|
||||
</p>
|
||||
{passwordWarning && (
|
||||
<div style={{ background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, padding: '10px 14px', marginBottom: 12, fontSize: '0.82rem', color: '#92400e', textAlign: 'left' }}>
|
||||
⚠️ {passwordWarning}
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
|
||||
Go to Sign In
|
||||
</button>
|
||||
|
|
@ -468,7 +459,7 @@ export default function LandingPage() {
|
|||
))}
|
||||
</div>
|
||||
<a
|
||||
href="https://scribe.pedshub.com"
|
||||
href="https://app.pedshub.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
|
|
@ -533,7 +524,7 @@ export default function LandingPage() {
|
|||
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
|
||||
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
|
||||
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
|
||||
<a href="https://scribe.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
|
||||
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ export default function RegisterPage() {
|
|||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [passwordWarning, setPasswordWarning] = useState('')
|
||||
const [turnstileToken, setTurnstileToken] = useState('')
|
||||
const { loginWithToken } = useAuth()
|
||||
|
||||
|
|
@ -46,9 +45,6 @@ export default function RegisterPage() {
|
|||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null })
|
||||
if (res.data.password_warning) {
|
||||
setPasswordWarning(res.data.password_warning)
|
||||
}
|
||||
if (res.data.requires_verification) {
|
||||
setDone(true)
|
||||
} else {
|
||||
|
|
@ -80,11 +76,6 @@ export default function RegisterPage() {
|
|||
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
|
||||
Click the link in the email to activate your account, then you can log in.
|
||||
</p>
|
||||
{passwordWarning && (
|
||||
<div style={{ background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, padding: '10px 14px', marginBottom: 16, fontSize: '0.85rem', color: '#92400e', textAlign: 'left' }}>
|
||||
⚠️ {passwordWarning}
|
||||
</div>
|
||||
)}
|
||||
<Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>Go to Login</Link>
|
||||
<div className="auth-link" style={{ marginTop: 16 }}>
|
||||
Wrong email? <button onClick={() => setDone(false)} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0 }}>Go back</button>
|
||||
|
|
|
|||
|
|
@ -19,12 +19,9 @@ export default function ResetPasswordPage() {
|
|||
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/auth/reset-password', { token, new_password: password })
|
||||
if (res.data.password_warning) {
|
||||
setError(res.data.password_warning)
|
||||
}
|
||||
await api.post('/auth/reset-password', { token, new_password: password })
|
||||
setSuccess(true)
|
||||
setTimeout(() => navigate('/login'), 3500)
|
||||
setTimeout(() => navigate('/login'), 2500)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Reset failed. The link may have expired.')
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -33,10 +33,7 @@ function ProfileSection({ user }) {
|
|||
if (!Object.keys(payload).length) return setError('No changes to save')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.put('/auth/me', payload)
|
||||
if (res.data.password_warning) {
|
||||
setError(res.data.password_warning)
|
||||
}
|
||||
await api.put('/auth/me', payload)
|
||||
setSuccess('Saved successfully')
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
if (payload.name) setTimeout(() => window.location.reload(), 800)
|
||||
|
|
|
|||
Loading…
Reference in a new issue