#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The auth service's HTML pages: login, registration, reset, forgot.First Jinja-rendered pages of the redesign. Server-rendered throughout, withPOST → redirect → GET after every state change so reloads are safe. The twoJS-dependent moments — the SRP login handshake and the set-password verifierderivation — run from same-origin `/assets` scripts (CSP: no inline code).Flow endpoints (all on the public socket; Caddy exposes them in step 5): GET /login the sign-in page (JS drives /auth/challenge+verify) GET /register?token=… invite link → Terms → OTP → set password GET /reset?token=… self-service reset → OTP → set password POST /register/otp /reset/otp accept Terms (register) + mail an OTP POST /register/verify /reset/verify check the OTP POST /register/complete /reset/complete store {salt, verifier}, single-use GET+POST /forgot request a reset link (generic response)Every invalid/expired/foreign token renders the same generic message — noaccount enumeration; responses never echo the token except in the redirect thebrowser already holds. OTP mail runs in a worker thread (the Gmail submissionmust not block the event loop)."""from__future__importannotations__all__=['add_page_routes']importasyncioimportloggingfromurllib.parseimportquote,urlsplit,urlunsplitimportaiohttp_jinja2fromaiohttpimportwebfromeuler_site.authimportpolicyfromeuler_site.auth.appimportAuthService,_client_key,submitted_kdf,utcnow_isofromeuler_site.auth.pendingimportPendingRecordfromeuler_site.auth.requestsimportNAME_MAX,REMARKS_MAX,sanitizefromeuler_site.auth.returntoimportreturn_pathfromeuler_site.auth.usersimportnormalize_emaillog=logging.getLogger('euler-auth')#: pending-record state → which stage of register.html to render, per kind._STAGES={'register':{'invited':'terms','otp_sent':'otp','verified':'password'},'reset':{'invited':'request','otp_sent':'otp','verified':'password'},}_FLAGS={'sent':('notice','A verification code is on its way to your mailbox.'),'badotp':('error','Wrong or expired code — try again.'),'exhausted':('error','Too many wrong attempts — request a fresh code.'),'mailfail':('error','The code could not be emailed just now — try again shortly.'),'capped':('error','No more codes can be sent for this link — ask for a fresh invite.'),}#: The signed-out pages that may link the standalone Terms — path → the name the#: back link calls it. Anything else (a stale bookmark, a foreign site, a typed#: URL) is not a page we can return someone to, so it falls back to sign-in._BACK_LABELS={'/login':'sign in','/register':'registration','/reset':'password reset','/forgot':'password reset','/password':'change password',}_BACK_DEFAULT=('/login',_BACK_LABELS['/login'])def_back_link(request:web.Request)->tuple[str,str]:"""The (href, label) the standalone Terms page returns a reader to. `Referrer-Policy: no-referrer` (csp.py) means there is no `Referer` to read, so the caller names itself: base.html's footer carries its own path in `?back=` — with the query intact, so a half-finished registration comes back to its own token rather than to a dead link. The value is only ever honoured when it is a relative path (no scheme, no host — `//evil.example` is a netloc, not a path) naming one of our own pages. """raw=request.query.get('back','')parsed=urlsplit(raw)ifnotraw.startswith('/')orparsed.schemeorparsed.netloc:return_BACK_DEFAULTlabel=_BACK_LABELS.get(parsed.path)iflabelisNone:return_BACK_DEFAULTreturnurlunsplit(('','',parsed.path,parsed.query,'')),labeldef_is_htmx(request:web.Request)->bool:"""True when the content shell fetched this page for its left pane (`HX-Request`). The terms / change-password pages then answer with a bare fragment instead of the full auth card, so the shell can swap it into `#content` without nesting a second page. """returnrequest.headers.get('HX-Request','').lower()=='true'def_bad_link(request:web.Request)->web.Response:"""The one generic answer for any invalid/expired/foreign token."""returnaiohttp_jinja2.render_template('message.html',request,{'heading':'This link is not valid','message':'It may have expired or already been used. ''Request a fresh one, or contact the operator.','show_login':True})def_flash(request:web.Request)->dict[str,str]:"""Map redirect flags (?sent=1 …) onto the template's error/notice banners."""context:dict[str,str]={}forflag,(key,text)in_FLAGS.items():ifflaginrequest.query:context[key]=textreturncontextdefadd_page_routes(app:web.Application,service:AuthService)->None:"""Attach the HTML flow to the public app."""defrender_stage(request:web.Request,kind:str,token:str,record:PendingRecord)->web.Response:returnaiohttp_jinja2.render_template('register.html',request,{'kind':kind,'base':f'/{"register"ifkind=="register"else"reset"}','token':token,'email':record.email,'stage':_STAGES[kind][record.state],'terms_version':service.config.terms_version,**_flash(request)})defrecord_for(request:web.Request,kind:str)->tuple[str,PendingRecord]|None:"""The (token, live record) for the request, or None — form body over query."""token=str(request.get('form_token')orrequest.query.get('token',''))ifnottoken:returnNonerecord=service.pending.get(token)ifrecordisNoneorrecord.kind!=kind:returnNonereturntoken,recorddefredirect(kind:str,token:str,flag:str='')->web.HTTPSeeOther:suffix=f'&{flag}=1'ifflagelse''returnweb.HTTPSeeOther(location=f'/{kind}?token={quote(token)}{suffix}')# ── login ─────────────────────────────────────────────────────────────────────asyncdeflogin_page(request:web.Request)->web.Response:context:dict[str,str|bool]={}if'registered'inrequest.query:context['notice']='Registration complete — sign in with your new password.'elif'reset'inrequest.query:context['notice']='Password updated — sign in with your new password.'# Where the reader was (returnto): login.js lands there once the handshake succeeds.context['next']=return_path(request.query.get('next'),signed_in=True)# A remember-me cookie may sign the reader back in with no password: login.js tries# it first (POST /auth/resume). The cookie is HttpOnly, so only this page can tell.context['resume']=policy.REMEMBER_COOKIEinrequest.cookiesreturnaiohttp_jinja2.render_template('login.html',request,context)# ── the shared invite/reset pipeline ───────────────────────────────────asyncdefflow_page(request:web.Request)->web.Response:kind='register'ifrequest.path.startswith('/register')else'reset'found=record_for(request,kind)iffoundisNone:return_bad_link(request)returnrender_stage(request,kind,*found)asyncdefflow_otp(request:web.Request)->web.Response:"""Accept Terms (registration) and mail a fresh OTP."""kind='register'ifrequest.path.startswith('/register')else'reset'ifnotservice.rate.allow(_client_key(request)):returnweb.Response(status=429,text='rate limited')form=awaitrequest.post()request['form_token']=form.get('token','')found=record_for(request,kind)iffoundisNone:return_bad_link(request)token,record=foundifkind=='register':ifform.get('accepted')!='yes':returnrender_stage(request,kind,token,record)service.pending.accept_terms(token,service.config.terms_version,utcnow_iso())otp=service.pending.issue_otp(token)ifotpisNoneornotservice.mail_allowed(record.email):# send cap / mail budgetraiseredirect(kind,token,'capped')try:awaitasyncio.to_thread(service.mailer.send_otp,record.email,otp)exceptOSErrorasexc:log.warning('otp mail to %s failed: %s',record.email,exc)raiseredirect(kind,token,'mailfail')log.info('otp sent for %s (%s)',record.email,kind)raiseredirect(kind,token,'sent')asyncdefflow_verify(request:web.Request)->web.Response:kind='register'ifrequest.path.startswith('/register')else'reset'ifnotservice.rate.allow(_client_key(request)):returnweb.Response(status=429,text='rate limited')form=awaitrequest.post()request['form_token']=form.get('token','')found=record_for(request,kind)iffoundisNone:return_bad_link(request)token,_record=foundifservice.pending.verify_otp(token,str(form.get('otp',''))):raiseredirect(kind,token)# → password stageafter=service.pending.get(token)flag='exhausted'ifafterisnotNoneandafter.state=='invited'else'badotp'raiseredirect(kind,token,flag)asyncdefflow_complete(request:web.Request)->web.Response:"""Store the browser-derived {salt, verifier}; single-use (the set-password step)."""kind='register'ifrequest.path.startswith('/register')else'reset'ifnotservice.rate.allow(_client_key(request)):returnweb.Response(status=429,text='rate limited')form=awaitrequest.post()token=str(form.get('token',''))salt=str(form.get('salt','')).lower()verifier=str(form.get('verifier','')).lower()kdf=submitted_kdf(form.get('kdf'))try:# sanity: well-formed hex of sane sizeifnot(kdfandlen(salt)==32and0<len(verifier)<=512andint(verifier,16)>0):raiseValueErrorbytes.fromhex(salt)exceptValueError:return_bad_link(request)record=service.pending.consume(token)# only succeeds in state 'verified'ifrecordisNoneorrecord.kind!=kind:return_bad_link(request)ifkind=='register':# The profile is not stored on the SRP record — it lives in# authorizations.json, assigned by the admin `users add/change` path.service.users.create(record.email,salt,verifier,record.terms_version,record.terms_accepted_at,kdf=kdf)log.info('registration completed for %s (profile from authorizations.json)',record.email)raiseweb.HTTPSeeOther(location='/login?registered=1')ifnotservice.users.set_credentials(record.email,salt,verifier,kdf=kdf):return_bad_link(request)# account vanished mid-flowservice.forget_challenge(record.email)# no pre-reset handshake may finishservice.pending.revoke_email(record.email)# nor any other reset link still outservice.sessions.revoke_email(record.email)# a password change logsservice.remember.revoke_email(record.email)# every device outawaitservice.push_shell_teardown(record.email)# …and any live shellawaitservice.push_vault_reset(record.email)# a reset destroys the vaultlog.info('password reset completed for %s',record.email)raiseweb.HTTPSeeOther(location='/login?reset=1')# ── change password (signed-in self-service; distinct from forgot/reset) ──────asyncdefpassword_page(request:web.Request)->web.Response:"""The signed-in change-password page (`GET /password`). Current password + new password twice; the browser proves the current password over SRP and derives the new verifier locally (no mailbox round-trip — that is the *forgot* flow's job). No session → login. On `HX-Request` it returns a **bare fragment** (form + SRP scripts + OOB breadcrumb) so the content shell can swap it straight into the left pane; a direct visit gets the full auth page (the no-JS/deep-link form). """identity=service.session_identity(request)ifidentityisNone:raiseweb.HTTPFound('/login')template='password_fragment.html'if_is_htmx(request)else'password.html'returnaiohttp_jinja2.render_template(template,request,{'email':identity[0]})# ── forgot (self-service reset entry) ───────────────────────────────────defpow_fields()->dict[str,object]:"""What a form behind the proof of work renders: a fresh stamp and its difficulty."""return{'pow':service.pow.stamp(),'pow_bits':service.pow.bits}defpow_refused(request:web.Request)->web.Response:"""The answer to a post without a valid proof of work — a stale page, or no JS."""returnaiohttp_jinja2.render_template('message.html',request,{'heading':'Please try again','message':'That request could not be checked. Reload the page and send it again ''(it needs JavaScript).','show_login':True},status=400)asyncdefforgot_page(request:web.Request)->web.Response:returnaiohttp_jinja2.render_template('forgot.html',request,pow_fields())# ── request an invite (unauthenticated intake) ────────────────────────────────asyncdefrequest_invite_page(request:web.Request)->web.Response:returnaiohttp_jinja2.render_template('request_invite.html',request,pow_fields())asyncdefrequest_invite_submit(request:web.Request)->web.Response:"""Queue a prospective collaborator's request; answer generically either way. This creates **no** authorization state — only the sudo `users add` path does. The response is the same whether the request stored, deduped, or was dropped (a full queue / bad email), so it leaks neither membership nor capacity. The owner notice is best-effort and off the event loop. """ifnotservice.rate.allow(_client_key(request)):returnweb.Response(status=429,text='rate limited')form=awaitrequest.post()ifnotservice.pow.check(str(form.get('pow','')),str(form.get('pow_nonce',''))):returnpow_refused(request)name=sanitize(str(form.get('name','')),NAME_MAX)email=normalize_email(str(form.get('email','')))remarks=sanitize(str(form.get('remarks','')),REMARKS_MAX,allow_newlines=True)if'@'inemailandservice.requests.submit(name,email,remarks,_client_key(request)):awaitasyncio.to_thread(service.notify_invite_request,name,email,remarks)log.info('invite request queued for %s',email)else:log.warning('invite request not queued (bad email or full queue)')returnaiohttp_jinja2.render_template('message.html',request,{'heading':'Request received','message':'Thanks. If the operator approves it, an invitation link will be ''emailed to the address you gave.','show_login':True})asyncdefterms_page(request:web.Request)->web.Response:"""Standalone view of the Terms of use (the same _terms.html partial the registration page embeds). On `HX-Request` it returns a bare fragment (the terms + OOB breadcrumb) for the content shell's left pane; a direct visit gets the full page — which carries a back link, being otherwise a dead end: it is the one auth-tier page a reader arrives at with nothing to sign in to."""if_is_htmx(request):# the shell's own crumbs lead backreturnaiohttp_jinja2.render_template('terms_fragment.html',request,{'terms_version':service.config.terms_version})back_url,back_label=_back_link(request)returnaiohttp_jinja2.render_template('terms.html',request,{'terms_version':service.config.terms_version,'back_url':back_url,'back_label':back_label})asyncdefforgot_submit(request:web.Request)->web.Response:ifnotservice.rate.allow(_client_key(request)):returnweb.Response(status=429,text='rate limited')form=awaitrequest.post()ifnotservice.pow.check(str(form.get('pow','')),str(form.get('pow_nonce',''))):returnpow_refused(request)email=normalize_email(str(form.get('email','')))user=service.users.get(email)if'@'inemailelseNoneifuserisnotNoneandnotuser.disabled:# Reset does not change the profile (authorizations.json is authoritative);# the pending record's profile is informational only. mint_reset never replaces# a link already sent, and refuses inside the cooldown / daily cap. The mail goes# in the background, so the answer takes the same time whether or not the account# exists — the page is generic, and so is its timing.token=service.pending.mint_reset(email,service.profile_for(email))iftokenisnotNoneandservice.mail_allowed(email):service.send_later(service.mailer.send_invite,email,token,'reset',what=f'reset mail to {email}')log.info('reset link queued for %s',email)returnaiohttp_jinja2.render_template('message.html',request,{'heading':'Check your mailbox','message':'If that account exists and is enabled, a reset link is on its way.','show_login':True})app.add_routes([web.get('/login',login_page),web.get('/register',flow_page),web.get('/reset',flow_page),web.post('/register/otp',flow_otp),web.post('/reset/otp',flow_otp),web.post('/register/verify',flow_verify),web.post('/reset/verify',flow_verify),web.post('/register/complete',flow_complete),web.post('/reset/complete',flow_complete),web.get('/forgot',forgot_page),web.post('/forgot',forgot_submit),web.get('/request-invite',request_invite_page),web.post('/request-invite',request_invite_submit),web.get('/password',password_page),web.get('/terms',terms_page),])