/forgot mails a link to set a new password when the cluster's mail settings say how - SMTP relay, sender, and the public base_url the link points at, never the request's Host - and says the same whether or not the address has an account. /reset sets it once, closing every session. Links are kept by their hash in password_resets, good for an hour, a newer one replacing an older. Without mail, /forgot says to ask an operator, and llmgw users reset-link makes the link to hand over. Single sign-on accounts get none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
145 lines
4.6 KiB
Go
145 lines
4.6 KiB
Go
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)
|
|
}
|