package web import ( "context" "errors" "net/http" "net/url" "strings" "gitea.mynodes.xyz/ilker/llmgw/internal/account" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) // resetter is the accounts' half of a forgotten password. type resetter interface { RequestReset(ctx context.Context, rs account.ResetStore, email string) (string, store.User, error) ResetPassword(ctx context.Context, rs account.ResetStore, pw account.PasswordChanger, token, next string) error } // Mailer sends a message: the cluster's mail settings. type Mailer interface { Enabled() bool Send(ctx context.Context, to, subject, body string) error } // SetPasswordReset turns on /forgot and /reset: links kept in rs, sent by m // when it can send, with public as the address they point at. Without a // mailer that can, /forgot says to ask an operator, who has `llmgw users // reset-link`. func (p *AccountPages) SetPasswordReset(rs account.ResetStore, m Mailer, public string) { p.resets, p.mailer, p.publicURL = rs, m, strings.TrimRight(public, "/") } type forgotData struct { Email, Error, CSRF, Version string Sent, NoMail bool } type resetData struct { Token, Error, CSRF, Version string MinPassword int Done, Bad bool } func (p *AccountPages) registerReset(mux *http.ServeMux) { if p.resets == nil { return } if _, ok := p.accounts.(resetter); !ok { return } mux.HandleFunc("GET /forgot", p.forgotForm) mux.HandleFunc("POST /forgot", rationed(newRation(5, 10), p.forgot)) mux.HandleFunc("GET /reset", p.resetForm) mux.HandleFunc("POST /reset", rationed(newRation(10, 10), p.reset)) } func (p *AccountPages) canMail() bool { return p.mailer != nil && p.mailer.Enabled() && p.publicURL != "" } func (p *AccountPages) forgotForm(w http.ResponseWriter, r *http.Request) { noStore(w) render(w, r, "forgot", forgotData{NoMail: !p.canMail(), Version: p.version, CSRF: setLoginToken(w, r)}) } func (p *AccountPages) forgot(w http.ResponseWriter, r *http.Request) { noStore(w) l := langOf(r) d := forgotData{Email: strings.TrimSpace(r.FormValue("email")), NoMail: !p.canMail(), Version: p.version} if !loginTokenOK(r) { d.Error, d.CSRF = l.T("login.err.form_expired"), setLoginToken(w, r) render(w, r, "forgot", d) return } if d.NoMail { d.CSRF = setLoginToken(w, r) render(w, r, "forgot", d) return } token, u, err := p.accounts.(resetter).RequestReset(r.Context(), p.resets, d.Email) switch { case errors.Is(err, account.ErrTooManyTries): d.Error, d.CSRF = l.T("login.err.too_many_tries"), setLoginToken(w, r) render(w, r, "forgot", d) return case err == nil: link := p.publicURL + "/reset?token=" + url.QueryEscape(token) body := l.T("reset.mail.body", u.Name, link, int(account.ResetLinkTTL.Minutes())) if err := p.mailer.Send(r.Context(), u.Email, l.T("reset.mail.subject"), body); err != nil { ref := errorRef() p.log.Error("a password reset link could not be sent", "ref", ref, "err", err) } } // The same page whether or not the address has an account: anything // else tells strangers which addresses do. d.Sent = true render(w, r, "forgot", d) } func (p *AccountPages) resetForm(w http.ResponseWriter, r *http.Request) { noStore(w) // The token is in this page's address: no other site is told it. w.Header().Set("Referrer-Policy", "no-referrer") token := r.URL.Query().Get("token") d := resetData{Token: token, MinPassword: account.MinPasswordLen, Version: p.version, CSRF: setLoginToken(w, r)} if _, err := p.resets.PasswordResetUser(r.Context(), token); err != nil { d.Bad = true } render(w, r, "reset", d) } func (p *AccountPages) reset(w http.ResponseWriter, r *http.Request) { noStore(w) w.Header().Set("Referrer-Policy", "no-referrer") l := langOf(r) d := resetData{Token: r.FormValue("token"), MinPassword: account.MinPasswordLen, Version: p.version} if !loginTokenOK(r) { d.Error, d.CSRF = l.T("login.err.form_expired"), setLoginToken(w, r) render(w, r, "reset", d) return } next := r.FormValue("password") if next != r.FormValue("confirm") { d.Error, d.CSRF = l.T("reset.err.mismatch"), setLoginToken(w, r) render(w, r, "reset", d) return } err := p.accounts.(resetter).ResetPassword(r.Context(), p.resets, p.passwords, d.Token, next) switch { case err == nil: d.Done = true case errors.Is(err, account.ErrWeakPassword): d.Error = l.T("signup.err.weak_password", account.MinPasswordLen) case errors.Is(err, account.ErrNoReset): d.Bad = true default: ref := errorRef() p.log.Error("a password could not be set from a reset link", "ref", ref, "err", err) d.Error = l.T("signup.err.internal", ref) } d.CSRF = setLoginToken(w, r) render(w, r, "reset", d) }