The setup screen showed the secret and the otpauth URI as text and asked you to scan — with nothing to scan. It renders the URI as a QR now, an SVG (one dark path on a white card, so it reads in either theme) generated from the barcode encoder already in the module graph via pquerna/otp. The secret stays below it for a device that cannot scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
190 lines
5.9 KiB
Go
190 lines
5.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"`
|
|
// The URI as a scannable QR code (SVG). The secret and URI stay for typing in by
|
|
// hand on a device that cannot scan.
|
|
QR string `json:"qr"`
|
|
}
|
|
|
|
func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
|
// The password, the same as disable has always asked for.
|
|
//
|
|
// Symmetry is the point. Turning the second factor on is at least as
|
|
// consequential as turning it off — it decides which device gets to say yes
|
|
// from now on — and a door that opens more easily than it closes is not a
|
|
// door. Together with the move behind requireSession, this is what stops a
|
|
// stolen credential from enrolling its own authenticator and keeping the only
|
|
// copy of the recovery codes.
|
|
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 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
|
|
}
|
|
qrSVG, err := auth.TOTPQRSVG(uri)
|
|
if err != nil {
|
|
s.internal(w, r, "render totp qr", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: uri, QR: qrSVG})
|
|
}
|
|
|
|
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 + ")"
|
|
}
|