189 lines
7.4 KiB
Go
189 lines
7.4 KiB
Go
// 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)
|
|
}
|