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:
parent
ffd60206d0
commit
d845ef45fd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ Member:
|
|||
Length: 6
|
||||
TTLSeconds: 300
|
||||
MaxAttempts: 10
|
||||
ResendCooldownSeconds: 1
|
||||
ResendCooldownSeconds: 60
|
||||
DailyVerifyLimit: 200
|
||||
TOTP:
|
||||
Issuer: CloudEP-k6
|
||||
|
|
|
|||
|
|
@ -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 />}>
|
||||
|
|
|
|||
|
|
@ -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: '{}' });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue