fix: two ways to be told an address has an account, and a door to writing
`/auth/forgot-password` and `/auth/resend-verification` both take care to say "if that email exists" and both then answered the question anyway. The reset limiter returned early for an unknown address, so it counted nothing for one and counted for the other: ask four times and a registered address gets 429 while an unknown one gets 200 for ever. It counts either way now — in Redis for an address with no rows to count, keyed by a fingerprint, because a list of addresses somebody tried is itself worth not keeping. Resend answered "Email already verified." for a known verified address and "if that email exists" for everything else, which is not a hint but an answer. One sentence for every outcome now. And Editorial has a way to write something. Drafting was only reachable from the library — a page about reading, behind a button an educator arriving to work has no reason to look for — so the two panels now open from a link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
3418ed023b
commit
1c66eb84b1
4 changed files with 41 additions and 6 deletions
|
|
@ -45,11 +45,28 @@ RESET_WINDOW_HOURS = 1
|
|||
|
||||
|
||||
def _check_reset_rate_limit(db: Session, email: str):
|
||||
"""Refuse a fourth request in an hour — for any address, not only a real one.
|
||||
|
||||
Returning early for an unknown address made this the oracle the careful
|
||||
wording below exists to avoid: ask four times and a registered address
|
||||
answers 429 while an unknown one answers 200 for ever. The limit is now
|
||||
counted against the address as typed, so both answer the same.
|
||||
"""
|
||||
email_normalized = email.lower().strip()
|
||||
user = db.query(User).filter(User.email == email_normalized).first()
|
||||
if not user:
|
||||
return # silently ignore unknown emails
|
||||
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
|
||||
if user is None:
|
||||
# No rows to count for an address with no account, so the attempts are
|
||||
# counted in Redis instead — keyed by a fingerprint, because a list of
|
||||
# addresses somebody tried is itself worth not keeping.
|
||||
import hashlib
|
||||
|
||||
from app.utils.auth import check_rate_limit
|
||||
check_rate_limit(
|
||||
key=f"pwreset:{hashlib.sha256(email_normalized.encode()).hexdigest()}",
|
||||
max_calls=RESET_LIMIT, window_seconds=RESET_WINDOW_HOURS * 3600,
|
||||
detail="Too many reset requests. Please wait before trying again.")
|
||||
return
|
||||
count = db.query(PasswordReset).filter(
|
||||
PasswordReset.user_id == user.id,
|
||||
PasswordReset.created_at >= window_start,
|
||||
|
|
@ -205,7 +222,10 @@ async def resend_verification(data: ForgotPasswordRequest, background_tasks: Bac
|
|||
|
||||
record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
|
||||
if record and record.verified_at is not None:
|
||||
return {"message": "Email already verified."}
|
||||
# The same sentence as every other outcome. Saying "already verified"
|
||||
# here told anybody who asked that the address has an account and that
|
||||
# it is in use — which is the whole of what the wording below is for.
|
||||
return {"message": "If that email exists, a verification link has been sent."}
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
if record:
|
||||
|
|
|
|||
|
|
@ -31,8 +31,12 @@ export default function ArticlesPage() {
|
|||
const [categoryId, setCategoryId] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showAi, setShowAi] = useState(false)
|
||||
// Openable from elsewhere. Editorial is where somebody arrives meaning to
|
||||
// write, and it had to send them to a page about reading and hope they
|
||||
// spotted a button on it.
|
||||
const [params] = useSearchParams()
|
||||
const [showCreate, setShowCreate] = useState(params.get('new') === '1')
|
||||
const [showAi, setShowAi] = useState(params.get('draft') === '1')
|
||||
const [title, setTitle] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
|
|
|||
|
|
@ -59,3 +59,7 @@
|
|||
.ed-actions { width: 100%; }
|
||||
.ed-actions .btn { flex: 1; }
|
||||
}
|
||||
|
||||
|
||||
/* Three doors, and the one that writes something is the loud one. */
|
||||
.ed-header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
|
|
|||
|
|
@ -68,7 +68,14 @@ export default function EditorialPage() {
|
|||
<h1>Editorial</h1>
|
||||
<p>What still needs a person, rather than a list of everything that exists.</p>
|
||||
</div>
|
||||
<Link className="btn btn-secondary" to="/articles">Library</Link>
|
||||
{/* Writing one is the other half of this page's job, and it was only
|
||||
reachable from the library — a page about reading, behind a button
|
||||
an educator arriving here to work has no reason to look for. */}
|
||||
<div className="ed-header-actions">
|
||||
<Link className="btn btn-secondary" to="/articles">Library</Link>
|
||||
<Link className="btn btn-secondary" to="/articles?new=1">Write one</Link>
|
||||
<Link className="btn btn-primary" to="/articles?draft=1">Draft with AI</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ed-counts">
|
||||
|
|
|
|||
Loading…
Reference in a new issue