forked from baron/baron-sso
Merge pull request 'feature/password-reset' (#492) from feature/password-reset into dev
Reviewed-on: baron/baron-sso#492
This commit is contained in:
@@ -515,7 +515,7 @@ jobs:
|
||||
adminfront-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
|
||||
runs-on: playwright
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -594,7 +594,7 @@ jobs:
|
||||
devfront-tests:
|
||||
needs: lint
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
|
||||
runs-on: playwright
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Key,
|
||||
Loader2,
|
||||
Mail,
|
||||
@@ -32,16 +34,24 @@ import {
|
||||
} from "../../components/ui/card";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../components/ui/tabs";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type UserSummary,
|
||||
type UserUpdateRequest,
|
||||
deleteUser,
|
||||
fetchPasswordPolicy,
|
||||
fetchMe,
|
||||
fetchTenants,
|
||||
fetchUser,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import type { PasswordPolicyResponse } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { generateSecurePassword } from "../../lib/utils";
|
||||
|
||||
@@ -58,6 +68,116 @@ type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||
};
|
||||
|
||||
type PasswordResetMode = "generated" | "manual";
|
||||
|
||||
const PASSWORD_RESET_MIN_LENGTH = 12;
|
||||
|
||||
function buildPasswordPolicyDescription(policy?: PasswordPolicyResponse) {
|
||||
const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH;
|
||||
const minTypes = policy?.minCharacterTypes ?? 0;
|
||||
const requiresLower = policy?.lowercase ?? true;
|
||||
const requiresUpper = policy?.uppercase ?? false;
|
||||
const requiresNumber = policy?.number ?? true;
|
||||
const requiresSymbol = policy?.nonAlphanumeric ?? true;
|
||||
|
||||
const parts = [
|
||||
t("msg.userfront.signup.policy.min_length", "최소 {{count}}자 이상", {
|
||||
count: String(minLength),
|
||||
}),
|
||||
];
|
||||
|
||||
if (minTypes > 0) {
|
||||
parts.push(
|
||||
t(
|
||||
"msg.userfront.signup.policy.min_types",
|
||||
"영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상",
|
||||
{ count: String(minTypes) },
|
||||
),
|
||||
);
|
||||
}
|
||||
if (requiresLower) {
|
||||
parts.push(t("msg.userfront.signup.policy.lowercase", "소문자"));
|
||||
}
|
||||
if (requiresUpper) {
|
||||
parts.push(t("msg.userfront.signup.policy.uppercase", "대문자"));
|
||||
}
|
||||
if (requiresNumber) {
|
||||
parts.push(t("msg.userfront.signup.policy.number", "숫자"));
|
||||
}
|
||||
if (requiresSymbol) {
|
||||
parts.push(t("msg.userfront.signup.policy.symbol", "특수문자"));
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function validateManualPassword(
|
||||
password: string,
|
||||
policy?: PasswordPolicyResponse,
|
||||
) {
|
||||
if (password.trim().length === 0) {
|
||||
return t(
|
||||
"msg.admin.users.detail.password_manual_required",
|
||||
"비밀번호를 입력해 주세요.",
|
||||
);
|
||||
}
|
||||
|
||||
const minLength = policy?.minLength ?? PASSWORD_RESET_MIN_LENGTH;
|
||||
if (password.length < minLength) {
|
||||
return t(
|
||||
"msg.userfront.reset.error.min_length",
|
||||
"비밀번호는 최소 {{count}}자 이상이어야 합니다.",
|
||||
{ count: String(minLength) },
|
||||
);
|
||||
}
|
||||
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasSymbol = /[\W_]/.test(password);
|
||||
let typeCount = 0;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasNumber) typeCount++;
|
||||
if (hasSymbol) typeCount++;
|
||||
|
||||
const minTypes = policy?.minCharacterTypes ?? 0;
|
||||
if (minTypes > 0 && typeCount < minTypes) {
|
||||
return t(
|
||||
"msg.userfront.reset.error.min_types",
|
||||
"비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.",
|
||||
{ count: String(minTypes) },
|
||||
);
|
||||
}
|
||||
|
||||
if ((policy?.lowercase ?? true) && !hasLower) {
|
||||
return t(
|
||||
"msg.userfront.reset.error.lowercase",
|
||||
"최소 1개 이상의 소문자를 포함해야 합니다.",
|
||||
);
|
||||
}
|
||||
if ((policy?.uppercase ?? false) && !hasUpper) {
|
||||
return t(
|
||||
"msg.userfront.reset.error.uppercase",
|
||||
"최소 1개 이상의 대문자를 포함해야 합니다.",
|
||||
);
|
||||
}
|
||||
if ((policy?.number ?? true) && !hasNumber) {
|
||||
return t(
|
||||
"msg.userfront.reset.error.number",
|
||||
"최소 1개 이상의 숫자를 포함해야 합니다.",
|
||||
);
|
||||
}
|
||||
if ((policy?.nonAlphanumeric ?? true) && !hasSymbol) {
|
||||
return t(
|
||||
"msg.userfront.reset.error.symbol",
|
||||
"최소 1개 이상의 특수문자를 포함해야 합니다.",
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TenantMetadataFields({
|
||||
tenant,
|
||||
schema,
|
||||
@@ -166,6 +286,15 @@ function UserDetailPage() {
|
||||
const [generatedPassword, setGeneratedPassword] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [passwordResetMode, setPasswordResetMode] =
|
||||
React.useState<PasswordResetMode>("generated");
|
||||
const [manualPassword, setManualPassword] = React.useState("");
|
||||
const [manualPasswordConfirm, setManualPasswordConfirm] = React.useState("");
|
||||
const [isManualPasswordVisible, setIsManualPasswordVisible] =
|
||||
React.useState(false);
|
||||
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -187,6 +316,12 @@ function UserDetailPage() {
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
const { data: passwordPolicy, isLoading: isPasswordPolicyLoading } = useQuery(
|
||||
{
|
||||
queryKey: ["password-policy"],
|
||||
queryFn: fetchPasswordPolicy,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -211,11 +346,13 @@ function UserDetailPage() {
|
||||
|
||||
const isAdmin =
|
||||
profile?.role === "super_admin" || profile?.role === "tenant_admin";
|
||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
|
||||
onSuccess: (_, newPass) => {
|
||||
setGeneratedPassword(newPass);
|
||||
setPasswordResetError(null);
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.detail.password_generated",
|
||||
@@ -224,20 +361,67 @@ function UserDetailPage() {
|
||||
);
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
const message =
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.users.detail.update_error", "수정에 실패했습니다."),
|
||||
);
|
||||
t("msg.admin.users.detail.update_error", "수정에 실패했습니다.");
|
||||
setPasswordResetError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleGeneratePassword = () => {
|
||||
const handleOpenPasswordReset = () => {
|
||||
if (isSelf) {
|
||||
return;
|
||||
}
|
||||
setIsPasswordResetOpen(true);
|
||||
setGeneratedPassword(null);
|
||||
setPasswordResetMode("generated");
|
||||
setManualPassword("");
|
||||
setManualPasswordConfirm("");
|
||||
setIsManualPasswordVisible(false);
|
||||
setPasswordResetError(null);
|
||||
};
|
||||
|
||||
const confirmGeneratePassword = () => {
|
||||
const newPass = generateSecurePassword();
|
||||
const handleClosePasswordReset = () => {
|
||||
setIsPasswordResetOpen(false);
|
||||
setGeneratedPassword(null);
|
||||
setPasswordResetMode("generated");
|
||||
setManualPassword("");
|
||||
setManualPasswordConfirm("");
|
||||
setIsManualPasswordVisible(false);
|
||||
setPasswordResetError(null);
|
||||
};
|
||||
|
||||
const confirmPasswordReset = () => {
|
||||
if (isSelf) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newPass = manualPassword;
|
||||
|
||||
if (passwordResetMode === "manual") {
|
||||
const validationError = validateManualPassword(
|
||||
manualPassword,
|
||||
passwordPolicy,
|
||||
);
|
||||
if (validationError) {
|
||||
setPasswordResetError(validationError);
|
||||
return;
|
||||
}
|
||||
if (manualPassword !== manualPasswordConfirm) {
|
||||
setPasswordResetError(
|
||||
t(
|
||||
"msg.userfront.reset.error.mismatch",
|
||||
"비밀번호가 일치하지 않습니다.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
newPass = generateSecurePassword();
|
||||
}
|
||||
|
||||
setPasswordResetError(null);
|
||||
resetPasswordMutation.mutate(newPass);
|
||||
};
|
||||
|
||||
@@ -717,7 +901,7 @@ function UserDetailPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/20">
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t(
|
||||
@@ -726,44 +910,205 @@ function UserDetailPage() {
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
사용자의 비밀번호를 강제로 재설정하고 새 비밀번호를
|
||||
생성합니다.
|
||||
{t(
|
||||
"msg.admin.users.detail.reset_password_help",
|
||||
"사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleGeneratePassword}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleOpenPasswordReset}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.users.detail.reset_password", "초기화 및 생성")}
|
||||
{t("ui.admin.users.detail.reset_password", "초기화 및 설정")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPasswordResetOpen && !generatedPassword && (
|
||||
<div className="p-4 border rounded-lg bg-destructive/5 space-y-4">
|
||||
<p className="text-sm">
|
||||
{isSelf && (
|
||||
<div className="rounded-lg border px-4 py-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.detail.reset_password_confirm",
|
||||
"정말로 이 사용자의 비밀번호를 초기화하시겠습니까? 기존 비밀번호로는 즉시 로그인할 수 없게 됩니다.",
|
||||
"msg.admin.users.detail.self_password_reset_blocked",
|
||||
"본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPasswordResetOpen && !generatedPassword && !isSelf && (
|
||||
<div className="space-y-4 rounded-lg border px-4 py-4">
|
||||
<Tabs
|
||||
value={passwordResetMode}
|
||||
onValueChange={(value) => {
|
||||
setPasswordResetMode(value as PasswordResetMode);
|
||||
setPasswordResetError(null);
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2 rounded-lg bg-muted p-1">
|
||||
<TabsTrigger
|
||||
value="generated"
|
||||
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.password_mode_generated",
|
||||
"자동 생성",
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="manual"
|
||||
className="bg-slate-200 font-normal text-foreground data-[state=inactive]:bg-slate-200 data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.users.detail.password_mode_manual",
|
||||
"수동 입력",
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="generated" className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.users.detail.password_generated_help",
|
||||
"보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다.",
|
||||
)}
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value="manual" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPasswordPolicyLoading
|
||||
? t(
|
||||
"msg.userfront.signup.policy.loading",
|
||||
"비밀번호 정책을 불러오는 중입니다...",
|
||||
)
|
||||
: t(
|
||||
"msg.userfront.signup.policy.summary",
|
||||
"보안 정책: {{rules}}",
|
||||
{
|
||||
rules:
|
||||
buildPasswordPolicyDescription(
|
||||
passwordPolicy,
|
||||
),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="manualPassword"
|
||||
type={isManualPasswordVisible ? "text" : "password"}
|
||||
value={manualPassword}
|
||||
placeholder=" "
|
||||
className="peer pr-12 pt-5"
|
||||
onChange={(event) => {
|
||||
setManualPassword(event.target.value);
|
||||
if (passwordResetError) {
|
||||
setPasswordResetError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="manualPassword"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
|
||||
>
|
||||
{t(
|
||||
"ui.userfront.reset.new_password",
|
||||
"새 비밀번호",
|
||||
)}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
|
||||
onClick={() =>
|
||||
setIsManualPasswordVisible((prev) => !prev)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.toggle_password_visibility",
|
||||
"비밀번호 표시 전환",
|
||||
)}
|
||||
>
|
||||
{isManualPasswordVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="manualPasswordConfirm"
|
||||
type={isManualPasswordVisible ? "text" : "password"}
|
||||
value={manualPasswordConfirm}
|
||||
placeholder=" "
|
||||
className="peer pr-12 pt-5"
|
||||
onChange={(event) => {
|
||||
setManualPasswordConfirm(event.target.value);
|
||||
if (passwordResetError) {
|
||||
setPasswordResetError(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="manualPasswordConfirm"
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 bg-background px-1 text-sm text-muted-foreground transition-all peer-placeholder-shown:top-1/2 peer-placeholder-shown:text-sm peer-focus:top-0 peer-focus:translate-y-[-50%] peer-focus:text-xs peer-[&:not(:placeholder-shown)]:top-0 peer-[&:not(:placeholder-shown)]:translate-y-[-50%] peer-[&:not(:placeholder-shown)]:text-xs"
|
||||
>
|
||||
{t(
|
||||
"ui.userfront.reset.confirm_password",
|
||||
"새 비밀번호 확인",
|
||||
)}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2"
|
||||
onClick={() =>
|
||||
setIsManualPasswordVisible((prev) => !prev)
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.detail.toggle_password_visibility",
|
||||
"비밀번호 표시 전환",
|
||||
)}
|
||||
>
|
||||
{isManualPasswordVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{passwordResetError && (
|
||||
<p className="text-sm text-destructive">
|
||||
{passwordResetError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsPasswordResetOpen(false)}
|
||||
onClick={handleClosePasswordReset}
|
||||
>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={confirmGeneratePassword}
|
||||
onClick={confirmPasswordReset}
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
>
|
||||
{resetPasswordMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t(
|
||||
"ui.admin.users.detail.reset_password",
|
||||
"초기화 및 생성",
|
||||
"ui.admin.users.detail.reset_password_apply",
|
||||
"비밀번호 적용",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -774,7 +1119,10 @@ function UserDetailPage() {
|
||||
<div className="p-4 border border-dashed rounded-lg bg-yellow-500/5 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-yellow-600 uppercase tracking-wider">
|
||||
Generated Password
|
||||
{t(
|
||||
"ui.admin.users.detail.password_result_title",
|
||||
"Reset Password",
|
||||
)}
|
||||
</p>
|
||||
<p className="font-mono text-lg font-bold">
|
||||
{generatedPassword}
|
||||
|
||||
@@ -499,6 +499,22 @@ export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export type PasswordPolicyResponse = {
|
||||
minLength?: number;
|
||||
lowercase?: boolean;
|
||||
uppercase?: boolean;
|
||||
number?: boolean;
|
||||
nonAlphanumeric?: boolean;
|
||||
minCharacterTypes?: number;
|
||||
};
|
||||
|
||||
export async function fetchPasswordPolicy() {
|
||||
const { data } = await apiClient.get<PasswordPolicyResponse>(
|
||||
"/v1/auth/password/policy",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
@@ -288,6 +288,12 @@ name_required = "Name Required"
|
||||
|
||||
[msg.admin.users.detail.security]
|
||||
password_hint = "Password Hint"
|
||||
password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately."
|
||||
password_manual_help = "Enter and apply a password with at least {{count}} characters."
|
||||
password_manual_min_length = "Password must be at least {{count}} characters long."
|
||||
password_manual_required = "Please enter a password."
|
||||
reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one."
|
||||
self_password_reset_blocked = "Please change your own password from the UserFront settings page."
|
||||
|
||||
[msg.admin.users.list]
|
||||
delete_confirm = "Delete Confirm"
|
||||
@@ -1094,6 +1100,16 @@ password = "Password"
|
||||
password_placeholder = "Password Placeholder"
|
||||
title = "Security Settings"
|
||||
|
||||
[ui.admin.users.detail]
|
||||
manual_password = "New Password"
|
||||
manual_password_placeholder = "Enter a new password"
|
||||
password_mode_generated = "Auto Generate"
|
||||
password_mode_manual = "Manual Input"
|
||||
password_result_title = "Reset Password"
|
||||
reset_password = "Reset & Set"
|
||||
reset_password_apply = "Apply Password"
|
||||
toggle_password_visibility = "Toggle password visibility"
|
||||
|
||||
[ui.admin.users.detail.tenants_section]
|
||||
additional = "Additional Affiliated/Manageable Tenants"
|
||||
primary = "Representative Affiliated Tenant"
|
||||
|
||||
@@ -288,6 +288,12 @@ name_required = "이름은 필수입니다."
|
||||
|
||||
[msg.admin.users.detail.security]
|
||||
password_hint = "비밀번호를 변경하려면 입력하세요. 비워두면 현재 비밀번호가 유지됩니다."
|
||||
password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다."
|
||||
password_manual_help = "최소 {{count}}자 이상의 비밀번호를 직접 입력해 적용합니다."
|
||||
password_manual_min_length = "비밀번호는 최소 {{count}}자 이상이어야 합니다."
|
||||
password_manual_required = "비밀번호를 입력해 주세요."
|
||||
reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다."
|
||||
self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요."
|
||||
|
||||
[msg.admin.users.list]
|
||||
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
||||
@@ -1094,6 +1100,16 @@ password = "비밀번호 변경"
|
||||
password_placeholder = "변경할 경우에만 입력"
|
||||
title = "보안 설정"
|
||||
|
||||
[ui.admin.users.detail]
|
||||
manual_password = "새 비밀번호"
|
||||
manual_password_placeholder = "새 비밀번호를 입력하세요"
|
||||
password_mode_generated = "자동 생성"
|
||||
password_mode_manual = "수동 입력"
|
||||
password_result_title = "Reset Password"
|
||||
reset_password = "초기화 및 설정"
|
||||
reset_password_apply = "비밀번호 적용"
|
||||
toggle_password_visibility = "비밀번호 표시 전환"
|
||||
|
||||
[ui.admin.users.detail.tenants_section]
|
||||
additional = "추가 소속/관리 테넌트"
|
||||
primary = "대표 소속 테넌트"
|
||||
|
||||
@@ -288,6 +288,12 @@ name_required = ""
|
||||
|
||||
[msg.admin.users.detail.security]
|
||||
password_hint = ""
|
||||
password_generated_help = ""
|
||||
password_manual_help = ""
|
||||
password_manual_min_length = ""
|
||||
password_manual_required = ""
|
||||
reset_password_help = ""
|
||||
self_password_reset_blocked = ""
|
||||
|
||||
[msg.admin.users.list]
|
||||
delete_confirm = ""
|
||||
@@ -1094,6 +1100,16 @@ password = ""
|
||||
password_placeholder = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.users.detail]
|
||||
manual_password = ""
|
||||
manual_password_placeholder = ""
|
||||
password_mode_generated = ""
|
||||
password_mode_manual = ""
|
||||
password_result_title = ""
|
||||
reset_password = ""
|
||||
reset_password_apply = ""
|
||||
toggle_password_visibility = ""
|
||||
|
||||
[ui.admin.users.detail.tenants_section]
|
||||
additional = ""
|
||||
primary = ""
|
||||
|
||||
@@ -539,8 +539,12 @@ func main() {
|
||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||
auth.Get("/password/reset/v/:token", authHandler.VerifyPasswordResetPage)
|
||||
auth.Get("/password/reset/ve", authHandler.VerifyPasswordResetPage)
|
||||
// [Added] Use POST for actual verification triggered by the user
|
||||
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/v/:token", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/ve", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
||||
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
||||
auth.Post("/sms", authHandler.SendSms)
|
||||
|
||||
@@ -11,6 +11,7 @@ type NaverSmsRequest struct {
|
||||
ContentType string `json:"contentType"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
From string `json:"from"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Messages []SmsMessage `json:"messages"`
|
||||
}
|
||||
|
||||
@@ -66,19 +66,21 @@ const (
|
||||
loginFlowLink = "link"
|
||||
|
||||
// Durations
|
||||
defaultExpiration = 5 * time.Minute
|
||||
signupStateExpiration = 10 * time.Minute
|
||||
signupBlockDuration = 10 * time.Minute
|
||||
maxSignupFailures = 5
|
||||
emailCodeTTL = 5 * time.Minute
|
||||
smsCodeTTL = 3 * time.Minute
|
||||
prefixPwdResetToken = "pwdreset_token:"
|
||||
pwdResetExpiration = 15 * time.Minute
|
||||
minPollInterval = 2 * time.Second
|
||||
loginCodeExpiration = 10 * time.Minute
|
||||
linkResendCooldown = 60 * time.Second
|
||||
prefixDrySend = "dry_send:"
|
||||
headlessJWKSFetchTTL = 5 * time.Second
|
||||
defaultExpiration = 5 * time.Minute
|
||||
signupStateExpiration = 10 * time.Minute
|
||||
signupBlockDuration = 10 * time.Minute
|
||||
maxSignupFailures = 5
|
||||
emailCodeTTL = 5 * time.Minute
|
||||
smsCodeTTL = 3 * time.Minute
|
||||
prefixPwdResetToken = "pwdreset_token:"
|
||||
prefixPwdResetUsed = "pwdreset_used:"
|
||||
pwdResetExpiration = 15 * time.Minute
|
||||
pwdResetUsedExpiration = 2 * time.Minute
|
||||
minPollInterval = 2 * time.Second
|
||||
loginCodeExpiration = 10 * time.Minute
|
||||
linkResendCooldown = 60 * time.Second
|
||||
prefixDrySend = "dry_send:"
|
||||
headlessJWKSFetchTTL = 5 * time.Second
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
@@ -2368,9 +2370,9 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
userfrontURL := h.resolveUserfrontURL(c)
|
||||
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
|
||||
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
|
||||
ale.RedirectTo = redirectURL
|
||||
// 비밀번호 재설정 링크는 backend verify 엔드포인트를 거쳐서 userfront로 이동합니다.
|
||||
// 이렇게 해야 메일/SMS 링크 프리뷰나 자동 스캔으로 토큰이 직접 노출되는 경로를 줄일 수 있습니다.
|
||||
verifyBaseURL := fmt.Sprintf("%s/api/v1/auth/password/reset/v", userfrontURL)
|
||||
|
||||
// 내부 토큰 발급 + 우리 채널로 전송
|
||||
resetToken := GenerateSecureToken(32)
|
||||
@@ -2390,7 +2392,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to store reset token")
|
||||
}
|
||||
|
||||
resetLink := fmt.Sprintf("%s/reset-password?token=%s", userfrontURL, resetToken)
|
||||
resetLink := fmt.Sprintf("%s/%s", verifyBaseURL, resetToken)
|
||||
ale.RedirectTo = resetLink
|
||||
ale.Operation = "SendPasswordReset"
|
||||
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
||||
@@ -2456,6 +2458,9 @@ func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
|
||||
if token == "" {
|
||||
token = c.Query("t")
|
||||
}
|
||||
if token == "" {
|
||||
token = c.Params("token")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
||||
@@ -2509,6 +2514,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
||||
token = c.Query("t")
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
token = c.Params("token")
|
||||
}
|
||||
ale.Token = token
|
||||
|
||||
if token == "" {
|
||||
@@ -2583,6 +2591,14 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
||||
if resetToken != "" {
|
||||
val, err := h.RedisService.Get(prefixPwdResetToken + resetToken)
|
||||
if err != nil || strings.TrimSpace(val) == "" {
|
||||
if usedLoginID, usedErr := h.RedisService.Get(prefixPwdResetUsed + resetToken); usedErr == nil && strings.TrimSpace(usedLoginID) != "" {
|
||||
ale.Status = fiber.StatusOK
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.Token = resetToken
|
||||
ale.LoginIDs["loginId"] = strings.TrimSpace(usedLoginID)
|
||||
ale.Log(slog.LevelInfo, "Duplicate reset completion ignored after successful use")
|
||||
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||
}
|
||||
ale.Status = fiber.StatusUnauthorized
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.ProviderError = "Invalid or expired reset token"
|
||||
@@ -2652,6 +2668,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
||||
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
||||
if resetToken != "" {
|
||||
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
||||
_ = h.RedisService.Set(prefixPwdResetUsed+resetToken, loginID, pwdResetUsedExpiration)
|
||||
}
|
||||
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||
}
|
||||
|
||||
@@ -16,13 +16,29 @@ import (
|
||||
)
|
||||
|
||||
// Mock services
|
||||
type mockEmailService struct{}
|
||||
type mockEmailService struct {
|
||||
lastTo string
|
||||
lastSubject string
|
||||
lastBody string
|
||||
}
|
||||
|
||||
func (m *mockEmailService) SendEmail(to, subject, body string) error { return nil }
|
||||
func (m *mockEmailService) SendEmail(to, subject, body string) error {
|
||||
m.lastTo = to
|
||||
m.lastSubject = subject
|
||||
m.lastBody = body
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockSmsService struct{}
|
||||
type mockSmsService struct {
|
||||
lastTo string
|
||||
lastContent string
|
||||
}
|
||||
|
||||
func (m *mockSmsService) SendSms(to, content string) error { return nil }
|
||||
func (m *mockSmsService) SendSms(to, content string) error {
|
||||
m.lastTo = to
|
||||
m.lastContent = content
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -254,6 +255,65 @@ func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_DuplicateTokenSubmitIsIdempotent(t *testing.T) {
|
||||
const resetToken = "dup-token"
|
||||
const loginID = "user@example.com"
|
||||
const newPassword = "StrongPass1!"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + resetToken: loginID,
|
||||
},
|
||||
}
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
err: nil,
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: idp,
|
||||
}
|
||||
app := newResetFlowTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": newPassword,
|
||||
})
|
||||
url := fmt.Sprintf(
|
||||
"/api/v1/auth/password/reset/complete?token=%s",
|
||||
resetToken,
|
||||
)
|
||||
|
||||
firstReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
firstReq.Header.Set("Content-Type", "application/json")
|
||||
firstResp, err := app.Test(firstReq)
|
||||
if err != nil {
|
||||
t.Fatalf("first request failed: %v", err)
|
||||
}
|
||||
defer firstResp.Body.Close()
|
||||
|
||||
if firstResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected first response to be 200, got %d", firstResp.StatusCode)
|
||||
}
|
||||
if idp.updateCallCount != 1 {
|
||||
t.Fatalf("expected first request to update password once, got %d", idp.updateCallCount)
|
||||
}
|
||||
|
||||
secondReq := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body))
|
||||
secondReq.Header.Set("Content-Type", "application/json")
|
||||
secondResp, err := app.Test(secondReq)
|
||||
if err != nil {
|
||||
t.Fatalf("second request failed: %v", err)
|
||||
}
|
||||
defer secondResp.Body.Close()
|
||||
|
||||
if secondResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected duplicate response to be 200, got %d", secondResp.StatusCode)
|
||||
}
|
||||
if idp.updateCallCount != 1 {
|
||||
t.Fatalf("expected duplicate request not to update password again, got %d", idp.updateCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
||||
const token = "tok-enc"
|
||||
const loginID = "user+alias@example.com"
|
||||
@@ -295,6 +355,102 @@ func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetVerifyAlias_AcceptsShortVePath(t *testing.T) {
|
||||
const token = "tok-ve"
|
||||
const loginID = "user@example.com"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + token: loginID,
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/password/reset/ve", h.VerifyPasswordResetPage)
|
||||
app.Post("/api/v1/auth/password/reset/ve", h.ProcessPasswordResetToken)
|
||||
|
||||
getReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/auth/password/reset/ve?token="+token,
|
||||
nil,
|
||||
)
|
||||
getResp, err := app.Test(getReq)
|
||||
if err != nil {
|
||||
t.Fatalf("get request failed: %v", err)
|
||||
}
|
||||
defer getResp.Body.Close()
|
||||
|
||||
if getResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected alias GET to return 200, got %d", getResp.StatusCode)
|
||||
}
|
||||
|
||||
postReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/password/reset/ve?token="+token,
|
||||
nil,
|
||||
)
|
||||
postResp, err := app.Test(postReq)
|
||||
if err != nil {
|
||||
t.Fatalf("post request failed: %v", err)
|
||||
}
|
||||
defer postResp.Body.Close()
|
||||
|
||||
if postResp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected alias POST to return 302, got %d", postResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetVerifyPathToken_AcceptsShortVPath(t *testing.T) {
|
||||
const token = "tok-path"
|
||||
const loginID = "user@example.com"
|
||||
|
||||
redis := &testRedisRepo{
|
||||
values: map[string]string{
|
||||
prefixPwdResetToken + token: loginID,
|
||||
},
|
||||
}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/auth/password/reset/v/:token", h.VerifyPasswordResetPage)
|
||||
app.Post("/api/v1/auth/password/reset/v/:token", h.ProcessPasswordResetToken)
|
||||
|
||||
getReq := httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/v1/auth/password/reset/v/"+token,
|
||||
nil,
|
||||
)
|
||||
getResp, err := app.Test(getReq)
|
||||
if err != nil {
|
||||
t.Fatalf("get request failed: %v", err)
|
||||
}
|
||||
defer getResp.Body.Close()
|
||||
|
||||
if getResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected path-token GET to return 200, got %d", getResp.StatusCode)
|
||||
}
|
||||
|
||||
postReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/password/reset/v/"+token,
|
||||
nil,
|
||||
)
|
||||
postResp, err := app.Test(postReq)
|
||||
if err != nil {
|
||||
t.Fatalf("post request failed: %v", err)
|
||||
}
|
||||
defer postResp.Body.Close()
|
||||
|
||||
if postResp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("expected path-token POST to return 302, got %d", postResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newResetInitAppWithErrorCodeEnricher(h)
|
||||
@@ -326,3 +482,40 @@ func TestPasswordResetInit_LegacyErrorResponseHasCodeViaMiddleware(t *testing.T)
|
||||
t.Fatalf("expected code=bad_request, got %v", got["code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePasswordReset_SmsContainsVerifyLink(t *testing.T) {
|
||||
t.Setenv("USERFRONT_URL", "https://sss.hmac.kr")
|
||||
|
||||
redis := &testRedisRepo{values: map[string]string{}}
|
||||
smsSvc := &mockSmsService{}
|
||||
h := &AuthHandler{
|
||||
RedisService: redis,
|
||||
IdpProvider: &mockIdpProvider{},
|
||||
SmsService: smsSvc,
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/password/reset/init", h.InitiatePasswordReset)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"loginId": "01012345678",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/init", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(smsSvc.lastContent, "/api/v1/auth/password/reset/v/") {
|
||||
t.Fatalf("expected SMS to contain short path verify link, got %q", smsSvc.lastContent)
|
||||
}
|
||||
if strings.Contains(smsSvc.lastContent, "/reset-password?token=") {
|
||||
t.Fatalf("expected direct reset-password link to be removed, got %q", smsSvc.lastContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type mockIdpProvider struct {
|
||||
err error
|
||||
initiateLinkErr error
|
||||
updateCalled bool
|
||||
updateCallCount int
|
||||
updatedLoginID string
|
||||
updatedPassword string
|
||||
}
|
||||
@@ -68,6 +69,7 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
|
||||
|
||||
func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
m.updateCalled = true
|
||||
m.updateCallCount++
|
||||
m.updatedLoginID = loginID
|
||||
m.updatedPassword = newPassword
|
||||
return m.err
|
||||
|
||||
@@ -1203,12 +1203,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
finalLoginID := extractTraitString(traits, "id")
|
||||
explicitLoginID := strings.TrimSpace(extractTraitString(traits, "id"))
|
||||
userEmail := extractTraitString(traits, "email")
|
||||
userPhone := extractTraitString(traits, "phone")
|
||||
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
|
||||
userPhone := extractTraitString(traits, "phone_number")
|
||||
if err := domain.ValidateLoginID(explicitLoginID, userEmail, userPhone); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
finalLoginID := resolvePasswordLoginID(traits)
|
||||
|
||||
state := normalizeKratosState(req.Status)
|
||||
|
||||
@@ -1234,7 +1235,10 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
|
||||
if h.OryProvider == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available")
|
||||
}
|
||||
if err := h.OryProvider.UpdateUserPassword(finalLoginID, *req.Password, nil); err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1508,6 +1512,16 @@ func extractTraitString(traits map[string]interface{}, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolvePasswordLoginID(traits map[string]interface{}) string {
|
||||
if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" {
|
||||
return loginID
|
||||
}
|
||||
if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" {
|
||||
return email
|
||||
}
|
||||
return strings.TrimSpace(extractTraitString(traits, "phone_number"))
|
||||
}
|
||||
|
||||
// syncLoginID ensures that the 'id' trait (used as Kratos identifier) is in sync with the configured custom field.
|
||||
func syncLoginID(traits map[string]interface{}, metadata map[string]any, tenantID string, loginIDField string) {
|
||||
if loginIDField == "" || loginIDField == "id" {
|
||||
|
||||
@@ -488,6 +488,117 @@ func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_PasswordUsesProvider(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
|
||||
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||
return h.UpdateUser(c)
|
||||
})
|
||||
|
||||
userID := "u-1"
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"id": "dyddus1210",
|
||||
"email": "dyddus1210@gmail.com",
|
||||
"companyCode": "test-tenant",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
}, nil)
|
||||
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["id"] == "dyddus1210"
|
||||
}), "").Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"id": "dyddus1210",
|
||||
"email": "dyddus1210@gmail.com",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
mockOry.On("UpdateUserPassword", "dyddus1210", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"password": "asdfzxcv1234!",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
mockOry.AssertExpectations(t)
|
||||
mockKratos.AssertNotCalled(t, "UpdateIdentityPassword", mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateUser_PasswordFallsBackToEmail(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockKratos := new(MockKratosAdmin)
|
||||
mockOry := new(MockOryProvider)
|
||||
mockTenant := new(MockTenantServiceForUser)
|
||||
h := &UserHandler{
|
||||
KratosAdmin: mockKratos,
|
||||
OryProvider: mockOry,
|
||||
TenantService: mockTenant,
|
||||
}
|
||||
|
||||
app.Put("/users/:id", func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
|
||||
return h.UpdateUser(c)
|
||||
})
|
||||
|
||||
userID := "u-2"
|
||||
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "dyddus1210@gmail.com",
|
||||
"companyCode": "test-tenant",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
|
||||
ID: "t-1",
|
||||
Slug: "test-tenant",
|
||||
}, nil)
|
||||
mockTenant.On("ListManageableTenants", mock.Anything, userID).Return([]domain.Tenant{}, nil).Once()
|
||||
|
||||
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
|
||||
return traits["email"] == "dyddus1210@gmail.com"
|
||||
}), "").Return(&service.KratosIdentity{
|
||||
ID: userID,
|
||||
Traits: map[string]interface{}{
|
||||
"email": "dyddus1210@gmail.com",
|
||||
},
|
||||
}, nil).Once()
|
||||
|
||||
mockOry.On("UpdateUserPassword", "dyddus1210@gmail.com", "asdfzxcv1234!", (*http.Request)(nil)).Return(nil).Once()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"password": "asdfzxcv1234!",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("PUT", "/users/"+userID, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
mockOry.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUserHandler_CreateUser_LoginIDSync(t *testing.T) {
|
||||
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
|
||||
app := fiber.New()
|
||||
|
||||
@@ -11,14 +11,20 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type KratosIdentity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
SchemaID string `json:"schema_id,omitempty"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
MetadataAdmin interface{} `json:"metadata_admin,omitempty"`
|
||||
MetadataPublic interface{} `json:"metadata_public,omitempty"`
|
||||
ExternalID string `json:"external_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type KratosAdminService interface {
|
||||
@@ -172,20 +178,54 @@ func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID stri
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
patchOps := []map[string]interface{}{
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/credentials/password/config/password",
|
||||
"value": newPassword,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(patchOps)
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
|
||||
identity, err := s.GetIdentity(ctx, identityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
if identity == nil {
|
||||
return fmt.Errorf("kratos admin identity not found: %s", identityID)
|
||||
}
|
||||
|
||||
hashedPassword, err := hashPasswordForKratosAdmin(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"schema_id": identity.SchemaID,
|
||||
"traits": identity.Traits,
|
||||
"state": identity.State,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"config": map[string]string{
|
||||
"hashed_password": hashedPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if payload["schema_id"] == "" {
|
||||
payload["schema_id"] = "default"
|
||||
}
|
||||
if payload["state"] == "" {
|
||||
payload["state"] = "active"
|
||||
}
|
||||
if identity.MetadataAdmin != nil {
|
||||
payload["metadata_admin"] = identity.MetadataAdmin
|
||||
}
|
||||
if identity.MetadataPublic != nil {
|
||||
payload["metadata_public"] = identity.MetadataPublic
|
||||
}
|
||||
if identity.ExternalID != "" {
|
||||
payload["external_id"] = identity.ExternalID
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
@@ -199,6 +239,14 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashPasswordForKratosAdmin(password string) (string, error) {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hashed), nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
|
||||
@@ -711,20 +713,53 @@ func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Re
|
||||
return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
||||
}
|
||||
|
||||
patchOps := []map[string]interface{}{
|
||||
{
|
||||
"op": "add",
|
||||
"path": "/credentials/password/config/password",
|
||||
"value": newPassword,
|
||||
},
|
||||
identity, err := o.getIdentity(identityID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: load identity failed: %w", err)
|
||||
}
|
||||
if identity == nil {
|
||||
return fmt.Errorf("ory provider: identity payload missing for loginID=%s", loginID)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(patchOps)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||
hashedPassword, err := hashPasswordForKratos(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: hash password failed: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"schema_id": identity.SchemaID,
|
||||
"traits": identity.Traits,
|
||||
"state": identity.State,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"config": map[string]string{
|
||||
"hashed_password": hashedPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if payload["schema_id"] == "" {
|
||||
payload["schema_id"] = "default"
|
||||
}
|
||||
if payload["state"] == "" {
|
||||
payload["state"] = "active"
|
||||
}
|
||||
if identity.MetadataAdmin != nil {
|
||||
payload["metadata_admin"] = identity.MetadataAdmin
|
||||
}
|
||||
if identity.MetadataPublic != nil {
|
||||
payload["metadata_public"] = identity.MetadataPublic
|
||||
}
|
||||
if identity.ExternalID != "" {
|
||||
payload["external_id"] = identity.ExternalID
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ory provider: build request failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
@@ -789,6 +824,41 @@ func (o *OryProvider) findIdentityID(loginID string) (string, error) {
|
||||
return identities[0].ID, nil
|
||||
}
|
||||
|
||||
func (o *OryProvider) getIdentity(identityID string) (*KratosIdentity, error) {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := o.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("ory provider: get identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var identity KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
func hashPasswordForKratos(password string) (string, error) {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(hashed), nil
|
||||
}
|
||||
|
||||
func (o *OryProvider) httpClient() *http.Client {
|
||||
if o.HTTPClient != nil {
|
||||
return o.HTTPClient
|
||||
|
||||
@@ -45,18 +45,38 @@ func TestUpdateUserPassword_Success(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
||||
q := r.URL.Query()
|
||||
if got := q.Get("credentials_identifier"); got != loginID {
|
||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
||||
if r.URL.Path == "/admin/identities" {
|
||||
q := r.URL.Query()
|
||||
if got := q.Get("credentials_identifier"); got != loginID {
|
||||
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": identityID},
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": identityID},
|
||||
if r.URL.Path != "/admin/identities/"+identityID {
|
||||
t.Fatalf("unexpected identity lookup path: %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": identityID,
|
||||
"schema_id": "default",
|
||||
"state": "active",
|
||||
"traits": map[string]interface{}{
|
||||
"email": loginID,
|
||||
},
|
||||
})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch:
|
||||
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPut:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if !strings.Contains(string(body), newPassword) {
|
||||
t.Fatalf("payload missing new password, body=%s", string(body))
|
||||
if !strings.Contains(string(body), "\"hashed_password\"") {
|
||||
t.Fatalf("payload missing hashed_password, body=%s", string(body))
|
||||
}
|
||||
if strings.Contains(string(body), newPassword) {
|
||||
t.Fatalf("payload must not contain plain password, body=%s", string(body))
|
||||
}
|
||||
if !strings.Contains(string(body), "\"schema_id\":\"default\"") {
|
||||
t.Fatalf("payload missing schema_id, body=%s", string(body))
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
@@ -99,11 +119,25 @@ func TestUpdateUserPassword_ServerError(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": "abc"},
|
||||
})
|
||||
return
|
||||
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch:
|
||||
if r.URL.Path == "/admin/identities" {
|
||||
_ = json.NewEncoder(w).Encode([]map[string]string{
|
||||
{"id": "abc"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/admin/identities/abc" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"id": "abc",
|
||||
"schema_id": "default",
|
||||
"state": "active",
|
||||
"traits": map[string]interface{}{
|
||||
"email": "user@example.com",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
|
||||
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPut:
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
default:
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const naverSMSMaxBytes = 90
|
||||
|
||||
type SmsServiceImpl struct {
|
||||
accessKey string
|
||||
secretKey string
|
||||
@@ -46,17 +48,11 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
||||
// Naver SENS API requires phone number without '+'
|
||||
sanitizedTo := strings.Replace(to, "+", "", 1)
|
||||
|
||||
reqBody := domain.NaverSmsRequest{
|
||||
Type: "SMS",
|
||||
ContentType: "COMM",
|
||||
CountryCode: "82",
|
||||
From: s.senderPhone,
|
||||
Content: content,
|
||||
Messages: []domain.SmsMessage{
|
||||
{
|
||||
To: sanitizedTo,
|
||||
},
|
||||
},
|
||||
reqBody := buildNaverSmsRequest(s.senderPhone, sanitizedTo, content)
|
||||
if reqBody.Type == "LMS" {
|
||||
slog.Info("[SmsService] Upgrading message type to LMS due to content length",
|
||||
"bytes", len([]byte(content)),
|
||||
)
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
@@ -100,6 +96,29 @@ func (s *SmsServiceImpl) SendSms(to, content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildNaverSmsRequest(senderPhone, sanitizedTo, content string) domain.NaverSmsRequest {
|
||||
requestType := "SMS"
|
||||
subject := ""
|
||||
if len([]byte(content)) > naverSMSMaxBytes {
|
||||
requestType = "LMS"
|
||||
subject = "[Baron 로그인]"
|
||||
}
|
||||
|
||||
return domain.NaverSmsRequest{
|
||||
Type: requestType,
|
||||
ContentType: "COMM",
|
||||
CountryCode: "82",
|
||||
From: senderPhone,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Messages: []domain.SmsMessage{
|
||||
{
|
||||
To: sanitizedTo,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
||||
space := " "
|
||||
newLine := "\n"
|
||||
|
||||
26
backend/internal/service/sms_service_test.go
Normal file
26
backend/internal/service/sms_service_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildNaverSmsRequest_UsesSMSForShortContent(t *testing.T) {
|
||||
req := buildNaverSmsRequest("0262857755", "821012345678", "123456")
|
||||
|
||||
if req.Type != "SMS" {
|
||||
t.Fatalf("expected SMS, got %s", req.Type)
|
||||
}
|
||||
if req.Subject != "" {
|
||||
t.Fatalf("expected empty subject for SMS, got %q", req.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNaverSmsRequest_UsesLMSForLongContent(t *testing.T) {
|
||||
content := "[Baron 로그인] 비밀번호 재설정 링크: http://sso-test.hmac.kr/api/v1/auth/password/reset/v/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
req := buildNaverSmsRequest("0262857755", "821012345678", content)
|
||||
|
||||
if req.Type != "LMS" {
|
||||
t.Fatalf("expected LMS, got %s", req.Type)
|
||||
}
|
||||
if req.Subject == "" {
|
||||
t.Fatal("expected LMS subject to be set")
|
||||
}
|
||||
}
|
||||
@@ -976,7 +976,7 @@ function ClientGeneralPage() {
|
||||
>
|
||||
{t(
|
||||
"ui.dev.clients.general.security.trusted_rp_enable",
|
||||
"Trusted RP (자체 로그인 UI 사용)",
|
||||
"Headless Login (자체 로그인 UI 사용)",
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
@@ -998,7 +998,7 @@ function ClientGeneralPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. Public Key Registration (Trusted RP) */}
|
||||
{/* 4. Public Key Registration (Headless Login) */}
|
||||
{clientType === "pkce" && headlessLoginEnabled && (
|
||||
<Card className="glass-panel border-primary/20">
|
||||
<CardHeader className="pb-3">
|
||||
@@ -1013,7 +1013,7 @@ function ClientGeneralPage() {
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.subtitle",
|
||||
"Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다.",
|
||||
"Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
@@ -390,12 +390,12 @@ subtitle = "Define the permission scopes this application can request."
|
||||
private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers."
|
||||
pkce_help = "PKCE App (SPA/Mobile): For apps that cannot safely store a client secret. PKCE is mandatory."
|
||||
subtitle = "Select application type. Security level determines authentication method."
|
||||
trusted_help = "Operate as a trusted RP using private_key_jwt and public key registration. Headless login is only available for this profile."
|
||||
trusted_help = "Operate as Headless Login using private_key_jwt and public key registration. Headless login is only available for this profile."
|
||||
|
||||
[msg.dev.clients.general.public_key]
|
||||
auth_method_client_secret_basic_help = "Standard authentication method for server-side applications."
|
||||
auth_method_none_help = "Use this for PKCE-based public clients."
|
||||
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for trusted RP bootstrap and JAR verification."
|
||||
auth_method_private_key_jwt_help = "Signed key-based client authentication recommended for Headless Login bootstrap and JAR verification."
|
||||
guide_example = "Recommended example: https://rp.example.com/.well-known/jwks.json"
|
||||
guide_intro = "A JWKS URI is not created by Baron. It is the URL where the RP backend exposes its public key."
|
||||
guide_step_1 = "Generate a key pair on the RP server and keep the private key only in the RP backend."
|
||||
@@ -406,7 +406,7 @@ jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an
|
||||
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
|
||||
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
|
||||
subtitle = "Manage the public key and headless login settings required for trusted RP evaluation."
|
||||
subtitle = "Manage the public key and headless login settings required for Headless Login evaluation."
|
||||
|
||||
[msg.dev.clients.general.public_key.validation]
|
||||
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
|
||||
@@ -1392,9 +1392,9 @@ delete = "Delete"
|
||||
[ui.dev.clients.general.security]
|
||||
private = "Server Side App"
|
||||
pkce = "PKCE"
|
||||
trusted = "Trusted RP"
|
||||
trusted = "Headless Login"
|
||||
title = "Security Settings"
|
||||
trusted_rp_enable = "Trusted RP (Custom Login UI)"
|
||||
trusted_rp_enable = "Headless Login (Custom Login UI)"
|
||||
trusted_rp_enable_help = "Enable this if you want to implement your own login screen within the app instead of using the Baron SSO login page."
|
||||
|
||||
[ui.dev.clients.general.public_key]
|
||||
|
||||
@@ -390,12 +390,12 @@ subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다."
|
||||
pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다."
|
||||
private_help = "Server side App (서버 사이드 앱): Node.js, Java 등 비밀키를 안전하게 보관 가능한 경우 사용합니다."
|
||||
subtitle = "앱 유형을 선택하세요. 보안 수준에 따라 인증 방식이 달라집니다."
|
||||
trusted_help = "private_key_jwt와 공개키 등록을 사용해 trusted RP로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
||||
trusted_help = "private_key_jwt와 공개키 등록을 사용해 Headless Login으로 운영합니다.\nHeadless Login은 이 프로필에서만 사용할 수 있습니다."
|
||||
|
||||
[msg.dev.clients.general.public_key]
|
||||
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
|
||||
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
|
||||
auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
|
||||
auth_method_private_key_jwt_help = "Headless Login bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
|
||||
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
|
||||
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
|
||||
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
|
||||
@@ -406,7 +406,7 @@ jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa
|
||||
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
|
||||
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
|
||||
subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다."
|
||||
subtitle = "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다."
|
||||
|
||||
[msg.dev.clients.general.public_key.validation]
|
||||
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
|
||||
@@ -1393,7 +1393,7 @@ delete = "삭제"
|
||||
private = "Server side App"
|
||||
pkce = "PKCE"
|
||||
title = "보안 설정"
|
||||
trusted_rp_enable = "Trusted RP (자체 로그인 UI 사용)"
|
||||
trusted_rp_enable = "Headless Login (자체 로그인 UI 사용)"
|
||||
trusted_rp_enable_help = "Baron SSO 로그인 창을 거치지 않고 애플리케이션 내의 자체 로그인 화면을 직접 구현하고 싶은 경우 활성화합니다."
|
||||
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
||||
});
|
||||
|
||||
test("pkce trusted rp with inline ssh-rsa key should persist mapped payload", async ({
|
||||
test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
@@ -139,7 +139,7 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
|
||||
await page
|
||||
.getByRole("switch", {
|
||||
name: /Trusted RP \(자체 로그인 UI 사용\)|Trusted RP \(Custom Login UI\)/i,
|
||||
name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i,
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -158,15 +158,20 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.tokenEndpointAuthMethod)
|
||||
.toBe("private_key_jwt");
|
||||
.toBe("none");
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.metadata?.headless_login_enabled)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
() => state.clients[0]?.metadata?.headless_token_endpoint_auth_method,
|
||||
)
|
||||
.toBe("private_key_jwt");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.jwks as {
|
||||
state.clients[0]?.metadata?.headless_jwks as {
|
||||
keys?: Array<{ kty?: string; alg?: string }>;
|
||||
}
|
||||
)?.keys?.[0]?.kty,
|
||||
@@ -176,7 +181,7 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.jwks as {
|
||||
state.clients[0]?.metadata?.headless_jwks as {
|
||||
keys?: Array<{ kty?: string; alg?: string }>;
|
||||
}
|
||||
)?.keys?.[0]?.alg,
|
||||
|
||||
@@ -313,6 +313,10 @@ not_found = "Not Found"
|
||||
update_error = "Failed to User Edit."
|
||||
update_success = "Update Success"
|
||||
password_generated = "A secure password has been generated."
|
||||
password_generated_help = "Generate a temporary password that meets the security policy and apply it immediately."
|
||||
password_manual_required = "Please enter a password."
|
||||
reset_password_help = "Force-reset the user's password and apply either an auto-generated password or a manually entered one."
|
||||
self_password_reset_blocked = "Please change your own password from the UserFront settings page."
|
||||
|
||||
[msg.admin.users.detail.form]
|
||||
field_required = "Required."
|
||||
@@ -1155,6 +1159,11 @@ back = "Back"
|
||||
edit_title = "Edit Title"
|
||||
title = "User Details"
|
||||
generate_password = "Auto Generate"
|
||||
password_mode_generated = "Auto Generate"
|
||||
password_mode_manual = "Manual Entry"
|
||||
password_result_title = "Reset Password"
|
||||
reset_password_apply = "Apply Password"
|
||||
toggle_password_visibility = "Toggle password visibility"
|
||||
|
||||
[ui.admin.users.detail.breadcrumb]
|
||||
section = "Users"
|
||||
|
||||
@@ -685,6 +685,10 @@ not_found = "사용자를 찾을 수 없습니다."
|
||||
update_error = "사용자 수정에 실패했습니다."
|
||||
update_success = "사용자 정보가 수정되었습니다."
|
||||
password_generated = "안전한 비밀번호가 생성되었습니다."
|
||||
password_generated_help = "보안 기준에 맞는 임시 비밀번호를 자동 생성해 즉시 적용합니다."
|
||||
password_manual_required = "비밀번호를 입력해 주세요."
|
||||
reset_password_help = "사용자의 비밀번호를 강제로 재설정하고 자동 생성하거나 직접 입력한 비밀번호를 적용합니다."
|
||||
self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털(UserFront) 설정에서 변경해 주세요."
|
||||
|
||||
[msg.admin.users.list]
|
||||
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
|
||||
@@ -1145,6 +1149,11 @@ back = "목록으로 돌아가기"
|
||||
edit_title = "정보 수정"
|
||||
title = "사용자 상세"
|
||||
generate_password = "자동 생성"
|
||||
password_mode_generated = "자동 생성"
|
||||
password_mode_manual = "수동 입력"
|
||||
password_result_title = "Reset Password"
|
||||
reset_password_apply = "비밀번호 적용"
|
||||
toggle_password_visibility = "비밀번호 표시 전환"
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = "사용자 추가"
|
||||
|
||||
@@ -685,6 +685,10 @@ not_found = ""
|
||||
update_error = ""
|
||||
update_success = ""
|
||||
password_generated = ""
|
||||
password_generated_help = ""
|
||||
password_manual_required = ""
|
||||
reset_password_help = ""
|
||||
self_password_reset_blocked = ""
|
||||
|
||||
[msg.admin.users.list]
|
||||
delete_confirm = ""
|
||||
@@ -1145,6 +1149,11 @@ back = ""
|
||||
edit_title = ""
|
||||
title = ""
|
||||
generate_password = ""
|
||||
password_mode_generated = ""
|
||||
password_mode_manual = ""
|
||||
password_result_title = ""
|
||||
reset_password_apply = ""
|
||||
toggle_password_visibility = ""
|
||||
|
||||
[ui.admin.users.list]
|
||||
add = ""
|
||||
|
||||
@@ -7,32 +7,94 @@ type RequestCapture = {
|
||||
clientLogs: string[];
|
||||
};
|
||||
|
||||
const SIGNIN_PASSWORD_TAB_X = 522;
|
||||
const SIGNIN_TAB_Y = 158;
|
||||
const SIGNIN_LOGIN_ID_X = 640;
|
||||
const SIGNIN_LOGIN_ID_Y = 245;
|
||||
const SIGNIN_PASSWORD_X = 640;
|
||||
const SIGNIN_PASSWORD_Y = 311;
|
||||
const SIGNIN_SUBMIT_X = 640;
|
||||
const SIGNIN_SUBMIT_Y = 381;
|
||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(300);
|
||||
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||
if (await button.count()) {
|
||||
await button.click({ force: true });
|
||||
const placeholder = page.locator('flt-semantics-placeholder');
|
||||
if (await placeholder.count()) {
|
||||
await placeholder.first().click({ force: true });
|
||||
}
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
}
|
||||
|
||||
const RESET_NEW_PASSWORD_X = 640;
|
||||
const RESET_NEW_PASSWORD_Y = 382;
|
||||
const RESET_CONFIRM_PASSWORD_X = 640;
|
||||
const RESET_CONFIRM_PASSWORD_Y = 464;
|
||||
const RESET_SUBMIT_X = 640;
|
||||
const RESET_SUBMIT_Y = 534;
|
||||
type ScreenCoords = {
|
||||
signinPasswordTabX: number;
|
||||
signinTabY: number;
|
||||
signinLoginIdX: number;
|
||||
signinLoginIdY: number;
|
||||
signinPasswordX: number;
|
||||
signinPasswordY: number;
|
||||
signinSubmitX: number;
|
||||
signinSubmitY: number;
|
||||
resetNewPasswordX: number;
|
||||
resetNewPasswordY: number;
|
||||
resetConfirmPasswordX: number;
|
||||
resetConfirmPasswordY: number;
|
||||
resetSubmitX: number;
|
||||
resetSubmitY: number;
|
||||
};
|
||||
|
||||
const desktopCoords: ScreenCoords = {
|
||||
signinPasswordTabX: 522,
|
||||
signinTabY: 158,
|
||||
signinLoginIdX: 640,
|
||||
signinLoginIdY: 245,
|
||||
signinPasswordX: 640,
|
||||
signinPasswordY: 311,
|
||||
signinSubmitX: 640,
|
||||
signinSubmitY: 381,
|
||||
resetNewPasswordX: 640,
|
||||
resetNewPasswordY: 382,
|
||||
resetConfirmPasswordX: 640,
|
||||
resetConfirmPasswordY: 464,
|
||||
resetSubmitX: 640,
|
||||
resetSubmitY: 534,
|
||||
};
|
||||
|
||||
const mobileCoords: ScreenCoords = {
|
||||
signinPasswordTabX: 90,
|
||||
signinTabY: 158,
|
||||
signinLoginIdX: 206,
|
||||
signinLoginIdY: 268,
|
||||
signinPasswordX: 206,
|
||||
signinPasswordY: 334,
|
||||
signinSubmitX: 206,
|
||||
signinSubmitY: 399,
|
||||
resetNewPasswordX: 206,
|
||||
resetNewPasswordY: 382,
|
||||
resetConfirmPasswordX: 206,
|
||||
resetConfirmPasswordY: 464,
|
||||
resetSubmitX: 206,
|
||||
resetSubmitY: 534,
|
||||
};
|
||||
|
||||
function coordsFor(page: Page): ScreenCoords {
|
||||
const viewport = page.viewportSize();
|
||||
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
||||
}
|
||||
|
||||
function isMobileProject(page: Page): boolean {
|
||||
const viewport = page.viewportSize();
|
||||
return (viewport?.width ?? 1280) <= 500;
|
||||
}
|
||||
|
||||
async function clickPasswordTab(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.waitForTimeout(900);
|
||||
const pane = page.locator('flt-glass-pane');
|
||||
await pane.click({
|
||||
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y },
|
||||
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
await pane.click({
|
||||
position: { x: SIGNIN_PASSWORD_TAB_X, y: SIGNIN_TAB_Y },
|
||||
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
@@ -47,6 +109,68 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise<
|
||||
await page.keyboard.type(value);
|
||||
}
|
||||
|
||||
async function fillPasswordLoginForm(
|
||||
page: Page,
|
||||
loginId: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await enableFlutterAccessibility(page);
|
||||
const inputs = page.getByRole('textbox');
|
||||
await inputs.nth(0).fill(loginId);
|
||||
await inputs.nth(1).fill(password);
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId);
|
||||
await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password);
|
||||
}
|
||||
|
||||
async function submitPasswordLogin(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await page.getByRole('button', { name: '로그인' }).click({ force: true });
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: coords.signinSubmitX, y: coords.signinSubmitY },
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function fillResetPasswordForm(page: Page, password: string): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await enableFlutterAccessibility(page);
|
||||
await page
|
||||
.getByRole('textbox', { name: /^새 비밀번호$/ })
|
||||
.fill(password);
|
||||
await page
|
||||
.getByRole('textbox', { name: /^새 비밀번호 확인$/ })
|
||||
.fill(password);
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await fillAt(page, coords.resetNewPasswordX, coords.resetNewPasswordY, password);
|
||||
await fillAt(
|
||||
page,
|
||||
coords.resetConfirmPasswordX,
|
||||
coords.resetConfirmPasswordY,
|
||||
password,
|
||||
);
|
||||
}
|
||||
|
||||
async function submitResetPassword(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true });
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: coords.resetSubmitX, y: coords.resetSubmitY },
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
@@ -186,17 +310,17 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
|
||||
|
||||
test.describe('UserFront WASM password login and reset', () => {
|
||||
test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => {
|
||||
test.skip(
|
||||
isMobileProject(page),
|
||||
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
||||
);
|
||||
const capture: RequestCapture = { clientLogs: [] };
|
||||
await mockAuthApis(page, capture);
|
||||
|
||||
await page.goto('/ko/signin');
|
||||
await clickPasswordTab(page);
|
||||
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com');
|
||||
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'ValidPass1!');
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
|
||||
force: true,
|
||||
});
|
||||
await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!');
|
||||
await submitPasswordLogin(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
|
||||
@@ -210,17 +334,17 @@ test.describe('UserFront WASM password login and reset', () => {
|
||||
});
|
||||
|
||||
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
|
||||
test.skip(
|
||||
isMobileProject(page),
|
||||
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
||||
);
|
||||
const capture: RequestCapture = { clientLogs: [] };
|
||||
await mockAuthApis(page, capture);
|
||||
|
||||
await page.goto('/ko/signin');
|
||||
await clickPasswordTab(page);
|
||||
await fillAt(page, SIGNIN_LOGIN_ID_X, SIGNIN_LOGIN_ID_Y, 'e2e@example.com');
|
||||
await fillAt(page, SIGNIN_PASSWORD_X, SIGNIN_PASSWORD_Y, 'WrongPass1!');
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: SIGNIN_SUBMIT_X, y: SIGNIN_SUBMIT_Y },
|
||||
force: true,
|
||||
});
|
||||
await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!');
|
||||
await submitPasswordLogin(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
await expect
|
||||
@@ -246,17 +370,8 @@ test.describe('UserFront WASM password login and reset', () => {
|
||||
await page.goto('/ko/reset-password?token=reset-token-e2e');
|
||||
await policyLoaded;
|
||||
await page.waitForTimeout(900);
|
||||
await fillAt(page, RESET_NEW_PASSWORD_X, RESET_NEW_PASSWORD_Y, 'ValidPass1!A');
|
||||
await fillAt(
|
||||
page,
|
||||
RESET_CONFIRM_PASSWORD_X,
|
||||
RESET_CONFIRM_PASSWORD_Y,
|
||||
'ValidPass1!A',
|
||||
);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: RESET_SUBMIT_X, y: RESET_SUBMIT_Y },
|
||||
force: true,
|
||||
});
|
||||
await fillResetPasswordForm(page, 'ValidPass1!A');
|
||||
await submitResetPassword(page);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
|
||||
@@ -6,12 +6,50 @@ type ProfileState = {
|
||||
putBodies: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const PROFILE_DEPARTMENT_EDIT_X = 1170;
|
||||
const PROFILE_DEPARTMENT_EDIT_Y = 680;
|
||||
const PROFILE_DEPARTMENT_INPUT_X = 110;
|
||||
const PROFILE_DEPARTMENT_INPUT_Y = 685;
|
||||
const PROFILE_BLUR_X = 200;
|
||||
const PROFILE_BLUR_Y = 260;
|
||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||
if (await button.count()) {
|
||||
await button.click({ force: true });
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
type ProfileCoords = {
|
||||
departmentEditX: number;
|
||||
departmentEditY: number;
|
||||
departmentInputX: number;
|
||||
departmentInputY: number;
|
||||
blurX: number;
|
||||
blurY: number;
|
||||
};
|
||||
|
||||
const desktopCoords: ProfileCoords = {
|
||||
departmentEditX: 1170,
|
||||
departmentEditY: 680,
|
||||
departmentInputX: 110,
|
||||
departmentInputY: 685,
|
||||
blurX: 200,
|
||||
blurY: 260,
|
||||
};
|
||||
|
||||
const mobileCoords: ProfileCoords = {
|
||||
departmentEditX: 350,
|
||||
departmentEditY: 680,
|
||||
departmentInputX: 110,
|
||||
departmentInputY: 685,
|
||||
blurX: 200,
|
||||
blurY: 260,
|
||||
};
|
||||
|
||||
function coordsFor(page: Page): ProfileCoords {
|
||||
const viewport = page.viewportSize();
|
||||
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
||||
}
|
||||
|
||||
function isMobileProject(page: Page): boolean {
|
||||
const viewport = page.viewportSize();
|
||||
return (viewport?.width ?? 1280) <= 500;
|
||||
}
|
||||
|
||||
async function seedTokenLogin(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
@@ -32,26 +70,56 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise<
|
||||
}
|
||||
|
||||
async function openDepartmentEditor(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await enableFlutterAccessibility(page);
|
||||
await page
|
||||
.getByRole('group', { name: '소속 QA' })
|
||||
.getByRole('button', { name: '편집' })
|
||||
.click({ force: true });
|
||||
await page.waitForTimeout(200);
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: PROFILE_DEPARTMENT_EDIT_X, y: PROFILE_DEPARTMENT_EDIT_Y },
|
||||
position: { x: coords.departmentEditX, y: coords.departmentEditY },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await page.getByRole('textbox', { name: '소속' }).blur();
|
||||
await page.waitForTimeout(250);
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.locator('flt-glass-pane').click({
|
||||
position: { x: PROFILE_BLUR_X, y: PROFILE_BLUR_Y },
|
||||
position: { x: coords.blurX, y: coords.blurY },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function submitDepartmentEditor(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await page.getByRole('textbox', { name: '소속' }).press('Enter');
|
||||
await page.waitForTimeout(250);
|
||||
return;
|
||||
}
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function fillDepartmentField(page: Page, value: string): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await page.getByRole('textbox', { name: '소속' }).fill(value);
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await fillAt(page, coords.departmentInputX, coords.departmentInputY, value);
|
||||
}
|
||||
|
||||
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||
await page.route('**/api/v1/**', async (route: Route) => {
|
||||
const request = route.request();
|
||||
@@ -174,7 +242,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Updated');
|
||||
await fillDepartmentField(page, 'QA-Updated');
|
||||
await submitDepartmentEditor(page);
|
||||
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
@@ -201,7 +269,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-Repro');
|
||||
await fillDepartmentField(page, 'QA-Repro');
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
@@ -228,7 +296,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA');
|
||||
await fillDepartmentField(page, 'QA');
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
@@ -246,7 +314,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, '');
|
||||
await fillDepartmentField(page, '');
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
@@ -265,7 +333,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-1');
|
||||
await fillDepartmentField(page, 'QA-1');
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
|
||||
@@ -274,7 +342,7 @@ test.describe('UserFront WASM profile department editing', () => {
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillAt(page, PROFILE_DEPARTMENT_INPUT_X, PROFILE_DEPARTMENT_INPUT_Y, 'QA-2');
|
||||
await fillDepartmentField(page, 'QA-2');
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(2);
|
||||
|
||||
|
||||
@@ -8,11 +8,7 @@ class AuthTokenStoreBackend {
|
||||
AuthTokenStoreBackend({
|
||||
required AuthTokenStorageTarget localTarget,
|
||||
required AuthTokenStorageTarget sessionTarget,
|
||||
}) : _targets = [
|
||||
localTarget,
|
||||
sessionTarget,
|
||||
_MemoryStorageTarget(),
|
||||
];
|
||||
}) : _targets = [localTarget, sessionTarget, _MemoryStorageTarget()];
|
||||
|
||||
static const _tokenKey = 'baron_auth_token';
|
||||
static const _providerKey = 'baron_auth_provider';
|
||||
|
||||
@@ -91,7 +91,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 0);
|
||||
_tabController.addListener(_handleTabSelection);
|
||||
_drySendEnabled =
|
||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||
|
||||
@@ -68,6 +68,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
if (_isLoading) return;
|
||||
if (_formKey.currentState?.validate() != true) return;
|
||||
if ((_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)) {
|
||||
@@ -76,6 +77,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
bool isSuccess = false;
|
||||
|
||||
try {
|
||||
await AuthProxyService.completePasswordReset(
|
||||
@@ -84,6 +86,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
newPassword: _passwordController.text,
|
||||
);
|
||||
|
||||
isSuccess = true;
|
||||
if (mounted) {
|
||||
ToastService.success(tr('msg.userfront.reset.success'));
|
||||
context.go(buildLocalizedSigninPath(Uri.base));
|
||||
@@ -98,7 +101,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
if (mounted && !isSuccess) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:logging/logging.dart';
|
||||
import 'package:userfront/i18n.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/i18n/locale_utils.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../../../core/ui/toast_service.dart';
|
||||
@@ -54,10 +55,80 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
bool _showCurrentPassword = false;
|
||||
bool _showNewPassword = false;
|
||||
bool _showConfirmPassword = false;
|
||||
Map<String, dynamic>? _passwordPolicy;
|
||||
bool _isPasswordPolicyLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPasswordPolicy();
|
||||
}
|
||||
|
||||
Future<void> _loadPasswordPolicy() async {
|
||||
setState(() {
|
||||
_isPasswordPolicyLoading = true;
|
||||
});
|
||||
try {
|
||||
final policy = await AuthProxyService.fetchPasswordPolicy();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_passwordPolicy = policy;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 정책 조회 실패 시 기본 검증 규칙 사용
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPasswordPolicyLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _buildPasswordPolicyDescription() {
|
||||
if (_isPasswordPolicyLoading) {
|
||||
return tr('msg.userfront.signup.policy.loading');
|
||||
}
|
||||
|
||||
final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _passwordPolicy?['lowercase'] ?? true;
|
||||
final requiresUpper = _passwordPolicy?['uppercase'] ?? false;
|
||||
final requiresNumber = _passwordPolicy?['number'] ?? true;
|
||||
final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>[
|
||||
tr(
|
||||
'msg.userfront.signup.policy.min_length',
|
||||
params: {'count': '$minLength'},
|
||||
),
|
||||
];
|
||||
if (minTypes > 0) {
|
||||
parts.add(
|
||||
tr(
|
||||
'msg.userfront.signup.policy.min_types',
|
||||
params: {'count': '$minTypes'},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (requiresLower) {
|
||||
parts.add(tr('msg.userfront.signup.policy.lowercase'));
|
||||
}
|
||||
if (requiresUpper) {
|
||||
parts.add(tr('msg.userfront.signup.policy.uppercase'));
|
||||
}
|
||||
if (requiresNumber) {
|
||||
parts.add(tr('msg.userfront.signup.policy.number'));
|
||||
}
|
||||
if (requiresSymbol) {
|
||||
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
||||
}
|
||||
|
||||
return tr(
|
||||
'msg.userfront.signup.policy.summary',
|
||||
params: {'rules': parts.join(", ")},
|
||||
);
|
||||
}
|
||||
|
||||
void _debugLog(
|
||||
@@ -267,6 +338,58 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final minLength = (_passwordPolicy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_passwordPolicy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final hasLower = RegExp(r'[a-z]').hasMatch(newPassword);
|
||||
final hasUpper = RegExp(r'[A-Z]').hasMatch(newPassword);
|
||||
final hasNumber = RegExp(r'[0-9]').hasMatch(newPassword);
|
||||
final hasSymbol = RegExp(r'[\W_]').hasMatch(newPassword);
|
||||
int typeCount = 0;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasNumber) typeCount++;
|
||||
if (hasSymbol) typeCount++;
|
||||
|
||||
if (newPassword.length < minLength) {
|
||||
setState(
|
||||
() => _passwordError = tr(
|
||||
'msg.userfront.reset.error.min_length',
|
||||
params: {'count': '$minLength'},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (minTypes > 0 && typeCount < minTypes) {
|
||||
setState(
|
||||
() => _passwordError = tr(
|
||||
'msg.userfront.reset.error.min_types',
|
||||
params: {'count': '$minTypes'},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ((_passwordPolicy?['lowercase'] ?? true) && !hasLower) {
|
||||
setState(
|
||||
() => _passwordError = tr('msg.userfront.reset.error.lowercase'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ((_passwordPolicy?['uppercase'] ?? false) && !hasUpper) {
|
||||
setState(
|
||||
() => _passwordError = tr('msg.userfront.reset.error.uppercase'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if ((_passwordPolicy?['number'] ?? true) && !hasNumber) {
|
||||
setState(() => _passwordError = tr('msg.userfront.reset.error.number'));
|
||||
return;
|
||||
}
|
||||
if ((_passwordPolicy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
||||
setState(() => _passwordError = tr('msg.userfront.reset.error.symbol'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword != confirmPassword) {
|
||||
setState(
|
||||
() => _passwordError = tr('msg.userfront.profile.password.mismatch'),
|
||||
@@ -645,6 +768,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
}) {
|
||||
final isEditing = _editingField == field;
|
||||
final displayValue = value.isEmpty ? '-' : value;
|
||||
final isCompact = MediaQuery.of(context).size.width < 640;
|
||||
|
||||
if (!isEditing) {
|
||||
return ListTile(
|
||||
@@ -661,57 +785,64 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
|
||||
final hasChanged = _hasFieldChanged(profile, field);
|
||||
|
||||
final inputField = TextField(
|
||||
key: Key('profile-$field-input'),
|
||||
controller: controller,
|
||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _saveField(profile),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: label,
|
||||
errorText: _fieldSaveError,
|
||||
),
|
||||
);
|
||||
final saveButton = ElevatedButton(
|
||||
key: Key('profile-$field-save-button'),
|
||||
onPressed: isUpdating || !hasChanged || _isSavingField
|
||||
? null
|
||||
: () => _saveField(profile),
|
||||
child: _isSavingField
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(tr('ui.common.save')),
|
||||
);
|
||||
final cancelButton = OutlinedButton(
|
||||
key: Key('profile-$field-cancel-button'),
|
||||
onPressed: isUpdating || _isSavingField
|
||||
? null
|
||||
: () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: Key('profile-$field-input'),
|
||||
controller: controller,
|
||||
focusNode: field == 'name' ? _nameFocus : _departmentFocus,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _saveField(profile),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_fieldSaveError = null;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: label,
|
||||
errorText: _fieldSaveError,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
key: Key('profile-$field-save-button'),
|
||||
onPressed: isUpdating || !hasChanged || _isSavingField
|
||||
? null
|
||||
: () => _saveField(profile),
|
||||
child: _isSavingField
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(tr('ui.common.save')),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
key: Key('profile-$field-cancel-button'),
|
||||
onPressed: isUpdating || _isSavingField
|
||||
? null
|
||||
: () => _cancelEditing(profile),
|
||||
child: Text(tr('ui.common.cancel')),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isCompact) ...[
|
||||
inputField,
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: [saveButton, cancelButton]),
|
||||
] else
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: inputField),
|
||||
const SizedBox(width: 12),
|
||||
saveButton,
|
||||
const SizedBox(width: 8),
|
||||
cancelButton,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -853,6 +984,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
tr('msg.userfront.profile.password.subtitle'),
|
||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_buildPasswordPolicyDescription(),
|
||||
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _currentPasswordController,
|
||||
|
||||
@@ -5,7 +5,9 @@ void main() {
|
||||
group('AuthTokenStoreBackend', () {
|
||||
test('local 저장소가 실패하면 session 저장소에서 토큰을 읽는다', () {
|
||||
final local = _FakeTarget(throwsOnRead: true);
|
||||
final session = _FakeTarget(readSeed: {'baron_auth_token': 'session-jwt'});
|
||||
final session = _FakeTarget(
|
||||
readSeed: {'baron_auth_token': 'session-jwt'},
|
||||
);
|
||||
final store = AuthTokenStoreBackend(
|
||||
localTarget: local,
|
||||
sessionTarget: session,
|
||||
|
||||
Reference in New Issue
Block a user