unified code verification service

This commit is contained in:
Stephan D
2026-02-10 01:55:33 +01:00
parent 76c3bfdea9
commit 7f540671c1
120 changed files with 1863 additions and 1394 deletions

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
@@ -17,7 +18,7 @@ func (a *AccountAPI) verify(r *http.Request) http.HandlerFunc {
// Get user
ctx := r.Context()
// Delete verification token to confirm account
t, err := a.vdb.Consume(ctx, token)
t, err := a.vdb.Consume(ctx, bson.NilObjectID, model.PurposeAccountActivation, token)
if err != nil {
a.logger.Debug("Failed to consume verification token", zap.Error(err))
return a.mapTokenErrorToResponse(err)

View File

@@ -128,7 +128,7 @@ func (a *AccountAPI) resetPassword(r *http.Request) http.HandlerFunc {
return response.Auto(a.logger, a.Name(), err)
}
t, err := a.vdb.Consume(ctx, token)
t, err := a.vdb.Consume(ctx, accountRef, model.PurposePasswordReset, token)
if err != nil {
a.logger.Warn("Failed to consume password reset token", zap.Error(err), zap.String("token", token))
return a.mapTokenErrorToResponse(err)

View File

@@ -1,48 +0,0 @@
package confirmationimp
import (
"encoding/json"
"net/http"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
func (a *ConfirmationAPI) requestCode(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc {
var req confirmationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
a.logger.Warn("Failed to decode confirmation request", zap.Error(err))
return response.BadPayload(a.logger, a.Name(), err)
}
target, err := a.parseTarget(req.Target)
if err != nil {
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
if target == model.ConfirmationTargetLogin && (token == nil || !token.Pending) {
return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token")
}
destination := a.resolveDestination(req.Destination, account)
if destination == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
code, rec, err := a.store.Create(r.Context(), account.ID, destination, target, a.config, a.generateCode)
if err != nil {
a.logger.Warn("Failed to create confirmation code", zap.Error(err), mzap.ObjRef("account_ref", account.ID))
return response.Internal(a.logger, a.Name(), err)
}
a.sendCode(account, target, destination, code)
return response.Accepted(a.logger, confirmationResponse{
TTLSeconds: int(time.Until(rec.ExpiresAt).Seconds()),
CooldownSeconds: int(a.config.Cooldown.Seconds()),
Destination: maskEmail(destination),
})
}

View File

@@ -1,56 +0,0 @@
package confirmationimp
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mzap"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
func (a *ConfirmationAPI) resendCode(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc {
var req confirmationRequest
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)
}
target, err := a.parseTarget(req.Target)
if err != nil {
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
if target == model.ConfirmationTargetLogin && (token == nil || !token.Pending) {
return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token")
}
destination := a.resolveDestination(req.Destination, account)
if destination == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
code, rec, err := a.store.Resend(r.Context(), account.ID, destination, target, a.config, a.generateCode)
switch {
case errors.Is(err, errConfirmationNotFound):
return response.NotFound(a.logger, a.Name(), "no_active_code_for_resend")
case errors.Is(err, errConfirmationCooldown):
return response.Forbidden(a.logger, a.Name(), "cooldown_active", "please wait before requesting another code")
case errors.Is(err, errConfirmationResendLimit):
return response.Forbidden(a.logger, a.Name(), "resend_limit_reached", "too many resend attempts")
case err != nil:
a.logger.Warn("Failed to resend confirmation code", zap.Error(err), mzap.ObjRef("account_ref", account.ID))
return response.Internal(a.logger, a.Name(), err)
}
a.sendCode(account, target, destination, code)
return response.Accepted(a.logger, confirmationResponse{
TTLSeconds: int(time.Until(rec.ExpiresAt).Seconds()),
CooldownSeconds: int(a.config.Cooldown.Seconds()),
Destination: maskEmail(destination),
})
}

View File

@@ -1,158 +0,0 @@
package confirmationimp
import (
"context"
"crypto/rand"
"fmt"
"strings"
"time"
"github.com/go-chi/jwtauth/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/messaging"
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
eapi "github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
emodel "github.com/tech/sendico/server/interface/model"
"go.uber.org/zap"
)
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 ConfirmationAPI struct {
logger mlogger.Logger
config Config
store *ConfirmationStore
rtdb refreshtokens.DB
producer messaging.Producer
tokenConfig middleware.TokenConfig
signature middleware.Signature
}
func (a *ConfirmationAPI) Name() mservice.Type {
return mservice.Confirmations
}
func (a *ConfirmationAPI) Finish(_ context.Context) error {
return nil
}
func CreateAPI(a eapi.API) (*ConfirmationAPI, error) {
cdb, err := a.DBFactory().NewConfirmationsDB()
if err != nil {
return nil, err
}
rtdb, err := a.DBFactory().NewRefreshTokensDB()
if err != nil {
return nil, err
}
p := &ConfirmationAPI{
logger: a.Logger().Named(mservice.Confirmations),
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.resendCode)
a.Register().PendingAccountHandler(p.Name(), "/verify", api.Post, p.verifyCode)
return p, nil
}
func (a *ConfirmationAPI) generateCode() (string, error) {
const digits = "0123456789"
b := make([]byte, a.config.CodeLength)
_, err := rand.Read(b)
if err != nil {
return "", err
}
for i := range b {
b[i] = digits[int(b[i])%len(digits)]
}
return string(b), nil
}
func (a *ConfirmationAPI) parseTarget(raw string) (model.ConfirmationTarget, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case string(model.ConfirmationTargetLogin):
return model.ConfirmationTargetLogin, nil
case string(model.ConfirmationTargetPayout):
return model.ConfirmationTargetPayout, nil
default:
return "", merrors.InvalidArgument(fmt.Sprintf("unsupported target '%s'", raw), "target")
}
}
func (a *ConfirmationAPI) resolveDestination(reqDest string, account *model.Account) string {
destination := strings.ToLower(strings.TrimSpace(reqDest))
if destination == "" && account != nil {
destination = strings.ToLower(strings.TrimSpace(account.Login))
}
return destination
}
func (a *ConfirmationAPI) sendCode(account *model.Account, target model.ConfirmationTarget, destination, code string) {
a.logger.Info("Confirmation code generated",
zap.String("target", string(target)),
zap.String("destination", maskEmail(destination)),
mzap.ObjRef("account_ref", 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.ObjRef("account_ref", account.ID))
}
a.logger.Debug("Confirmation code debug dump", zap.String("code", code))
}
func maskEmail(email string) string {
parts := strings.Split(email, "@")
if len(parts) != 2 {
return email
}
local := parts[0]
if len(local) > 2 {
local = local[:1] + "***" + local[len(local)-1:]
} else {
local = local[:1] + "***"
}
return local + "@" + parts[1]
}
func (a *ConfirmationAPI) 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

@@ -1,187 +0,0 @@
package confirmationimp
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"errors"
"time"
"github.com/tech/sendico/pkg/db/confirmation"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
var (
errConfirmationNotFound confirmationError = "confirmation not found or expired"
errConfirmationUsed confirmationError = "confirmation already used"
errConfirmationMismatch confirmationError = "confirmation code mismatch"
errConfirmationAttemptsExceeded confirmationError = "confirmation attempts exceeded"
errConfirmationCooldown confirmationError = "confirmation cooldown active"
errConfirmationResendLimit confirmationError = "confirmation resend limit reached"
)
type confirmationError string
func (e confirmationError) Error() string {
return string(e)
}
type ConfirmationStore struct {
db confirmation.DB
}
func NewStore(db confirmation.DB) *ConfirmationStore {
return &ConfirmationStore{db: db}
}
func (s *ConfirmationStore) Create(
ctx context.Context,
accountRef bson.ObjectID,
destination string,
target model.ConfirmationTarget,
cfg Config,
generator func() (string, error),
) (string, *model.ConfirmationCode, error) {
if err := s.db.DeleteTuple(ctx, accountRef, destination, target); err != nil && !errors.Is(err, merrors.ErrNoData) {
return "", nil, err
}
code, _, rec, err := s.buildRecord(accountRef, destination, target, cfg, generator)
if err != nil {
return "", nil, err
}
if err := s.db.Create(ctx, rec); err != nil {
return "", nil, err
}
return code, rec, nil
}
func (s *ConfirmationStore) Resend(
ctx context.Context,
accountRef bson.ObjectID,
destination string,
target model.ConfirmationTarget,
cfg Config,
generator func() (string, error),
) (string, *model.ConfirmationCode, error) {
now := time.Now().UTC()
active, err := s.db.FindActive(ctx, accountRef, destination, target, now.Unix())
if errors.Is(err, merrors.ErrNoData) {
return s.Create(ctx, accountRef, destination, target, cfg, generator)
}
if err != nil {
return "", nil, err
}
if active.ResendCount >= active.ResendLimit {
return "", nil, errConfirmationResendLimit
}
if now.Before(active.CooldownUntil) {
return "", nil, errConfirmationCooldown
}
code, salt, updated, err := s.buildRecord(accountRef, destination, target, cfg, generator)
if err != nil {
return "", nil, err
}
// Preserve attempt counters but bump resend count.
updated.ID = active.ID
updated.CreatedAt = active.CreatedAt
updated.Attempts = active.Attempts
updated.ResendCount = active.ResendCount + 1
updated.Salt = salt
if err := s.db.Update(ctx, updated); err != nil {
return "", nil, err
}
return code, updated, nil
}
func (s *ConfirmationStore) Verify(
ctx context.Context,
accountRef bson.ObjectID,
destination string,
target model.ConfirmationTarget,
code string,
) error {
now := time.Now().UTC()
rec, err := s.db.FindActive(ctx, accountRef, destination, target, now.Unix())
if errors.Is(err, merrors.ErrNoData) {
return errConfirmationNotFound
}
if err != nil {
return err
}
if rec.Used {
return errConfirmationUsed
}
rec.Attempts++
if rec.Attempts > rec.MaxAttempts {
rec.Used = true
_ = s.db.Update(ctx, rec)
return errConfirmationAttemptsExceeded
}
if subtle.ConstantTimeCompare(rec.CodeHash, hashCode(rec.Salt, code)) != 1 {
_ = s.db.Update(ctx, rec)
return errConfirmationMismatch
}
rec.Used = true
return s.db.Update(ctx, rec)
}
func (s *ConfirmationStore) buildRecord(
accountRef bson.ObjectID,
destination string,
target model.ConfirmationTarget,
cfg Config,
generator func() (string, error),
) (string, []byte, *model.ConfirmationCode, error) {
code, err := generator()
if err != nil {
return "", nil, nil, err
}
salt, err := newSalt()
if err != nil {
return "", nil, nil, err
}
now := time.Now().UTC()
rec := &model.ConfirmationCode{
AccountRef: accountRef,
Destination: destination,
Target: target,
CodeHash: hashCode(salt, code),
Salt: salt,
ExpiresAt: now.Add(cfg.TTL),
MaxAttempts: cfg.MaxAttempts,
ResendLimit: cfg.ResendLimit,
CooldownUntil: now.Add(cfg.Cooldown),
Used: false,
Attempts: 0,
ResendCount: 0,
}
return code, salt, rec, nil
}
func hashCode(salt []byte, code string) []byte {
h := sha256.New()
h.Write(salt)
h.Write([]byte(code))
return h.Sum(nil)
}
func newSalt() ([]byte, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return nil, err
}
return buf, nil
}

View File

@@ -1,23 +0,0 @@
package confirmationimp
import (
"github.com/tech/sendico/pkg/model"
)
type confirmationRequest struct {
Target string `json:"target"`
Destination string `json:"destination,omitempty"`
}
type confirmationVerifyRequest struct {
Target string `json:"target"`
Code string `json:"code"`
Destination string `json:"destination,omitempty"`
SessionIdentifier model.SessionIdentifier `json:"sessionIdentifier"`
}
type confirmationResponse struct {
TTLSeconds int `json:"ttl_seconds"`
CooldownSeconds int `json:"cooldown_seconds"`
Destination string `json:"destination"`
}

View File

@@ -33,19 +33,19 @@ func (a *PermissionsAPI) changeRole(r *http.Request, account *model.Account, _ *
res, err := a.enforcer.Enforce(ctx, a.rolesPermissionRef, account.ID, orgRef, req.AccountRef, model.ActionUpdate)
if err != nil {
a.logger.Warn("Failed to check permissions while assigning new role", zap.Error(err),
mzap.ObjRef("requesting_account_ref", account.ID), mzap.ObjRef("account_ref", req.AccountRef),
mzap.ObjRef("requesting_account_ref", account.ID), mzap.AccRef(req.AccountRef),
mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
}
if !res {
a.logger.Debug("Permission denied to set new role", mzap.ObjRef("requesting_account_ref", account.ID),
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
return response.AccessDenied(a.logger, a.Name(), "no permission to change user roles")
}
var roleDescription model.RoleDescription
if err := a.rdb.Get(ctx, req.NewRoleDescriptionRef, &roleDescription); err != nil {
a.logger.Warn("Failed to fetch and validate role description", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
return response.Auto(a.logger, a.Name(), err)
}
@@ -57,13 +57,13 @@ func (a *PermissionsAPI) changeRoleImp(ctx context.Context, req *srequest.Change
// TODO: add check that role revocation won't leave venue without the owner
if err != nil {
a.logger.Warn("Failed to fetch account roles", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef))
return response.Auto(a.logger, a.Name(), err)
}
for _, role := range roles {
if err := a.manager.Role().Revoke(ctx, role.DescriptionRef, req.AccountRef, organizationRef); err != nil {
a.logger.Warn("Failed to revoke old role", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
mzap.ObjRef("role_ref", role.DescriptionRef))
// continue...
}
@@ -76,7 +76,7 @@ func (a *PermissionsAPI) changeRoleImp(ctx context.Context, req *srequest.Change
}
if err := a.manager.Role().Assign(ctx, &role); err != nil {
a.logger.Warn("Failed to assign new role", zap.Error(err), mzap.ObjRef("requesting_account_ref", account.ID),
mzap.ObjRef("account_ref", req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
mzap.AccRef(req.AccountRef), mzap.ObjRef("role_description_ref", req.NewRoleDescriptionRef),
mzap.ObjRef("role_ref", req.NewRoleDescriptionRef))
return response.Auto(a.logger, a.Name(), err)
}

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.Destination, 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()),
Destination: mask.Email(target),
IdempotencyKey: req.IdempotencyKey,
})
}

View File

@@ -0,0 +1,19 @@
package verificationimp
import (
cnotifications "github.com/tech/sendico/pkg/messaging/notifications/confirmation"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mutil/mask"
"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)),
zap.String("destination", mask.Email(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,45 @@
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) {
code, err := s.db.Create(ctx, request)
if err != nil {
return "", err
}
return code, nil
}
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"`
Destination string `json:"destination,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"`
Destination string `json:"destination"`
}

View File

@@ -1,8 +1,7 @@
package confirmationimp
package verificationimp
import (
"encoding/json"
"errors"
"net/http"
"strings"
@@ -12,50 +11,42 @@ import (
"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 *ConfirmationAPI) verifyCode(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc {
var req confirmationVerifyRequest
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)
}
target, err := a.parseTarget(req.Target)
purpose, err := model.VPFromString(req.Purpose)
if err != nil {
return response.BadRequest(a.logger, a.Name(), "invalid_target", err.Error())
}
if target == model.ConfirmationTargetLogin && (token == nil || !token.Pending) {
return response.Forbidden(a.logger, a.Name(), "pending_token_required", "login confirmation requires pending token")
}
if strings.TrimSpace(req.Code) == "" {
return response.BadRequest(a.logger, a.Name(), "missing_code", "confirmation code is required")
}
destination := a.resolveDestination(req.Destination, account)
if destination == "" {
target := a.resolveTarget(req.Destination, account)
if target == "" {
return response.BadRequest(a.logger, a.Name(), "missing_destination", "email destination is required")
}
err = a.store.Verify(r.Context(), account.ID, destination, target, strings.TrimSpace(req.Code))
switch {
case errors.Is(err, errConfirmationNotFound):
return response.NotFound(a.logger, a.Name(), "code_not_found_or_expired")
case errors.Is(err, errConfirmationUsed):
return response.Forbidden(a.logger, a.Name(), "code_used", "code has already been used")
case errors.Is(err, errConfirmationAttemptsExceeded):
return response.Forbidden(a.logger, a.Name(), "attempt_limit_reached", "too many failed attempts")
case errors.Is(err, errConfirmationMismatch):
return response.Forbidden(a.logger, a.Name(), "invalid_code", "code does not match")
case err != nil:
a.logger.Warn("Failed to verify confirmation code", zap.Error(err), mzap.ObjRef("account_ref", account.ID))
return response.Internal(a.logger, a.Name(), err)
dst, err := a.store.Verify(r.Context(), account.ID, purpose, req.Code)
if err != nil {
a.logger.Debug("Code verification failed", zap.Error(err))
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("target", string(target)), mzap.ObjRef("account_ref", account.ID))
if target == model.ConfirmationTargetLogin {
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")
}