Files
2026-09-03 08:36:04 +05:30

130 lines
3.5 KiB
Go

package api
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"sync"
"time"
"github.com/google/uuid"
"ferrum/internal/auth"
)
// pendingLogins bridges authLogin (password verified) to authLoginTOTP (code
// verified) for accounts with 2FA enabled — mirrors the short-lived,
// single-use session-token pattern already used for console hand-off.
var (
pendingLoginsMu sync.Mutex
pendingLogins = map[string]pendingLogin{}
)
type pendingLogin struct {
userID string
expires time.Time
}
func newPendingLogin(userID string) string {
token := uuid.NewString()
now := time.Now()
pendingLoginsMu.Lock()
for id, p := range pendingLogins {
if now.After(p.expires.Add(5 * time.Minute)) {
delete(pendingLogins, id)
}
}
pendingLogins[token] = pendingLogin{userID: userID, expires: now.Add(2 * time.Minute)}
pendingLoginsMu.Unlock()
return token
}
func consumePendingLogin(token string) (userID string, ok bool) {
pendingLoginsMu.Lock()
defer pendingLoginsMu.Unlock()
p, found := pendingLogins[token]
if found {
delete(pendingLogins, token)
}
if !found || time.Now().After(p.expires) {
return "", false
}
return p.userID, true
}
func (s *Server) totpStatus(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r)
enabled, remaining, err := s.auth.TOTPStatus(r.Context(), u.ID)
if err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"enabled": enabled, "remainingRecoveryCodes": remaining})
}
func (s *Server) totpEnroll(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r)
enrollment, err := s.auth.EnrollTOTP(r.Context(), u.ID, u.Username)
if err != nil {
if errors.Is(err, auth.ErrTOTPAlreadyEnabled) {
writeErrorMsg(w, http.StatusConflict, "two-factor authentication is already enabled")
return
}
s.writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"secret": enrollment.Secret,
"otpauthUrl": enrollment.OTPAuthURL,
"qrCodePng": base64.StdEncoding.EncodeToString(enrollment.QRCodePNG),
})
}
type confirmTOTPRequest struct {
Code string `json:"code"`
}
func (s *Server) totpConfirm(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r)
var req confirmTOTPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
codes, err := s.auth.ConfirmTOTP(r.Context(), u.ID, req.Code)
if err != nil {
if errors.Is(err, auth.ErrInvalidTOTPCode) {
writeErrorMsg(w, http.StatusBadRequest, "invalid code — check your authenticator app and try again")
return
}
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "auth.2fa.enable", "auth", u.Username)
writeJSON(w, http.StatusOK, map[string]any{"recoveryCodes": codes})
}
type disableTOTPRequest struct {
Password string `json:"password"`
}
func (s *Server) totpDisable(w http.ResponseWriter, r *http.Request) {
u := userFromContext(r)
var req disableTOTPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.writeError(w, http.StatusBadRequest, err)
return
}
if _, err := s.auth.VerifyPassword(r.Context(), u.Username, req.Password); err != nil {
writeErrorMsg(w, http.StatusUnauthorized, "incorrect password")
return
}
if err := s.auth.DisableTOTP(r.Context(), u.ID); err != nil {
s.writeError(w, http.StatusInternalServerError, err)
return
}
s.audit(r, "auth.2fa.disable", "auth", u.Username)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}