diff --git a/docs/api.md b/docs/api.md index 475f7c8..421fb98 100644 --- a/docs/api.md +++ b/docs/api.md @@ -118,6 +118,22 @@ curl -X POST https://todo.example.dk/api/v1/tasks/quick-add \ | `GET` | `/export/projects/{id}.csv` | Todoist-compatible. | | `GET` | `/export/projects/{id}.ics` | A calendar file. | +### API tokens + +| Method | Path | | +|---|---|---| +| `GET` `POST` | `/tokens` | List, or mint one. `name` is required; `expires_in_days` is optional, and `0` means it never expires. | +| `DELETE` | `/tokens/{id}` | Revoked immediately. | + +The plaintext token comes back from `POST` and never again — only its hash is +stored. The listing shows a `prefix`, which is enough to tell two of your own +tokens apart. + +!!! warning "These three need a signed-in session" + A bearer token is refused here with `403`, even though it is accepted + everywhere else. Otherwise a leaked token would be permanent: whoever had it + could issue a second one, and revoking the first would change nothing. + ### Everything else | Method | Path | | diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go index c96dba1..b308537 100644 --- a/internal/httpapi/auth_handlers.go +++ b/internal/httpapi/auth_handlers.go @@ -156,6 +156,72 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, toUserJSON(userFrom(r.Context()))) } +type updateProfileRequest struct { + Name *string `json:"name"` + Timezone *string `json:"timezone"` + Locale *string `json:"locale"` +} + +// handleUpdateProfile changes name, timezone and language. +// +// The timezone is not cosmetic: every date in the app is resolved in it, so "i +// morgen kl 9" means a different instant after this call than before. Which is +// exactly why it has to be changeable — an account created from an invite +// inherits a default, and somebody in another country would otherwise have every +// due date land an hour out with no way to say so. +func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + var req updateProfileRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + user := userFrom(r.Context()) + + name, timezone, locale := user.Name, user.Timezone, user.Locale + fields := map[string]string{} + + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + if name == "" { + fields["name"] = "required" + } else if utf8.RuneCountInString(name) > 80 { + fields["name"] = "must be at most 80 characters" + } + } + if req.Timezone != nil { + timezone = strings.TrimSpace(*req.Timezone) + // Checked against the running system's zone database rather than a list + // kept here: a hardcoded list goes stale every time a country moves its + // clocks, and this one is the same database the dates are resolved with. + if _, err := time.LoadLocation(timezone); err != nil || timezone == "" { + fields["timezone"] = "must be a zone like Europe/Copenhagen" + } + } + if req.Locale != nil { + locale = strings.TrimSpace(*req.Locale) + // The locale picks which quick-add grammar parses a line, so an unknown + // one would quietly stop "i morgen" being understood. + if locale != "da" && locale != "en" { + fields["locale"] = "must be da or en" + } + } + if len(fields) > 0 { + writeFieldErrors(w, fields) + return + } + + if err := s.db.UpdateProfile(r.Context(), user.ID, name, timezone, locale); err != nil { + s.storeError(w, "update profile", err) + return + } + + updated, err := s.db.UserByID(r.Context(), user.ID) + if err != nil { + s.internal(w, "reload user", err) + return + } + writeJSON(w, http.StatusOK, toUserJSON(updated)) +} + // --- creating accounts -------------------------------------------------------- type signupRequest struct { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 91f86b7..8f47513 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -131,6 +131,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Group(func(r chi.Router) { r.Use(s.requireAuth) r.Get("/me", s.handleMe) + r.Patch("/me", s.handleUpdateProfile) r.Post("/logout", s.handleLogout) r.Post("/password/change", s.handleChangePassword) r.Post("/totp/setup", s.handleTOTPSetup) @@ -190,6 +191,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Get("/mail-address", s.handleGetMailAddress) r.Post("/mail-address/rotate", s.handleRotateMailAddress) + // Personal API tokens. Behind requireSession rather than the ambient + // auth: a token must not be able to mint or revoke another. + r.Route("/tokens", func(r chi.Router) { + r.Use(s.requireSession) + r.Get("/", s.handleListAPITokens) + r.Post("/", s.handleCreateAPIToken) + r.Delete("/{tokenID}", s.handleDeleteAPIToken) + }) + r.Route("/ai", func(r chi.Router) { r.Get("/settings", s.handleGetAISettings) r.Put("/settings", s.handleSetAISettings) diff --git a/internal/httpapi/tokens_handlers.go b/internal/httpapi/tokens_handlers.go new file mode 100644 index 0000000..875ab86 --- /dev/null +++ b/internal/httpapi/tokens_handlers.go @@ -0,0 +1,143 @@ +package httpapi + +import ( + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +// Personal API tokens: what a script, a CalDAV client or an MCP connector +// authenticates with. The store has held them since the first migration; this is +// the surface that lets somebody actually make one. +// +// Every route here needs a *session*, not merely an authenticated caller — see +// requireSession. Managing credentials is something a person does in a browser. + +type apiTokenJSON struct { + ID string `json:"id"` + Name string `json:"name"` + // Prefix is the first few characters, which is all that is ever shown again. + Prefix string `json:"prefix"` + CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +// createdTokenJSON is the only response that carries the token itself, and only +// the once. The store keeps a hash, so there is nothing to reveal later even if +// the interface offered to. +type createdTokenJSON struct { + apiTokenJSON + Token string `json:"token"` +} + +func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) { + tokens, err := s.db.ListAPITokens(r.Context(), userFrom(r.Context()).ID) + if err != nil { + s.internal(w, "list api tokens", err) + return + } + + out := make([]apiTokenJSON, 0, len(tokens)) + for _, t := range tokens { + j := apiTokenJSON{ + ID: t.ID, Name: t.Name, Prefix: t.Prefix, + CreatedAt: t.CreatedAt.Format(time.RFC3339), + } + if t.LastUsedAt != nil { + j.LastUsedAt = t.LastUsedAt.Format(time.RFC3339) + } + if t.ExpiresAt != nil { + j.ExpiresAt = t.ExpiresAt.Format(time.RFC3339) + } + out = append(out, j) + } + writeJSON(w, http.StatusOK, map[string]any{"tokens": out}) +} + +type createTokenRequest struct { + Name string `json:"name"` + // ExpiresInDays is optional; 0 means the token does not expire, which is what + // a calendar client subscribed for years actually needs. + ExpiresInDays int `json:"expires_in_days"` +} + +func (s *Server) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) { + var req createTokenRequest + if err := decodeJSON(w, r, &req); err != nil { + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + writeFieldErrors(w, map[string]string{"name": "required"}) + return + } + if len([]rune(name)) > 80 { + writeFieldErrors(w, map[string]string{"name": "must be at most 80 characters"}) + return + } + // A negative lifetime would mint a token that is already dead — accepted + // silently it looks like the feature is broken rather than the input. + if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 { + writeFieldErrors(w, map[string]string{ + "expires_in_days": "must be between 0 and 3650, where 0 means no expiry", + }) + return + } + + var expires *time.Time + if req.ExpiresInDays > 0 { + at := time.Now().UTC().AddDate(0, 0, req.ExpiresInDays) + expires = &at + } + + plaintext, token, err := s.db.CreateAPIToken(r.Context(), userFrom(r.Context()).ID, name, expires) + if err != nil { + s.internal(w, "create api token", err) + return + } + + j := createdTokenJSON{ + apiTokenJSON: apiTokenJSON{ + ID: token.ID, Name: token.Name, Prefix: token.Prefix, + CreatedAt: token.CreatedAt.Format(time.RFC3339), + }, + Token: plaintext, + } + if expires != nil { + j.ExpiresAt = expires.Format(time.RFC3339) + } + s.log.Info("api token created", "user", userFrom(r.Context()).ID, "name", name) + writeJSON(w, http.StatusCreated, j) +} + +func (s *Server) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) { + err := s.db.DeleteAPIToken(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "tokenID")) + if err != nil { + s.storeError(w, "delete api token", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// requireSession refuses a caller who authenticated with an API token. +// +// authenticate() accepts a bearer token everywhere else on purpose: a token +// carries its owner's permissions exactly. Minting tokens is the one thing that +// must not follow that rule, because it is how a single leaked token becomes +// permanent access — the thief issues a second one and revoking the first +// changes nothing. Deleting is held to the same standard so a token cannot +// revoke the session-holder's other credentials either. +func (s *Server) requireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if sessionFrom(r.Context()) == nil { + writeError(w, http.StatusForbidden, CodeForbidden, + "API tokens are managed while signed in, not with an API token") + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/httpapi/tokens_test.go b/internal/httpapi/tokens_test.go new file mode 100644 index 0000000..00268c8 --- /dev/null +++ b/internal/httpapi/tokens_test.go @@ -0,0 +1,166 @@ +package httpapi + +import ( + "net/http" + "strings" + "testing" +) + +func TestAPITokenLifecycle(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := ts.do(t, "GET", "/api/v1/tokens", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("list: status %d, body %v", resp.StatusCode, body) + } + if tokens, _ := body["tokens"].([]any); len(tokens) != 0 { + t.Errorf("a fresh account already has tokens: %v", tokens) + } + + resp, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{ + "name": "min server", "expires_in_days": 30, + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create: status %d, body %v", resp.StatusCode, created) + } + + plaintext, _ := created["token"].(string) + if !strings.HasPrefix(plaintext, "vrd_") { + t.Fatalf("token %q does not carry the scanner-visible prefix", plaintext) + } + if created["expires_at"] == nil { + t.Error("an expiry was asked for and none came back") + } + prefix, _ := created["prefix"].(string) + if !strings.HasPrefix(plaintext, prefix) { + t.Errorf("prefix %q is not the start of the token", prefix) + } + + // The token authenticates as its owner. + req, err := http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+plaintext) + me, err := (&http.Client{}).Do(req) + if err != nil { + t.Fatalf("bearer request: %v", err) + } + defer me.Body.Close() + if me.StatusCode != http.StatusOK { + t.Errorf("the new token does not authenticate: status %d", me.StatusCode) + } + + // Listing shows it, without the secret. + _, body = ts.do(t, "GET", "/api/v1/tokens", nil) + tokens, _ := body["tokens"].([]any) + if len(tokens) != 1 { + t.Fatalf("want one token listed, got %v", tokens) + } + listed := tokens[0].(map[string]any) + if _, leaked := listed["token"]; leaked { + t.Error("the listing carries the token itself") + } + if listed["name"] != "min server" { + t.Errorf("name = %v", listed["name"]) + } + + id, _ := created["id"].(string) + resp, _ = ts.do(t, "DELETE", "/api/v1/tokens/"+id, nil) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("delete: status %d", resp.StatusCode) + } + + // And is dead the moment it is deleted. + req, _ = http.NewRequest("GET", ts.URL+"/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+plaintext) + me, err = (&http.Client{}).Do(req) + if err != nil { + t.Fatalf("bearer request: %v", err) + } + defer me.Body.Close() + if me.StatusCode != http.StatusUnauthorized { + t.Errorf("a deleted token still works: status %d", me.StatusCode) + } +} + +// A token must not be able to mint another one. Otherwise a leaked token is +// permanent: revoking it leaves behind the one it issued. +func TestAPITokenCannotMintAnother(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{"name": "første"}) + plaintext, _ := created["token"].(string) + + for _, tc := range []struct{ method, path string }{ + {"GET", "/api/v1/tokens"}, + {"POST", "/api/v1/tokens"}, + {"DELETE", "/api/v1/tokens/" + created["id"].(string)}, + } { + req, err := http.NewRequest(tc.method, ts.URL+tc.path, strings.NewReader(`{"name":"anden"}`)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+plaintext) + req.Header.Set("Content-Type", "application/json") + + resp, err := (&http.Client{}).Do(req) + if err != nil { + t.Fatalf("%s %s: %v", tc.method, tc.path, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("%s %s with a bearer token: status %d, want 403", + tc.method, tc.path, resp.StatusCode) + } + } +} + +func TestAPITokenValidation(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + for _, tc := range []struct { + name string + body map[string]any + field string + }{ + {"no name", map[string]any{"name": " "}, "name"}, + {"negative lifetime", map[string]any{"name": "x", "expires_in_days": -1}, "expires_in_days"}, + {"absurd lifetime", map[string]any{"name": "x", "expires_in_days": 4000}, "expires_in_days"}, + } { + t.Run(tc.name, func(t *testing.T) { + resp, body := ts.do(t, "POST", "/api/v1/tokens", tc.body) + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("status %d, want 422", resp.StatusCode) + } + fields, _ := body["fields"].(map[string]any) + if fields[tc.field] == nil { + t.Errorf("no error on %q: %v", tc.field, body) + } + }) + } +} + +// Somebody else's token id must delete nothing, and must not confirm it exists. +func TestAPITokenDeleteIsScopedToOwner(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + _, created := ts.do(t, "POST", "/api/v1/tokens", map[string]any{"name": "min"}) + id := created["id"].(string) + + other := ts.newUser(t, "anden@example.dk", "Anden") + resp, _ := other.do(t, "DELETE", "/api/v1/tokens/"+id, nil) + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status %d, want 404 for a token belonging to somebody else", resp.StatusCode) + } + + // Still there for its owner. + _, body := ts.do(t, "GET", "/api/v1/tokens", nil) + if tokens, _ := body["tokens"].([]any); len(tokens) != 1 { + t.Errorf("the owner's token was affected: %v", tokens) + } +} diff --git a/internal/store/users.go b/internal/store/users.go index 9ddf4b4..6784b3f 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -146,6 +146,24 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI }) } +// UpdateProfile changes the things a person may change about themselves. +// +// The email address is not among them: it identifies the account, it is what +// invites were sent to, and changing it is a re-verification flow rather than a +// field. Neither is is_admin — an account cannot promote itself. +func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error { + res, err := db.ExecContext(ctx, + `UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`, + name, timezone, locale, time.Now().Unix(), userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error { var value any if secret != "" {