diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go new file mode 100644 index 0000000..c96dba1 --- /dev/null +++ b/internal/httpapi/auth_handlers.go @@ -0,0 +1,460 @@ +package httpapi + +import ( + "errors" + "net/http" + "net/mail" + "strings" + "time" + "unicode/utf8" + + "github.com/kristianwind/verdande/internal/auth" + "github.com/kristianwind/verdande/internal/store" +) + +// userJSON is what the API says about a person. It deliberately does not carry the +// password hash or the TOTP secret: those are not "fields that happen to be +// private", they are values that must not leave the process, and the way to +// guarantee that is for the response type not to have them. +type userJSON struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarColor string `json:"avatar_color"` + Timezone string `json:"timezone"` + Locale string `json:"locale"` + IsAdmin bool `json:"is_admin"` + TOTPEnabled bool `json:"totp_enabled"` + CreatedAt string `json:"created_at"` +} + +func toUserJSON(u *store.User) userJSON { + return userJSON{ + ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor, + Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin, + TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339), + } +} + +// --- sign in ----------------------------------------------------------------- + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type loginResponse struct { + User *userJSON `json:"user,omitempty"` + TOTPRequired bool `json:"totp_required"` +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + + user, err := s.db.UserByEmail(r.Context(), req.Email) + if err != nil || auth.VerifyPassword(user.PasswordHash, req.Password) != nil { + // One message for "no such account" and for "wrong password". Telling them + // apart turns the login form into a way to enumerate who has an account + // here, which for a private instance is most of what an attacker wants. + // + // The timing still differs — a missing user skips the Argon2 work — and + // closing that would mean hashing against a dummy on every miss. Not done: + // it costs 19 MiB per probe and hands over an easy way to exhaust memory. + writeError(w, http.StatusUnauthorized, CodeUnauthorized, + "that email address and password do not match an account") + return + } + + // Argon2 parameters get raised over time; a login is the only moment the + // plaintext exists and an upgrade is possible. + if auth.NeedsRehash(user.PasswordHash) { + if hash, err := auth.HashPassword(req.Password); err == nil { + if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, ""); err != nil { + s.log.Warn("rehash password", "err", err, "user", user.ID) + } + } + } + + s.loginLimiter.reset(clientIP(r)) + + // A session that still needs a code gets a short life of its own: long enough + // to fetch a phone, not long enough to be worth stealing. + ttl := s.cfg.SessionTTL + if user.TOTPEnabled { + ttl = 10 * time.Minute + } + token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), ttl, user.TOTPEnabled) + if err != nil { + s.internal(w, "create session", err) + return + } + s.setSessionCookie(w, token, ttl) + + if user.TOTPEnabled { + writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true}) + return + } + uj := toUserJSON(user) + writeJSON(w, http.StatusOK, loginResponse{User: &uj}) +} + +type totpRequest struct { + Code string `json:"code"` +} + +// handleLoginTOTP completes a two-step login. It runs behind requirePendingSession, +// so it is reachable only by a session that has already passed the password step. +func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) { + var req totpRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + session := sessionFrom(r.Context()) + + err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now()) + if err != nil { + // A recovery code is accepted in the same field. Making somebody find a + // different form while locked out of their account is a poor time to + // introduce a second concept. + used, rerr := s.db.UseRecoveryCode(r.Context(), user.ID, req.Code) + if rerr != nil || !used { + writeError(w, http.StatusUnauthorized, CodeUnauthorized, + "that verification code is not valid") + return + } + s.log.Info("recovery code used", "user", user.ID) + } + + if err := s.db.PromoteSession(r.Context(), session.ID, s.cfg.SessionTTL); err != nil { + s.internal(w, "promote session", err) + return + } + // The cookie is reissued so its lifetime matches the session's new one. + if cookie, cerr := r.Cookie(s.cookieName()); cerr == nil { + s.setSessionCookie(w, cookie.Value, s.cfg.SessionTTL) + } + + uj := toUserJSON(user) + writeJSON(w, http.StatusOK, loginResponse{User: &uj}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if session := sessionFrom(r.Context()); session != nil { + if err := s.db.DeleteSession(r.Context(), session.ID); err != nil { + s.log.Warn("delete session", "err", err) + } + } + s.clearSessionCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, toUserJSON(userFrom(r.Context()))) +} + +// --- creating accounts -------------------------------------------------------- + +type signupRequest struct { + Token string `json:"token"` + Name string `json:"name"` + Password string `json:"password"` + Timezone string `json:"timezone,omitempty"` + Locale string `json:"locale,omitempty"` +} + +// handleSignup creates an account from an invite. There is no open registration: +// the email address comes from the invite, not from the form, so an invite cannot +// be redirected to somebody else. +func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { + var req signupRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + + invite, err := s.db.InviteByToken(r.Context(), req.Token) + if err != nil { + writeError(w, http.StatusBadRequest, CodeBadRequest, + "this invite link is not valid or has expired") + return + } + + fields := map[string]string{} + name := strings.TrimSpace(req.Name) + if name == "" { + fields["name"] = "required" + } + if msg := checkPassword(req.Password); msg != "" { + fields["password"] = msg + } + if len(fields) > 0 { + writeFieldErrors(w, fields) + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + s.internal(w, "hash password", err) + return + } + + user := &store.User{ + Email: invite.Email, Name: name, PasswordHash: hash, + Timezone: req.Timezone, Locale: req.Locale, + } + if err := s.db.CreateUser(r.Context(), user, inboxName(req.Locale)); err != nil { + if errors.Is(err, store.ErrEmailInUse) { + writeError(w, http.StatusConflict, CodeConflict, + "an account already exists for that email address") + return + } + s.internal(w, "create user", err) + return + } + + // Accepting the invite is what adds the membership and burns the token, in one + // transaction: a signup that half-succeeded would leave a usable invite behind. + if err := s.db.AcceptInvite(r.Context(), invite.ID, user.ID); err != nil { + s.internal(w, "accept invite", err) + return + } + + s.startSession(w, r, user) +} + +type bootstrapRequest struct { + Email string `json:"email"` + Name string `json:"name"` + Password string `json:"password"` + Timezone string `json:"timezone,omitempty"` + Locale string `json:"locale,omitempty"` +} + +// handleBootstrap creates the first administrator, and only ever the first: the +// endpoint refuses once any account exists. That is what makes it safe to leave +// unauthenticated — it has to be, because there is nobody to authenticate as yet. +// +// The race matters. Two requests arriving together must not both see an empty +// database, so the check and the insert happen in one transaction inside the store. +func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) { + var req bootstrapRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + + fields := map[string]string{} + if _, err := mail.ParseAddress(req.Email); err != nil { + fields["email"] = "must be an email address" + } + if strings.TrimSpace(req.Name) == "" { + fields["name"] = "required" + } + if msg := checkPassword(req.Password); msg != "" { + fields["password"] = msg + } + if len(fields) > 0 { + writeFieldErrors(w, fields) + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + s.internal(w, "hash password", err) + return + } + + user := &store.User{ + Email: req.Email, Name: strings.TrimSpace(req.Name), PasswordHash: hash, + IsAdmin: true, Timezone: req.Timezone, Locale: req.Locale, + } + if err := s.db.CreateFirstAdmin(r.Context(), user, inboxName(req.Locale)); err != nil { + if errors.Is(err, store.ErrAlreadySetUp) { + writeError(w, http.StatusConflict, CodeConflict, "this instance is already set up") + return + } + s.internal(w, "create first admin", err) + return + } + + s.log.Info("first administrator created", "user", user.ID, "email", user.Email) + s.startSession(w, r, user) +} + +// handleSetupState tells the sign-in page whether to offer setup or a login form. +// It reveals only whether any account exists, which anyone can determine anyway by +// looking at whether the setup form works. +func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) { + n, err := s.db.UserCount(r.Context()) + if err != nil { + s.internal(w, "count users", err) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0}) +} + +func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) { + token, _, err := s.db.CreateSession(r.Context(), user.ID, r.UserAgent(), clientIP(r), s.cfg.SessionTTL, false) + if err != nil { + s.internal(w, "create session", err) + return + } + s.setSessionCookie(w, token, s.cfg.SessionTTL) + uj := toUserJSON(user) + writeJSON(w, http.StatusCreated, loginResponse{User: &uj}) +} + +// --- password reset ----------------------------------------------------------- + +type forgotRequest struct { + Email string `json:"email"` +} + +// handleForgotPassword always answers the same way. +// +// Saying "no account with that address" would turn this into an account-existence +// oracle that needs no password at all. The cost is that somebody who mistypes +// their address waits for an email that never comes, which is the better failure. +func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { + var req forgotRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + + user, err := s.db.UserByEmail(r.Context(), req.Email) + if err == nil { + token, err := s.db.CreatePasswordReset(r.Context(), user.ID, s.cfg.ResetTTL) + if err != nil { + s.internal(w, "create password reset", err) + return + } + link := s.cfg.BaseURL + "/reset?token=" + token + if err := s.mail.SendPasswordReset(r.Context(), user.Email, user.Name, link, s.cfg.ResetTTL); err != nil { + s.log.Error("send password reset", "err", err, "user", user.ID) + } + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "if that address has an account, a reset link is on its way", + }) +} + +type resetRequest struct { + Token string `json:"token"` + Password string `json:"password"` +} + +func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { + var req resetRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + if msg := checkPassword(req.Password); msg != "" { + writeFieldErrors(w, map[string]string{"password": msg}) + return + } + + userID, err := s.db.UsePasswordReset(r.Context(), req.Token) + if err != nil { + writeError(w, http.StatusBadRequest, CodeBadRequest, + "this reset link is not valid or has expired") + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + s.internal(w, "hash password", err) + return + } + // Every session ends: a password reset is what somebody does when they think + // another person has been in their account. + if err := s.db.UpdatePasswordHash(r.Context(), userID, hash, ""); err != nil { + s.internal(w, "update password", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type changePasswordRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + var req changePasswordRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + // The current password is required even though the caller is signed in: it is + // what stops an unattended laptop from becoming a permanent account takeover. + if err := auth.VerifyPassword(user.PasswordHash, req.CurrentPassword); err != nil { + writeFieldErrors(w, map[string]string{"current_password": "not correct"}) + return + } + if msg := checkPassword(req.NewPassword); msg != "" { + writeFieldErrors(w, map[string]string{"new_password": msg}) + return + } + + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + s.internal(w, "hash password", err) + return + } + keep := "" + if session := sessionFrom(r.Context()); session != nil { + keep = session.ID + } + if err := s.db.UpdatePasswordHash(r.Context(), user.ID, hash, keep); err != nil { + s.internal(w, "update password", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- validation --------------------------------------------------------------- + +// Password rules, and only these. +// +// A length floor and nothing else is deliberate. Composition rules — a digit, a +// symbol, a capital — measurably push people towards "Password1!" and are no longer +// recommended by NIST or OWASP. Length is what actually helps, and a passphrase +// must not be rejected for lacking punctuation. +const ( + minPasswordLength = 10 + maxPasswordLength = 1024 // Argon2 will hash anything; this bounds the work +) + +func checkPassword(p string) string { + switch { + case p == "": + return "required" + case utf8.RuneCountInString(p) < minPasswordLength: + return "must be at least 10 characters" + case len(p) > maxPasswordLength: + return "is too long" + } + return "" +} + +// inboxName is the one string that has to be right at account creation, before the +// user has ever seen a settings page to state a preference on. +func inboxName(locale string) string { + if strings.HasPrefix(strings.ToLower(locale), "en") { + return "Inbox" + } + return "Indbakke" +} + +// internal logs the real error and tells the client nothing about it. The detail +// belongs in the log, where the operator can see it; in the response it would be a +// description of the server's internals handed to whoever asked. +func (s *Server) internal(w http.ResponseWriter, what string, err error) { + s.log.Error(what, "err", err) + writeError(w, http.StatusInternalServerError, CodeInternal, "something went wrong") +} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go new file mode 100644 index 0000000..1dceaab --- /dev/null +++ b/internal/httpapi/auth_test.go @@ -0,0 +1,656 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" + + "github.com/kristianwind/verdande/internal/config" + "github.com/kristianwind/verdande/internal/store" +) + +// testServer runs the real router against a real database over a real listener. +// Auth is mostly cookies, status codes and middleware ordering — none of which a +// unit test of the handler functions would exercise. +type testServer struct { + *httptest.Server + db *store.DB + client *http.Client +} + +func newTestServer(t *testing.T) *testServer { + t.Helper() + + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + cfg := &config.Config{ + BaseURL: "http://127.0.0.1", + DataDir: t.TempDir(), + SessionTTL: 24 * time.Hour, + InviteTTL: 7 * 24 * time.Hour, + ResetTTL: time.Hour, + Dev: true, + } + // Discard: a passing test should print nothing, and these handlers log freely. + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + + srv := httptest.NewServer(New(cfg, db, log, nil)) + t.Cleanup(srv.Close) + + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return &testServer{ + Server: srv, + db: db, + // The cookie jar is what makes this a session test rather than a + // sequence of unrelated requests. + client: &http.Client{Jar: jar, Timeout: 10 * time.Second}, + } +} + +func (ts *testServer) do(t *testing.T, method, path string, body any) (*http.Response, map[string]any) { + t.Helper() + + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, ts.URL+path, reader) + if err != nil { + t.Fatalf("build request: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + // Same-origin, as a browser would report for the app's own fetches. + req.Header.Set("Sec-Fetch-Site", "same-origin") + + resp, err := ts.client.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + t.Cleanup(func() { resp.Body.Close() }) + + var decoded map[string]any + raw, _ := io.ReadAll(resp.Body) + if len(raw) > 0 { + _ = json.Unmarshal(raw, &decoded) + } + return resp, decoded +} + +// bootstrap creates the first admin and leaves the client signed in. +func (ts *testServer) bootstrap(t *testing.T) { + t.Helper() + resp, _ := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{ + "email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("bootstrap: status %d", resp.StatusCode) + } +} + +func TestSetupCreatesTheFirstAdminOnlyOnce(t *testing.T) { + ts := newTestServer(t) + + resp, body := ts.do(t, "GET", "/api/v1/auth/setup", nil) + if resp.StatusCode != http.StatusOK || body["needs_setup"] != true { + t.Fatalf("a fresh instance should need setup: %d %v", resp.StatusCode, body) + } + + resp, body = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{ + "email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("setup: status %d, body %v", resp.StatusCode, body) + } + user, _ := body["user"].(map[string]any) + if user == nil || user["is_admin"] != true { + t.Errorf("the first account is not an admin: %v", body) + } + // The password hash must never appear in a response. + if _, leaked := user["password_hash"]; leaked { + t.Error("the response carries the password hash") + } + + // A second attempt must be refused, or anyone could add themselves as admin. + resp, _ = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{ + "email": "someone@else.dk", "name": "Someone", "password": "et andet kodeord", + }) + if resp.StatusCode != http.StatusConflict { + t.Errorf("second setup: status %d, want 409", resp.StatusCode) + } + + resp, body = ts.do(t, "GET", "/api/v1/auth/setup", nil) + if body["needs_setup"] != false { + t.Errorf("instance still reports needing setup: %v", body) + } +} + +func TestSetupCreatesAnInbox(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + if _, err := ts.db.InboxID(t.Context(), user.ID); err != nil { + t.Errorf("no Inbox was created for the first user: %v", err) + } +} + +func TestLoginAndLogout(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + // Signed in from bootstrap. + resp, body := ts.do(t, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusOK || body["email"] != "kristian@example.dk" { + t.Fatalf("me: %d %v", resp.StatusCode, body) + } + + resp, _ = ts.do(t, "POST", "/api/v1/auth/logout", nil) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("logout: status %d", resp.StatusCode) + } + resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("me after logout: status %d, want 401", resp.StatusCode) + } + + resp, body = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("login: %d %v", resp.StatusCode, body) + } + if body["totp_required"] != false { + t.Errorf("login asked for a code with 2FA off: %v", body) + } + + resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("me after login: status %d", resp.StatusCode) + } +} + +// The address is normalised, so one account cannot be reached under two spellings +// and, worse, a second account cannot be created that shadows the first. +func TestLoginIgnoresEmailCaseAndSpace(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ts.do(t, "POST", "/api/v1/auth/logout", nil) + + for _, email := range []string{ + "kristian@example.dk", "KRISTIAN@example.dk", " Kristian@Example.DK ", + } { + resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": email, "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusOK { + t.Errorf("login as %q: status %d", email, resp.StatusCode) + } + ts.do(t, "POST", "/api/v1/auth/logout", nil) + } +} + +// A wrong password and an address with no account must be indistinguishable, or the +// login form becomes a way to find out who has an account here. +func TestLoginDoesNotRevealWhichAccountsExist(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ts.do(t, "POST", "/api/v1/auth/logout", nil) + + respWrong, bodyWrong := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "forkert kodeord", + }) + respMissing, bodyMissing := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "findes-ikke@example.dk", "password": "forkert kodeord", + }) + + if respWrong.StatusCode != respMissing.StatusCode { + t.Errorf("status differs: %d vs %d", respWrong.StatusCode, respMissing.StatusCode) + } + if bodyWrong["error"] != bodyMissing["error"] || bodyWrong["code"] != bodyMissing["code"] { + t.Errorf("message differs:\n wrong password: %v\n no account: %v", bodyWrong, bodyMissing) + } +} + +func TestSessionCookieIsHardened(t *testing.T) { + ts := newTestServer(t) + + // Read the cookie off the response, not out of the jar: Go's cookiejar keeps + // only name and value, so every attribute checked here would come back unset + // regardless of what the server actually sent. + resp, _ := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{ + "email": "kristian@example.dk", "name": "Kristian", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("setup: status %d", resp.StatusCode) + } + + var session *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == sessionCookieInsecure || c.Name == sessionCookieSecure { + session = c + } + } + if session == nil { + t.Fatal("no session cookie was set") + } + if !session.HttpOnly { + t.Error("session cookie is readable from JavaScript") + } + if session.SameSite != http.SameSiteLaxMode { + t.Errorf("SameSite = %v, want Lax", session.SameSite) + } + if session.Path != "/" { + t.Errorf("Path = %q, want /", session.Path) + } + + // The stored value must be a hash, never the cookie itself: a database dump + // must not contain anything that can be pasted into a browser. + var stored string + err := ts.db.QueryRow(`SELECT id FROM sessions LIMIT 1`).Scan(&stored) + if err != nil { + t.Fatal(err) + } + if stored == session.Value { + t.Error("the session token is stored verbatim; it must be hashed") + } +} + +func TestTOTPEnrolmentAndTwoStepLogin(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + // Begin: a secret exists, but 2FA is not on yet — closing the tab here must + // not lock the account. + resp, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("totp setup: %d %v", resp.StatusCode, body) + } + secret, _ := body["secret"].(string) + if secret == "" { + t.Fatal("no secret returned") + } + _, me := ts.do(t, "GET", "/api/v1/auth/me", nil) + if me["totp_enabled"] != false { + t.Error("2FA switched on before the code was confirmed") + } + + // Confirm with a real code. + resp, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{ + "code": codeFor(t, secret), + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("totp confirm: %d %v", resp.StatusCode, body) + } + codes, _ := body["recovery_codes"].([]any) + if len(codes) == 0 { + t.Fatal("no recovery codes were issued") + } + + // Now a login is two steps. + ts.do(t, "POST", "/api/v1/auth/logout", nil) + resp, body = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusOK || body["totp_required"] != true { + t.Fatalf("login should ask for a code: %d %v", resp.StatusCode, body) + } + + // The half-finished session must not reach anything. + resp, body = ts.do(t, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("a pending session reached /me: %d", resp.StatusCode) + } + if body["code"] != CodeTOTPRequired { + t.Errorf("code = %v, want %s", body["code"], CodeTOTPRequired) + } + + // A wrong code does not finish the login. + resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": "000000"}) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("a wrong code was accepted: %d", resp.StatusCode) + } + + // The right one does. + resp, body = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{ + "code": codeFor(t, secret), + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("totp login: %d %v", resp.StatusCode, body) + } + resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusOK { + t.Errorf("me after completing 2FA: %d", resp.StatusCode) + } +} + +// A recovery code is accepted in the same field as a TOTP code, and works once. +func TestRecoveryCodeCompletesLoginOnce(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, body := ts.do(t, "POST", "/api/v1/auth/totp/setup", nil) + secret := body["secret"].(string) + _, body = ts.do(t, "POST", "/api/v1/auth/totp/confirm", map[string]string{ + "code": codeFor(t, secret), + }) + raw := body["recovery_codes"].([]any) + recovery := raw[0].(string) + + ts.do(t, "POST", "/api/v1/auth/logout", nil) + ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "et langt kodeord", + }) + resp, _ := ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": recovery}) + if resp.StatusCode != http.StatusOK { + t.Fatalf("recovery code rejected: %d", resp.StatusCode) + } + + // The same code must not work a second time. + ts.do(t, "POST", "/api/v1/auth/logout", nil) + ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "et langt kodeord", + }) + resp, _ = ts.do(t, "POST", "/api/v1/auth/login/totp", map[string]string{"code": recovery}) + if resp.StatusCode == http.StatusOK { + t.Error("a recovery code worked twice") + } +} + +func TestChangePasswordRequiresTheCurrentOne(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, _ := ts.do(t, "POST", "/api/v1/auth/password/change", map[string]string{ + "current_password": "det forkerte", "new_password": "et helt nyt kodeord", + }) + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("wrong current password: status %d, want 422", resp.StatusCode) + } + + resp, _ = ts.do(t, "POST", "/api/v1/auth/password/change", map[string]string{ + "current_password": "et langt kodeord", "new_password": "et helt nyt kodeord", + }) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("change password: status %d", resp.StatusCode) + } + + // The session that made the change survives; the old password does not. + resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusOK { + t.Error("changing the password logged out the session that did it") + } + ts.do(t, "POST", "/api/v1/auth/logout", nil) + resp, _ = ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "et langt kodeord", + }) + if resp.StatusCode == http.StatusOK { + t.Error("the old password still works") + } +} + +func TestPasswordRulesRejectShortOnesOnly(t *testing.T) { + ts := newTestServer(t) + + resp, body := ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{ + "email": "k@example.dk", "name": "K", "password": "kort", + }) + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("short password: status %d", resp.StatusCode) + } + fields, _ := body["fields"].(map[string]any) + if fields["password"] == nil { + t.Errorf("the error does not name the password field: %v", body) + } + + // A long passphrase with no digits or symbols must be accepted: composition + // rules push people towards worse passwords, not better ones. + resp, _ = ts.do(t, "POST", "/api/v1/auth/setup", map[string]string{ + "email": "k@example.dk", "name": "K", "password": "syv heste løber over marken", + }) + if resp.StatusCode != http.StatusCreated { + t.Errorf("a long passphrase was rejected: status %d", resp.StatusCode) + } +} + +// Asking to reset a password must not reveal whether the address has an account. +func TestForgotPasswordAnswersTheSameEitherWay(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + respKnown, bodyKnown := ts.do(t, "POST", "/api/v1/auth/password/forgot", map[string]string{ + "email": "kristian@example.dk", + }) + respUnknown, bodyUnknown := ts.do(t, "POST", "/api/v1/auth/password/forgot", map[string]string{ + "email": "findes-ikke@example.dk", + }) + + if respKnown.StatusCode != respUnknown.StatusCode { + t.Errorf("status differs: %d vs %d", respKnown.StatusCode, respUnknown.StatusCode) + } + if bodyKnown["status"] != bodyUnknown["status"] { + t.Errorf("message differs: %v vs %v", bodyKnown, bodyUnknown) + } +} + +func TestPasswordResetEndsEverySession(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + token, err := ts.db.CreatePasswordReset(t.Context(), user.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + + resp, _ := ts.do(t, "POST", "/api/v1/auth/password/reset", map[string]string{ + "token": token, "password": "et helt nyt kodeord", + }) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("reset: status %d", resp.StatusCode) + } + + // Everything is signed out — a reset is what you do when you think somebody + // else has been in your account. + resp, _ = ts.do(t, "GET", "/api/v1/auth/me", nil) + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("a session survived a password reset: %d", resp.StatusCode) + } + + // The link works exactly once. + resp, _ = ts.do(t, "POST", "/api/v1/auth/password/reset", map[string]string{ + "token": token, "password": "endnu et kodeord", + }) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("a reset link worked twice: %d", resp.StatusCode) + } +} + +func TestSignupRequiresAnInvite(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, _ := ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{ + "token": "opfundet", "name": "Ubuden", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("signup without a valid invite: status %d, want 400", resp.StatusCode) + } + + admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") + if err != nil { + t.Fatal(err) + } + token, _, err := ts.db.CreateInvite(t.Context(), "ny@example.dk", "", store.RoleEditor, admin.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + + // The address comes from the invite, not from the form — so an invite cannot + // be redirected to somebody else by editing the request. + resp, body := ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{ + "token": token, "name": "Ny Bruger", "password": "et langt kodeord", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("signup: %d %v", resp.StatusCode, body) + } + user, _ := body["user"].(map[string]any) + if user["email"] != "ny@example.dk" { + t.Errorf("email = %v, want the invited address", user["email"]) + } + if user["is_admin"] != false { + t.Error("an invited user was made an admin") + } + + // And the invite is spent. + resp, _ = ts.do(t, "POST", "/api/v1/auth/signup", map[string]string{ + "token": token, "name": "Endnu En", "password": "et langt kodeord", + }) + if resp.StatusCode == http.StatusCreated { + t.Error("an invite link was used twice") + } +} + +func TestUnauthenticatedRequestsAreRefused(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ts.do(t, "POST", "/api/v1/auth/logout", nil) + + for _, tc := range []struct{ method, path string }{ + {"GET", "/api/v1/auth/me"}, + {"POST", "/api/v1/auth/totp/setup"}, + {"POST", "/api/v1/auth/password/change"}, + {"GET", "/api/v1/recovery-codes"}, + } { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + resp, _ := ts.do(t, tc.method, tc.path, map[string]string{}) + if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusNotFound { + t.Errorf("status %d, want 401", resp.StatusCode) + } + }) + } +} + +// A cross-site POST must not be able to act on somebody's session. +func TestCrossSiteWritesAreRefused(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + req, err := http.NewRequest("POST", ts.URL+"/api/v1/auth/logout", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Sec-Fetch-Site", "cross-site") + resp, err := ts.client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("cross-site POST: status %d, want 403", resp.StatusCode) + } + // And the session survived it. + if r, _ := ts.do(t, "GET", "/api/v1/auth/me", nil); r.StatusCode != http.StatusOK { + t.Error("the cross-site request logged the user out anyway") + } +} + +func TestMalformedRequestBodies(t *testing.T) { + ts := newTestServer(t) + + cases := []struct { + name, body string + contentType string + wantStatus int + }{ + {"empty", "", "application/json", http.StatusBadRequest}, + {"not json", "hejsa", "application/json", http.StatusBadRequest}, + {"wrong field type", `{"email": 42}`, "application/json", http.StatusBadRequest}, + {"unknown field", `{"email":"a@b.dk","sneaky":1}`, "application/json", http.StatusBadRequest}, + {"two objects", `{"email":"a@b.dk"}{"email":"c@d.dk"}`, "application/json", http.StatusBadRequest}, + {"wrong content type", `{"email":"a@b.dk"}`, "text/plain", http.StatusUnsupportedMediaType}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest("POST", ts.URL+"/api/v1/auth/login", bytes.NewReader([]byte(tc.body))) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", tc.contentType) + req.Header.Set("Sec-Fetch-Site", "same-origin") + resp, err := ts.client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != tc.wantStatus { + t.Errorf("status %d, want %d", resp.StatusCode, tc.wantStatus) + } + }) + } +} + +// Repeated password guesses must stop being answered. +func TestLoginIsRateLimited(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ts.do(t, "POST", "/api/v1/auth/logout", nil) + + var limited bool + for i := 0; i < 30; i++ { + resp, _ := ts.do(t, "POST", "/api/v1/auth/login", map[string]string{ + "email": "kristian@example.dk", "password": "gæt", + }) + if resp.StatusCode == http.StatusTooManyRequests { + if resp.Header.Get("Retry-After") == "" { + t.Error("a rate-limited response does not say when to retry") + } + limited = true + break + } + } + if !limited { + t.Error("thirty wrong passwords in a row were all answered") + } +} + +func codeFor(t *testing.T, secret string) string { + t.Helper() + code, err := totp.GenerateCodeCustom(secret, time.Now(), totp.ValidateOpts{ + Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, + }) + if err != nil { + t.Fatalf("generate code: %v", err) + } + return code +} diff --git a/internal/httpapi/errors.go b/internal/httpapi/errors.go new file mode 100644 index 0000000..4b5817a --- /dev/null +++ b/internal/httpapi/errors.go @@ -0,0 +1,120 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +// APIError is the one shape every failure takes. A client that has learned to read +// it once can read all of them. +// +// `code` is a stable machine-readable string; `error` is English prose for a +// developer reading a log. Neither is what the user sees — the frontend maps `code` +// to a Danish or English message, because the server has no business deciding which +// language somebody's browser is in. +type APIError struct { + Code string `json:"code"` + Message string `json:"error"` + Fields map[string]string `json:"fields,omitempty"` +} + +func (e APIError) Error() string { return e.Message } + +// The codes the API can return. Kept in one list so the frontend's mapping can be +// checked against it rather than discovered from whatever a handler happened to write. +const ( + CodeBadRequest = "bad_request" + CodeValidation = "validation_failed" + CodeUnauthorized = "unauthorized" + CodeTOTPRequired = "totp_required" + CodeForbidden = "forbidden" + CodeNotFound = "not_found" + CodeConflict = "conflict" + CodeRateLimited = "rate_limited" + CodeInternal = "internal_error" + CodePayloadTooLarge = "payload_too_large" +) + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, APIError{Code: code, Message: message}) +} + +func writeFieldErrors(w http.ResponseWriter, fields map[string]string) { + writeJSON(w, http.StatusUnprocessableEntity, APIError{ + Code: CodeValidation, + Message: "one or more fields are not valid", + Fields: fields, + }) +} + +// maxBodyBytes caps a JSON request body at 1 MiB. Task text and comments are small; +// anything larger is either a mistake or an attempt to make the server allocate. +// File uploads do not come through here. +const maxBodyBytes = 1 << 20 + +// decodeJSON reads a request body strictly: unknown fields are an error rather than +// being ignored. +// +// Silently dropping a field the client sent is the worst of the options — a typo in +// `priorty` becomes "the API accepted my request and ignored half of it", which is +// discovered much later and by the user rather than by the developer. +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error { + if ct := r.Header.Get("Content-Type"); ct != "" { + if mt := strings.TrimSpace(strings.Split(ct, ";")[0]); mt != "application/json" { + writeError(w, http.StatusUnsupportedMediaType, CodeBadRequest, + "request body must be application/json") + return errors.New("wrong content type") + } + } + + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + if err := dec.Decode(dst); err != nil { + var maxErr *http.MaxBytesError + switch { + case errors.As(err, &maxErr): + writeError(w, http.StatusRequestEntityTooLarge, CodePayloadTooLarge, + "request body is too large") + case errors.Is(err, io.EOF): + writeError(w, http.StatusBadRequest, CodeBadRequest, "request body is empty") + default: + writeError(w, http.StatusBadRequest, CodeBadRequest, jsonErrorMessage(err)) + } + return err + } + + // Exactly one JSON value, so a body with a second object appended cannot smuggle + // anything past a handler that only reads the first. + if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, CodeBadRequest, + "request body must contain a single JSON object") + return errors.New("trailing data") + } + return nil +} + +// jsonErrorMessage turns the standard library's decoder errors into something that +// names the offending field, since "json: cannot unmarshal string into Go struct +// field taskRequest.priority of type int" is not a sentence to put in an API. +func jsonErrorMessage(err error) string { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + if typeErr.Field != "" { + return "field " + typeErr.Field + " has the wrong type" + } + return "a field has the wrong type" + } + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + return "request body is not valid JSON" + } + if strings.HasPrefix(err.Error(), "json: unknown field ") { + return "unknown field " + strings.TrimPrefix(err.Error(), "json: unknown field ") + } + return "request body could not be read" +} diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go new file mode 100644 index 0000000..5a7e9ae --- /dev/null +++ b/internal/httpapi/middleware.go @@ -0,0 +1,208 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/kristianwind/verdande/internal/auth" + "github.com/kristianwind/verdande/internal/store" +) + +type ctxKey int + +const ( + ctxUser ctxKey = iota + ctxSession +) + +// sessionCookie is prefixed with __Host- when served over HTTPS. That prefix is +// enforced by the browser: it refuses the cookie unless it is Secure, path=/ and +// has no Domain — which means a subdomain cannot set a cookie that overwrites this +// one. Over plain HTTP the prefix is invalid, so a local dev instance uses the +// bare name. +const ( + sessionCookieSecure = "__Host-verdande_session" + sessionCookieInsecure = "verdande_session" +) + +func (s *Server) cookieName() string { + if s.secureCookies() { + return sessionCookieSecure + } + return sessionCookieInsecure +} + +func (s *Server) secureCookies() bool { + return strings.HasPrefix(s.cfg.BaseURL, "https://") +} + +func (s *Server) setSessionCookie(w http.ResponseWriter, token string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: s.cookieName(), + Value: token, + Path: "/", + // httpOnly: the frontend never reads this, so a cross-site script that + // gets to run cannot exfiltrate the session. + HttpOnly: true, + Secure: s.secureCookies(), + // Lax rather than Strict: Strict would mean following an invite link from + // an email lands you logged out, which looks exactly like a broken invite. + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(ttl), + MaxAge: int(ttl.Seconds()), + }) +} + +func (s *Server) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: s.cookieName(), + Value: "", + Path: "/", + HttpOnly: true, + Secure: s.secureCookies(), + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// requireAuth rejects anything without a complete login. +// +// A session still waiting on its TOTP code is refused here with totp_required, so +// the two-step login cannot be skipped by simply calling the endpoint you wanted. +func (s *Server) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, session, err := s.authenticate(r) + if err != nil { + writeError(w, http.StatusUnauthorized, CodeUnauthorized, "not signed in") + return + } + if session != nil && session.PendingTOTP { + writeError(w, http.StatusUnauthorized, CodeTOTPRequired, + "this login still needs its verification code") + return + } + ctx := context.WithValue(r.Context(), ctxUser, user) + if session != nil { + ctx = context.WithValue(ctx, ctxSession, session) + } + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// requirePendingSession guards the second step of a login, and nothing else. +func (s *Server) requirePendingSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, session, err := s.authenticate(r) + if err != nil || session == nil || !session.PendingTOTP { + writeError(w, http.StatusUnauthorized, CodeUnauthorized, "no login is in progress") + return + } + ctx := context.WithValue(r.Context(), ctxUser, user) + ctx = context.WithValue(ctx, ctxSession, session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *Server) requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if u := userFrom(r.Context()); u == nil || !u.IsAdmin { + writeError(w, http.StatusForbidden, CodeForbidden, "administrator access is required") + return + } + next.ServeHTTP(w, r) + }) +} + +// authenticate resolves a request to a user, by session cookie or by API token. +// +// Both are accepted everywhere. An API token belongs to a person and carries their +// permissions exactly — there is no separate service identity to reason about, and +// a script therefore cannot reach anything its owner could not. +func (s *Server) authenticate(r *http.Request) (*store.User, *store.Session, error) { + if token := bearerToken(r); token != "" && auth.IsAPIToken(token) { + user, err := s.db.UserByAPIToken(r.Context(), token) + if err != nil { + return nil, nil, err + } + return user, nil, nil + } + + cookie, err := r.Cookie(s.cookieName()) + if err != nil || cookie.Value == "" { + return nil, nil, errors.New("no credentials") + } + session, user, err := s.db.SessionByToken(r.Context(), cookie.Value) + if err != nil { + return nil, nil, err + } + return user, session, nil +} + +func bearerToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") { + return strings.TrimSpace(h[7:]) + } + return "" +} + +func userFrom(ctx context.Context) *store.User { + u, _ := ctx.Value(ctxUser).(*store.User) + return u +} + +func sessionFrom(ctx context.Context) *store.Session { + s, _ := ctx.Value(ctxSession).(*store.Session) + return s +} + +// requireCSRF blocks cross-site form posts. +// +// SameSite=Lax already stops the browser sending the cookie on a cross-site POST, +// which covers current browsers. This is the second lock: a state-changing request +// must either be same-origin by its own headers or carry an API token, which a +// cross-site form cannot set. Checked here rather than with a token in every form, +// because a token the frontend has to remember to include is a token it will one +// day forget. +func (s *Server) requireCSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + // A bearer token cannot be attached by a cross-site form or image tag. + if bearerToken(r) != "" { + next.ServeHTTP(w, r) + return + } + + // Sec-Fetch-Site is set by the browser and cannot be forged by page script. + switch r.Header.Get("Sec-Fetch-Site") { + case "same-origin", "same-site", "none": + next.ServeHTTP(w, r) + return + case "cross-site": + writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused") + return + } + + // Older clients send no Sec-Fetch-Site; fall back to comparing Origin. + if origin := r.Header.Get("Origin"); origin != "" && !s.isOwnOrigin(origin) { + writeError(w, http.StatusForbidden, CodeForbidden, "cross-site request refused") + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) isOwnOrigin(origin string) bool { + if strings.EqualFold(origin, s.cfg.BaseURL) { + return true + } + // A local dev frontend on another port talks to the API directly. + return s.cfg.Dev && strings.HasPrefix(origin, "http://localhost:") +} diff --git a/internal/httpapi/ratelimit.go b/internal/httpapi/ratelimit.go new file mode 100644 index 0000000..8851fcb --- /dev/null +++ b/internal/httpapi/ratelimit.go @@ -0,0 +1,121 @@ +package httpapi + +import ( + "net" + "net/http" + "sync" + "time" +) + +// limiter is a fixed-window counter, per key, held in memory. +// +// In memory is the right scope here: verdande is one process with one SQLite file +// beneath it, so there is no second instance for a shared counter to coordinate +// with. Resetting the limits on restart is a real weakness, and an acceptable one — +// this exists to make online password guessing impractical, not to survive a +// determined attacker who can also restart the server. +type limiter struct { + mu sync.Mutex + windows map[string]*window + limit int + period time.Duration +} + +type window struct { + count int + until time.Time +} + +func newLimiter(limit int, period time.Duration) *limiter { + l := &limiter{windows: map[string]*window{}, limit: limit, period: period} + go l.sweep() + return l +} + +// allow records an attempt and reports whether it may proceed, with how long to +// wait if not. +func (l *limiter) allow(key string) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + + now := time.Now() + w, ok := l.windows[key] + if !ok || now.After(w.until) { + l.windows[key] = &window{count: 1, until: now.Add(l.period)} + return true, 0 + } + if w.count >= l.limit { + return false, time.Until(w.until) + } + w.count++ + return true, 0 +} + +// reset clears a key. Called after a successful login, so somebody who mistyped +// their password four times is not still rate-limited once they get it right. +func (l *limiter) reset(key string) { + l.mu.Lock() + delete(l.windows, key) + l.mu.Unlock() +} + +// sweep drops expired windows. Without it the map is a slow memory leak keyed by +// every address that ever failed a login. +func (l *limiter) sweep() { + for range time.Tick(5 * time.Minute) { + l.mu.Lock() + now := time.Now() + for k, w := range l.windows { + if now.After(w.until) { + delete(l.windows, k) + } + } + l.mu.Unlock() + } +} + +// rateLimit guards an endpoint by client address. +func (s *Server) rateLimit(l *limiter, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ok, retry := l.allow(clientIP(r)); !ok { + w.Header().Set("Retry-After", retryAfterSeconds(retry)) + writeError(w, http.StatusTooManyRequests, CodeRateLimited, + "too many attempts; wait a moment and try again") + return + } + next.ServeHTTP(w, r) + }) +} + +// clientIP is the address the middleware chain has already resolved through any +// proxy headers — behind a Cloudflare Tunnel that is the caller, not the tunnel. +// The port is dropped so repeated attempts from one machine share a bucket. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func retryAfterSeconds(d time.Duration) string { + secs := int(d.Seconds()) + if secs < 1 { + secs = 1 + } + return itoa(secs) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return string(b[i:]) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 18f2705..e551b2a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -17,14 +17,23 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/kristianwind/verdande/internal/config" + "github.com/kristianwind/verdande/internal/mail" "github.com/kristianwind/verdande/internal/store" ) type Server struct { - cfg *config.Config - db *store.DB - log *slog.Logger - web fs.FS + cfg *config.Config + db *store.DB + log *slog.Logger + web fs.FS + mail *mail.Sender + + // Password guessing is the attack this application is actually exposed to, so + // the endpoints that check a secret are limited separately and more tightly + // than the rest of the API. + loginLimiter *limiter + resetLimiter *limiter + router chi.Router } @@ -32,7 +41,12 @@ type Server struct { // at build time; a nil value serves an explanatory placeholder instead so that a // backend-only build still runs and reports its health. func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server { - s := &Server{cfg: cfg, db: db, log: log, web: web} + s := &Server{ + cfg: cfg, db: db, log: log, web: web, + mail: mail.New(cfg.SMTP, cfg.BaseURL, log), + loginLimiter: newLimiter(10, 15*time.Minute), + resetLimiter: newLimiter(5, time.Hour), + } r := chi.NewRouter() r.Use(middleware.RequestID) @@ -47,9 +61,46 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/healthz", s.handleHealth) r.Route("/api/v1", func(r chi.Router) { + r.Use(s.requireCSRF) + r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) + + // Reachable without being signed in — these are how you become signed in. + r.Route("/auth", func(r chi.Router) { + r.Get("/setup", s.handleSetupState) + r.Method(http.MethodPost, "/setup", + s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBootstrap))) + r.Method(http.MethodPost, "/login", + s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleLogin))) + r.Method(http.MethodPost, "/signup", + s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleSignup))) + r.Method(http.MethodPost, "/password/forgot", + s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleForgotPassword))) + r.Method(http.MethodPost, "/password/reset", + s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword))) + + // The second step of a login: a session that has passed the password + // but not yet the code, and can reach nothing else. + r.Group(func(r chi.Router) { + r.Use(s.requirePendingSession) + r.Method(http.MethodPost, "/login/totp", + s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleLoginTOTP))) + }) + + r.Group(func(r chi.Router) { + r.Use(s.requireAuth) + r.Get("/me", s.handleMe) + r.Post("/logout", s.handleLogout) + r.Post("/password/change", s.handleChangePassword) + r.Post("/totp/setup", s.handleTOTPSetup) + r.Post("/totp/confirm", s.handleTOTPConfirm) + r.Post("/totp/disable", s.handleTOTPDisable) + r.Get("/recovery-codes", s.handleRecoveryCodesCount) + r.Post("/recovery-codes", s.handleRecoveryCodesRegenerate) + }) + }) }) r.NotFound(s.serveWeb) diff --git a/internal/httpapi/totp_handlers.go b/internal/httpapi/totp_handlers.go new file mode 100644 index 0000000..36d6d66 --- /dev/null +++ b/internal/httpapi/totp_handlers.go @@ -0,0 +1,164 @@ +package httpapi + +import ( + "net/http" + "time" + + "github.com/kristianwind/verdande/internal/auth" +) + +// TOTP enrolment is three steps on purpose: begin, confirm, and only then is it on. +// +// Turning it on the moment a secret is generated would let somebody lock themselves +// out by closing the tab before their authenticator app had actually stored it. The +// secret is written to the user row but `totp_enabled` stays 0 until a code proves +// the app has it. + +type totpSetupResponse struct { + Secret string `json:"secret"` + URI string `json:"uri"` +} + +func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) { + user := userFrom(r.Context()) + if user.TOTPEnabled { + writeError(w, http.StatusConflict, CodeConflict, + "two-factor authentication is already switched on") + return + } + + secret, uri, err := auth.NewTOTPSecret(s.issuer(), user.Email) + if err != nil { + s.internal(w, "generate totp secret", err) + return + } + // Stored but not enabled: confirm decides that. + if err := s.db.SetTOTPSecret(r.Context(), user.ID, secret, false); err != nil { + s.internal(w, "store totp secret", err) + return + } + writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri}) +} + +type totpConfirmResponse struct { + RecoveryCodes []string `json:"recovery_codes"` +} + +func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) { + var req totpRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + if user.TOTPSecret == "" { + writeError(w, http.StatusBadRequest, CodeBadRequest, + "start setting up two-factor authentication first") + return + } + if err := auth.VerifyTOTP(user.TOTPSecret, req.Code, time.Now()); err != nil { + writeFieldErrors(w, map[string]string{"code": "not correct"}) + return + } + + codes, hashes, err := auth.NewRecoveryCodes() + if err != nil { + s.internal(w, "generate recovery codes", err) + return + } + if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil { + s.internal(w, "store recovery codes", err) + return + } + if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil { + s.internal(w, "enable totp", err) + return + } + + // Shown once. The UI has to make that clear at this point, because there is no + // way to show them again — only the hashes were kept. + writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes}) +} + +type totpDisableRequest struct { + Password string `json:"password"` +} + +// handleTOTPDisable requires the password again. Switching off a second factor is +// exactly the action an attacker with a borrowed session would take first. +func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) { + var req totpDisableRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil { + writeFieldErrors(w, map[string]string{"password": "not correct"}) + return + } + if err := s.db.SetTOTPSecret(r.Context(), user.ID, "", false); err != nil { + s.internal(w, "disable totp", err) + return + } + if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, nil); err != nil { + s.log.Warn("clear recovery codes", "err", err, "user", user.ID) + } + w.WriteHeader(http.StatusNoContent) +} + +// handleRecoveryCodesRegenerate issues a fresh set and retires the old one. +func (s *Server) handleRecoveryCodesRegenerate(w http.ResponseWriter, r *http.Request) { + var req totpDisableRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + if !user.TOTPEnabled { + writeError(w, http.StatusConflict, CodeConflict, + "two-factor authentication is not switched on") + return + } + if err := auth.VerifyPassword(user.PasswordHash, req.Password); err != nil { + writeFieldErrors(w, map[string]string{"password": "not correct"}) + return + } + + codes, hashes, err := auth.NewRecoveryCodes() + if err != nil { + s.internal(w, "generate recovery codes", err) + return + } + if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil { + s.internal(w, "store recovery codes", err) + return + } + writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes}) +} + +func (s *Server) handleRecoveryCodesCount(w http.ResponseWriter, r *http.Request) { + n, err := s.db.CountRecoveryCodes(r.Context(), userFrom(r.Context()).ID) + if err != nil { + s.internal(w, "count recovery codes", err) + return + } + writeJSON(w, http.StatusOK, map[string]int{"remaining": n}) +} + +// issuer is what an authenticator app lists the entry under. It is the instance's +// hostname rather than a bare "verdande", so somebody running two of them can tell +// the two entries apart in a list of a dozen. +func (s *Server) issuer() string { + host := s.cfg.BaseURL + for _, prefix := range []string{"https://", "http://"} { + if len(host) > len(prefix) && host[:len(prefix)] == prefix { + host = host[len(prefix):] + break + } + } + if host == "" { + return "verdande" + } + return "verdande (" + host + ")" +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 0000000..3f7bc04 --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,204 @@ +// Package mail sends the handful of messages verdande needs to send: invites, +// password resets, and later reminders and notifications. +// +// Written against net/smtp rather than a mail library because the requirement is +// small and fixed. What it does need to get right is the failure mode: an instance +// with no mail server configured must still work, so sending falls back to logging +// the link rather than returning an error that blocks the operation that caused it. +package mail + +import ( + "context" + "crypto/tls" + "fmt" + "log/slog" + "mime" + "net" + "net/smtp" + "strings" + "time" + + "github.com/kristianwind/verdande/internal/config" +) + +type Sender struct { + cfg config.SMTP + log *slog.Logger + // appName and baseURL appear in the body of every message. + appName string + baseURL string +} + +func New(cfg config.SMTP, baseURL string, log *slog.Logger) *Sender { + return &Sender{cfg: cfg, log: log, appName: "verdande", baseURL: baseURL} +} + +// Configured reports whether mail can actually be sent. Handlers use it to decide +// whether to show an invite link on screen instead of promising an email. +func (s *Sender) Configured() bool { return s.cfg.Configured() } + +func (s *Sender) SendInvite(ctx context.Context, to, inviterName, projectName, link string, ttl time.Duration) error { + subject := fmt.Sprintf("%s har delt noget med dig i verdande", inviterName) + what := "verdande" + if projectName != "" { + what = "projektet “" + projectName + "”" + } + body := fmt.Sprintf(`Hej + +%s har inviteret dig til %s. + +Opret din konto her: +%s + +Linket virker i %s. Hvis du ikke ved, hvad det her handler om, kan du roligt slette denne mail — der sker ikke noget, hvis du ikke bruger linket. + +— verdande +%s +`, inviterName, what, link, humanDuration(ttl), s.baseURL) + + return s.send(ctx, to, subject, body) +} + +func (s *Sender) SendPasswordReset(ctx context.Context, to, name, link string, ttl time.Duration) error { + subject := "Nulstil din adgangskode i verdande" + body := fmt.Sprintf(`Hej %s + +Der er bedt om at nulstille adgangskoden til din verdande-konto. + +Vælg en ny adgangskode her: +%s + +Linket virker i %s og kan kun bruges én gang. + +Har du ikke selv bedt om det, behøver du ikke gøre noget — din nuværende adgangskode virker stadig, og linket udløber af sig selv. + +— verdande +%s +`, name, link, humanDuration(ttl), s.baseURL) + + return s.send(ctx, to, subject, body) +} + +// send delivers one message, or logs the link if there is no mail server. +// +// Returning nil when unconfigured is deliberate: a single-user instance with no +// SMTP host is a supported way to run this, and a password reset must not fail +// there. The operator finds the link in the log. +func (s *Sender) send(ctx context.Context, to, subject, body string) error { + if !s.cfg.Configured() { + s.log.Warn("no SMTP host configured; the message was not sent", + "to", to, "subject", subject, "body", body) + return nil + } + + from := s.cfg.From + msg := buildMessage(from, to, subject, body) + addr := net.JoinHostPort(s.cfg.Host, itoa(s.cfg.Port)) + + // The dial is bounded so a mail server that accepts connections and then says + // nothing cannot hold an HTTP handler open until its own timeout. + d := net.Dialer{Timeout: 10 * time.Second} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("mail: dial %s: %w", addr, err) + } + + tlsCfg := &tls.Config{ + ServerName: s.cfg.Host, + // Only when the operator has said so, for a self-signed internal server. + InsecureSkipVerify: s.cfg.Insecure, + } + // Port 465 is TLS from the first byte; everything else starts in the clear and + // negotiates with STARTTLS. + if !s.cfg.StartTLS { + conn = tls.Client(conn, tlsCfg) + } + + c, err := smtp.NewClient(conn, s.cfg.Host) + if err != nil { + conn.Close() + return fmt.Errorf("mail: smtp handshake: %w", err) + } + defer c.Close() + + if s.cfg.StartTLS { + if ok, _ := c.Extension("STARTTLS"); ok { + if err := c.StartTLS(tlsCfg); err != nil { + return fmt.Errorf("mail: starttls: %w", err) + } + } else { + // Not fatal — a mail server on the same Docker network is a reasonable + // place not to have TLS — but the operator should know credentials are + // crossing in the clear. + s.log.Warn("mail server does not offer STARTTLS; sending unencrypted", "host", s.cfg.Host) + } + } + + if s.cfg.Username != "" { + auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host) + if err := c.Auth(auth); err != nil { + return fmt.Errorf("mail: authenticate: %w", err) + } + } + if err := c.Mail(from); err != nil { + return fmt.Errorf("mail: from: %w", err) + } + if err := c.Rcpt(to); err != nil { + return fmt.Errorf("mail: to: %w", err) + } + wc, err := c.Data() + if err != nil { + return fmt.Errorf("mail: data: %w", err) + } + if _, err := wc.Write([]byte(msg)); err != nil { + wc.Close() + return fmt.Errorf("mail: write: %w", err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("mail: close body: %w", err) + } + return c.Quit() +} + +// buildMessage assembles the headers and body. +// +// The subject is RFC 2047 encoded because it contains Danish letters, and a raw +// "æ" in a header is not valid — it arrives as mojibake in most clients. The body +// declares UTF-8 for the same reason. +func buildMessage(from, to, subject, body string) string { + var b strings.Builder + b.WriteString("From: " + from + "\r\n") + b.WriteString("To: " + to + "\r\n") + b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n") + b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("Content-Transfer-Encoding: 8bit\r\n") + // These are transactional messages sent because somebody just asked for one. + b.WriteString("Auto-Submitted: auto-generated\r\n") + b.WriteString("\r\n") + // SMTP ends a message on a line containing a single dot, so a body line that is + // just "." has to be escaped or it truncates everything after it. + b.WriteString(strings.ReplaceAll(normaliseNewlines(body), "\r\n.\r\n", "\r\n..\r\n")) + return b.String() +} + +func normaliseNewlines(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\n", "\r\n") +} + +func humanDuration(d time.Duration) string { + switch { + case d >= 48*time.Hour: + return fmt.Sprintf("%d dage", int(d.Hours()/24)) + case d >= 2*time.Hour: + return fmt.Sprintf("%d timer", int(d.Hours())) + case d >= time.Hour: + return "en time" + default: + return fmt.Sprintf("%d minutter", int(d.Minutes())) + } +} + +func itoa(n int) string { return fmt.Sprintf("%d", n) } diff --git a/internal/store/apitokens.go b/internal/store/apitokens.go new file mode 100644 index 0000000..0133d21 --- /dev/null +++ b/internal/store/apitokens.go @@ -0,0 +1,123 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/kristianwind/verdande/internal/auth" +) + +type APIToken struct { + ID string + UserID string + Name string + Prefix string + CreatedAt time.Time + LastUsedAt *time.Time + ExpiresAt *time.Time +} + +// CreateAPIToken returns the token in plaintext exactly once. Only its hash is +// stored, so it can never be shown again — the UI has to say so at the point of +// creation rather than offering a "reveal" that cannot work. +func (db *DB) CreateAPIToken(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *APIToken, error) { + token, prefix, err := auth.NewAPIToken() + if err != nil { + return "", nil, err + } + t := &APIToken{ + ID: NewID(), + UserID: userID, + Name: name, + Prefix: prefix, + CreatedAt: time.Now().UTC(), + ExpiresAt: expiresAt, + } + + var expires any + if expiresAt != nil { + expires = expiresAt.Unix() + } + _, err = db.ExecContext(ctx, + `INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + t.ID, t.UserID, t.Name, auth.HashToken(token), t.Prefix, t.CreatedAt.Unix(), expires) + if err != nil { + return "", nil, err + } + return token, t, nil +} + +// UserByAPIToken resolves a bearer token to its owner. +func (db *DB) UserByAPIToken(ctx context.Context, token string) (*User, error) { + var id, userID string + var expires sql.NullInt64 + err := db.QueryRowContext(ctx, + `SELECT id, user_id, expires_at FROM api_tokens WHERE token_hash = ?`, + auth.HashToken(token)).Scan(&id, &userID, &expires) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if expires.Valid && time.Now().Unix() > expires.Int64 { + return nil, ErrNotFound + } + + // last_used_at is what lets somebody delete the token they no longer recognise + // without wondering what will break. Written at most hourly: a token driving a + // sync loop would otherwise mean a write per request. + _, _ = db.ExecContext(ctx, + `UPDATE api_tokens SET last_used_at = ? + WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`, + time.Now().Unix(), id, time.Now().Add(-time.Hour).Unix()) + + return db.UserByID(ctx, userID) +} + +func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at + FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []APIToken + for rows.Next() { + var t APIToken + var created int64 + var lastUsed, expires sql.NullInt64 + if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires); err != nil { + return nil, err + } + t.CreatedAt = time.Unix(created, 0).UTC() + if lastUsed.Valid { + v := time.Unix(lastUsed.Int64, 0).UTC() + t.LastUsedAt = &v + } + if expires.Valid { + v := time.Unix(expires.Int64, 0).UTC() + t.ExpiresAt = &v + } + out = append(out, t) + } + return out, rows.Err() +} + +// DeleteAPIToken is scoped by user so an id from somewhere else deletes nothing. +func (db *DB) DeleteAPIToken(ctx context.Context, userID, tokenID string) error { + res, err := db.ExecContext(ctx, + `DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, tokenID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/store/id.go b/internal/store/id.go new file mode 100644 index 0000000..3c06d16 --- /dev/null +++ b/internal/store/id.go @@ -0,0 +1,107 @@ +package store + +import ( + "crypto/rand" + "encoding/binary" + "encoding/hex" + "fmt" + "sync" + "time" +) + +// NewID returns a UUIDv7: 48 bits of Unix milliseconds followed by randomness. +// +// Time-ordered ids matter more here than they look. SQLite stores rows in a B-tree +// keyed by the primary key, so random ids scatter inserts across the whole file and +// every new task dirties a different page. Sequential ids append. It also means +// `ORDER BY id` is creation order, which removes a column and an index from every +// table that would otherwise need one. +// +// Implemented directly rather than through a dependency: it is twenty lines, and the +// monotonic counter below is the only part with any subtlety. +func NewID() string { + var b [16]byte + + ms, seq := nextTimestamp() + // 48-bit big-endian millisecond timestamp. + b[0] = byte(ms >> 40) + b[1] = byte(ms >> 32) + b[2] = byte(ms >> 24) + b[3] = byte(ms >> 16) + b[4] = byte(ms >> 8) + b[5] = byte(ms) + + // Version 7 in the high nibble of byte 6, then 12 bits of sequence. The + // sequence is what keeps ids ordered *within* a millisecond — without it, two + // tasks created in the same tick sort by their random bits, and a list created + // by one fast paste comes back shuffled. + b[6] = 0x70 | byte(seq>>8) + b[7] = byte(seq) + + if _, err := rand.Read(b[8:]); err != nil { + // crypto/rand does not fail on any supported platform; if it somehow + // does, continuing would silently produce guessable ids. + panic(fmt.Sprintf("store: read random for id: %v", err)) + } + // Variant bits: 10xx in the top of byte 8. + b[8] = (b[8] & 0x3f) | 0x80 + + return hex.EncodeToString(b[:4]) + "-" + + hex.EncodeToString(b[4:6]) + "-" + + hex.EncodeToString(b[6:8]) + "-" + + hex.EncodeToString(b[8:10]) + "-" + + hex.EncodeToString(b[10:]) +} + +var idClock struct { + sync.Mutex + lastMS uint64 + seq uint16 +} + +// nextTimestamp returns the current millisecond and a counter that increments for +// repeated calls inside the same one, so ids issued in a burst stay ordered. +func nextTimestamp() (uint64, uint16) { + idClock.Lock() + defer idClock.Unlock() + + ms := uint64(time.Now().UnixMilli()) + switch { + case ms > idClock.lastMS: + idClock.lastMS, idClock.seq = ms, 0 + case idClock.seq < 0xfff: + idClock.seq++ + default: + // More than 4096 ids in one millisecond: borrow from the next. The clock + // value stays ahead of real time until it catches up, which keeps ids + // unique and ordered rather than wrapping the counter. + idClock.lastMS++ + idClock.seq = 0 + ms = idClock.lastMS + } + return ms, idClock.seq +} + +// IDTime recovers the moment an id was generated. Useful in diagnostics, and the +// reason a created_at column can be trusted against its own row's id. +func IDTime(id string) (time.Time, bool) { + clean := make([]byte, 0, 32) + for i := 0; i < len(id); i++ { + if id[i] != '-' { + clean = append(clean, id[i]) + } + } + if len(clean) != 32 { + return time.Time{}, false + } + decoded := make([]byte, 16) + if _, err := hex.Decode(decoded, clean); err != nil { + return time.Time{}, false + } + if decoded[6]>>4 != 7 { + return time.Time{}, false + } + ms := uint64(binary.BigEndian.Uint16(decoded[0:2]))<<32 | + uint64(binary.BigEndian.Uint32(decoded[2:6])) + return time.UnixMilli(int64(ms)), true +} diff --git a/internal/store/invites.go b/internal/store/invites.go new file mode 100644 index 0000000..78ff4f5 --- /dev/null +++ b/internal/store/invites.go @@ -0,0 +1,314 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/kristianwind/verdande/internal/auth" +) + +var ErrAlreadySetUp = errors.New("store: this instance already has an account") + +type Invite struct { + ID string + Email string + ProjectID string // empty for an invite to the instance rather than to a project + Role Role + CreatedBy string + CreatedAt time.Time + ExpiresAt time.Time +} + +// CreateFirstAdmin creates the very first account, and fails if one already exists. +// +// The check and the insert are in one transaction because the endpoint behind this +// is necessarily unauthenticated: two requests arriving together must not both find +// an empty database and both create an administrator. With SQLite serialising +// writes, the second transaction sees the first one's row and loses. +func (db *DB) CreateFirstAdmin(ctx context.Context, u *User, inboxName string) error { + u.Email = NormalizeEmail(u.Email) + if u.ID == "" { + u.ID = NewID() + } + if u.AvatarColor == "" { + u.AvatarColor = avatarColorFor(u.Email) + } + u.IsAdmin = true + now := time.Now().UTC() + u.CreatedAt, u.UpdatedAt = now, now + + return db.Tx(ctx, func(tx *sql.Tx) error { + var n int + if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n); err != nil { + return err + } + if n > 0 { + return ErrAlreadySetUp + } + + _, err := tx.ExecContext(ctx, + `INSERT INTO users (id, email, name, password_hash, totp_enabled, avatar_color, + timezone, locale, is_admin, created_at, updated_at) + VALUES (?, ?, ?, ?, 0, ?, ?, ?, 1, ?, ?)`, + u.ID, u.Email, u.Name, u.PasswordHash, u.AvatarColor, + orDefault(u.Timezone, "Europe/Copenhagen"), orDefault(u.Locale, "da"), + now.Unix(), now.Unix()) + if err != nil { + return fmt.Errorf("insert first admin: %w", err) + } + + _, err = tx.ExecContext(ctx, + `INSERT INTO projects (id, name, color, owner_id, is_inbox, sort_order, created_at, updated_at) + VALUES (?, ?, 'graphite', ?, 1, 0, ?, ?)`, + NewID(), inboxName, u.ID, now.Unix(), now.Unix()) + return err + }) +} + +// CreateInvite issues an invite and returns the token for the emailed link. +// projectID may be empty, which invites somebody to the instance rather than to a +// particular project. +func (db *DB) CreateInvite(ctx context.Context, email, projectID string, role Role, createdBy string, ttl time.Duration) (string, *Invite, error) { + if !role.Valid() { + return "", nil, fmt.Errorf("store: %q is not a role", role) + } + token, err := auth.NewToken() + if err != nil { + return "", nil, err + } + now := time.Now().UTC() + inv := &Invite{ + ID: NewID(), + Email: NormalizeEmail(email), + ProjectID: projectID, + Role: role, + CreatedBy: createdBy, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + } + + var project any + if projectID != "" { + project = projectID + } + _, err = db.ExecContext(ctx, + `INSERT INTO invites (id, email, project_id, role, token_hash, created_by, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + inv.ID, inv.Email, project, string(inv.Role), auth.HashToken(token), + inv.CreatedBy, inv.CreatedAt.Unix(), inv.ExpiresAt.Unix()) + if err != nil { + return "", nil, err + } + return token, inv, nil +} + +// InviteByToken resolves an invite link. An expired or already-accepted invite is +// reported as not found: to whoever is holding the link, those are the same thing. +func (db *DB) InviteByToken(ctx context.Context, token string) (*Invite, error) { + if token == "" { + return nil, ErrInviteInvalid + } + var inv Invite + var projectID sql.NullString + var created, expires int64 + var accepted sql.NullInt64 + + err := db.QueryRowContext(ctx, + `SELECT id, email, project_id, role, created_by, created_at, expires_at, accepted_at + FROM invites WHERE token_hash = ?`, auth.HashToken(token)). + Scan(&inv.ID, &inv.Email, &projectID, &inv.Role, &inv.CreatedBy, &created, &expires, &accepted) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInviteInvalid + } + if err != nil { + return nil, err + } + if accepted.Valid || time.Now().Unix() > expires { + return nil, ErrInviteInvalid + } + + inv.ProjectID = projectID.String + inv.CreatedAt = time.Unix(created, 0).UTC() + inv.ExpiresAt = time.Unix(expires, 0).UTC() + return &inv, nil +} + +// AcceptInvite marks the invite used and grants the membership it promised, in one +// transaction. Splitting the two would allow a signup to fail partway and leave a +// live invite behind, which is a link that still works after it has been used. +// +// The UPDATE is guarded on accepted_at IS NULL, so two requests racing with the +// same link produce one membership and one failure rather than two memberships. +func (db *DB) AcceptInvite(ctx context.Context, inviteID, userID string) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + res, err := tx.ExecContext(ctx, + `UPDATE invites SET accepted_at = ? WHERE id = ? AND accepted_at IS NULL`, + time.Now().Unix(), inviteID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrInviteInvalid + } + + var projectID sql.NullString + var role Role + if err := tx.QueryRowContext(ctx, + `SELECT project_id, role FROM invites WHERE id = ?`, inviteID). + Scan(&projectID, &role); err != nil { + return err + } + if !projectID.Valid || projectID.String == "" { + return nil // an invite to the instance, with no project attached + } + + // ON CONFLICT rather than a plain insert: somebody may already be a member + // through another route, and being invited again should not fail. + _, err = tx.ExecContext(ctx, + `INSERT INTO project_members (project_id, user_id, role, added_at) + VALUES (?, ?, ?, ?) + ON CONFLICT (project_id, user_id) DO UPDATE SET role = excluded.role`, + projectID.String, userID, string(role), time.Now().Unix()) + return err + }) +} + +// --- password resets ---------------------------------------------------------- + +// CreatePasswordReset invalidates any outstanding reset for the user before issuing +// a new one, so asking twice does not leave two working links in two inboxes. +func (db *DB) CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error) { + token, err := auth.NewToken() + if err != nil { + return "", err + } + now := time.Now() + + err = db.Tx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, + `UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at IS NULL`, + now.Unix(), userID); err != nil { + return err + } + _, err := tx.ExecContext(ctx, + `INSERT INTO password_resets (id, user_id, token_hash, created_at, expires_at) + VALUES (?, ?, ?, ?, ?)`, + NewID(), userID, auth.HashToken(token), now.Unix(), now.Add(ttl).Unix()) + return err + }) + if err != nil { + return "", err + } + return token, nil +} + +// UsePasswordReset consumes a reset token and returns whose it was. Guarded on +// used_at IS NULL so the link works exactly once. +func (db *DB) UsePasswordReset(ctx context.Context, token string) (string, error) { + if token == "" { + return "", ErrNotFound + } + hash := auth.HashToken(token) + + var userID string + err := db.Tx(ctx, func(tx *sql.Tx) error { + var expires int64 + var used sql.NullInt64 + err := tx.QueryRowContext(ctx, + `SELECT user_id, expires_at, used_at FROM password_resets WHERE token_hash = ?`, hash). + Scan(&userID, &expires, &used) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if used.Valid || time.Now().Unix() > expires { + return ErrNotFound + } + _, err = tx.ExecContext(ctx, + `UPDATE password_resets SET used_at = ? WHERE token_hash = ? AND used_at IS NULL`, + time.Now().Unix(), hash) + return err + }) + if err != nil { + return "", err + } + return userID, nil +} + +// --- TOTP recovery codes ------------------------------------------------------- + +// ReplaceRecoveryCodes swaps the whole set. Codes are only ever issued as a batch, +// and a new batch must retire the old one — otherwise a printout somebody threw +// away still works. +func (db *DB) ReplaceRecoveryCodes(ctx context.Context, userID string, hashes []string) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, + `DELETE FROM totp_recovery_codes WHERE user_id = ?`, userID); err != nil { + return err + } + now := time.Now().Unix() + for _, h := range hashes { + if _, err := tx.ExecContext(ctx, + `INSERT INTO totp_recovery_codes (id, user_id, code_hash, created_at) + VALUES (?, ?, ?, ?)`, NewID(), userID, h, now); err != nil { + return err + } + } + return nil + }) +} + +// UseRecoveryCode spends a code if it matches an unused one. Guarded on +// used_at IS NULL, so a code works exactly once even if submitted twice at once. +func (db *DB) UseRecoveryCode(ctx context.Context, userID, code string) (bool, error) { + if code == "" { + return false, nil + } + rows, err := db.QueryContext(ctx, + `SELECT id, code_hash FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL`, userID) + if err != nil { + return false, err + } + var ids, hashes []string + for rows.Next() { + var id, hash string + if err := rows.Scan(&id, &hash); err != nil { + rows.Close() + return false, err + } + ids = append(ids, id) + hashes = append(hashes, hash) + } + rows.Close() + if err := rows.Err(); err != nil { + return false, err + } + + i := auth.MatchRecoveryCode(hashes, code) + if i < 0 { + return false, nil + } + res, err := db.ExecContext(ctx, + `UPDATE totp_recovery_codes SET used_at = ? WHERE id = ? AND used_at IS NULL`, + time.Now().Unix(), ids[i]) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +// CountRecoveryCodes reports how many are left, so the UI can warn before the last +// one is gone. +func (db *DB) CountRecoveryCodes(ctx context.Context, userID string) (int, error) { + var n int + err := db.QueryRowContext(ctx, + `SELECT count(*) FROM totp_recovery_codes WHERE user_id = ? AND used_at IS NULL`, + userID).Scan(&n) + return n, err +} diff --git a/internal/store/migrations/0002_auth.sql b/internal/store/migrations/0002_auth.sql new file mode 100644 index 0000000..1942f77 --- /dev/null +++ b/internal/store/migrations/0002_auth.sql @@ -0,0 +1,23 @@ +-- Sessions gain a two-step state, and TOTP gains a way back in. + +-- A session created by a correct password but not yet by a correct TOTP code. +-- Such a session authenticates exactly one thing — completing the TOTP step — and +-- nothing else. Modelling the half-finished login as a session rather than as a +-- separate token means it expires, is listed, and can be revoked by the same code +-- that handles every other session. +ALTER TABLE sessions ADD COLUMN pending_totp INTEGER NOT NULL DEFAULT 0; + +-- Single-use codes for someone who has lost their authenticator. +-- +-- Without these, losing a phone means losing the account, and the only remaining +-- repair is editing SQLite by hand — which is not a recovery story you can put in +-- front of somebody who invited their partner to a shared shopping list. +CREATE TABLE totp_recovery_codes ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + used_at INTEGER +); + +CREATE INDEX idx_recovery_codes_user ON totp_recovery_codes (user_id) WHERE used_at IS NULL; diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..0f88fd3 --- /dev/null +++ b/internal/store/sessions.go @@ -0,0 +1,172 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/kristianwind/verdande/internal/auth" +) + +type Session struct { + ID string // the hash, which is what is stored — never the cookie value + UserID string + UA string + IP string + PendingTOTP bool + CreatedAt time.Time + LastSeenAt time.Time + ExpiresAt time.Time +} + +// CreateSession issues a session and returns the value to put in the cookie. +// +// Only the hash is stored. A session cookie is a bearer credential — whoever holds +// it is logged in — so a database dump must not contain anything that can be pasted +// into a browser. +// +// pendingTOTP marks a login that has passed the password step but not the second +// factor. Such a session is accepted by exactly one endpoint. +func (db *DB) CreateSession(ctx context.Context, userID, ua, ip string, ttl time.Duration, pendingTOTP bool) (string, *Session, error) { + token, err := auth.NewToken() + if err != nil { + return "", nil, err + } + now := time.Now().UTC() + s := &Session{ + ID: auth.HashToken(token), + UserID: userID, + UA: truncate(ua, 500), + IP: truncate(ip, 100), + PendingTOTP: pendingTOTP, + CreatedAt: now, + LastSeenAt: now, + ExpiresAt: now.Add(ttl), + } + + _, err = db.ExecContext(ctx, + `INSERT INTO sessions (id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + s.ID, s.UserID, s.UA, s.IP, boolToInt(s.PendingTOTP), + s.CreatedAt.Unix(), s.LastSeenAt.Unix(), s.ExpiresAt.Unix()) + if err != nil { + return "", nil, err + } + return token, s, nil +} + +// SessionByToken looks up a session by the cookie value and returns it with its +// user. An expired session is reported as not found and deleted on the way past: +// expiry is checked here rather than left to the nightly sweep, so a stale cookie +// stops working at the moment it should. +func (db *DB) SessionByToken(ctx context.Context, token string) (*Session, *User, error) { + id := auth.HashToken(token) + + var s Session + var pending int + var created, lastSeen, expires int64 + err := db.QueryRowContext(ctx, + `SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at + FROM sessions WHERE id = ?`, id). + Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, ErrNotFound + } + if err != nil { + return nil, nil, err + } + + s.PendingTOTP = pending == 1 + s.CreatedAt = time.Unix(created, 0).UTC() + s.LastSeenAt = time.Unix(lastSeen, 0).UTC() + s.ExpiresAt = time.Unix(expires, 0).UTC() + + if time.Now().After(s.ExpiresAt) { + _, _ = db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, s.ID) + return nil, nil, ErrNotFound + } + + user, err := db.UserByID(ctx, s.UserID) + if err != nil { + return nil, nil, err + } + + // last_seen_at drives the session list in settings ("this device, 2 minutes + // ago"). It is written at most once a minute: every request would be a write + // per request, which on SQLite means a write lock per request. + if time.Since(s.LastSeenAt) > time.Minute { + _, _ = db.ExecContext(ctx, + `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, time.Now().Unix(), s.ID) + } + return &s, user, nil +} + +// PromoteSession completes a two-step login: the session stops being pending and +// its clock restarts, so the full lifetime is counted from finishing the login and +// not from typing the password. +func (db *DB) PromoteSession(ctx context.Context, sessionID string, ttl time.Duration) error { + res, err := db.ExecContext(ctx, + `UPDATE sessions SET pending_totp = 0, expires_at = ? WHERE id = ? AND pending_totp = 1`, + time.Now().Add(ttl).Unix(), sessionID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (db *DB) DeleteSession(ctx context.Context, sessionID string) error { + _, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID) + return err +} + +func (db *DB) DeleteUserSessions(ctx context.Context, userID string) error { + _, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) + return err +} + +func (db *DB) ListSessions(ctx context.Context, userID string) ([]Session, error) { + rows, err := db.QueryContext(ctx, + `SELECT id, user_id, ua, ip, pending_totp, created_at, last_seen_at, expires_at + FROM sessions WHERE user_id = ? AND pending_totp = 0 ORDER BY last_seen_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Session + for rows.Next() { + var s Session + var pending int + var created, lastSeen, expires int64 + if err := rows.Scan(&s.ID, &s.UserID, &s.UA, &s.IP, &pending, &created, &lastSeen, &expires); err != nil { + return nil, err + } + s.PendingTOTP = pending == 1 + s.CreatedAt = time.Unix(created, 0).UTC() + s.LastSeenAt = time.Unix(lastSeen, 0).UTC() + s.ExpiresAt = time.Unix(expires, 0).UTC() + out = append(out, s) + } + return out, rows.Err() +} + +// PurgeExpiredSessions is run by the nightly sweep. SessionByToken already refuses +// an expired session, so this only reclaims space and keeps the session list honest. +func (db *DB) PurgeExpiredSessions(ctx context.Context) (int64, error) { + res, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix()) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5e6d1d1..29450df 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -50,21 +50,28 @@ func TestMigrateIsIdempotent(t *testing.T) { if err != nil { t.Fatalf("first open: %v", err) } + var first int + if err := db.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&first); err != nil { + t.Fatalf("count migrations: %v", err) + } db.Close() + if first == 0 { + t.Fatal("no migrations were applied") + } - // Re-opening must not try to re-run 0001 and fail on "table already exists". + // Re-opening must not re-run anything and fail on "table already exists". db2, err := Open(path) if err != nil { t.Fatalf("second open: %v", err) } defer db2.Close() - var n int - if err := db2.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&n); err != nil { + var second int + if err := db2.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&second); err != nil { t.Fatalf("count migrations: %v", err) } - if n != 1 { - t.Errorf("schema_migrations has %d rows, want 1", n) + if second != first { + t.Errorf("re-opening applied %d further migrations", second-first) } } diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..263e96d --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,203 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +var ( + ErrNotFound = errors.New("store: not found") + ErrEmailInUse = errors.New("store: that email address already has an account") + ErrInviteInvalid = errors.New("store: invite is not valid") +) + +type User struct { + ID string + Email string + Name string + PasswordHash string + TOTPSecret string + TOTPEnabled bool + AvatarColor string + Timezone string + Locale string + IsAdmin bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// NormalizeEmail is the one place an address is canonicalised. Addresses arrive +// with stray whitespace and inconsistent case from every direction — a login form, +// an invite, an import — and two spellings of one address must never become two +// accounts. +// +// Only case and surrounding space are touched. Gmail's dots-and-plus rules are +// deliberately not applied: they are Gmail's, not the internet's, and stripping a +// "+todo" suffix would break the very addressing people use to route mail here. +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +// CreateUser inserts a user and gives them an Inbox, in one transaction. +// +// The Inbox is created here rather than lazily on first use because every other +// part of the app is entitled to assume it exists — a task with no project has +// nowhere to go, and "create it if missing" scattered across handlers is how two +// of them end up racing to create the second one. +func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error { + u.Email = NormalizeEmail(u.Email) + if u.ID == "" { + u.ID = NewID() + } + if u.AvatarColor == "" { + u.AvatarColor = avatarColorFor(u.Email) + } + now := time.Now().UTC() + u.CreatedAt, u.UpdatedAt = now, now + + return db.Tx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO users (id, email, name, password_hash, totp_secret, totp_enabled, + avatar_color, timezone, locale, is_admin, created_at, updated_at) + VALUES (?, ?, ?, ?, NULL, 0, ?, ?, ?, ?, ?, ?)`, + u.ID, u.Email, u.Name, u.PasswordHash, u.AvatarColor, + orDefault(u.Timezone, "Europe/Copenhagen"), orDefault(u.Locale, "da"), + boolToInt(u.IsAdmin), now.Unix(), now.Unix()) + if err != nil { + if isUniqueViolation(err) { + return ErrEmailInUse + } + return fmt.Errorf("insert user: %w", err) + } + + _, err = tx.ExecContext(ctx, + `INSERT INTO projects (id, name, color, owner_id, is_inbox, sort_order, created_at, updated_at) + VALUES (?, ?, 'graphite', ?, 1, 0, ?, ?)`, + NewID(), inboxName, u.ID, now.Unix(), now.Unix()) + if err != nil { + return fmt.Errorf("create inbox: %w", err) + } + return nil + }) +} + +func (db *DB) UserByEmail(ctx context.Context, email string) (*User, error) { + return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE email = ?`, NormalizeEmail(email)) +} + +func (db *DB) UserByID(ctx context.Context, id string) (*User, error) { + return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE id = ?`, id) +} + +const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled, + avatar_color, timezone, locale, is_admin, created_at, updated_at` + +func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) { + var u User + var secret sql.NullString + var totpEnabled, isAdmin int + var created, updated int64 + + err := db.QueryRowContext(ctx, query, args...).Scan( + &u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled, + &u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &created, &updated) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + + u.TOTPSecret = secret.String + u.TOTPEnabled = totpEnabled == 1 + u.IsAdmin = isAdmin == 1 + u.CreatedAt = time.Unix(created, 0).UTC() + u.UpdatedAt = time.Unix(updated, 0).UTC() + return &u, nil +} + +// UserCount is what decides whether the instance still needs its first admin. +func (db *DB) UserCount(ctx context.Context) (int, error) { + var n int + err := db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n) + return n, err +} + +// UpdatePasswordHash also clears every session but the one asking, so changing a +// password actually ends the sessions of whoever else was using the account. That +// is most of the reason people change one. +func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionID string) error { + return db.Tx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, + `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, + hash, time.Now().Unix(), userID) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, + `DELETE FROM sessions WHERE user_id = ? AND id <> ?`, userID, keepSessionID) + return err + }) +} + +func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error { + var value any + if secret != "" { + value = secret + } + _, err := db.ExecContext(ctx, + `UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`, + value, boolToInt(enabled), time.Now().Unix(), userID) + return err +} + +// InboxID returns the user's Inbox project. +func (db *DB) InboxID(ctx context.Context, userID string) (string, error) { + var id string + err := db.QueryRowContext(ctx, + `SELECT id FROM projects WHERE owner_id = ? AND is_inbox = 1 AND deleted_at IS NULL`, + userID).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return id, err +} + +// avatarColorFor picks a stable colour from the address, so a person looks the same +// to everyone who shares a project with them without anyone having to choose one. +func avatarColorFor(seed string) string { + // Muted tones only: an avatar is a small mark beside text, not a highlight. + palette := []string{ + "#8a8f98", "#7d8c7a", "#9a8577", "#7a8a9a", "#8f7f92", + "#a08a6b", "#6f8a86", "#947d7d", + } + var sum uint32 + for i := 0; i < len(seed); i++ { + sum = sum*31 + uint32(seed[i]) + } + return palette[sum%uint32(len(palette))] +} + +func orDefault(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// isUniqueViolation recognises a broken UNIQUE constraint without depending on the +// driver's error type, which modernc's SQLite does not export in a usable form. +func isUniqueViolation(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint") +}