move api/server to api/edge/bff

This commit is contained in:
Stephan D
2026-02-28 00:39:20 +01:00
parent 34182af3b8
commit 98db0e4e9e
248 changed files with 406 additions and 18 deletions

View File

@@ -0,0 +1,57 @@
package verificationimp
import (
"encoding/json"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/db/verification"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mask"
"github.com/tech/sendico/pkg/mutil/mzap"
emodel "github.com/tech/sendico/server/interface/model"
mutil "github.com/tech/sendico/server/internal/mutil/verification"
"go.uber.org/zap"
)
func (a *VerificationAPI) requestCode(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc {
var req verificationCodeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode confirmation resend request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
purpose, err := model.VPFromString(req.Purpose)
if err != nil {
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
if purpose == model.PurposeLogin && (token == nil || !token.Pending) {
return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token")
}
target := a.resolveTarget(req.Target, account)
if target == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
vReq := verification.NewOTPRequest(account.ID, purpose, target).
WithTTL(a.config.TTL).
WithCooldown(a.config.Cooldown).
WithMaxRetries(a.config.ResendLimit).
WithIdempotencyKey(req.IdempotencyKey)
otp, err := a.store.Create(r.Context(), vReq)
if err != nil {
a.logger.Warn("Failed to create confirmation code for resend", zap.Error(err), mzap.AccRef(account.ID))
return mutil.MapTokenErrorToResponse(a.logger, a.Name(), err)
}
a.sendCode(account, purpose, target, otp)
return response.Accepted(a.logger, verificationResponse{
TTLSeconds: int(vReq.Ttl.Seconds()),
CooldownSeconds: int(a.config.Cooldown.Seconds()),
Target: mask.Email(target),
IdempotencyKey: req.IdempotencyKey,
})
}

View File

@@ -0,0 +1,18 @@
package verificationimp
import (
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.uber.org/zap"
)
func (a *VerificationAPI) sendCode(account *model.Account, target model.VerificationPurpose, destination, code string) {
a.logger.Info("Confirmation code generated",
zap.String("target", string(target)),
mzap.MaskEmail("destination", destination),
mzap.AccRef(account.ID))
if err := a.producer.SendMessage(cnotifications.Code(a.Name(), account.ID, destination, target, code)); err != nil {
a.logger.Warn("Failed to send confirmation code notification", zap.Error(err), mzap.AccRef(account.ID))
}
}

View File

@@ -0,0 +1,80 @@
package verificationimp
import (
"context"
"time"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/messaging"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/middleware"
)
type Config struct {
CodeLength int
TTL time.Duration
MaxAttempts int
Cooldown time.Duration
ResendLimit int
}
func defaultConfig() Config {
return Config{
CodeLength: 6,
TTL: 10 * time.Minute,
MaxAttempts: 5,
Cooldown: time.Minute,
ResendLimit: 5,
}
}
func DefaultConfig() Config {
return defaultConfig()
}
type VerificationAPI struct {
logger mlogger.Logger
config Config
store *ConfirmationStore
rtdb refreshtokens.DB
producer messaging.Producer
tokenConfig middleware.TokenConfig
signature middleware.Signature
}
func (a *VerificationAPI) Name() mservice.Type {
return mservice.Verification
}
func (a *VerificationAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a eapi.API) (*VerificationAPI, error) {
cdb, err := a.DBFactory().NewVerificationsDB()
if err != nil {
return nil, err
}
rtdb, err := a.DBFactory().NewRefreshTokensDB()
if err != nil {
return nil, err
}
p := &VerificationAPI{
logger: a.Logger().Named(mservice.Verification),
config: defaultConfig(),
store: NewStore(cdb),
rtdb: rtdb,
producer: a.Register().Messaging().Producer(),
tokenConfig: a.Config().Mw.Token,
signature: middleware.SignatureConf(a.Config().Mw),
}
a.Register().PendingAccountHandler(p.Name(), "/", api.Post, p.requestCode)
a.Register().PendingAccountHandler(p.Name(), "/resend", api.Post, p.requestCode)
a.Register().PendingAccountHandler(p.Name(), "/verify", api.Post, p.verifyCode)
return p, nil
}

View File

@@ -0,0 +1,41 @@
package verificationimp
import (
"context"
"github.com/tech/sendico/pkg/db/verification"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ConfirmationStore struct {
db verification.DB
}
func NewStore(db verification.DB) *ConfirmationStore {
return &ConfirmationStore{db: db}
}
func (s *ConfirmationStore) Create(
ctx context.Context,
request *verification.Request,
) (verificationCode string, err error) {
return s.db.Create(ctx, request)
}
func (s *ConfirmationStore) Verify(
ctx context.Context,
accountRef bson.ObjectID,
purpose model.VerificationPurpose,
code string,
) (target string, err error) {
t, err := s.db.Consume(ctx, accountRef, purpose, code)
if err != nil {
return "", err
}
if t.Purpose != purpose {
return "", merrors.DataConflict("token has different verificaton purpose")
}
return t.Target, nil
}

View File

@@ -0,0 +1,15 @@
package verificationimp
import (
"strings"
"github.com/tech/sendico/pkg/model"
)
func (a *VerificationAPI) resolveTarget(reqDest string, account *model.Account) string {
target := strings.ToLower(strings.TrimSpace(reqDest))
if target == "" && account != nil {
target = strings.ToLower(strings.TrimSpace(account.Login))
}
return target
}

View File

@@ -0,0 +1,20 @@
package verificationimp
import (
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
)
func (a *VerificationAPI) createAccessToken(account *model.Account) (sresponse.TokenData, error) {
ja := jwtauth.New(a.signature.Algorithm, a.signature.PrivateKey, a.signature.PublicKey)
_, res, err := ja.Encode(emodel.Account2Claims(account, a.tokenConfig.Expiration.Account))
token := sresponse.TokenData{
Token: res,
Expiration: time.Now().Add(time.Duration(a.tokenConfig.Expiration.Account) * time.Hour),
}
return token, err
}

View File

@@ -0,0 +1,24 @@
package verificationimp
import (
"github.com/tech/sendico/pkg/model"
)
type verificationCodeRequest struct {
Purpose string `json:"purpose"`
Target string `json:"target,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type codeVerificationRequest struct {
verificationCodeRequest `json:",inline"`
Code string `json:"code"`
SessionIdentifier model.SessionIdentifier `json:"sessionIdentifier"`
}
type verificationResponse struct {
IdempotencyKey string `json:"idempotencyKey"`
TTLSeconds int `json:"ttl_seconds"`
CooldownSeconds int `json:"cooldown_seconds"`
Target string `json:"target"`
}

View File

@@ -0,0 +1,82 @@
package verificationimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
"github.com/tech/sendico/server/interface/api/sresponse"
emodel "github.com/tech/sendico/server/interface/model"
rtokens "github.com/tech/sendico/server/internal/api/routers/tokens"
mutil "github.com/tech/sendico/server/internal/mutil/verification"
"go.uber.org/zap"
)
func (a *VerificationAPI) verifyCode(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc {
var req codeVerificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode confirmation verification request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
purpose, err := model.VPFromString(req.Purpose)
if err != nil {
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
code := strings.TrimSpace(req.Code)
if code == "" {
return response.BadRequest(a.logger, a.Name(), "missing_code", "confirmation code is required")
}
target := a.resolveTarget(req.Target, account)
if target == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
dst, err := a.store.Verify(r.Context(), account.ID, purpose, code)
if err != nil {
a.logger.Debug("Code verification failed", zap.Error(err),
mzap.AccRef(account.ID), zap.String("purpose", req.Purpose),
)
return mutil.MapTokenErrorToResponse(a.logger, a.Name(), err)
}
if dst != target {
a.logger.Warn("Verification code destination mismatch", zap.String("expected", target), zap.String("actual", dst), mzap.AccRef(account.ID))
return response.DataConflict(a.logger, a.Name(), "the provided code does not match the expected destination")
}
a.logger.Info("Confirmation code verified", zap.String("purpose", req.Purpose), mzap.AccRef(account.ID))
if purpose == model.PurposeLogin {
if req.SessionIdentifier.ClientID == "" || req.SessionIdentifier.DeviceID == "" {
return response.BadRequest(a.logger, a.Name(), "missing_session", "session identifier is required")
}
accessToken, err := a.createAccessToken(account)
if err != nil {
a.logger.Warn("Failed to generate access token", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
refreshToken, err := rtokens.PrepareRefreshToken(
r.Context(),
r,
a.rtdb,
a.tokenConfig.Length,
a.tokenConfig.Expiration.Refresh,
&req.SessionIdentifier,
account,
a.logger,
)
if err != nil {
a.logger.Warn("Failed to generate refresh token", zap.Error(err))
return response.Internal(a.logger, a.Name(), err)
}
rt := sresponse.TokenData{
Token: refreshToken.RefreshToken,
Expiration: refreshToken.ExpiresAt,
}
return sresponse.Login(a.logger, account, &accessToken, &rt)
}
return response.Success(a.logger)
}