Merge pull request 'Add self-service sign-up and invite links' (#19) from signup-invites into main
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s

Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
2026-09-21 08:42:07 +00:00
7 changed files with 856 additions and 36 deletions
+25 -1
View File
@@ -550,6 +550,25 @@ granting the flag itself. Everybody else works incidents — acknowledging,
assigning, snoozing, resolving, noting — and manages their own account and assigning, snoozing, resolving, noting — and manages their own account and
nobody else's. An API key carries exactly the rights of the user it belongs to. nobody else's. An API key carries exactly the rights of the user it belongs to.
**Getting an account.** The first one comes from `/api/bootstrap`. After that
it depends on `signup_mode`, an administrator setting:
- `invite_only` (the default) — a team owner mints a link with
`POST /api/teams/{teamID}/invites`, and the person who opens it picks a
username and password and lands in that team with the role the link carries.
Links are single-use unless told otherwise, expire after seven days, and can
be revoked before that.
- `open` — anybody who can reach the server can create an account, and must
name a team, which they then own.
Invites are **links, not email**: this server has no SMTP, and adding it to send
one message would be a subsystem to run, secure and monitor. Send the link
however you already talk to the person.
A domain-restricted third mode was considered and dropped: with no email there
is nothing to verify an address against, so it would only check the domain of a
string somebody typed.
The first user, from `/api/bootstrap`, is an administrator. Users created The first user, from `/api/bootstrap`, is an administrator. Users created
afterwards are not, until an administrator says so. An install always keeps at afterwards are not, until an administrator says so. An install always keeps at
least one: the last administrator can be neither deleted nor demoted, and least one: the last administrator can be neither deleted nor demoted, and
@@ -584,6 +603,8 @@ on anybody's.
| Method | Path | Who | Description | | Method | Path | Who | Description |
|---|---|---|---| |---|---|---|---|
| `GET` | `/api/signup` | — | Whether sign-up is open, and whether `?invite=` is usable. No session needed: the caller has no account yet |
| `POST` | `/api/signup` | — | Create an account `{"username","email","password","invite"?,"team_name"?}` and sign in. `403` without a usable invite when the mode is invite-only |
| `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator | | `POST` | `/api/bootstrap` | — | Create first user + API key `{"username","email","password"?}` (only works on empty DB). The user is an administrator |
| `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people | | `GET` | `/api/users` | any | List users. Open to everybody: the queue's assignment control and the schedule both have to name people |
| `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator | | `POST` | `/api/users` | **admin** | Create user `{"username","email"}`. Not an administrator |
@@ -601,7 +622,7 @@ on anybody's.
|---|---|---|---| |---|---|---|---|
| `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" | | `GET` | `/api/admin/teams` | **admin** | Every team on the server, with its member and open-incident counts. `/api/teams` answers "what am I in"; this answers "what is there" |
| `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials | | `GET` | `/api/admin/settings` | **admin** | The editable settings with their bounds, plus the environment-configured ones, read-only. Never credentials |
| `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`. `400` for an unknown key or a value outside its bounds | | `PUT` | `/api/admin/settings` | **admin** | Change one or more `{"key": seconds}`, or `{"signup_mode": "open"\|"invite_only"}`. `400` for an unknown key or a value outside its bounds |
### Alert ingestion ### Alert ingestion
@@ -632,6 +653,9 @@ and was removed in v0.13.0 once senders had moved onto keys.
| `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys | | `GET` | `/api/teams/{teamID}/integrations` | member | List integrations. Never returns keys |
| `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once | | `POST` | `/api/teams/{teamID}/integrations` | **owner** | Mint an integration `{"name","kind"}` — key and URL shown once |
| `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration | | `DELETE` | `/api/teams/{teamID}/integrations/{integrationID}` | **owner** | Revoke an integration |
| `GET` | `/api/teams/{teamID}/invites` | **owner** | The team's invite links, with their uses and expiry. Never the tokens |
| `POST` | `/api/teams/{teamID}/invites` | **owner** | Mint one `{"role","max_uses"}` — the full URL is returned once |
| `DELETE` | `/api/teams/{teamID}/invites/{inviteID}` | **owner** | Revoke a link before it expires |
| `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none | | `GET` | `/api/teams/{teamID}/escalation` | member | The team's [escalation ladder](#escalation) `{repeat_count, fallback_topic, levels[]}`. Empty levels means the team has none |
| `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it | | `PUT` | `/api/teams/{teamID}/escalation` | **owner** | Replace it wholesale. `400` for a level with no targets or no timeout — a rung that pages nobody is a silence with a number on it |
| `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` | | `GET` | `/api/teams/{teamID}/deadman` | member | The team's [dead man's switch](#dead-mans-switch) configuration `{matchers, timeout_seconds, severity}` |
+29 -20
View File
@@ -139,6 +139,34 @@ func hashPassword(pw string) (string, error) {
return string(h), err return string(h), err
} }
// startSession mints a session and sets the cookie. Shared by login and
// sign-up: somebody who has just chosen a password is signed in, rather than
// being sent to a form to type the same credential again.
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
raw, tokenHash, err := randomToken()
if err != nil {
return err
}
now := time.Now()
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
return nil
}
// handleLogin exchanges a username and password for a session cookie. // handleLogin exchanges a username and password for a session cookie.
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc { func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
@@ -182,29 +210,10 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
} }
limiter.clear(userKey) limiter.clear(userKey)
raw, tokenHash, err := randomToken() if err := startSession(w, r, db, userID, publicURL); err != nil {
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
now := time.Now()
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(sessionTTL).Unix(), r.UserAgent()); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
user, err := fetchUser(r.Context(), db, userID) user, err := fetchUser(r.Context(), db, userID)
if err != nil { if err != nil {
+19 -1
View File
@@ -15,6 +15,12 @@ import (
// notify disables notifications. Dead man's switches are per team and read from // notify disables notifications. Dead man's switches are per team and read from
// the database, so nothing about them is wired in here. // the database, so nothing about them is wired in here.
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler { func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler {
// One limiter each, both process-wide for the life of the router: login
// counts failed passwords, sign-up counts account creation, and mixing the
// two would let a burst of sign-ups lock somebody out of logging in.
loginLimit := newLoginLimiter()
signupLimiter := newLoginLimiter()
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
@@ -39,9 +45,16 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
// JSON 404 every unknown /api path gets. // JSON 404 every unknown /api path gets.
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify)) r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
// Signing up. Both are unauthenticated by necessity: the caller has no
// account yet. The info endpoint says whether the door is open and whether
// an invite link is good, so the form can say so before somebody picks a
// password.
r.Get("/api/signup", handleSignupInfo(db))
r.Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
// Signing in to the web UI. Login trades a password for a session cookie, // Signing in to the web UI. Login trades a password for a session cookie,
// which AuthMiddleware accepts in place of an API key. // which AuthMiddleware accepts in place of an API key.
r.Post("/api/login", handleLogin(db, newLoginLimiter(), notify.PublicURL)) r.Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
r.Post("/api/logout", handleLogout(db, notify.PublicURL)) r.Post("/api/logout", handleLogout(db, notify.PublicURL))
// All other /api routes require a valid API key. // All other /api routes require a valid API key.
@@ -109,6 +122,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config) http.Handler
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db)) r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db)) r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
// Invite links into this team.
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
// A team's escalation ladder: who is paged when nobody answers. // A team's escalation ladder: who is paged when nobody answers.
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db)) r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db))
r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db)) r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db))
+52 -14
View File
@@ -90,6 +90,16 @@ func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
type settingsResponse struct { type settingsResponse struct {
Editable map[string]settingValue `json:"editable"` Editable map[string]settingValue `json:"editable"`
FromEnv map[string]string `json:"from_env"` FromEnv map[string]string `json:"from_env"`
// Choices are settings that are a word from a fixed list rather than a
// duration. One so far: who may create an account.
Choices map[string]choiceValue `json:"choices"`
}
type choiceValue struct {
Value string `json:"value"`
Options []string `json:"options"`
Description string `json:"description"`
} }
type settingValue struct { type settingValue struct {
@@ -104,6 +114,14 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
out := settingsResponse{ out := settingsResponse{
Editable: map[string]settingValue{}, Editable: map[string]settingValue{},
Choices: map[string]choiceValue{
SettingSignupMode: {
Value: signupMode(r.Context(), db),
Options: []string{SignupInviteOnly, SignupOpen},
Description: "who may create an account: invite_only means a link from a team owner, " +
"open means anybody who can reach this server",
},
},
FromEnv: map[string]string{ FromEnv: map[string]string{
// Never the ntfy token or the DSN: both are credentials, and an // Never the ntfy token or the DSN: both are credentials, and an
// admin page that renders them turns a browser tab into a place // admin page that renders them turns a browser tab into a place
@@ -137,7 +155,7 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
// sit in the table looking like configuration and doing nothing. // sit in the table looking like configuration and doing nothing.
func handleSetSettings(db *sql.DB) http.HandlerFunc { func handleSetSettings(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var req map[string]int64 var req map[string]any
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body")) respond(w, http.StatusBadRequest, errResp("invalid request body"))
return return
@@ -147,17 +165,37 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
return return
} }
for key, secs := range req { // Validate everything before writing anything: a request that sets two
b, known := settingBounds[key] // settings and gets one wrong should change neither.
if !known { values := map[string]string{}
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key)) for key, raw := range req {
return switch key {
} case SettingSignupMode:
d := time.Duration(secs) * time.Second mode, _ := raw.(string)
if d < b.min || d > b.max { if mode != SignupOpen && mode != SignupInviteOnly {
respond(w, http.StatusBadRequest, errResp( respond(w, http.StatusBadRequest,
key+" must be between "+b.min.String()+" and "+b.max.String())) errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
return return
}
values[key] = mode
default:
b, known := settingBounds[key]
if !known {
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
return
}
secs, ok := raw.(float64) // JSON numbers decode as float64
if !ok {
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
return
}
d := time.Duration(int64(secs)) * time.Second
if d < b.min || d > b.max {
respond(w, http.StatusBadRequest, errResp(
key+" must be between "+b.min.String()+" and "+b.max.String()))
return
}
values[key] = strconv.FormatInt(int64(secs), 10)
} }
} }
@@ -168,13 +206,13 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc {
} }
defer tx.Rollback() //nolint:errcheck defer tx.Rollback() //nolint:errcheck
for key, secs := range req { for key, value := range values {
if _, err := tx.ExecContext(r.Context(), ` if _, err := tx.ExecContext(r.Context(), `
INSERT INTO settings (key, value, updated_at) INSERT INTO settings (key, value, updated_at)
VALUES ($1, $2, `+nowEpoch+`) VALUES ($1, $2, `+nowEpoch+`)
ON CONFLICT (key) DO UPDATE SET ON CONFLICT (key) DO UPDATE SET
value = excluded.value, updated_at = excluded.updated_at`, value = excluded.value, updated_at = excluded.updated_at`,
key, strconv.FormatInt(secs, 10)); err != nil { key, value); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
+412
View File
@@ -0,0 +1,412 @@
package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// SettingSignupMode says who may create an account. It lives in the settings
// table with the other behaviour settings, so an administrator changes it in
// the admin page rather than in a chart.
//
// Two modes, not three. A domain-restricted mode was considered and dropped:
// with no email in this server there is nothing to verify an address against,
// so it would check the domain of a string somebody typed — a speed bump
// dressed as a control.
const (
SettingSignupMode = "signup_mode"
SignupInviteOnly = "invite_only"
SignupOpen = "open"
)
// defaultSignupMode is invite-only. An install that gets a public hostname
// before anybody has thought about sign-up should not be collecting accounts
// from the internet by default.
const defaultSignupMode = SignupInviteOnly
// inviteTTL is how long a new invite link lives. Long enough to send it and be
// read tomorrow, short enough that a link in an old chat log stops working.
const inviteTTL = 7 * 24 * time.Hour
// signupMode reads the current mode, falling back to invite-only for a missing
// or unrecognised value: the failure mode of a typo in this setting should be
// the closed door, not the open one.
func signupMode(ctx context.Context, db *sql.DB) string {
var raw string
if err := db.QueryRowContext(ctx,
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
return defaultSignupMode
}
if raw != SignupOpen && raw != SignupInviteOnly {
return defaultSignupMode
}
return raw
}
// handleSignupInfo tells the sign-up page what it may offer, without requiring
// a session: whether open sign-up is on, and whether the invite in the URL is
// any good. A bad invite is better reported before somebody picks a password.
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := map[string]any{"mode": signupMode(r.Context(), db)}
if token := r.URL.Query().Get("invite"); token != "" {
inv, err := loadInvite(r.Context(), db, token)
switch {
case err == nil:
out["invite_valid"] = true
out["invite_team"] = inv.teamName
default:
// Deliberately one answer for expired, revoked, used up and
// never existed. Telling a stranger which it was tells them
// something about links they do not hold.
out["invite_valid"] = false
}
}
respond(w, http.StatusOK, out)
}
}
type invite struct {
id int64
teamID int64
teamName string
role string
}
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
// it exists, has not been revoked, has not expired and has uses left.
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
var inv invite
err := q.QueryRowContext(ctx, `
SELECT i.id, i.team_id, t.name, i.role
FROM invites i
JOIN teams t ON t.id = i.team_id
WHERE i.token_hash = $1
AND i.revoked_at IS NULL
AND i.expires_at > `+nowEpoch+`
AND i.uses < i.max_uses`, hashToken(token)).
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
if errors.Is(err, sql.ErrNoRows) {
return invite{}, errInviteUnusable
}
return inv, err
}
var errInviteUnusable = errors.New("invite is not usable")
// handleSignup creates an account, and puts it somewhere.
//
// Rate-limited on the same limiter as login, by address: sign-up is the other
// unauthenticated endpoint that writes, and an open install without this is a
// way to fill somebody's user table.
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addr := clientAddr(r)
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
TeamName string `json:"team_name"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Username = strings.TrimSpace(req.Username)
req.Email = strings.TrimSpace(req.Email)
req.TeamName = strings.TrimSpace(req.TeamName)
if req.Username == "" || req.Email == "" {
respond(w, http.StatusBadRequest, errResp("username and email are required"))
return
}
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
mode := signupMode(r.Context(), db)
var inv invite
hasInvite := false
if req.Invite != "" {
var err error
inv, err = loadInvite(r.Context(), db, req.Invite)
if err != nil {
limiter.fail("signup:" + addr)
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return
}
hasInvite = true
}
if !hasInvite && mode != SignupOpen {
// No invite and the door is shut. Not 404: the endpoint exists and
// saying so is how somebody knows to ask for a link.
respond(w, http.StatusForbidden,
errResp("sign-up is invite-only on this server"))
return
}
if !hasInvite && req.TeamName == "" {
// Open sign-up with no team would create an account that sees an
// empty queue and can be paged by nobody.
respond(w, http.StatusBadRequest, errResp("team_name is required"))
return
}
hash, err := hashPassword(req.Password)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
var userID int64
var invitedVia *int64
if hasInvite {
invitedVia = &inv.id
}
if err := tx.QueryRowContext(r.Context(), `
INSERT INTO users (username, email, password_hash, invited_via)
VALUES ($1, $2, $3, $4) RETURNING id`,
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("username or email already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
teamID, role := inv.teamID, inv.role
if !hasInvite {
// Open sign-up makes a team, and its creator owns it.
if err := tx.QueryRowContext(r.Context(),
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
role = models.RoleOwner
}
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
teamID, userID, role); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if hasInvite {
// Counted inside the transaction, so two people redeeming the last
// use of a link at once cannot both get in.
res, err := tx.ExecContext(r.Context(),
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return
}
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
// Signed in immediately: the alternative is a form that says "now go
// and log in", which is the same credential typed twice.
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, _ := fetchUser(r.Context(), db, userID)
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
}
}
// maxSignupsPerAddr is looser than the login limit: several people joining from
// one office share an address, and the thing being limited is account creation
// rather than password guessing.
const maxSignupsPerAddr = 10
// ---------------------------------------------------------------------------
// Invites
// ---------------------------------------------------------------------------
type inviteJSON struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int64 `json:"max_uses"`
Uses int64 `json:"uses"`
Revoked bool `json:"revoked"`
// URL is the whole link, returned once when the invite is created. Like an
// integration key, only its hash is stored.
URL string `json:"url,omitempty"`
}
func handleListInvites(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
rows, err := db.QueryContext(r.Context(), `
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
FROM invites
WHERE team_id = $1
ORDER BY id DESC`, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
out := []inviteJSON{}
for rows.Next() {
var i inviteJSON
var created, expires int64
var revoked *int64
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
&i.MaxUses, &i.Uses, &revoked); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
i.Revoked = revoked != nil
out = append(out, i)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, out)
}
}
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
// a team's configuration: deciding who joins is configuring the team.
func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
var req struct {
Role string `json:"role"`
MaxUses int64 `json:"max_uses"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Role == "" {
req.Role = models.RoleMember
}
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
return
}
if req.MaxUses == 0 {
req.MaxUses = 1
}
if req.MaxUses < 1 || req.MaxUses > 100 {
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
return
}
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
caller, _ := userFromContext(r.Context())
expires := time.Now().Add(inviteTTL)
var out inviteJSON
var created, expiresAt int64
if err := db.QueryRowContext(r.Context(), `
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
hash, teamID, req.Role, caller.ID, expires.Unix(), req.MaxUses).
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
out.CreatedAt = time.Unix(created, 0).UTC()
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
respond(w, http.StatusCreated, out)
}
}
// handleRevokeInvite stops a link working without waiting for it to expire.
func handleRevokeInvite(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
teamID, ok := teamParam(w, r)
if !ok {
return
}
if !requireTeamOwner(w, r, teamID) {
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
return
}
res, err := db.ExecContext(r.Context(),
"UPDATE invites SET revoked_at = "+nowEpoch+
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusNotFound, errResp("not found"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+270
View File
@@ -0,0 +1,270 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/cookiejar"
"testing"
)
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
// and returns the response and a client holding whatever cookie came back.
func signup(t *testing.T, s *ts, body map[string]any) (*http.Response, *http.Client) {
t.Helper()
data, _ := json.Marshal(body)
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
req, _ := http.NewRequest(http.MethodPost, s.URL+"/api/signup", bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("signup: %v", err)
}
return resp, client
}
// invite mints a link into the default team and returns its raw token.
func invite(t *testing.T, s *ts, role string, maxUses int64) string {
t.Helper()
var out struct {
URL string `json:"url"`
}
decode(t, s.req(t, http.MethodPost, "/api/teams/"+defaultTeam+"/invites",
map[string]any{"role": role, "max_uses": maxUses}), &out)
if out.URL == "" {
t.Fatal("no invite URL returned")
}
// ...?invite=<token>
i := len(out.URL) - 1
for ; i >= 0 && out.URL[i] != '='; i-- {
}
return out.URL[i+1:]
}
func setSignupMode(t *testing.T, s *ts, mode string) {
t.Helper()
resp := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": mode})
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("set signup mode: %d", resp.StatusCode)
}
}
// The default is the closed door. An install that gets a public hostname before
// anybody has thought about sign-up should not be collecting accounts.
func TestSignup_InviteOnlyByDefault(t *testing.T) {
s := newTS(t)
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
if info["mode"] != "invite_only" {
t.Errorf("default sign-up mode is %v, want invite_only", info["mode"])
}
resp, _ := signup(t, s, map[string]any{
"username": "stranger", "email": "s@test.com", "password": "correct-horse-battery",
})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("sign-up without an invite: expected 403, got %d", resp.StatusCode)
}
}
// An invite carries the team and the role, so redeeming one lands somewhere
// usable rather than in an account that sees an empty queue.
func TestSignup_InviteCreatesAMemberOfThatTeam(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 1)
// The form checks the link before asking for a password.
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
if info["invite_valid"] != true {
t.Fatalf("a fresh invite should be valid: %v", info)
}
if info["invite_team"] != "Default" {
t.Errorf("the form should name the team: %v", info["invite_team"])
}
resp, client := signup(t, s, map[string]any{
"username": "newcomer", "email": "n@test.com",
"password": "correct-horse-battery", "invite": token,
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("redeeming an invite: %d", resp.StatusCode)
}
var me struct {
User struct {
ID int64 `json:"id"`
IsAdmin bool `json:"is_admin"`
} `json:"user"`
}
decode(t, resp, &me)
if me.User.IsAdmin {
t.Error("somebody who signs up must not be an administrator")
}
// Signed in already: the cookie came back with the response.
got, err := client.Get(s.URL + "/api/teams")
if err != nil {
t.Fatal(err)
}
teams := list(t, got)
if len(teams) != 1 || teams[0]["name"] != "Default" || teams[0]["role"] != "member" {
t.Errorf("expected membership of Default as member, got %v", teams)
}
}
// A single-use link is single-use, and the check is inside the transaction so
// two people redeeming the last use at once cannot both get in.
func TestSignup_InviteCannotBeUsedTwice(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 1)
first, _ := signup(t, s, map[string]any{
"username": "first", "email": "f@test.com",
"password": "correct-horse-battery", "invite": token,
})
first.Body.Close()
if first.StatusCode != http.StatusCreated {
t.Fatalf("first redemption: %d", first.StatusCode)
}
second, _ := signup(t, s, map[string]any{
"username": "second", "email": "s@test.com",
"password": "correct-horse-battery", "invite": token,
})
second.Body.Close()
if second.StatusCode != http.StatusForbidden {
t.Errorf("second redemption: expected 403, got %d", second.StatusCode)
}
// And the link reports itself unusable before anybody types a password.
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup?invite="+token, nil), &info)
if info["invite_valid"] != false {
t.Error("a used-up invite should report itself invalid")
}
}
// Revoking stops a link without waiting for it to expire.
func TestSignup_RevokedInviteStopsWorking(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 5)
invites := list(t, s.req(t, http.MethodGet, "/api/teams/"+defaultTeam+"/invites", nil))
if len(invites) != 1 {
t.Fatalf("expected one invite, got %d", len(invites))
}
id := int64(invites[0]["id"].(float64))
resp := s.req(t, http.MethodDelete, "/api/teams/"+defaultTeam+"/invites/"+id64(id), nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("revoke: %d", resp.StatusCode)
}
used, _ := signup(t, s, map[string]any{
"username": "late", "email": "l@test.com",
"password": "correct-horse-battery", "invite": token,
})
used.Body.Close()
if used.StatusCode != http.StatusForbidden {
t.Errorf("a revoked invite: expected 403, got %d", used.StatusCode)
}
}
// Open sign-up makes a team, because an account in no team sees an empty queue
// and can be paged by nobody.
func TestSignup_OpenModeMakesATeam(t *testing.T) {
s := newTS(t)
setSignupMode(t, s, "open")
missing, _ := signup(t, s, map[string]any{
"username": "solo", "email": "s@test.com", "password": "correct-horse-battery",
})
missing.Body.Close()
if missing.StatusCode != http.StatusBadRequest {
t.Errorf("open sign-up with no team name: expected 400, got %d", missing.StatusCode)
}
resp, client := signup(t, s, map[string]any{
"username": "solo", "email": "s@test.com",
"password": "correct-horse-battery", "team_name": "Solo",
})
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("open sign-up: %d", resp.StatusCode)
}
got, err := client.Get(s.URL + "/api/teams")
if err != nil {
t.Fatal(err)
}
teams := list(t, got)
if len(teams) != 1 || teams[0]["name"] != "Solo" || teams[0]["role"] != "owner" {
t.Errorf("the creator should own their new team, got %v", teams)
}
}
// Switching the mode is an administrator's decision, and it takes effect at
// once rather than at the next restart.
func TestSignup_ModeIsAnAdminSetting(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "plain")
resp := call(http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "open"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a member changing the mode: expected 403, got %d", resp.StatusCode)
}
bad := s.req(t, http.MethodPut, "/api/admin/settings", map[string]any{"signup_mode": "everybody"})
bad.Body.Close()
if bad.StatusCode != http.StatusBadRequest {
t.Errorf("an unknown mode: expected 400, got %d", bad.StatusCode)
}
setSignupMode(t, s, "open")
var info map[string]any
decode(t, s.req(t, http.MethodGet, "/api/signup", nil), &info)
if info["mode"] != "open" {
t.Errorf("the change should be visible at once, got %v", info["mode"])
}
}
// Minting a link is configuring the team, so it is an owner's job.
func TestSignup_InvitesAreOwnerOnly(t *testing.T) {
s := newTS(t)
_, call := member(t, s, "plain")
resp := call(http.MethodPost, "/api/teams/"+defaultTeam+"/invites", map[string]any{"role": "member"})
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("a member minting an invite: expected 403, got %d", resp.StatusCode)
}
}
// A password still has to be a password, and a taken username is still taken.
func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) {
s := newTS(t)
token := invite(t, s, "member", 5)
short, _ := signup(t, s, map[string]any{
"username": "shorty", "email": "sh@test.com", "password": "abc", "invite": token,
})
short.Body.Close()
if short.StatusCode != http.StatusBadRequest {
t.Errorf("a short password: expected 400, got %d", short.StatusCode)
}
taken, _ := signup(t, s, map[string]any{
"username": "admin", "email": "other@test.com",
"password": "correct-horse-battery", "invite": token,
})
taken.Body.Close()
if taken.StatusCode != http.StatusConflict {
t.Errorf("an existing username: expected 409, got %d", taken.StatusCode)
}
}
@@ -0,0 +1,49 @@
-- Self-service sign-up, and the invite links that make it useful.
--
-- Until now the only way to get an account was for somebody who already had one
-- to create it, and the login page told people to "ask an admin". That is a
-- workable arrangement for one operator and an impossible one for a team.
--
-- An invite is a link, not an email: this server has no SMTP and adding it to
-- send one message would be a new subsystem to run, secure and monitor. The
-- person inviting sends the link however they already talk to the person they
-- are inviting.
CREATE TABLE invites (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
-- SHA-256 of the raw token, like api_keys, the integration keys and the
-- acknowledgement tokens. A leaked database hands nobody an account.
token_hash TEXT NOT NULL UNIQUE,
-- Which team the invitee lands in, and as what. An invite always names a
-- team: an account in no team sees an empty queue and can be paged by
-- nobody, which is not a state to invite somebody into.
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
-- Invites expire. A link that works forever is a credential nobody
-- remembers issuing, sitting in a chat log.
expires_at BIGINT NOT NULL,
-- Single-use by default: max_uses 1. A team onboarding six people at once
-- can raise it rather than minting six links.
max_uses BIGINT NOT NULL DEFAULT 1 CHECK (max_uses > 0 AND max_uses <= 100),
uses BIGINT NOT NULL DEFAULT 0,
-- Revoked by hand, separately from expiry, so "this link is no longer
-- wanted" and "this link timed out" stay distinguishable in the listing.
revoked_at BIGINT
);
CREATE INDEX invites_team_idx ON invites(team_id);
-- Who redeemed which invite. Kept after the invite is gone — the answer to "how
-- did this account get here" should outlive the link that made it.
ALTER TABLE users ADD COLUMN invited_via BIGINT REFERENCES invites(id) ON DELETE SET NULL;
-- Where a person is in the first-run checklist, so it can be resumed and
-- dismissed rather than nagging forever. One row per user, created on demand.
ALTER TABLE users ADD COLUMN onboarding_dismissed_at BIGINT;