#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""Pending invite / reset store at `<state>/pending.json`.One record per in-flight registration or password reset, keyed by the **hash**of the emailed link token (the token itself is never stored or logged). Therecord walks the registration state machine:: invited ──(Terms accepted, OTP mailed)──▶ otp_sent ──(OTP matches)──▶ verified │ (salt+verifier posted) consume ─┘The link token (32 bytes, 7-day TTL, single-use) proves possession of theinvite; the OTP (6 digits, 10-minute TTL, 5 tries) proves *live* control of themailbox at completion time. Expired records sweep on every access; `consume`deletes the record so a link can complete a registration exactly once."""from__future__importannotations__all__=['PendingRecord','PendingStore','generate_token','hash_token']importsecretsimporttimefromhashlibimportsha256frompathlibimportPathfromtypingimportAny,NamedTuplefromeuler_site.authimportpolicyfromeuler_site.auth.storageimportload_json,save_jsonfromeuler_site.auth.usersimportnormalize_emaildefgenerate_token()->str:"""Return a fresh URL-safe link token (`LINK_TOKEN_BYTES` of entropy)."""returnsecrets.token_urlsafe(policy.LINK_TOKEN_BYTES)defhash_token(token:str)->str:"""SHA-256 of a token, for storage/lookup (the raw token never persists)."""returnsha256(token.encode()).hexdigest()def_generate_otp()->str:"""Return a fresh zero-padded numeric OTP of `OTP_DIGITS` digits."""returnstr(secrets.randbelow(10**policy.OTP_DIGITS)).zfill(policy.OTP_DIGITS)classPendingRecord(NamedTuple):"""One in-flight invite/reset (as returned to callers; secrets stay hashed)."""email:strprofile:strkind:str# 'register' | 'reset'state:str# 'invited' | 'otp_sent' | 'verified'expiry:floatterms_version:strterms_accepted_at:strotp_sends:intdefsummary(self)->dict[str,Any]:"""A secret-free view for admin listings."""return{'email':self.email,'profile':self.profile,'kind':self.kind,'state':self.state,'expires_in_h':max(0,round((self.expiry-time.time())/3600,1))}classPendingStore:"""Single-writer store over the pending records, swept on every load."""def__init__(self,path:Path)->None:self._path=pathdef_load(self)->dict[str,dict[str,Any]]:now=time.time()records=load_json(self._path)return{key:recforkey,recinrecords.items()ifisinstance(rec,dict)andfloat(rec.get('expiry',0))>now}def_save(self,records:dict[str,dict[str,Any]])->None:save_json(self._path,records)@staticmethoddef_to_record(raw:dict[str,Any])->PendingRecord:returnPendingRecord(email=str(raw.get('email','')),profile=str(raw.get('profile','user')),kind=str(raw.get('kind','register')),state=str(raw.get('state','invited')),expiry=float(raw.get('expiry',0)),terms_version=str(raw.get('terms_version','')),terms_accepted_at=str(raw.get('terms_accepted_at','')),otp_sends=int(raw.get('otp_sends',0)))defmint(self,email:str,profile:str,kind:str)->str:"""Create a fresh record (replacing any same-email+kind one); return the link token."""records=self._load()key_email=normalize_email(email)records={key:recforkey,recinrecords.items()ifnot(rec.get('email')==key_emailandrec.get('kind')==kind)}token=generate_token()records[hash_token(token)]=self._new_record(key_email,profile,kind)self._save(records)returntoken@staticmethoddef_new_record(email:str,profile:str,kind:str)->dict[str,Any]:now=time.time()return{'email':email,'profile':profile,'kind':kind,'state':'invited','minted':now,'expiry':now+policy.INVITE_TTL_SECONDS,'terms_version':'','terms_accepted_at':'','otp_sends':0}defmint_reset(self,email:str,profile:str)->str|None:"""Mint a password-reset link for *email* alongside any already live; None if refused. Unlike :meth:`mint`, this never replaces a live record: `/forgot` is unauthenticated, and replacing would let anyone who knows an address keep killing the link its owner was sent. Refused (None) within :data:`~policy.RESET_COOLDOWN_SECONDS` of the last one, or past :data:`~policy.RESET_DAILY_MAX` in a day. A completed reset revokes the rest (:meth:`revoke_email`). """records=self._load()key_email=normalize_email(email)now=time.time()minted=[float(rec.get('minted',0))forrecinrecords.values()ifrec.get('email')==key_emailandrec.get('kind')=='reset']ifany(now-when<policy.RESET_COOLDOWN_SECONDSforwheninminted):returnNoneifsum(1forwheninmintedifnow-when<86400)>=policy.RESET_DAILY_MAX:returnNonetoken=generate_token()records[hash_token(token)]=self._new_record(key_email,profile,'reset')self._save(records)returntokendefget(self,token:str)->PendingRecord|None:"""Return the live record for a raw link token, or None."""raw=self._load().get(hash_token(token))returnself._to_record(raw)ifrawisnotNoneelseNonedefaccept_terms(self,token:str,terms_version:str,accepted_at:str)->bool:"""Record Terms acceptance on the pending record (registration only)."""records=self._load()raw=records.get(hash_token(token))ifrawisNone:returnFalseraw['terms_version']=terms_versionraw['terms_accepted_at']=accepted_atself._save(records)returnTruedefissue_otp(self,token:str)->str|None:"""Mint and store (hashed) a fresh OTP for the record; None if not allowed. Allowed from any pre-`verified` state while under the send cap; resets the attempt counter and moves the record to `otp_sent`. """records=self._load()raw=records.get(hash_token(token))ifrawisNoneorraw.get('state')=='verified':returnNoneifint(raw.get('otp_sends',0))>=policy.OTP_MAX_SENDS:returnNoneotp=_generate_otp()raw['otp_hash']=sha256(otp.encode()).hexdigest()raw['otp_expiry']=time.time()+policy.OTP_TTL_SECONDSraw['otp_attempts']=0raw['otp_sends']=int(raw.get('otp_sends',0))+1raw['state']='otp_sent'self._save(records)returnotpdefverify_otp(self,token:str,otp:str)->bool:"""Check the OTP; on success the record becomes `verified`. A wrong try increments the attempt counter; `OTP_MAX_ATTEMPTS` failures (or expiry) invalidate the OTP — a fresh one must be requested. """records=self._load()raw=records.get(hash_token(token))ifrawisNoneorraw.get('state')!='otp_sent':returnFalseiffloat(raw.get('otp_expiry',0))<=time.time():returnFalseattempts=int(raw.get('otp_attempts',0))+1raw['otp_attempts']=attemptsok=secrets.compare_digest(str(raw.get('otp_hash','')),sha256(otp.encode()).hexdigest())ifok:raw['state']='verified'raw.pop('otp_hash',None)elifattempts>=policy.OTP_MAX_ATTEMPTS:raw.pop('otp_hash',None)# invalidated: request a fresh OTPraw['state']='invited'self._save(records)returnokdefconsume(self,token:str)->PendingRecord|None:"""Pop and return the record iff it is `verified` (single-use completion)."""records=self._load()key=hash_token(token)raw=records.get(key)ifrawisNoneorraw.get('state')!='verified':returnNonedelrecords[key]self._save(records)returnself._to_record(raw)defrevoke_email(self,email:str)->int:"""Drop every pending record for *email*; return how many were dropped."""records=self._load()key_email=normalize_email(email)keep={key:recforkey,recinrecords.items()ifrec.get('email')!=key_email}dropped=len(records)-len(keep)ifdropped:self._save(keep)returndroppeddefall(self)->list[PendingRecord]:"""Every live record, sorted by email (for admin listings)."""returnsorted((self._to_record(raw)forrawinself._load().values()),key=lambdarecord:record.email)