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
|
||||
|
||||
.PHONY: help tools gen-api gen-mock build-go-doc gen-doc test fmt lint lint-fix fix check run \
|
||||
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test setup-dev run-local
|
||||
deps-up deps-up-smtp deps-down deps-down-v deps-logs deps-ps mongo-index notify-test totp-test member-seed setup-dev run-local
|
||||
|
||||
help: ## 顯示可用指令
|
||||
@echo "Gateway Makefile"
|
||||
|
|
@ -118,5 +118,9 @@ totp-test: setup-dev ## 互動式 TOTP 綁定 + 驗證(Google Authenticator;
|
|||
$(if $(ACCOUNT),-account "$(ACCOUNT)",) $(if $(STEP),-step "$(STEP)",) \
|
||||
$(if $(CODE),-code "$(CODE)",)
|
||||
|
||||
member-seed: setup-dev ## 建立 dev tenant + member(需 Mongo+Redis)
|
||||
$(GO) run ./cmd/member-seed -f etc/gateway.dev.yaml \
|
||||
$(if $(TENANT),-tenant "$(TENANT)",) $(if $(EMAIL),-email "$(EMAIL)",)
|
||||
|
||||
config-check: ## 驗證 gateway.yaml / gateway.dev.yaml 可載入
|
||||
$(GO) test ./internal/config/ -run TestLoadGatewayYAML -v
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// Use when docker-entrypoint-initdb.d did not run (existing mongo_data volume).
|
||||
// Command mongo-index ensures Gateway MongoDB indexes exist.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
|
@ -10,12 +9,13 @@ import (
|
|||
"time"
|
||||
|
||||
"gateway/internal/config"
|
||||
memberrepo "gateway/internal/model/member/repository"
|
||||
notifrepo "gateway/internal/model/notification/repository"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/conf"
|
||||
)
|
||||
|
||||
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file (local; copy from etc/gateway.dev.example.yaml)")
|
||||
var configFile = flag.String("f", "etc/gateway.dev.yaml", "config file")
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
|
|
@ -45,7 +45,10 @@ func run() error {
|
|||
if err := dlqRepo.Index20260520001UP(ctx); err != nil {
|
||||
return fmt.Errorf("mongo-index: notification_dlq: %w", err)
|
||||
}
|
||||
if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
|
||||
return fmt.Errorf("mongo-index: member: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("mongo-index: notifications + notification_dlq indexes OK")
|
||||
fmt.Println("mongo-index: notifications + notification_dlq + member indexes OK")
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ info (
|
|||
|
||||
import (
|
||||
"common.api"
|
||||
"member.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"
|
||||
"time"
|
||||
|
||||
member "gateway/internal/handler/member"
|
||||
normal "gateway/internal/handler/normal"
|
||||
"gateway/internal/svc"
|
||||
|
||||
|
|
@ -14,6 +15,84 @@ import (
|
|||
)
|
||||
|
||||
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||
server.AddRoutes(
|
||||
[]rest.Route{
|
||||
{
|
||||
// 取得當前會員 profile(dev:Header X-Tenant-ID + X-UID)
|
||||
Method: http.MethodGet,
|
||||
Path: "/me",
|
||||
Handler: member.GetMemberMeHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 更新當前會員 profile
|
||||
Method: http.MethodPatch,
|
||||
Path: "/me",
|
||||
Handler: member.UpdateMemberMeHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// TOTP 狀態
|
||||
Method: http.MethodGet,
|
||||
Path: "/me/totp",
|
||||
Handler: member.GetTOTPStatusHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 解除 TOTP 綁定
|
||||
Method: http.MethodDelete,
|
||||
Path: "/me/totp",
|
||||
Handler: member.DisableTOTPHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 重產 TOTP 備援碼
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/totp/backup-codes",
|
||||
Handler: member.RegenerateTOTPBackupCodesHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 確認 TOTP 綁定
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/totp/enroll-confirm",
|
||||
Handler: member.ConfirmTOTPEnrollHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 開始 TOTP 綁定
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/totp/enroll-start",
|
||||
Handler: member.StartTOTPEnrollHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 驗證 TOTP(step-up 測試)
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/totp/verify",
|
||||
Handler: member.VerifyTOTPHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 確認業務 email 驗證
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/verifications/email/confirm",
|
||||
Handler: member.ConfirmEmailVerificationHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 開始業務 email 驗證
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/verifications/email/start",
|
||||
Handler: member.StartEmailVerificationHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 確認業務 phone 驗證
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/verifications/phone/confirm",
|
||||
Handler: member.ConfirmPhoneVerificationHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
// 開始業務 phone 驗證
|
||||
Method: http.MethodPost,
|
||||
Path: "/me/verifications/phone/start",
|
||||
Handler: member.StartPhoneVerificationHandler(serverCtx),
|
||||
},
|
||||
},
|
||||
rest.WithPrefix("/api/v1/members"),
|
||||
)
|
||||
|
||||
server.AddRoutes(
|
||||
[]rest.Route{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
const (
|
||||
OTPPurposeBusinessEmail OTPPurpose = "business_email"
|
||||
OTPPurposeBusinessPhone OTPPurpose = "business_phone"
|
||||
OTPPurposeStepUp OTPPurpose = "step_up"
|
||||
OTPPurposeRegistrationEmail OTPPurpose = "registration_email"
|
||||
OTPPurposeBusinessEmail OTPPurpose = "business_email"
|
||||
OTPPurposeBusinessPhone OTPPurpose = "business_phone"
|
||||
OTPPurposeStepUp OTPPurpose = "step_up"
|
||||
OTPPurposePasswordReset OTPPurpose = "password_reset"
|
||||
)
|
||||
|
||||
func (p OTPPurpose) String() string {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
ErrTOTPInvalidCode = fmt.Errorf("member: invalid totp code")
|
||||
ErrTOTPCodeReplay = fmt.Errorf("member: totp code already used")
|
||||
ErrDuplicateMember = fmt.Errorf("member: duplicate member")
|
||||
ErrDuplicateTenant = fmt.Errorf("member: duplicate tenant")
|
||||
ErrInvalidStatus = fmt.Errorf("member: invalid member status transition")
|
||||
ErrTenantNotFound = fmt.Errorf("member: tenant not found")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const (
|
|||
VerifyDailyRedisKey RedisKey = "member:verify:daily"
|
||||
TOTPEnrollRedisKey RedisKey = "member:totp:enroll"
|
||||
TOTPUsedRedisKey RedisKey = "member:totp:used"
|
||||
MemberSeqRedisKey RedisKey = "member:seq"
|
||||
)
|
||||
|
||||
// With appends colon-separated parts to the key.
|
||||
|
|
@ -59,3 +60,8 @@ func GetTOTPEnrollRedisKey(tenantID, uid string) string {
|
|||
func GetTOTPUsedRedisKey(tenantID, uid, timestep string) string {
|
||||
return TOTPUsedRedisKey.With(tenantID, uid, timestep).String()
|
||||
}
|
||||
|
||||
// GetMemberSeqRedisKey returns the UID sequence counter key for a tenant.
|
||||
func GetMemberSeqRedisKey(tenantID string) string {
|
||||
return MemberSeqRedisKey.With(tenantID).String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
libcrypto "gateway/internal/library/crypto"
|
||||
libmongo "gateway/internal/library/mongo"
|
||||
redislib "gateway/internal/library/redis"
|
||||
memberconfig "gateway/internal/model/member/config"
|
||||
domrepo "gateway/internal/model/member/domain/repository"
|
||||
|
|
@ -16,52 +17,95 @@ import (
|
|||
// business_email verified") are assembled at the logic / driver layer and
|
||||
// MUST NOT live inside another usecase.
|
||||
type Module struct {
|
||||
// OTP issues and verifies one-time codes (purpose-agnostic).
|
||||
OTP domusecase.OTPUseCase
|
||||
// TOTP is nil when Member.TOTP.SecretKEK is empty / invalid; downstream
|
||||
// code must gracefully degrade (e.g. fall back to SMS/email OTP at the
|
||||
// logic layer).
|
||||
TOTP domusecase.TOTPUseCase
|
||||
OTP domusecase.OTPUseCase
|
||||
TOTP domusecase.TOTPUseCase
|
||||
Profile domusecase.ProfileUseCase
|
||||
Lifecycle domusecase.LifecycleUseCase
|
||||
Provisioning domusecase.ProvisioningUseCase
|
||||
Tenant domusecase.TenantUseCase
|
||||
VerifyRate domrepo.VerifyRateStore
|
||||
|
||||
// Stores exposed for logic-layer orchestration (rate limit, profile
|
||||
// flips). They are intentionally surfaced so the logic layer can compose
|
||||
// atomic usecases with rate-limit + profile mutation without re-wiring.
|
||||
VerifyRate domrepo.VerifyRateStore
|
||||
Profile domrepo.ProfileRepository
|
||||
Members domrepo.MemberRepository
|
||||
Tenants domrepo.TenantRepository
|
||||
Identities domrepo.IdentityRepository
|
||||
}
|
||||
|
||||
// ModuleParam wires member module dependencies.
|
||||
type ModuleParam struct {
|
||||
Redis *redislib.Client
|
||||
Config memberconfig.Config
|
||||
// Profile is optional; defaults to memory repository.
|
||||
Profile domrepo.ProfileRepository
|
||||
// TOTPProfile is optional; defaults to memory repository.
|
||||
Redis *redislib.Client
|
||||
MongoConf *libmongo.Conf
|
||||
Config memberconfig.Config
|
||||
// Optional overrides for tests.
|
||||
Members domrepo.MemberRepository
|
||||
Tenants domrepo.TenantRepository
|
||||
Identities domrepo.IdentityRepository
|
||||
TOTPProfile domrepo.TOTPProfileRepository
|
||||
UIDGen domrepo.UIDGenerator
|
||||
}
|
||||
|
||||
// NewModuleFromParam builds member atomic usecases.
|
||||
//
|
||||
// TOTP is wired only when Member.TOTP.SecretKEK is provided; this lets local
|
||||
// dev / unit tests boot without a KMS-backed key while production deployments
|
||||
// fail loud when the operator forgets to configure it.
|
||||
func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||
if param.Redis == nil || param.Redis.Zero() == nil {
|
||||
return nil, fmt.Errorf("member: redis is required")
|
||||
}
|
||||
|
||||
cfg := param.Config.Defaults()
|
||||
otpStore := repository.NewRedisOTPChallengeStore(param.Redis)
|
||||
rateStore := repository.NewRedisVerifyRateStore(param.Redis)
|
||||
profile := param.Profile
|
||||
if profile == nil {
|
||||
profile = repository.NewMemoryProfileRepository()
|
||||
|
||||
members := param.Members
|
||||
tenants := param.Tenants
|
||||
identities := param.Identities
|
||||
uidGen := param.UIDGen
|
||||
totpProfile := param.TOTPProfile
|
||||
|
||||
if param.MongoConf != nil && param.MongoConf.Host != "" {
|
||||
if members == nil {
|
||||
members = repository.NewMemberRepository(repository.MemberRepositoryParam{Conf: param.MongoConf})
|
||||
}
|
||||
if tenants == nil {
|
||||
tenants = repository.NewTenantRepository(repository.TenantRepositoryParam{Conf: param.MongoConf})
|
||||
}
|
||||
if identities == nil {
|
||||
identities = repository.NewIdentityRepository(repository.IdentityRepositoryParam{Conf: param.MongoConf})
|
||||
}
|
||||
if totpProfile == nil {
|
||||
totpProfile = repository.NewMongoTOTPProfileRepository(param.MongoConf)
|
||||
}
|
||||
}
|
||||
if uidGen == nil {
|
||||
uidGen = repository.NewRedisUIDGenerator(param.Redis)
|
||||
}
|
||||
if totpProfile == nil {
|
||||
totpProfile = repository.NewMemoryTOTPProfileRepository()
|
||||
}
|
||||
|
||||
cfg := param.Config.Defaults()
|
||||
mod := &Module{
|
||||
OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}),
|
||||
VerifyRate: rateStore,
|
||||
Profile: profile,
|
||||
Members: members,
|
||||
Tenants: tenants,
|
||||
Identities: identities,
|
||||
}
|
||||
|
||||
if members != nil {
|
||||
mod.Profile = MustProfileUseCase(ProfileUseCaseParam{Members: members})
|
||||
}
|
||||
if members != nil && tenants != nil && uidGen != nil {
|
||||
mod.Lifecycle = MustLifecycleUseCase(LifecycleUseCaseParam{
|
||||
Members: members,
|
||||
Tenants: tenants,
|
||||
UIDGen: uidGen,
|
||||
})
|
||||
mod.Tenant = MustTenantUseCase(TenantUseCaseParam{Tenants: tenants})
|
||||
}
|
||||
if members != nil && identities != nil && tenants != nil && uidGen != nil {
|
||||
mod.Provisioning = MustProvisioningUseCase(ProvisioningUseCaseParam{
|
||||
Members: members,
|
||||
Identities: identities,
|
||||
Tenants: tenants,
|
||||
UIDGen: uidGen,
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.TOTP.SecretKEK != "" {
|
||||
|
|
@ -69,10 +113,6 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("member: totp kek: %w", err)
|
||||
}
|
||||
totpProfile := param.TOTPProfile
|
||||
if totpProfile == nil {
|
||||
totpProfile = repository.NewMemoryTOTPProfileRepository()
|
||||
}
|
||||
mod.TOTP = MustTOTPUseCase(TOTPUseCaseParam{
|
||||
Profile: totpProfile,
|
||||
Enroll: repository.NewRedisTOTPEnrollStore(param.Redis),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
"gateway/internal/config"
|
||||
libmongo "gateway/internal/library/mongo"
|
||||
redislib "gateway/internal/library/redis"
|
||||
"gateway/internal/library/validate"
|
||||
domrepo "gateway/internal/model/member/domain/repository"
|
||||
|
|
@ -18,30 +19,21 @@ import (
|
|||
)
|
||||
|
||||
type ServiceContext struct {
|
||||
Config config.Config
|
||||
Validator validate.Validate
|
||||
// Redis is the process-wide client (one pool per Addr); nil when Redis.Host is empty.
|
||||
Redis *redislib.Client
|
||||
// Notifier is nil when Mongo is not configured (local scaffold without DB).
|
||||
Notifier domnotif.NotifierUseCase
|
||||
// NotificationAdmin is nil when Mongo is not configured.
|
||||
Config config.Config
|
||||
Validator validate.Validate
|
||||
Redis *redislib.Client
|
||||
Notifier domnotif.NotifierUseCase
|
||||
NotificationAdmin domnotif.AdminNotifierUseCase
|
||||
// NotificationRetry runs async delivery when Mongo + Redis are configured.
|
||||
NotificationRetry *notification_retry.Runner
|
||||
|
||||
// MemberOTP is the atomic OTP usecase (Generate / Verify / Invalidate).
|
||||
// nil when Redis is not configured. Logic layer composes it with the
|
||||
// Notifier + Profile flips; usecases MUST NOT call other usecases.
|
||||
MemberOTP dommember.OTPUseCase
|
||||
// MemberTOTP is the atomic TOTP usecase; nil when Member.TOTP.SecretKEK
|
||||
// is unset or Redis is missing.
|
||||
MemberTOTP dommember.TOTPUseCase
|
||||
// MemberVerifyRate exposes resend-cooldown / daily-cap helpers for the
|
||||
// logic layer.
|
||||
MemberVerifyRate domrepo.VerifyRateStore
|
||||
// MemberProfile flips BusinessEmail/Phone verified flags; consumed by
|
||||
// the logic layer after a successful OTP confirmation.
|
||||
MemberProfile domrepo.ProfileRepository
|
||||
MemberOTP dommember.OTPUseCase
|
||||
MemberTOTP dommember.TOTPUseCase
|
||||
MemberProfile dommember.ProfileUseCase
|
||||
MemberLifecycle dommember.LifecycleUseCase
|
||||
MemberProvisioning dommember.ProvisioningUseCase
|
||||
MemberTenant dommember.TenantUseCase
|
||||
MemberVerifyRate domrepo.VerifyRateStore
|
||||
MemberRepo domrepo.MemberRepository
|
||||
}
|
||||
|
||||
func NewServiceContext(c config.Config) *ServiceContext {
|
||||
|
|
@ -74,29 +66,36 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
|||
sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker)
|
||||
}
|
||||
if rds != nil && rds.Zero() != nil {
|
||||
var mongoConf *libmongo.Conf
|
||||
if c.Mongo.Host != "" {
|
||||
mongoConf = &c.Mongo
|
||||
}
|
||||
memberMod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{
|
||||
Redis: rds,
|
||||
Config: c.Member,
|
||||
Redis: rds,
|
||||
MongoConf: mongoConf,
|
||||
Config: c.Member,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
sc.MemberOTP = memberMod.OTP
|
||||
sc.MemberTOTP = memberMod.TOTP
|
||||
sc.MemberVerifyRate = memberMod.VerifyRate
|
||||
sc.MemberProfile = memberMod.Profile
|
||||
sc.MemberLifecycle = memberMod.Lifecycle
|
||||
sc.MemberProvisioning = memberMod.Provisioning
|
||||
sc.MemberTenant = memberMod.Tenant
|
||||
sc.MemberVerifyRate = memberMod.VerifyRate
|
||||
sc.MemberRepo = memberMod.Members
|
||||
}
|
||||
return sc
|
||||
}
|
||||
|
||||
// StartWorkers launches background workers (notification retry, etc.).
|
||||
func (sc *ServiceContext) StartWorkers(ctx context.Context) {
|
||||
if sc.NotificationRetry != nil {
|
||||
sc.NotificationRetry.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// StopWorkers waits for background workers to shut down.
|
||||
func (sc *ServiceContext) StopWorkers() {
|
||||
if sc.NotificationRetry != nil {
|
||||
sc.NotificationRetry.Stop()
|
||||
|
|
|
|||
|
|
@ -16,6 +16,26 @@ type ErrorDetail struct {
|
|||
Detail uint32 `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type MemberMeData struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
UID string `json:"uid"`
|
||||
ZitadelEmail string `json:"zitadel_email,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Origin string `json:"origin"`
|
||||
BusinessEmail string `json:"business_email,omitempty"`
|
||||
BusinessEmailVerified bool `json:"business_email_verified"`
|
||||
BusinessPhone string `json:"business_phone,omitempty"`
|
||||
BusinessPhoneVerified bool `json:"business_phone_verified"`
|
||||
TOTPEnrolled bool `json:"totp_enrolled"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
UpdateAt int64 `json:"update_at"`
|
||||
}
|
||||
|
||||
type PingData struct {
|
||||
Pong string `json:"pong"`
|
||||
}
|
||||
|
|
@ -25,3 +45,58 @@ type PingOKStatus struct {
|
|||
Message string `json:"message"`
|
||||
Data PingData `json:"data"`
|
||||
}
|
||||
|
||||
type TOTPBackupCodesData struct {
|
||||
BackupCodes []string `json:"backup_codes"`
|
||||
}
|
||||
|
||||
type TOTPEnrollConfirmData struct {
|
||||
BackupCodes []string `json:"backup_codes"`
|
||||
}
|
||||
|
||||
type TOTPEnrollConfirmReq struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type TOTPEnrollStartData struct {
|
||||
OtpauthURL string `json:"otpauth_url"`
|
||||
Issuer string `json:"issuer"`
|
||||
Account string `json:"account"`
|
||||
Digits int `json:"digits"`
|
||||
PeriodSec int `json:"period_seconds"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type TOTPStatusData struct {
|
||||
Enrolled bool `json:"enrolled"`
|
||||
EnrolledAt int64 `json:"enrolled_at,omitempty"`
|
||||
BackupCodesRemaining int `json:"backup_codes_remaining"`
|
||||
Digits int `json:"digits,omitempty"`
|
||||
PeriodSeconds int `json:"period_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type TOTPVerifyReq struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type UpdateMemberMeReq struct {
|
||||
DisplayName string `json:"display_name,optional"`
|
||||
Avatar string `json:"avatar,optional"`
|
||||
Language string `json:"language,optional"`
|
||||
Currency string `json:"currency,optional"`
|
||||
Phone string `json:"phone,optional"`
|
||||
}
|
||||
|
||||
type VerificationConfirmReq struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type VerificationStartData struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type VerificationStartReq struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue