add member totp
This commit is contained in:
parent
240fa92f6f
commit
2ae86e9002
6
Makefile
6
Makefile
|
|
@ -16,7 +16,7 @@ GOLANGCI_PKG := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
|
.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: ## 顯示可用指令
|
help: ## 顯示可用指令
|
||||||
@echo "Gateway Makefile"
|
@echo "Gateway Makefile"
|
||||||
|
|
@ -118,5 +118,9 @@ totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;
|
||||||
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
|
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
|
||||||
$(if $(CODE),-code "$(CODE)",)
|
$(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 可載入
|
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
|
||||||
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// Command mongo-index ensures Gateway notification MongoDB indexes exist.
|
// Command mongo-index ensures Gateway MongoDB indexes exist.
|
||||||
// Use when docker-entrypoint-initdb.d did not run (existing mongo_data volume).
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -10,12 +9,13 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gateway/internal/config"
|
"gateway/internal/config"
|
||||||
|
memberrepo "gateway/internal/model/member/repository"
|
||||||
notifrepo "gateway/internal/model/notification/repository"
|
notifrepo "gateway/internal/model/notification/repository"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/conf"
|
"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() {
|
func main() {
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
|
|
@ -45,7 +45,10 @@ func run() error {
|
||||||
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
|
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
|
||||||
return fmt.Errorf("mongo-index: notification_dlq: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ info (
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"common.api"
|
"common.api"
|
||||||
|
"member.api"
|
||||||
"normal.api"
|
"normal.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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
member "gateway/internal/handler/member"
|
||||||
normal "gateway/internal/handler/normal"
|
normal "gateway/internal/handler/normal"
|
||||||
"gateway/internal/svc"
|
"gateway/internal/svc"
|
||||||
|
|
||||||
|
|
@ -14,6 +15,84 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
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(
|
server.AddRoutes(
|
||||||
[]rest.Route{
|
[]rest.Route{
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,11 @@ package enum
|
||||||
type OTPPurpose string
|
type OTPPurpose string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
OTPPurposeRegistrationEmail OTPPurpose = "registration_email"
|
||||||
OTPPurposeBusinessEmail OTPPurpose = "business_email"
|
OTPPurposeBusinessEmail OTPPurpose = "business_email"
|
||||||
OTPPurposeBusinessPhone OTPPurpose = "business_phone"
|
OTPPurposeBusinessPhone OTPPurpose = "business_phone"
|
||||||
OTPPurposeStepUp OTPPurpose = "step_up"
|
OTPPurposeStepUp OTPPurpose = "step_up"
|
||||||
|
OTPPurposePasswordReset OTPPurpose = "password_reset"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (p OTPPurpose) String() string {
|
func (p OTPPurpose) String() string {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -20,4 +20,8 @@ var (
|
||||||
ErrTOTPAlreadyEnroll = fmt.Errorf("member: totp already enrolled")
|
ErrTOTPAlreadyEnroll = fmt.Errorf("member: totp already enrolled")
|
||||||
ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code")
|
ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code")
|
||||||
ErrTOTPCodeReplay = fmt.Errorf("member: totp code already used")
|
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")
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const (
|
||||||
VerifyDailyRedisKey RedisKey = "member:verify:daily"
|
VerifyDailyRedisKey RedisKey = "member:verify:daily"
|
||||||
TOTPEnrollRedisKey RedisKey = "member:totp:enroll"
|
TOTPEnrollRedisKey RedisKey = "member:totp:enroll"
|
||||||
TOTPUsedRedisKey RedisKey = "member:totp:used"
|
TOTPUsedRedisKey RedisKey = "member:totp:used"
|
||||||
|
MemberSeqRedisKey RedisKey = "member:seq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// With appends colon-separated parts to the key.
|
// 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 {
|
func GetTOTPUsedRedisKey(tenantID, uid, timestep string) string {
|
||||||
return TOTPUsedRedisKey.With(tenantID, uid, timestep).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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
libcrypto "gateway/internal/library/crypto"
|
libcrypto "gateway/internal/library/crypto"
|
||||||
|
libmongo "gateway/internal/library/mongo"
|
||||||
redislib "gateway/internal/library/redis"
|
redislib "gateway/internal/library/redis"
|
||||||
memberconfig "gateway/internal/model/member/config"
|
memberconfig "gateway/internal/model/member/config"
|
||||||
domrepo "gateway/internal/model/member/domain/repository"
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
|
|
@ -16,52 +17,95 @@ import (
|
||||||
// business_email verified") are assembled at the logic / driver layer and
|
// business_email verified") are assembled at the logic / driver layer and
|
||||||
// MUST NOT live inside another usecase.
|
// MUST NOT live inside another usecase.
|
||||||
type Module struct {
|
type Module struct {
|
||||||
// OTP issues and verifies one-time codes (purpose-agnostic).
|
|
||||||
OTP domusecase.OTPUseCase
|
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
|
TOTP domusecase.TOTPUseCase
|
||||||
|
Profile domusecase.ProfileUseCase
|
||||||
// Stores exposed for logic-layer orchestration (rate limit, profile
|
Lifecycle domusecase.LifecycleUseCase
|
||||||
// flips). They are intentionally surfaced so the logic layer can compose
|
Provisioning domusecase.ProvisioningUseCase
|
||||||
// atomic usecases with rate-limit + profile mutation without re-wiring.
|
Tenant domusecase.TenantUseCase
|
||||||
VerifyRate domrepo.VerifyRateStore
|
VerifyRate domrepo.VerifyRateStore
|
||||||
Profile domrepo.ProfileRepository
|
|
||||||
|
Members domrepo.MemberRepository
|
||||||
|
Tenants domrepo.TenantRepository
|
||||||
|
Identities domrepo.IdentityRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModuleParam wires member module dependencies.
|
// ModuleParam wires member module dependencies.
|
||||||
type ModuleParam struct {
|
type ModuleParam struct {
|
||||||
Redis *redislib.Client
|
Redis *redislib.Client
|
||||||
|
MongoConf *libmongo.Conf
|
||||||
Config memberconfig.Config
|
Config memberconfig.Config
|
||||||
// Profile is optional; defaults to memory repository.
|
// Optional overrides for tests.
|
||||||
Profile domrepo.ProfileRepository
|
Members domrepo.MemberRepository
|
||||||
// TOTPProfile is optional; defaults to memory repository.
|
Tenants domrepo.TenantRepository
|
||||||
|
Identities domrepo.IdentityRepository
|
||||||
TOTPProfile domrepo.TOTPProfileRepository
|
TOTPProfile domrepo.TOTPProfileRepository
|
||||||
|
UIDGen domrepo.UIDGenerator
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModuleFromParam builds member atomic usecases.
|
// 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) {
|
func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||||
if param.Redis == nil || param.Redis.Zero() == nil {
|
if param.Redis == nil || param.Redis.Zero() == nil {
|
||||||
return nil, fmt.Errorf("member: redis is required")
|
return nil, fmt.Errorf("member: redis is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg := param.Config.Defaults()
|
||||||
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
|
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
|
||||||
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
|
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
|
||||||
profile := param.Profile
|
|
||||||
if profile == nil {
|
members := param.Members
|
||||||
profile = repository.NewMemoryProfileRepository()
|
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{
|
mod := &Module{
|
||||||
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
|
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
|
||||||
VerifyRate: rateStore,
|
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 != "" {
|
if cfg.TOTP.SecretKEK != "" {
|
||||||
|
|
@ -69,10 +113,6 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("member: totp kek: %w", err)
|
return nil, fmt.Errorf("member: totp kek: %w", err)
|
||||||
}
|
}
|
||||||
totpProfile := param.TOTPProfile
|
|
||||||
if totpProfile == nil {
|
|
||||||
totpProfile = repository.NewMemoryTOTPProfileRepository()
|
|
||||||
}
|
|
||||||
mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{
|
mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{
|
||||||
Profile: totpProfile,
|
Profile: totpProfile,
|
||||||
Enroll: repository.NewRedisTOTPEnrollStore(param.Redis),
|
Enroll: repository.NewRedisTOTPEnrollStore(param.Redis),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"gateway/internal/config"
|
"gateway/internal/config"
|
||||||
|
libmongo "gateway/internal/library/mongo"
|
||||||
redislib "gateway/internal/library/redis"
|
redislib "gateway/internal/library/redis"
|
||||||
"gateway/internal/library/validate"
|
"gateway/internal/library/validate"
|
||||||
domrepo "gateway/internal/model/member/domain/repository"
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
|
|
@ -20,28 +21,19 @@ import (
|
||||||
type ServiceContext struct {
|
type ServiceContext struct {
|
||||||
Config config.Config
|
Config config.Config
|
||||||
Validator validate.Validate
|
Validator validate.Validate
|
||||||
// Redis is the process-wide client (one pool per Addr); nil when Redis.Host is empty.
|
|
||||||
Redis *redislib.Client
|
Redis *redislib.Client
|
||||||
// Notifier is nil when Mongo is not configured (local scaffold without DB).
|
|
||||||
Notifier domnotif.NotifierUseCase
|
Notifier domnotif.NotifierUseCase
|
||||||
// NotificationAdmin is nil when Mongo is not configured.
|
|
||||||
NotificationAdmin domnotif.AdminNotifierUseCase
|
NotificationAdmin domnotif.AdminNotifierUseCase
|
||||||
// NotificationRetry runs async delivery when Mongo + Redis are configured.
|
|
||||||
NotificationRetry *notification_retry.Runner
|
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
|
MemberOTP dommember.OTPUseCase
|
||||||
// MemberTOTP is the atomic TOTP usecase; nil when Member.TOTP.SecretKEK
|
|
||||||
// is unset or Redis is missing.
|
|
||||||
MemberTOTP dommember.TOTPUseCase
|
MemberTOTP dommember.TOTPUseCase
|
||||||
// MemberVerifyRate exposes resend-cooldown / daily-cap helpers for the
|
MemberProfile dommember.ProfileUseCase
|
||||||
// logic layer.
|
MemberLifecycle dommember.LifecycleUseCase
|
||||||
|
MemberProvisioning dommember.ProvisioningUseCase
|
||||||
|
MemberTenant dommember.TenantUseCase
|
||||||
MemberVerifyRate domrepo.VerifyRateStore
|
MemberVerifyRate domrepo.VerifyRateStore
|
||||||
// MemberProfile flips BusinessEmail/Phone verified flags; consumed by
|
MemberRepo domrepo.MemberRepository
|
||||||
// the logic layer after a successful OTP confirmation.
|
|
||||||
MemberProfile domrepo.ProfileRepository
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServiceContext(c config.Config) *ServiceContext {
|
func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
|
|
@ -74,8 +66,13 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
|
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
|
||||||
}
|
}
|
||||||
if rds != nil && rds.Zero() != nil {
|
if rds != nil && rds.Zero() != nil {
|
||||||
|
var mongoConf *libmongo.Conf
|
||||||
|
if c.Mongo.Host != "" {
|
||||||
|
mongoConf = &c.Mongo
|
||||||
|
}
|
||||||
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||||
Redis: rds,
|
Redis: rds,
|
||||||
|
MongoConf: mongoConf,
|
||||||
Config: c.Member,
|
Config: c.Member,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -83,20 +80,22 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
}
|
}
|
||||||
sc.MemberOTP = memberMod.OTP
|
sc.MemberOTP = memberMod.OTP
|
||||||
sc.MemberTOTP = memberMod.TOTP
|
sc.MemberTOTP = memberMod.TOTP
|
||||||
sc.MemberVerifyRate = memberMod.VerifyRate
|
|
||||||
sc.MemberProfile = memberMod.Profile
|
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
|
return sc
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartWorkers launches background workers (notification retry, etc.).
|
|
||||||
func (sc *ServiceContext) StartWorkers(ctx context.Context) {
|
func (sc *ServiceContext) StartWorkers(ctx context.Context) {
|
||||||
if sc.NotificationRetry != nil {
|
if sc.NotificationRetry != nil {
|
||||||
sc.NotificationRetry.Start(ctx)
|
sc.NotificationRetry.Start(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopWorkers waits for background workers to shut down.
|
|
||||||
func (sc *ServiceContext) StopWorkers() {
|
func (sc *ServiceContext) StopWorkers() {
|
||||||
if sc.NotificationRetry != nil {
|
if sc.NotificationRetry != nil {
|
||||||
sc.NotificationRetry.Stop()
|
sc.NotificationRetry.Stop()
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,26 @@ type ErrorDetail struct {
|
||||||
Detail uint32 `json:"detail,omitempty"`
|
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 {
|
type PingData struct {
|
||||||
Pong string `json:"pong"`
|
Pong string `json:"pong"`
|
||||||
}
|
}
|
||||||
|
|
@ -25,3 +45,58 @@ type PingOKStatus struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data PingData `json:"data"`
|
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"`
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue