Files
verdande/internal/httpapi/totp_handlers.go
T
Kristian WindandClaude Opus 5 1bb644f4d1 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>
2026-08-16 23:31:29 +02:00

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, "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 + ")"
}