move api/server to api/edge/bff
This commit is contained in:
57
api/edge/bff/internal/server/verificationimp/request.go
Normal file
57
api/edge/bff/internal/server/verificationimp/request.go
Normal 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,
|
||||
})
|
||||
}
|
||||
18
api/edge/bff/internal/server/verificationimp/sendcode.go
Normal file
18
api/edge/bff/internal/server/verificationimp/sendcode.go
Normal 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))
|
||||
}
|
||||
}
|
||||
80
api/edge/bff/internal/server/verificationimp/service.go
Normal file
80
api/edge/bff/internal/server/verificationimp/service.go
Normal 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
|
||||
}
|
||||
41
api/edge/bff/internal/server/verificationimp/store.go
Normal file
41
api/edge/bff/internal/server/verificationimp/store.go
Normal 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
|
||||
}
|
||||
15
api/edge/bff/internal/server/verificationimp/target.go
Normal file
15
api/edge/bff/internal/server/verificationimp/target.go
Normal 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
|
||||
}
|
||||
20
api/edge/bff/internal/server/verificationimp/token.go
Normal file
20
api/edge/bff/internal/server/verificationimp/token.go
Normal 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
|
||||
}
|
||||
24
api/edge/bff/internal/server/verificationimp/types.go
Normal file
24
api/edge/bff/internal/server/verificationimp/types.go
Normal 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"`
|
||||
}
|
||||
82
api/edge/bff/internal/server/verificationimp/verify.go
Normal file
82
api/edge/bff/internal/server/verificationimp/verify.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user