feat/env #1
|
|
@ -134,6 +134,8 @@ linters:
|
||||||
paths:
|
paths:
|
||||||
- generate/doc-generate
|
- generate/doc-generate
|
||||||
- docs/openapi
|
- docs/openapi
|
||||||
|
- frontend
|
||||||
|
- cmd/k6-seed-admin
|
||||||
|
|
||||||
formatters:
|
formatters:
|
||||||
enable:
|
enable:
|
||||||
|
|
@ -142,6 +144,8 @@ formatters:
|
||||||
generated: lax
|
generated: lax
|
||||||
paths:
|
paths:
|
||||||
- generate/doc-generate
|
- generate/doc-generate
|
||||||
|
- frontend
|
||||||
|
- cmd/k6-seed-admin
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|
|
||||||
|
|
@ -49,15 +49,15 @@ var (
|
||||||
// a stable email would collide with the existing ZITADEL user (28303000
|
// a stable email would collide with the existing ZITADEL user (28303000
|
||||||
// email already registered) since ZITADEL state lives outside docker
|
// email already registered) since ZITADEL state lives outside docker
|
||||||
// volumes that `make k6-down` clears. Override with -email or ADMIN_EMAIL.
|
// 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")
|
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")
|
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")
|
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")
|
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")
|
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)")
|
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)")
|
flagPollSecs = flag.Int("otp-timeout", 10, "MailHog OTP poll timeout (seconds)")
|
||||||
flagDryRun = flag.Bool("dry-run", false, "Skip Mongo writes; only test register flow")
|
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")
|
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)")
|
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)
|
deadline := time.Now().Add(timeout)
|
||||||
url := fmt.Sprintf("%s/api/v2/search?kind=to&query=%s&start=0&limit=5", *flagMailhog, email)
|
url := fmt.Sprintf("%s/api/v2/search?kind=to&query=%s&start=0&limit=5", *flagMailhog, email)
|
||||||
for time.Now().Before(deadline) {
|
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)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err == nil && resp.StatusCode == 200 {
|
if err == nil && resp.StatusCode == 200 {
|
||||||
raw, _ := io.ReadAll(resp.Body)
|
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})
|
roles := permrepo.NewRoleRepository(permrepo.RoleRepositoryParam{Conf: conf})
|
||||||
role, err := roles.GetByKey(ctx, tenantID, "tenant_admin")
|
role, err := roles.GetByKey(ctx, tenantID, "tenant_admin")
|
||||||
if err != nil || role == nil {
|
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})
|
urRepo := permrepo.NewUserRoleRepository(permrepo.UserRoleRepositoryParam{Conf: conf})
|
||||||
if err := urRepo.Insert(ctx, &permentity.UserRole{
|
if err := urRepo.Insert(ctx, &permentity.UserRole{
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ Member:
|
||||||
Length: 6
|
Length: 6
|
||||||
TTLSeconds: 300
|
TTLSeconds: 300
|
||||||
MaxAttempts: 10
|
MaxAttempts: 10
|
||||||
ResendCooldownSeconds: 1
|
ResendCooldownSeconds: 60
|
||||||
DailyVerifyLimit: 200
|
DailyVerifyLimit: 200
|
||||||
TOTP:
|
TOTP:
|
||||||
Issuer: CloudEP-k6
|
Issuer: CloudEP-k6
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { RolesPage } from './pages/admin/RolesPage';
|
||||||
import { UserRolesPage } from './pages/admin/UserRolesPage';
|
import { UserRolesPage } from './pages/admin/UserRolesPage';
|
||||||
import { RolePermissionsPage } from './pages/admin/RolePermissionsPage';
|
import { RolePermissionsPage } from './pages/admin/RolePermissionsPage';
|
||||||
import { ConfirmPage } from './pages/user/ConfirmPage';
|
import { ConfirmPage } from './pages/user/ConfirmPage';
|
||||||
|
import { ForgotPasswordPage } from './pages/user/ForgotPasswordPage';
|
||||||
import { HomePage } from './pages/user/HomePage';
|
import { HomePage } from './pages/user/HomePage';
|
||||||
import { LoginPage } from './pages/user/LoginPage';
|
import { LoginPage } from './pages/user/LoginPage';
|
||||||
import { ProfilePage } from './pages/user/ProfilePage';
|
import { ProfilePage } from './pages/user/ProfilePage';
|
||||||
|
|
@ -30,6 +31,7 @@ function App() {
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/register/confirm" element={<ConfirmPage />} />
|
<Route path="/register/confirm" element={<ConfirmPage />} />
|
||||||
|
<Route path="/password/forgot" element={<ForgotPasswordPage />} />
|
||||||
|
|
||||||
<Route element={<ProtectedRoute />}>
|
<Route element={<ProtectedRoute />}>
|
||||||
<Route element={<UserLayout />}>
|
<Route element={<UserLayout />}>
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,51 @@ export interface AuthTokenData {
|
||||||
token_type: string;
|
token_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoginData extends Partial<AuthTokenData> {
|
||||||
|
mfa_required?: boolean;
|
||||||
|
mfa_challenge_id?: string;
|
||||||
|
mfa_expires_in?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
challenge_id: string;
|
challenge_id: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
uid: string;
|
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) {
|
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,
|
auth: false,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ tenant_slug: tenantSlug, email, password }),
|
body: JSON.stringify({ tenant_slug: tenantSlug, email, password }),
|
||||||
});
|
});
|
||||||
setTokens(data.access_token, data.refresh_token);
|
if (data.mfa_required) {
|
||||||
localStorage.setItem('uid', data.uid);
|
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;
|
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() {
|
export async function logout() {
|
||||||
try {
|
try {
|
||||||
await api('/api/v1/auth/logout', { method: 'POST', body: '{}' });
|
await api('/api/v1/auth/logout', { method: 'POST', body: '{}' });
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { api } from './http';
|
||||||
export interface MemberMe {
|
export interface MemberMe {
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
uid: string;
|
uid: string;
|
||||||
|
origin: string;
|
||||||
zitadel_email?: string;
|
zitadel_email?: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
|
@ -98,3 +99,13 @@ export function confirmTOTPEnroll(code: string) {
|
||||||
export function disableTOTP() {
|
export function disableTOTP() {
|
||||||
return api('/api/v1/members/me/totp', { method: 'DELETE' });
|
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 預設值,可在登入頁覆寫 */
|
/** 本機 k6 / dev 預設值,可在登入頁覆寫 */
|
||||||
export const DEFAULT_TENANT = 'k6-tenant';
|
export const DEFAULT_TENANT = 'k6-tenant';
|
||||||
export const DEFAULT_INVITE = 'K6INVITE';
|
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 { 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 * as authApi from '../../api/auth';
|
||||||
import { ApiError } from '../../api/http';
|
import { ApiError } from '../../api/http';
|
||||||
|
import { DEFAULT_TENANT, OTP_RESEND_COOLDOWN_SECONDS } from '../../config';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
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() {
|
export function ConfirmPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { syncSession, refreshRoles } = useAuth();
|
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 [challengeId, setChallengeId] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
|
|
@ -16,17 +52,41 @@ export function ConfirmPage() {
|
||||||
const [resendMsg, setResendMsg] = useState('');
|
const [resendMsg, setResendMsg] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const raw = sessionStorage.getItem('register_pending');
|
const state = location.state as ConfirmNavState | null;
|
||||||
if (!raw) return;
|
if (!state?.challenge_id || !state.email) return;
|
||||||
const p = JSON.parse(raw) as {
|
setTenant(state.tenant);
|
||||||
tenant: string;
|
setEmail(state.email);
|
||||||
challenge_id: string;
|
setChallengeId(state.challenge_id);
|
||||||
email: string;
|
setStep('otp');
|
||||||
};
|
startCooldown();
|
||||||
setTenant(p.tenant);
|
}, [location.state, startCooldown]);
|
||||||
setChallengeId(p.challenge_id);
|
|
||||||
setEmail(p.email);
|
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) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -34,10 +94,9 @@ export function ConfirmPage() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await authApi.registerConfirm(tenant, challengeId, code);
|
await authApi.registerConfirm(tenant, challengeId, code);
|
||||||
sessionStorage.removeItem('register_pending');
|
|
||||||
syncSession();
|
syncSession();
|
||||||
await refreshRoles();
|
await refreshRoles();
|
||||||
navigate('/app');
|
navigate('/app', { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : '驗證失敗');
|
setError(err instanceof ApiError ? err.message : '驗證失敗');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -46,20 +105,59 @@ export function ConfirmPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const resend = async () => {
|
const resend = async () => {
|
||||||
|
if (!canSend) return;
|
||||||
setResendMsg('');
|
setResendMsg('');
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await authApi.registerResend(tenant, challengeId);
|
await dispatchCode();
|
||||||
setResendMsg('已重新寄送驗證碼');
|
setResendMsg('驗證碼已重新寄出');
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<p>請先完成註冊步驟。</p>
|
<h1>完成 Email 驗證</h1>
|
||||||
<Link to="/register">返回註冊</Link>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -86,10 +184,31 @@ export function ConfirmPage() {
|
||||||
{loading ? '驗證中…' : '完成註冊'}
|
{loading ? '驗證中…' : '完成註冊'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" className="btn-link" onClick={resend}>
|
<button
|
||||||
重送驗證碼
|
type="button"
|
||||||
|
className="btn-link"
|
||||||
|
onClick={resend}
|
||||||
|
disabled={sendDisabled}
|
||||||
|
>
|
||||||
|
{cooldownLabel(secondsLeft, '重送驗證碼')}
|
||||||
</button>
|
</button>
|
||||||
{resendMsg && <p className="hint">{resendMsg}</p>}
|
{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>
|
</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 { 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 * as authApi from '../../api/auth';
|
||||||
import { ApiError } from '../../api/http';
|
import { ApiError } from '../../api/http';
|
||||||
import { DEFAULT_TENANT } from '../../config';
|
import { DEFAULT_TENANT } from '../../config';
|
||||||
|
|
@ -8,34 +8,116 @@ import * as permApi from '../../api/permission';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { syncSession, refreshRoles } = useAuth();
|
const { syncSession, refreshRoles } = useAuth();
|
||||||
const [tenant, setTenant] = useState(
|
const [tenant, setTenant] = useState(
|
||||||
() => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT,
|
() => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT,
|
||||||
);
|
);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [mfaChallengeId, setMfaChallengeId] = useState<string | null>(null);
|
||||||
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [info, setInfo] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
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) => {
|
const submit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('tenant_slug', tenant);
|
localStorage.setItem('tenant_slug', tenant);
|
||||||
await authApi.login(tenant, email, password);
|
const result = await authApi.login(tenant, email, password);
|
||||||
syncSession();
|
if (result.mfa_required) {
|
||||||
await refreshRoles();
|
if (!result.mfa_challenge_id) {
|
||||||
const me = await permApi.getMyPermissions();
|
throw new Error('缺少 MFA challenge');
|
||||||
const admin = permApi.isAdminRole(me.roles ?? []);
|
}
|
||||||
navigate(admin ? '/admin' : '/app');
|
setMfaChallengeId(result.mfa_challenge_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await finishLogin();
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<h1>登入</h1>
|
<h1>登入</h1>
|
||||||
|
|
@ -63,12 +145,17 @@ export function LoginPage() {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <p className="form-error">{error}</p>}
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
{info && <p className="form-ok">{info}</p>}
|
||||||
<button type="submit" className="btn-primary" disabled={loading}>
|
<button type="submit" className="btn-primary" disabled={loading}>
|
||||||
{loading ? '登入中…' : '登入'}
|
{loading ? '登入中…' : '登入'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="auth-footer">
|
<p className="auth-footer">
|
||||||
還沒有帳號? <Link to="/register">註冊</Link>
|
還沒有帳號? <Link to="/register">註冊</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/register/confirm">尚未完成驗證?</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/password/forgot">忘記密碼?</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,17 +30,17 @@ export function RegisterPage() {
|
||||||
display_name: displayName || undefined,
|
display_name: displayName || undefined,
|
||||||
language: 'zh-TW',
|
language: 'zh-TW',
|
||||||
});
|
});
|
||||||
sessionStorage.setItem(
|
navigate('/register/confirm', {
|
||||||
'register_pending',
|
state: { tenant, email, challenge_id: data.challenge_id },
|
||||||
JSON.stringify({
|
});
|
||||||
tenant,
|
|
||||||
challenge_id: data.challenge_id,
|
|
||||||
email,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
navigate('/register/confirm');
|
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +91,8 @@ export function RegisterPage() {
|
||||||
</form>
|
</form>
|
||||||
<p className="auth-footer">
|
<p className="auth-footer">
|
||||||
已有帳號? <Link to="/login">登入</Link>
|
已有帳號? <Link to="/login">登入</Link>
|
||||||
|
{' · '}
|
||||||
|
<Link to="/register/confirm">尚未完成驗證?</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ export function SecurityPage() {
|
||||||
const [phoneCode, setPhoneCode] = useState('');
|
const [phoneCode, setPhoneCode] = useState('');
|
||||||
|
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [error, setError] = 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>安全設定</h1>
|
<h1>安全設定</h1>
|
||||||
{msg && <p className="form-ok">{msg}</p>}
|
{msg && <p className="form-ok">{msg}</p>}
|
||||||
{error && <p className="form-error">{error}</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">
|
<section className="section">
|
||||||
<h2>商業聯絡 Email</h2>
|
<h2>商業聯絡 Email</h2>
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,32 @@ type (
|
||||||
ChallengeID string `json:"challenge_id" validate:"required"` // 註冊流程的 OTP challenge ID
|
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 {
|
AuthTokenData {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
|
@ -64,6 +90,23 @@ type (
|
||||||
Password string `json:"password" validate:"required,min=8,max=128"` // 密碼(8-128 字元)
|
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 {
|
TokenRefreshReq {
|
||||||
RefreshToken string `json:"refresh_token" validate:"required"` // 先前核發的 refresh token
|
RefreshToken string `json:"refresh_token" validate:"required"` // 先前核發的 refresh token
|
||||||
}
|
}
|
||||||
|
|
@ -107,6 +150,12 @@ type (
|
||||||
Data AuthTokenData `json:"data"`
|
Data AuthTokenData `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LoginOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data LoginData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
RegisterSocialStartOKStatus {
|
RegisterSocialStartOKStatus {
|
||||||
Code int64 `json:"code"`
|
Code int64 `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|
@ -124,6 +173,18 @@ type (
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data LogoutData `json:"data"`
|
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(
|
@server(
|
||||||
|
|
@ -229,6 +290,77 @@ service gateway {
|
||||||
@handler registerResend
|
@handler registerResend
|
||||||
post /register/resend (RegisterResendReq) returns (RegisterData)
|
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"
|
@doc "Social 註冊:建立 session 並回傳 OAuth URL"
|
||||||
/*
|
/*
|
||||||
@respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000)
|
@respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000)
|
||||||
|
|
@ -299,9 +431,9 @@ service gateway {
|
||||||
@handler registerSocialCallback
|
@handler registerSocialCallback
|
||||||
get /register/social/callback (RegisterSocialCallbackReq) returns (AuthTokenData)
|
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 (
|
@respdoc-400 (
|
||||||
10101000: (APIErrorStatus) 參數格式錯誤
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
10104000: (APIErrorStatus) 缺少必填欄位
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
|
@ -327,7 +459,33 @@ service gateway {
|
||||||
) // 第三方服務錯誤
|
) // 第三方服務錯誤
|
||||||
*/
|
*/
|
||||||
@handler login
|
@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"
|
@doc "以 refresh_token 換發新的 access/refresh token"
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,15 @@ type (
|
||||||
Phone string `json:"phone,optional"` // 聯絡電話 E.164 格式(可選)
|
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 {
|
VerificationStartReq {
|
||||||
Target string `json:"target"` // 驗證目標:email 地址或 E.164 手機號(依端點而定)
|
Target string `json:"target"` // 驗證目標:email 地址或 E.164 手機號(依端點而定)
|
||||||
}
|
}
|
||||||
|
|
@ -165,6 +174,29 @@ service gateway {
|
||||||
@handler updateMemberMe
|
@handler updateMemberMe
|
||||||
patch /me (UpdateMemberMeReq) returns (MemberMeData)
|
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 驗證"
|
@doc "開始業務 email 驗證"
|
||||||
/*
|
/*
|
||||||
@respdoc-200 (VerificationStartOKStatus) // 成功(code=102000)
|
@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(
|
server.AddRoutes(
|
||||||
[]rest.Route{
|
[]rest.Route{
|
||||||
{
|
{
|
||||||
// Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)
|
// Email + 密碼登入(ZITADEL ROPG → CloudEP JWT;若已啟用 TOTP 則回傳 MFA challenge)
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/login",
|
Path: "/login",
|
||||||
Handler: auth.LoginHandler(serverCtx),
|
Handler: auth.LoginHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 確認登入 MFA(TOTP / 備援碼)並核發 JWT
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/login/mfa",
|
||||||
|
Handler: auth.LoginMfaConfirmHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Social 登入 OAuth callback
|
// Social 登入 OAuth callback
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|
@ -37,6 +43,18 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/login/social/start",
|
Path: "/login/social/start",
|
||||||
Handler: auth.LoginSocialStartHandler(serverCtx),
|
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)
|
// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
|
|
@ -55,6 +73,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/register/resend",
|
Path: "/register/resend",
|
||||||
Handler: auth.RegisterResendHandler(serverCtx),
|
Handler: auth.RegisterResendHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 恢復未完成註冊(依 Email 重寄 registration OTP)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/register/resume",
|
||||||
|
Handler: auth.RegisterResumeHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Social 註冊 OAuth callback
|
// Social 註冊 OAuth callback
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|
@ -114,6 +138,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||||
Path: "/me",
|
Path: "/me",
|
||||||
Handler: member.UpdateMemberMeHandler(serverCtx),
|
Handler: member.UpdateMemberMeHandler(serverCtx),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 變更登入密碼(僅 platform_native 平台帳號)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/me/password",
|
||||||
|
Handler: member.ChangePasswordHandler(serverCtx),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// TOTP 狀態
|
// TOTP 狀態
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fieldPassword = "password"
|
||||||
|
|
||||||
// Client calls ZITADEL Management API v2 and OAuth token endpoints.
|
// Client calls ZITADEL Management API v2 and OAuth token endpoints.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
conf Conf
|
conf Conf
|
||||||
|
|
@ -131,8 +133,8 @@ func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest
|
||||||
"email": req.Email,
|
"email": req.Email,
|
||||||
"isVerified": req.EmailVerified,
|
"isVerified": req.EmailVerified,
|
||||||
},
|
},
|
||||||
"password": map[string]any{
|
fieldPassword: map[string]any{
|
||||||
"password": req.Password,
|
fieldPassword: req.Password,
|
||||||
"changeRequired": false,
|
"changeRequired": false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +154,28 @@ func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest
|
||||||
return &CreateHumanUserResult{UserID: out.UserID}, nil
|
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.
|
// DeactivateUser disables login for the user via POST /v2/users/{id}/deactivate.
|
||||||
func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
|
func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
|
||||||
if c == nil {
|
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) {
|
func (c *Client) verifyPasswordROPG(ctx context.Context, username, password string) (*TokenResult, error) {
|
||||||
form := url.Values{}
|
form := url.Values{}
|
||||||
form.Set("grant_type", "password")
|
form.Set("grant_type", fieldPassword)
|
||||||
form.Set("client_id", c.conf.OAuthClientID)
|
form.Set("client_id", c.conf.OAuthClientID)
|
||||||
form.Set("client_secret", c.conf.OAuthClientSecret)
|
form.Set("client_secret", c.conf.OAuthClientSecret)
|
||||||
form.Set("username", username)
|
form.Set("username", username)
|
||||||
form.Set("password", password)
|
form.Set(fieldPassword, password)
|
||||||
form.Set("scope", "openid profile email")
|
form.Set("scope", "openid profile email")
|
||||||
|
|
||||||
return c.postToken(ctx, form)
|
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{
|
if err := c.doSessionJSON(ctx, http.MethodPatch, c.apiBase+"/v2/sessions/"+created.SessionID, map[string]any{
|
||||||
"checks": map[string]any{
|
"checks": map[string]any{
|
||||||
"password": map[string]any{"password": password},
|
fieldPassword: map[string]any{fieldPassword: password},
|
||||||
},
|
},
|
||||||
}, nil); err != nil {
|
}, nil); err != nil {
|
||||||
if isSessionPasswordInvalid(err) {
|
if isSessionPasswordInvalid(err) {
|
||||||
|
|
@ -74,7 +74,7 @@ func (c *Client) verifyPasswordSession(ctx context.Context, loginName, password
|
||||||
}, nil
|
}, 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
|
var r io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
raw, err := json.Marshal(body)
|
raw, err := json.Marshal(body)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package auth
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
errs "gateway/internal/library/errors"
|
errs "gateway/internal/library/errors"
|
||||||
"gateway/internal/library/errors/code"
|
"gateway/internal/library/errors/code"
|
||||||
|
|
@ -130,3 +131,89 @@ func isMemberNotFound(err error) bool {
|
||||||
e := errs.FromError(err)
|
e := errs.FromError(err)
|
||||||
return e != nil && e.Category() == code.ResNotFound
|
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 {
|
if err := requireLoginDeps(l.svcCtx); err != nil {
|
||||||
return nil, err
|
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)
|
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"
|
memberenum "gateway/internal/model/member/domain/enum"
|
||||||
dommember "gateway/internal/model/member/domain/usecase"
|
dommember "gateway/internal/model/member/domain/usecase"
|
||||||
"gateway/internal/svc"
|
"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) {
|
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 {
|
if sc.MemberLifecycle == nil {
|
||||||
return errb.SysNotImplemented("member lifecycle not configured")
|
return errb.SysNotImplemented("member lifecycle not configured")
|
||||||
}
|
}
|
||||||
|
if sc.MemberProfile == nil {
|
||||||
|
return errb.SysNotImplemented("member profile not configured")
|
||||||
|
}
|
||||||
if sc.MemberOTP == nil {
|
if sc.MemberOTP == nil {
|
||||||
return errb.SysNotImplemented("member OTP not configured")
|
return errb.SysNotImplemented("member OTP not configured")
|
||||||
}
|
}
|
||||||
|
|
@ -96,3 +102,109 @@ func requireRegistrationDeps(sc *svc.ServiceContext) error {
|
||||||
}
|
}
|
||||||
return nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -42,6 +43,21 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, e
|
||||||
return nil, err
|
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
|
regCfg := l.svcCtx.Config.Member.Defaults().Registration
|
||||||
var inviteCodeID string
|
var inviteCodeID string
|
||||||
if regCfg.RequireInviteCode {
|
if regCfg.RequireInviteCode {
|
||||||
|
|
@ -53,23 +69,14 @@ func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, e
|
||||||
Code: req.InviteCode,
|
Code: req.InviteCode,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
inviteCodeID = consumed.ID
|
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{
|
memberDTO, err := l.svcCtx.MemberLifecycle.CreateUnverified(l.ctx, &dommember.CreatePlatformMemberRequest{
|
||||||
TenantID: tenant.TenantID,
|
TenantID: tenant.TenantID,
|
||||||
Email: email,
|
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"
|
notifuc "gateway/internal/model/notification/domain/usecase"
|
||||||
"gateway/internal/svc"
|
"gateway/internal/svc"
|
||||||
"gateway/internal/types"
|
"gateway/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
func startVerification(
|
func startVerification(
|
||||||
|
|
@ -76,7 +78,9 @@ func startVerification(
|
||||||
}
|
}
|
||||||
if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil {
|
if os.Getenv("GATEWAY_E2E") == "1" && sc.Redis != nil && sc.Redis.Zero() != nil {
|
||||||
key := fmt.Sprintf("e2e:otp:%s", dto.ChallengeID)
|
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{
|
return &types.VerificationStartData{
|
||||||
ChallengeID: dto.ChallengeID,
|
ChallengeID: dto.ChallengeID,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ func LoginSessionRedisKey(sessionID string) string {
|
||||||
return fmt.Sprintf("auth:login:session:%s", sessionID)
|
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.
|
// NormalizeInviteCode trims and uppercases user input before hashing.
|
||||||
func NormalizeInviteCode(code string) string {
|
func NormalizeInviteCode(code string) string {
|
||||||
return strings.ToUpper(strings.TrimSpace(code))
|
return strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,5 @@ var (
|
||||||
ErrDuplicateRegistrationMeta = fmt.Errorf("auth: duplicate registration metadata")
|
ErrDuplicateRegistrationMeta = fmt.Errorf("auth: duplicate registration metadata")
|
||||||
ErrRegistrationSessionNotFound = fmt.Errorf("auth: registration session not found")
|
ErrRegistrationSessionNotFound = fmt.Errorf("auth: registration session not found")
|
||||||
ErrLoginSessionNotFound = fmt.Errorf("auth: login 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
|
RegistrationMeta domusecase.RegistrationMetaUseCase
|
||||||
RegistrationSession domusecase.RegistrationSessionUseCase
|
RegistrationSession domusecase.RegistrationSessionUseCase
|
||||||
LoginSession domusecase.LoginSessionUseCase
|
LoginSession domusecase.LoginSessionUseCase
|
||||||
|
LoginMFAChallenge domusecase.LoginMFAChallengeUseCase
|
||||||
|
|
||||||
Invites domrepo.InviteRepository
|
Invites domrepo.InviteRepository
|
||||||
RegistrationMetaRepo domrepo.RegistrationMetaRepository
|
RegistrationMetaRepo domrepo.RegistrationMetaRepository
|
||||||
|
|
@ -47,6 +48,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||||
regMetaRepo := repository.NewRegistrationMetaRepository(repository.RegistrationMetaRepositoryParam{Conf: param.MongoConf})
|
regMetaRepo := repository.NewRegistrationMetaRepository(repository.RegistrationMetaRepositoryParam{Conf: param.MongoConf})
|
||||||
sessionStore := repository.NewRedisRegistrationSessionStore(param.Redis)
|
sessionStore := repository.NewRedisRegistrationSessionStore(param.Redis)
|
||||||
loginStore := repository.NewRedisLoginSessionStore(param.Redis)
|
loginStore := repository.NewRedisLoginSessionStore(param.Redis)
|
||||||
|
loginMFAStore := repository.NewRedisLoginMFAChallengeStore(param.Redis)
|
||||||
lock := param.Lock
|
lock := param.Lock
|
||||||
if lock == nil {
|
if lock == nil {
|
||||||
lock = repository.NewRedisInviteConsumeLock(param.Redis)
|
lock = repository.NewRedisInviteConsumeLock(param.Redis)
|
||||||
|
|
@ -68,6 +70,9 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) {
|
||||||
LoginSession: MustLoginSessionUseCase(LoginSessionUseCaseParam{
|
LoginSession: MustLoginSessionUseCase(LoginSessionUseCaseParam{
|
||||||
Store: loginStore,
|
Store: loginStore,
|
||||||
}),
|
}),
|
||||||
|
LoginMFAChallenge: MustLoginMFAChallengeUseCase(LoginMFAChallengeUseCaseParam{
|
||||||
|
Store: loginMFAStore,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
return mod, nil
|
return mod, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type MemberRepository interface {
|
||||||
Insert(ctx context.Context, member *entity.Member) error
|
Insert(ctx context.Context, member *entity.Member) error
|
||||||
GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
|
GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
|
||||||
GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID 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)
|
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
|
UpdateStatus(ctx context.Context, tenantID, uid string, status enum.MemberStatus, suspendReason string) error
|
||||||
List(ctx context.Context, filter ListMembersFilter) ([]*entity.Member, int64, error)
|
List(ctx context.Context, filter ListMembersFilter) ([]*entity.Member, int64, error)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
type ProfileUseCase interface {
|
type ProfileUseCase interface {
|
||||||
GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error)
|
GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error)
|
||||||
GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*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)
|
Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error)
|
||||||
List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error)
|
List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error)
|
||||||
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error
|
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error
|
||||||
|
|
@ -54,6 +55,7 @@ type MemberDTO struct {
|
||||||
TenantID string `json:"tenant_id"`
|
TenantID string `json:"tenant_id"`
|
||||||
UID string `json:"uid"`
|
UID string `json:"uid"`
|
||||||
ZitadelEmail string `json:"zitadel_email,omitempty"`
|
ZitadelEmail string `json:"zitadel_email,omitempty"`
|
||||||
|
ZitadelUserID string `json:"zitadel_user_id,omitempty"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
Avatar string `json:"avatar,omitempty"`
|
Avatar string `json:"avatar,omitempty"`
|
||||||
Phone string `json:"phone,omitempty"`
|
Phone string `json:"phone,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package repository
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
libmongo "gateway/internal/library/mongo"
|
libmongo "gateway/internal/library/mongo"
|
||||||
|
|
@ -85,6 +86,21 @@ func (r *memberRepository) GetByZitadelUserID(ctx context.Context, tenantID, zit
|
||||||
return &doc, nil
|
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) {
|
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()}
|
set := bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()}
|
||||||
if update.DisplayName != nil {
|
if update.DisplayName != nil {
|
||||||
|
|
@ -213,6 +229,11 @@ func (r *memberRepository) Index20260520001UP(ctx context.Context) error {
|
||||||
[]int32{1, 1}, true); err != nil {
|
[]int32{1, 1}, true); err != nil {
|
||||||
return err
|
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,
|
return r.db.PopulateMultiIndex(ctx,
|
||||||
[]string{member.BSONFieldTenantID, member.BSONFieldMemberStatus, member.BSONFieldCreateAt},
|
[]string{member.BSONFieldTenantID, member.BSONFieldMemberStatus, member.BSONFieldCreateAt},
|
||||||
[]int32{1, 1, -1}, false)
|
[]int32{1, 1, -1}, false)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ func memberToDTO(m *entity.Member) *domusecase.MemberDTO {
|
||||||
TenantID: m.TenantID,
|
TenantID: m.TenantID,
|
||||||
UID: m.UID,
|
UID: m.UID,
|
||||||
ZitadelEmail: m.ZitadelEmail,
|
ZitadelEmail: m.ZitadelEmail,
|
||||||
|
ZitadelUserID: m.ZitadelUserID,
|
||||||
DisplayName: m.DisplayName,
|
DisplayName: m.DisplayName,
|
||||||
Avatar: m.Avatar,
|
Avatar: m.Avatar,
|
||||||
Phone: m.Phone,
|
Phone: m.Phone,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package usecase
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
member "gateway/internal/model/member/domain"
|
member "gateway/internal/model/member/domain"
|
||||||
domrepo "gateway/internal/model/member/domain/repository"
|
domrepo "gateway/internal/model/member/domain/repository"
|
||||||
|
|
@ -54,6 +55,20 @@ func (uc *profileUseCase) GetByZitadelUserID(ctx context.Context, tenantID, zita
|
||||||
return memberToDTO(rec), nil
|
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) {
|
func (uc *profileUseCase) Update(ctx context.Context, req *domusecase.UpdateMemberRequest) (*domusecase.MemberDTO, error) {
|
||||||
if req == nil || req.TenantID == "" || req.UID == "" {
|
if req == nil || req.TenantID == "" || req.UID == "" {
|
||||||
return nil, errb.InputMissingRequired("tenant_id and uid are required")
|
return nil, errb.InputMissingRequired("tenant_id and uid are required")
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ type ServiceContext struct {
|
||||||
AuthRegistrationMeta domauth.RegistrationMetaUseCase
|
AuthRegistrationMeta domauth.RegistrationMetaUseCase
|
||||||
AuthRegistrationSession domauth.RegistrationSessionUseCase
|
AuthRegistrationSession domauth.RegistrationSessionUseCase
|
||||||
AuthLoginSession domauth.LoginSessionUseCase
|
AuthLoginSession domauth.LoginSessionUseCase
|
||||||
|
AuthLoginMFAChallenge domauth.LoginMFAChallengeUseCase
|
||||||
Zitadel *zitadel.Client
|
Zitadel *zitadel.Client
|
||||||
Notifier domnotif.NotifierUseCase
|
Notifier domnotif.NotifierUseCase
|
||||||
NotificationAdmin domnotif.AdminNotifierUseCase
|
NotificationAdmin domnotif.AdminNotifierUseCase
|
||||||
|
|
@ -125,6 +126,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
|
||||||
sc.AuthRegistrationMeta = authMod.RegistrationMeta
|
sc.AuthRegistrationMeta = authMod.RegistrationMeta
|
||||||
sc.AuthRegistrationSession = authMod.RegistrationSession
|
sc.AuthRegistrationSession = authMod.RegistrationSession
|
||||||
sc.AuthLoginSession = authMod.LoginSession
|
sc.AuthLoginSession = authMod.LoginSession
|
||||||
|
sc.AuthLoginMFAChallenge = authMod.LoginMFAChallenge
|
||||||
}
|
}
|
||||||
if rds != nil && rds.Zero() != nil {
|
if rds != nil && rds.Zero() != nil {
|
||||||
var mongoConf *libmongo.Conf
|
var mongoConf *libmongo.Conf
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,15 @@ type AuthTokenOKStatus struct {
|
||||||
Data AuthTokenData `json:"data"`
|
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 {
|
type CreateRoleReq struct {
|
||||||
Key string `json:"key" validate:"required,min=2,max=64"` // 角色 key(2-64 字元,不可以 system. / platform_ 開頭)
|
Key string `json:"key" validate:"required,min=2,max=64"` // 角色 key(2-64 字元,不可以 system. / platform_ 開頭)
|
||||||
DisplayName string `json:"display_name,optional"` // 角色顯示名稱(給人看的)
|
DisplayName string `json:"display_name,optional"` // 角色顯示名稱(給人看的)
|
||||||
|
|
@ -69,6 +78,29 @@ type ListUserRolesReq struct {
|
||||||
UID string `path:"uid"` // 使用者 UID(path)
|
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 {
|
type LoginReq struct {
|
||||||
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
|
TenantSlug string `json:"tenant_slug" validate:"required"` // 租戶 slug
|
||||||
Email string `json:"email" validate:"required,email"` // 電子郵件
|
Email string `json:"email" validate:"required,email"` // 電子郵件
|
||||||
|
|
@ -152,6 +184,39 @@ type MemberMeOKStatus struct {
|
||||||
Data MemberMeData `json:"data"`
|
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 {
|
type PermissionCatalogData struct {
|
||||||
Tree []PermissionNode `json:"tree,omitempty"`
|
Tree []PermissionNode `json:"tree,omitempty"`
|
||||||
List []PermissionNode `json:"list,omitempty"`
|
List []PermissionNode `json:"list,omitempty"`
|
||||||
|
|
@ -239,6 +304,11 @@ type RegisterResendReq struct {
|
||||||
ChallengeID string `json:"challenge_id" validate:"required"` // 註冊流程的 OTP challenge ID
|
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 {
|
type RegisterSocialCallbackReq struct {
|
||||||
Code string `form:"code" validate:"required"` // IdP 回傳的 OAuth authorization code
|
Code string `form:"code" validate:"required"` // IdP 回傳的 OAuth authorization code
|
||||||
State string `form:"state" validate:"required"` // IdP 回傳的 OAuth state(對應 session)
|
State string `form:"state" validate:"required"` // IdP 回傳的 OAuth state(對應 session)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue