d827ceedff
First half of #7. 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" -- workable for one operator, impossible for a team. Two modes, chosen by an administrator in the settings table: invite_only, which is the default, and open. A third domain-restricted mode was considered and dropped, because with no email in this server there is nothing to verify an address against and it would only check the domain of a string somebody typed. 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 from the internet, and the failure mode of a typo in the setting is invite_only rather than open. An invite is a link, not an email. Adding SMTP to send one message would be a subsystem to run, secure and monitor; the person inviting sends the link however they already talk to the person they are inviting. A link carries the team and the role, because an account in no team sees an empty queue and can be paged by nobody -- that is not a state to invite somebody into. Links are single-use by default, expire after seven days, and can be revoked before that: a link that works forever is a credential nobody remembers issuing, sitting in a chat log. The uses counter is incremented inside the sign-up transaction and guarded by `uses < max_uses`, so two people redeeming the last use at once cannot both get in. GET /api/signup reports the mode and whether a link is usable, so the form can say "this link has expired" before somebody picks a password rather than after. It gives 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. Sign-up signs you in. The alternative is a form that says "now go and log in", which is the same credential typed twice. login and signup now share startSession rather than each minting a cookie. Rate-limited per address on its own limiter, not login's: a burst of sign-ups must not lock somebody out of logging in. The settings table grew a second shape for this. It held only durations; signup_mode is a word from a fixed list, so the admin endpoint now validates everything before writing anything -- a request that sets two settings and gets one wrong changes neither. Still to come in #7: the sign-up and invite-redemption pages, the first-run checklist, and the in-app integration instructions. The schema carries onboarding_dismissed_at for the checklist already. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
413 lines
13 KiB
Go
413 lines
13 KiB
Go
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)
|
|
}
|
|
}
|