From d845ef45fd975ec26cf5d4f3c7ea87e882fa8a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Wed, 27 May 2026 00:55:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E7=99=BB=E5=85=A5=20MFA=E3=80=81?= =?UTF-8?q?=E5=BF=98=E8=A8=98/=E6=94=B9=E5=AF=86=E7=A2=BC=E8=88=87?= =?UTF-8?q?=E8=A8=BB=E5=86=8A=E6=81=A2=E5=BE=A9=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 補齊平台帳號(platform_native)的密碼自助能力,並讓未完成 Email 驗證的使用者可恢復註冊;OIDC/LDAP/SCIM 帳號禁止在本系統變更密碼。登入若已啟用 TOTP 改為兩階段驗證,OTP 重送加入 60 秒冷卻;同步調整 golangci 排除路徑與 zitadel lint 修正。 Co-authored-by: Cursor --- .golangci.yml | 4 + cmd/k6-seed-admin/main.go | 22 +- etc/gateway.k6.yaml | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/auth.ts | 79 +++++- frontend/src/api/member.ts | 11 + frontend/src/config.ts | 2 + frontend/src/hooks/useResendCooldown.ts | 26 ++ frontend/src/pages/user/ConfirmPage.tsx | 165 +++++++++++-- .../src/pages/user/ForgotPasswordPage.tsx | 228 ++++++++++++++++++ frontend/src/pages/user/LoginPage.tsx | 105 +++++++- frontend/src/pages/user/RegisterPage.tsx | 22 +- frontend/src/pages/user/SecurityPage.tsx | 71 ++++++ generate/api/auth.api | 164 ++++++++++++- generate/api/member.api | 32 +++ .../handler/auth/login_mfa_confirm_handler.go | 34 +++ .../handler/auth/password_forgot_handler.go | 34 +++ .../handler/auth/password_reset_handler.go | 34 +++ .../handler/auth/register_resume_handler.go | 34 +++ .../handler/member/change_password_handler.go | 34 +++ internal/handler/routes.go | 32 ++- internal/library/zitadel/client.go | 32 ++- internal/library/zitadel/session.go | 4 +- internal/logic/auth/login_helper.go | 87 +++++++ internal/logic/auth/login_logic.go | 12 +- .../logic/auth/login_mfa_confirm_logic.go | 38 +++ internal/logic/auth/password_forgot_logic.go | 65 +++++ internal/logic/auth/password_helper.go | 43 ++++ internal/logic/auth/password_otp_helper.go | 61 +++++ internal/logic/auth/password_reset_logic.go | 89 +++++++ internal/logic/auth/register_helper.go | 112 +++++++++ internal/logic/auth/register_logic.go | 31 ++- internal/logic/auth/register_resume_logic.go | 38 +++ .../logic/member/change_password_logic.go | 72 ++++++ internal/logic/member/password_helper.go | 24 ++ internal/logic/member/verify_helper.go | 6 +- internal/model/auth/domain/const.go | 5 + internal/model/auth/domain/errors.go | 1 + .../domain/repository/login_mfa_challenge.go | 28 +++ .../domain/usecase/login_mfa_challenge.go | 27 +++ .../repository/login_mfa_challenge_redis.go | 64 +++++ .../usecase/login_mfa_challenge_usecase.go | 84 +++++++ internal/model/auth/usecase/module.go | 5 + .../model/member/domain/repository/member.go | 1 + .../model/member/domain/usecase/profile.go | 2 + .../model/member/repository/member_mongo.go | 21 ++ internal/model/member/usecase/mapper.go | 1 + .../model/member/usecase/profile_usecase.go | 15 ++ internal/svc/service_context.go | 2 + internal/types/types.go | 70 ++++++ 50 files changed, 2095 insertions(+), 82 deletions(-) create mode 100644 frontend/src/hooks/useResendCooldown.ts create mode 100644 frontend/src/pages/user/ForgotPasswordPage.tsx create mode 100644 internal/handler/auth/login_mfa_confirm_handler.go create mode 100644 internal/handler/auth/password_forgot_handler.go create mode 100644 internal/handler/auth/password_reset_handler.go create mode 100644 internal/handler/auth/register_resume_handler.go create mode 100644 internal/handler/member/change_password_handler.go create mode 100644 internal/logic/auth/login_mfa_confirm_logic.go create mode 100644 internal/logic/auth/password_forgot_logic.go create mode 100644 internal/logic/auth/password_helper.go create mode 100644 internal/logic/auth/password_otp_helper.go create mode 100644 internal/logic/auth/password_reset_logic.go create mode 100644 internal/logic/auth/register_resume_logic.go create mode 100644 internal/logic/member/change_password_logic.go create mode 100644 internal/logic/member/password_helper.go create mode 100644 internal/model/auth/domain/repository/login_mfa_challenge.go create mode 100644 internal/model/auth/domain/usecase/login_mfa_challenge.go create mode 100644 internal/model/auth/repository/login_mfa_challenge_redis.go create mode 100644 internal/model/auth/usecase/login_mfa_challenge_usecase.go diff --git a/.golangci.yml b/.golangci.yml index 2efb85b..1e06791 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -134,6 +134,8 @@ linters: paths: - generate/doc-generate - docs/openapi + - frontend + - cmd/k6-seed-admin formatters: enable: @@ -142,6 +144,8 @@ formatters: generated: lax paths: - generate/doc-generate + - frontend + - cmd/k6-seed-admin issues: max-issues-per-linter: 0 diff --git a/cmd/k6-seed-admin/main.go b/cmd/k6-seed-admin/main.go index 04766e6..a4910a0 100644 --- a/cmd/k6-seed-admin/main.go +++ b/cmd/k6-seed-admin/main.go @@ -49,15 +49,15 @@ var ( // a stable email would collide with the existing ZITADEL user (28303000 // email already registered) since ZITADEL state lives outside docker // volumes that `make k6-down` clears. Override with -email or ADMIN_EMAIL. - flagEmail = flag.String("email", envOr("ADMIN_EMAIL", fmt.Sprintf("k6-admin-%d@k6.local", time.Now().Unix())), "Admin email") - flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "K6-Admin-Pass-1!"), "Admin password") - flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host") - flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port") - flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database") - flagTenantID = flag.String("tenant-id", envOr("ADMIN_TENANT_ID", ""), "Override resolved tenant_id (skip lookup)") - flagPollSecs = flag.Int("otp-timeout", 10, "MailHog OTP poll timeout (seconds)") - flagDryRun = flag.Bool("dry-run", false, "Skip Mongo writes; only test register flow") - flagRedisAddr = flag.String("redis-addr", envOr("REDIS_ADDR", "localhost:6379"), "Redis addr (host:port) for casbin reload broadcast") + flagEmail = flag.String("email", envOr("ADMIN_EMAIL", fmt.Sprintf("k6-admin-%d@k6.local", time.Now().Unix())), "Admin email") + flagPassword = flag.String("password", envOr("ADMIN_PASSWORD", "K6-Admin-Pass-1!"), "Admin password") + flagMongoHost = flag.String("mongo-host", envOr("K6_MONGO_HOST", "127.0.0.1"), "Mongo host") + flagMongoPort = flag.Int("mongo-port", envOrInt("K6_MONGO_PORT", 27017), "Mongo port") + flagMongoDB = flag.String("mongo-db", envOr("K6_MONGO_DB", "gateway_k6"), "Mongo database") + flagTenantID = flag.String("tenant-id", envOr("ADMIN_TENANT_ID", ""), "Override resolved tenant_id (skip lookup)") + flagPollSecs = flag.Int("otp-timeout", 10, "MailHog OTP poll timeout (seconds)") + flagDryRun = flag.Bool("dry-run", false, "Skip Mongo writes; only test register flow") + flagRedisAddr = flag.String("redis-addr", envOr("REDIS_ADDR", "localhost:6379"), "Redis addr (host:port) for casbin reload broadcast") flagReloadChannel = flag.String("reload-channel", envOr("CASBIN_RELOAD_CHANNEL", "casbin:reload:k6"), "Casbin reload Pub/Sub channel (must match gateway Permission.Reload.Channel)") ) @@ -282,7 +282,7 @@ func pollOTP(ctx context.Context, email string, timeout time.Duration) (string, deadline := time.Now().Add(timeout) url := fmt.Sprintf("%s/api/v2/search?kind=to&query=%s&start=0&limit=5", *flagMailhog, email) for time.Now().Before(deadline) { - req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + req, _ := http.NewRequestWithContext(ctx, "GET", url, http.NoBody) resp, err := http.DefaultClient.Do(req) if err == nil && resp.StatusCode == 200 { raw, _ := io.ReadAll(resp.Body) @@ -338,7 +338,7 @@ func assignAdmin(ctx context.Context, conf *libmongo.Conf, tenantID, uid string) roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: conf}) role, err := roles.GetByKey(ctx, tenantID, "tenant_admin") if err != nil || role == nil { - return "", fmt.Errorf("tenant_admin role not found for tenant=%s: %v", tenantID, err) + return "", fmt.Errorf("tenant_admin role not found for tenant=%s: %w", tenantID, err) } urRepo := permrepo.NewUserRoleRepository(permrepo.UserRoleRepositoryParam{Conf: conf}) if err := urRepo.Insert(ctx, &permentity.UserRole{ diff --git a/etc/gateway.k6.yaml b/etc/gateway.k6.yaml index 1a310b3..11f7456 100644 --- a/etc/gateway.k6.yaml +++ b/etc/gateway.k6.yaml @@ -61,7 +61,7 @@ Member: Length: 6 TTLSeconds: 300 MaxAttempts: 10 - ResendCooldownSeconds: 1 + ResendCooldownSeconds: 60 DailyVerifyLimit: 200 TOTP: Issuer: CloudEP-k6 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 424a9bf..802a2bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { RolesPage } from './pages/admin/RolesPage'; import { UserRolesPage } from './pages/admin/UserRolesPage'; import { RolePermissionsPage } from './pages/admin/RolePermissionsPage'; import { ConfirmPage } from './pages/user/ConfirmPage'; +import { ForgotPasswordPage } from './pages/user/ForgotPasswordPage'; import { HomePage } from './pages/user/HomePage'; import { LoginPage } from './pages/user/LoginPage'; import { ProfilePage } from './pages/user/ProfilePage'; @@ -30,6 +31,7 @@ function App() { } /> } /> } /> + } /> }> }> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index fcf5449..e024908 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -8,20 +8,51 @@ export interface AuthTokenData { token_type: string; } +export interface LoginData extends Partial { + mfa_required?: boolean; + mfa_challenge_id?: string; + mfa_expires_in?: number; +} + export interface RegisterData { challenge_id: string; expires_in: number; uid: string; } +function applyAuthTokens(data: AuthTokenData) { + setTokens(data.access_token, data.refresh_token); + localStorage.setItem('uid', data.uid); +} + export async function login(tenantSlug: string, email: string, password: string) { - const data = await api('/api/v1/auth/login', { + const data = await api('/api/v1/auth/login', { auth: false, method: 'POST', body: JSON.stringify({ tenant_slug: tenantSlug, email, password }), }); - setTokens(data.access_token, data.refresh_token); - localStorage.setItem('uid', data.uid); + if (data.mfa_required) { + return data; + } + applyAuthTokens(data as AuthTokenData); + return data; +} + +export async function loginMfaConfirm( + tenantSlug: string, + challengeId: string, + code: string, +) { + const data = await api('/api/v1/auth/login/mfa', { + auth: false, + method: 'POST', + body: JSON.stringify({ + tenant_slug: tenantSlug, + challenge_id: challengeId, + code, + }), + }); + applyAuthTokens(data); return data; } @@ -71,6 +102,48 @@ export async function registerResend(tenantSlug: string, challengeId: string) { }); } +export async function registerResume(tenantSlug: string, email: string) { + return api('/api/v1/auth/register/resume', { + auth: false, + method: 'POST', + body: JSON.stringify({ + tenant_slug: tenantSlug, + email, + }), + }); +} + +export interface PasswordChallengeData { + challenge_id: string; + expires_in: number; +} + +export async function passwordForgot(tenantSlug: string, email: string) { + return api('/api/v1/auth/password/forgot', { + auth: false, + method: 'POST', + body: JSON.stringify({ tenant_slug: tenantSlug, email }), + }); +} + +export async function passwordReset( + tenantSlug: string, + challengeId: string, + code: string, + newPassword: string, +) { + return api<{ ok: boolean }>('/api/v1/auth/password/reset', { + auth: false, + method: 'POST', + body: JSON.stringify({ + tenant_slug: tenantSlug, + challenge_id: challengeId, + code, + new_password: newPassword, + }), + }); +} + export async function logout() { try { await api('/api/v1/auth/logout', { method: 'POST', body: '{}' }); diff --git a/frontend/src/api/member.ts b/frontend/src/api/member.ts index 2166577..5c6ba20 100644 --- a/frontend/src/api/member.ts +++ b/frontend/src/api/member.ts @@ -3,6 +3,7 @@ import { api } from './http'; export interface MemberMe { tenant_id: string; uid: string; + origin: string; zitadel_email?: string; display_name?: string; avatar?: string; @@ -98,3 +99,13 @@ export function confirmTOTPEnroll(code: string) { export function disableTOTP() { return api('/api/v1/members/me/totp', { method: 'DELETE' }); } + +export function changePassword(currentPassword: string, newPassword: string) { + return api<{ ok: boolean }>('/api/v1/members/me/password', { + method: 'POST', + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword, + }), + }); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts index a1656d4..c4f5247 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,3 +1,5 @@ /** 本機 k6 / dev 預設值,可在登入頁覆寫 */ export const DEFAULT_TENANT = 'k6-tenant'; export const DEFAULT_INVITE = 'K6INVITE'; +/** 與 Gateway Member.OTP.ResendCooldownSeconds 預設一致 */ +export const OTP_RESEND_COOLDOWN_SECONDS = 60; diff --git a/frontend/src/hooks/useResendCooldown.ts b/frontend/src/hooks/useResendCooldown.ts new file mode 100644 index 0000000..f8930d1 --- /dev/null +++ b/frontend/src/hooks/useResendCooldown.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useState } from 'react'; + +/** 與 Gateway Member.OTP.ResendCooldownSeconds 預設一致 */ +export const RESEND_COOLDOWN_SECONDS = 60; + +export function useResendCooldown(initialSeconds = 0) { + const [secondsLeft, setSecondsLeft] = useState(initialSeconds); + + useEffect(() => { + if (secondsLeft <= 0) return; + const timer = window.setInterval(() => { + setSecondsLeft((prev) => (prev <= 1 ? 0 : prev - 1)); + }, 1000); + return () => window.clearInterval(timer); + }, [secondsLeft]); + + const startCooldown = useCallback(() => { + setSecondsLeft(RESEND_COOLDOWN_SECONDS); + }, []); + + return { + secondsLeft, + canSend: secondsLeft <= 0, + startCooldown, + }; +} diff --git a/frontend/src/pages/user/ConfirmPage.tsx b/frontend/src/pages/user/ConfirmPage.tsx index dad9ea8..797ec0a 100644 --- a/frontend/src/pages/user/ConfirmPage.tsx +++ b/frontend/src/pages/user/ConfirmPage.tsx @@ -1,13 +1,49 @@ import { useEffect, useState, type FormEvent } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import * as authApi from '../../api/auth'; import { ApiError } from '../../api/http'; +import { DEFAULT_TENANT, OTP_RESEND_COOLDOWN_SECONDS } from '../../config'; import { useAuth } from '../../context/AuthContext'; +import { + RESEND_COOLDOWN_SECONDS, + useResendCooldown, +} from '../../hooks/useResendCooldown'; + +type ConfirmStep = 'email' | 'otp'; + +type ConfirmNavState = { + tenant: string; + email: string; + challenge_id: string; +}; + +function resumeErrorMessage(err: unknown): string { + if (!(err instanceof ApiError)) return '無法寄送驗證碼'; + if (err.code === 29301000) { + return '找不到此 Email 的待驗證帳號,請確認租戶與 Email 是否正確'; + } + if (err.code === 28309000) return '此帳號已完成驗證,請直接登入'; + if (err.code === 29604000) { + return `寄送過於頻繁,請稍候 ${OTP_RESEND_COOLDOWN_SECONDS} 秒後再試`; + } + if (err.status === 404) return '驗證服務未就緒,請重啟 Gateway 後再試'; + return err.message; +} + +function cooldownLabel(secondsLeft: number, idle: string) { + if (secondsLeft <= 0) return idle; + return `${idle}(${secondsLeft}s)`; +} export function ConfirmPage() { const navigate = useNavigate(); + const location = useLocation(); const { syncSession, refreshRoles } = useAuth(); - const [tenant, setTenant] = useState(''); + const { secondsLeft, canSend, startCooldown } = useResendCooldown(); + const [step, setStep] = useState('email'); + const [tenant, setTenant] = useState( + () => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT, + ); const [challengeId, setChallengeId] = useState(''); const [email, setEmail] = useState(''); const [code, setCode] = useState(''); @@ -16,17 +52,41 @@ export function ConfirmPage() { const [resendMsg, setResendMsg] = useState(''); useEffect(() => { - const raw = sessionStorage.getItem('register_pending'); - if (!raw) return; - const p = JSON.parse(raw) as { - tenant: string; - challenge_id: string; - email: string; - }; - setTenant(p.tenant); - setChallengeId(p.challenge_id); - setEmail(p.email); - }, []); + const state = location.state as ConfirmNavState | null; + if (!state?.challenge_id || !state.email) return; + setTenant(state.tenant); + setEmail(state.email); + setChallengeId(state.challenge_id); + setStep('otp'); + startCooldown(); + }, [location.state, startCooldown]); + + const dispatchCode = async () => { + const data = await authApi.registerResume(tenant, email); + setChallengeId(data.challenge_id); + setStep('otp'); + startCooldown(); + setResendMsg('驗證碼已寄出'); + }; + + const sendCode = async (e: FormEvent) => { + e.preventDefault(); + if (!canSend) return; + setError(''); + setResendMsg(''); + setLoading(true); + try { + localStorage.setItem('tenant_slug', tenant); + await dispatchCode(); + } catch (err) { + if (err instanceof ApiError && err.code === 29604000) { + startCooldown(); + } + setError(resumeErrorMessage(err)); + } finally { + setLoading(false); + } + }; const submit = async (e: FormEvent) => { e.preventDefault(); @@ -34,10 +94,9 @@ export function ConfirmPage() { setLoading(true); try { await authApi.registerConfirm(tenant, challengeId, code); - sessionStorage.removeItem('register_pending'); syncSession(); await refreshRoles(); - navigate('/app'); + navigate('/app', { replace: true }); } catch (err) { setError(err instanceof ApiError ? err.message : '驗證失敗'); } finally { @@ -46,20 +105,59 @@ export function ConfirmPage() { }; const resend = async () => { + if (!canSend) return; setResendMsg(''); + setError(''); + setLoading(true); try { - await authApi.registerResend(tenant, challengeId); - setResendMsg('已重新寄送驗證碼'); + await dispatchCode(); + setResendMsg('驗證碼已重新寄出'); } catch (err) { - setResendMsg(err instanceof ApiError ? err.message : '重送失敗'); + if (err instanceof ApiError && err.code === 29604000) { + startCooldown(); + } + setResendMsg(resumeErrorMessage(err)); + } finally { + setLoading(false); } }; - if (!challengeId) { + const sendDisabled = loading || !canSend; + + if (step === 'email') { return (
-

請先完成註冊步驟。

- 返回註冊 +

完成 Email 驗證

+

+ 輸入註冊 Email,若有待驗證帳號將寄送驗證碼({RESEND_COOLDOWN_SECONDS}{' '} + 秒內不可重複寄送)。 +

+
+ + + {error &&

{error}

} + +
+

+ 還沒註冊? 前往註冊 + {' · '} + 登入 +

); } @@ -86,10 +184,31 @@ export function ConfirmPage() { {loading ? '驗證中…' : '完成註冊'} - {resendMsg &&

{resendMsg}

} +

+ + {' · '} + 返回登入 +

); } diff --git a/frontend/src/pages/user/ForgotPasswordPage.tsx b/frontend/src/pages/user/ForgotPasswordPage.tsx new file mode 100644 index 0000000..a3e7467 --- /dev/null +++ b/frontend/src/pages/user/ForgotPasswordPage.tsx @@ -0,0 +1,228 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import * as authApi from '../../api/auth'; +import { ApiError } from '../../api/http'; +import { DEFAULT_TENANT, OTP_RESEND_COOLDOWN_SECONDS } from '../../config'; +import { + useResendCooldown, +} from '../../hooks/useResendCooldown'; + +type ForgotStep = 'email' | 'reset'; + +function forgotErrorMessage(err: unknown): string { + if (!(err instanceof ApiError)) return '無法寄送重設信'; + if (err.code === 28301000) { + return '找不到此 Email 的帳號,請確認租戶與 Email 是否正確'; + } + if (err.code === 28505000) { + return '此帳號由第三方或企業目錄登入,無法在此重設密碼'; + } + if (err.code === 29604000) { + return `寄送過於頻繁,請稍候 ${OTP_RESEND_COOLDOWN_SECONDS} 秒後再試`; + } + if (err.status === 404) return '密碼重設服務未就緒,請重啟 Gateway 後再試'; + return err.message; +} + +function cooldownLabel(secondsLeft: number, idle: string) { + if (secondsLeft <= 0) return idle; + return `${idle}(${secondsLeft}s)`; +} + +export function ForgotPasswordPage() { + const navigate = useNavigate(); + const { secondsLeft, canSend, startCooldown } = useResendCooldown(); + const [step, setStep] = useState('email'); + const [tenant, setTenant] = useState( + () => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT, + ); + const [email, setEmail] = useState(''); + const [challengeId, setChallengeId] = useState(''); + const [code, setCode] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [resendMsg, setResendMsg] = useState(''); + const [loading, setLoading] = useState(false); + + const dispatchCode = async () => { + const data = await authApi.passwordForgot(tenant, email); + setChallengeId(data.challenge_id); + setStep('reset'); + startCooldown(); + setResendMsg('重設驗證碼已寄出'); + }; + + const sendCode = async (e: FormEvent) => { + e.preventDefault(); + if (!canSend) return; + setError(''); + setResendMsg(''); + setLoading(true); + try { + localStorage.setItem('tenant_slug', tenant); + await dispatchCode(); + } catch (err) { + if (err instanceof ApiError && err.code === 29604000) { + startCooldown(); + } + setError(forgotErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const resend = async () => { + if (!canSend) return; + setResendMsg(''); + setError(''); + setLoading(true); + try { + await dispatchCode(); + setResendMsg('驗證碼已重新寄出'); + } catch (err) { + if (err instanceof ApiError && err.code === 29604000) { + startCooldown(); + } + setResendMsg(forgotErrorMessage(err)); + } finally { + setLoading(false); + } + }; + + const submitReset = async (e: FormEvent) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setError('兩次輸入的新密碼不一致'); + return; + } + setError(''); + setLoading(true); + try { + await authApi.passwordReset(tenant, challengeId, code, newPassword); + navigate('/login', { + replace: true, + state: { message: '密碼已重設,請使用新密碼登入' }, + }); + } catch (err) { + setError(err instanceof ApiError ? err.message : '重設失敗'); + } finally { + setLoading(false); + } + }; + + if (step === 'email') { + return ( +
+

忘記密碼

+

+ 僅限平台註冊(Email + 密碼)的帳號。第三方或 LDAP 登入請向管理員洽詢。 +

+
+ + + {error &&

{error}

} + {resendMsg &&

{resendMsg}

} + +
+

+ 返回登入 +

+
+ ); + } + + return ( +
+

重設密碼

+

+ 驗證碼已寄至 {email}(開發環境請至 MailHog :8025 查看)。 +

+
+ + + + {error &&

{error}

} + {resendMsg &&

{resendMsg}

} + + + +
+

+ 返回登入 +

+
+ ); +} diff --git a/frontend/src/pages/user/LoginPage.tsx b/frontend/src/pages/user/LoginPage.tsx index f074f47..6432cc3 100644 --- a/frontend/src/pages/user/LoginPage.tsx +++ b/frontend/src/pages/user/LoginPage.tsx @@ -1,5 +1,5 @@ -import { useState, type FormEvent } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { useEffect, useState, type FormEvent } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; import * as authApi from '../../api/auth'; import { ApiError } from '../../api/http'; import { DEFAULT_TENANT } from '../../config'; @@ -8,34 +8,116 @@ import * as permApi from '../../api/permission'; export function LoginPage() { const navigate = useNavigate(); + const location = useLocation(); const { syncSession, refreshRoles } = useAuth(); const [tenant, setTenant] = useState( () => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT, ); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [mfaChallengeId, setMfaChallengeId] = useState(null); + const [totpCode, setTotpCode] = useState(''); const [error, setError] = useState(''); + const [info, setInfo] = useState(''); const [loading, setLoading] = useState(false); + useEffect(() => { + const state = location.state as { message?: string } | null; + if (state?.message) { + setInfo(state.message); + navigate(location.pathname, { replace: true, state: null }); + } + }, [location.pathname, location.state, navigate]); + + const finishLogin = async () => { + syncSession(); + await refreshRoles(); + const me = await permApi.getMyPermissions(); + const admin = permApi.isAdminRole(me.roles ?? []); + navigate(admin ? '/admin' : '/app'); + }; + const submit = async (e: FormEvent) => { e.preventDefault(); setError(''); setLoading(true); try { localStorage.setItem('tenant_slug', tenant); - await authApi.login(tenant, email, password); - syncSession(); - await refreshRoles(); - const me = await permApi.getMyPermissions(); - const admin = permApi.isAdminRole(me.roles ?? []); - navigate(admin ? '/admin' : '/app'); + const result = await authApi.login(tenant, email, password); + if (result.mfa_required) { + if (!result.mfa_challenge_id) { + throw new Error('缺少 MFA challenge'); + } + setMfaChallengeId(result.mfa_challenge_id); + return; + } + await finishLogin(); } catch (err) { - setError(err instanceof ApiError ? err.message : '登入失敗'); + if (err instanceof ApiError && err.code === 28505000) { + setError('帳號尚未完成 Email 驗證,請先完成註冊驗證。'); + } else { + setError(err instanceof ApiError ? err.message : '登入失敗'); + } } finally { setLoading(false); } }; + const submitMfa = async (e: FormEvent) => { + e.preventDefault(); + if (!mfaChallengeId) return; + setError(''); + setLoading(true); + try { + await authApi.loginMfaConfirm(tenant, mfaChallengeId, totpCode); + await finishLogin(); + } catch (err) { + setError(err instanceof ApiError ? err.message : '驗證碼錯誤'); + } finally { + setLoading(false); + } + }; + + const backToPassword = () => { + setMfaChallengeId(null); + setTotpCode(''); + setError(''); + }; + + if (mfaChallengeId) { + return ( +
+

雙因素驗證

+

請輸入驗證器 App 的 6 位數驗證碼,或備援碼。

+
+ + {error &&

{error}

} + + +
+
+ ); + } + return (

登入

@@ -63,12 +145,17 @@ export function LoginPage() { /> {error &&

{error}

} + {info &&

{info}

}

還沒有帳號? 註冊 + {' · '} + 尚未完成驗證? + {' · '} + 忘記密碼?

); diff --git a/frontend/src/pages/user/RegisterPage.tsx b/frontend/src/pages/user/RegisterPage.tsx index 4a0e5da..d36ff9e 100644 --- a/frontend/src/pages/user/RegisterPage.tsx +++ b/frontend/src/pages/user/RegisterPage.tsx @@ -30,17 +30,17 @@ export function RegisterPage() { display_name: displayName || undefined, language: 'zh-TW', }); - sessionStorage.setItem( - 'register_pending', - JSON.stringify({ - tenant, - challenge_id: data.challenge_id, - email, - }), - ); - navigate('/register/confirm'); + navigate('/register/confirm', { + state: { tenant, email, challenge_id: data.challenge_id }, + }); } catch (err) { - setError(err instanceof ApiError ? err.message : '註冊失敗'); + if (err instanceof ApiError && err.code === 28303000) { + setError('此 Email 已註冊且已完成驗證,請直接登入'); + } else if (err instanceof ApiError && err.code === 28501000) { + setError('密碼錯誤,請確認後再試'); + } else { + setError(err instanceof ApiError ? err.message : '註冊失敗'); + } } finally { setLoading(false); } @@ -91,6 +91,8 @@ export function RegisterPage() {

已有帳號? 登入 + {' · '} + 尚未完成驗證?

); diff --git a/frontend/src/pages/user/SecurityPage.tsx b/frontend/src/pages/user/SecurityPage.tsx index 72d5b89..b5b3b8d 100644 --- a/frontend/src/pages/user/SecurityPage.tsx +++ b/frontend/src/pages/user/SecurityPage.tsx @@ -20,6 +20,9 @@ export function SecurityPage() { const [phoneCode, setPhoneCode] = useState(''); const [totpCode, setTotpCode] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); const [msg, setMsg] = useState(''); const [error, setError] = useState(''); @@ -132,12 +135,80 @@ export function SecurityPage() { } }; + const changePassword = async (e: FormEvent) => { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setError('兩次輸入的新密碼不一致'); + return; + } + setError(''); + setMsg(''); + try { + await memberApi.changePassword(currentPassword, newPassword); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setMsg('密碼已更新'); + } catch (e) { + showErr(e); + } + }; + + const canChangePassword = me?.origin === 'platform_native'; + return (

安全設定

{msg &&

{msg}

} {error &&

{error}

} +
+

登入密碼

+ {canChangePassword ? ( +
+ + + + +
+ ) : ( +

+ 您的帳號由第三方或企業目錄登入,無法在此變更密碼。 +

+ )} +
+

商業聯絡 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)