/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>
133 lines
5.1 KiB
Go
133 lines
5.1 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
type sentMail struct{ to, subject, body string }
|
|
|
|
type fakeMailer struct{ sent []sentMail }
|
|
|
|
func (*fakeMailer) Enabled() bool { return true }
|
|
func (m *fakeMailer) Send(_ context.Context, to, subject, body string) error {
|
|
m.sent = append(m.sent, sentMail{to, subject, body})
|
|
return nil
|
|
}
|
|
|
|
func TestAForgottenPasswordIsSetThroughAMailedLink(t *testing.T) {
|
|
ctx := context.Background()
|
|
st, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "w.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
hash, _ := account.HashPassword("an old long password")
|
|
if err := st.CreateUser(ctx, store.User{ID: "usr-a", Email: "ayse@example.com", Name: "Ayşe",
|
|
PasswordHash: hash, State: store.UserActive, CreatedAt: time.Now()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc := account.New(st, account.DefaultConfig(), slog.New(slog.DiscardHandler))
|
|
p := NewAccountPages(svc, st, st, "v0", slog.New(slog.DiscardHandler))
|
|
mailer := &fakeMailer{}
|
|
p.SetPasswordReset(st, mailer, "https://llm.example.com/")
|
|
mux := http.NewServeMux()
|
|
p.Register(mux)
|
|
|
|
formToken := func(path string) *http.Cookie {
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
|
for _, c := range rec.Result().Cookies() {
|
|
if c.Name == loginCSRFCookie {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("%s carries no form token", path)
|
|
return nil
|
|
}
|
|
post := func(path string, form url.Values, c *http.Cookie, host string) string {
|
|
form.Set("csrf", c.Value)
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Host = host
|
|
req.AddCookie(c)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
return rec.Body.String()
|
|
}
|
|
|
|
// Asked from a Host somebody chose: the link still points at the
|
|
// gateway's own address.
|
|
c := formToken("/forgot")
|
|
sentPage := post("/forgot", url.Values{"email": {"ayse@example.com"}}, c, "evil.example")
|
|
if len(mailer.sent) != 1 || !strings.Contains(mailer.sent[0].body, "https://llm.example.com/reset?token=") ||
|
|
strings.Contains(mailer.sent[0].body, "evil.example") || mailer.sent[0].to != "ayse@example.com" {
|
|
t.Fatalf("mail: %+v", mailer.sent)
|
|
}
|
|
// An address with no account gets the same page, and no mail.
|
|
nobody := post("/forgot", url.Values{"email": {"nobody@example.com"}}, formToken("/forgot"), "llm.example.com")
|
|
if len(mailer.sent) != 1 || !strings.Contains(nobody, "on its way") || !strings.Contains(sentPage, "on its way") {
|
|
t.Errorf("an unknown address was told apart, or mailed: %d sent", len(mailer.sent))
|
|
}
|
|
|
|
token := regexp.MustCompile(`token=([^\s]+)`).FindStringSubmatch(mailer.sent[0].body)[1]
|
|
token, _ = url.QueryUnescape(token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/reset?token="+url.QueryEscape(token), nil))
|
|
if rec.Header().Get("Referrer-Policy") != "no-referrer" || !strings.Contains(rec.Body.String(), `name="confirm"`) {
|
|
t.Fatalf("the reset form: %s", rec.Body.String())
|
|
}
|
|
mismatch := post("/reset", url.Values{"token": {token}, "password": {"a brand new password"},
|
|
"confirm": {"not the same"}}, formToken("/reset?token="+url.QueryEscape(token)), "llm.example.com")
|
|
if !strings.Contains(mismatch, "not the same") {
|
|
t.Errorf("two different passwords: %s", mismatch)
|
|
}
|
|
done := post("/reset", url.Values{"token": {token}, "password": {"a brand new password"},
|
|
"confirm": {"a brand new password"}}, formToken("/reset?token="+url.QueryEscape(token)), "llm.example.com")
|
|
if !strings.Contains(done, "Your password is set") {
|
|
t.Fatalf("setting it: %s", done)
|
|
}
|
|
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "a brand new password"); err != nil {
|
|
t.Errorf("the new password does not sign in: %v", err)
|
|
}
|
|
again := post("/reset", url.Values{"token": {token}, "password": {"yet another password"},
|
|
"confirm": {"yet another password"}}, formToken("/reset?token="+url.QueryEscape(token)), "llm.example.com")
|
|
if !strings.Contains(again, "does not work") {
|
|
t.Errorf("a link used twice: %s", again)
|
|
}
|
|
}
|
|
|
|
func TestWithoutMailForgottenSaysAskAnOperator(t *testing.T) {
|
|
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "w.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
svc := account.New(st, account.DefaultConfig(), slog.New(slog.DiscardHandler))
|
|
p := NewAccountPages(svc, st, st, "v0", slog.New(slog.DiscardHandler))
|
|
p.SetPasswordReset(st, nil, "")
|
|
mux := http.NewServeMux()
|
|
p.Register(mux)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/forgot", nil))
|
|
if !strings.Contains(rec.Body.String(), "llmgw users reset-link") {
|
|
t.Errorf("%s", rec.Body.String())
|
|
}
|
|
rec = httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
|
if !strings.Contains(rec.Body.String(), `href="/forgot"`) {
|
|
t.Error("sign-in does not link to /forgot")
|
|
}
|
|
}
|