feat(auth): 登入 MFA、忘記/改密碼與註冊恢復流程

補齊平台帳號(platform_native)的密碼自助能力,並讓未完成 Email 驗證的使用者可恢復註冊;OIDC/LDAP/SCIM 帳號禁止在本系統變更密碼。登入若已啟用 TOTP 改為兩階段驗證,OTP 重送加入 60 秒冷卻;同步調整 golangci 排除路徑與 zitadel lint 修正。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-05-27 00:55:37 +08:00
parent ffd60206d0
commit d845ef45fd
50 changed files with 2095 additions and 82 deletions

View File

@ -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

View File

@ -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{

View File

@ -61,7 +61,7 @@ Member:
Length: 6
TTLSeconds: 300
MaxAttempts: 10
ResendCooldownSeconds: 1
ResendCooldownSeconds: 60
DailyVerifyLimit: 200
TOTP:
Issuer: CloudEP-k6

View File

@ -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() {
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/register/confirm" element={<ConfirmPage />} />
<Route path="/password/forgot" element={<ForgotPasswordPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<UserLayout />}>

View File

@ -8,20 +8,51 @@ export interface AuthTokenData {
token_type: string;
}
export interface LoginData extends Partial<AuthTokenData> {
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<AuthTokenData>('/api/v1/auth/login', {
const data = await api<LoginData>('/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<AuthTokenData>('/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<RegisterData>('/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<PasswordChallengeData>('/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: '{}' });

View File

@ -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,
}),
});
}

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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<ConfirmStep>('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 (
<div className="auth-card">
<p></p>
<Link to="/register"></Link>
<h1> Email </h1>
<p className="hint">
Email{RESEND_COOLDOWN_SECONDS}{' '}
</p>
<form onSubmit={sendCode} className="form">
<label>
<input value={tenant} onChange={(e) => setTenant(e.target.value)} />
</label>
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
{error && <p className="form-error">{error}</p>}
<button type="submit" className="btn-primary" disabled={sendDisabled}>
{loading
? '寄送中…'
: cooldownLabel(secondsLeft, '寄送驗證碼')}
</button>
</form>
<p className="auth-footer">
<Link to="/register"></Link>
{' · '}
<Link to="/login"></Link>
</p>
</div>
);
}
@ -86,10 +184,31 @@ export function ConfirmPage() {
{loading ? '驗證中…' : '完成註冊'}
</button>
</form>
<button type="button" className="btn-link" onClick={resend}>
<button
type="button"
className="btn-link"
onClick={resend}
disabled={sendDisabled}
>
{cooldownLabel(secondsLeft, '重送驗證碼')}
</button>
{resendMsg && <p className="hint">{resendMsg}</p>}
<p className="auth-footer">
<button
type="button"
className="btn-link"
onClick={() => {
setStep('email');
setCode('');
setError('');
setResendMsg('');
}}
>
Email
</button>
{' · '}
<Link to="/login"></Link>
</p>
</div>
);
}

View File

@ -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<ForgotStep>('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 (
<div className="auth-card">
<h1></h1>
<p className="auth-hint">
Email + LDAP
</p>
<form onSubmit={sendCode} className="form">
<label>
<input value={tenant} onChange={(e) => setTenant(e.target.value)} />
</label>
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
{error && <p className="form-error">{error}</p>}
{resendMsg && <p className="form-ok">{resendMsg}</p>}
<button
type="submit"
className="btn-primary"
disabled={loading || !canSend}
>
{loading
? '寄送中…'
: cooldownLabel(secondsLeft, '寄送重設驗證碼')}
</button>
</form>
<p className="auth-footer">
<Link to="/login"></Link>
</p>
</div>
);
}
return (
<div className="auth-card">
<h1></h1>
<p className="auth-hint">
<strong>{email}</strong> MailHog :8025
</p>
<form onSubmit={submitReset} className="form">
<label>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
/>
</label>
<label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
{error && <p className="form-error">{error}</p>}
{resendMsg && <p className="form-ok">{resendMsg}</p>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '重設中…' : '確認重設密碼'}
</button>
<button
type="button"
className="btn-link"
onClick={resend}
disabled={loading || !canSend}
>
{cooldownLabel(secondsLeft, '重新寄送驗證碼')}
</button>
<button
type="button"
className="btn-link"
onClick={() => {
setStep('email');
setCode('');
setNewPassword('');
setConfirmPassword('');
setError('');
setResendMsg('');
}}
disabled={loading}
>
Email
</button>
</form>
<p className="auth-footer">
<Link to="/login"></Link>
</p>
</div>
);
}

View File

@ -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<string | null>(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 (
<div className="auth-card">
<h1></h1>
<p className="auth-hint"> App 6 </p>
<form onSubmit={submitMfa} className="form">
<label>
<input
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
/>
</label>
{error && <p className="form-error">{error}</p>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '驗證中…' : '確認登入'}
</button>
<button
type="button"
className="btn-link"
onClick={backToPassword}
disabled={loading}
>
</button>
</form>
</div>
);
}
return (
<div className="auth-card">
<h1></h1>
@ -63,12 +145,17 @@ export function LoginPage() {
/>
</label>
{error && <p className="form-error">{error}</p>}
{info && <p className="form-ok">{info}</p>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '登入中…' : '登入'}
</button>
</form>
<p className="auth-footer">
<Link to="/register"></Link>
{' · '}
<Link to="/register/confirm"></Link>
{' · '}
<Link to="/password/forgot"></Link>
</p>
</div>
);

View File

@ -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() {
</form>
<p className="auth-footer">
<Link to="/login"></Link>
{' · '}
<Link to="/register/confirm"></Link>
</p>
</div>
);

View File

@ -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 (
<div>
<h1></h1>
{msg && <p className="form-ok">{msg}</p>}
{error && <p className="form-error">{error}</p>}
<section className="section">
<h2></h2>
{canChangePassword ? (
<form onSubmit={changePassword} className="form">
<label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</label>
<button type="submit" className="btn-primary">
</button>
</form>
) : (
<p className="hint">
</p>
)}
</section>
<section className="section">
<h2> Email</h2>
<p className="hint">

View File

@ -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=102000mfa_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 "確認登入 MFATOTP / 備援碼)並核發 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"
/*

View File

@ -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

View File

@ -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"
)
// 確認登入 MFATOTP / 備援碼)並核發 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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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),
},
{
// 確認登入 MFATOTP / 備援碼)並核發 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,

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}
// 確認登入 MFATOTP / 備援碼)並核發 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)
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -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, &notifuc.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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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,

View File

@ -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))

View File

@ -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")
)

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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)

View File

@ -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"`

View File

@ -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)

View File

@ -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,

View File

@ -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")

View File

@ -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

View File

@ -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"` // 角色 key2-64 字元,不可以 system. / platform_ 開頭)
DisplayName string `json:"display_name,optional"` // 角色顯示名稱(給人看的)
@ -69,6 +78,29 @@ type ListUserRolesReq struct {
UID string `path:"uid"` // 使用者 UIDpath
}
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