feat(api): authentication — login, two-step TOTP, invites and resets

Sessions are stored as hashes, so a database dump contains nothing that
can be pasted into a browser. A login that has passed the password but
not the second factor is modelled as a pending session: it expires, is
revocable, and reaches exactly one endpoint.

Signup is invite-only and takes the email address from the invite rather
than the form, so a link cannot be redirected to somebody else. The
first admin is created by an endpoint that refuses once any account
exists, checked and inserted in one transaction because it is
necessarily unauthenticated.

Login failures and forgotten-password requests answer identically
whether or not the address has an account — otherwise the form is an
account-existence oracle. Password rules are a length floor and nothing
else: composition rules push people towards worse passwords.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-16 23:31:29 +02:00
co-authored by Claude Opus 5
parent 8251e69155
commit 1bb644f4d1
15 changed files with 2943 additions and 10 deletions
+460
View File
@@ -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")
}
+656
View File
@@ -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
}
+120
View File
@@ -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"
}
+208
View File
@@ -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:")
}
+121
View File
@@ -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:])
}
+52 -1
View File
@@ -17,6 +17,7 @@ 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"
)
@@ -25,6 +26,14 @@ type Server struct {
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)
+164
View File
@@ -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 + ")"
}
+204
View File
@@ -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) }
+123
View File
@@ -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
}
+107
View File
@@ -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
}
+314
View File
@@ -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
}
+23
View File
@@ -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;
+172
View File
@@ -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]
}
+12 -5
View File
@@ -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)
}
}
+203
View File
@@ -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")
}