sessionRotateToken replaces the bootstrap auth token with a caller- supplied value. Requires the *current* token in the body even when the request is authenticated, so a stolen session alone cannot rotate the secret. All existing sessions are invalidated; clients must re-login with the new token.
(w http.ResponseWriter, r *http.Request)
| 499 | // the secret. All existing sessions are invalidated; clients must |
| 500 | // re-login with the new token. |
| 501 | func (s *Server) sessionRotateToken(w http.ResponseWriter, r *http.Request) { |
| 502 | r.Body = http.MaxBytesReader(w, r.Body, 4<<10) |
| 503 | var req struct { |
| 504 | CurrentToken string `json:"currentToken"` |
| 505 | NewToken string `json:"newToken"` |
| 506 | } |
| 507 | if err := readJSON(r, &req); err != nil { |
| 508 | http.Error(w, err.Error(), http.StatusBadRequest) |
| 509 | return |
| 510 | } |
| 511 | current := strings.TrimSpace(req.CurrentToken) |
| 512 | next := strings.TrimSpace(req.NewToken) |
| 513 | if len(next) < 16 { |
| 514 | http.Error(w, "new token must be at least 16 characters", http.StatusBadRequest) |
| 515 | return |
| 516 | } |
| 517 | if next == current { |
| 518 | http.Error(w, "new token must differ from current", http.StatusBadRequest) |
| 519 | return |
| 520 | } |
| 521 | |
| 522 | s.mu.Lock() |
| 523 | cfgCurrent := s.Config.AuthToken |
| 524 | sourceCurrent := s.Config.AuthTokenSource |
| 525 | if sourceCurrent == config.AuthTokenSourceEnv || sourceCurrent == config.AuthTokenSourceFile { |
| 526 | s.mu.Unlock() |
| 527 | http.Error(w, "auth token is managed outside ZenNotes; update the token source and restart", http.StatusConflict) |
| 528 | return |
| 529 | } |
| 530 | if !subtleCompare(current, strings.TrimSpace(cfgCurrent)) { |
| 531 | s.mu.Unlock() |
| 532 | http.Error(w, "current token mismatch", http.StatusUnauthorized) |
| 533 | return |
| 534 | } |
| 535 | s.Config.AuthToken = next |
| 536 | s.Config.AuthTokenSource = config.AuthTokenSourceConfig |
| 537 | cfgCopy := s.Config |
| 538 | s.mu.Unlock() |
| 539 | |
| 540 | if err := config.SaveHost(cfgCopy); err != nil { |
| 541 | s.mu.Lock() |
| 542 | s.Config.AuthToken = cfgCurrent |
| 543 | s.Config.AuthTokenSource = sourceCurrent |
| 544 | s.mu.Unlock() |
| 545 | writeError(w, err) |
| 546 | return |
| 547 | } |
| 548 | s.sessions.deleteAll() |
| 549 | http.SetCookie(w, s.clearSessionCookie(r)) |
| 550 | writeJSON(w, http.StatusOK, map[string]any{"rotated": true}) |
| 551 | } |
| 552 | |
| 553 | func subtleCompare(left string, right string) bool { |
| 554 | if len(left) == 0 || len(right) == 0 { |
nothing calls this directly
no test coverage detected