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

@@ -12,7 +12,6 @@ import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/interface/services/account"
"github.com/tech/sendico/server/interface/services/confirmation"
"github.com/tech/sendico/server/interface/services/invitation"
"github.com/tech/sendico/server/interface/services/ledger"
"github.com/tech/sendico/server/interface/services/logo"
@@ -22,6 +21,7 @@ import (
"github.com/tech/sendico/server/interface/services/permission"
"github.com/tech/sendico/server/interface/services/recipient"
"github.com/tech/sendico/server/interface/services/site"
"github.com/tech/sendico/server/interface/services/verification"
"github.com/tech/sendico/server/interface/services/wallet"
"go.uber.org/zap"
)
@@ -82,7 +82,7 @@ func (a *APIImp) installServices() error {
srvf := make([]api.MicroServiceFactoryT, 0)
srvf = append(srvf, account.Create)
srvf = append(srvf, confirmation.Create)
srvf = append(srvf, verification.Create)
srvf = append(srvf, organization.Create)
srvf = append(srvf, invitation.Create)
srvf = append(srvf, logo.Create)

View File

@@ -137,7 +137,7 @@ func CreateMiddleware(logger mlogger.Logger, db db.Factory, enforcer auth.Enforc
return nil, err
}
cdb, err := db.NewConfirmationsDB()
cdb, err := db.NewVerificationsDB()
if err != nil {
p.logger.Error("Failed to create confirmations database", zap.Error(err))
return nil, err

View File

@@ -38,12 +38,12 @@ func (ar *AuthorizedRouter) tokenHandler(service mservice.Type, endpoint string,
func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc) {
hndlr := func(r *http.Request, t *emodel.AccountToken) http.HandlerFunc {
if t.Pending {
return response.Forbidden(ar.logger, ar.service, "confirmation_required", "pending token requires confirmation")
return response.Unauthorized(ar.logger, ar.service, "additional verification required")
}
var a model.Account
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
if errors.Is(err, merrors.ErrNoData) {
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.ObjRef("account_ref", t.AccountRef))
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef))
return response.NotFound(ar.logger, ar.service, err.Error())
}
return response.Internal(ar.logger, ar.service, err)
@@ -63,7 +63,7 @@ func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoin
var a model.Account
if err := ar.db.Get(r.Context(), t.AccountRef, &a); err != nil {
if errors.Is(err, merrors.ErrNoData) {
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.ObjRef("account_ref", t.AccountRef))
ar.logger.Debug("Failed to find related user", zap.Error(err), mzap.AccRef(t.AccountRef))
return response.NotFound(ar.logger, ar.service, err.Error())
}
return response.Internal(ar.logger, ar.service, err)

View File

@@ -8,8 +8,8 @@ import (
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/confirmation"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/db/verification"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
@@ -36,7 +36,7 @@ func (d *Dispatcher) PendingAccountHandler(service mservice.Type, endpoint strin
d.protected.PendingAccountHandler(service, endpoint, method, handler)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, cdb confirmation.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.Config) *Dispatcher {
func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, vdb verification.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.Config) *Dispatcher {
d := &Dispatcher{
logger: logger.Named("api_dispatcher"),
}
@@ -45,7 +45,7 @@ func NewDispatcher(logger mlogger.Logger, router chi.Router, db account.DB, cdb
endpoint := os.Getenv(config.EndPointEnv)
signature := middleware.SignatureConf(config)
router.Group(func(r chi.Router) {
d.public = rpublic.NewRouter(d.logger, endpoint, db, cdb, rtdb, r, &config.Token, &signature)
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, r, &config.Token, &signature)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, enforcer, &config.Token, &signature)

View File

@@ -6,15 +6,13 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mask"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/internal/server/confirmationimp"
"go.uber.org/zap"
)
@@ -47,15 +45,7 @@ func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *sre
return response.Internal(pr.logger, pr.service, err)
}
cfg := confirmationimp.DefaultConfig()
_, rec, err := pr.cstore.Create(ctx, account.ID, account.Login, model.ConfirmationTargetLogin, cfg, pr.generateCode)
if err != nil {
pr.logger.Warn("Failed to create login confirmation code", zap.Error(err))
return response.Internal(pr.logger, pr.service, err)
}
pr.logger.Info("Login confirmation code issued", zap.String("destination", pr.maskEmail(account.Login)))
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
return sresponse.LoginPending(pr.logger, account, &pendingToken, mask.Email(account.Login))
}
func (a *PublicRouter) login(r *http.Request) http.HandlerFunc {

View File

@@ -1,27 +1,21 @@
package routers
import (
"crypto/rand"
"strings"
"github.com/go-chi/chi/v5"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/confirmation"
"github.com/tech/sendico/pkg/db/refreshtokens"
"github.com/tech/sendico/pkg/db/verification"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api/sresponse"
"github.com/tech/sendico/server/interface/middleware"
re "github.com/tech/sendico/server/internal/api/routers/endpoint"
"github.com/tech/sendico/server/internal/server/confirmationimp"
)
type PublicRouter struct {
logger mlogger.Logger
db account.DB
cdb confirmation.DB
cstore *confirmationimp.ConfirmationStore
imp *re.HttpEndpointRouter
rtdb refreshtokens.DB
config middleware.TokenConfig
@@ -33,39 +27,11 @@ func (pr *PublicRouter) InstallHandler(service mservice.Type, endpoint string, m
pr.imp.InstallHandler(service, endpoint, method, handler)
}
func (pr *PublicRouter) generateCode() (string, error) {
const digits = "0123456789"
b := make([]byte, confirmationimp.DefaultConfig().CodeLength)
if _, err := rand.Read(b); err != nil {
return "", err
}
for i := range b {
b[i] = digits[int(b[i])%len(digits)]
}
return string(b), nil
}
func (pr *PublicRouter) 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 NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, cdb confirmation.DB, rtdb refreshtokens.DB, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *PublicRouter {
func NewRouter(logger mlogger.Logger, apiEndpoint string, db account.DB, vdb verification.DB, rtdb refreshtokens.DB, router chi.Router, config *middleware.TokenConfig, signature *middleware.Signature) *PublicRouter {
l := logger.Named("public")
hr := PublicRouter{
logger: l,
db: db,
cdb: cdb,
cstore: confirmationimp.NewStore(cdb),
rtdb: rtdb,
config: *config,
signature: *signature,

View File

@@ -45,7 +45,7 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, _ *http.Reques
var account model.Account
if err := pr.db.Get(ctx, *rt.AccountRef, &account); errors.Is(err, merrors.ErrNoData) {
pr.logger.Info("User not found while rotating refresh token", zap.Error(err), mzap.ObjRef("account_ref", *rt.AccountRef))
pr.logger.Info("User not found while rotating refresh token", zap.Error(err), mzap.AccRef(*rt.AccountRef))
return nil, nil, merrors.Unauthorized("user not found")
}

View File

@@ -0,0 +1,45 @@
package mutil
import (
"errors"
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/db/verification"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
"go.uber.org/zap"
)
func MapTokenErrorToResponse(logger mlogger.Logger, service mservice.Type, err error) http.HandlerFunc {
if errors.Is(err, verification.ErrTokenNotFound) {
logger.Debug("Verification token not found during consume", zap.Error(err))
return response.NotFound(logger, service, "No account found associated with given verifcation token")
}
if errors.Is(err, verification.ErrTokenExpired) {
logger.Debug("Verification token expired during consume", zap.Error(err))
return response.Gone(logger, service, "token_expired", "verification token has expired")
}
if errors.Is(err, verification.ErrTokenAlreadyUsed) {
logger.Debug("Verification token already used during consume", zap.Error(err))
return response.DataConflict(logger, service, "verification token has already been used")
}
if errors.Is(err, verification.ErrTokenAttemptsExceeded) {
logger.Debug("Verification token attempts exceeded", zap.Error(err))
return response.Forbidden(logger, service, "code_attempts_exceeded", "verification token has already been used")
}
if errors.Is(err, verification.ErrCooldownActive) {
logger.Debug("Cooldown is still active", zap.Error(err))
return response.TooManyRequests(logger, service, "verification token can't be generated yet, cooldown is still active")
}
if errors.Is(err, verification.ErrIdempotencyConflict) {
logger.Debug("Verification idempotency key conflict", zap.Error(err))
return response.DataConflict(logger, service, "verification request was already processed")
}
if err != nil {
logger.Warn("Unexpected error during token verification", zap.Error(err))
return response.Auto(logger, service, err)
}
logger.Debug("No token verification error found")
return response.Success(logger)
}

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")
}