unified code verification service
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
module github.com/tech/sendico/server
|
||||
|
||||
go 1.25.6
|
||||
go 1.25.7
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../pkg
|
||||
|
||||
@@ -31,7 +31,7 @@ require (
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/net v0.50.0
|
||||
google.golang.org/grpc v1.78.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -134,9 +134,9 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
)
|
||||
|
||||
@@ -292,8 +292,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -308,8 +308,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -336,14 +336,14 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -361,10 +361,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
@@ -155,14 +155,18 @@ func (s *service) CreateAccount(
|
||||
func (s *service) VerifyAccount(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (verificationToken string, err error) {
|
||||
verificationToken, err = s.vdb.Create(ctx, *acct.GetID(), model.PurposeAccountActivation, "", time.Duration(time.Hour*24))
|
||||
) (string, error) {
|
||||
token, err := s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, "").
|
||||
WithTTL(time.Duration(time.Hour*24)),
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to create verification token for new account", zap.Error(err), mzap.StorableRef(acct))
|
||||
return "", err
|
||||
}
|
||||
|
||||
return verificationToken, nil
|
||||
return token, nil
|
||||
|
||||
}
|
||||
|
||||
@@ -174,19 +178,19 @@ func (s *service) DeleteAccount(
|
||||
// Check if this is the only member in the organization
|
||||
if len(org.Members) <= 1 {
|
||||
s.logger.Warn("Cannot delete account - it's the only member in the organization",
|
||||
mzap.ObjRef("account_ref", accountRef), mzap.StorableRef(org))
|
||||
mzap.AccRef(accountRef), mzap.StorableRef(org))
|
||||
return merrors.InvalidArgument("Cannot delete the only member of an organization")
|
||||
}
|
||||
|
||||
// 1) Remove from organization
|
||||
if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
|
||||
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// 2) Delete the account document
|
||||
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
|
||||
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -203,13 +207,13 @@ func (s *service) RemoveAccountFromOrganization(
|
||||
roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org),
|
||||
mzap.ObjRef("account_ref", accountRef))
|
||||
mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
for _, role := range roles {
|
||||
if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil {
|
||||
s.logger.Warn("Failed to revoke account role", zap.Error(err),
|
||||
mzap.ObjRef("account_ref", accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -218,7 +222,7 @@ func (s *service) RemoveAccountFromOrganization(
|
||||
// Remove the member by slicing it out
|
||||
org.Members = append(org.Members[:i], org.Members[i+1:]...)
|
||||
if err := s.orgDB.Update(ctx, accountRef, org); err != nil {
|
||||
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
|
||||
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
break
|
||||
@@ -231,7 +235,11 @@ func (s *service) ResetPassword(
|
||||
ctx context.Context,
|
||||
acct *model.Account,
|
||||
) (string, error) {
|
||||
return s.vdb.Create(ctx, *acct.GetID(), model.PurposePasswordReset, "", time.Duration(time.Hour*1))
|
||||
return s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, "").
|
||||
WithTTL(time.Duration(time.Hour*1)),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *service) UpdateLogin(
|
||||
@@ -239,7 +247,11 @@ func (s *service) UpdateLogin(
|
||||
acct *model.Account,
|
||||
newLogin string,
|
||||
) (string, error) {
|
||||
return s.vdb.Create(ctx, *acct.GetID(), model.PurposeEmailChange, newLogin, time.Duration(time.Hour*1))
|
||||
return s.vdb.Create(
|
||||
ctx,
|
||||
verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin).
|
||||
WithTTL(time.Duration(time.Hour*1)),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *service) JoinOrganization(
|
||||
@@ -350,7 +362,7 @@ func (s *service) DeleteAll(
|
||||
accountRef bson.ObjectID,
|
||||
) error {
|
||||
s.logger.Info("Starting complete deletion (organization + account)",
|
||||
mzap.StorableRef(org), mzap.ObjRef("account_ref", accountRef))
|
||||
mzap.StorableRef(org), mzap.AccRef(accountRef))
|
||||
|
||||
// 1. First delete the organization and all its data
|
||||
if err := s.DeleteOrganization(ctx, org); err != nil {
|
||||
@@ -359,11 +371,11 @@ func (s *service) DeleteAll(
|
||||
|
||||
// 2. Then delete the account
|
||||
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
|
||||
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.ObjRef("account_ref", accountRef))
|
||||
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.ObjRef("account_ref", accountRef))
|
||||
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,9 @@ type pendingLoginResponse struct {
|
||||
Account accountResponse `json:"account"`
|
||||
PendingToken TokenData `json:"pendingToken"`
|
||||
Destination string `json:"destination"`
|
||||
TTLSeconds int `json:"ttlSeconds"`
|
||||
}
|
||||
|
||||
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, destination string, ttlSeconds int) http.HandlerFunc {
|
||||
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, destination string) http.HandlerFunc {
|
||||
return response.Accepted(
|
||||
logger,
|
||||
&pendingLoginResponse{
|
||||
@@ -25,7 +24,6 @@ func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *T
|
||||
},
|
||||
PendingToken: *pendingToken,
|
||||
Destination: destination,
|
||||
TTLSeconds: ttlSeconds,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package confirmation
|
||||
package verification
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"github.com/tech/sendico/server/interface/api"
|
||||
"github.com/tech/sendico/server/internal/server/confirmationimp"
|
||||
"github.com/tech/sendico/server/internal/server/verificationimp"
|
||||
)
|
||||
|
||||
func Create(a api.API) (mservice.MicroService, error) {
|
||||
return confirmationimp.CreateAPI(a)
|
||||
return verificationimp.CreateAPI(a)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
45
api/server/internal/mutil/verification/verificatoin.go
Normal file
45
api/server/internal/mutil/verification/verificatoin.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
57
api/server/internal/server/verificationimp/request.go
Normal file
57
api/server/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.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,
|
||||
})
|
||||
}
|
||||
19
api/server/internal/server/verificationimp/sendcode.go
Normal file
19
api/server/internal/server/verificationimp/sendcode.go
Normal 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))
|
||||
}
|
||||
}
|
||||
80
api/server/internal/server/verificationimp/service.go
Normal file
80
api/server/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
|
||||
}
|
||||
45
api/server/internal/server/verificationimp/store.go
Normal file
45
api/server/internal/server/verificationimp/store.go
Normal 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
|
||||
}
|
||||
15
api/server/internal/server/verificationimp/target.go
Normal file
15
api/server/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/server/internal/server/verificationimp/token.go
Normal file
20
api/server/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/server/internal/server/verificationimp/types.go
Normal file
24
api/server/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"`
|
||||
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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user