// Package factorhttp exposes authenticated factor settings. Operation scope and // account ownership are resolved on the server; raw proofs never cross HTTP. package factorhttp import ( "encoding/json" "errors" "io" "math" "net/http" "strconv" "time" sresponse "git.sendico.io/tech/sxchnge/product/auth/http/v1/response" "git.sendico.io/tech/sxchnge/product/auth/ipguard" customer "git.sendico.io/tech/sxchnge/product/customers/model" "git.sendico.io/tech/sxchnge/product/twofactor" model "git.sendico.io/tech/sxchnge/product/twofactor/model" verification "git.sendico.io/tech/sxchnge/product/verification/store" merrors "git.sendico.io/tech/sxchnge/shared/errors" api "git.sendico.io/tech/sxchnge/shared/http" "git.sendico.io/tech/sxchnge/shared/http/response" mlogger "git.sendico.io/tech/sxchnge/shared/logging" mservice "git.sendico.io/tech/sxchnge/shared/service" ) type Registrar interface { AccountHandler(mservice.Type, string, api.HTTPMethod, sresponse.AccountHandlerFunc) } type router struct { service *twofactor.Service logger mlogger.Logger } func Install(reg Registrar, service *twofactor.Service, logger mlogger.Logger) { h := &router{service: service, logger: logger.Named("twofactor_http")} reg.AccountHandler(mservice.Accounts, "/two-factor", api.Get, h.settings) reg.AccountHandler(mservice.Accounts, "/two-factor/enroll", api.Post, h.enroll) reg.AccountHandler(mservice.Accounts, "/two-factor/enroll/verify", api.Post, h.confirmEnrollment) reg.AccountHandler(mservice.Accounts, "/two-factor/preference", api.Post, h.preference) reg.AccountHandler(mservice.Accounts, "/two-factor/preference/verify", api.Post, h.confirmPreference) reg.AccountHandler(mservice.Accounts, "/two-factor/resend", api.Post, h.resend) } func decode(r *http.Request, dst any) error { d := json.NewDecoder(io.LimitReader(r.Body, 8193)) d.DisallowUnknownFields() if err := d.Decode(dst); err != nil { return merrors.InvalidArgument("invalid request") } var extra any if err := d.Decode(&extra); err != io.EOF { return merrors.InvalidArgument("invalid request") } return nil } func requestIP(r *http.Request) string { if ip := ipguard.ClientIP(r); ip != nil { return ip.String() } return "unresolved" } func (h *router) result(data any, err error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") if err == nil { response.Accepted(h.logger, data).ServeHTTP(w, r) return } var handler http.HandlerFunc if throttle, ok := verification.Throttle(err); ok { w.Header().Set("Retry-After", strconv.Itoa(max(1, int(math.Ceil(throttle.RetryAfter.Seconds()))))) handler = response.TooManyRequests(h.logger, mservice.Accounts, "Wait before requesting another code") } else { switch { case errors.Is(err, verification.ErrTokenExpired): handler = response.BadRequest(h.logger, mservice.Accounts, "expired", "Verification expired; restart the operation") case errors.Is(err, verification.ErrTokenAttemptsExceeded): handler = response.BadRequest(h.logger, mservice.Accounts, "locked", "Too many attempts; restart the operation") case errors.Is(err, verification.ErrTokenNotFound), errors.Is(err, verification.ErrTokenAlreadyUsed), errors.Is(err, merrors.ErrNoData): handler = response.BadRequest(h.logger, mservice.Accounts, "invalid", "Verification is invalid or already used") case errors.Is(err, merrors.ErrInvalidArg): handler = response.BadRequest(h.logger, mservice.Accounts, "invalid_request", "Check the request fields") case errors.Is(err, merrors.ErrUnauthorized): handler = response.Unauthorized(h.logger, mservice.Accounts, "Verification settings changed or account is inactive; restart the operation") case errors.Is(err, merrors.ErrDataConflict): handler = response.DataConflict(h.logger, mservice.Accounts, "Factor or request already exists") case errors.Is(err, merrors.ErrNotImplemented): handler = response.NotImplemented(h.logger, mservice.Accounts, "Second-factor channel is unavailable") default: h.logger.Warn("Second-factor settings dependency failed") handler = response.Error(h.logger, mservice.Accounts, http.StatusServiceUnavailable, "unavailable", "Unable to complete verification; retry") } } handler.ServeHTTP(w, r) } } type challengeDTO struct { ID string `json:"challengeId"` Channel model.Channel `json:"channel"` Target string `json:"target"` ExpiresAt time.Time `json:"expiresAt"` ResendAt time.Time `json:"resendAt"` } func mapChallenge(c model.Challenge) challengeDTO { return challengeDTO{c.ID, c.Channel, c.Target, c.ExpiresAt, c.ResendAt} } type choiceDTO struct { ID string `json:"id"` Channel model.Channel `json:"channel"` Target string `json:"target"` } type settingsDTO struct { Required bool `json:"required"` Preferred string `json:"preferred"` Factors []choiceDTO `json:"factors"` } func mapSettings(s model.Settings) settingsDTO { result := settingsDTO{Required: s.Required, Preferred: s.Preferred, Factors: []choiceDTO{}} for _, f := range s.Factors { result.Factors = append(result.Factors, choiceDTO{f.ID, f.Channel, f.Target}) } return result } func (h *router) settings(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { s, err := h.service.Settings(r.Context(), a.ID) return h.result(mapSettings(s), err) } func (h *router) enroll(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { var req struct { Channel model.Channel `json:"channel"` Address string `json:"address"` Key string `json:"idempotencyKey"` } if err := decode(r, &req); err != nil { return h.result(nil, err) } c, err := h.service.Enroll(r.Context(), a.ID, req.Channel, req.Address, req.Key, requestIP(r)) return h.result(mapChallenge(c), err) } func (h *router) confirmEnrollment(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { var req struct { ID string `json:"challengeId"` Code string `json:"code"` } if err := decode(r, &req); err != nil { return h.result(nil, err) } s, err := h.service.ConfirmEnrollment(r.Context(), a.ID, req.ID, req.Code) return h.result(mapSettings(s), err) } func (h *router) preference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { var req struct { ID string `json:"factorId"` Key string `json:"idempotencyKey"` } if err := decode(r, &req); err != nil { return h.result(nil, err) } c, err := h.service.BeginPreference(r.Context(), a.ID, req.ID, req.Key, requestIP(r)) return h.result(mapChallenge(c), err) } func (h *router) confirmPreference(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { var req struct { Factor string `json:"factorId"` ID string `json:"challengeId"` Code string `json:"code"` } if err := decode(r, &req); err != nil { return h.result(nil, err) } s, err := h.service.ConfirmPreference(r.Context(), a.ID, req.Factor, req.ID, req.Code) return h.result(mapSettings(s), err) } func (h *router) resend(r *http.Request, a *customer.Account, _ *sresponse.TokenData) http.HandlerFunc { var req struct { ID string `json:"challengeId"` Key string `json:"idempotencyKey"` } if err := decode(r, &req); err != nil { return h.result(nil, err) } c, err := h.service.ResendSettings(r.Context(), a.ID, req.ID, req.Key, requestIP(r)) return h.result(mapChallenge(c), err) }