商業聯絡 Email
diff --git a/generate/api/auth.api b/generate/api/auth.api
index 7a8db54..cf7fbf4 100644
--- a/generate/api/auth.api
+++ b/generate/api/auth.api
@@ -29,6 +29,32 @@ type (
ChallengeID string `json:"challenge_id" validate:"required"` // 註冊流程的 OTP challenge ID
}
+ RegisterResumeReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
+ Email string `json:"email" validate:"required,email"` // 註冊 Email
+ }
+
+ PasswordForgotReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
+ Email string `json:"email" validate:"required,email"` // 登入 Email
+ }
+
+ PasswordForgotData {
+ ChallengeID string `json:"challenge_id"`
+ ExpiresIn int `json:"expires_in"`
+ }
+
+ PasswordResetReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
+ ChallengeID string `json:"challenge_id" validate:"required"` // 忘記密碼 OTP challenge ID
+ Code string `json:"code" validate:"required,len=6"` // 6 位數 OTP
+ NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼
+ }
+
+ PasswordResetData {
+ OK bool `json:"ok"`
+ }
+
AuthTokenData {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
@@ -64,6 +90,23 @@ type (
Password string `json:"password" validate:"required,min=8,max=128"` // 密碼(8-128 字元)
}
+ LoginData {
+ AccessToken string `json:"access_token,optional"`
+ RefreshToken string `json:"refresh_token,optional"`
+ ExpiresIn int64 `json:"expires_in,optional"`
+ UID string `json:"uid,optional"`
+ TokenType string `json:"token_type,optional"`
+ MFARequired bool `json:"mfa_required,optional"`
+ MFAChallengeID string `json:"mfa_challenge_id,optional"`
+ MFAExpiresIn int `json:"mfa_expires_in,optional"`
+ }
+
+ LoginMFAConfirmReq {
+ TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
+ ChallengeID string `json:"challenge_id" validate:"required"` // 密碼登入後回傳的 MFA challenge ID
+ Code string `json:"code" validate:"required,len=6"` // TOTP 或備援碼(6 位數)
+ }
+
TokenRefreshReq {
RefreshToken string `json:"refresh_token" validate:"required"` // 先前核發的 refresh token
}
@@ -107,6 +150,12 @@ type (
Data AuthTokenData `json:"data"`
}
+ LoginOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data LoginData `json:"data"`
+ }
+
RegisterSocialStartOKStatus {
Code int64 `json:"code"`
Message string `json:"message"`
@@ -124,6 +173,18 @@ type (
Message string `json:"message"`
Data LogoutData `json:"data"`
}
+
+ PasswordForgotOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data PasswordForgotData `json:"data"`
+ }
+
+ PasswordResetOKStatus {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data PasswordResetData `json:"data"`
+ }
)
@server(
@@ -229,6 +290,77 @@ service gateway {
@handler registerResend
post /register/resend (RegisterResendReq) returns (RegisterData)
+ @doc "恢復未完成註冊(依 Email 重寄 registration OTP)"
+ /*
+ @respdoc-200 (RegisterOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant / 待驗證 member 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-409 (
+ 28309000: (APIErrorStatus) 帳號已完成驗證(Auth scope)
+ ) // 資源狀態衝突
+ @respdoc-429 (
+ 28604000: (APIErrorStatus) OTP 重送冷卻
+ ) // 請求過於頻繁
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
+ @handler registerResume
+ post /register/resume (RegisterResumeReq) returns (RegisterData)
+
+ @doc "忘記密碼:寄送重設 OTP(僅 platform_native 平台帳號)"
+ /*
+ @respdoc-200 (PasswordForgotOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) 外部身份帳號不可重設密碼(Auth scope)
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant / member 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-429 (
+ 29604000: (APIErrorStatus) OTP 重送冷卻
+ ) // 請求過於頻繁
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
+ @handler passwordForgot
+ post /password/forgot (PasswordForgotReq) returns (PasswordForgotData)
+
+ @doc "忘記密碼:驗證 OTP 並重設密碼(僅 platform_native)"
+ /*
+ @respdoc-200 (PasswordResetOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) OTP 無效(Auth scope)
+ 29505000: (APIErrorStatus) OTP 無效(Member scope)
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant / OTP challenge 不存在(Member scope)
+ ) // 資源不存在
+ @respdoc-502 (
+ 28802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler passwordReset
+ post /password/reset (PasswordResetReq) returns (PasswordResetData)
+
@doc "Social 註冊:建立 session 並回傳 OAuth URL"
/*
@respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000)
@@ -299,9 +431,9 @@ service gateway {
@handler registerSocialCallback
get /register/social/callback (RegisterSocialCallbackReq) returns (AuthTokenData)
- @doc "Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)"
+ @doc "Email + 密碼登入(ZITADEL ROPG → CloudEP JWT;若已啟用 TOTP 則回傳 MFA challenge)"
/*
- @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
+ @respdoc-200 (LoginOKStatus) // 成功(code=102000);mfa_required=true 時僅含 challenge
@respdoc-400 (
10101000: (APIErrorStatus) 參數格式錯誤
10104000: (APIErrorStatus) 缺少必填欄位
@@ -327,7 +459,33 @@ service gateway {
) // 第三方服務錯誤
*/
@handler login
- post /login (LoginReq) returns (AuthTokenData)
+ post /login (LoginReq) returns (LoginData)
+
+ @doc "確認登入 MFA(TOTP / 備援碼)並核發 JWT"
+ /*
+ @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-403 (
+ 28505000: (APIErrorStatus) TOTP 無效 / challenge tenant 不符(Auth scope)
+ 29505000: (APIErrorStatus) OTP 無效(Member scope)
+ ) // 禁止存取
+ @respdoc-404 (
+ 29301000: (APIErrorStatus) tenant 不存在(Member scope)
+ 28301000: (APIErrorStatus) login mfa challenge 不存在(Auth scope)
+ ) // 資源不存在
+ @respdoc-500 (
+ 28201000: (APIErrorStatus) 資料庫錯誤
+ 28601000: (APIErrorStatus) 系統內部錯誤
+ ) // 內部錯誤
+ @respdoc-501 (
+ 28605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ */
+ @handler loginMfaConfirm
+ post /login/mfa (LoginMFAConfirmReq) returns (AuthTokenData)
@doc "以 refresh_token 換發新的 access/refresh token"
/*
diff --git a/generate/api/member.api b/generate/api/member.api
index ddb9a07..4b9aada 100644
--- a/generate/api/member.api
+++ b/generate/api/member.api
@@ -29,6 +29,15 @@ type (
Phone string `json:"phone,optional"` // 聯絡電話 E.164 格式(可選)
}
+ ChangePasswordReq {
+ CurrentPassword string `json:"current_password" validate:"required,min=8,max=128"` // 目前密碼
+ NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼
+ }
+
+ ChangePasswordData {
+ OK bool `json:"ok"`
+ }
+
VerificationStartReq {
Target string `json:"target"` // 驗證目標:email 地址或 E.164 手機號(依端點而定)
}
@@ -165,6 +174,29 @@ service gateway {
@handler updateMemberMe
patch /me (UpdateMemberMeReq) returns (MemberMeData)
+ @doc "變更登入密碼(僅 platform_native 平台帳號)"
+ /*
+ @respdoc-200 (EmptyOKStatus) // 成功(code=102000)
+ @respdoc-400 (
+ 10101000: (APIErrorStatus) 參數格式錯誤
+ 10104000: (APIErrorStatus) 缺少必填欄位
+ ) // 參數錯誤
+ @respdoc-401 (
+ 29501000: (APIErrorStatus) 目前密碼錯誤
+ ) // 未授權
+ @respdoc-403 (
+ 29505000: (APIErrorStatus) 外部身份帳號不可變更密碼(Member scope)
+ ) // 禁止存取
+ @respdoc-501 (
+ 29605000: (APIErrorStatus) 功能未配置
+ ) // 未實作
+ @respdoc-502 (
+ 29802000: (APIErrorStatus) ZITADEL 第三方錯誤
+ ) // 第三方服務錯誤
+ */
+ @handler changePassword
+ post /me/password (ChangePasswordReq) returns (ChangePasswordData)
+
@doc "開始業務 email 驗證"
/*
@respdoc-200 (VerificationStartOKStatus) // 成功(code=102000)
diff --git a/internal/handler/auth/login_mfa_confirm_handler.go b/internal/handler/auth/login_mfa_confirm_handler.go
new file mode 100644
index 0000000..23dcab7
--- /dev/null
+++ b/internal/handler/auth/login_mfa_confirm_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 確認登入 MFA(TOTP / 備援碼)並核發 JWT
+func LoginMfaConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.LoginMFAConfirmReq
+ 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 := auth.NewLoginMfaConfirmLogic(r.Context(), svcCtx)
+ data, err := l.LoginMfaConfirm(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/password_forgot_handler.go b/internal/handler/auth/password_forgot_handler.go
new file mode 100644
index 0000000..d73a7c2
--- /dev/null
+++ b/internal/handler/auth/password_forgot_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 忘記密碼:寄送重設 OTP(僅 platform_native 平台帳號)
+func PasswordForgotHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PasswordForgotReq
+ 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 := auth.NewPasswordForgotLogic(r.Context(), svcCtx)
+ data, err := l.PasswordForgot(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/password_reset_handler.go b/internal/handler/auth/password_reset_handler.go
new file mode 100644
index 0000000..3f68319
--- /dev/null
+++ b/internal/handler/auth/password_reset_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 忘記密碼:驗證 OTP 並重設密碼(僅 platform_native)
+func PasswordResetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.PasswordResetReq
+ 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 := auth.NewPasswordResetLogic(r.Context(), svcCtx)
+ data, err := l.PasswordReset(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/auth/register_resume_handler.go b/internal/handler/auth/register_resume_handler.go
new file mode 100644
index 0000000..e4d8a5e
--- /dev/null
+++ b/internal/handler/auth/register_resume_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/auth"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 恢復未完成註冊(重新寄送 registration OTP)
+func RegisterResumeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.RegisterResumeReq
+ 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 := auth.NewRegisterResumeLogic(r.Context(), svcCtx)
+ data, err := l.RegisterResume(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/member/change_password_handler.go b/internal/handler/member/change_password_handler.go
new file mode 100644
index 0000000..6e981f5
--- /dev/null
+++ b/internal/handler/member/change_password_handler.go
@@ -0,0 +1,34 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package member
+
+import (
+ "net/http"
+
+ "gateway/internal/logic/member"
+ "gateway/internal/response"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/rest/httpx"
+)
+
+// 變更登入密碼(僅 platform_native 平台帳號)
+func ChangePasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ChangePasswordReq
+ 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.NewChangePasswordLogic(r.Context(), svcCtx)
+ data, err := l.ChangePassword(&req)
+ response.Write(r.Context(), w, data, err)
+ }
+}
diff --git a/internal/handler/routes.go b/internal/handler/routes.go
index 06d3d25..cad6f0e 100644
--- a/internal/handler/routes.go
+++ b/internal/handler/routes.go
@@ -20,11 +20,17 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{
- // Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)
+ // Email + 密碼登入(ZITADEL ROPG → CloudEP JWT;若已啟用 TOTP 則回傳 MFA challenge)
Method: http.MethodPost,
Path: "/login",
Handler: auth.LoginHandler(serverCtx),
},
+ {
+ // 確認登入 MFA(TOTP / 備援碼)並核發 JWT
+ Method: http.MethodPost,
+ Path: "/login/mfa",
+ Handler: auth.LoginMfaConfirmHandler(serverCtx),
+ },
{
// Social 登入 OAuth callback
Method: http.MethodGet,
@@ -37,6 +43,18 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/login/social/start",
Handler: auth.LoginSocialStartHandler(serverCtx),
},
+ {
+ // 忘記密碼:寄送重設 OTP(僅 platform_native 平台帳號)
+ Method: http.MethodPost,
+ Path: "/password/forgot",
+ Handler: auth.PasswordForgotHandler(serverCtx),
+ },
+ {
+ // 忘記密碼:驗證 OTP 並重設密碼(僅 platform_native)
+ Method: http.MethodPost,
+ Path: "/password/reset",
+ Handler: auth.PasswordResetHandler(serverCtx),
+ },
{
// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
Method: http.MethodPost,
@@ -55,6 +73,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/register/resend",
Handler: auth.RegisterResendHandler(serverCtx),
},
+ {
+ // 恢復未完成註冊(依 Email 重寄 registration OTP)
+ Method: http.MethodPost,
+ Path: "/register/resume",
+ Handler: auth.RegisterResumeHandler(serverCtx),
+ },
{
// Social 註冊 OAuth callback
Method: http.MethodGet,
@@ -114,6 +138,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/me",
Handler: member.UpdateMemberMeHandler(serverCtx),
},
+ {
+ // 變更登入密碼(僅 platform_native 平台帳號)
+ Method: http.MethodPost,
+ Path: "/me/password",
+ Handler: member.ChangePasswordHandler(serverCtx),
+ },
{
// TOTP 狀態
Method: http.MethodGet,
diff --git a/internal/library/zitadel/client.go b/internal/library/zitadel/client.go
index 6b40dfb..082b9d4 100644
--- a/internal/library/zitadel/client.go
+++ b/internal/library/zitadel/client.go
@@ -11,6 +11,8 @@ import (
"strings"
)
+const fieldPassword = "password"
+
// Client calls ZITADEL Management API v2 and OAuth token endpoints.
type Client struct {
conf Conf
@@ -131,8 +133,8 @@ func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest
"email": req.Email,
"isVerified": req.EmailVerified,
},
- "password": map[string]any{
- "password": req.Password,
+ fieldPassword: map[string]any{
+ fieldPassword: req.Password,
"changeRequired": false,
},
}
@@ -152,6 +154,28 @@ func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest
return &CreateHumanUserResult{UserID: out.UserID}, nil
}
+// SetUserPassword sets a human user's password via management API (PAT).
+// When currentPassword is non-empty, ZITADEL validates the old password first.
+func (c *Client) SetUserPassword(ctx context.Context, userID, newPassword, currentPassword string) error {
+ if c == nil {
+ return ErrNotConfigured
+ }
+ if userID == "" || newPassword == "" {
+ return fmt.Errorf("zitadel: user id and new password are required")
+ }
+ body := map[string]any{
+ "newPassword": map[string]any{
+ fieldPassword: newPassword,
+ "changeRequired": false,
+ },
+ }
+ if strings.TrimSpace(currentPassword) != "" {
+ body["currentPassword"] = currentPassword
+ }
+ endpoint := c.apiBase + "/v2/users/" + url.PathEscape(userID) + "/password"
+ return c.doJSON(ctx, http.MethodPost, endpoint, c.serviceAuth(), body, http.StatusOK, nil)
+}
+
// DeactivateUser disables login for the user via POST /v2/users/{id}/deactivate.
func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
if c == nil {
@@ -189,11 +213,11 @@ func (c *Client) VerifyPassword(ctx context.Context, username, password string)
func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) {
form := url.Values{}
- form.Set("grant_type", "password")
+ form.Set("grant_type", fieldPassword)
form.Set("client_id", c.conf.OAuthClientID)
form.Set("client_secret", c.conf.OAuthClientSecret)
form.Set("username", username)
- form.Set("password", password)
+ form.Set(fieldPassword, password)
form.Set("scope", "openid profile email")
return c.postToken(ctx, form)
diff --git a/internal/library/zitadel/session.go b/internal/library/zitadel/session.go
index 2711816..e0a30c0 100644
--- a/internal/library/zitadel/session.go
+++ b/internal/library/zitadel/session.go
@@ -40,7 +40,7 @@ func (c *Client) verifyPasswordSession(ctx context.Context, loginName, password
if err := c.doSessionJSON(ctx, http.MethodPatch, c.apiBase+"/v2/sessions/"+created.SessionID, map[string]any{
"checks": map[string]any{
- "password": map[string]any{"password": password},
+ fieldPassword: map[string]any{fieldPassword: password},
},
}, nil); err != nil {
if isSessionPasswordInvalid(err) {
@@ -74,7 +74,7 @@ func (c *Client) verifyPasswordSession(ctx context.Context, loginName, password
}, nil
}
-func (c *Client) doSessionJSON(ctx context.Context, method, endpoint string, body any, out any) error {
+func (c *Client) doSessionJSON(ctx context.Context, method, endpoint string, body, out any) error {
var r io.Reader
if body != nil {
raw, err := json.Marshal(body)
diff --git a/internal/logic/auth/login_helper.go b/internal/logic/auth/login_helper.go
index 5941f2d..2636bd3 100644
--- a/internal/logic/auth/login_helper.go
+++ b/internal/logic/auth/login_helper.go
@@ -3,6 +3,7 @@ package auth
import (
"context"
"strings"
+ "time"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
@@ -130,3 +131,89 @@ func isMemberNotFound(err error) bool {
e := errs.FromError(err)
return e != nil && e.Category() == code.ResNotFound
}
+
+func loginDataFromTokens(tokens *types.AuthTokenData) *types.LoginData {
+ if tokens == nil {
+ return nil
+ }
+ return &types.LoginData{
+ AccessToken: tokens.AccessToken,
+ RefreshToken: tokens.RefreshToken,
+ ExpiresIn: tokens.ExpiresIn,
+ UID: tokens.UID,
+ TokenType: tokens.TokenType,
+ }
+}
+
+func beginLoginMFA(ctx context.Context, sc *svc.ServiceContext, tenantID, tenantSlug, uid string) (*types.LoginData, error) {
+ if sc.AuthLoginMFAChallenge == nil {
+ return nil, errb.SysNotImplemented("login mfa challenge not configured")
+ }
+ if sc.MemberTOTP == nil {
+ return nil, errb.SysNotImplemented("member TOTP not configured")
+ }
+ ttl := time.Duration(sc.Config.Auth.Defaults().RegistrationSessionTTLSeconds) * time.Second
+ challenge, err := sc.AuthLoginMFAChallenge.Create(ctx, &domauth.CreateLoginMFAChallengeRequest{
+ TenantID: tenantID,
+ TenantSlug: tenantSlug,
+ UID: uid,
+ TTL: ttl,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &types.LoginData{
+ MFARequired: true,
+ MFAChallengeID: challenge.ChallengeID,
+ MFAExpiresIn: challenge.ExpiresIn,
+ }, nil
+}
+
+func confirmLoginMFA(ctx context.Context, sc *svc.ServiceContext, tenantSlug, challengeID, totpCode string) (*types.AuthTokenData, error) {
+ if sc.AuthLoginMFAChallenge == nil {
+ return nil, errb.SysNotImplemented("login mfa challenge not configured")
+ }
+ if sc.MemberTOTP == nil {
+ return nil, errb.SysNotImplemented("member TOTP not configured")
+ }
+
+ tenant, err := resolveTenant(ctx, sc, tenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ challenge, err := sc.AuthLoginMFAChallenge.Get(ctx, challengeID)
+ if err != nil {
+ return nil, err
+ }
+ if challenge.TenantID != tenant.TenantID {
+ return nil, errb.AuthForbidden("login mfa challenge tenant mismatch")
+ }
+ if !strings.EqualFold(strings.TrimSpace(challenge.TenantSlug), strings.TrimSpace(tenantSlug)) {
+ return nil, errb.AuthForbidden("login mfa challenge tenant mismatch")
+ }
+
+ member, err := sc.MemberProfile.GetByUID(ctx, &dommember.GetMemberRequest{
+ TenantID: challenge.TenantID,
+ UID: challenge.UID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := ensureLoginEligible(member.Status); err != nil {
+ return nil, err
+ }
+ if !member.TOTPEnrolled {
+ return nil, errb.ResInvalidState("totp not enrolled").WithCause(memberdom.ErrTOTPNotEnrolled)
+ }
+
+ if err := sc.MemberTOTP.VerifyCode(ctx, challenge.TenantID, challenge.UID, strings.TrimSpace(totpCode)); err != nil {
+ return nil, err
+ }
+
+ if err := sc.AuthLoginMFAChallenge.Delete(ctx, challengeID); err != nil {
+ return nil, err
+ }
+
+ return issueAuthToken(ctx, sc, challenge.TenantID, challenge.UID)
+}
diff --git a/internal/logic/auth/login_logic.go b/internal/logic/auth/login_logic.go
index 6899cee..0a875a2 100644
--- a/internal/logic/auth/login_logic.go
+++ b/internal/logic/auth/login_logic.go
@@ -24,7 +24,7 @@ func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic
}
}
-func (l *LoginLogic) Login(req *types.LoginReq) (*types.AuthTokenData, error) {
+func (l *LoginLogic) Login(req *types.LoginReq) (*types.LoginData, error) {
if err := requireLoginDeps(l.svcCtx); err != nil {
return nil, err
}
@@ -54,5 +54,13 @@ func (l *LoginLogic) Login(req *types.LoginReq) (*types.AuthTokenData, error) {
logx.WithContext(l.ctx).Infof("login: zitadel email mismatch for uid=%s", member.UID)
}
- return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, member.UID)
+ if member.TOTPEnrolled {
+ return beginLoginMFA(l.ctx, l.svcCtx, tenant.TenantID, req.TenantSlug, member.UID)
+ }
+
+ tokens, err := issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, member.UID)
+ if err != nil {
+ return nil, err
+ }
+ return loginDataFromTokens(tokens), nil
}
diff --git a/internal/logic/auth/login_mfa_confirm_logic.go b/internal/logic/auth/login_mfa_confirm_logic.go
new file mode 100644
index 0000000..e14075f
--- /dev/null
+++ b/internal/logic/auth/login_mfa_confirm_logic.go
@@ -0,0 +1,38 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type LoginMfaConfirmLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// 確認登入 MFA(TOTP / 備援碼)並核發 JWT
+func NewLoginMfaConfirmLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginMfaConfirmLogic {
+ return &LoginMfaConfirmLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *LoginMfaConfirmLogic) LoginMfaConfirm(req *types.LoginMFAConfirmReq) (*types.AuthTokenData, error) {
+ if err := requireLoginDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+ if req == nil {
+ return nil, errb.InputMissingRequired("request body is required")
+ }
+ return confirmLoginMFA(l.ctx, l.svcCtx, req.TenantSlug, req.ChallengeID, req.Code)
+}
diff --git a/internal/logic/auth/password_forgot_logic.go b/internal/logic/auth/password_forgot_logic.go
new file mode 100644
index 0000000..e49a811
--- /dev/null
+++ b/internal/logic/auth/password_forgot_logic.go
@@ -0,0 +1,65 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type PasswordForgotLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPasswordForgotLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PasswordForgotLogic {
+ return &PasswordForgotLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *PasswordForgotLogic) PasswordForgot(req *types.PasswordForgotReq) (*types.PasswordForgotData, error) {
+ if err := requireRegistrationDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+ if req == nil {
+ return nil, errb.InputMissingRequired("request body is required")
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ email := normalizeLoginEmail(req.Email)
+ member, err := l.svcCtx.MemberProfile.GetByZitadelEmail(l.ctx, tenant.TenantID, email)
+ if err != nil {
+ if isMemberNotFound(err) {
+ return nil, errb.ResNotFound("member", email)
+ }
+ return nil, err
+ }
+ if err := ensurePlatformNativePassword(member); err != nil {
+ return nil, err
+ }
+ if err := ensurePasswordResetEligible(member.Status); err != nil {
+ return nil, err
+ }
+ if member.ZitadelUserID == "" {
+ return nil, errb.ResInvalidState("member has no zitadel identity")
+ }
+
+ target := email
+ if member.ZitadelEmail != "" {
+ target = normalizeLoginEmail(member.ZitadelEmail)
+ }
+ return sendPasswordResetOTP(l.ctx, l.svcCtx, tenant.TenantID, member.UID, target)
+}
diff --git a/internal/logic/auth/password_helper.go b/internal/logic/auth/password_helper.go
new file mode 100644
index 0000000..b596cc0
--- /dev/null
+++ b/internal/logic/auth/password_helper.go
@@ -0,0 +1,43 @@
+package auth
+
+import (
+ memberenum "gateway/internal/model/member/domain/enum"
+ dommember "gateway/internal/model/member/domain/usecase"
+)
+
+func passwordResetPurpose() memberenum.OTPPurpose {
+ return memberenum.OTPPurposePasswordReset
+}
+
+func ensurePlatformNativePassword(member *dommember.MemberDTO) error {
+ if member == nil {
+ return errb.ResNotFound("member", "")
+ }
+ switch member.Origin {
+ case memberenum.MemberOriginPlatformNative:
+ return nil
+ case memberenum.MemberOriginOIDC:
+ return errb.AuthForbidden("social login accounts cannot change password here")
+ case memberenum.MemberOriginLDAP:
+ return errb.AuthForbidden("ldap accounts cannot change password here")
+ case memberenum.MemberOriginSCIM:
+ return errb.AuthForbidden("scim provisioned accounts cannot change password here")
+ default:
+ return errb.AuthForbidden("account cannot change password here")
+ }
+}
+
+func ensurePasswordResetEligible(status memberenum.MemberStatus) error {
+ switch status {
+ case memberenum.MemberStatusActive:
+ return nil
+ case memberenum.MemberStatusUnverified:
+ return errb.AuthForbidden("account is not verified")
+ case memberenum.MemberStatusSuspended:
+ return errb.AuthForbidden("account is suspended")
+ case memberenum.MemberStatusDeleted:
+ return errb.ResNotFound("member", "")
+ default:
+ return errb.AuthForbidden("account is not allowed to reset password")
+ }
+}
diff --git a/internal/logic/auth/password_otp_helper.go b/internal/logic/auth/password_otp_helper.go
new file mode 100644
index 0000000..e93afd8
--- /dev/null
+++ b/internal/logic/auth/password_otp_helper.go
@@ -0,0 +1,61 @@
+package auth
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ memberdom "gateway/internal/model/member/domain"
+ dommember "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"
+)
+
+func sendPasswordResetOTP(
+ ctx context.Context,
+ sc *svc.ServiceContext,
+ tenantID, uid, email string,
+) (*types.PasswordForgotData, error) {
+ cfg := sc.Config.Member.Defaults()
+ rateKey := memberdom.GetVerifyRateRedisKey(tenantID, uid, string(passwordResetPurpose()))
+ if err := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil {
+ return nil, err
+ }
+
+ dto, plainCode, err := sc.MemberOTP.Generate(ctx, &dommember.GenerateOTPRequest{
+ TenantID: tenantID,
+ UID: uid,
+ Purpose: passwordResetPurpose(),
+ Target: email,
+ })
+ if err != nil {
+ return nil, err
+ }
+ locale := sc.Config.Notification.DefaultLocale
+ if strings.TrimSpace(locale) == "" {
+ locale = "en-us"
+ }
+ if _, sendErr := sc.Notifier.Send(ctx, ¬ifuc.SendRequest{
+ TenantID: tenantID,
+ UID: uid,
+ Channel: notifenum.ChannelEmail,
+ Kind: notifenum.NotifyVerifyEmail,
+ Target: email,
+ 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, invErr
+ }
+ return nil, sendErr
+ }
+ return &types.PasswordForgotData{
+ ChallengeID: dto.ChallengeID,
+ ExpiresIn: dto.ExpiresIn,
+ }, nil
+}
diff --git a/internal/logic/auth/password_reset_logic.go b/internal/logic/auth/password_reset_logic.go
new file mode 100644
index 0000000..c8deac3
--- /dev/null
+++ b/internal/logic/auth/password_reset_logic.go
@@ -0,0 +1,89 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "context"
+
+ dommember "gateway/internal/model/member/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type PasswordResetLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewPasswordResetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PasswordResetLogic {
+ return &PasswordResetLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *PasswordResetLogic) PasswordReset(req *types.PasswordResetReq) (*types.PasswordResetData, error) {
+ if err := requireRegistrationDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+ if l.svcCtx.Zitadel == nil {
+ return nil, errb.SysNotImplemented("zitadel not configured")
+ }
+ if req == nil {
+ return nil, errb.InputMissingRequired("request body is required")
+ }
+
+ tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ ch, err := l.svcCtx.MemberOTP.MatchChallenge(l.ctx, &dommember.MatchChallengeRequest{
+ ChallengeID: req.ChallengeID,
+ TenantID: tenant.TenantID,
+ Purpose: passwordResetPurpose(),
+ RequireUID: true,
+ RequireTarget: true,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ member, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &dommember.GetMemberRequest{
+ TenantID: tenant.TenantID,
+ UID: ch.UID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := ensurePlatformNativePassword(member); err != nil {
+ return nil, err
+ }
+ if err := ensurePasswordResetEligible(member.Status); err != nil {
+ return nil, err
+ }
+ if member.ZitadelUserID == "" {
+ return nil, errb.ResInvalidState("member has no zitadel identity")
+ }
+
+ if _, err := l.svcCtx.MemberOTP.Verify(l.ctx, &dommember.VerifyOTPRequest{
+ TenantID: tenant.TenantID,
+ UID: ch.UID,
+ ChallengeID: req.ChallengeID,
+ Code: req.Code,
+ Purpose: passwordResetPurpose(),
+ }); err != nil {
+ return nil, err
+ }
+
+ if err := l.svcCtx.Zitadel.SetUserPassword(l.ctx, member.ZitadelUserID, req.NewPassword, ""); err != nil {
+ return nil, wrapZitadelErr(err)
+ }
+
+ return &types.PasswordResetData{OK: true}, nil
+}
diff --git a/internal/logic/auth/register_helper.go b/internal/logic/auth/register_helper.go
index fa114f8..c0d2a71 100644
--- a/internal/logic/auth/register_helper.go
+++ b/internal/logic/auth/register_helper.go
@@ -12,6 +12,9 @@ import (
memberenum "gateway/internal/model/member/domain/enum"
dommember "gateway/internal/model/member/domain/usecase"
"gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
func resolveTenant(ctx context.Context, sc *svc.ServiceContext, slug string) (*dommember.TenantDTO, error) {
@@ -85,6 +88,9 @@ func requireRegistrationDeps(sc *svc.ServiceContext) error {
if sc.MemberLifecycle == nil {
return errb.SysNotImplemented("member lifecycle not configured")
}
+ if sc.MemberProfile == nil {
+ return errb.SysNotImplemented("member profile not configured")
+ }
if sc.MemberOTP == nil {
return errb.SysNotImplemented("member OTP not configured")
}
@@ -96,3 +102,109 @@ func requireRegistrationDeps(sc *svc.ServiceContext) error {
}
return nil
}
+
+func resumeRegistration(
+ ctx context.Context,
+ sc *svc.ServiceContext,
+ tenantSlug, email string,
+) (*types.RegisterData, error) {
+ tenant, err := resolveTenant(ctx, sc, tenantSlug)
+ if err != nil {
+ return nil, err
+ }
+
+ email = normalizeLoginEmail(email)
+ member, err := sc.MemberProfile.GetByZitadelEmail(ctx, tenant.TenantID, email)
+ if err != nil {
+ if isMemberNotFound(err) {
+ return nil, errb.ResNotFound("member", email)
+ }
+ return nil, err
+ }
+ if member.Status != memberenum.MemberStatusUnverified {
+ return nil, errb.ResInvalidState("account already verified, please login")
+ }
+
+ data, err := sendRegistrationOTP(ctx, sc, tenant.TenantID, member.UID, email)
+ if err != nil {
+ return nil, err
+ }
+ data.UID = member.UID
+ return data, nil
+}
+
+func recoverPendingRegistration(
+ ctx context.Context,
+ sc *svc.ServiceContext,
+ tenant *dommember.TenantDTO,
+ req *types.RegisterReq,
+) (*types.RegisterData, error) {
+ if req == nil {
+ return nil, errb.InputMissingRequired("request body is required")
+ }
+
+ email := normalizeLoginEmail(req.Email)
+ tok, err := sc.Zitadel.VerifyPassword(ctx, email, req.Password)
+ if err != nil {
+ return nil, errb.AuthUnauthorized("invalid credentials").WithCause(wrapZitadelErr(err))
+ }
+
+ identity, err := zitadelIdentityFromToken(ctx, sc.Zitadel, tok)
+ if err != nil {
+ return nil, err
+ }
+
+ memberDTO, err := memberForRegistrationRecovery(ctx, sc, tenant.TenantID, identity.Sub, email, req)
+ if err != nil {
+ return nil, err
+ }
+
+ switch memberDTO.Status {
+ case memberenum.MemberStatusUnverified:
+ case memberenum.MemberStatusActive:
+ return nil, errb.ResAlreadyExist("email already registered, please login")
+ default:
+ return nil, errb.ResInvalidState("account cannot complete registration")
+ }
+
+ data, err := sendRegistrationOTP(ctx, sc, tenant.TenantID, memberDTO.UID, email)
+ if err != nil {
+ return nil, err
+ }
+ data.UID = memberDTO.UID
+ return data, nil
+}
+
+func memberForRegistrationRecovery(
+ ctx context.Context,
+ sc *svc.ServiceContext,
+ tenantID, zitadelSub, email string,
+ req *types.RegisterReq,
+) (*dommember.MemberDTO, error) {
+ if dto, err := sc.MemberProfile.GetByZitadelUserID(ctx, tenantID, zitadelSub); err == nil {
+ return dto, nil
+ } else if !isMemberNotFound(err) {
+ return nil, err
+ }
+
+ if dto, err := sc.MemberProfile.GetByZitadelEmail(ctx, tenantID, email); err == nil {
+ return dto, nil
+ } else if !isMemberNotFound(err) {
+ return nil, err
+ }
+
+ memberDTO, err := sc.MemberLifecycle.CreateUnverified(ctx, &dommember.CreatePlatformMemberRequest{
+ TenantID: tenantID,
+ Email: email,
+ DisplayName: strings.TrimSpace(req.DisplayName),
+ Language: strings.TrimSpace(req.Language),
+ ZitadelUserID: zitadelSub,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := recordRegistrationMeta(ctx, sc, tenantID, memberDTO.UID, "", req.AcceptTermsVersion, req.MarketingOptIn, authmetaenum.RegistrationChannelEmail); err != nil {
+ logx.WithContext(ctx).Infof("register recover: registration meta skipped: %v", err)
+ }
+ return memberDTO, nil
+}
diff --git a/internal/logic/auth/register_logic.go b/internal/logic/auth/register_logic.go
index 4f8bbc5..06b53dd 100644
--- a/internal/logic/auth/register_logic.go
+++ b/internal/logic/auth/register_logic.go
@@ -2,6 +2,7 @@ package auth
import (
"context"
+ "errors"
"strings"
"time"
@@ -42,6 +43,21 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, e
return nil, err
}
+ email := normalizeLoginEmail(req.Email)
+ zResult, err := l.svcCtx.Zitadel.CreateHumanUser(l.ctx, zitadel.CreateHumanUserRequest{
+ OrgID: tenant.OrgID,
+ Email: email,
+ Password: req.Password,
+ DisplayName: strings.TrimSpace(req.DisplayName),
+ Language: strings.TrimSpace(req.Language),
+ })
+ if err != nil {
+ if errors.Is(err, zitadel.ErrUserAlreadyExists) {
+ return recoverPendingRegistration(l.ctx, l.svcCtx, tenant, req)
+ }
+ return nil, wrapZitadelErr(err)
+ }
+
regCfg := l.svcCtx.Config.Member.Defaults().Registration
var inviteCodeID string
if regCfg.RequireInviteCode {
@@ -53,23 +69,14 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, e
Code: req.InviteCode,
})
if err != nil {
+ if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil {
+ logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after invite failure: %v", deactErr)
+ }
return nil, err
}
inviteCodeID = consumed.ID
}
- email := strings.TrimSpace(strings.ToLower(req.Email))
- zResult, err := l.svcCtx.Zitadel.CreateHumanUser(l.ctx, zitadel.CreateHumanUserRequest{
- OrgID: tenant.OrgID,
- Email: email,
- Password: req.Password,
- DisplayName: strings.TrimSpace(req.DisplayName),
- Language: strings.TrimSpace(req.Language),
- })
- if err != nil {
- return nil, wrapZitadelErr(err)
- }
-
memberDTO, err := l.svcCtx.MemberLifecycle.CreateUnverified(l.ctx, &dommember.CreatePlatformMemberRequest{
TenantID: tenant.TenantID,
Email: email,
diff --git a/internal/logic/auth/register_resume_logic.go b/internal/logic/auth/register_resume_logic.go
new file mode 100644
index 0000000..7a879cb
--- /dev/null
+++ b/internal/logic/auth/register_resume_logic.go
@@ -0,0 +1,38 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+ "context"
+
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type RegisterResumeLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+// 恢復未完成註冊(重新寄送 registration OTP)
+func NewRegisterResumeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterResumeLogic {
+ return &RegisterResumeLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *RegisterResumeLogic) RegisterResume(req *types.RegisterResumeReq) (*types.RegisterData, error) {
+ if err := requireRegistrationDeps(l.svcCtx); err != nil {
+ return nil, err
+ }
+ if req == nil {
+ return nil, errb.InputMissingRequired("request body is required")
+ }
+ return resumeRegistration(l.ctx, l.svcCtx, req.TenantSlug, req.Email)
+}
diff --git a/internal/logic/member/change_password_logic.go b/internal/logic/member/change_password_logic.go
new file mode 100644
index 0000000..7b47c9d
--- /dev/null
+++ b/internal/logic/member/change_password_logic.go
@@ -0,0 +1,72 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package member
+
+import (
+ "context"
+ "strings"
+
+ domusecase "gateway/internal/model/member/domain/usecase"
+ "gateway/internal/svc"
+ "gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
+)
+
+type ChangePasswordLogic struct {
+ logx.Logger
+ ctx context.Context
+ svcCtx *svc.ServiceContext
+}
+
+func NewChangePasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChangePasswordLogic {
+ return &ChangePasswordLogic{
+ Logger: logx.WithContext(ctx),
+ ctx: ctx,
+ svcCtx: svcCtx,
+ }
+}
+
+func (l *ChangePasswordLogic) ChangePassword(req *types.ChangePasswordReq) (*types.ChangePasswordData, error) {
+ actor, err := actorOrErr(l.ctx)
+ if err != nil {
+ return nil, err
+ }
+ if l.svcCtx.MemberProfile == nil {
+ return nil, errb.SysNotImplemented("member profile not configured")
+ }
+ if l.svcCtx.Zitadel == nil {
+ return nil, errb.SysNotImplemented("zitadel not configured")
+ }
+ if req == nil {
+ return nil, errb.InputMissingRequired("request body is required")
+ }
+
+ member, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{
+ TenantID: actor.TenantID,
+ UID: actor.UID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := ensurePlatformNativePassword(member); err != nil {
+ return nil, err
+ }
+ if member.ZitadelUserID == "" {
+ return nil, errb.ResInvalidState("member has no zitadel identity")
+ }
+
+ email := strings.TrimSpace(member.ZitadelEmail)
+ if email == "" {
+ return nil, errb.ResInvalidState("member has no login email")
+ }
+ if _, err := l.svcCtx.Zitadel.VerifyPassword(l.ctx, email, req.CurrentPassword); err != nil {
+ return nil, errb.AuthUnauthorized("invalid current password")
+ }
+ if err := l.svcCtx.Zitadel.SetUserPassword(l.ctx, member.ZitadelUserID, req.NewPassword, req.CurrentPassword); err != nil {
+ return nil, errb.SvcThirdParty("zitadel password update failed").WithCause(err)
+ }
+
+ return &types.ChangePasswordData{OK: true}, nil
+}
diff --git a/internal/logic/member/password_helper.go b/internal/logic/member/password_helper.go
new file mode 100644
index 0000000..e0284eb
--- /dev/null
+++ b/internal/logic/member/password_helper.go
@@ -0,0 +1,24 @@
+package member
+
+import (
+ memberenum "gateway/internal/model/member/domain/enum"
+ domusecase "gateway/internal/model/member/domain/usecase"
+)
+
+func ensurePlatformNativePassword(member *domusecase.MemberDTO) error {
+ if member == nil {
+ return errb.ResNotFound("member", "")
+ }
+ switch member.Origin {
+ case memberenum.MemberOriginPlatformNative:
+ return nil
+ case memberenum.MemberOriginOIDC:
+ return errb.AuthForbidden("social login accounts cannot change password here")
+ case memberenum.MemberOriginLDAP:
+ return errb.AuthForbidden("ldap accounts cannot change password here")
+ case memberenum.MemberOriginSCIM:
+ return errb.AuthForbidden("scim provisioned accounts cannot change password here")
+ default:
+ return errb.AuthForbidden("account cannot change password here")
+ }
+}
diff --git a/internal/logic/member/verify_helper.go b/internal/logic/member/verify_helper.go
index a6f39b1..374d7b1 100644
--- a/internal/logic/member/verify_helper.go
+++ b/internal/logic/member/verify_helper.go
@@ -13,6 +13,8 @@ import (
notifuc "gateway/internal/model/notification/domain/usecase"
"gateway/internal/svc"
"gateway/internal/types"
+
+ "github.com/zeromicro/go-zero/core/logx"
)
func startVerification(
@@ -76,7 +78,9 @@ func startVerification(
}
if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil {
key := fmt.Sprintf("e2e:otp:%s", dto.ChallengeID)
- _ = sc.Redis.Zero().SetexCtx(ctx, key, plainCode, dto.ExpiresIn)
+ if setErr := sc.Redis.Zero().SetexCtx(ctx, key, plainCode, dto.ExpiresIn); setErr != nil {
+ logx.WithContext(ctx).Infof("e2e otp mirror skipped: %v", setErr)
+ }
}
return &types.VerificationStartData{
ChallengeID: dto.ChallengeID,
diff --git a/internal/model/auth/domain/const.go b/internal/model/auth/domain/const.go
index 6c161fd..db9a2e3 100644
--- a/internal/model/auth/domain/const.go
+++ b/internal/model/auth/domain/const.go
@@ -46,6 +46,11 @@ func LoginSessionRedisKey(sessionID string) string {
return fmt.Sprintf("auth:login:session:%s", sessionID)
}
+// LoginMFAChallengeRedisKey returns the Redis key for a password-login MFA challenge.
+func LoginMFAChallengeRedisKey(challengeID string) string {
+ return fmt.Sprintf("auth:login:mfa:%s", challengeID)
+}
+
// NormalizeInviteCode trims and uppercases user input before hashing.
func NormalizeInviteCode(code string) string {
return strings.ToUpper(strings.TrimSpace(code))
diff --git a/internal/model/auth/domain/errors.go b/internal/model/auth/domain/errors.go
index 0e68eb5..a997ac0 100644
--- a/internal/model/auth/domain/errors.go
+++ b/internal/model/auth/domain/errors.go
@@ -14,4 +14,5 @@ var (
ErrDuplicateRegistrationMeta = fmt.Errorf("auth: duplicate registration metadata")
ErrRegistrationSessionNotFound = fmt.Errorf("auth: registration session not found")
ErrLoginSessionNotFound = fmt.Errorf("auth: login session not found")
+ ErrLoginMFAChallengeNotFound = fmt.Errorf("auth: login mfa challenge not found")
)
diff --git a/internal/model/auth/domain/repository/login_mfa_challenge.go b/internal/model/auth/domain/repository/login_mfa_challenge.go
new file mode 100644
index 0000000..64fcf56
--- /dev/null
+++ b/internal/model/auth/domain/repository/login_mfa_challenge.go
@@ -0,0 +1,28 @@
+package repository
+
+import (
+ "context"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+)
+
+// LoginMFAChallenge holds pending password-login state after credentials pass.
+type LoginMFAChallenge struct {
+ ChallengeID string
+ TenantID string
+ TenantSlug string
+ UID string
+}
+
+// LoginMFAChallengeStore persists short-lived login MFA challenges.
+type LoginMFAChallengeStore interface {
+ Save(ctx context.Context, challenge *LoginMFAChallenge, ttl time.Duration) error
+ Get(ctx context.Context, challengeID string) (*LoginMFAChallenge, error)
+ Delete(ctx context.Context, challengeID string) error
+}
+
+// LoginMFAChallengeRedisKey re-exports the Redis key helper for tests.
+func LoginMFAChallengeRedisKey(challengeID string) string {
+ return authdomain.LoginMFAChallengeRedisKey(challengeID)
+}
diff --git a/internal/model/auth/domain/usecase/login_mfa_challenge.go b/internal/model/auth/domain/usecase/login_mfa_challenge.go
new file mode 100644
index 0000000..cd71770
--- /dev/null
+++ b/internal/model/auth/domain/usecase/login_mfa_challenge.go
@@ -0,0 +1,27 @@
+package usecase
+
+import (
+ "context"
+ "time"
+)
+
+// CreateLoginMFAChallengeRequest binds tenant/member after password verification.
+type CreateLoginMFAChallengeRequest struct {
+ TenantID string
+ TenantSlug string
+ UID string
+ TTL time.Duration
+}
+
+// LoginMFAChallengeView is returned when login requires TOTP confirmation.
+type LoginMFAChallengeView struct {
+ ChallengeID string
+ ExpiresIn int
+}
+
+// LoginMFAChallengeUseCase manages password-login MFA challenges.
+type LoginMFAChallengeUseCase interface {
+ Create(ctx context.Context, req *CreateLoginMFAChallengeRequest) (*LoginMFAChallengeView, error)
+ Get(ctx context.Context, challengeID string) (*CreateLoginMFAChallengeRequest, error)
+ Delete(ctx context.Context, challengeID string) error
+}
diff --git a/internal/model/auth/repository/login_mfa_challenge_redis.go b/internal/model/auth/repository/login_mfa_challenge_redis.go
new file mode 100644
index 0000000..ab36871
--- /dev/null
+++ b/internal/model/auth/repository/login_mfa_challenge_redis.go
@@ -0,0 +1,64 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ redislib "gateway/internal/library/redis"
+ authdomain "gateway/internal/model/auth/domain"
+ domrepo "gateway/internal/model/auth/domain/repository"
+
+ "github.com/zeromicro/go-zero/core/stores/redis"
+)
+
+type redisLoginMFAChallengeStore struct {
+ client *redis.Redis
+}
+
+// NewRedisLoginMFAChallengeStore creates a Redis-backed login MFA challenge store.
+func NewRedisLoginMFAChallengeStore(client *redislib.Client) domrepo.LoginMFAChallengeStore {
+ if client == nil || client.Zero() == nil {
+ panic("auth: redis client is required for login mfa challenge store")
+ }
+ return &redisLoginMFAChallengeStore{client: client.Zero()}
+}
+
+func (s *redisLoginMFAChallengeStore) Save(ctx context.Context, challenge *domrepo.LoginMFAChallenge, ttl time.Duration) error {
+ if challenge == nil || challenge.ChallengeID == "" {
+ return fmt.Errorf("auth: login mfa challenge id is required")
+ }
+ raw, err := json.Marshal(challenge)
+ if err != nil {
+ return fmt.Errorf("auth: marshal login mfa challenge: %w", err)
+ }
+ seconds := int(ttl.Seconds())
+ if seconds < 1 {
+ seconds = 1
+ }
+ return s.client.SetexCtx(ctx, authdomain.LoginMFAChallengeRedisKey(challenge.ChallengeID), string(raw), seconds)
+}
+
+func (s *redisLoginMFAChallengeStore) Get(ctx context.Context, challengeID string) (*domrepo.LoginMFAChallenge, error) {
+ val, err := s.client.GetCtx(ctx, authdomain.LoginMFAChallengeRedisKey(challengeID))
+ if errors.Is(err, redis.Nil) {
+ return nil, authdomain.ErrLoginMFAChallengeNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+ var challenge domrepo.LoginMFAChallenge
+ if err := json.Unmarshal([]byte(val), &challenge); err != nil {
+ return nil, fmt.Errorf("auth: unmarshal login mfa challenge: %w", err)
+ }
+ return &challenge, nil
+}
+
+func (s *redisLoginMFAChallengeStore) Delete(ctx context.Context, challengeID string) error {
+ _, err := s.client.DelCtx(ctx, authdomain.LoginMFAChallengeRedisKey(challengeID))
+ return err
+}
+
+var _ domrepo.LoginMFAChallengeStore = (*redisLoginMFAChallengeStore)(nil)
diff --git a/internal/model/auth/usecase/login_mfa_challenge_usecase.go b/internal/model/auth/usecase/login_mfa_challenge_usecase.go
new file mode 100644
index 0000000..c367dca
--- /dev/null
+++ b/internal/model/auth/usecase/login_mfa_challenge_usecase.go
@@ -0,0 +1,84 @@
+package usecase
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ authdomain "gateway/internal/model/auth/domain"
+ domrepo "gateway/internal/model/auth/domain/repository"
+ domusecase "gateway/internal/model/auth/domain/usecase"
+
+ "github.com/google/uuid"
+)
+
+type loginMFAChallengeUseCase struct {
+ store domrepo.LoginMFAChallengeStore
+}
+
+// LoginMFAChallengeUseCaseParam wires LoginMFAChallengeUseCase.
+type LoginMFAChallengeUseCaseParam struct {
+ Store domrepo.LoginMFAChallengeStore
+}
+
+// MustLoginMFAChallengeUseCase constructs LoginMFAChallengeUseCase.
+func MustLoginMFAChallengeUseCase(param LoginMFAChallengeUseCaseParam) domusecase.LoginMFAChallengeUseCase {
+ if param.Store == nil {
+ panic("auth: login mfa challenge store is required")
+ }
+ return &loginMFAChallengeUseCase{store: param.Store}
+}
+
+func (uc *loginMFAChallengeUseCase) Create(ctx context.Context, req *domusecase.CreateLoginMFAChallengeRequest) (*domusecase.LoginMFAChallengeView, error) {
+ if req == nil || req.TenantID == "" || req.TenantSlug == "" || req.UID == "" {
+ return nil, errb.InputMissingRequired("tenant_id, tenant_slug and uid are required")
+ }
+ ttl := req.TTL
+ if ttl <= 0 {
+ ttl = 5 * time.Minute
+ }
+ challengeID := uuid.NewString()
+ challenge := &domrepo.LoginMFAChallenge{
+ ChallengeID: challengeID,
+ TenantID: req.TenantID,
+ TenantSlug: req.TenantSlug,
+ UID: req.UID,
+ }
+ if err := uc.store.Save(ctx, challenge, ttl); err != nil {
+ return nil, wrapRepoErr(err, "save login mfa challenge failed")
+ }
+ return &domusecase.LoginMFAChallengeView{
+ ChallengeID: challengeID,
+ ExpiresIn: int(ttl.Seconds()),
+ }, nil
+}
+
+func (uc *loginMFAChallengeUseCase) Get(ctx context.Context, challengeID string) (*domusecase.CreateLoginMFAChallengeRequest, error) {
+ if challengeID == "" {
+ return nil, errb.InputMissingRequired("challenge_id is required")
+ }
+ challenge, err := uc.store.Get(ctx, challengeID)
+ if err != nil {
+ if errors.Is(err, authdomain.ErrLoginMFAChallengeNotFound) {
+ return nil, errb.ResNotFound("login mfa challenge", challengeID).WithCause(err)
+ }
+ return nil, wrapRepoErr(err, "read login mfa challenge failed")
+ }
+ return &domusecase.CreateLoginMFAChallengeRequest{
+ TenantID: challenge.TenantID,
+ TenantSlug: challenge.TenantSlug,
+ UID: challenge.UID,
+ }, nil
+}
+
+func (uc *loginMFAChallengeUseCase) Delete(ctx context.Context, challengeID string) error {
+ if challengeID == "" {
+ return errb.InputMissingRequired("challenge_id is required")
+ }
+ if err := uc.store.Delete(ctx, challengeID); err != nil {
+ return wrapRepoErr(err, "delete login mfa challenge failed")
+ }
+ return nil
+}
+
+var _ domusecase.LoginMFAChallengeUseCase = (*loginMFAChallengeUseCase)(nil)
diff --git a/internal/model/auth/usecase/module.go b/internal/model/auth/usecase/module.go
index af12c5f..f0e8003 100644
--- a/internal/model/auth/usecase/module.go
+++ b/internal/model/auth/usecase/module.go
@@ -16,6 +16,7 @@ type Module struct {
RegistrationMeta domusecase.RegistrationMetaUseCase
RegistrationSession domusecase.RegistrationSessionUseCase
LoginSession domusecase.LoginSessionUseCase
+ LoginMFAChallenge domusecase.LoginMFAChallengeUseCase
Invites domrepo.InviteRepository
RegistrationMetaRepo domrepo.RegistrationMetaRepository
@@ -47,6 +48,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
regMetaRepo := repository.NewRegistrationMetaRepository(repository.RegistrationMetaRepositoryParam{Conf: param.MongoConf})
sessionStore := repository.NewRedisRegistrationSessionStore(param.Redis)
loginStore := repository.NewRedisLoginSessionStore(param.Redis)
+ loginMFAStore := repository.NewRedisLoginMFAChallengeStore(param.Redis)
lock := param.Lock
if lock == nil {
lock = repository.NewRedisInviteConsumeLock(param.Redis)
@@ -68,6 +70,9 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
LoginSession: MustLoginSessionUseCase(LoginSessionUseCaseParam{
Store: loginStore,
}),
+ LoginMFAChallenge: MustLoginMFAChallengeUseCase(LoginMFAChallengeUseCaseParam{
+ Store: loginMFAStore,
+ }),
}
return mod, nil
}
diff --git a/internal/model/member/domain/repository/member.go b/internal/model/member/domain/repository/member.go
index 25a14ea..8be823c 100644
--- a/internal/model/member/domain/repository/member.go
+++ b/internal/model/member/domain/repository/member.go
@@ -29,6 +29,7 @@ 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)
+ GetByZitadelEmail(ctx context.Context, tenantID, email 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)
diff --git a/internal/model/member/domain/usecase/profile.go b/internal/model/member/domain/usecase/profile.go
index 8568d5f..b59ce4a 100644
--- a/internal/model/member/domain/usecase/profile.go
+++ b/internal/model/member/domain/usecase/profile.go
@@ -10,6 +10,7 @@ import (
type ProfileUseCase interface {
GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error)
GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*MemberDTO, error)
+ GetByZitadelEmail(ctx context.Context, tenantID, email string) (*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
@@ -54,6 +55,7 @@ type MemberDTO struct {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
ZitadelEmail string `json:"zitadel_email,omitempty"`
+ ZitadelUserID string `json:"zitadel_user_id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"`
diff --git a/internal/model/member/repository/member_mongo.go b/internal/model/member/repository/member_mongo.go
index 788373e..d0305dc 100644
--- a/internal/model/member/repository/member_mongo.go
+++ b/internal/model/member/repository/member_mongo.go
@@ -3,6 +3,7 @@ package repository
import (
"context"
"errors"
+ "strings"
"time"
libmongo "gateway/internal/library/mongo"
@@ -85,6 +86,21 @@ func (r *memberRepository) GetByZitadelUserID(ctx context.Context, tenantID, zit
return &doc, nil
}
+func (r *memberRepository) GetByZitadelEmail(ctx context.Context, tenantID, email string) (*entity.Member, error) {
+ var doc entity.Member
+ filter := bson.M{
+ member.BSONFieldTenantID: tenantID,
+ member.BSONFieldZitadelEmail: strings.ToLower(strings.TrimSpace(email)),
+ }
+ 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 {
@@ -213,6 +229,11 @@ func (r *memberRepository) Index20260520001UP(ctx context.Context) error {
[]int32{1, 1}, true); err != nil {
return err
}
+ if err := r.db.PopulateMultiIndex(ctx,
+ []string{member.BSONFieldTenantID, member.BSONFieldZitadelEmail},
+ []int32{1, 1}, false); err != nil {
+ return err
+ }
return r.db.PopulateMultiIndex(ctx,
[]string{member.BSONFieldTenantID, member.BSONFieldMemberStatus, member.BSONFieldCreateAt},
[]int32{1, 1, -1}, false)
diff --git a/internal/model/member/usecase/mapper.go b/internal/model/member/usecase/mapper.go
index bb6dfa0..7cc6287 100644
--- a/internal/model/member/usecase/mapper.go
+++ b/internal/model/member/usecase/mapper.go
@@ -13,6 +13,7 @@ func memberToDTO(m *entity.Member) *domusecase.MemberDTO {
TenantID: m.TenantID,
UID: m.UID,
ZitadelEmail: m.ZitadelEmail,
+ ZitadelUserID: m.ZitadelUserID,
DisplayName: m.DisplayName,
Avatar: m.Avatar,
Phone: m.Phone,
diff --git a/internal/model/member/usecase/profile_usecase.go b/internal/model/member/usecase/profile_usecase.go
index 9ed8583..022c076 100644
--- a/internal/model/member/usecase/profile_usecase.go
+++ b/internal/model/member/usecase/profile_usecase.go
@@ -3,6 +3,7 @@ package usecase
import (
"context"
"errors"
+ "strings"
member "gateway/internal/model/member/domain"
domrepo "gateway/internal/model/member/domain/repository"
@@ -54,6 +55,20 @@ func (uc *profileUseCase) GetByZitadelUserID(ctx context.Context, tenantID, zita
return memberToDTO(rec), nil
}
+func (uc *profileUseCase) GetByZitadelEmail(ctx context.Context, tenantID, email string) (*domusecase.MemberDTO, error) {
+ if tenantID == "" || strings.TrimSpace(email) == "" {
+ return nil, errb.InputMissingRequired("tenant_id and email are required")
+ }
+ rec, err := uc.members.GetByZitadelEmail(ctx, tenantID, strings.ToLower(strings.TrimSpace(email)))
+ if err != nil {
+ if errors.Is(err, member.ErrNotFound) {
+ return nil, errb.ResNotFound("member", email).WithCause(err)
+ }
+ return nil, wrapRepoErr(err, "read member failed")
+ }
+ 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")
diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go
index f6b07e4..ce8fbfd 100644
--- a/internal/svc/service_context.go
+++ b/internal/svc/service_context.go
@@ -38,6 +38,7 @@ type ServiceContext struct {
AuthRegistrationMeta domauth.RegistrationMetaUseCase
AuthRegistrationSession domauth.RegistrationSessionUseCase
AuthLoginSession domauth.LoginSessionUseCase
+ AuthLoginMFAChallenge domauth.LoginMFAChallengeUseCase
Zitadel *zitadel.Client
Notifier domnotif.NotifierUseCase
NotificationAdmin domnotif.AdminNotifierUseCase
@@ -125,6 +126,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
sc.AuthRegistrationMeta = authMod.RegistrationMeta
sc.AuthRegistrationSession = authMod.RegistrationSession
sc.AuthLoginSession = authMod.LoginSession
+ sc.AuthLoginMFAChallenge = authMod.LoginMFAChallenge
}
if rds != nil && rds.Zero() != nil {
var mongoConf *libmongo.Conf
diff --git a/internal/types/types.go b/internal/types/types.go
index 99a30cf..9a8fa61 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -34,6 +34,15 @@ type AuthTokenOKStatus struct {
Data AuthTokenData `json:"data"`
}
+type ChangePasswordData struct {
+ OK bool `json:"ok"`
+}
+
+type ChangePasswordReq struct {
+ CurrentPassword string `json:"current_password" validate:"required,min=8,max=128"` // 目前密碼
+ NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼
+}
+
type CreateRoleReq struct {
Key string `json:"key" validate:"required,min=2,max=64"` // 角色 key(2-64 字元,不可以 system. / platform_ 開頭)
DisplayName string `json:"display_name,optional"` // 角色顯示名稱(給人看的)
@@ -69,6 +78,29 @@ type ListUserRolesReq struct {
UID string `path:"uid"` // 使用者 UID(path)
}
+type LoginData struct {
+ AccessToken string `json:"access_token,optional"`
+ RefreshToken string `json:"refresh_token,optional"`
+ ExpiresIn int64 `json:"expires_in,optional"`
+ UID string `json:"uid,optional"`
+ TokenType string `json:"token_type,optional"`
+ MFARequired bool `json:"mfa_required,optional"`
+ MFAChallengeID string `json:"mfa_challenge_id,optional"`
+ MFAExpiresIn int `json:"mfa_expires_in,optional"`
+}
+
+type LoginMFAConfirmReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
+ ChallengeID string `json:"challenge_id" validate:"required"` // 密碼登入後回傳的 MFA challenge ID
+ Code string `json:"code" validate:"required,len=6"` // TOTP 或備援碼(6 位數)
+}
+
+type LoginOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data LoginData `json:"data"`
+}
+
type LoginReq struct {
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
Email string `json:"email" validate:"required,email"` // 電子郵件
@@ -152,6 +184,39 @@ type MemberMeOKStatus struct {
Data MemberMeData `json:"data"`
}
+type PasswordForgotData struct {
+ ChallengeID string `json:"challenge_id"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+type PasswordForgotOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data PasswordForgotData `json:"data"`
+}
+
+type PasswordForgotReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
+ Email string `json:"email" validate:"required,email"` // 登入 Email
+}
+
+type PasswordResetData struct {
+ OK bool `json:"ok"`
+}
+
+type PasswordResetOKStatus struct {
+ Code int64 `json:"code"`
+ Message string `json:"message"`
+ Data PasswordResetData `json:"data"`
+}
+
+type PasswordResetReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
+ ChallengeID string `json:"challenge_id" validate:"required"` // 忘記密碼 OTP challenge ID
+ Code string `json:"code" validate:"required,len=6"` // 6 位數 OTP
+ NewPassword string `json:"new_password" validate:"required,min=8,max=128"` // 新密碼
+}
+
type PermissionCatalogData struct {
Tree []PermissionNode `json:"tree,omitempty"`
List []PermissionNode `json:"list,omitempty"`
@@ -239,6 +304,11 @@ type RegisterResendReq struct {
ChallengeID string `json:"challenge_id" validate:"required"` // 註冊流程的 OTP challenge ID
}
+type RegisterResumeReq struct {
+ TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
+ Email string `json:"email" validate:"required,email"` // 註冊 Email
+}
+
type RegisterSocialCallbackReq struct {
Code string `form:"code" validate:"required"` // IdP 回傳的 OAuth authorization code
State string `form:"state" validate:"required"` // IdP 回傳的 OAuth state(對應 session)