Make product API versions independently installable
This commit is contained in:
1 parent
ce6583f454
commit
d04c8b12ab
69 files changed
+755
-404
No files matched your search
@@ -0,0 +1,188 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user