A forgotten password is set through a one-time link

/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>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-07 13:20:11 +02:00
1 parent f2b2e24e1f
commit 66f702d5c1
25 files changed
+989 -17

No files matched your search

+36
View File
@@ -22,6 +22,42 @@ is only released once its **exit criterion** is met, not when its code lands.
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
APIs change between minors without a deprecation period.
## Unreleased
What was left of the field report from v0.10.6.
### Added
- **A replica says what its engine computes on**: llama.cpp's start-up output
is read as a shard becomes ready — the devices, the backend, the layers
offloaded — and shown by `ctl replicas` (ON: `CUDA 49/49`, `CPU`), the
console and the admin API.
- **`ctl engine-check llamacpp` computes a token on the GPU**: a small GGUF
(`--model`, or the smallest in the weights cache) is loaded with every layer
on the GPU and asked for one token; a build whose CUDA backend does not load
fails, by llama.cpp's own account. With no `--bin` it checks the build the
agent installed.
- **Engine versions and compute capability on nodes**: `ctl nodes` shows
`llamacpp b10991` and `6000-24g (7.5)`; the admin API gives
`engine_versions` and `compute_capabilities`. A profile's `engine_version`
restricts it to nodes whose engine version starts with it, and
explain-placement says which version a node has, or which engines.
- **`ctl restart --max-surge N` and `--max-unavailable N`**: that many of a
model's replicas restart at once, beside themselves or stopped first.
- **Multi-node readiness apart from the node's**: doctor gives both, with
fabric findings in a section of their own; `node add` says the multi-node
one; `ctl nodes` shows a declared fabric's adapters that are down.
- **A forgotten password is set through a link**: `/forgot` mails one when
the cluster's `mail` settings say how (SMTP, sender, the public
`base_url` it points at), saying the same whether or not the address has an
account; `/reset` sets the password once and closes every session. Without
mail, `llmgw users reset-link` makes the link for an operator to hand over.
### Changed
- **A GPU placement llama.cpp says it computes on the CPU is refused**, from
its own output, even where nvidia-smi cannot be asked.
## v0.12.2 — 2026-10-06
The guardrails turned off and on as a whole, and a judge that refuses
+27 -5
View File
@@ -379,16 +379,38 @@ is what someone gets from a borrowed laptop, and the password is what they do no
have — and it signs every other browser out, which is the point if they are
changing it because something went wrong.
There is no self-service reset, because this gateway sends no email and a reset
link with nowhere to send it is not a reset. When someone loses theirs:
Someone who has forgotten theirs follows **Forgotten your password?** on the
sign-in page. With mail set up in the cluster's settings, a link to set a new one
is mailed to their address — the page says the same whether or not the address
has an account, so nobody can find out who does from it:
```
llmgw ctl config set mail '{smtp: smtp.example.com:587, from: llmgw@example.com,
username: llmgw, password_file: /etc/llmgw/secrets/smtp, base_url: https://llm.example.com}'
```
`base_url` is where the link points, and it is never taken from the request:
otherwise a stranger could ask for someone's link from a page of their own. A
link works once, for an hour, and a newer one replaces it.
Without mail, the page says to ask an operator, who makes the link and hands it
over however suits:
```
llmgw users reset-link ayse@example.com --store … # --base-url without mail.base_url
```
Or sets a temporary password to give them, and has them change it from their
profile:
```
llmgw users reset-password usr-k4mq… --password "a temporary passphrase" --store …
```
Tell them out of band and have them change it from their profile. Their sessions
are closed — if they lost the password, the reason might be that someone else has
it. Their API keys keep working; those are separate credentials.
Either way their sessions are closed — if they lost the password, the reason
might be that someone else has it. Their API keys keep working; those are
separate credentials. People who sign in through single sign-on have no password
here, and get no link.
## Trying a model in the browser
+49
View File
@@ -289,6 +289,7 @@ rather than repeated.
| `guard` | `guard.Config` | [Server](#server) |
| `secrets` | `Secrets` | [Server](#server) |
| `notify` | `notify.Config` | [Server](#server) |
| `mail` | `mail.Config` | [Server](#server) |
| `firewall` | `FirewallSettings` | [Server](#server) |
| `firewall_networks` | `[]string` | [Server](#server) |
| `backup_recipients` | `[]string` | [Server](#server) |
@@ -376,6 +377,11 @@ rather than repeated.
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
| `default` | `[]string` | [guard.Config](#guardconfig) |
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
| `smtp` | `string` | [mail.Config](#mailconfig) |
| `from` | `string` | [mail.Config](#mailconfig) |
| `username` | `string` | [mail.Config](#mailconfig) |
| `password_file` | `string` | [mail.Config](#mailconfig) |
| `base_url` | `string` | [mail.Config](#mailconfig) |
| `webhooks` | `[]Webhook` | [notify.Config](#notifyconfig) |
| `host` | `string` | [notify.Config](#notifyconfig) |
| `work_dir` | `string` | [supervisor.Config](#supervisorconfig) |
@@ -2466,6 +2472,15 @@ Prometheus and Alertmanager in between. Empty sends nothing.
Takes a [notify.Config](#notifyconfig) block.
### `mail`
`mail.Config`
Mail is where the gateway's few emails go out from: a link to set a
forgotten password. Unset, none is sent and people ask an operator.
Takes a [mail.Config](#mailconfig) block.
### `firewall`
`FirewallSettings`
@@ -3092,6 +3107,40 @@ whatever is defined or attached. Unset is on.
`map[string]Policy`
## mail.Config
### `smtp`
`string`
SMTP is the relay, host:port: 587 with STARTTLS, 465 with TLS from the
start, 25 as the relay allows. Empty sends nothing.
### `from`
`string`
From is the sender, llmgw@example.com or "llmgw <llmgw@example.com>".
### `username`
`string`
Username and PasswordFile log in to the relay, when it wants that.
The password is a secret reference: a file, env:NAME or vault:path.
### `password_file`
`string`
### `base_url`
`string`
BaseURL is the gateway's public address, which links in mail point
at: https://llm.example.com. Never taken from a request, whose Host a
sender chooses and could point a reset link at their own site.
## notify.Config
### `webhooks`
+37
View File
@@ -448,3 +448,40 @@ func TestAHashThatCannotGetATurnSaysTheServerIsBusy(t *testing.T) {
t.Errorf("a hash with no turn: %v", err)
}
}
func TestAForgottenPasswordIsResetOnceThroughALink(t *testing.T) {
svc, st := newService(t)
ctx := context.Background()
must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword))
u, _ := st.UserByEmail(ctx, "ayse@example.com")
// Waiting for approval: no link, said the same as for nobody.
if _, _, err := svc.RequestReset(ctx, st, "ayse@example.com"); !errors.Is(err, ErrNoReset) {
t.Fatalf("a pending account got a link: %v", err)
}
if _, _, err := svc.RequestReset(ctx, st, "nobody@example.com"); !errors.Is(err, ErrNoReset) {
t.Fatalf("an unknown address: %v", err)
}
must(t, st.ApproveUser(ctx, u.ID, "proj", "ilker", time.Now()))
session, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
must(t, err)
token, got, err := svc.RequestReset(ctx, st, "AYSE@example.com")
if err != nil || token == "" || got.ID != u.ID {
t.Fatalf("an active account: %q %+v %v", token, got, err)
}
if err := svc.ResetPassword(ctx, st, st, token, "short"); !errors.Is(err, ErrWeakPassword) {
t.Fatalf("a weak password: %v", err)
}
next := goodPassword + "-new"
must(t, svc.ResetPassword(ctx, st, st, token, next))
if _, _, err := svc.Resolve(ctx, session); err == nil {
t.Error("a session from before the reset still works")
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", next); err != nil {
t.Errorf("the new password does not sign in: %v", err)
}
if err := svc.ResetPassword(ctx, st, st, token, next+"2"); !errors.Is(err, ErrNoReset) {
t.Errorf("a link used twice: %v", err)
}
}
+60
View File
@@ -373,3 +373,63 @@ func (s *Service) OpenSession(ctx context.Context, userID string) (token, csrf s
}
return token, csrf, nil
}
// ResetLinkTTL is how long a password reset link works.
const ResetLinkTTL = time.Hour
// ResetStore keeps password reset links.
type ResetStore interface {
UserByEmail(ctx context.Context, email string) (store.User, error)
CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error)
PasswordResetUser(ctx context.Context, token string) (string, error)
UsePasswordReset(ctx context.Context, token string) (string, error)
}
// ErrNoReset is an address that gets no reset link: none has an account, the
// account signs in through single sign-on, or it is not active. Never told to
// whoever asked, who learns the same either way.
var ErrNoReset = errors.New("no reset link for that address")
// RequestReset mints a reset link token for the person an address belongs
// to, when they sign in with a password and are active. A few a quarter of
// an hour per address: each sends a mail.
func (s *Service) RequestReset(ctx context.Context, rs ResetStore, email string) (string, store.User, error) {
email = store.NormalizeEmail(email)
if !validEmail(email) {
return "", store.User{}, ErrNoReset
}
if !s.allowAttempt("reset:"+email, 3) {
return "", store.User{}, ErrTooManyTries
}
u, err := rs.UserByEmail(ctx, email)
if err != nil || u.PasswordHash == "" || u.State != store.UserActive {
return "", store.User{}, ErrNoReset
}
token, err := rs.CreatePasswordReset(ctx, u.ID, ResetLinkTTL)
if err != nil {
return "", store.User{}, err
}
s.log.Info("a password reset link was made", "user", u.ID)
return token, u, nil
}
// ResetPassword sets a password from a reset link, which is spent, and signs
// the person out everywhere: if they lost it, someone else may have it.
func (s *Service) ResetPassword(ctx context.Context, rs ResetStore, pw PasswordChanger, token, next string) error {
hash, err := HashPassword(next)
if err != nil {
return err
}
userID, err := rs.UsePasswordReset(ctx, token)
if err != nil {
return ErrNoReset
}
if err := pw.SetPassword(ctx, userID, hash); err != nil {
return err
}
if err := pw.DeleteUserSessions(ctx, userID); err != nil {
return err
}
s.log.Info("a password was set from a reset link", "user", userID)
return nil
}
+53 -4
View File
@@ -3,6 +3,7 @@ package cli
import (
"errors"
"fmt"
"net/url"
"strings"
"text/tabwriter"
"time"
@@ -34,7 +35,7 @@ func newUsersCmd() *cobra.Command {
"Stop an account working, keeping it and its history"),
newUserStateCmd(open, "reinstate", store.UserActive,
"Let a suspended account work again"),
newUserCreateCmd(open), newUserResetPasswordCmd(open),
newUserCreateCmd(open), newUserResetPasswordCmd(open), newUserResetLinkCmd(open, &sf),
newUserJoinCmd(open), newUserLeaveCmd(open), newUserDeleteCmd(open), newUserForgetCmd(open),
newUserAdminCmd(open, "promote", true, "Let an account see the cluster dashboard at /admin"),
newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard"),
@@ -254,9 +255,10 @@ func newUserResetPasswordCmd(open userOpener) *cobra.Command {
cmd := &cobra.Command{
Use: "reset-password <user>",
Short: "Set a new password for someone who lost theirs",
Long: "There is no self-service reset, because this gateway sends no email and\n" +
"a reset link with nowhere to send it is not a reset. Set one here, tell\n" +
"them out of band, and have them change it from their profile.\n\n" +
Long: "People reset a forgotten password themselves at /forgot when the cluster\n" +
"has mail settings (mail.smtp); without them, 'llmgw users reset-link' makes\n" +
"a link to hand them. Or set one here, tell them out of band, and have them\n" +
"change it from their profile.\n\n" +
"Every session they have open is closed: if they lost the password, the\n" +
"reason might be that somebody else has it.",
Args: cobra.ExactArgs(1),
@@ -532,3 +534,50 @@ func newUserForgetCmd(open userOpener) *cobra.Command {
cmd.Flags().BoolVar(&yes, "yes", false, "confirm")
return cmd
}
func newUserResetLinkCmd(open userOpener, sf *storeFlags) *cobra.Command {
var base string
cmd := &cobra.Command{
Use: "reset-link <user>",
Short: "Make a one-time link for someone to set a forgotten password",
Long: "For a gateway that sends no mail, or a person whose mail does not arrive:\n" +
"the link is printed, to hand them however suits. It works once, for an\n" +
"hour, and a newer one replaces it. Setting the password closes every\n" +
"session they have open. The address it points at is mail.base_url from\n" +
"the cluster's settings, or --base-url.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if base == "" {
if cfg, err := loadEffectiveConfig(sf.config); err == nil {
base = cfg.Mail.BaseURL
}
}
if base == "" {
return errors.New("give --base-url, the address people reach this gateway at, or set mail.base_url " +
"(llmgw ctl config set mail.base_url https://…)")
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
u, err := resolveUser(cmd.Context(), s, args[0])
if err != nil {
return err
}
if u.PasswordHash == "" {
return fmt.Errorf("%s signs in through single sign-on: there is no password to set", u.Email)
}
token, err := s.CreatePasswordReset(cmd.Context(), u.ID, account.ResetLinkTTL)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s/reset?token=%s\n", strings.TrimRight(base, "/"), url.QueryEscape(token))
cmd.PrintErrf("For %s: works once, for %s. Hand it to them alone; it opens their account.\n",
u.Email, account.ResetLinkTTL)
return nil
},
}
cmd.Flags().StringVar(&base, "base-url", "", "the address people reach this gateway at (default: mail.base_url)")
return cmd
}
+8
View File
@@ -19,6 +19,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/mail"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
@@ -146,6 +147,10 @@ type Server struct {
// Prometheus and Alertmanager in between. Empty sends nothing.
Notify notify.Config `koanf:"notify"`
// Mail is where the gateway's few emails go out from: a link to set a
// forgotten password. Unset, none is sent and people ask an operator.
Mail mail.Config `koanf:"mail"`
// Firewall turns llmgw's firewall on every machine on or off.
Firewall FirewallSettings `koanf:"firewall"`
@@ -914,6 +919,9 @@ func (s Server) Validate() error {
return fmt.Errorf("backup_copy_to needs backup_recipients: an archive holds the certificate authority's key " +
"and every secret in the store, and leaves this machine only encrypted")
}
if err := s.Mail.Validate(); err != nil {
return err
}
if _, err := s.FirewallAllow(); err != nil {
return err
}
+8
View File
@@ -367,6 +367,7 @@ var Reference = []Block{
{Key: "guard", Type: "guard.Config", Doc: "Guard is the library of guardrail policies: what requests and answers\nare checked for, and what is done. Applied to every request through\nguard.default, and to parts of the tenancy tree by attaching them\n(llmgw ctl guard attach). Empty checks nothing.", Nested: "guard.Config"},
{Key: "secrets", Type: "Secrets", Doc: "Secrets is where secrets kept outside files are read from, and the key\nthe store's own secrets are sealed under.", Nested: "config.Secrets"},
{Key: "notify", Type: "notify.Config", Doc: "Notify sends what needs a person — a model parked, a node lost, a\nbudget spent, new weights published — to webhooks, signed, without a\nPrometheus and Alertmanager in between. Empty sends nothing.", Nested: "notify.Config"},
{Key: "mail", Type: "mail.Config", Doc: "Mail is where the gateway's few emails go out from: a link to set a\nforgotten password. Unset, none is sent and people ask an operator.", Nested: "mail.Config"},
{Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"},
{Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""},
{Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""},
@@ -489,6 +490,13 @@ var Reference = []Block{
{Key: "default", Type: "[]string", Doc: "", Nested: ""},
{Key: "policies", Type: "map[string]Policy", Doc: "", Nested: ""},
}},
{Package: "mail", Name: "Config", Title: "mail.Config", Doc: "", Fields: []Field{
{Key: "smtp", Type: "string", Doc: "SMTP is the relay, host:port: 587 with STARTTLS, 465 with TLS from the\nstart, 25 as the relay allows. Empty sends nothing.", Nested: ""},
{Key: "from", Type: "string", Doc: "From is the sender, llmgw@example.com or \"llmgw <llmgw@example.com>\".", Nested: ""},
{Key: "username", Type: "string", Doc: "Username and PasswordFile log in to the relay, when it wants that.\nThe password is a secret reference: a file, env:NAME or vault:path.", Nested: ""},
{Key: "password_file", Type: "string", Doc: "", Nested: ""},
{Key: "base_url", Type: "string", Doc: "BaseURL is the gateway's public address, which links in mail point\nat: https://llm.example.com. Never taken from a request, whose Host a\nsender chooses and could point a reset link at their own site.", Nested: ""},
}},
{Package: "notify", Name: "Config", Title: "notify.Config", Doc: "", Fields: []Field{
{Key: "webhooks", Type: "[]Webhook", Doc: "", Nested: ""},
{Key: "host", Type: "string", Doc: "Host names the gateway in messages; empty is this machine's name.", Nested: ""},
+1
View File
@@ -31,6 +31,7 @@ var Sources = []string{
"internal/guard/judge.go",
"internal/config/oidc.go",
"internal/config/firewall.go",
"internal/mail/mail.go",
"internal/notify/notify.go",
"internal/secrets/secrets.go",
"internal/obs/trace.go",
+125
View File
@@ -0,0 +1,125 @@
// Package mail sends the gateway's few emails — a password reset link — by
// SMTP, when the cluster's settings say where through.
package mail
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
)
// Config is where mail goes out.
type Config struct {
// SMTP is the relay, host:port: 587 with STARTTLS, 465 with TLS from the
// start, 25 as the relay allows. Empty sends nothing.
SMTP string `koanf:"smtp"`
// From is the sender, llmgw@example.com or "llmgw <llmgw@example.com>".
From string `koanf:"from"`
// Username and PasswordFile log in to the relay, when it wants that.
// The password is a secret reference: a file, env:NAME or vault:path.
Username string `koanf:"username"`
PasswordFile string `koanf:"password_file"`
// BaseURL is the gateway's public address, which links in mail point
// at: https://llm.example.com. Never taken from a request, whose Host a
// sender chooses and could point a reset link at their own site.
BaseURL string `koanf:"base_url"`
}
// Enabled is whether mail can be sent.
func (c Config) Enabled() bool { return c.SMTP != "" && c.From != "" && c.BaseURL != "" }
// Validate refuses a half-set configuration.
func (c Config) Validate() error {
if c.SMTP == "" && c.From == "" && c.BaseURL == "" {
return nil
}
if _, _, err := net.SplitHostPort(c.SMTP); err != nil {
return fmt.Errorf("mail.smtp %q: host:port, like smtp.example.com:587", c.SMTP)
}
if c.From == "" || !strings.Contains(c.From, "@") {
return errors.New("mail.from: the sender's address")
}
if !strings.HasPrefix(c.BaseURL, "https://") && !strings.HasPrefix(c.BaseURL, "http://") {
return fmt.Errorf("mail.base_url %q: the gateway's public address, https://…", c.BaseURL)
}
return nil
}
// Send sends one plain-text message.
func (c Config) Send(ctx context.Context, to, subject, body string) error {
if !c.Enabled() {
return errors.New("no mail relay is set (mail.smtp, mail.from, mail.base_url)")
}
host, port, _ := net.SplitHostPort(c.SMTP)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
d := net.Dialer{}
var conn net.Conn
var err error
if port == "465" {
conn, err = (&tls.Dialer{NetDialer: &d, Config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}}).DialContext(ctx, "tcp", c.SMTP)
} else {
conn, err = d.DialContext(ctx, "tcp", c.SMTP)
}
if err != nil {
return fmt.Errorf("reaching %s: %w", c.SMTP, err)
}
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return err
}
defer cl.Close()
if ok, _ := cl.Extension("STARTTLS"); ok && port != "465" {
if err := cl.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("STARTTLS with %s: %w", c.SMTP, err)
}
}
if c.Username != "" {
pass, err := secrets.ReadString(ctx, c.PasswordFile)
if err != nil {
return fmt.Errorf("mail.password_file: %w", err)
}
if err := cl.Auth(smtp.PlainAuth("", c.Username, pass, host)); err != nil {
return fmt.Errorf("logging in to %s: %w", c.SMTP, err)
}
}
if err := cl.Mail(address(c.From)); err != nil {
return err
}
if err := cl.Rcpt(to); err != nil {
return err
}
w, err := cl.Data()
if err != nil {
return err
}
msg := "From: " + c.From + "\r\nTo: " + to + "\r\nSubject: " + subject +
"\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nDate: " +
time.Now().UTC().Format(time.RFC1123Z) + "\r\n\r\n" + strings.ReplaceAll(body, "\n", "\r\n")
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return cl.Quit()
}
// address is the bare address in "Name <a@b>".
func address(from string) string {
if i, j := strings.LastIndexByte(from, '<'), strings.LastIndexByte(from, '>'); i >= 0 && j > i {
return from[i+1 : j]
}
return strings.TrimSpace(from)
}
+93
View File
@@ -0,0 +1,93 @@
package mail
import (
"bufio"
"context"
"net"
"strings"
"testing"
)
// relay is the least of an SMTP server: it takes one message and keeps it.
func relay(t *testing.T) (addr string, got chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
got = make(chan string, 1)
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
r := bufio.NewReader(c)
say := func(s string) { _, _ = c.Write([]byte(s + "\r\n")) }
say("220 relay ready")
var data strings.Builder
inData := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
if inData {
if line == ".\r\n" {
inData = false
got <- data.String()
say("250 kept")
continue
}
data.WriteString(line)
continue
}
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
say("250 relay")
case strings.HasPrefix(cmd, "DATA"):
inData = true
say("354 go on")
case strings.HasPrefix(cmd, "QUIT"):
say("221 bye")
return
default:
say("250 ok")
}
}
}()
t.Cleanup(func() { _ = ln.Close() })
return ln.Addr().String(), got
}
func TestAMessageGoesThroughTheRelay(t *testing.T) {
addr, got := relay(t)
c := Config{SMTP: addr, From: "llmgw <llmgw@example.com>", BaseURL: "https://llm.example.com"}
if err := c.Validate(); err != nil {
t.Fatal(err)
}
if err := c.Send(context.Background(), "ayse@example.com", "Set a new password", "Hello,\nthe link."); err != nil {
t.Fatal(err)
}
msg := <-got
for _, want := range []string{"Subject: Set a new password", "To: ayse@example.com", "Hello,\r\nthe link."} {
if !strings.Contains(msg, want) {
t.Errorf("the message lacks %q:\n%s", want, msg)
}
}
}
func TestHalfAMailConfigurationIsRefused(t *testing.T) {
if err := (Config{}).Validate(); err != nil || (Config{}).Enabled() {
t.Errorf("none: %v", err)
}
if err := (Config{SMTP: "smtp.example.com:587"}).Validate(); err == nil {
t.Error("a relay without a sender was accepted")
}
if err := (Config{SMTP: "smtp.example.com:587", From: "a@b.c", BaseURL: "llm.example.com"}).Validate(); err == nil {
t.Error("a base URL without a scheme was accepted")
}
if got := address("llmgw <llmgw@example.com>"); got != "llmgw@example.com" {
t.Errorf("%q", got)
}
}
+3
View File
@@ -217,6 +217,9 @@ func (r *Router) Handler() http.Handler {
pages.SetPersonalDefaults(r.personalDefaults)
if r.store != nil {
pages.SetAudit(r.store)
// A forgotten password: a link mailed when the cluster's
// settings say how, otherwise asked of an operator.
pages.SetPasswordReset(r.store, r.mail, r.mail.BaseURL)
if r.oidc.Enabled() {
if err := pages.RegisterOIDC(mux, r.oidc, r.store); err != nil {
r.log.Error("single sign-on is off", "err", err)
+4
View File
@@ -9,6 +9,7 @@ import (
"context"
"errors"
"fmt"
"gitea.mynodes.xyz/ilker/llmgw/internal/mail"
"log/slog"
"math"
"net"
@@ -112,6 +113,8 @@ type Router struct {
// oidc is sign-in through an identity provider, and scim provisioning
// by it.
oidc config.OIDC
// mail sends what the portal mails: a password reset link.
mail mail.Config
scim config.SCIM
// threads holds conversations and memory notes. It is store when auth is on,
@@ -332,6 +335,7 @@ func New(cfg config.Server, log *slog.Logger) (*Router, error) {
st.SetPersonalDefaults(personalLimits(cfg.Auth.Personal))
r.personalOff = cfg.Auth.Personal.Disabled
r.oidc = cfg.Auth.OIDC
r.mail = cfg.Mail
r.scim = cfg.Auth.SCIM
r.personalDefaults = personalLimits(cfg.Auth.Personal)
r.llamaVersion = cfg.WantedEngines()["llamacpp"]
+2
View File
@@ -60,6 +60,8 @@ var copyOrder = []string{
"guard_attachments",
// Or the cluster would start the new store on server.yaml's settings again.
"cluster_settings",
// A link sent and not yet used still works after the move.
"password_resets",
// Who changed what is not lost by moving the store.
"audit_events",
"key_ttl",
@@ -0,0 +1,8 @@
-- Ported from the SQLite migration of the same number.
CREATE TABLE password_resets (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
used_at BIGINT NOT NULL DEFAULT 0
);
@@ -0,0 +1,10 @@
-- One-time links to set a forgotten password: kept by the token's hash, as
-- sessions are, so a copy of the store opens nobody's account. A link is
-- good once, until it expires.
CREATE TABLE password_resets (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER NOT NULL DEFAULT 0
);
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"context"
"database/sql"
"encoding/hex"
"errors"
"time"
)
// CreatePasswordReset mints a one-time link token to set userID's password,
// good for ttl. Only its hash is kept.
func (s *DB) CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error) {
token, err := NewSessionToken()
if err != nil {
return "", err
}
now := time.Now()
// Older links for the same person stop working: the newest is the one
// they were sent.
if _, err := s.db.ExecContext(ctx,
`UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at = 0`, millis(now), userID); err != nil {
return "", err
}
_, err = s.db.ExecContext(ctx,
`INSERT INTO password_resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
resetHash(token), userID, millis(now), millis(now.Add(ttl)))
return token, err
}
// PasswordResetUser is whose a reset token is, while it is good: ErrNotFound
// for one used, expired or never made.
func (s *DB) PasswordResetUser(ctx context.Context, token string) (string, error) {
var userID string
err := s.db.QueryRowContext(ctx,
`SELECT user_id FROM password_resets WHERE token_hash = ? AND used_at = 0 AND expires_at > ?`,
resetHash(token), millis(time.Now())).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return userID, err
}
// UsePasswordReset spends a reset token, returning whose it was. Of two uses
// at once, one wins and the other gets ErrNotFound.
func (s *DB) UsePasswordReset(ctx context.Context, token string) (string, error) {
userID, err := s.PasswordResetUser(ctx, token)
if err != nil {
return "", err
}
res, err := s.db.ExecContext(ctx,
`UPDATE password_resets SET used_at = ? WHERE token_hash = ? AND used_at = 0`,
millis(time.Now()), resetHash(token))
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n != 1 {
return "", ErrNotFound
}
return userID, nil
}
func resetHash(token string) string { return hex.EncodeToString(HashKey(token)) }
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"errors"
"testing"
"time"
)
func TestAResetLinkWorksOnceAndUntilItExpires(t *testing.T) {
s := capacityStore(t)
ctx := t.Context()
tok, err := s.CreatePasswordReset(ctx, "usr-1", time.Hour)
if err != nil {
t.Fatal(err)
}
if u, err := s.PasswordResetUser(ctx, tok); err != nil || u != "usr-1" {
t.Fatalf("%q %v", u, err)
}
if u, err := s.UsePasswordReset(ctx, tok); err != nil || u != "usr-1" {
t.Fatalf("first use: %q %v", u, err)
}
if _, err := s.UsePasswordReset(ctx, tok); !errors.Is(err, ErrNotFound) {
t.Errorf("used twice: %v", err)
}
expired, _ := s.CreatePasswordReset(ctx, "usr-2", -time.Minute)
if _, err := s.PasswordResetUser(ctx, expired); !errors.Is(err, ErrNotFound) {
t.Errorf("an expired link: %v", err)
}
first, _ := s.CreatePasswordReset(ctx, "usr-3", time.Hour)
if _, err := s.CreatePasswordReset(ctx, "usr-3", time.Hour); err != nil {
t.Fatal(err)
}
if _, err := s.PasswordResetUser(ctx, first); !errors.Is(err, ErrNotFound) {
t.Errorf("an older link still works after a newer one: %v", err)
}
}
+11 -3
View File
@@ -127,6 +127,11 @@ type AccountPages struct {
quotaStore QuotaAdmin
usageStore UsageAdmin
usagePriced Priced
// resets, mailer and publicURL serve a forgotten password; see
// SetPasswordReset.
resets account.ResetStore
mailer Mailer
publicURL string
}
// ServedModels supplies the names the profile page's example should use.
@@ -177,6 +182,7 @@ func (p *AccountPages) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /login", p.loginForm)
mux.HandleFunc("POST /login", rationed(newRation(30, 30), p.login))
mux.HandleFunc("POST /logout", p.logout)
p.registerReset(mux)
mux.HandleFunc("GET /profile", p.profile)
mux.HandleFunc("POST /profile/keys", p.selfAudited(p.createKey))
mux.HandleFunc("POST /profile/keys/{id}/revoke", p.selfAudited(p.revokeKey))
@@ -243,7 +249,9 @@ func (p *AccountPages) ssoLabel(l *Lang) string {
type loginData struct {
Email, Error string
SignupOpen bool
Version string
// Forgot is whether /forgot is there to link to.
Forgot bool
Version string
// SSO is the identity provider's label, when sign-in through one is on.
SSO string
// CSRF is the form's token, matched by a cookie.
@@ -252,7 +260,7 @@ type loginData struct {
func (p *AccountPages) loginForm(w http.ResponseWriter, r *http.Request) {
noStore(w)
render(w, r, "login", loginData{SSO: p.ssoLabel(langOf(r)), SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)})
render(w, r, "login", loginData{SSO: p.ssoLabel(langOf(r)), SignupOpen: p.accounts.SignupOpen(), Forgot: p.resets != nil, Version: p.version, CSRF: setLoginToken(w, r)})
}
func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
@@ -270,7 +278,7 @@ func (p *AccountPages) login(w http.ResponseWriter, r *http.Request) {
ctx := account.WithClient(r.Context(), clientAddr(r))
token, csrf, err := p.accounts.SignIn(ctx, email, r.FormValue("password"))
if err != nil {
d := loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Version: p.version, CSRF: setLoginToken(w, r)}
d := loginData{SSO: p.ssoLabel(l), Email: email, SignupOpen: p.accounts.SignupOpen(), Forgot: p.resets != nil, Version: p.version, CSRF: setLoginToken(w, r)}
switch {
case errors.Is(err, account.ErrBadCredentials),
errors.Is(err, account.ErrNotApproved),
+19 -2
View File
@@ -5,7 +5,7 @@
"signup.thanks": "Thank you. Your account is waiting for approval.",
"signup.no_email": "You will not get an email from this gateway — it does not send any.\nTell whoever you asked for access that you have signed up.",
"signup.sign_in_once_approved": "<a href=\"/login\">Sign in</a> once you have been approved.",
"signup.already_had": "If this address already had an account, nothing new was made:\n<a href=\"/login\">sign in</a> with it, or, if you have forgotten its password, ask\nan operator to reset it.",
"signup.already_had": "If this address already had an account, nothing new was made:\n<a href=\"/login\">sign in</a> with it, or <a href=\"/forgot\">set a new password</a> if you have\nforgotten it.",
"signup.email": "Email",
"signup.name": "Name",
"signup.password": "Password",
@@ -148,5 +148,22 @@
"profile.err.not_member": "you are not a member of that project",
"profile.err.wrong_password": "that is not your current password",
"profile.err.same_password": "that is the password you already have",
"connect.own_code": "From your own code: the official OpenAI and Anthropic SDKs work as they are —\nthe <a href=\"/docs/clients\">client guide</a> shows how, in Python, TypeScript, Go and curl."
"connect.own_code": "From your own code: the official OpenAI and Anthropic SDKs work as they are —\nthe <a href=\"/docs/clients\">client guide</a> shows how, in Python, TypeScript, Go and curl.",
"login.forgot": "Forgotten your password?",
"forgot.title": "Forgotten password · llmgw",
"forgot.heading": "Forgotten password",
"forgot.sub": "Give your account's address and a link to set a new password is sent to it.",
"forgot.submit": "Send the link",
"forgot.sent": "If an account signs in with that address, a link to set a new password is on its way. It works once, for an hour.",
"forgot.no_mail": "This gateway sends no email. Ask an operator for a link to set a new password: they make one with <code>llmgw users reset-link</code>.",
"reset.title": "Set a new password · llmgw",
"reset.heading": "Set a new password",
"reset.password": "New password",
"reset.confirm": "The same again",
"reset.submit": "Set the password",
"reset.done": "Your password is set. Every session you had open was closed; sign in with the new one.",
"reset.bad": "This link does not work: it was used already, it expired, or a newer one was sent.",
"reset.err.mismatch": "the two passwords are not the same",
"reset.mail.subject": "Set a new password for llmgw",
"reset.mail.body": "Hello %s,\n\nA link to set a new password for your llmgw account was asked for:\n\n%s\n\nIt works once, for %d minutes. If you did not ask for it, ignore this message: your password stays as it is.\n"
}
+19 -2
View File
@@ -5,7 +5,7 @@
"signup.thanks": "Teşekkürler. Hesabınız onay bekliyor.",
"signup.no_email": "Bu ağ geçidi e-posta göndermez, dolayısıyla sizden bir e-posta gelmeyecek.\nErişim istediğiniz kişiye kaydolduğunuzu haber verin.",
"signup.sign_in_once_approved": "Onaylandıktan sonra <a href=\"/login\">giriş yapın</a>.",
"signup.already_had": "Bu adresle zaten bir hesap varsa yeni bir hesap açılmadı:\nmevcut hesapla <a href=\"/login\">giriş yapın</a>; parolasını unuttuysanız\nbir operatörden sıfırlamasını isteyin.",
"signup.already_had": "Bu adresle zaten bir hesap varsa yeni bir hesap açılmadı:\nmevcut hesapla <a href=\"/login\">giriş yapın</a> ya da parolasını unuttuysanız\n<a href=\"/forgot\">yeni şifre belirleyin</a>.",
"signup.email": "E-posta",
"signup.name": "Ad",
"signup.password": "Parola",
@@ -148,5 +148,22 @@
"profile.err.not_member": "o projenin üyesi değilsiniz",
"profile.err.wrong_password": "mevcut parolanız bu değil",
"profile.err.same_password": "bu zaten kullandığınız parola",
"connect.own_code": "Kendi kodunuzdan: resmî OpenAI ve Anthropic SDK'ları olduğu gibi çalışır;\nnasıl yapılacağını <a href=\"/docs/clients\">istemci kılavuzu</a> Python, TypeScript, Go ve curl ile gösteriyor."
"connect.own_code": "Kendi kodunuzdan: resmî OpenAI ve Anthropic SDK'ları olduğu gibi çalışır;\nnasıl yapılacağını <a href=\"/docs/clients\">istemci kılavuzu</a> Python, TypeScript, Go ve curl ile gösteriyor.",
"login.forgot": "Şifrenizi mi unuttunuz?",
"forgot.title": "Unutulan şifre · llmgw",
"forgot.heading": "Unutulan şifre",
"forgot.sub": "Hesabınızın adresini yazın; yeni şifre belirlemek için bir bağlantı o adrese gönderilir.",
"forgot.submit": "Bağlantıyı gönder",
"forgot.sent": "Bu adresle giriş yapan bir hesap varsa, yeni şifre belirleme bağlantısı yola çıktı. Bir kez ve bir saat boyunca geçerli.",
"forgot.no_mail": "Bu gateway e-posta göndermiyor. Yeni şifre bağlantısı için bir operatöre başvurun: <code>llmgw users reset-link</code> ile oluşturur.",
"reset.title": "Yeni şifre belirle · llmgw",
"reset.heading": "Yeni şifre belirle",
"reset.password": "Yeni şifre",
"reset.confirm": "Tekrar",
"reset.submit": "Şifreyi belirle",
"reset.done": "Şifreniz belirlendi. Açık olan tüm oturumlarınız kapatıldı; yeni şifreyle giriş yapın.",
"reset.bad": "Bu bağlantı çalışmıyor: daha önce kullanılmış, süresi dolmuş ya da daha yenisi gönderilmiş.",
"reset.err.mismatch": "iki şifre aynı değil",
"reset.mail.subject": "llmgw için yeni şifre belirleyin",
"reset.mail.body": "Merhaba %s,\n\nllmgw hesabınız için yeni şifre belirleme bağlantısı istendi:\n\n%s\n\nBir kez ve %d dakika boyunca geçerli. Siz istemediyseniz bu mesajı dikkate almayın: şifreniz değişmez.\n"
}
+144
View File
@@ -0,0 +1,144 @@
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)
}
+132
View File
@@ -0,0 +1,132 @@
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")
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ func TestASignupSaysWhatToDoIfTheAddressHadAnAccount(t *testing.T) {
// is say what to do in either case.
body := signupBody(t, nil)
if !strings.Contains(body, "already had an account") || !strings.Contains(body, `href="/login"`) ||
!strings.Contains(body, "reset it") {
!strings.Contains(body, `href="/forgot"`) {
t.Errorf("the done page leaves someone who already had an account stuck: %s", body)
}
}
+40
View File
@@ -43,6 +43,7 @@
<button type="submit">{{t "login.submit"}}</button>
</form>
{{if .SSO}}<p><a class="btn" href="/login/oidc">{{t "login.with_sso" .SSO}}</a></p>{{end}}
{{if .Forgot}}<p class="note"><a href="/forgot">{{t "login.forgot"}}</a></p>{{end}}
{{if .SignupOpen}}<p class="note">{{th "login.no_account"}}</p>{{end}}
<footer><span>llmgw {{.Version}}</span><span><a href="/">{{t "common.about_gateway"}}</a></span>{{template "langs"}}</footer>
</div></body></html>{{end}}
@@ -214,3 +215,42 @@ document.addEventListener("click", (e) => {
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
{{define "forgot"}}<!doctype html>
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{t "forgot.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
</head><body><div class="wrap narrow">
<header><h1>{{t "forgot.heading"}}</h1></header>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if .Sent}}<p>{{t "forgot.sent"}}</p><p class="note"><a href="/login">{{t "login.submit"}}</a></p>
{{else if .NoMail}}<p>{{th "forgot.no_mail"}}</p>
{{else}}<p class="sub">{{t "forgot.sub"}}</p>
<form method="post" autocomplete="on">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>{{t "login.email"}}<input type="email" name="email" value="{{.Email}}" required autocomplete="email"></label>
<button type="submit">{{t "forgot.submit"}}</button>
</form>{{end}}
<footer><span>llmgw {{.Version}}</span><span><a href="/login">{{t "login.heading"}}</a></span>{{template "langs"}}</footer>
</div></body></html>{{end}}
{{define "reset"}}<!doctype html>
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{t "reset.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
</head><body><div class="wrap narrow">
<header><h1>{{t "reset.heading"}}</h1></header>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if .Done}}<p>{{t "reset.done"}}</p><p class="note"><a href="/login">{{t "login.submit"}}</a></p>
{{else if .Bad}}<p class="error">{{t "reset.bad"}}</p><p class="note"><a href="/forgot">{{t "forgot.heading"}}</a></p>
{{else}}
<form method="post" autocomplete="off">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="token" value="{{.Token}}">
<label>{{t "reset.password"}}<input type="password" name="password" required minlength="{{.MinPassword}}" autocomplete="new-password"></label>
<label>{{t "reset.confirm"}}<input type="password" name="confirm" required minlength="{{.MinPassword}}" autocomplete="new-password"></label>
<p class="note">{{t "signup.password_rule" .MinPassword}}</p>
<button type="submit">{{t "reset.submit"}}</button>
</form>{{end}}
<footer><span>llmgw {{.Version}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}