슈퍼관리자로 로그인했더니 403이 떴다. 원인을 찾아 고치고 나니, 더 큰 문제가 보였다. 계정을 만드는 방법이 너무 수동이었다.

1. 증상: 로그인은 되는데 화면이 Forbidden

슈퍼관리자 계정으로 로그인하면 성공 직후 /admin으로 이동한다. 그런데 앱 화면 대신 Apache의 기본 Forbidden 페이지가 떴다. CodeIgniter 4가 내는 에러 화면이 아니라는 점이 첫 단서였다. 요청이 애플리케이션까지 가지 못하고 웹 서버에서 막히고 있었다.

2. 원인: public/admin/이 진짜 디렉터리였다

public/admin/ 아래에는 관리자 화면용 정적 파일(css, js)이 들어 있다. 그래서 Apache는 /admin을 라우트가 아니라 실제 디렉터리로 해석했다.

  1. /admin으로 요청이 오면 디렉터리라서 /admin/으로 리다이렉트한다.
  2. 그 폴더에는 index 파일이 없다.
  3. .htaccess에 Options -Indexes가 걸려 있어서 목록도 보여 주지 않고 403을 낸다.

CI4의 admin 라우트 그룹까지 요청이 도달하지 못한 것이다. 라우트는 이미 있었으니 코드는 문제가 없었다.

수정

public/.htaccess 맨 앞에 /admin과 /admin/을 index.php/admin으로 바로 보내는 규칙을 추가했다. /admin/css/*나 /admin/login 같은 다른 경로는 기존 동작 그대로 둔다.

 
 
# 디렉터리 해석보다 먼저, /admin 과 /admin/ 을 프런트 컨트롤러로 보낸다
RewriteRule ^admin/?$ index.php/admin [L,NC,QSA]
 

한계도 분명했다. .htaccess만 바뀌었으니 단위 테스트로는 검증할 수 없다. 배포 후 /admin이 열리는지 직접 봐야 했고, 서버 vhost에 AllowOverride All이 켜져 있어야 규칙이 적용된다. 그래서 PR에 "배포 후 확인 필요"를 적어 두었다.

3. 더 큰 문제: 슈퍼관리자를 만드는 방법

403을 고친 뒤 확인하려고 슈퍼관리자 계정을 다시 만들다가 불편함이 터졌다. 당시 절차는 이랬다.

  1. 비밀번호 해시를 따로 생성한다.
  2. 그 해시를 넣어 INSERT SQL을 직접 작성한다.
  3. 운영 DB에 붙어서 실행한다.

사람이 해시를 만지고 SQL을 손으로 쓰는 절차다. 오타 한 번이면 로그인이 안 되고, 비밀번호가 셸 히스토리나 메모장에 남을 위험도 있다. 그래서 요청이 바뀌었다. "슈퍼관리자 만드는 방식을 php spark로 바꿔줘. 지금은 너무 수동이야."

4. 해결: php spark admin:create-super

 
 
php spark admin:create-super ops@example.com # 신규 발급
php spark admin:create-super ops@example.com --reset # 비밀번호 분실 복구
 

설계 포인트

  • 임시 비밀번호는 한 번만 출력한다. 16자를 random_int 기반(CSPRNG)으로 만들고, DB에는 Argon2id 해시만 저장한다.
  • 첫 로그인 때 비밀번호 변경과 2FA 등록을 강제한다. must_change_password를 켜 둔다. 출력된 임시 비밀번호는 오래 살아남지 않는다.
  • 이미 있는 이메일이면 실패한다. 종료 코드 1을 돌려줘서 스크립트에서도 안전하게 쓸 수 있다.
  • --reset은 분실 복구용이다. 기존 슈퍼관리자의 임시 비밀번호를 다시 발급하고, 로그인 잠금과 Refresh 토큰을 풀어 준다. 2FA 설정은 유지한다. 슈퍼관리자가 아닌 이메일이면 실패한다. 처음 요청 범위 밖이었지만, 비밀번호를 잃을 때마다 SQL을 다시 쓰는 일을 막으려고 넣었다.
  • 로직은 서비스에, 명령은 얇게. 발급과 재발급은 SuperAdminProvisioner 서비스가 맡고, 명령 클래스는 입력을 검증하고 서비스를 호출하기만 한다. 이메일은 FILTER_VALIDATE_EMAIL로 검증하고 소문자로 정규화한 뒤 서비스에 넘긴다. 쿼리는 CI4 Query Builder라 바인딩이 보장된다.

테스트는 서비스 단위로 4개를 추가했고, PHPStan과 CS Fixer도 통과시켰다. 문서(setup.md)의 "첫 슈퍼관리자 계정" 절에서는 해시 생성과 INSERT 안내를 지우고 명령 사용법으로 바꿨다.

5. 덤으로 찾은 버그: 슈퍼관리자에게 보이는 메뉴

처음 로그인 에러를 다시 살펴보니 .htaccess 말고도 원인이 하나 더 있었다. 문제은행 API(/admin/questions)는 기업관리자와 인사담당자 전용이라 슈퍼관리자는 설계상 403(AUTH.PERMISSION.DENIED)을 받는다. 그런데 사이드바는 슈퍼관리자에게도 문제은행, 대시보드, 면접 세션, 설정 메뉴를 그대로 보여 주고 있었다. 누르면 권한 오류가 나는 메뉴가 화면에 있었던 것이다.

  • 레이아웃에서 슈퍼관리자일 때 기업용 메뉴를 숨겼다.
  • 슈퍼관리자가 /admin/platform과 /admin/password 외의 관리자 화면에 들어가면 /admin/platform으로 보낸다. 로그인 직후의 /admin 대시보드도 포함한다.
  • 메뉴가 숨겨지는지 확인하는 테스트를 추가했다.

이 리다이렉트는 JWT 클레임을 클라이언트에서 읽어 판단한다. UX를 위한 보조 장치일 뿐이고, 권한 강제는 서버가 한다. 클라이언트 판단을 보안 경계로 착각하지 않도록 코드에 그렇게 적어 두었다. 슈퍼관리자가 특정 기업의 문제은행을 볼 수 있어야 한다면 API 권한을 푸는 게 아니라 TenantAccess를 거치는 별도 기능으로 만들어야 하는 문제다.

6. 같이 생각해 볼 것: 시더의 production 차단

같은 시기에 다른 서비스에서 비슷한 결정을 한 번 더 했다. 테스트용 시더에 이런 가드가 있었다.

 
 
if (ENVIRONMENT === 'production') {
throw new RuntimeException('테스트 시드 데이터는 production 환경에서 실행할 수 없습니다.');
}
 

운영자가 쓰는 시더를 운영 환경에서 돌려야 했기에 이 가드를 제거했다. 그런데 커밋 보안 리뷰가 바로 "control-regression"으로 지적했다. 사용자가 원한 변경이라 취약점은 아니었지만, 지적 자체는 정확했다. 이 시더는 고객, 견적, 세금계산서 같은 더미 데이터를 [SEED] 마커와 함께 운영 DB에 쌓는다.

슈퍼관리자 발급 명령과 비교하면 선택지가 보인다.

  슈퍼관리자 발급 데이터 시더
운영에서 실행 필요함 필요한 경우가 있음
안전장치 한 번만 출력, 중복 발급 거부, 최초 로그인 시 변경 강제 가드 제거 후에는 없음
실수했을 때 계정 하나, --reset으로 복구 더미 데이터가 운영 DB에 섞임

가드를 완전히 없애는 것보다, 운영에서 정말 돌릴 시더만 허용하는 명시적 장치(예: 별도 운영자 시더 클래스나 --force-production 같은 확인 옵션)를 두는 편이 낫다. 이 부분은 아직 정하지 않았고, 다음 작업으로 남겨 둔다.

7. 정리

  • 로그인 403은 앱 버그가 아니라 정적 디렉터리 때문에 요청이 Apache에서 끝난 문제였다. 에러 화면이 앱 것인지 서버 것인지부터 구분하자.
  • 사람이 해시를 만지고 SQL을 쓰는 절차는 CLI 명령 하나로 바꿀 수 있다. 임시 비밀번호는 한 번만 출력하고, 첫 로그인에서 변경과 2FA를 강제한다.
  • 서버가 막는 권한은 화면에서도 보이지 않게 맞추되, 그 화면 처리를 보안 경계로 삼지 않는다.
  • 운영 환경 가드는 "있다 / 없다"가 아니라 "누가, 어떤 조건에서"로 설계한다.