· nambak80 Blog 로그인
CodeIgniter4

CodeIgniter 4로 블로그 만들기 #15 — 글 삭제와 작성자 권한

CodeIgniter 4로 블로그 만들기 #15 — 글 삭제와 작성자 권한

지난 회차에서 글 수정을 붙이면서 한 가지를 일부러 미뤄 뒀습니다. 권한입니다. 지금까지의 쓰기 라우트는 session 필터로 "로그인 여부"만 확인했습니다. 그래서 로그인만 하면 남의 글도 수정·삭제 화면에 접근할 수 있는 상태입니다.

이번 회차에서 이 구멍을 메웁니다. 글 삭제 기능을 추가하면서, 동시에 작성자 본인 또는 관리자만 삭제할 수 있도록 컨트롤러 가드를 세웁니다. 인증(로그인했는가)과 인가(그럴 자격이 있는가)는 다른 문제라는 것을 코드로 확인합니다.

이번 회차의 목표

  • POST posts/(:num)/delete 라우트를 로그인 그룹에 추가한다.
  • delete()작성자 본인 또는 admin 그룹만 허용하는 컨트롤러 가드(canModify)를 둔다. 권한이 없으면 403, 성공하면 목록으로 리다이렉트.
  • 상세 화면의 수정/삭제 버튼을 권한 있는 사용자에게만 노출한다.
  • 이번에도 테스트 우선 — 게스트 차단·작성자 삭제·타인 403·관리자 삭제·버튼 노출/숨김까지.

1. 실패하는 테스트부터 — PostDeleteTest

권한 로직은 경우의 수가 많아서 테스트로 못 박아 두는 게 특히 유용합니다. tests/Feature/PostDeleteTest.php에서 확인하고 싶은 계약은 여섯 가지입니다.

  1. 비로그인 사용자는 삭제 불가 (리다이렉트, 글은 남아 있음)
  2. 작성자 본인은 자기 글 삭제 가능
  3. 작성자도 관리자도 아닌 사용자는 삭제 불가 (403)
  4. 관리자는 남의 글도 삭제 가능
  5. 삭제 버튼은 작성자에게 보임
  6. 삭제 버튼은 타인에게 안 보임

준비 코드는 ep14와 같은 골격입니다. 세션 초기화 setUp(), 그리고 사용자를 이름/이메일을 받아 만드는 헬퍼로 확장합니다.

    protected function setUp(): void
    {
        parent::setUp();

        // 앞선 테스트의 로그인 세션이 새 나가지 않도록 비운다.
        $_SESSION = [];
        \Config\Services::resetSingle('session');
        \Config\Services::resetSingle('auth');
    }

    private function makeUser(string $username, string $email): User
    {
        $users = auth()->getProvider();

        $user = new User([
            'username' => $username,
            'email'    => $email,
            'password' => 'secret-password-123',
        ]);
        $users->save($user);

        return $users->findById($users->getInsertID());
    }

    private function makePost(int $userId): int
    {
        $model = model(PostModel::class);
        // 제목/본문에 '삭제'가 들어가면 버튼 노출 검사와 헷갈리므로 피한다.
        $model->insert([
            'user_id' => $userId,
            'title'   => '권한 테스트 글',
            'body'    => '권한 테스트 본문',
            'slug'    => 'post-to-delete',
        ]);

        return $model->getInsertID();
    }

makePost의 주석이 사소해 보여도 중요합니다. 버튼 노출 테스트에서 assertSee('삭제')/assertDontSee('삭제')로 확인할 텐데, 글 제목이나 본문에 '삭제'라는 글자가 들어 있으면 버튼과 구별이 안 됩니다. 그래서 데이터에서 그 단어를 피했습니다.

이제 여섯 계약을 테스트로 옮깁니다.

    public function testGuestCannotDeletePost(): void
    {
        $id = $this->makePost(1);

        $result = $this->call('POST', "posts/{$id}/delete");

        $result->assertRedirect();
        $this->seeInDatabase('posts', ['id' => $id]);
    }

    public function testAuthorCanDeleteOwnPost(): void
    {
        $author = $this->makeUser('author', 'author@example.com');
        $id     = $this->makePost($author->id);

        $result = $this->actingAs($author)->call('POST', "posts/{$id}/delete");

        $result->assertRedirect();
        $this->dontSeeInDatabase('posts', ['id' => $id]);
    }

    public function testNonAuthorCannotDeletePost(): void
    {
        $author = $this->makeUser('author', 'author@example.com');
        $id     = $this->makePost($author->id);
        $other  = $this->makeUser('other', 'other@example.com');

        $result = $this->actingAs($other)->call('POST', "posts/{$id}/delete");

        $result->assertStatus(403);
        $this->seeInDatabase('posts', ['id' => $id]);
    }

    public function testAdminCanDeleteAnyPost(): void
    {
        $author = $this->makeUser('author', 'author@example.com');
        $id     = $this->makePost($author->id);

        $admin = $this->makeUser('admin', 'admin@example.com');
        $admin->addGroup('admin');

        $result = $this->actingAs($admin)->call('POST', "posts/{$id}/delete");

        $result->assertRedirect();
        $this->dontSeeInDatabase('posts', ['id' => $id]);
    }

    public function testDeleteButtonVisibleToAuthor(): void
    {
        $author = $this->makeUser('author', 'author@example.com');
        $this->makePost($author->id);

        $result = $this->actingAs($author)->call('GET', 'posts/post-to-delete');

        $result->assertSee('삭제');
    }

    public function testDeleteButtonHiddenFromNonAuthor(): void
    {
        $author = $this->makeUser('author', 'author@example.com');
        $this->makePost($author->id);
        $other = $this->makeUser('other', 'other@example.com');

        $result = $this->actingAs($other)->call('GET', 'posts/post-to-delete');

        $result->assertDontSee('삭제');
    }

testAdminCanDeleteAnyPost에서 관리자를 만드는 방법을 보세요. Shield는 사용자를 그룹으로 묶는데, $admin->addGroup('admin') 한 줄이면 그 사용자가 admin 그룹에 속합니다. 우리 가드는 이 그룹 여부를 검사해 관리자를 특별 대우할 겁니다.

2. 삭제 라우트 추가

수정 라우트 바로 아래에 삭제를 더합니다. 역시 session 필터 그룹 안입니다.

$routes->group('', ['filter' => 'session'], static function ($routes) {
    $routes->get('posts/new', 'Posts::new');            // 글 작성 폼
    $routes->post('posts', 'Posts::create');            // 글 저장
    $routes->get('posts/(:num)/edit', 'Posts::edit/$1');     // 글 수정 폼
    $routes->post('posts/(:num)', 'Posts::update/$1');       // 글 수정 저장
    $routes->post('posts/(:num)/delete', 'Posts::delete/$1'); // 글 삭제
});

삭제는 상태를 바꾸는 위험한 작업이라 절대 GET으로 두지 않습니다. 링크 클릭이나 크롤러의 GET 요청 한 번에 글이 지워지면 안 되니까요. 반드시 POST + CSRF 토큰을 거치게 합니다.

3. 컨트롤러 — delete()와 canModify() 가드

이번 회차의 심장입니다. app/Controllers/Posts.php에 삭제 메서드와 권한 판단 헬퍼를 추가합니다. 먼저 상단 use에 두 클래스를 더합니다.

use App\Entities\Post;
use CodeIgniter\HTTP\ResponseInterface;

그리고 메서드 본체.

    /**
     * 글을 삭제한다. 작성자 본인 또는 관리자만 가능하다.
     */
    public function delete(int $id): ResponseInterface|RedirectResponse
    {
        $model = model(PostModel::class);
        $post  = $model->find($id);

        if ($post === null) {
            throw PageNotFoundException::forPageNotFound();
        }

        // 컨트롤러 가드: 권한이 없으면 403 으로 막는다.
        if (! $this->canModify($post)) {
            return $this->response->setStatusCode(403, '삭제 권한이 없습니다.');
        }

        $model->delete($id);

        return redirect()->to('posts');
    }

    /**
     * 현재 사용자가 이 글을 수정/삭제할 수 있는지 판단한다.
     * 작성자 본인이거나 admin 그룹이면 true.
     */
    private function canModify(Post $post): bool
    {
        $user = auth()->user();

        if ($user === null) {
            return false;
        }

        return (int) $post->user_id === (int) $user->id
            || $user->inGroup('admin');
    }

흐름을 따라가 봅시다.

  1. 글 존재 확인 — 없으면 404.
  2. 권한 확인canModify()falsesetStatusCode(403)으로 응답을 끊습니다. 여기서 글은 손대지 않습니다.
  3. 삭제 실행 — 가드를 통과한 경우에만 $model->delete($id).
  4. 리다이렉트 — 목록으로 돌려보냅니다.

canModify()의 논리가 이번 회차의 학습 포인트입니다.

return (int) $post->user_id === (int) $user->id
    || $user->inGroup('admin');
  • 작성자 본인: 글의 user_id와 현재 로그인 사용자 id가 같은가. (형변환 (int)로 문자열/정수 불일치를 방지)
  • 또는 관리자: Shield가 제공하는 $user->inGroup('admin')true인가.

둘 중 하나라도 만족하면 허용입니다. $usernull(비로그인)이면 곧장 false를 돌려주는데, 사실 이 경로는 라우트의 session 필터가 먼저 막아 주므로 이중 안전장치입니다.

4. 상세 화면 — 권한 있는 사람에게만 버튼 노출

백엔드가 403으로 막더라도, UI에서 애초에 남의 글에 삭제 버튼을 보여 주면 혼란스럽습니다. app/Views/posts/show.php에서 뷰 차원의 가드도 겁니다.

    <?php // 작성자 본인 또는 관리자에게만 수정/삭제를 노출한다. ?>
    <?php $canModify = auth()->loggedIn()
        && ((int) $post->user_id === (int) auth()->id() || auth()->user()->inGroup('admin')); ?>
    <?php if ($canModify): ?>
        <p class="post-actions">
            <a class="btn btn-ghost" href="<?= site_url('posts/' . $post->id . '/edit') ?>">수정</a>
            <form action="<?= site_url('posts/' . $post->id . '/delete') ?>" method="post"
                  onsubmit="return confirm('정말 삭제하시겠습니까?');" style="display:inline">
                <?= csrf_field() ?>
                <button type="submit" class="btn btn-danger">삭제</button>
            </form>
        </p>
    <?php endif ?>

    <p><a class="nav-link" href="<?= site_url('posts') ?>">← 목록으로</a></p>

뷰의 $canModify 조건은 컨트롤러 canModify()같은 논리입니다(작성자 본인 또는 admin). 삭제 버튼은 <form method="post"> 안에 넣고 csrf_field()를 반드시 포함시킵니다. onsubmitconfirm()은 실수 클릭을 한 번 걸러 주는 브라우저 확인창입니다.

디자인은 이미 app.css에 있는 .btn btn-ghost(수정)와 .btn btn-danger(삭제, 빨강 계열)를 그대로 씁니다.

핵심 개념 — 왜 이렇게 하는가

하나. 인증과 인가는 다른 층이다. session 필터는 "로그인했는가(인증)"만 봅니다. "이 글을 지울 자격이 있는가(인가)"는 그다음 문제입니다. 필터가 인증을 걸러 준 뒤, 컨트롤러 가드가 인가를 판단합니다. 이 둘을 한 층에 뭉뚱그리지 않는 게 안전한 설계입니다.

두울. 방어는 서버에서, UI는 거들 뿐. 뷰에서 버튼을 숨기는 건 UX일 뿐 보안이 아닙니다. 버튼이 없어도 누군가 직접 POST posts/5/delete를 쏠 수 있으니까요. 진짜 방어선은 컨트롤러의 canModify() 403입니다. 뷰의 조건 숨김은 정직한 사용자에게 친절한 화면을 주는 보조 장치라고 생각하세요. 그래서 테스트도 "버튼 노출"과 "실제 403"을 둘 다 검사합니다.

세엣. Shield 그룹으로 역할을 표현한다. 관리자를 하드코딩한 이메일 목록 같은 걸로 판단하지 않고, Shield의 그룹(admin)으로 다룹니다. $user->inGroup('admin') 한 줄이면 되고, 나중에 역할이 늘어도 그룹만 추가하면 됩니다.

곁다리 — 세션 누수 보강

이번 회차에서 새 로그인 테스트들이 들어오면서, 알파벳순으로 앞서 도는 PostDeleteTest가 뒤에 도는 PostStoreTest의 게스트 테스트로 세션을 흘려보내는 잠재적 누수가 드러났습니다. ep14에서 배운 세션 초기화 setUp()PostStoreTest에도 똑같이 보강해 문제를 막았습니다.

    protected function setUp(): void
    {
        parent::setUp();

        $_SESSION = [];
        \Config\Services::resetSingle('session');
        \Config\Services::resetSingle('auth');
    }

인증이 얽힌 Feature 테스트는 "격리"가 생명이라는 걸 다시 확인하는 대목입니다.

마무리 — 커밋과 태그

이번 회차에서 한 일:

  • 실패하는 PostDeleteTest 작성(게스트·작성자·타인 403·관리자·버튼 노출/숨김)
  • 삭제 라우트(POST) 추가
  • delete() + canModify() 가드 구현 — 권한 없으면 403
  • 상세 화면에 권한 기반 수정/삭제 버튼 노출
  • PostStoreTest에 세션 초기화 보강

빨간불 → 초록불을 같은 커밋에 담아 태그를 답니다.

git add .
git commit -m "feat: 글 삭제와 작성자 권한"
git tag ep15

다음 회차

다음 글에서는 플래시 메시지와 삭제 확인을 다듬습니다. 지금은 글을 등록·수정·삭제해도 화면에 아무 안내가 없어 조금 허전합니다. 세션 플래시데이터로 "글이 삭제되었습니다" 같은 1회성 안내를 남기고 공통 레이아웃에서 출력합니다. 되돌릴 수 없는 삭제에는 확인 문구도 더 분명하게 손봅니다.


이번 회차 요약

  • 다루는 파일: tests/Feature/PostDeleteTest.php(생성) / app/Controllers/Posts.php(delete()·canModify() 추가) / app/Config/Routes.php(삭제 라우트) / app/Views/posts/show.php(권한 버튼) / tests/Feature/PostStoreTest.php(세션 초기화 보강)
  • 핵심: 인증(session 필터)과 인가(컨트롤러 가드)는 다른 층. 권한 판단은 (int)$post->user_id === (int)$user->id || $user->inGroup('admin'), 실패 시 403. 뷰의 버튼 숨김은 보조일 뿐 진짜 방어는 서버에서.

다음: 플래시 메시지와 삭제 확인

댓글 0

아직 댓글이 없습니다.

로그인 후 댓글을 남길 수 있습니다.

← 목록으로