Remove HIBP breached password warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-09 02:24:24 +02:00
parent 197181f6f9
commit 695392f022
6 changed files with 10 additions and 90 deletions

View file

@ -1,8 +1,6 @@
import hashlib
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
import httpx
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -22,25 +20,6 @@ from app.utils.auth import (
router = APIRouter() 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): def _check_login_rate_limit(client_ip: str):
"""Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis.""" """Rate limit: max 10 login attempts per IP per 15 min, persisted in Redis."""
try: try:
@ -129,15 +108,6 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
if len(user_data.password) < 8: if len(user_data.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters") 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() email_normalized = user_data.email.lower().strip()
existing = db.query(User).filter(User.email == email_normalized).first() existing = db.query(User).filter(User.email == email_normalized).first()
if existing: if existing:
@ -166,17 +136,11 @@ async def register(user_data: UserCreate, background_tasks: BackgroundTasks, db:
if is_first_user: if is_first_user:
# Auto-verified admin — log them in immediately # Auto-verified admin — log them in immediately
access_token = create_access_token(data={"sub": user.email}) access_token = create_access_token(data={"sub": user.email})
resp = {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
if password_warning:
resp["password_warning"] = password_warning
return resp
# Regular user — must verify email before logging in # Regular user — must verify email before logging in
background_tasks.add_task(email_service.send_verification_email, user.email, user.name, token) 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."} return {"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
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
@ -289,17 +253,10 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)):
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") 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) user.hashed_password = get_password_hash(data.new_password)
record.used = True record.used = True
db.commit() db.commit()
resp = {"message": "Password reset successfully. You can now log in."} return {"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
@router.get("/me/settings") @router.get("/me/settings")
@ -339,7 +296,6 @@ def get_me(current_user: User = Depends(get_current_user)):
@router.put("/me") @router.put("/me")
def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): 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 data.new_password:
if not data.current_password: if not data.current_password:
raise HTTPException(status_code=400, detail="Current password required to set a new one") 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") raise HTTPException(status_code=400, detail="Current password is incorrect")
if len(data.new_password) < 8: if len(data.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters") 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) current_user.hashed_password = get_password_hash(data.new_password)
if data.name: if data.name:
@ -360,10 +310,7 @@ def update_me(data: UserUpdateMe, db: Session = Depends(get_db), current_user: U
db.commit() db.commit()
db.refresh(current_user) db.refresh(current_user)
resp = { return {
"id": current_user.id, "email": current_user.email, "id": current_user.id, "email": current_user.email,
"name": current_user.name, "role": current_user.role, "name": current_user.name, "role": current_user.role,
} }
if password_warning:
resp["password_warning"] = password_warning
return resp

View file

@ -41,10 +41,7 @@ export default function AccountPage() {
return return
} }
const res = await api.put('/auth/me', payload) await api.put('/auth/me', payload)
if (res.data.password_warning) {
setError(res.data.password_warning)
}
setSuccess('Account updated successfully') setSuccess('Account updated successfully')
setCurrentPassword('') setCurrentPassword('')
setNewPassword('') setNewPassword('')

View file

@ -178,7 +178,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
const [resendSent, setResendSent] = useState(false) const [resendSent, setResendSent] = useState(false)
const [resending, setResending] = useState(false) const [resending, setResending] = useState(false)
const [registered, setRegistered] = useState(false) const [registered, setRegistered] = useState(false)
const [passwordWarning, setPasswordWarning] = useState('')
const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) } const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) }
const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) } const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) }
@ -203,9 +202,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
setLoading(true) setLoading(true)
try { try {
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null }) 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) { if (res.data.requires_verification) {
setRegistered(true) setRegistered(true)
} else { } else {
@ -266,11 +262,6 @@ function AuthModal({ mode, onClose, onSwitch }) {
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem', marginBottom: 16 }}> <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. We sent a verification link to <strong>{email}</strong>. Click it to activate your account.
</p> </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') }}> <button className="btn btn-primary" onClick={() => { setRegistered(false); switchMode('login') }}>
Go to Sign In Go to Sign In
</button> </button>
@ -468,7 +459,7 @@ export default function LandingPage() {
))} ))}
</div> </div>
<a <a
href="https://scribe.pedshub.com" href="https://app.pedshub.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn btn-primary" className="btn btn-primary"
@ -533,7 +524,7 @@ export default function LandingPage() {
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}> <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> <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="#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>
</div> </div>
</footer> </footer>

View file

@ -36,7 +36,6 @@ export default function RegisterPage() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const [passwordWarning, setPasswordWarning] = useState('')
const [turnstileToken, setTurnstileToken] = useState('') const [turnstileToken, setTurnstileToken] = useState('')
const { loginWithToken } = useAuth() const { loginWithToken } = useAuth()
@ -46,9 +45,6 @@ export default function RegisterPage() {
setLoading(true) setLoading(true)
try { try {
const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null }) 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) { if (res.data.requires_verification) {
setDone(true) setDone(true)
} else { } else {
@ -80,11 +76,6 @@ export default function RegisterPage() {
<p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}> <p style={{ color: '#64748b', fontSize: '0.875rem', marginBottom: 24 }}>
Click the link in the email to activate your account, then you can log in. Click the link in the email to activate your account, then you can log in.
</p> </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> <Link to="/login" className="btn btn-primary" style={{ display: 'inline-block' }}>Go to Login</Link>
<div className="auth-link" style={{ marginTop: 16 }}> <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> Wrong email? <button onClick={() => setDone(false)} style={{ background: 'none', border: 'none', color: 'var(--primary)', cursor: 'pointer', padding: 0 }}>Go back</button>

View file

@ -19,12 +19,9 @@ export default function ResetPasswordPage() {
if (password.length < 8) { setError('Password must be at least 8 characters'); return } if (password.length < 8) { setError('Password must be at least 8 characters'); return }
setLoading(true) setLoading(true)
try { try {
const res = await api.post('/auth/reset-password', { token, new_password: password }) await api.post('/auth/reset-password', { token, new_password: password })
if (res.data.password_warning) {
setError(res.data.password_warning)
}
setSuccess(true) setSuccess(true)
setTimeout(() => navigate('/login'), 3500) setTimeout(() => navigate('/login'), 2500)
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Reset failed. The link may have expired.') setError(err.response?.data?.detail || 'Reset failed. The link may have expired.')
} finally { } finally {

View file

@ -33,10 +33,7 @@ function ProfileSection({ user }) {
if (!Object.keys(payload).length) return setError('No changes to save') if (!Object.keys(payload).length) return setError('No changes to save')
setLoading(true) setLoading(true)
try { try {
const res = await api.put('/auth/me', payload) await api.put('/auth/me', payload)
if (res.data.password_warning) {
setError(res.data.password_warning)
}
setSuccess('Saved successfully') setSuccess('Saved successfully')
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('') setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
if (payload.name) setTimeout(() => window.location.reload(), 800) if (payload.name) setTimeout(() => window.location.reload(), 800)