Merge pull request 'Add self-service sign-up and invite links' (#19) from signup-invites into main
Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user