콘텐츠로 이동

세션 인증#

Django Ninja 입문 가이드

Django Ninja 입문 가이드의 28번째 글이에요.

7장에 오신 것을 환영해요! 이번 장은 총 두 편의 글로 이루어져 있어요:

  • 28편: 사용자 인증 - Session 인증과 전역 설정
  • 29편: 단위 테스트 - Test Client와 pytest를 이용한 API 테스트

이 주제들의 핵심 기능은 Django Ninja에 의해 구현된 것은 아니지만, 프레임워크가 어느 정도의 통합 기능을 제공해요. 그리고 이 기능들은 어떤 Django 프로젝트에서든 아주 중요하답니다.

이 글에서는 거의 모든 API 프로젝트에 필요한 사용자 인증(Authentication)에 대해 소개할게요.

Django Ninja에서 Django에 내장된 session 기반 인증을 활용하여 완전한 로그인 인증 기능을 구현하는 방법을 알아보고, 더 나아가 코드 중복을 줄이기 위해 전역 인증을 설정하는 방법도 설명해 드릴게요.

이번 글의 모든 코드 변경 사항은 이 PR을 참고해 주세요.

GitHub 예제 프로젝트#

👉 Django-Ninja-Tutorial


인증의 두 가지 계층#

구현에 들어가기 전에, 소위 말하는 사용자 인증이 정확히 무엇을 의미하는지부터 이해해야 해요.

"아이디/비밀번호 + session 인증"을 예로 들면, 사용자 인증의 범위는 크게 두 단계로 나뉘어요.

첫째, 사용자가 아이디와 비밀번호를 통해 로그인을 시도할 때, 시스템은 이 내용을 확인하여 신원이 합법적인지 검사해요. 로그인에 성공하면 시스템은 사용자 정보(예: 사용자 id)를 session에 저장하여 로그인 상태를 유지해요.

이것이 로그인 시점의 인증이며, 우리가 가장 흔히 말하는 인증이에요. (좁은 의미의 인증)

그다음, 사용자가 "인증으로 보호된" API에 접근을 시도할 때, 시스템은 session을 확인하여 신원을 검증함으로써 모든 API 요청이 합법적으로 로그인한 사용자로부터 왔는지 확인해요.

간단히 말해서:

  • 1단계: 최초 로그인 시 신원 확인.
  • 2단계: 이후 요청 시 신원 확인.

이 두 계층은 서로를 보완하며 동전의 양면처럼 작동하여, 사용자가 로그인하고 후속 작업을 수행하는 동안 서비스가 적절한 보안을 제공할 수 있도록 보장한답니다.


"사용자 로그인" API 구현#

위의 두 계층을 이해했으니, 먼저 "좁은 의미"의 인증, 즉 로그인 검증 자체를 구현해 볼 차례예요.

"사용자 로그인" API를 만들고, Django의 authenticatelogin 함수를 통해 아이디/비밀번호 검증로그인 상태 저장을 직접 처리할 거예요. 아주 편리하죠!

authenticate는 사용자가 입력한 아이디(username)와 비밀번호가 맞는지 검증하는 데 쓰이고, login은 사용자의 로그인 상태를 session에 저장해요.

코드 구현#

먼저 로그인 요청 Schema를 추가할게요:

# user/schemas.py
class LoginRequest(Schema):
    username: str = Field(examples=['Alice'])
    password: str = Field(examples=['password123'])

그리고 뷰 함수예요:

from django.contrib.auth import authenticate, login

from user.schemas import CreateUserRequest, LoginRequest
...

@router.post('/users/login/', summary='사용자 로그인')
def login_user(
    request: HttpRequest, payload: LoginRequest
) -> dict[str, str]:
    """
    사용자 로그인
    """
    user = authenticate(
        request,
        username=payload.username,
        password=payload.password
    )
    if user is not None:
        login(request, user)  # 사용자의 로그인 상태를 session에 저장
        return {'message': '로그인 성공'}
    else:
        raise HttpError(401, '아이디 또는 비밀번호가 잘못되었습니다')

아주 간단하죠!

참고로 저는 코드에 "불필요한" else가 있는 것을 좋아하지 않아요. 그래서 위 작성 방식은 다소 이상적이지 못해요. else완전히 생략할 수 있거든요.

최신 코드를 보시면 이렇게 바뀐 것을 확인할 수 있어요:

user = authenticate(...)
if user is None:
    raise HttpError(401, '아이디 또는 비밀번호가 잘못되었습니다')

login(request, user)  # 사용자의 로그인 상태를 session에 저장
return {'message': '로그인 성공'}

이런 방식을 Guard Clause(보호절) 또는 Early Return이라고 불러요 (여기서는 return 대신 raise를 썼지만요).

간단한 평가 및 중요한 보충 설명#

authenticatelogin의 사용법은 거의 정해져 있어서 이해하기 쉬워요:

  • authenticate는 검증에 성공하면 해당하는 User 객체를 반환하고, 실패하면 None을 반환해요.
  • login은 무언가를 반환하지는 않지만, requestuser가 필수 매개변수예요.

성공적으로 로그인하면 200 응답과 함께 두 개의 쿠키 세트를 받게 돼요:

이건 API 클라이언트(예: Postman) 사용자에게 아주 중요해요. 웹 브라우저는 자동으로 쿠키를 저장해 주지만, 이런 도구들은 그렇지 않거든요. 아 참, 제가 틀렸네요. 최소한 제가 쓰는 RapidAPI는 자동으로 저장하고 전송해 줘요!

(API를 테스트하면서 "이상하네? 왜 인증 보호가 안 먹히지?"라며 갸우뚱했었죠 🤣)

만약 도구가 자동으로 처리해 주지 않는다면, 요청 headers에 직접 추가해야 한다는 점 잊지 마세요:

POST /users/2/avatar/ HTTP/1.1
...
Cookie: csrftoken=...; sessionid=...
X-CSRFToken: ...

authenticate는 기본적으로 AbstractUserusername 필드와 비밀번호를 기준으로 인증을 수행해요. 만약 email처럼 다른 필드를 사용하고 싶다면 Django의 인증 백엔드를 직접 오버라이딩(overriding)해야 해요.


API에 "인증 보호" 추가하기#

로그인 기능이 완성되었으니, 이제 "로그인해야만 접근할 수 있는" API들에 각각 인증 보호를 추가할 차례예요. 여기서는 Django의 내장 session 인증 전용으로 Django Ninja가 제공하는 django_auth를 사용할 거예요.

"프로필 사진(avatar) 업로드" API를 예로 들어볼게요:

from ninja.security import django_auth
...

@router.post(
    path='/users/{int:user_id}/avatar/',
    summary='avatar 업로드',
    auth=django_auth  # 이 매개변수 추가
)

이 예제에서 auth=django_auth는 오직 "로그인한 사용자"만 이 API에 접근할 수 있도록 보장해요. 그렇지 않으면 401 또는 403 응답을 받게 돼요.


Django Ninja의 request.auth#

그런데 이런 생각이 드실 수도 있어요:

단순히 "로그인했음"만 검증하는 것으로 충분할까?

"avatar 업로드"는 "자신"의 것만 업로드할 수 있어야 하잖아요. "남의" 아바타를 함부로 업로드해 줄 수는 없으니까요!

맞아요, 그래서 우리는 뷰 함수 내부한 단계의 검증을 더 추가해야 해요.

Django의 request.user#

전통적인 Django 프로젝트에서는 함수의 첫 번째 매개변수인 request를 통해 request.user를 사용하여 현재 사용자 정보를 얻곤 했어요. 예를 들면: (공식 문서 참고)

if request.user.is_authenticated:
    # 인증된 사용자를 위한 작업
    ...
else:
    # 익명 사용자를 위한 작업
    ...
구체적으로 말하자면: - 사용자가 로그인한 상태일 때 request.user현재 로그인한 사용자를 나타내는 User 인스턴스가 돼요. - 로그인하지 않았을 때 request.user비로그인 사용자를 나타내는 AnonymousUser 인스턴스예요.

사용자가 로그인한 경우 request.user.id와 같이 request.user의 속성을 확인하여 "본인"인지 확인할 수 있죠.

Django Ninja의 request.auth#

하지만 Django Ninja를 작성할 때는 프레임워크가 제공하는 request.auth를 사용해야 해요. 구현 결과는 다음과 같아요:

...
def upload_avatar(...) -> dict[str, str]:
    """
    avatar 업로드
    """
    # 로그인한 사용자가 "본인"인지 확인
    if request.auth.id != user_id:
        raise HttpError(403, '다른 사용자의 avatar를 업로드할 권한이 없습니다')
    ...

테스트를 해보죠. 로그인한 후 URL path에 다른 사람의 id를 넣어 이 API를 호출하면:

// 403 Forbidden
{
    "detail": "다른 사용자의 avatar를 업로드할 권한이 없습니다"
}

아주 좋네요!


request.auth 분석#

여기서 request.user 대신 request.auth를 사용하긴 했지만, 두 가지가 품고 있는 의미는 아주 달라요.

Django Ninja에서 request.auth인증 프로세스에서 반환된 결과를 의미해요. 게다가 Django Ninja는 여러분이 인증 방식을 커스터마이징할 수 있도록 허용하기 때문에 request.auth의 내용은 고정되어 있지 않아요.

더 깊이 알아볼까요.

인증 결과#

request.auth에는 현재의 인증 방법이 반환한 값이 들어있어요. - 이 값의 타입은 인증 로직을 어떻게 구현했느냐에 따라 어떤 타입이든 될 수 있어요. - 이는 개발자에게 엄청난 유연성을 제공해 주죠. User 객체가 될 수도 있고, 문자열이나 Python 딕셔너리가 될 수도 있어요.

인증 방법과 일반적인 사용 사례#

  • Django의 session 인증을 사용할 때 request.auth는 Django의 User 객체예요.
  • API key 인증의 경우 request.auth는 API key 자체이거나 그와 관련된 정보일 수 있어요.
  • JWT 인증에서는 request.auth가 디코딩된 토큰 정보를 포함할 수 있어요.

결론적으로, 뷰 함수 안에서 한 걸음 더 나아가 인증 정보를 얻고 싶다면 request.auth를 통해야 한다는 점만 기억하시면 돼요.


이렇게 하면 이미 인증 구현은 완료된 거지만 상황을 조금 더 "간단하게" 만들 수 있어요.

전역 인증 설정 및 예외 처리#

각각의 API마다 일일이 인증 보호를 설정하는 건 좀 번거롭게 느껴져요. 특히 API 개수가 많아질수록요.

이에 대응하여 Django Ninja는 전역 인증(Global Authentication)을 지원해요. 모든 API가 기본적으로 보호되도록 하고, 개발자는 특정 라우트에서만 예외 처리를 하여 인증을 적용하고 싶지 않은 API를 제외하기만 하면 되죠.

구현은 아주 간단해요. Django Ninja는 전역 session 기반 인증을 처리하기 위해 SessionAuth 인증 클래스를 바로 제공하고 있거든요.

전역 인증 구현: SessionAuth 사용#

프로젝트의 api.py에 다음 내용을 추가하세요:

# NinjaForum/api.py
from ninja.security import SessionAuth
...

api = NinjaAPI(
    auth=SessionAuth(),  # 전역 인증 설정
    ...
)

이렇게 하면 모든 API가 기본적으로 인증 보호를 갖게 돼요. 그리고 "사용자 로그인"처럼 특정 API에서 이 보호를 제외할 수 있어요:

@router.post(path='/users/login/', summary='사용자 로그인', auth=None)

라우팅 데코레이터에서 authNone으로 정의하면 인증 보호를 해제할 수 있답니다.


인증 보호 테스트#

"인증 보호가 있는" API를 테스트해 볼까요? 로그인하지 않은 상태에서 서로 다른 HTTP 메서드의 API를 호출해 보면 다른 에러 응답이 돌아오는 것을 확인할 수 있어요:

  • GET: 401 Unauthorized
  • POST: 403 Forbidden

그래서 앞서 "401 또는 403" 응답을 받게 될 거라고 말한 거예요.

"모든 사용자 조회" API 테스트#

우리 프로젝트 설계에서는 로그인한 사용자만이 "모든 사용자 조회" API에 접근할 수 있어요.

로그인하지 않은 상태라면 401 응답을 받게 돼요:

// 401 Unauthorized
{
    "detail": "Unauthorized"
}

"게시글 작성" API 테스트#

로그인하지 않으면 "게시글 작성" API에도 접근할 수 없어요. 이건 당연히 무척 합리적인 처사죠. 그렇지 않으면 글에 글쓴이가 없을 테니까요 😅

403 응답을 받게 될 거예요:

// 403 Forbidden
{
    "detail": "CSRF check Failed"
}

여러분은 속으로 "어라? 왜 CSRF check Failed지?"라고 생각하실 수 있어요.

이것은 Django의 CSRF 보호 메커니즘 때문이에요. 우리 API가 POST 메서드를 사용하므로 Django가 자동으로 CSRF 토큰을 검사하는데, 우리가 CSRF 토큰을 제공하지 않았기 때문에 이 에러가 발생하는 거예요.


요약 및 다음 단계#

이 글에서는 Django의 session 인증과 Django Ninja의 통합에 대해 살펴보고, "사용자 로그인" API를 구현했으며 다른 API에 인증 보호를 추가해 보았어요. 마지막으로 전체 프로세스를 더욱 단순하게 만들어주는 전역 인증 설정 방법도 다루었죠.

이 시리즈의 마지막 실습으로 프로젝트를 위한 테스트를 작성해 볼 거예요!

다음 글에서는 test client와 pytest를 사용하여 우리의 Django API를 위한 단위 테스트를 작성하는 방법을 알아볼게요. 이는 기존 기능을 검증하는 데 도움을 줄 뿐만 아니라, 향후 개발 및 리팩터링을 위한 추가적인 안전망을 제공해 준답니다.