From 2ae86e900286f585e4929718bb525d5ad287d18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 21 May 2026 07:51:22 +0800 Subject: [PATCH] add member totp --- Makefile | 6 +- cmd/member-seed/main.go | 85 +++++++ cmd/mongo-index/main.go | 11 +- generate/api/gateway.api | 1 + generate/api/member.api | 132 +++++++++++ .../confirm_email_verification_handler.go | 34 +++ .../confirm_phone_verification_handler.go | 34 +++ .../member/confirm_t_o_t_p_enroll_handler.go | 34 +++ internal/handler/member/context.go | 12 + .../handler/member/disable_t_o_t_p_handler.go | 21 ++ .../handler/member/get_member_me_handler.go | 21 ++ .../member/get_t_o_t_p_status_handler.go | 21 ++ ...regenerate_t_o_t_p_backup_codes_handler.go | 21 ++ .../start_email_verification_handler.go | 34 +++ .../start_phone_verification_handler.go | 34 +++ .../member/start_t_o_t_p_enroll_handler.go | 21 ++ .../member/update_member_me_handler.go | 34 +++ .../handler/member/verify_t_o_t_p_handler.go | 34 +++ internal/handler/routes.go | 79 +++++++ internal/logic/member/actor.go | 28 +++ .../confirm_email_verification_logic.go | 30 +++ .../confirm_phone_verification_logic.go | 30 +++ .../member/confirm_t_o_t_p_enroll_logic.go | 40 ++++ .../logic/member/disable_t_o_t_p_logic.go | 31 +++ internal/logic/member/get_member_me_logic.go | 40 ++++ .../logic/member/get_t_o_t_p_status_logic.go | 43 ++++ internal/logic/member/mapper_types.go | 31 +++ .../regenerate_t_o_t_p_backup_codes_logic.go | 36 +++ .../member/start_email_verification_logic.go | 35 +++ .../member/start_phone_verification_logic.go | 35 +++ .../member/start_t_o_t_p_enroll_logic.go | 53 +++++ .../logic/member/update_member_me_logic.go | 55 +++++ internal/logic/member/verify_helper.go | 130 +++++++++++ internal/logic/member/verify_t_o_t_p_logic.go | 36 +++ internal/model/member/README.md | 24 ++ internal/model/member/domain/const.go | 49 ++++ .../model/member/domain/entity/identity.go | 19 ++ internal/model/member/domain/entity/member.go | 44 ++++ internal/model/member/domain/entity/tenant.go | 25 ++ .../model/member/domain/enum/member_origin.go | 25 ++ .../model/member/domain/enum/member_status.go | 25 ++ .../model/member/domain/enum/otp_purpose.go | 8 +- .../model/member/domain/enum/tenant_status.go | 14 ++ internal/model/member/domain/errors.go | 4 + internal/model/member/domain/redis.go | 6 + .../member/domain/repository/identity.go | 15 ++ .../model/member/domain/repository/member.go | 37 +++ .../model/member/domain/repository/profile.go | 9 - .../model/member/domain/repository/tenant.go | 15 ++ .../model/member/domain/repository/uid.go | 8 + .../model/member/domain/usecase/lifecycle.go | 22 ++ .../model/member/domain/usecase/profile.go | 72 ++++++ .../member/domain/usecase/provisioning.go | 42 ++++ .../model/member/domain/usecase/tenant.go | 29 +++ .../model/member/repository/identity_mongo.go | 105 +++++++++ internal/model/member/repository/index.go | 49 ++++ .../model/member/repository/member_mongo.go | 221 ++++++++++++++++++ .../model/member/repository/profile_memory.go | 44 ---- .../model/member/repository/tenant_mongo.go | 90 +++++++ .../member/repository/totp_profile_mongo.go | 126 ++++++++++ internal/model/member/repository/uid_redis.go | 41 ++++ .../model/member/usecase/lifecycle_usecase.go | 128 ++++++++++ internal/model/member/usecase/mapper.go | 70 ++++++ internal/model/member/usecase/module.go | 98 +++++--- .../model/member/usecase/profile_usecase.go | 112 +++++++++ .../member/usecase/provisioning_usecase.go | 149 ++++++++++++ .../model/member/usecase/tenant_usecase.go | 80 +++++++ internal/svc/service_context.go | 51 ++-- internal/types/types.go | 75 ++++++ 69 files changed, 3137 insertions(+), 116 deletions(-) create mode 100644 cmd/member-seed/main.go create mode 100644 generate/api/member.api create mode 100644 internal/handler/member/confirm_email_verification_handler.go create mode 100644 internal/handler/member/confirm_phone_verification_handler.go create mode 100644 internal/handler/member/confirm_t_o_t_p_enroll_handler.go create mode 100644 internal/handler/member/context.go create mode 100644 internal/handler/member/disable_t_o_t_p_handler.go create mode 100644 internal/handler/member/get_member_me_handler.go create mode 100644 internal/handler/member/get_t_o_t_p_status_handler.go create mode 100644 internal/handler/member/regenerate_t_o_t_p_backup_codes_handler.go create mode 100644 internal/handler/member/start_email_verification_handler.go create mode 100644 internal/handler/member/start_phone_verification_handler.go create mode 100644 internal/handler/member/start_t_o_t_p_enroll_handler.go create mode 100644 internal/handler/member/update_member_me_handler.go create mode 100644 internal/handler/member/verify_t_o_t_p_handler.go create mode 100644 internal/logic/member/actor.go create mode 100644 internal/logic/member/confirm_email_verification_logic.go create mode 100644 internal/logic/member/confirm_phone_verification_logic.go create mode 100644 internal/logic/member/confirm_t_o_t_p_enroll_logic.go create mode 100644 internal/logic/member/disable_t_o_t_p_logic.go create mode 100644 internal/logic/member/get_member_me_logic.go create mode 100644 internal/logic/member/get_t_o_t_p_status_logic.go create mode 100644 internal/logic/member/mapper_types.go create mode 100644 internal/logic/member/regenerate_t_o_t_p_backup_codes_logic.go create mode 100644 internal/logic/member/start_email_verification_logic.go create mode 100644 internal/logic/member/start_phone_verification_logic.go create mode 100644 internal/logic/member/start_t_o_t_p_enroll_logic.go create mode 100644 internal/logic/member/update_member_me_logic.go create mode 100644 internal/logic/member/verify_helper.go create mode 100644 internal/logic/member/verify_t_o_t_p_logic.go create mode 100644 internal/model/member/domain/const.go create mode 100644 internal/model/member/domain/entity/identity.go create mode 100644 internal/model/member/domain/entity/member.go create mode 100644 internal/model/member/domain/entity/tenant.go create mode 100644 internal/model/member/domain/enum/member_origin.go create mode 100644 internal/model/member/domain/enum/member_status.go create mode 100644 internal/model/member/domain/enum/tenant_status.go create mode 100644 internal/model/member/domain/repository/identity.go create mode 100644 internal/model/member/domain/repository/member.go delete mode 100644 internal/model/member/domain/repository/profile.go create mode 100644 internal/model/member/domain/repository/tenant.go create mode 100644 internal/model/member/domain/repository/uid.go create mode 100644 internal/model/member/domain/usecase/lifecycle.go create mode 100644 internal/model/member/domain/usecase/profile.go create mode 100644 internal/model/member/domain/usecase/provisioning.go create mode 100644 internal/model/member/domain/usecase/tenant.go create mode 100644 internal/model/member/repository/identity_mongo.go create mode 100644 internal/model/member/repository/index.go create mode 100644 internal/model/member/repository/member_mongo.go delete mode 100644 internal/model/member/repository/profile_memory.go create mode 100644 internal/model/member/repository/tenant_mongo.go create mode 100644 internal/model/member/repository/totp_profile_mongo.go create mode 100644 internal/model/member/repository/uid_redis.go create mode 100644 internal/model/member/usecase/lifecycle_usecase.go create mode 100644 internal/model/member/usecase/mapper.go create mode 100644 internal/model/member/usecase/profile_usecase.go create mode 100644 internal/model/member/usecase/provisioning_usecase.go create mode 100644 internal/model/member/usecase/tenant_usecase.go diff --git a/Makefile b/Makefile index 7a09f33..e9ba5ea 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 .DEFAULT_GOAL := help .PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \ - deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test setup-dev run-local + deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test member-seed setup-dev run-local help: ## 顯示可用指令 @echo "Gateway Makefile" @@ -118,5 +118,9 @@ totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator; $(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \ $(if $(CODE),-code "$(CODE)",) +member-seed: setup-dev ## 建立 dev tenant + member(需 Mongo+Redis) + $(GO) run ./cmd/member-seed -f etc/gateway.dev.yaml \ + $(if $(TENANT),-tenant "$(TENANT)",) $(if $(EMAIL),-email "$(EMAIL)",) + config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入 $(GO) test ./internal/config/ -run TestLoadGatewayYAML -v diff --git a/cmd/member-seed/main.go b/cmd/member-seed/main.go new file mode 100644 index 0000000..fbd7f7b --- /dev/null +++ b/cmd/member-seed/main.go @@ -0,0 +1,85 @@ +// Command member-seed creates a dev tenant and member for local API testing. +// +// make deps-up && make mongo-index && make member-seed +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "gateway/internal/config" + redislib "gateway/internal/library/redis" + domusecase "gateway/internal/model/member/domain/usecase" + memberusecase "gateway/internal/model/member/usecase" + + "github.com/zeromicro/go-zero/core/conf" +) + +var ( + configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") + tenantID = flag.String("tenant", "dev-tenant", "tenant_id") + slug = flag.String("slug", "dev", "tenant slug") + uidPrefix = flag.String("prefix", "DEV", "uid prefix") + email = flag.String("email", "dev@example.com", "member email") +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + flag.Parse() + var c config.Config + conf.MustLoad(*configFile, &c) + if c.Mongo.Host == "" || c.Redis.Host == "" { + return fmt.Errorf("member-seed: Mongo and Redis are required") + } + + ctx := context.Background() + rds, err := redislib.NewClient(c.Redis) + if err != nil { + return fmt.Errorf("member-seed: redis: %w", err) + } + mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ + Redis: rds, + MongoConf: &c.Mongo, + Config: c.Member, + }) + if err != nil { + return fmt.Errorf("member-seed: module: %w", err) + } + if mod.Tenant == nil || mod.Lifecycle == nil { + return fmt.Errorf("member-seed: tenant/lifecycle not wired (need Mongo)") + } + + if _, err := mod.Tenant.Create(ctx, &domusecase.CreateTenantRequest{ + TenantID: *tenantID, + Slug: *slug, + Name: "Dev Tenant", + UIDPrefix: *uidPrefix, + }); err != nil { + fmt.Printf("tenant create skipped (may exist): %v\n", err) + } + + m, err := mod.Lifecycle.CreateUnverified(ctx, &domusecase.CreatePlatformMemberRequest{ + TenantID: *tenantID, + Email: *email, + DisplayName: "Dev User", + Language: "zh-tw", + }) + if err != nil { + return fmt.Errorf("member-seed: create member: %w", err) + } + if err := mod.Lifecycle.Activate(ctx, *tenantID, m.UID); err != nil { + return fmt.Errorf("member-seed: activate: %w", err) + } + + fmt.Printf("tenant_id=%s uid=%s\n", *tenantID, m.UID) + fmt.Printf("Use headers: X-Tenant-ID=%s X-UID=%s\n", *tenantID, m.UID) + return nil +} diff --git a/cmd/mongo-index/main.go b/cmd/mongo-index/main.go index cccc758..95a6562 100644 --- a/cmd/mongo-index/main.go +++ b/cmd/mongo-index/main.go @@ -1,5 +1,4 @@ -// Command mongo-index ensures Gateway notification MongoDB indexes exist. -// Use when docker-entrypoint-initdb.d did not run (existing mongo_data volume). +// Command mongo-index ensures Gateway MongoDB indexes exist. package main import ( @@ -10,12 +9,13 @@ import ( "time" "gateway/internal/config" + memberrepo "gateway/internal/model/member/repository" notifrepo "gateway/internal/model/notification/repository" "github.com/zeromicro/go-zero/core/conf" ) -var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)") +var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file") func main() { if err := run(); err != nil { @@ -45,7 +45,10 @@ func run() error { if err := dlqRepo.Index20260520001UP(ctx); err != nil { return fmt.Errorf("mongo-index: notification_dlq: %w", err) } + if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { + return fmt.Errorf("mongo-index: member: %w", err) + } - fmt.Println("mongo-index: notifications + notification_dlq indexes OK") + fmt.Println("mongo-index: notifications + notification_dlq + member indexes OK") return nil } diff --git a/generate/api/gateway.api b/generate/api/gateway.api index 3a1d1d1..00d7543 100644 --- a/generate/api/gateway.api +++ b/generate/api/gateway.api @@ -15,6 +15,7 @@ info ( import ( "common.api" + "member.api" "normal.api" ) diff --git a/generate/api/member.api b/generate/api/member.api new file mode 100644 index 0000000..359ed42 --- /dev/null +++ b/generate/api/member.api @@ -0,0 +1,132 @@ +syntax = "v1" + +type ( + MemberMeData { + TenantID string `json:"tenant_id"` + UID string `json:"uid"` + ZitadelEmail string `json:"zitadel_email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Avatar string `json:"avatar,omitempty"` + Phone string `json:"phone,omitempty"` + Language string `json:"language,omitempty"` + Currency string `json:"currency,omitempty"` + Status string `json:"status"` + Origin string `json:"origin"` + BusinessEmail string `json:"business_email,omitempty"` + BusinessEmailVerified bool `json:"business_email_verified"` + BusinessPhone string `json:"business_phone,omitempty"` + BusinessPhoneVerified bool `json:"business_phone_verified"` + TOTPEnrolled bool `json:"totp_enrolled"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + } + + UpdateMemberMeReq { + DisplayName string `json:"display_name,optional"` + Avatar string `json:"avatar,optional"` + Language string `json:"language,optional"` + Currency string `json:"currency,optional"` + Phone string `json:"phone,optional"` + } + + VerificationStartReq { + Target string `json:"target"` + } + + VerificationStartData { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + } + + VerificationConfirmReq { + ChallengeID string `json:"challenge_id"` + Code string `json:"code"` + } + + TOTPStatusData { + Enrolled bool `json:"enrolled"` + EnrolledAt int64 `json:"enrolled_at,omitempty"` + BackupCodesRemaining int `json:"backup_codes_remaining"` + Digits int `json:"digits,omitempty"` + PeriodSeconds int `json:"period_seconds,omitempty"` + } + + TOTPEnrollStartData { + OtpauthURL string `json:"otpauth_url"` + Issuer string `json:"issuer"` + Account string `json:"account"` + Digits int `json:"digits"` + PeriodSec int `json:"period_seconds"` + ExpiresIn int `json:"expires_in"` + } + + TOTPEnrollConfirmReq { + Code string `json:"code"` + } + + TOTPEnrollConfirmData { + BackupCodes []string `json:"backup_codes"` + } + + TOTPVerifyReq { + Code string `json:"code"` + } + + TOTPBackupCodesData { + BackupCodes []string `json:"backup_codes"` + } +) + +@server( + group: member + prefix: /api/v1/members +) +service gateway { + @doc "取得當前會員 profile(dev:Header X-Tenant-ID + X-UID)" + @handler getMemberMe + get /me returns (MemberMeData) + + @doc "更新當前會員 profile" + @handler updateMemberMe + patch /me (UpdateMemberMeReq) returns (MemberMeData) + + @doc "開始業務 email 驗證" + @handler startEmailVerification + post /me/verifications/email/start (VerificationStartReq) returns (VerificationStartData) + + @doc "確認業務 email 驗證" + @handler confirmEmailVerification + post /me/verifications/email/confirm (VerificationConfirmReq) + + @doc "開始業務 phone 驗證" + @handler startPhoneVerification + post /me/verifications/phone/start (VerificationStartReq) returns (VerificationStartData) + + @doc "確認業務 phone 驗證" + @handler confirmPhoneVerification + post /me/verifications/phone/confirm (VerificationConfirmReq) + + @doc "TOTP 狀態" + @handler getTOTPStatus + get /me/totp returns (TOTPStatusData) + + @doc "開始 TOTP 綁定" + @handler startTOTPEnroll + post /me/totp/enroll-start returns (TOTPEnrollStartData) + + @doc "確認 TOTP 綁定" + @handler confirmTOTPEnroll + post /me/totp/enroll-confirm (TOTPEnrollConfirmReq) returns (TOTPEnrollConfirmData) + + @doc "驗證 TOTP(step-up 測試)" + @handler verifyTOTP + post /me/totp/verify (TOTPVerifyReq) + + @doc "重產 TOTP 備援碼" + @handler regenerateTOTPBackupCodes + post /me/totp/backup-codes returns (TOTPBackupCodesData) + + @doc "解除 TOTP 綁定" + @handler disableTOTP + delete /me/totp +} diff --git a/internal/handler/member/confirm_email_verification_handler.go b/internal/handler/member/confirm_email_verification_handler.go new file mode 100644 index 0000000..ff531f9 --- /dev/null +++ b/internal/handler/member/confirm_email_verification_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 確認業務 email 驗證 +func ConfirmEmailVerificationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.VerificationConfirmReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := member.NewConfirmEmailVerificationLogic(actorContext(r.Context(), r), svcCtx) + err := l.ConfirmEmailVerification(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/internal/handler/member/confirm_phone_verification_handler.go b/internal/handler/member/confirm_phone_verification_handler.go new file mode 100644 index 0000000..484cd1f --- /dev/null +++ b/internal/handler/member/confirm_phone_verification_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 確認業務 phone 驗證 +func ConfirmPhoneVerificationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.VerificationConfirmReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := member.NewConfirmPhoneVerificationLogic(actorContext(r.Context(), r), svcCtx) + err := l.ConfirmPhoneVerification(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/internal/handler/member/confirm_t_o_t_p_enroll_handler.go b/internal/handler/member/confirm_t_o_t_p_enroll_handler.go new file mode 100644 index 0000000..ab41bac --- /dev/null +++ b/internal/handler/member/confirm_t_o_t_p_enroll_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 確認 TOTP 綁定 +func ConfirmTOTPEnrollHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.TOTPEnrollConfirmReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := member.NewConfirmTOTPEnrollLogic(actorContext(r.Context(), r), svcCtx) + data, err := l.ConfirmTOTPEnroll(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/context.go b/internal/handler/member/context.go new file mode 100644 index 0000000..07b11c7 --- /dev/null +++ b/internal/handler/member/context.go @@ -0,0 +1,12 @@ +package member + +import ( + "context" + "net/http" + + logic "gateway/internal/logic/member" +) + +func actorContext(ctx context.Context, r *http.Request) context.Context { + return logic.WithActor(ctx, r.Header.Get("X-Tenant-ID"), r.Header.Get("X-UID")) +} diff --git a/internal/handler/member/disable_t_o_t_p_handler.go b/internal/handler/member/disable_t_o_t_p_handler.go new file mode 100644 index 0000000..dd748a5 --- /dev/null +++ b/internal/handler/member/disable_t_o_t_p_handler.go @@ -0,0 +1,21 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" +) + +// 解除 TOTP 綁定 +func DisableTOTPHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := member.NewDisableTOTPLogic(actorContext(r.Context(), r), svcCtx) + err := l.DisableTOTP() + response.Write(r.Context(), w, nil, err) + } +} diff --git a/internal/handler/member/get_member_me_handler.go b/internal/handler/member/get_member_me_handler.go new file mode 100644 index 0000000..674e969 --- /dev/null +++ b/internal/handler/member/get_member_me_handler.go @@ -0,0 +1,21 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" +) + +// 取得當前會員 profile(dev:Header X-Tenant-ID + X-UID) +func GetMemberMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := member.NewGetMemberMeLogic(actorContext(r.Context(), r), svcCtx) + data, err := l.GetMemberMe() + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/get_t_o_t_p_status_handler.go b/internal/handler/member/get_t_o_t_p_status_handler.go new file mode 100644 index 0000000..ad37348 --- /dev/null +++ b/internal/handler/member/get_t_o_t_p_status_handler.go @@ -0,0 +1,21 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" +) + +// TOTP 狀態 +func GetTOTPStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := member.NewGetTOTPStatusLogic(actorContext(r.Context(), r), svcCtx) + data, err := l.GetTOTPStatus() + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/regenerate_t_o_t_p_backup_codes_handler.go b/internal/handler/member/regenerate_t_o_t_p_backup_codes_handler.go new file mode 100644 index 0000000..566f308 --- /dev/null +++ b/internal/handler/member/regenerate_t_o_t_p_backup_codes_handler.go @@ -0,0 +1,21 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" +) + +// 重產 TOTP 備援碼 +func RegenerateTOTPBackupCodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := member.NewRegenerateTOTPBackupCodesLogic(actorContext(r.Context(), r), svcCtx) + data, err := l.RegenerateTOTPBackupCodes() + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/start_email_verification_handler.go b/internal/handler/member/start_email_verification_handler.go new file mode 100644 index 0000000..a618c90 --- /dev/null +++ b/internal/handler/member/start_email_verification_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 開始業務 email 驗證 +func StartEmailVerificationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.VerificationStartReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := member.NewStartEmailVerificationLogic(actorContext(r.Context(), r), svcCtx) + data, err := l.StartEmailVerification(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/start_phone_verification_handler.go b/internal/handler/member/start_phone_verification_handler.go new file mode 100644 index 0000000..845c8c6 --- /dev/null +++ b/internal/handler/member/start_phone_verification_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 開始業務 phone 驗證 +func StartPhoneVerificationHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.VerificationStartReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := member.NewStartPhoneVerificationLogic(actorContext(r.Context(), r), svcCtx) + data, err := l.StartPhoneVerification(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/start_t_o_t_p_enroll_handler.go b/internal/handler/member/start_t_o_t_p_enroll_handler.go new file mode 100644 index 0000000..690f2a0 --- /dev/null +++ b/internal/handler/member/start_t_o_t_p_enroll_handler.go @@ -0,0 +1,21 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" +) + +// 開始 TOTP 綁定 +func StartTOTPEnrollHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := member.NewStartTOTPEnrollLogic(actorContext(r.Context(), r), svcCtx) + data, err := l.StartTOTPEnroll() + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/update_member_me_handler.go b/internal/handler/member/update_member_me_handler.go new file mode 100644 index 0000000..5bf55e1 --- /dev/null +++ b/internal/handler/member/update_member_me_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 更新當前會員 profile +func UpdateMemberMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.UpdateMemberMeReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := member.NewUpdateMemberMeLogic(actorContext(r.Context(), r), svcCtx) + data, err := l.UpdateMemberMe(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/verify_t_o_t_p_handler.go b/internal/handler/member/verify_t_o_t_p_handler.go new file mode 100644 index 0000000..6235d78 --- /dev/null +++ b/internal/handler/member/verify_t_o_t_p_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package member + +import ( + "net/http" + + "gateway/internal/logic/member" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 驗證 TOTP(step-up 測試) +func VerifyTOTPHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.TOTPVerifyReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := member.NewVerifyTOTPLogic(actorContext(r.Context(), r), svcCtx) + err := l.VerifyTOTP(&req) + response.Write(r.Context(), w, nil, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 85e5cc2..6f07a8f 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + member "gateway/internal/handler/member" normal "gateway/internal/handler/normal" "gateway/internal/svc" @@ -14,6 +15,84 @@ import ( ) func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { + server.AddRoutes( + []rest.Route{ + { + // 取得當前會員 profile(dev:Header X-Tenant-ID + X-UID) + Method: http.MethodGet, + Path: "/me", + Handler: member.GetMemberMeHandler(serverCtx), + }, + { + // 更新當前會員 profile + Method: http.MethodPatch, + Path: "/me", + Handler: member.UpdateMemberMeHandler(serverCtx), + }, + { + // TOTP 狀態 + Method: http.MethodGet, + Path: "/me/totp", + Handler: member.GetTOTPStatusHandler(serverCtx), + }, + { + // 解除 TOTP 綁定 + Method: http.MethodDelete, + Path: "/me/totp", + Handler: member.DisableTOTPHandler(serverCtx), + }, + { + // 重產 TOTP 備援碼 + Method: http.MethodPost, + Path: "/me/totp/backup-codes", + Handler: member.RegenerateTOTPBackupCodesHandler(serverCtx), + }, + { + // 確認 TOTP 綁定 + Method: http.MethodPost, + Path: "/me/totp/enroll-confirm", + Handler: member.ConfirmTOTPEnrollHandler(serverCtx), + }, + { + // 開始 TOTP 綁定 + Method: http.MethodPost, + Path: "/me/totp/enroll-start", + Handler: member.StartTOTPEnrollHandler(serverCtx), + }, + { + // 驗證 TOTP(step-up 測試) + Method: http.MethodPost, + Path: "/me/totp/verify", + Handler: member.VerifyTOTPHandler(serverCtx), + }, + { + // 確認業務 email 驗證 + Method: http.MethodPost, + Path: "/me/verifications/email/confirm", + Handler: member.ConfirmEmailVerificationHandler(serverCtx), + }, + { + // 開始業務 email 驗證 + Method: http.MethodPost, + Path: "/me/verifications/email/start", + Handler: member.StartEmailVerificationHandler(serverCtx), + }, + { + // 確認業務 phone 驗證 + Method: http.MethodPost, + Path: "/me/verifications/phone/confirm", + Handler: member.ConfirmPhoneVerificationHandler(serverCtx), + }, + { + // 開始業務 phone 驗證 + Method: http.MethodPost, + Path: "/me/verifications/phone/start", + Handler: member.StartPhoneVerificationHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1/members"), + ) + server.AddRoutes( []rest.Route{ { diff --git a/internal/logic/member/actor.go b/internal/logic/member/actor.go new file mode 100644 index 0000000..0fba39b --- /dev/null +++ b/internal/logic/member/actor.go @@ -0,0 +1,28 @@ +package member + +import ( + "context" + "fmt" +) + +type actorKey struct{} + +// Actor identifies the calling member in dev mode (JWT middleware not wired yet). +type Actor struct { + TenantID string + UID string +} + +// WithActor stores tenant/uid on the context for member logic handlers. +func WithActor(ctx context.Context, tenantID, uid string) context.Context { + return context.WithValue(ctx, actorKey{}, Actor{TenantID: tenantID, UID: uid}) +} + +// ActorFromContext reads the dev actor injected by handlers. +func ActorFromContext(ctx context.Context) (Actor, error) { + v, ok := ctx.Value(actorKey{}).(Actor) + if !ok || v.TenantID == "" || v.UID == "" { + return Actor{}, fmt.Errorf("missing X-Tenant-ID or X-UID header") + } + return v, nil +} diff --git a/internal/logic/member/confirm_email_verification_logic.go b/internal/logic/member/confirm_email_verification_logic.go new file mode 100644 index 0000000..8505943 --- /dev/null +++ b/internal/logic/member/confirm_email_verification_logic.go @@ -0,0 +1,30 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/model/member/domain/enum" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ConfirmEmailVerificationLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewConfirmEmailVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ConfirmEmailVerificationLogic { + return &ConfirmEmailVerificationLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ConfirmEmailVerificationLogic) ConfirmEmailVerification(req *types.VerificationConfirmReq) error { + actor, err := actorOrErr(l.ctx) + if err != nil { + return err + } + return confirmVerification(l.ctx, l.svcCtx, actor, req, enum.OTPPurposeBusinessEmail, l.svcCtx.MemberProfile.SetBusinessEmailVerified) +} diff --git a/internal/logic/member/confirm_phone_verification_logic.go b/internal/logic/member/confirm_phone_verification_logic.go new file mode 100644 index 0000000..150a541 --- /dev/null +++ b/internal/logic/member/confirm_phone_verification_logic.go @@ -0,0 +1,30 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/model/member/domain/enum" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ConfirmPhoneVerificationLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewConfirmPhoneVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ConfirmPhoneVerificationLogic { + return &ConfirmPhoneVerificationLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ConfirmPhoneVerificationLogic) ConfirmPhoneVerification(req *types.VerificationConfirmReq) error { + actor, err := actorOrErr(l.ctx) + if err != nil { + return err + } + return confirmVerification(l.ctx, l.svcCtx, actor, req, enum.OTPPurposeBusinessPhone, l.svcCtx.MemberProfile.SetBusinessPhoneVerified) +} diff --git a/internal/logic/member/confirm_t_o_t_p_enroll_logic.go b/internal/logic/member/confirm_t_o_t_p_enroll_logic.go new file mode 100644 index 0000000..48a904a --- /dev/null +++ b/internal/logic/member/confirm_t_o_t_p_enroll_logic.go @@ -0,0 +1,40 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ConfirmTOTPEnrollLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewConfirmTOTPEnrollLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ConfirmTOTPEnrollLogic { + return &ConfirmTOTPEnrollLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ConfirmTOTPEnrollLogic) ConfirmTOTPEnroll(req *types.TOTPEnrollConfirmReq) (*types.TOTPEnrollConfirmData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + if err := requireTOTP(l.svcCtx); err != nil { + return nil, err + } + code := "" + if req != nil { + code = req.Code + } + backup, err := l.svcCtx.MemberTOTP.ConfirmEnroll(l.ctx, actor.TenantID, actor.UID, code) + if err != nil { + return nil, err + } + return &types.TOTPEnrollConfirmData{BackupCodes: backup}, nil +} diff --git a/internal/logic/member/disable_t_o_t_p_logic.go b/internal/logic/member/disable_t_o_t_p_logic.go new file mode 100644 index 0000000..7331875 --- /dev/null +++ b/internal/logic/member/disable_t_o_t_p_logic.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/svc" + + "github.com/zeromicro/go-zero/core/logx" +) + +type DisableTOTPLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewDisableTOTPLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DisableTOTPLogic { + return &DisableTOTPLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *DisableTOTPLogic) DisableTOTP() error { + actor, err := actorOrErr(l.ctx) + if err != nil { + return err + } + if err := requireTOTP(l.svcCtx); err != nil { + return err + } + return l.svcCtx.MemberTOTP.Disable(l.ctx, actor.TenantID, actor.UID) +} diff --git a/internal/logic/member/get_member_me_logic.go b/internal/logic/member/get_member_me_logic.go new file mode 100644 index 0000000..e20f9ba --- /dev/null +++ b/internal/logic/member/get_member_me_logic.go @@ -0,0 +1,40 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + domusecase "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetMemberMeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetMemberMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberMeLogic { + return &GetMemberMeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetMemberMeLogic) GetMemberMe() (*types.MemberMeData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + if l.svcCtx.MemberProfile == nil { + return nil, errb.SysInternal("member profile not configured") + } + dto, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{ + TenantID: actor.TenantID, + UID: actor.UID, + }) + if err != nil { + return nil, err + } + return memberDTOToTypes(dto), nil +} diff --git a/internal/logic/member/get_t_o_t_p_status_logic.go b/internal/logic/member/get_t_o_t_p_status_logic.go new file mode 100644 index 0000000..baae106 --- /dev/null +++ b/internal/logic/member/get_t_o_t_p_status_logic.go @@ -0,0 +1,43 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetTOTPStatusLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetTOTPStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetTOTPStatusLogic { + return &GetTOTPStatusLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetTOTPStatusLogic) GetTOTPStatus() (*types.TOTPStatusData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + if err := requireTOTP(l.svcCtx); err != nil { + return nil, err + } + status, err := l.svcCtx.MemberTOTP.Status(l.ctx, actor.TenantID, actor.UID) + if err != nil { + return nil, err + } + cfg := l.svcCtx.Config.Member.Defaults().TOTP + return &types.TOTPStatusData{ + Enrolled: status.Enrolled, + EnrolledAt: status.EnrolledAt, + BackupCodesRemaining: status.BackupCodesRemaining, + Digits: cfg.Digits, + PeriodSeconds: cfg.PeriodSeconds, + }, nil +} diff --git a/internal/logic/member/mapper_types.go b/internal/logic/member/mapper_types.go new file mode 100644 index 0000000..6fa5567 --- /dev/null +++ b/internal/logic/member/mapper_types.go @@ -0,0 +1,31 @@ +package member + +import ( + domusecase "gateway/internal/model/member/domain/usecase" + "gateway/internal/types" +) + +func memberDTOToTypes(d *domusecase.MemberDTO) *types.MemberMeData { + if d == nil { + return nil + } + return &types.MemberMeData{ + TenantID: d.TenantID, + UID: d.UID, + ZitadelEmail: d.ZitadelEmail, + DisplayName: d.DisplayName, + Avatar: d.Avatar, + Phone: d.Phone, + Language: d.Language, + Currency: d.Currency, + Status: string(d.Status), + Origin: string(d.Origin), + BusinessEmail: d.BusinessEmail, + BusinessEmailVerified: d.BusinessEmailVerified, + BusinessPhone: d.BusinessPhone, + BusinessPhoneVerified: d.BusinessPhoneVerified, + TOTPEnrolled: d.TOTPEnrolled, + CreateAt: d.CreateAt, + UpdateAt: d.UpdateAt, + } +} diff --git a/internal/logic/member/regenerate_t_o_t_p_backup_codes_logic.go b/internal/logic/member/regenerate_t_o_t_p_backup_codes_logic.go new file mode 100644 index 0000000..76a6054 --- /dev/null +++ b/internal/logic/member/regenerate_t_o_t_p_backup_codes_logic.go @@ -0,0 +1,36 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegenerateTOTPBackupCodesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRegenerateTOTPBackupCodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegenerateTOTPBackupCodesLogic { + return &RegenerateTOTPBackupCodesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RegenerateTOTPBackupCodesLogic) RegenerateTOTPBackupCodes() (*types.TOTPBackupCodesData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + if err := requireTOTP(l.svcCtx); err != nil { + return nil, err + } + codes, err := l.svcCtx.MemberTOTP.RegenerateBackupCodes(l.ctx, actor.TenantID, actor.UID) + if err != nil { + return nil, err + } + return &types.TOTPBackupCodesData{BackupCodes: codes}, nil +} diff --git a/internal/logic/member/start_email_verification_logic.go b/internal/logic/member/start_email_verification_logic.go new file mode 100644 index 0000000..d4ea2f9 --- /dev/null +++ b/internal/logic/member/start_email_verification_logic.go @@ -0,0 +1,35 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/model/member/domain/enum" + notifenum "gateway/internal/model/notification/domain/enum" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartEmailVerificationLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartEmailVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartEmailVerificationLogic { + return &StartEmailVerificationLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartEmailVerificationLogic) StartEmailVerification(req *types.VerificationStartReq) (*types.VerificationStartData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + target := "" + if req != nil { + target = req.Target + } + return startVerification(l.ctx, l.svcCtx, actor, enum.OTPPurposeBusinessEmail, notifenum.ChannelEmail, notifenum.NotifyVerifyEmail, target) +} diff --git a/internal/logic/member/start_phone_verification_logic.go b/internal/logic/member/start_phone_verification_logic.go new file mode 100644 index 0000000..9f16619 --- /dev/null +++ b/internal/logic/member/start_phone_verification_logic.go @@ -0,0 +1,35 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/model/member/domain/enum" + notifenum "gateway/internal/model/notification/domain/enum" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartPhoneVerificationLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartPhoneVerificationLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPhoneVerificationLogic { + return &StartPhoneVerificationLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartPhoneVerificationLogic) StartPhoneVerification(req *types.VerificationStartReq) (*types.VerificationStartData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + target := "" + if req != nil { + target = req.Target + } + return startVerification(l.ctx, l.svcCtx, actor, enum.OTPPurposeBusinessPhone, notifenum.ChannelSMS, notifenum.NotifyVerifyPhone, target) +} diff --git a/internal/logic/member/start_t_o_t_p_enroll_logic.go b/internal/logic/member/start_t_o_t_p_enroll_logic.go new file mode 100644 index 0000000..992c420 --- /dev/null +++ b/internal/logic/member/start_t_o_t_p_enroll_logic.go @@ -0,0 +1,53 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + domusecase "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type StartTOTPEnrollLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewStartTOTPEnrollLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartTOTPEnrollLogic { + return &StartTOTPEnrollLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *StartTOTPEnrollLogic) StartTOTPEnroll() (*types.TOTPEnrollStartData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + if err := requireTOTP(l.svcCtx); err != nil { + return nil, err + } + account := actor.UID + if l.svcCtx.MemberProfile != nil { + if me, getErr := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{ + TenantID: actor.TenantID, + UID: actor.UID, + }); getErr == nil && me.BusinessEmail != "" { + account = me.BusinessEmail + } + } + start, err := l.svcCtx.MemberTOTP.StartEnroll(l.ctx, actor.TenantID, actor.UID, account) + if err != nil { + return nil, err + } + return &types.TOTPEnrollStartData{ + OtpauthURL: start.OtpauthURL, + Issuer: start.Issuer, + Account: start.Account, + Digits: start.Digits, + PeriodSec: start.PeriodSec, + ExpiresIn: start.ExpiresIn, + }, nil +} diff --git a/internal/logic/member/update_member_me_logic.go b/internal/logic/member/update_member_me_logic.go new file mode 100644 index 0000000..36b18a4 --- /dev/null +++ b/internal/logic/member/update_member_me_logic.go @@ -0,0 +1,55 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + domusecase "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateMemberMeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateMemberMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberMeLogic { + return &UpdateMemberMeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateMemberMeLogic) UpdateMemberMe(req *types.UpdateMemberMeReq) (*types.MemberMeData, error) { + actor, err := actorOrErr(l.ctx) + if err != nil { + return nil, err + } + if l.svcCtx.MemberProfile == nil { + return nil, errb.SysInternal("member profile not configured") + } + update := &domusecase.UpdateMemberRequest{TenantID: actor.TenantID, UID: actor.UID} + if req != nil { + if req.DisplayName != "" { + update.DisplayName = &req.DisplayName + } + if req.Avatar != "" { + update.Avatar = &req.Avatar + } + if req.Language != "" { + update.Language = &req.Language + } + if req.Currency != "" { + update.Currency = &req.Currency + } + if req.Phone != "" { + update.Phone = &req.Phone + } + } + dto, err := l.svcCtx.MemberProfile.Update(l.ctx, update) + if err != nil { + return nil, err + } + return memberDTOToTypes(dto), nil +} diff --git a/internal/logic/member/verify_helper.go b/internal/logic/member/verify_helper.go new file mode 100644 index 0000000..bd9ef7a --- /dev/null +++ b/internal/logic/member/verify_helper.go @@ -0,0 +1,130 @@ +package member + +import ( + "context" + "time" + + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" + memberdom "gateway/internal/model/member/domain" + "gateway/internal/model/member/domain/enum" + domusecase "gateway/internal/model/member/domain/usecase" + notifenum "gateway/internal/model/notification/domain/enum" + notifuc "gateway/internal/model/notification/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" +) + +var errb = errs.For(code.Facade) + +func startVerification( + ctx context.Context, + sc *svc.ServiceContext, + actor Actor, + purpose enum.OTPPurpose, + channel notifenum.Channel, + kind notifenum.NotifyKind, + target string, +) (*types.VerificationStartData, error) { + if sc.MemberOTP == nil { + return nil, errb.SysInternal("member OTP not configured") + } + if sc.Notifier == nil { + return nil, errb.SysInternal("notifier not configured") + } + if target == "" { + return nil, errb.InputMissingRequired("target is required") + } + + cfg := sc.Config.Member.Defaults() + rateKey := memberdom.GetVerifyRateRedisKey(actor.TenantID, actor.UID, string(purpose)) + ok, err := sc.MemberVerifyRate.TryResendLock(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second) + if err != nil { + return nil, errb.SysInternal("rate limit check failed").WithCause(err) + } + if !ok { + return nil, errb.AuthForbidden("resend cooldown active").WithCause(memberdom.ErrResendCooldown) + } + dailyKey := memberdom.GetVerifyDailyRedisKey(actor.TenantID, actor.UID, string(purpose)) + count, err := sc.MemberVerifyRate.IncrDaily(ctx, dailyKey, 24*time.Hour) + if err != nil { + return nil, errb.SysInternal("daily limit check failed").WithCause(err) + } + if count > int64(cfg.OTP.DailyVerifyLimit) { + return nil, errb.AuthForbidden("daily verification limit exceeded").WithCause(memberdom.ErrDailyLimit) + } + + dto, plainCode, err := sc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{ + TenantID: actor.TenantID, + UID: actor.UID, + Purpose: purpose, + Target: target, + }) + if err != nil { + return nil, err + } + locale := sc.Config.Notification.DefaultLocale + if _, sendErr := sc.Notifier.Send(ctx, ¬ifuc.SendRequest{ + TenantID: actor.TenantID, + UID: actor.UID, + Channel: channel, + Kind: kind, + Target: target, + Locale: locale, + Data: map[string]any{"code": plainCode, "expires_in": dto.ExpiresIn}, + IdempotencyKey: dto.ChallengeID, + DoNotPersistBody: true, + Severity: notifenum.SeverityInfo, + }); sendErr != nil { + if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil { + return nil, errb.SysInternal("invalidate otp after send failure").WithCause(invErr) + } + return nil, sendErr + } + return &types.VerificationStartData{ + ChallengeID: dto.ChallengeID, + ExpiresIn: dto.ExpiresIn, + }, nil +} + +func confirmVerification( + ctx context.Context, + sc *svc.ServiceContext, + actor Actor, + req *types.VerificationConfirmReq, + purpose enum.OTPPurpose, + setVerified func(context.Context, string, string, string) error, +) error { + if sc.MemberOTP == nil || sc.MemberProfile == nil { + return errb.SysInternal("member module not configured") + } + if req == nil || req.ChallengeID == "" || req.Code == "" { + return errb.InputMissingRequired("challenge_id and code are required") + } + target, err := sc.MemberOTP.Verify(ctx, &domusecase.VerifyOTPRequest{ + TenantID: actor.TenantID, + UID: actor.UID, + ChallengeID: req.ChallengeID, + Code: req.Code, + Purpose: purpose, + }) + if err != nil { + return err + } + return setVerified(ctx, actor.TenantID, actor.UID, target) +} + +func requireTOTP(sc *svc.ServiceContext) error { + if sc.MemberTOTP == nil { + return errb.SysInternal("member TOTP not configured") + } + return nil +} + +func actorOrErr(ctx context.Context) (Actor, error) { + actor, err := ActorFromContext(ctx) + if err != nil { + return Actor{}, errb.AuthForbidden(err.Error()) + } + return actor, nil +} diff --git a/internal/logic/member/verify_t_o_t_p_logic.go b/internal/logic/member/verify_t_o_t_p_logic.go new file mode 100644 index 0000000..30aa107 --- /dev/null +++ b/internal/logic/member/verify_t_o_t_p_logic.go @@ -0,0 +1,36 @@ +// Code scaffolded by goctl. Safe to edit. +package member + +import ( + "context" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type VerifyTOTPLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewVerifyTOTPLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VerifyTOTPLogic { + return &VerifyTOTPLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *VerifyTOTPLogic) VerifyTOTP(req *types.TOTPVerifyReq) error { + actor, err := actorOrErr(l.ctx) + if err != nil { + return err + } + if err := requireTOTP(l.svcCtx); err != nil { + return err + } + code := "" + if req != nil { + code = req.Code + } + return l.svcCtx.MemberTOTP.VerifyCode(l.ctx, actor.TenantID, actor.UID, code) +} diff --git a/internal/model/member/README.md b/internal/model/member/README.md index 9aca6f9..2c2f9c3 100644 --- a/internal/model/member/README.md +++ b/internal/model/member/README.md @@ -308,6 +308,30 @@ Helper 函式見 `domain/redis.go`(`GetOTPChallengeRedisKey` 等)。 ## 測試 +### 本機 API(P4) + +> JWT / Casbin 尚未接入;dev 模式用 Header 帶身份: +> `X-Tenant-ID`、`X-UID` + +```bash +make deps-up +make mongo-index +make member-seed # 建立 dev tenant + member,輸出 headers +make run-local # 或 make run + +# 範例 +curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \ + http://127.0.0.1:8888/api/v1/members/me | jq + +# 業務 email 驗證(logic 層:OTP.Generate → Notifier.Send) +curl -s -X POST -H "Content-Type: application/json" \ + -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \ + -d '{"target":"you@example.com"}' \ + http://127.0.0.1:8888/api/v1/members/me/verifications/email/start | jq +``` + +完整 API 見 `generate/api/member.api`(§7.2 對照表)。 + ### 單元測試 ```bash diff --git a/internal/model/member/domain/const.go b/internal/model/member/domain/const.go new file mode 100644 index 0000000..f43d9d1 --- /dev/null +++ b/internal/model/member/domain/const.go @@ -0,0 +1,49 @@ +package domain + +// MongoDB BSON field names for member module collections. +const ( + BSONFieldID = "_id" + BSONFieldTenantID = "tenant_id" + BSONFieldUID = "uid" + BSONFieldZitadelUserID = "zitadel_user_id" + BSONFieldZitadelEmail = "zitadel_email" + BSONFieldDisplayName = "display_name" + BSONFieldAvatar = "avatar" + BSONFieldPhone = "phone" + BSONFieldLanguage = "language" + BSONFieldCurrency = "currency" + BSONFieldMemberStatus = "member_status" + BSONFieldOrigin = "origin" + BSONFieldPasswordHash = "password_hash" + BSONFieldBusinessEmail = "business_email" + BSONFieldBusinessEmailVerified = "business_email_verified" + BSONFieldBusinessEmailVerifiedAt = "business_email_verified_at" + BSONFieldBusinessPhone = "business_phone" + BSONFieldBusinessPhoneVerified = "business_phone_verified" + BSONFieldBusinessPhoneVerifiedAt = "business_phone_verified_at" + BSONFieldTOTPEnrolled = "totp_enrolled" + BSONFieldTOTPSecretCipher = "totp_secret_cipher" //nolint:gosec // BSON field name, not a credential + BSONFieldTOTPEnrolledAt = "totp_enrolled_at" + BSONFieldTOTPBackupCodesHash = "totp_backup_codes_hash" + BSONFieldCreateAt = "create_at" + BSONFieldUpdateAt = "update_at" + BSONFieldDeletedAt = "deleted_at" + BSONFieldAnonymizedAt = "anonymized_at" + BSONFieldSuspendReason = "suspend_reason" + + BSONFieldSlug = "slug" + BSONFieldUIDPrefix = "uid_prefix" + BSONFieldName = "name" + BSONFieldStatus = "status" + BSONFieldOrgID = "org_id" + + BSONFieldExternalID = "external_id" +) + +// UID sequence defaults (identity-member-design.md §12). +const ( + UIDSequenceStart int64 = 10_000_000 + UIDSequenceBucket int64 = 500 + UIDPrefixMinLength = 2 + UIDPrefixMaxLength = 4 +) diff --git a/internal/model/member/domain/entity/identity.go b/internal/model/member/domain/entity/identity.go new file mode 100644 index 0000000..41ca391 --- /dev/null +++ b/internal/model/member/domain/entity/identity.go @@ -0,0 +1,19 @@ +package entity + +import "go.mongodb.org/mongo-driver/v2/bson" + +// Identity maps external identity keys to a member UID within a tenant. +type Identity struct { + ID bson.ObjectID `bson:"_id,omitempty"` + TenantID string `bson:"tenant_id"` + UID string `bson:"uid"` + ZitadelUserID string `bson:"zitadel_user_id,omitempty"` + ExternalID string `bson:"external_id,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +// CollectionName returns the MongoDB collection for identity mappings. +func (Identity) CollectionName() string { + return "identities" +} diff --git a/internal/model/member/domain/entity/member.go b/internal/model/member/domain/entity/member.go new file mode 100644 index 0000000..368c9ee --- /dev/null +++ b/internal/model/member/domain/entity/member.go @@ -0,0 +1,44 @@ +package entity + +import ( + "gateway/internal/model/member/domain/enum" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Member is the tenant-scoped member profile document. +type Member struct { + ID bson.ObjectID `bson:"_id,omitempty"` + TenantID string `bson:"tenant_id"` + UID string `bson:"uid"` + ZitadelUserID string `bson:"zitadel_user_id,omitempty"` + ZitadelEmail string `bson:"zitadel_email,omitempty"` + DisplayName string `bson:"display_name,omitempty"` + Avatar string `bson:"avatar,omitempty"` + Phone string `bson:"phone,omitempty"` + Language string `bson:"language,omitempty"` + Currency string `bson:"currency,omitempty"` + Status enum.MemberStatus `bson:"member_status"` + Origin enum.MemberOrigin `bson:"origin"` + PasswordHash string `bson:"password_hash,omitempty"` + BusinessEmail string `bson:"business_email,omitempty"` + BusinessEmailVerified bool `bson:"business_email_verified"` + BusinessEmailVerifiedAt int64 `bson:"business_email_verified_at,omitempty"` + BusinessPhone string `bson:"business_phone,omitempty"` + BusinessPhoneVerified bool `bson:"business_phone_verified"` + BusinessPhoneVerifiedAt int64 `bson:"business_phone_verified_at,omitempty"` + TOTPEnrolled bool `bson:"totp_enrolled"` + TOTPSecretCipher []byte `bson:"totp_secret_cipher,omitempty"` + TOTPEnrolledAt int64 `bson:"totp_enrolled_at,omitempty"` + TOTPBackupCodesHash []string `bson:"totp_backup_codes_hash,omitempty"` + SuspendReason string `bson:"suspend_reason,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` + DeletedAt int64 `bson:"deleted_at,omitempty"` + AnonymizedAt int64 `bson:"anonymized_at,omitempty"` +} + +// CollectionName returns the MongoDB collection for members. +func (Member) CollectionName() string { + return "members" +} diff --git a/internal/model/member/domain/entity/tenant.go b/internal/model/member/domain/entity/tenant.go new file mode 100644 index 0000000..8680bb8 --- /dev/null +++ b/internal/model/member/domain/entity/tenant.go @@ -0,0 +1,25 @@ +package entity + +import ( + "gateway/internal/model/member/domain/enum" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Tenant holds tenant metadata including UID prefix for readable UIDs. +type Tenant struct { + ID bson.ObjectID `bson:"_id,omitempty"` + TenantID string `bson:"tenant_id"` + Slug string `bson:"slug"` + Name string `bson:"name"` + UIDPrefix string `bson:"uid_prefix"` + Status enum.TenantStatus `bson:"status"` + OrgID string `bson:"org_id,omitempty"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +// CollectionName returns the MongoDB collection for tenants. +func (Tenant) CollectionName() string { + return "tenants" +} diff --git a/internal/model/member/domain/enum/member_origin.go b/internal/model/member/domain/enum/member_origin.go new file mode 100644 index 0000000..bed36c3 --- /dev/null +++ b/internal/model/member/domain/enum/member_origin.go @@ -0,0 +1,25 @@ +package enum + +// MemberOrigin identifies how the member was provisioned. +type MemberOrigin string + +const ( + MemberOriginPlatformNative MemberOrigin = "platform_native" + MemberOriginOIDC MemberOrigin = "oidc" + MemberOriginLDAP MemberOrigin = "ldap" + MemberOriginSCIM MemberOrigin = "scim" +) + +func (o MemberOrigin) String() string { + return string(o) +} + +// IsValid reports whether the origin is a known enum value. +func (o MemberOrigin) IsValid() bool { + switch o { + case MemberOriginPlatformNative, MemberOriginOIDC, MemberOriginLDAP, MemberOriginSCIM: + return true + default: + return false + } +} diff --git a/internal/model/member/domain/enum/member_status.go b/internal/model/member/domain/enum/member_status.go new file mode 100644 index 0000000..8d448a3 --- /dev/null +++ b/internal/model/member/domain/enum/member_status.go @@ -0,0 +1,25 @@ +package enum + +// MemberStatus is the lifecycle state of a member within a tenant. +type MemberStatus string + +const ( + MemberStatusUnverified MemberStatus = "unverified" + MemberStatusActive MemberStatus = "active" + MemberStatusSuspended MemberStatus = "suspended" + MemberStatusDeleted MemberStatus = "deleted" +) + +func (s MemberStatus) String() string { + return string(s) +} + +// IsValid reports whether the status is a known enum value. +func (s MemberStatus) IsValid() bool { + switch s { + case MemberStatusUnverified, MemberStatusActive, MemberStatusSuspended, MemberStatusDeleted: + return true + default: + return false + } +} diff --git a/internal/model/member/domain/enum/otp_purpose.go b/internal/model/member/domain/enum/otp_purpose.go index 181e9bd..5469ab6 100644 --- a/internal/model/member/domain/enum/otp_purpose.go +++ b/internal/model/member/domain/enum/otp_purpose.go @@ -4,9 +4,11 @@ package enum type OTPPurpose string const ( - OTPPurposeBusinessEmail OTPPurpose = "business_email" - OTPPurposeBusinessPhone OTPPurpose = "business_phone" - OTPPurposeStepUp OTPPurpose = "step_up" + OTPPurposeRegistrationEmail OTPPurpose = "registration_email" + OTPPurposeBusinessEmail OTPPurpose = "business_email" + OTPPurposeBusinessPhone OTPPurpose = "business_phone" + OTPPurposeStepUp OTPPurpose = "step_up" + OTPPurposePasswordReset OTPPurpose = "password_reset" ) func (p OTPPurpose) String() string { diff --git a/internal/model/member/domain/enum/tenant_status.go b/internal/model/member/domain/enum/tenant_status.go new file mode 100644 index 0000000..a203cb9 --- /dev/null +++ b/internal/model/member/domain/enum/tenant_status.go @@ -0,0 +1,14 @@ +package enum + +// TenantStatus tracks tenant provisioning lifecycle. +type TenantStatus string + +const ( + TenantStatusProvisioning TenantStatus = "provisioning" + TenantStatusActive TenantStatus = "active" + TenantStatusFailed TenantStatus = "failed" +) + +func (s TenantStatus) String() string { + return string(s) +} diff --git a/internal/model/member/domain/errors.go b/internal/model/member/domain/errors.go index 8dcd2a9..e902568 100644 --- a/internal/model/member/domain/errors.go +++ b/internal/model/member/domain/errors.go @@ -20,4 +20,8 @@ var ( ErrTOTPAlreadyEnroll = fmt.Errorf("member: totp already enrolled") ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code") ErrTOTPCodeReplay = fmt.Errorf("member: totp code already used") + ErrDuplicateMember = fmt.Errorf("member: duplicate member") + ErrDuplicateTenant = fmt.Errorf("member: duplicate tenant") + ErrInvalidStatus = fmt.Errorf("member: invalid member status transition") + ErrTenantNotFound = fmt.Errorf("member: tenant not found") ) diff --git a/internal/model/member/domain/redis.go b/internal/model/member/domain/redis.go index 5b9433a..9239c4c 100644 --- a/internal/model/member/domain/redis.go +++ b/internal/model/member/domain/redis.go @@ -15,6 +15,7 @@ const ( VerifyDailyRedisKey RedisKey = "member:verify:daily" TOTPEnrollRedisKey RedisKey = "member:totp:enroll" TOTPUsedRedisKey RedisKey = "member:totp:used" + MemberSeqRedisKey RedisKey = "member:seq" ) // With appends colon-separated parts to the key. @@ -59,3 +60,8 @@ func GetTOTPEnrollRedisKey(tenantID, uid string) string { func GetTOTPUsedRedisKey(tenantID, uid, timestep string) string { return TOTPUsedRedisKey.With(tenantID, uid, timestep).String() } + +// GetMemberSeqRedisKey returns the UID sequence counter key for a tenant. +func GetMemberSeqRedisKey(tenantID string) string { + return MemberSeqRedisKey.With(tenantID).String() +} diff --git a/internal/model/member/domain/repository/identity.go b/internal/model/member/domain/repository/identity.go new file mode 100644 index 0000000..5d37b29 --- /dev/null +++ b/internal/model/member/domain/repository/identity.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "gateway/internal/model/member/domain/entity" +) + +// IdentityRepository persists zitadel_sub / external_id to uid mappings. +type IdentityRepository interface { + Insert(ctx context.Context, identity *entity.Identity) error + GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Identity, error) + GetByExternalID(ctx context.Context, tenantID, externalID string) (*entity.Identity, error) + GetByUID(ctx context.Context, tenantID, uid string) (*entity.Identity, error) +} diff --git a/internal/model/member/domain/repository/member.go b/internal/model/member/domain/repository/member.go new file mode 100644 index 0000000..25a14ea --- /dev/null +++ b/internal/model/member/domain/repository/member.go @@ -0,0 +1,37 @@ +package repository + +import ( + "context" + + "gateway/internal/model/member/domain/entity" + "gateway/internal/model/member/domain/enum" +) + +// MemberUpdate carries mutable profile fields for PATCH-style updates. +type MemberUpdate struct { + DisplayName *string + Avatar *string + Language *string + Currency *string + Phone *string +} + +// ListMembersFilter scopes a paginated member list query. +type ListMembersFilter struct { + TenantID string + Status enum.MemberStatus + Offset int64 + Limit int64 +} + +// MemberRepository persists member profiles (Mongo). +type MemberRepository interface { + Insert(ctx context.Context, member *entity.Member) error + GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) + GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Member, error) + UpdateProfile(ctx context.Context, tenantID, uid string, update *MemberUpdate) (*entity.Member, error) + UpdateStatus(ctx context.Context, tenantID, uid string, status enum.MemberStatus, suspendReason string) error + List(ctx context.Context, filter ListMembersFilter) ([]*entity.Member, int64, error) + SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error + SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error +} diff --git a/internal/model/member/domain/repository/profile.go b/internal/model/member/domain/repository/profile.go deleted file mode 100644 index fc30b4d..0000000 --- a/internal/model/member/domain/repository/profile.go +++ /dev/null @@ -1,9 +0,0 @@ -package repository - -import "context" - -// ProfileRepository updates member profile verification flags (Mongo in P4). -type ProfileRepository interface { - SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error - SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error -} diff --git a/internal/model/member/domain/repository/tenant.go b/internal/model/member/domain/repository/tenant.go new file mode 100644 index 0000000..c489ed4 --- /dev/null +++ b/internal/model/member/domain/repository/tenant.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + + "gateway/internal/model/member/domain/entity" +) + +// TenantRepository persists tenant metadata. +type TenantRepository interface { + Insert(ctx context.Context, tenant *entity.Tenant) error + GetByTenantID(ctx context.Context, tenantID string) (*entity.Tenant, error) + GetBySlug(ctx context.Context, slug string) (*entity.Tenant, error) + GetByUIDPrefix(ctx context.Context, uidPrefix string) (*entity.Tenant, error) +} diff --git a/internal/model/member/domain/repository/uid.go b/internal/model/member/domain/repository/uid.go new file mode 100644 index 0000000..04a60b3 --- /dev/null +++ b/internal/model/member/domain/repository/uid.go @@ -0,0 +1,8 @@ +package repository + +import "context" + +// UIDGenerator allocates readable UIDs in the form {UIDPrefix}-{Sequence}. +type UIDGenerator interface { + Next(ctx context.Context, tenantID, uidPrefix string) (string, error) +} diff --git a/internal/model/member/domain/usecase/lifecycle.go b/internal/model/member/domain/usecase/lifecycle.go new file mode 100644 index 0000000..67640b4 --- /dev/null +++ b/internal/model/member/domain/usecase/lifecycle.go @@ -0,0 +1,22 @@ +package usecase + +import "context" + +// LifecycleUseCase performs single-step member status transitions. +type LifecycleUseCase interface { + CreateUnverified(ctx context.Context, req *CreatePlatformMemberRequest) (*MemberDTO, error) + Activate(ctx context.Context, tenantID, uid string) error + Suspend(ctx context.Context, tenantID, uid, reason string) error + Reactivate(ctx context.Context, tenantID, uid string) error + SoftDelete(ctx context.Context, tenantID, uid string) error + AbortPending(ctx context.Context, tenantID, uid string) error +} + +// CreatePlatformMemberRequest creates an unverified platform-native member. +type CreatePlatformMemberRequest struct { + TenantID string + Email string + PasswordHash string + DisplayName string + Language string +} diff --git a/internal/model/member/domain/usecase/profile.go b/internal/model/member/domain/usecase/profile.go new file mode 100644 index 0000000..fa6703d --- /dev/null +++ b/internal/model/member/domain/usecase/profile.go @@ -0,0 +1,72 @@ +package usecase + +import ( + "context" + + "gateway/internal/model/member/domain/enum" +) + +// ProfileUseCase reads and updates member profile fields (not lifecycle transitions). +type ProfileUseCase interface { + GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error) + Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error) + List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error) + SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error + SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error +} + +// GetMemberRequest loads a member by tenant + uid. +type GetMemberRequest struct { + TenantID string + UID string +} + +// UpdateMemberRequest patches mutable profile fields. +type UpdateMemberRequest struct { + TenantID string + UID string + DisplayName *string + Avatar *string + Language *string + Currency *string + Phone *string +} + +// ListMembersRequest lists members within a tenant. +type ListMembersRequest struct { + TenantID string + Status enum.MemberStatus + Offset int64 + Limit int64 +} + +// ListMembersResponse is a paginated member list. +type ListMembersResponse struct { + Items []*MemberDTO `json:"items"` + Total int64 `json:"total"` + Offset int64 `json:"offset"` + Limit int64 `json:"limit"` +} + +// MemberDTO is the outward member profile shape. +type MemberDTO struct { + TenantID string `json:"tenant_id"` + UID string `json:"uid"` + ZitadelEmail string `json:"zitadel_email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Avatar string `json:"avatar,omitempty"` + Phone string `json:"phone,omitempty"` + Language string `json:"language,omitempty"` + Currency string `json:"currency,omitempty"` + Status enum.MemberStatus `json:"status"` + Origin enum.MemberOrigin `json:"origin"` + BusinessEmail string `json:"business_email,omitempty"` + BusinessEmailVerified bool `json:"business_email_verified"` + BusinessEmailVerifiedAt int64 `json:"business_email_verified_at,omitempty"` + BusinessPhone string `json:"business_phone,omitempty"` + BusinessPhoneVerified bool `json:"business_phone_verified"` + BusinessPhoneVerifiedAt int64 `json:"business_phone_verified_at,omitempty"` + TOTPEnrolled bool `json:"totp_enrolled"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} diff --git a/internal/model/member/domain/usecase/provisioning.go b/internal/model/member/domain/usecase/provisioning.go new file mode 100644 index 0000000..ddd4a24 --- /dev/null +++ b/internal/model/member/domain/usecase/provisioning.go @@ -0,0 +1,42 @@ +package usecase + +import "context" + +// ProvisioningUseCase upserts members from external identity sources. +type ProvisioningUseCase interface { + EnsureFromOIDC(ctx context.Context, req *EnsureFromOIDCRequest) (*MemberDTO, error) + EnsureFromLDAP(ctx context.Context, req *EnsureFromLDAPRequest) (*MemberDTO, error) + EnsureFromSCIM(ctx context.Context, req *EnsureFromSCIMRequest) (*MemberDTO, error) +} + +// EnsureFromOIDCRequest upserts from ZITADEL OIDC id_token claims. +type EnsureFromOIDCRequest struct { + TenantID string + ZitadelSub string + Email string + EmailVerified bool + DisplayName string + Locale string +} + +// EnsureFromLDAPRequest upserts from LDAP / Directory Sync. +type EnsureFromLDAPRequest struct { + TenantID string + ExternalID string + LDAPDN string + Username string + Email string + DisplayName string + ZitadelSub string +} + +// EnsureFromSCIMRequest upserts from SCIM provisioning. +type EnsureFromSCIMRequest struct { + TenantID string + ExternalID string + UserName string + Email string + DisplayName string + Active bool + ZitadelSub string +} diff --git a/internal/model/member/domain/usecase/tenant.go b/internal/model/member/domain/usecase/tenant.go new file mode 100644 index 0000000..d8e5acc --- /dev/null +++ b/internal/model/member/domain/usecase/tenant.go @@ -0,0 +1,29 @@ +package usecase + +import "context" + +// TenantUseCase manages tenant metadata. +type TenantUseCase interface { + Create(ctx context.Context, req *CreateTenantRequest) (*TenantDTO, error) + ResolveBySlug(ctx context.Context, slug string) (*TenantDTO, error) +} + +// CreateTenantRequest creates a new tenant draft. +type CreateTenantRequest struct { + TenantID string + Slug string + Name string + UIDPrefix string +} + +// TenantDTO is the outward tenant shape. +type TenantDTO struct { + TenantID string `json:"tenant_id"` + Slug string `json:"slug"` + Name string `json:"name"` + UIDPrefix string `json:"uid_prefix"` + Status string `json:"status"` + OrgID string `json:"org_id,omitempty"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} diff --git a/internal/model/member/repository/identity_mongo.go b/internal/model/member/repository/identity_mongo.go new file mode 100644 index 0000000..ee8af90 --- /dev/null +++ b/internal/model/member/repository/identity_mongo.go @@ -0,0 +1,105 @@ +package repository + +import ( + "context" + "errors" + "time" + + libmongo "gateway/internal/library/mongo" + member "gateway/internal/model/member/domain" + "gateway/internal/model/member/domain/entity" + domrepo "gateway/internal/model/member/domain/repository" + + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" +) + +// IdentityRepositoryParam configures the Mongo identity repository. +type IdentityRepositoryParam struct { + Conf *libmongo.Conf +} + +type identityRepository struct { + db libmongo.DocumentDBUseCase +} + +// NewIdentityRepository creates a Mongo-backed IdentityRepository. +func NewIdentityRepository(param IdentityRepositoryParam) domrepo.IdentityRepository { + documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.Identity{}.CollectionName()) + if err != nil { + panic(err) + } + return &identityRepository{db: documentDB} +} + +func (r *identityRepository) Insert(ctx context.Context, identity *entity.Identity) error { + now := time.Now().UTC().UnixMilli() + if identity.ID.IsZero() { + identity.ID = bson.NewObjectID() + } + if identity.CreateAt == 0 { + identity.CreateAt = now + } + if identity.UpdateAt == 0 { + identity.UpdateAt = now + } + _, err := r.db.GetClient().InsertOne(ctx, identity) + if err != nil { + if mongodriver.IsDuplicateKeyError(err) { + return member.ErrDuplicateMember + } + return err + } + return nil +} + +func (r *identityRepository) GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Identity, error) { + return r.findOne(ctx, bson.M{ + member.BSONFieldTenantID: tenantID, + member.BSONFieldZitadelUserID: zitadelUserID, + }) +} + +func (r *identityRepository) GetByExternalID(ctx context.Context, tenantID, externalID string) (*entity.Identity, error) { + return r.findOne(ctx, bson.M{ + member.BSONFieldTenantID: tenantID, + member.BSONFieldExternalID: externalID, + }) +} + +func (r *identityRepository) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Identity, error) { + return r.findOne(ctx, bson.M{ + member.BSONFieldTenantID: tenantID, + member.BSONFieldUID: uid, + }) +} + +func (r *identityRepository) findOne(ctx context.Context, filter bson.M) (*entity.Identity, error) { + var doc entity.Identity + if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return nil, member.ErrNotFound + } + return nil, err + } + return &doc, nil +} + +// Index20260520001UP ensures identities collection indexes exist. +func (r *identityRepository) Index20260520001UP(ctx context.Context) error { + if err := r.db.PopulateMultiIndex(ctx, + []string{member.BSONFieldTenantID, member.BSONFieldZitadelUserID}, + []int32{1, 1}, true); err != nil { + return err + } + if err := r.db.PopulateMultiIndex(ctx, + []string{member.BSONFieldTenantID, member.BSONFieldUID}, + []int32{1, 1}, false); err != nil { + return err + } + return r.db.PopulateMultiIndex(ctx, + []string{member.BSONFieldTenantID, member.BSONFieldExternalID}, + []int32{1, 1}, false) +} + +var _ domrepo.IdentityRepository = (*identityRepository)(nil) diff --git a/internal/model/member/repository/index.go b/internal/model/member/repository/index.go new file mode 100644 index 0000000..e0deaa3 --- /dev/null +++ b/internal/model/member/repository/index.go @@ -0,0 +1,49 @@ +package repository + +import ( + "context" + "fmt" + + libmongo "gateway/internal/library/mongo" +) + +// EnsureMongoIndexes creates indexes for member module collections. +func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error { + if conf == nil || conf.Host == "" { + return nil + } + if err := ensureMemberIndexes(ctx, conf); err != nil { + return err + } + if err := ensureTenantIndexes(ctx, conf); err != nil { + return err + } + return ensureIdentityIndexes(ctx, conf) +} + +func ensureMemberIndexes(ctx context.Context, conf *libmongo.Conf) error { + //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx + repo, ok := NewMemberRepository(MemberRepositoryParam{Conf: conf}).(*memberRepository) + if !ok { + return fmt.Errorf("member: unexpected repository type") + } + return repo.Index20260520001UP(ctx) +} + +func ensureTenantIndexes(ctx context.Context, conf *libmongo.Conf) error { + //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx + repo, ok := NewTenantRepository(TenantRepositoryParam{Conf: conf}).(*tenantRepository) + if !ok { + return fmt.Errorf("member: unexpected tenant repository type") + } + return repo.Index20260520001UP(ctx) +} + +func ensureIdentityIndexes(ctx context.Context, conf *libmongo.Conf) error { + //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx + repo, ok := NewIdentityRepository(IdentityRepositoryParam{Conf: conf}).(*identityRepository) + if !ok { + return fmt.Errorf("member: unexpected identity repository type") + } + return repo.Index20260520001UP(ctx) +} diff --git a/internal/model/member/repository/member_mongo.go b/internal/model/member/repository/member_mongo.go new file mode 100644 index 0000000..4efa0df --- /dev/null +++ b/internal/model/member/repository/member_mongo.go @@ -0,0 +1,221 @@ +package repository + +import ( + "context" + "errors" + "time" + + libmongo "gateway/internal/library/mongo" + member "gateway/internal/model/member/domain" + "gateway/internal/model/member/domain/entity" + "gateway/internal/model/member/domain/enum" + domrepo "gateway/internal/model/member/domain/repository" + + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// MemberRepositoryParam configures the Mongo member repository. +type MemberRepositoryParam struct { + Conf *libmongo.Conf +} + +type memberRepository struct { + db libmongo.DocumentDBUseCase +} + +// NewMemberRepository creates a Mongo-backed MemberRepository. +func NewMemberRepository(param MemberRepositoryParam) domrepo.MemberRepository { + documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.Member{}.CollectionName()) + if err != nil { + panic(err) + } + return &memberRepository{db: documentDB} +} + +func (r *memberRepository) Insert(ctx context.Context, rec *entity.Member) error { + now := time.Now().UTC().UnixMilli() + if rec.ID.IsZero() { + rec.ID = bson.NewObjectID() + } + if rec.CreateAt == 0 { + rec.CreateAt = now + } + if rec.UpdateAt == 0 { + rec.UpdateAt = now + } + _, err := r.db.GetClient().InsertOne(ctx, rec) + if err != nil { + if mongodriver.IsDuplicateKeyError(err) { + return member.ErrDuplicateMember + } + return err + } + return nil +} + +func (r *memberRepository) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) { + var doc entity.Member + filter := bson.M{ + member.BSONFieldTenantID: tenantID, + member.BSONFieldUID: uid, + } + if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return nil, member.ErrNotFound + } + return nil, err + } + return &doc, nil +} + +func (r *memberRepository) GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*entity.Member, error) { + var doc entity.Member + filter := bson.M{ + member.BSONFieldTenantID: tenantID, + member.BSONFieldZitadelUserID: zitadelUserID, + } + if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return nil, member.ErrNotFound + } + return nil, err + } + return &doc, nil +} + +func (r *memberRepository) UpdateProfile(ctx context.Context, tenantID, uid string, update *domrepo.MemberUpdate) (*entity.Member, error) { + set := bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()} + if update.DisplayName != nil { + set[member.BSONFieldDisplayName] = *update.DisplayName + } + if update.Avatar != nil { + set[member.BSONFieldAvatar] = *update.Avatar + } + if update.Language != nil { + set[member.BSONFieldLanguage] = *update.Language + } + if update.Currency != nil { + set[member.BSONFieldCurrency] = *update.Currency + } + if update.Phone != nil { + set[member.BSONFieldPhone] = *update.Phone + } + filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var doc entity.Member + if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, bson.M{"$set": set}, opts); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return nil, member.ErrNotFound + } + return nil, err + } + return &doc, nil +} + +func (r *memberRepository) UpdateStatus(ctx context.Context, tenantID, uid string, status enum.MemberStatus, suspendReason string) error { + set := bson.M{ + member.BSONFieldMemberStatus: status, + member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(), + } + if status == enum.MemberStatusSuspended { + set[member.BSONFieldSuspendReason] = suspendReason + } + if status == enum.MemberStatusDeleted { + set[member.BSONFieldDeletedAt] = time.Now().UTC().UnixMilli() + } + filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return member.ErrNotFound + } + return nil +} + +func (r *memberRepository) List(ctx context.Context, filter domrepo.ListMembersFilter) ([]*entity.Member, int64, error) { + q := bson.M{member.BSONFieldTenantID: filter.TenantID} + if filter.Status != "" { + q[member.BSONFieldMemberStatus] = filter.Status + } + total, err := r.db.GetClient().CountDocuments(ctx, q) + if err != nil { + return nil, 0, err + } + limit := filter.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + opts := options.Find(). + SetSkip(filter.Offset). + SetLimit(limit). + SetSort(bson.D{{Key: member.BSONFieldCreateAt, Value: -1}}) + var docs []*entity.Member + if err := r.db.GetClient().Find(ctx, &docs, q, opts); err != nil { + return nil, 0, err + } + return docs, total, nil +} + +func (r *memberRepository) SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error { + now := time.Now().UTC().UnixMilli() + set := bson.M{ + member.BSONFieldBusinessEmail: email, + member.BSONFieldBusinessEmailVerified: true, + member.BSONFieldBusinessEmailVerifiedAt: now, + member.BSONFieldUpdateAt: now, + } + filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return member.ErrNotFound + } + return nil +} + +func (r *memberRepository) SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error { + now := time.Now().UTC().UnixMilli() + set := bson.M{ + member.BSONFieldBusinessPhone: phone, + member.BSONFieldBusinessPhoneVerified: true, + member.BSONFieldBusinessPhoneVerifiedAt: now, + member.BSONFieldUpdateAt: now, + } + filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return member.ErrNotFound + } + return nil +} + +// Index20260520001UP ensures members collection indexes exist. +func (r *memberRepository) Index20260520001UP(ctx context.Context) error { + if err := r.db.PopulateMultiIndex(ctx, + []string{member.BSONFieldTenantID, member.BSONFieldUID}, + []int32{1, 1}, true); err != nil { + return err + } + if err := r.db.PopulateMultiIndex(ctx, + []string{member.BSONFieldTenantID, member.BSONFieldZitadelUserID}, + []int32{1, 1}, true); err != nil { + return err + } + return r.db.PopulateMultiIndex(ctx, + []string{member.BSONFieldTenantID, member.BSONFieldMemberStatus, member.BSONFieldCreateAt}, + []int32{1, 1, -1}, false) +} + +var _ domrepo.MemberRepository = (*memberRepository)(nil) diff --git a/internal/model/member/repository/profile_memory.go b/internal/model/member/repository/profile_memory.go deleted file mode 100644 index f6b6d11..0000000 --- a/internal/model/member/repository/profile_memory.go +++ /dev/null @@ -1,44 +0,0 @@ -package repository - -import ( - "context" - "sync" - - domrepo "gateway/internal/model/member/domain/repository" -) - -// MemoryProfileRepository is an in-memory ProfileRepository for tests and local dev until P4 Mongo entity exists. -type MemoryProfileRepository struct { - mu sync.RWMutex - emails map[string]string // tenant:uid -> email - phones map[string]string -} - -func NewMemoryProfileRepository() *MemoryProfileRepository { - return &MemoryProfileRepository{ - emails: make(map[string]string), - phones: make(map[string]string), - } -} - -func profileKey(tenantID, uid string) string { - return tenantID + ":" + uid -} - -func (r *MemoryProfileRepository) SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error { - _ = ctx - r.mu.Lock() - defer r.mu.Unlock() - r.emails[profileKey(tenantID, uid)] = email - return nil -} - -func (r *MemoryProfileRepository) SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error { - _ = ctx - r.mu.Lock() - defer r.mu.Unlock() - r.phones[profileKey(tenantID, uid)] = phone - return nil -} - -var _ domrepo.ProfileRepository = (*MemoryProfileRepository)(nil) diff --git a/internal/model/member/repository/tenant_mongo.go b/internal/model/member/repository/tenant_mongo.go new file mode 100644 index 0000000..a77bb74 --- /dev/null +++ b/internal/model/member/repository/tenant_mongo.go @@ -0,0 +1,90 @@ +package repository + +import ( + "context" + "errors" + "time" + + libmongo "gateway/internal/library/mongo" + member "gateway/internal/model/member/domain" + "gateway/internal/model/member/domain/entity" + domrepo "gateway/internal/model/member/domain/repository" + + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" +) + +// TenantRepositoryParam configures the Mongo tenant repository. +type TenantRepositoryParam struct { + Conf *libmongo.Conf +} + +type tenantRepository struct { + db libmongo.DocumentDBUseCase +} + +// NewTenantRepository creates a Mongo-backed TenantRepository. +func NewTenantRepository(param TenantRepositoryParam) domrepo.TenantRepository { + documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.Tenant{}.CollectionName()) + if err != nil { + panic(err) + } + return &tenantRepository{db: documentDB} +} + +func (r *tenantRepository) Insert(ctx context.Context, tenant *entity.Tenant) error { + now := time.Now().UTC().UnixMilli() + if tenant.ID.IsZero() { + tenant.ID = bson.NewObjectID() + } + if tenant.CreateAt == 0 { + tenant.CreateAt = now + } + if tenant.UpdateAt == 0 { + tenant.UpdateAt = now + } + _, err := r.db.GetClient().InsertOne(ctx, tenant) + if err != nil { + if mongodriver.IsDuplicateKeyError(err) { + return member.ErrDuplicateTenant + } + return err + } + return nil +} + +func (r *tenantRepository) GetByTenantID(ctx context.Context, tenantID string) (*entity.Tenant, error) { + return r.findOne(ctx, bson.M{member.BSONFieldTenantID: tenantID}) +} + +func (r *tenantRepository) GetBySlug(ctx context.Context, slug string) (*entity.Tenant, error) { + return r.findOne(ctx, bson.M{member.BSONFieldSlug: slug}) +} + +func (r *tenantRepository) GetByUIDPrefix(ctx context.Context, uidPrefix string) (*entity.Tenant, error) { + return r.findOne(ctx, bson.M{member.BSONFieldUIDPrefix: uidPrefix}) +} + +func (r *tenantRepository) findOne(ctx context.Context, filter bson.M) (*entity.Tenant, error) { + var doc entity.Tenant + if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return nil, member.ErrTenantNotFound + } + return nil, err + } + return &doc, nil +} + +// Index20260520001UP ensures tenants collection indexes exist. +func (r *tenantRepository) Index20260520001UP(ctx context.Context) error { + if err := r.db.PopulateIndex(ctx, member.BSONFieldTenantID, 1, true); err != nil { + return err + } + if err := r.db.PopulateIndex(ctx, member.BSONFieldSlug, 1, true); err != nil { + return err + } + return r.db.PopulateIndex(ctx, member.BSONFieldUIDPrefix, 1, true) +} + +var _ domrepo.TenantRepository = (*tenantRepository)(nil) diff --git a/internal/model/member/repository/totp_profile_mongo.go b/internal/model/member/repository/totp_profile_mongo.go new file mode 100644 index 0000000..f924eaa --- /dev/null +++ b/internal/model/member/repository/totp_profile_mongo.go @@ -0,0 +1,126 @@ +package repository + +import ( + "context" + "errors" + "time" + + libmongo "gateway/internal/library/mongo" + member "gateway/internal/model/member/domain" + "gateway/internal/model/member/domain/entity" + domrepo "gateway/internal/model/member/domain/repository" + + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" +) + +// MongoTOTPProfileRepository stores TOTP fields on the members document. +type MongoTOTPProfileRepository struct { + db libmongo.DocumentDBUseCase +} + +// NewMongoTOTPProfileRepository creates a TOTP profile repo backed by members. +func NewMongoTOTPProfileRepository(conf *libmongo.Conf) domrepo.TOTPProfileRepository { + documentDB, err := libmongo.NewDocumentDB(conf, entity.Member{}.CollectionName()) + if err != nil { + panic(err) + } + return &MongoTOTPProfileRepository{db: documentDB} +} + +func (r *MongoTOTPProfileRepository) Get(ctx context.Context, tenantID, uid string) (*domrepo.TOTPProfileRecord, error) { + var doc entity.Member + filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} + if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return &domrepo.TOTPProfileRecord{}, nil + } + return nil, err + } + return memberToTOTPRecord(&doc), nil +} + +func (r *MongoTOTPProfileRepository) Save(ctx context.Context, tenantID, uid string, rec *domrepo.TOTPProfileRecord) error { + set := bson.M{ + member.BSONFieldTOTPEnrolled: rec.Enrolled, + member.BSONFieldTOTPSecretCipher: rec.SecretCipher, + member.BSONFieldTOTPBackupCodesHash: rec.BackupCodesHash, + member.BSONFieldTOTPEnrolledAt: rec.EnrolledAt, + member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(), + } + filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return member.ErrNotFound + } + return nil +} + +func (r *MongoTOTPProfileRepository) Clear(ctx context.Context, tenantID, uid string) error { + set := bson.M{ + member.BSONFieldTOTPEnrolled: false, + member.BSONFieldTOTPSecretCipher: []byte{}, + member.BSONFieldTOTPBackupCodesHash: []string{}, + member.BSONFieldTOTPEnrolledAt: int64(0), + member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(), + } + filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return member.ErrNotFound + } + return nil +} + +func (r *MongoTOTPProfileRepository) ConsumeBackupCode(ctx context.Context, tenantID, uid, hash string) (bool, error) { + filter := bson.M{ + member.BSONFieldTenantID: tenantID, + member.BSONFieldUID: uid, + member.BSONFieldTOTPBackupCodesHash: hash, + } + update := bson.M{ + "$pull": bson.M{member.BSONFieldTOTPBackupCodesHash: hash}, + "$set": bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()}, + } + res, err := r.db.GetClient().UpdateOne(ctx, filter, update) + if err != nil { + return false, err + } + return res.ModifiedCount > 0, nil +} + +func (r *MongoTOTPProfileRepository) ReplaceBackupCodes(ctx context.Context, tenantID, uid string, hashes []string) error { + set := bson.M{ + member.BSONFieldTOTPBackupCodesHash: hashes, + member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(), + } + filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return member.ErrNotFound + } + return nil +} + +func memberToTOTPRecord(doc *entity.Member) *domrepo.TOTPProfileRecord { + if doc == nil { + return &domrepo.TOTPProfileRecord{} + } + return &domrepo.TOTPProfileRecord{ + Enrolled: doc.TOTPEnrolled, + SecretCipher: append([]byte(nil), doc.TOTPSecretCipher...), + BackupCodesHash: append([]string(nil), doc.TOTPBackupCodesHash...), + EnrolledAt: doc.TOTPEnrolledAt, + } +} + +var _ domrepo.TOTPProfileRepository = (*MongoTOTPProfileRepository)(nil) diff --git a/internal/model/member/repository/uid_redis.go b/internal/model/member/repository/uid_redis.go new file mode 100644 index 0000000..001e910 --- /dev/null +++ b/internal/model/member/repository/uid_redis.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + "fmt" + "strconv" + "strings" + + redislib "gateway/internal/library/redis" + member "gateway/internal/model/member/domain" + domrepo "gateway/internal/model/member/domain/repository" +) + +type uidGenerator struct { + redis *redislib.Client +} + +// NewRedisUIDGenerator allocates readable UIDs using Redis INCR. +func NewRedisUIDGenerator(rds *redislib.Client) domrepo.UIDGenerator { + return &uidGenerator{redis: rds} +} + +func (g *uidGenerator) Next(ctx context.Context, tenantID, uidPrefix string) (string, error) { + if tenantID == "" || uidPrefix == "" { + return "", fmt.Errorf("member: tenant_id and uid_prefix are required") + } + key := member.GetMemberSeqRedisKey(tenantID) + seq, err := g.redis.Zero().IncrCtx(ctx, key) + if err != nil { + return "", err + } + if seq == 1 { + seq, err = g.redis.Zero().IncrbyCtx(ctx, key, member.UIDSequenceStart-1) + if err != nil { + return "", err + } + } + return strings.ToUpper(uidPrefix) + "-" + strconv.FormatInt(seq, 10), nil +} + +var _ domrepo.UIDGenerator = (*uidGenerator)(nil) diff --git a/internal/model/member/usecase/lifecycle_usecase.go b/internal/model/member/usecase/lifecycle_usecase.go new file mode 100644 index 0000000..76318f0 --- /dev/null +++ b/internal/model/member/usecase/lifecycle_usecase.go @@ -0,0 +1,128 @@ +package usecase + +import ( + "context" + "errors" + "time" + + member "gateway/internal/model/member/domain" + "gateway/internal/model/member/domain/entity" + "gateway/internal/model/member/domain/enum" + domrepo "gateway/internal/model/member/domain/repository" + domusecase "gateway/internal/model/member/domain/usecase" +) + +type lifecycleUseCase struct { + members domrepo.MemberRepository + tenants domrepo.TenantRepository + uidGen domrepo.UIDGenerator +} + +// LifecycleUseCaseParam wires LifecycleUseCase. +type LifecycleUseCaseParam struct { + Members domrepo.MemberRepository + Tenants domrepo.TenantRepository + UIDGen domrepo.UIDGenerator +} + +// MustLifecycleUseCase constructs LifecycleUseCase. +func MustLifecycleUseCase(param LifecycleUseCaseParam) domusecase.LifecycleUseCase { + if param.Members == nil || param.Tenants == nil || param.UIDGen == nil { + panic("member: lifecycle dependencies are required") + } + return &lifecycleUseCase{ + members: param.Members, + tenants: param.Tenants, + uidGen: param.UIDGen, + } +} + +func (uc *lifecycleUseCase) CreateUnverified(ctx context.Context, req *domusecase.CreatePlatformMemberRequest) (*domusecase.MemberDTO, error) { + if req == nil || req.TenantID == "" || req.Email == "" { + return nil, errb.InputMissingRequired("tenant_id and email are required") + } + tenant, err := uc.tenants.GetByTenantID(ctx, req.TenantID) + if err != nil { + if errors.Is(err, member.ErrTenantNotFound) { + return nil, errb.ResNotFound("tenant", req.TenantID).WithCause(err) + } + return nil, errb.SysInternal("read tenant failed").WithCause(err) + } + uid, err := uc.uidGen.Next(ctx, req.TenantID, tenant.UIDPrefix) + if err != nil { + return nil, errb.SysInternal("allocate uid failed").WithCause(err) + } + now := time.Now().UTC().UnixMilli() + rec := &entity.Member{ + TenantID: req.TenantID, + UID: uid, + ZitadelEmail: req.Email, + DisplayName: req.DisplayName, + Language: defaultLanguage(req.Language), + Status: enum.MemberStatusUnverified, + Origin: enum.MemberOriginPlatformNative, + PasswordHash: req.PasswordHash, + CreateAt: now, + UpdateAt: now, + } + if err := uc.members.Insert(ctx, rec); err != nil { + if errors.Is(err, member.ErrDuplicateMember) { + return nil, errb.ResAlreadyExist("member already exists").WithCause(err) + } + return nil, errb.SysInternal("create member failed").WithCause(err) + } + return memberToDTO(rec), nil +} + +func (uc *lifecycleUseCase) Activate(ctx context.Context, tenantID, uid string) error { + return uc.transition(ctx, tenantID, uid, enum.MemberStatusUnverified, enum.MemberStatusActive, "") +} + +func (uc *lifecycleUseCase) Suspend(ctx context.Context, tenantID, uid, reason string) error { + return uc.transition(ctx, tenantID, uid, enum.MemberStatusActive, enum.MemberStatusSuspended, reason) +} + +func (uc *lifecycleUseCase) Reactivate(ctx context.Context, tenantID, uid string) error { + return uc.transition(ctx, tenantID, uid, enum.MemberStatusSuspended, enum.MemberStatusActive, "") +} + +func (uc *lifecycleUseCase) SoftDelete(ctx context.Context, tenantID, uid string) error { + rec, err := uc.members.GetByUID(ctx, tenantID, uid) + if err != nil { + if errors.Is(err, member.ErrNotFound) { + return errb.ResNotFound("member", uid).WithCause(err) + } + return errb.SysInternal("read member failed").WithCause(err) + } + if rec.Status == enum.MemberStatusDeleted { + return nil + } + if err := uc.members.UpdateStatus(ctx, tenantID, uid, enum.MemberStatusDeleted, ""); err != nil { + return errb.SysInternal("soft delete member failed").WithCause(err) + } + return nil +} + +func (uc *lifecycleUseCase) AbortPending(ctx context.Context, tenantID, uid string) error { + return uc.transition(ctx, tenantID, uid, enum.MemberStatusUnverified, enum.MemberStatusDeleted, "") +} + +func (uc *lifecycleUseCase) transition(ctx context.Context, tenantID, uid string, from, to enum.MemberStatus, reason string) error { + if tenantID == "" || uid == "" { + return errb.InputMissingRequired("tenant_id and uid are required") + } + rec, err := uc.members.GetByUID(ctx, tenantID, uid) + if err != nil { + if errors.Is(err, member.ErrNotFound) { + return errb.ResNotFound("member", uid).WithCause(err) + } + return errb.SysInternal("read member failed").WithCause(err) + } + if rec.Status != from { + return errb.ResInvalidState("invalid member status transition").WithCause(member.ErrInvalidStatus) + } + if err := uc.members.UpdateStatus(ctx, tenantID, uid, to, reason); err != nil { + return errb.SysInternal("update member status failed").WithCause(err) + } + return nil +} diff --git a/internal/model/member/usecase/mapper.go b/internal/model/member/usecase/mapper.go new file mode 100644 index 0000000..bb6dfa0 --- /dev/null +++ b/internal/model/member/usecase/mapper.go @@ -0,0 +1,70 @@ +package usecase + +import ( + "gateway/internal/model/member/domain/entity" + domusecase "gateway/internal/model/member/domain/usecase" +) + +func memberToDTO(m *entity.Member) *domusecase.MemberDTO { + if m == nil { + return nil + } + return &domusecase.MemberDTO{ + TenantID: m.TenantID, + UID: m.UID, + ZitadelEmail: m.ZitadelEmail, + DisplayName: m.DisplayName, + Avatar: m.Avatar, + Phone: m.Phone, + Language: m.Language, + Currency: m.Currency, + Status: m.Status, + Origin: m.Origin, + BusinessEmail: m.BusinessEmail, + BusinessEmailVerified: m.BusinessEmailVerified, + BusinessEmailVerifiedAt: m.BusinessEmailVerifiedAt, + BusinessPhone: m.BusinessPhone, + BusinessPhoneVerified: m.BusinessPhoneVerified, + BusinessPhoneVerifiedAt: m.BusinessPhoneVerifiedAt, + TOTPEnrolled: m.TOTPEnrolled, + CreateAt: m.CreateAt, + UpdateAt: m.UpdateAt, + } +} + +func tenantToDTO(t *entity.Tenant) *domusecase.TenantDTO { + if t == nil { + return nil + } + return &domusecase.TenantDTO{ + TenantID: t.TenantID, + Slug: t.Slug, + Name: t.Name, + UIDPrefix: t.UIDPrefix, + Status: string(t.Status), + OrgID: t.OrgID, + CreateAt: t.CreateAt, + UpdateAt: t.UpdateAt, + } +} + +func normalizeUIDPrefix(prefix string) string { + out := make([]byte, 0, len(prefix)) + for i := 0; i < len(prefix); i++ { + c := prefix[i] + if c >= 'a' && c <= 'z' { + c -= 'a' - 'A' + } + if (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') { + out = append(out, c) + } + } + return string(out) +} + +func defaultLanguage(lang string) string { + if lang != "" { + return lang + } + return "zh-tw" +} diff --git a/internal/model/member/usecase/module.go b/internal/model/member/usecase/module.go index a0a5571..bbbfef4 100644 --- a/internal/model/member/usecase/module.go +++ b/internal/model/member/usecase/module.go @@ -4,6 +4,7 @@ import ( "fmt" libcrypto "gateway/internal/library/crypto" + libmongo "gateway/internal/library/mongo" redislib "gateway/internal/library/redis" memberconfig "gateway/internal/model/member/config" domrepo "gateway/internal/model/member/domain/repository" @@ -16,52 +17,95 @@ import ( // business_email verified") are assembled at the logic / driver layer and // MUST NOT live inside another usecase. type Module struct { - // OTP issues and verifies one-time codes (purpose-agnostic). - OTP domusecase.OTPUseCase - // TOTP is nil when Member.TOTP.SecretKEK is empty / invalid; downstream - // code must gracefully degrade (e.g. fall back to SMS/email OTP at the - // logic layer). - TOTP domusecase.TOTPUseCase + OTP domusecase.OTPUseCase + TOTP domusecase.TOTPUseCase + Profile domusecase.ProfileUseCase + Lifecycle domusecase.LifecycleUseCase + Provisioning domusecase.ProvisioningUseCase + Tenant domusecase.TenantUseCase + VerifyRate domrepo.VerifyRateStore - // Stores exposed for logic-layer orchestration (rate limit, profile - // flips). They are intentionally surfaced so the logic layer can compose - // atomic usecases with rate-limit + profile mutation without re-wiring. - VerifyRate domrepo.VerifyRateStore - Profile domrepo.ProfileRepository + Members domrepo.MemberRepository + Tenants domrepo.TenantRepository + Identities domrepo.IdentityRepository } // ModuleParam wires member module dependencies. type ModuleParam struct { - Redis *redislib.Client - Config memberconfig.Config - // Profile is optional; defaults to memory repository. - Profile domrepo.ProfileRepository - // TOTPProfile is optional; defaults to memory repository. + Redis *redislib.Client + MongoConf *libmongo.Conf + Config memberconfig.Config + // Optional overrides for tests. + Members domrepo.MemberRepository + Tenants domrepo.TenantRepository + Identities domrepo.IdentityRepository TOTPProfile domrepo.TOTPProfileRepository + UIDGen domrepo.UIDGenerator } // NewModuleFromParam builds member atomic usecases. -// -// TOTP is wired only when Member.TOTP.SecretKEK is provided; this lets local -// dev / unit tests boot without a KMS-backed key while production deployments -// fail loud when the operator forgets to configure it. func NewModuleFromParam(param ModuleParam) (*Module, error) { if param.Redis == nil || param.Redis.Zero() == nil { return nil, fmt.Errorf("member: redis is required") } + cfg := param.Config.Defaults() otpStore := repository.NewRedisOTPChallengeStore(param.Redis) rateStore := repository.NewRedisVerifyRateStore(param.Redis) - profile := param.Profile - if profile == nil { - profile = repository.NewMemoryProfileRepository() + + members := param.Members + tenants := param.Tenants + identities := param.Identities + uidGen := param.UIDGen + totpProfile := param.TOTPProfile + + if param.MongoConf != nil && param.MongoConf.Host != "" { + if members == nil { + members = repository.NewMemberRepository(repository.MemberRepositoryParam{Conf: param.MongoConf}) + } + if tenants == nil { + tenants = repository.NewTenantRepository(repository.TenantRepositoryParam{Conf: param.MongoConf}) + } + if identities == nil { + identities = repository.NewIdentityRepository(repository.IdentityRepositoryParam{Conf: param.MongoConf}) + } + if totpProfile == nil { + totpProfile = repository.NewMongoTOTPProfileRepository(param.MongoConf) + } + } + if uidGen == nil { + uidGen = repository.NewRedisUIDGenerator(param.Redis) + } + if totpProfile == nil { + totpProfile = repository.NewMemoryTOTPProfileRepository() } - cfg := param.Config.Defaults() mod := &Module{ OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}), VerifyRate: rateStore, - Profile: profile, + Members: members, + Tenants: tenants, + Identities: identities, + } + + if members != nil { + mod.Profile = MustProfileUseCase(ProfileUseCaseParam{Members: members}) + } + if members != nil && tenants != nil && uidGen != nil { + mod.Lifecycle = MustLifecycleUseCase(LifecycleUseCaseParam{ + Members: members, + Tenants: tenants, + UIDGen: uidGen, + }) + mod.Tenant = MustTenantUseCase(TenantUseCaseParam{Tenants: tenants}) + } + if members != nil && identities != nil && tenants != nil && uidGen != nil { + mod.Provisioning = MustProvisioningUseCase(ProvisioningUseCaseParam{ + Members: members, + Identities: identities, + Tenants: tenants, + UIDGen: uidGen, + }) } if cfg.TOTP.SecretKEK != "" { @@ -69,10 +113,6 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) { if err != nil { return nil, fmt.Errorf("member: totp kek: %w", err) } - totpProfile := param.TOTPProfile - if totpProfile == nil { - totpProfile = repository.NewMemoryTOTPProfileRepository() - } mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{ Profile: totpProfile, Enroll: repository.NewRedisTOTPEnrollStore(param.Redis), diff --git a/internal/model/member/usecase/profile_usecase.go b/internal/model/member/usecase/profile_usecase.go new file mode 100644 index 0000000..bbb4654 --- /dev/null +++ b/internal/model/member/usecase/profile_usecase.go @@ -0,0 +1,112 @@ +package usecase + +import ( + "context" + "errors" + + member "gateway/internal/model/member/domain" + domrepo "gateway/internal/model/member/domain/repository" + domusecase "gateway/internal/model/member/domain/usecase" +) + +type profileUseCase struct { + members domrepo.MemberRepository +} + +// ProfileUseCaseParam wires ProfileUseCase. +type ProfileUseCaseParam struct { + Members domrepo.MemberRepository +} + +// MustProfileUseCase constructs ProfileUseCase. +func MustProfileUseCase(param ProfileUseCaseParam) domusecase.ProfileUseCase { + if param.Members == nil { + panic("member: member repository is required") + } + return &profileUseCase{members: param.Members} +} + +func (uc *profileUseCase) GetByUID(ctx context.Context, req *domusecase.GetMemberRequest) (*domusecase.MemberDTO, error) { + if req == nil || req.TenantID == "" || req.UID == "" { + return nil, errb.InputMissingRequired("tenant_id and uid are required") + } + rec, err := uc.members.GetByUID(ctx, req.TenantID, req.UID) + if err != nil { + if errors.Is(err, member.ErrNotFound) { + return nil, errb.ResNotFound("member", req.UID).WithCause(err) + } + return nil, errb.SysInternal("read member failed").WithCause(err) + } + return memberToDTO(rec), nil +} + +func (uc *profileUseCase) Update(ctx context.Context, req *domusecase.UpdateMemberRequest) (*domusecase.MemberDTO, error) { + if req == nil || req.TenantID == "" || req.UID == "" { + return nil, errb.InputMissingRequired("tenant_id and uid are required") + } + rec, err := uc.members.UpdateProfile(ctx, req.TenantID, req.UID, &domrepo.MemberUpdate{ + DisplayName: req.DisplayName, + Avatar: req.Avatar, + Language: req.Language, + Currency: req.Currency, + Phone: req.Phone, + }) + if err != nil { + if errors.Is(err, member.ErrNotFound) { + return nil, errb.ResNotFound("member", req.UID).WithCause(err) + } + return nil, errb.SysInternal("update member failed").WithCause(err) + } + return memberToDTO(rec), nil +} + +func (uc *profileUseCase) List(ctx context.Context, req *domusecase.ListMembersRequest) (*domusecase.ListMembersResponse, error) { + if req == nil || req.TenantID == "" { + return nil, errb.InputMissingRequired("tenant_id is required") + } + items, total, err := uc.members.List(ctx, domrepo.ListMembersFilter{ + TenantID: req.TenantID, + Status: req.Status, + Offset: req.Offset, + Limit: req.Limit, + }) + if err != nil { + return nil, errb.SysInternal("list members failed").WithCause(err) + } + out := make([]*domusecase.MemberDTO, 0, len(items)) + for _, item := range items { + out = append(out, memberToDTO(item)) + } + return &domusecase.ListMembersResponse{ + Items: out, + Total: total, + Offset: req.Offset, + Limit: req.Limit, + }, nil +} + +func (uc *profileUseCase) SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error { + if tenantID == "" || uid == "" || email == "" { + return errb.InputMissingRequired("tenant_id, uid and email are required") + } + if err := uc.members.SetBusinessEmailVerified(ctx, tenantID, uid, email); err != nil { + if errors.Is(err, member.ErrNotFound) { + return errb.ResNotFound("member", uid).WithCause(err) + } + return errb.SysInternal("set business email verified failed").WithCause(err) + } + return nil +} + +func (uc *profileUseCase) SetBusinessPhoneVerified(ctx context.Context, tenantID, uid, phone string) error { + if tenantID == "" || uid == "" || phone == "" { + return errb.InputMissingRequired("tenant_id, uid and phone are required") + } + if err := uc.members.SetBusinessPhoneVerified(ctx, tenantID, uid, phone); err != nil { + if errors.Is(err, member.ErrNotFound) { + return errb.ResNotFound("member", uid).WithCause(err) + } + return errb.SysInternal("set business phone verified failed").WithCause(err) + } + return nil +} diff --git a/internal/model/member/usecase/provisioning_usecase.go b/internal/model/member/usecase/provisioning_usecase.go new file mode 100644 index 0000000..4f7220a --- /dev/null +++ b/internal/model/member/usecase/provisioning_usecase.go @@ -0,0 +1,149 @@ +package usecase + +import ( + "context" + "errors" + "time" + + member "gateway/internal/model/member/domain" + "gateway/internal/model/member/domain/entity" + "gateway/internal/model/member/domain/enum" + domrepo "gateway/internal/model/member/domain/repository" + domusecase "gateway/internal/model/member/domain/usecase" +) + +type provisioningUseCase struct { + members domrepo.MemberRepository + identities domrepo.IdentityRepository + tenants domrepo.TenantRepository + uidGen domrepo.UIDGenerator +} + +// ProvisioningUseCaseParam wires ProvisioningUseCase. +type ProvisioningUseCaseParam struct { + Members domrepo.MemberRepository + Identities domrepo.IdentityRepository + Tenants domrepo.TenantRepository + UIDGen domrepo.UIDGenerator +} + +// MustProvisioningUseCase constructs ProvisioningUseCase. +func MustProvisioningUseCase(param ProvisioningUseCaseParam) domusecase.ProvisioningUseCase { + if param.Members == nil || param.Identities == nil || param.Tenants == nil || param.UIDGen == nil { + panic("member: provisioning dependencies are required") + } + return &provisioningUseCase{ + members: param.Members, + identities: param.Identities, + tenants: param.Tenants, + uidGen: param.UIDGen, + } +} + +func (uc *provisioningUseCase) EnsureFromOIDC(ctx context.Context, req *domusecase.EnsureFromOIDCRequest) (*domusecase.MemberDTO, error) { + if req == nil || req.TenantID == "" || req.ZitadelSub == "" { + return nil, errb.InputMissingRequired("tenant_id and zitadel_sub are required") + } + if existing, err := uc.members.GetByZitadelUserID(ctx, req.TenantID, req.ZitadelSub); err == nil { + return memberToDTO(existing), nil + } else if !errors.Is(err, member.ErrNotFound) { + return nil, errb.SysInternal("read member failed").WithCause(err) + } + return uc.createProvisioned(ctx, req.TenantID, req.ZitadelSub, "", req.Email, req.DisplayName, req.Locale, enum.MemberOriginOIDC) +} + +func (uc *provisioningUseCase) EnsureFromLDAP(ctx context.Context, req *domusecase.EnsureFromLDAPRequest) (*domusecase.MemberDTO, error) { + if req == nil || req.TenantID == "" { + return nil, errb.InputMissingRequired("tenant_id is required") + } + sub := req.ZitadelSub + if sub == "" { + sub = req.ExternalID + } + if sub != "" { + if existing, err := uc.members.GetByZitadelUserID(ctx, req.TenantID, sub); err == nil { + return memberToDTO(existing), nil + } else if !errors.Is(err, member.ErrNotFound) { + return nil, errb.SysInternal("read member failed").WithCause(err) + } + } + if req.ExternalID != "" { + if idn, err := uc.identities.GetByExternalID(ctx, req.TenantID, req.ExternalID); err == nil { + rec, getErr := uc.members.GetByUID(ctx, req.TenantID, idn.UID) + if getErr == nil { + return memberToDTO(rec), nil + } + } + } + return uc.createProvisioned(ctx, req.TenantID, sub, req.ExternalID, req.Email, req.DisplayName, "", enum.MemberOriginLDAP) +} + +func (uc *provisioningUseCase) EnsureFromSCIM(ctx context.Context, req *domusecase.EnsureFromSCIMRequest) (*domusecase.MemberDTO, error) { + if req == nil || req.TenantID == "" { + return nil, errb.InputMissingRequired("tenant_id is required") + } + if req.ExternalID != "" { + if idn, err := uc.identities.GetByExternalID(ctx, req.TenantID, req.ExternalID); err == nil { + rec, getErr := uc.members.GetByUID(ctx, req.TenantID, idn.UID) + if getErr == nil { + return memberToDTO(rec), nil + } + } + } + sub := req.ZitadelSub + return uc.createProvisioned(ctx, req.TenantID, sub, req.ExternalID, req.Email, req.DisplayName, "", enum.MemberOriginSCIM) +} + +func (uc *provisioningUseCase) createProvisioned( + ctx context.Context, + tenantID, zitadelSub, externalID, email, displayName, language string, + origin enum.MemberOrigin, +) (*domusecase.MemberDTO, error) { + tenant, err := uc.tenants.GetByTenantID(ctx, tenantID) + if err != nil { + if errors.Is(err, member.ErrTenantNotFound) { + return nil, errb.ResNotFound("tenant", tenantID).WithCause(err) + } + return nil, errb.SysInternal("read tenant failed").WithCause(err) + } + uid, err := uc.uidGen.Next(ctx, tenantID, tenant.UIDPrefix) + if err != nil { + return nil, errb.SysInternal("allocate uid failed").WithCause(err) + } + now := time.Now().UTC().UnixMilli() + rec := &entity.Member{ + TenantID: tenantID, + UID: uid, + ZitadelUserID: zitadelSub, + ZitadelEmail: email, + DisplayName: displayName, + Language: defaultLanguage(language), + Status: enum.MemberStatusActive, + Origin: origin, + CreateAt: now, + UpdateAt: now, + } + if err := uc.members.Insert(ctx, rec); err != nil { + if errors.Is(err, member.ErrDuplicateMember) { + if zitadelSub != "" { + if existing, getErr := uc.members.GetByZitadelUserID(ctx, tenantID, zitadelSub); getErr == nil { + return memberToDTO(existing), nil + } + } + return nil, errb.ResAlreadyExist("member already exists").WithCause(err) + } + return nil, errb.SysInternal("create member failed").WithCause(err) + } + idn := &entity.Identity{ + TenantID: tenantID, + UID: uid, + ZitadelUserID: zitadelSub, + ExternalID: externalID, + CreateAt: now, + UpdateAt: now, + } + if err := uc.identities.Insert(ctx, idn); err != nil && !errors.Is(err, member.ErrDuplicateMember) { + return nil, errb.SysInternal("create identity failed").WithCause(err) + } + return memberToDTO(rec), nil +} diff --git a/internal/model/member/usecase/tenant_usecase.go b/internal/model/member/usecase/tenant_usecase.go new file mode 100644 index 0000000..a9740c5 --- /dev/null +++ b/internal/model/member/usecase/tenant_usecase.go @@ -0,0 +1,80 @@ +package usecase + +import ( + "context" + "errors" + "strings" + "time" + + member "gateway/internal/model/member/domain" + "gateway/internal/model/member/domain/entity" + "gateway/internal/model/member/domain/enum" + domrepo "gateway/internal/model/member/domain/repository" + domusecase "gateway/internal/model/member/domain/usecase" +) + +type tenantUseCase struct { + tenants domrepo.TenantRepository +} + +// TenantUseCaseParam wires TenantUseCase. +type TenantUseCaseParam struct { + Tenants domrepo.TenantRepository +} + +// MustTenantUseCase constructs TenantUseCase. +func MustTenantUseCase(param TenantUseCaseParam) domusecase.TenantUseCase { + if param.Tenants == nil { + panic("member: tenant repository is required") + } + return &tenantUseCase{tenants: param.Tenants} +} + +func (uc *tenantUseCase) Create(ctx context.Context, req *domusecase.CreateTenantRequest) (*domusecase.TenantDTO, error) { + if req == nil || req.TenantID == "" || req.Slug == "" || req.Name == "" { + return nil, errb.InputMissingRequired("tenant_id, slug and name are required") + } + prefix := normalizeUIDPrefix(req.UIDPrefix) + if prefix == "" { + prefix = normalizeUIDPrefix(req.Slug) + } + if len(prefix) < member.UIDPrefixMinLength || len(prefix) > member.UIDPrefixMaxLength { + return nil, errb.InputInvalidFormat("uid_prefix must be 2-4 uppercase letters") + } + if _, err := uc.tenants.GetByUIDPrefix(ctx, prefix); err == nil { + return nil, errb.ResAlreadyExist("uid_prefix already exists") + } else if !errors.Is(err, member.ErrTenantNotFound) { + return nil, errb.SysInternal("read tenant failed").WithCause(err) + } + now := time.Now().UTC().UnixMilli() + rec := &entity.Tenant{ + TenantID: req.TenantID, + Slug: strings.ToLower(req.Slug), + Name: req.Name, + UIDPrefix: prefix, + Status: enum.TenantStatusActive, + CreateAt: now, + UpdateAt: now, + } + if err := uc.tenants.Insert(ctx, rec); err != nil { + if errors.Is(err, member.ErrDuplicateTenant) { + return nil, errb.ResAlreadyExist("tenant already exists").WithCause(err) + } + return nil, errb.SysInternal("create tenant failed").WithCause(err) + } + return tenantToDTO(rec), nil +} + +func (uc *tenantUseCase) ResolveBySlug(ctx context.Context, slug string) (*domusecase.TenantDTO, error) { + if slug == "" { + return nil, errb.InputMissingRequired("slug is required") + } + rec, err := uc.tenants.GetBySlug(ctx, strings.ToLower(slug)) + if err != nil { + if errors.Is(err, member.ErrTenantNotFound) { + return nil, errb.ResNotFound("tenant", slug).WithCause(err) + } + return nil, errb.SysInternal("read tenant failed").WithCause(err) + } + return tenantToDTO(rec), nil +} diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index 26b9eff..689a1a0 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -7,6 +7,7 @@ import ( "context" "gateway/internal/config" + libmongo "gateway/internal/library/mongo" redislib "gateway/internal/library/redis" "gateway/internal/library/validate" domrepo "gateway/internal/model/member/domain/repository" @@ -18,30 +19,21 @@ import ( ) type ServiceContext struct { - Config config.Config - Validator validate.Validate - // Redis is the process-wide client (one pool per Addr); nil when Redis.Host is empty. - Redis *redislib.Client - // Notifier is nil when Mongo is not configured (local scaffold without DB). - Notifier domnotif.NotifierUseCase - // NotificationAdmin is nil when Mongo is not configured. + Config config.Config + Validator validate.Validate + Redis *redislib.Client + Notifier domnotif.NotifierUseCase NotificationAdmin domnotif.AdminNotifierUseCase - // NotificationRetry runs async delivery when Mongo + Redis are configured. NotificationRetry *notification_retry.Runner - // MemberOTP is the atomic OTP usecase (Generate / Verify / Invalidate). - // nil when Redis is not configured. Logic layer composes it with the - // Notifier + Profile flips; usecases MUST NOT call other usecases. - MemberOTP dommember.OTPUseCase - // MemberTOTP is the atomic TOTP usecase; nil when Member.TOTP.SecretKEK - // is unset or Redis is missing. - MemberTOTP dommember.TOTPUseCase - // MemberVerifyRate exposes resend-cooldown / daily-cap helpers for the - // logic layer. - MemberVerifyRate domrepo.VerifyRateStore - // MemberProfile flips BusinessEmail/Phone verified flags; consumed by - // the logic layer after a successful OTP confirmation. - MemberProfile domrepo.ProfileRepository + MemberOTP dommember.OTPUseCase + MemberTOTP dommember.TOTPUseCase + MemberProfile dommember.ProfileUseCase + MemberLifecycle dommember.LifecycleUseCase + MemberProvisioning dommember.ProvisioningUseCase + MemberTenant dommember.TenantUseCase + MemberVerifyRate domrepo.VerifyRateStore + MemberRepo domrepo.MemberRepository } func NewServiceContext(c config.Config) *ServiceContext { @@ -74,29 +66,36 @@ func NewServiceContext(c config.Config) *ServiceContext { sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker) } if rds != nil && rds.Zero() != nil { + var mongoConf *libmongo.Conf + if c.Mongo.Host != "" { + mongoConf = &c.Mongo + } memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ - Redis: rds, - Config: c.Member, + Redis: rds, + MongoConf: mongoConf, + Config: c.Member, }) if err != nil { panic(err) } sc.MemberOTP = memberMod.OTP sc.MemberTOTP = memberMod.TOTP - sc.MemberVerifyRate = memberMod.VerifyRate sc.MemberProfile = memberMod.Profile + sc.MemberLifecycle = memberMod.Lifecycle + sc.MemberProvisioning = memberMod.Provisioning + sc.MemberTenant = memberMod.Tenant + sc.MemberVerifyRate = memberMod.VerifyRate + sc.MemberRepo = memberMod.Members } return sc } -// StartWorkers launches background workers (notification retry, etc.). func (sc *ServiceContext) StartWorkers(ctx context.Context) { if sc.NotificationRetry != nil { sc.NotificationRetry.Start(ctx) } } -// StopWorkers waits for background workers to shut down. func (sc *ServiceContext) StopWorkers() { if sc.NotificationRetry != nil { sc.NotificationRetry.Stop() diff --git a/internal/types/types.go b/internal/types/types.go index d2a433f..9e3ea25 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -16,6 +16,26 @@ type ErrorDetail struct { Detail uint32 `json:"detail,omitempty"` } +type MemberMeData struct { + TenantID string `json:"tenant_id"` + UID string `json:"uid"` + ZitadelEmail string `json:"zitadel_email,omitempty"` + DisplayName string `json:"display_name,omitempty"` + Avatar string `json:"avatar,omitempty"` + Phone string `json:"phone,omitempty"` + Language string `json:"language,omitempty"` + Currency string `json:"currency,omitempty"` + Status string `json:"status"` + Origin string `json:"origin"` + BusinessEmail string `json:"business_email,omitempty"` + BusinessEmailVerified bool `json:"business_email_verified"` + BusinessPhone string `json:"business_phone,omitempty"` + BusinessPhoneVerified bool `json:"business_phone_verified"` + TOTPEnrolled bool `json:"totp_enrolled"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` +} + type PingData struct { Pong string `json:"pong"` } @@ -25,3 +45,58 @@ type PingOKStatus struct { Message string `json:"message"` Data PingData `json:"data"` } + +type TOTPBackupCodesData struct { + BackupCodes []string `json:"backup_codes"` +} + +type TOTPEnrollConfirmData struct { + BackupCodes []string `json:"backup_codes"` +} + +type TOTPEnrollConfirmReq struct { + Code string `json:"code"` +} + +type TOTPEnrollStartData struct { + OtpauthURL string `json:"otpauth_url"` + Issuer string `json:"issuer"` + Account string `json:"account"` + Digits int `json:"digits"` + PeriodSec int `json:"period_seconds"` + ExpiresIn int `json:"expires_in"` +} + +type TOTPStatusData struct { + Enrolled bool `json:"enrolled"` + EnrolledAt int64 `json:"enrolled_at,omitempty"` + BackupCodesRemaining int `json:"backup_codes_remaining"` + Digits int `json:"digits,omitempty"` + PeriodSeconds int `json:"period_seconds,omitempty"` +} + +type TOTPVerifyReq struct { + Code string `json:"code"` +} + +type UpdateMemberMeReq struct { + DisplayName string `json:"display_name,optional"` + Avatar string `json:"avatar,optional"` + Language string `json:"language,optional"` + Currency string `json:"currency,optional"` + Phone string `json:"phone,optional"` +} + +type VerificationConfirmReq struct { + ChallengeID string `json:"challenge_id"` + Code string `json:"code"` +} + +type VerificationStartData struct { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` +} + +type VerificationStartReq struct { + Target string `json:"target"` +}