Release / Build and publish image (push) Failing after 12m56s
The panel's watcher reported four 5xx today — twice from one address at 11:49, twice from another at 10:45 — and there is no way to find out what any of them were. verdande logs the real cause of every 500, but on a Rune the log belongs to the container and the container is replaced on every restart. So an operator is told that something broke and given nothing to act on, which is the worst of both: alarming and useless. Every 500 now writes a row: what the handler was attempting in its own words, the error it hit, the method and path, who was asking, and the request id that ties it back to the log line for whoever still has it. The "what" is the part a status code cannot give — "list projects" and "move task" are the difference between a fault you can find and one you can only count. Written best-effort and never checked. This is the path that is already reporting a failure, and a diagnostic that can itself fail the request would turn one broken screen into two. Threading the request through `internal()` and `storeError()` touched 108 call sites across 19 files, which is the price of not having had it there from the start. It is mechanical and the compiler checked all of it. Kept for thirty days by the nightly sweep: long enough to answer "what happened last week", short enough that a fault loop cannot fill the volume the tasks live on. Read at Indstillinger → Fejl, administrators only — it carries paths, account names and the server's own error strings. The empty state says "Ingen fejl registreret. Det er den rigtige tilstand", because a blank panel should not read as a broken one. The test drops a table and asks for the endpoint that reads it: the same code path a disk error takes, and the row that comes back has to name the operation, the account and the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
165 lines
4.9 KiB
Go
165 lines
4.9 KiB
Go
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, r, "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, r, "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, r, "generate recovery codes", err)
|
|
return
|
|
}
|
|
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
|
s.internal(w, r, "store recovery codes", err)
|
|
return
|
|
}
|
|
if err := s.db.SetTOTPSecret(r.Context(), user.ID, user.TOTPSecret, true); err != nil {
|
|
s.internal(w, r, "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, r, "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, CodeTOTPNotEnabled,
|
|
"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, r, "generate recovery codes", err)
|
|
return
|
|
}
|
|
if err := s.db.ReplaceRecoveryCodes(r.Context(), user.ID, hashes); err != nil {
|
|
s.internal(w, r, "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, r, "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 + ")"
|
|
}
|