diff --git a/README.md b/README.md index 86ce339..123ee3b 100644 --- a/README.md +++ b/README.md @@ -550,6 +550,25 @@ granting the flag itself. Everybody else works incidents — acknowledging, 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. +**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 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 @@ -584,6 +603,8 @@ on anybody's. | 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 | | `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 | @@ -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/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 @@ -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 | | `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 | +| `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 | | `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}` | diff --git a/internal/api/auth.go b/internal/api/auth.go index ab5c037..e49824d 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -139,6 +139,34 @@ func hashPassword(pw string) (string, error) { 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. func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc { 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) - raw, tokenHash, err := randomToken() - if err != nil { + if err := startSession(w, r, db, userID, publicURL); err != nil { respond(w, http.StatusInternalServerError, errResp("internal error")) 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) if err != nil { diff --git a/internal/api/router.go b/internal/api/router.go index 9fe79a4..33314cc 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -15,6 +15,12 @@ import ( // notify disables notifications. Dead man's switches are per team and read from // the database, so nothing about them is wired in here. 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.Use(middleware.Logger) 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. 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, // 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)) // 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.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. r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db)) r.Put("/api/teams/{teamID}/escalation", handleSetEscalation(db)) diff --git a/internal/api/settings.go b/internal/api/settings.go index fee8b36..c263506 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -90,6 +90,16 @@ func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error { type settingsResponse struct { Editable map[string]settingValue `json:"editable"` 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 { @@ -104,6 +114,14 @@ func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { out := settingsResponse{ 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{ // Never the ntfy token or the DSN: both are credentials, and an // 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. func handleSetSettings(db *sql.DB) http.HandlerFunc { 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 { respond(w, http.StatusBadRequest, errResp("invalid request body")) return @@ -147,17 +165,37 @@ func handleSetSettings(db *sql.DB) http.HandlerFunc { return } - for key, secs := range req { - b, known := settingBounds[key] - if !known { - respond(w, http.StatusBadRequest, errResp("unknown setting: "+key)) - return - } - d := time.Duration(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 + // Validate everything before writing anything: a request that sets two + // settings and gets one wrong should change neither. + values := map[string]string{} + for key, raw := range req { + switch key { + case SettingSignupMode: + mode, _ := raw.(string) + if mode != SignupOpen && mode != SignupInviteOnly { + respond(w, http.StatusBadRequest, + errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen)) + 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 - for key, secs := range req { + for key, value := range values { if _, err := tx.ExecContext(r.Context(), ` INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, `+nowEpoch+`) ON CONFLICT (key) DO UPDATE SET 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")) return } diff --git a/internal/api/signup.go b/internal/api/signup.go new file mode 100644 index 0000000..1c750f3 --- /dev/null +++ b/internal/api/signup.go @@ -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) + } +} diff --git a/internal/api/signup_test.go b/internal/api/signup_test.go new file mode 100644 index 0000000..79a5d90 --- /dev/null +++ b/internal/api/signup_test.go @@ -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= + 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) + } +} diff --git a/internal/db/migrations/007_signup_invites.sql b/internal/db/migrations/007_signup_invites.sql new file mode 100644 index 0000000..c615353 --- /dev/null +++ b/internal/db/migrations/007_signup_invites.sql @@ -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;