feat(api): personal API tokens, and let people change their own profile
docs/api.md has told people to make a token under Settings since it was written. The store has held CreateAPIToken, ListAPITokens and DeleteAPIToken since the first migration. There were no routes: nothing between the two, and no way to make one. GET, POST and DELETE under /tokens, all behind a new requireSession middleware. A bearer token is refused there with 403 even though it is accepted everywhere else — otherwise a leaked token is permanent, because whoever has it issues a second one and revoking the first changes nothing. The plaintext comes back from POST and never again; only the hash is stored, so the interface has to say so at that moment rather than offering a reveal that cannot work. PATCH /auth/me alongside it. Name, timezone and language could not be changed anywhere — not through the API and not through the interface — and an account created from an invite inherits a default timezone. That is not cosmetic: every date in the app is resolved in it, so somebody in another country had every due date land an hour out with no way to say so. The zone is validated against the running system's database rather than a list kept here, which is the same database the dates are resolved with. The email address stays read-only: it identifies the account and is what invites were sent to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
06ab10a276
commit
68d82a1cb6
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user