feat: 로컬 계정 생성 기능 추가; 환경 설정에 따라 계정 생성 가능 여부 제어

This commit is contained in:
2026-01-17 15:20:59 +00:00
parent 1a03252782
commit 43dd42d928
6 changed files with 193 additions and 0 deletions
+61
View File
@@ -6,6 +6,7 @@ import { z } from 'zod';
import { procedure, router } from './trpc.js';
import { listScenarioPreviews, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
import { toPublicUser } from './auth/userRepository.js';
import type { AdminAuthContext } from './adminAuth.js';
import type { GatewayApiContext } from './context.js';
import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES } from './orchestrator/profileRepository.js';
@@ -41,6 +42,7 @@ const ADMIN_ROLE_PREFIX = 'admin.';
const ADMIN_ROLE_SUPERUSER = 'admin.superuser';
const ROLE_SUPERUSER = 'superuser';
const ROLE_ADMIN_USERS = 'admin.users.manage';
const ROLE_ADMIN_USERS_CREATE = 'admin.users.create';
const ROLE_ADMIN_PROFILES = 'admin.profiles.manage';
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
const ROLE_RESET_SCHEDULE = 'admin.reset.schedule';
@@ -142,6 +144,20 @@ const assertPermission = (adminAuth: AdminAuthContext, permission: string, profi
});
};
const canCreateLocalUser = (adminAuth: AdminAuthContext): boolean =>
hasScopedPermission(adminAuth, ROLE_ADMIN_USERS_CREATE) || hasScopedPermission(adminAuth, ROLE_ADMIN_USERS);
// 로컬 계정 임의 생성은 환경 설정이 켜져 있을 때만 허용한다.
const assertLocalAccountEnabled = (ctx: GatewayApiContext): void => {
if (ctx.adminLocalAccountEnabled) {
return;
}
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Local account provisioning is disabled.',
});
};
const adminProcedure = procedure.use(async ({ ctx, next }) => {
const adminAuth = await resolveAdminAuth(ctx as GatewayApiContext);
return next({
@@ -164,6 +180,17 @@ const userAdminProcedure = adminProcedure.use(({ ctx, next }) => {
return next();
});
const userCreateProcedure = adminProcedure.use(({ ctx, next }) => {
const adminAuth = requireAdminAuth(ctx);
if (!canCreateLocalUser(adminAuth)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Permission denied.',
});
}
return next();
});
const profileAdminProcedure = adminProcedure.use(({ ctx, next }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_PROFILES);
@@ -198,6 +225,12 @@ const zSanctionsPatch = z.object({
serverRestrictions: z.record(z.string(), zServerRestriction.nullable()).nullable().optional(),
});
const zLocalAccountInput = z.object({
username: z.string().min(2).max(32),
password: z.string().min(6).max(128),
displayName: z.string().min(2).max(40).optional(),
});
const zInstallAutorun = z.object({
limitMinutes: z.number().int().min(0).max(43200),
options: z.array(z.enum(AUTORUN_USER_OPTIONS)),
@@ -339,6 +372,34 @@ export const adminRouter = router({
}),
}),
users: router({
getLocalAccountStatus: adminProcedure.query(({ ctx }) => ({
enabled: (ctx as GatewayApiContext).adminLocalAccountEnabled,
})),
createLocal: userCreateProcedure.input(zLocalAccountInput).mutation(async ({ ctx, input }) => {
const gatewayCtx = ctx as GatewayApiContext;
assertLocalAccountEnabled(gatewayCtx);
const existing = await gatewayCtx.users.findByUsername(input.username);
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Username already exists.',
});
}
try {
const created = await gatewayCtx.users.createUser({
username: input.username,
password: input.password,
displayName: input.displayName,
});
return { user: toPublicUser(created) };
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Username already exists.',
cause: error,
});
}
}),
lookup: userAdminProcedure.input(zUserLookupInput).query(async ({ ctx, input }) => {
const user = input.id
? await ctx.users.findById(input.id)
+2
View File
@@ -16,6 +16,7 @@ export interface GatewayApiConfig {
kakaoAdminKey?: string;
kakaoRedirectUri: string;
publicBaseUrl: string;
adminLocalAccountEnabled: boolean;
orchestratorEnabled: boolean;
orchestratorReconcileIntervalMs: number;
orchestratorScheduleIntervalMs: number;
@@ -80,6 +81,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
kakaoAdminKey: env.KAKAO_ADMIN_KEY,
kakaoRedirectUri,
publicBaseUrl,
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
orchestratorEnabled: parseBooleanWithFallback(env.GATEWAY_ORCHESTRATOR_ENABLED, false),
orchestratorReconcileIntervalMs: parseNumberWithFallback(
env.GATEWAY_ORCHESTRATOR_RECONCILE_MS,
+3
View File
@@ -18,6 +18,7 @@ export interface GatewayApiContext {
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
adminLocalAccountEnabled: boolean;
profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
@@ -35,6 +36,7 @@ export const createGatewayApiContext = (options: {
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
adminLocalAccountEnabled: boolean;
profiles: GatewayProfileRepository;
orchestrator: GatewayOrchestratorHandle;
profileStatus: GatewayProfileStatusService;
@@ -49,6 +51,7 @@ export const createGatewayApiContext = (options: {
kakaoClient: options.kakaoClient,
oauthSessions: options.oauthSessions,
publicBaseUrl: options.publicBaseUrl,
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
profiles: options.profiles,
orchestrator: options.orchestrator,
profileStatus: options.profileStatus,
+1
View File
@@ -75,6 +75,7 @@ export const createGatewayApiServer = async () => {
kakaoClient,
oauthSessions,
publicBaseUrl: config.publicBaseUrl,
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
profiles,
orchestrator,
profileStatus,
+1
View File
@@ -82,6 +82,7 @@ const buildCaller = () => {
kakaoClient: kakaoClient as unknown as KakaoOAuthClient,
oauthSessions,
publicBaseUrl: 'http://localhost',
adminLocalAccountEnabled: false,
profiles,
orchestrator,
profileStatus,
@@ -53,6 +53,14 @@ type AdminUser = {
createdAt: string;
};
type AdminPublicUser = {
id: string;
username: string;
displayName: string;
roles: string[];
createdAt: string;
};
type AdminProfile = {
profileName: string;
profile: string;
@@ -133,6 +141,16 @@ type AdminClient = {
};
};
users: {
getLocalAccountStatus: {
query: () => Promise<{ enabled: boolean }>;
};
createLocal: {
mutate: (input: {
username: string;
password: string;
displayName?: string;
}) => Promise<{ user: AdminPublicUser }>;
};
lookup: {
query: (input: { id?: string; username?: string; email?: string }) => Promise<AdminUser | null>;
};
@@ -297,6 +315,16 @@ const userLoading = ref(false);
const userError = ref('');
const userResult = ref<AdminUser | null>(null);
const localAccountEnabled = ref(false);
const localAccountStatus = ref('');
const localAccountResult = ref('');
const localAccountLoading = ref(false);
const localAccountForm = ref({
username: '',
password: '',
displayName: '',
});
const passwordInput = ref('');
const passwordResult = ref('');
const passwordStatus = ref('');
@@ -322,6 +350,18 @@ const forceDeleteStatus = ref('');
const hasUser = computed(() => Boolean(userResult.value));
const loadLocalAccountStatus = async () => {
localAccountStatus.value = '';
try {
const result = await adminClient.users.getLocalAccountStatus.query();
localAccountEnabled.value = result.enabled;
localAccountStatus.value = result.enabled ? '' : 'ENV 설정이 비활성화 상태입니다.';
} catch (error) {
localAccountEnabled.value = false;
localAccountStatus.value = '로컬 계정 생성 설정 확인 실패';
}
};
const loadNotice = async () => {
noticeLoading.value = true;
try {
@@ -908,7 +948,46 @@ const forceDeleteUser = async () => {
}
};
const createLocalAccount = async () => {
localAccountStatus.value = '';
localAccountResult.value = '';
if (!localAccountEnabled.value) {
localAccountStatus.value = 'ENV 설정이 비활성화 상태입니다.';
return;
}
const username = localAccountForm.value.username.trim();
const password = localAccountForm.value.password.trim();
const displayName = localAccountForm.value.displayName.trim();
if (!username || !password) {
localAccountStatus.value = '아이디와 비밀번호를 입력하세요.';
return;
}
localAccountLoading.value = true;
try {
const result = await adminClient.users.createLocal.mutate({
username,
password,
displayName: displayName || undefined,
});
localAccountResult.value = `생성됨: ${result.user.username} (${result.user.id})`;
localAccountStatus.value = '로컬 계정 생성 완료';
localAccountForm.value = {
username: result.user.username,
password: '',
displayName: '',
};
userLookupMode.value = 'username';
userLookupValue.value = result.user.username;
await lookupUser();
} catch (error) {
localAccountStatus.value = '로컬 계정 생성 실패';
} finally {
localAccountLoading.value = false;
}
};
onMounted(() => {
void loadLocalAccountStatus();
void loadNotice();
void loadProfiles();
});
@@ -992,6 +1071,52 @@ onMounted(() => {
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-base font-semibold">로컬 계정 생성</h4>
<span class="text-xs text-zinc-500">
ENV {{ localAccountEnabled ? 'ON' : 'OFF' }}
</span>
</div>
<div class="text-xs text-zinc-500">
카카오 OAuth 없이 로그인 가능한 계정을 생성합니다.
</div>
<div class="grid gap-2">
<input
v-model="localAccountForm.username"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="아이디"
:disabled="!localAccountEnabled || localAccountLoading"
/>
<input
v-model="localAccountForm.password"
type="password"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="비밀번호"
:disabled="!localAccountEnabled || localAccountLoading"
/>
<input
v-model="localAccountForm.displayName"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="표시명 (선택)"
:disabled="!localAccountEnabled || localAccountLoading"
/>
<button
class="bg-cyan-600 hover:bg-cyan-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!localAccountEnabled || localAccountLoading"
@click="createLocalAccount"
>
계정 생성
</button>
</div>
<div class="text-xs text-zinc-500">{{ localAccountStatus }}</div>
<div v-if="localAccountResult" class="text-xs text-emerald-400">
{{ localAccountResult }}
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">비밀번호 리셋</h4>
<div class="flex flex-col md:flex-row gap-2">