DRF 이메일, 소셜 로그인

DRF 이메일, 소셜 로그인

Category
Published
August 28, 2024
Last updated
Last updated September 7, 2024
💡
이 포스트는 현재 작성중입니다

개요

해당 글은 Django REST framework에서 dj-rest-auth와 django-allauth 라이브러리를 이용해서 이메일 기반 로그인과, 소셜 로그인을 구현하는 방법을 소개합니다.

목표

해당 글에서의 구현 목표는, 기존 username과 password를 이용하여 인증하던 User 모델을 email와 password를 사용하게 변경하고, 추가적으로 소셜 로그인을 구현하는 것이다.
그리고 이메일 verification을 위해 이메일 주소 인증을 위해 이메일을 발송하는 것을 목표로 한다.
이 모든것을 DRF와 다른 라이브러리를 이용하여 REST API로 제공할 것이다.

라이브러리 소개

dj-rest-auth

django-allauth

Spring으로 치면 Spring Security와 비슷한 기능을 제공해준다.

simple-jwt

django-ses

Step 1. User 모델 수정

Django에서 기본적으로 주어지는 User 모델을 확장하는 방법은 크게 4가지로 나누어 볼 수 있다.
  • Proxy Model을 이용
Proxy Model은 장고에서 테이블에 컬럼(필드)의 추가 없이, 모델의 동작 변경이나 새로운 메소드를 추가할 수 있는 방법이다. 기존 User 모델의 컬럼을 변경하지는 못하기에 Django 기본 User 모델에서 크게 벗어나지 않는 경우에 사용 가능하다.
  • User model과 One-to-one 관계의 모델 생성
이 역시 기존 User 모델을 건드리지 않는 방식이다. Profile과 같은 모델을 만들어 User 모델과 1대1 연결 해 추가적인 필드를 정의할 수 있다. 이때 User 생성/삭제 시 연결된 1대1 모델의 자동 생성/삭제를 위해 signal을 사용할 수 있다.
  • AbstractUser 상속
AbstractUser를 상속받는 새로운 유저 모델을 만들어, 기존 User 모델을 대체하는 방법이다. 기존 User 모델은 AbstractUser와 거의 동일하기에, 기존 User 모델의 기능을 그대로 가지고 필요한 기능을 추가할 수 있다.
  • AbstractBaseUser 상속
AbstractUser를 상속받는 것과 비슷하나, passwordlast_login 필드 외의 기능이 주어지지 않아 기초적인 기능들을 모두 다시 구현해야 한다.
 
이때 기존 유저 모델 자체를 바꾸는 방법(AbstractBaseUser, AbstractUser 방식)을 선택한다면, settings.pyAUTH_USER_MODEL값을 설정하여 기본 User 모델을 오버라이딩 할 수 있다.
AUTH_USER_MODEL = "user.User"
settings.py
💡
이렇게 AUTH_USER_MODEL를 재지정할 때 DB의 외래키와 N:M관계에 영향을 많이 주기 때문에 마이그레이션하기 까다로워진다. 그렇기에 해당 방식을 사용한다면 프로젝트 초기에 최초 마이그레이션을 하기 전에 진행해주는 것이 좋다. 참고자료

UserManager, BaseUserManager

Django에서는 유저 생성 시에 사용하는 UserManager라는 헬퍼 클래스가 존재한다. 해당 매니저는 유저 생성 시 emailusername normalize, 관리자 계정 생성, 비밀번호 해싱등을 도와준다.
그렇기에 위에서 User를 커스터마이징 해서 사용한다면, BaseUserManager를 상속하는 커스텀 유저 매니저를 만들어 사용해야 할 수도 있다.
User 모델과 관련한 추가적인 사항은 아래 레퍼런스를 참고하자.

Reference

실습

해당 글에서는 AbstractUser를 상속받아 커스텀 User 모델을 만들고, User와 1:1 관계인 Profile 모델을 만들어 닉네임등의 정보를 저장할 것이다.
from django.contrib.auth.models import AbstractUser, BaseUserManager from django.db.models.signals import post_save from django.dispatch import receiver class User(AbstractUser): # email을 unique로 설정한다. email = models.EmailField(("email address"), unique=True) # username을 email로 대체할 예정이기에 제거한다. 나머지 필드도 미사용할 예정이기에 제거한다. username = None first_name = None last_name = None # 기본 username 필드를 email로 대체한다. USERNAME_FIELD = "email" # email을 새롭게 USERNAME_FIELD로 지정하면, REQUIRED_FILEDS를 비워줘야 한다. REQUIRED_FIELDS = [] # default: REQUIRED_FIELDS = ["email"] # 커스텀 유저 매니저를 사용한다 objects = UserManager() def __str__(self): return self.email class Profile(models.Model): # User와 1:1 관계인 모델을 추가한다. user = models.OneToOneField(User, on_delete=models.CASCADE) nickname = models.CharField(max_length=20) def __str__(self): return self.nickname # 유저 생성/저장시 프로필도 자동으로 생성/저장해주는 시그널들. @receiver(post_save, sender=User) def create_profile(sender, instance, created, **kwargs): if created and not hasattr(instance, "profile"): Profile.objects.create(user=instance) @receiver(post_save, sender=User) def save_profile(sender, instance, **kwargs): instance.profile.save() class UserManager(BaseUserManager): # 커스텀 유저 매니저를 정의한다. ...
user/models.py
AUTH_USER_MODEL = "user.User"
settings.py

Step 2. dj-rest-auth로 이메일 로그인 구현

인증 기능등을 모두 Rest API로 제공할 것이기에, DRF에 인증 관련 API를 추가하는 것을 도와주는 dj-rest-auth 라이브러리를 사용할 것이다.
dj-rest-auth는 회원가입과 소셜로그인 기능을 제공할 때는 django-allauth과 연동하여 기능을 제공한다. 그렇기 때문에 django-allauth의 기능과 dj-rest-auth의 기능이 헷갈릴 수 있다.
우선은 회원가입과 소셜로그인을 제외한 dj-rest-auth 라이브러리 만으로 제공 가능한 기능의 사용 방법을 설명한다.

구성

  1. dj-rest-auth를 설치한다.
pip install dj-rest-auth
  1. settings.pyINSTALLED_APPS에 ‘dj_rest_auth’를 추가해준다.
INSTALLED_APPS = ( ..., 'rest_framework', ..., 'dj_rest_auth' )
settings.py
  1. urls.py에 dj_rest_auth urls를 추가한다. 또는 필요한 url만 개별적으로 추가할 수도 있다.
urlpatterns = [ ..., path('dj-rest-auth/', include('dj_rest_auth.urls')) ]
urls.py
 
dj_rest_auth.urls는 다음과 같이 구성되어 있다.
from django.urls import path from dj_rest_auth.app_settings import api_settings from dj_rest_auth.views import ( LoginView, LogoutView, PasswordChangeView, PasswordResetConfirmView, PasswordResetView, UserDetailsView, ) urlpatterns = [ # URLs that do not require a session or valid token path('password/reset/', PasswordResetView.as_view(), name='rest_password_reset'), path('password/reset/confirm/', PasswordResetConfirmView.as_view(), name='rest_password_reset_confirm'), path('login/', LoginView.as_view(), name='rest_login'), # URLs that require a user to be logged in with a valid session / token. path('logout/', LogoutView.as_view(), name='rest_logout'), path('user/', UserDetailsView.as_view(), name='rest_user_details'), path('password/change/', PasswordChangeView.as_view(), name='rest_password_change'), ] if api_settings.USE_JWT: from rest_framework_simplejwt.views import TokenVerifyView from dj_rest_auth.jwt_auth import get_refresh_view urlpatterns += [ path('token/verify/', TokenVerifyView.as_view(), name='token_verify'), path('token/refresh/', get_refresh_view().as_view(), name='token_refresh'), ]
dj_rest_auth/urls.py
비밀번호 초기화(이메일 전송), 로그인, 로그아웃, 유저 정보 조회, 비밀번호 변경 기능을 기본적으로 제공한다.
LoginViewLoginSerializer의 작동 방식만 한번 살펴 보도록 하자.
from django.contrib.auth import login as django_login class LoginView(GenericAPIView): permission_classes = (AllowAny,) serializer_class = api_settings.LOGIN_SERIALIZER # 기본값은 dj_rest_auth.serializers.LoginSerializer def post(self, request, *args, **kwargs): # 요청을 받으면 serializer 처리를 한 후, self.login() 처리, # 최종적으로 self.get_response()를 반환한다. self.request = request self.serializer = self.get_serializer(data=self.request.data) # LoginSerializer에서는 요청의 username/email, password를 검증하고 # 해당 정보를 바탕으로 User를 찾아온다. 하단의 LoginSerializer 참고. self.serializer.is_valid(raise_exception=True) self.login() return self.get_response() def login(self): # JWT, token, session등 인증 방식에 따라 처리한다. self.user = self.serializer.validated_data['user'] token_model = get_token_model() if api_settings.USE_JWT: self.access_token, self.refresh_token = jwt_encode(self.user) elif token_model: self.token = api_settings.TOKEN_CREATOR(token_model, self.user, self.serializer) if api_settings.SESSION_LOGIN: self.process_login() # django_login(self.request, self.user) def get_response_serializer(self): # JWT, token 인증 방식에 따라 처리한다. if api_settings.USE_JWT: if api_settings.JWT_AUTH_RETURN_EXPIRATION: response_serializer = api_settings.JWT_SERIALIZER_WITH_EXPIRATION else: response_serializer = api_settings.JWT_SERIALIZER else: response_serializer = api_settings.TOKEN_SERIALIZER return response_serializer def get_response(self): # JWT, token등을 반환해준다. serializer_class = self.get_response_serializer() if api_settings.USE_JWT: from rest_framework_simplejwt.settings import ( api_settings as jwt_settings, ) access_token_expiration = (timezone.now() + jwt_settings.ACCESS_TOKEN_LIFETIME) refresh_token_expiration = (timezone.now() + jwt_settings.REFRESH_TOKEN_LIFETIME) return_expiration_times = api_settings.JWT_AUTH_RETURN_EXPIRATION auth_httponly = api_settings.JWT_AUTH_HTTPONLY data = { 'user': self.user, 'access': self.access_token, } if not auth_httponly: data['refresh'] = self.refresh_token else: # Wasnt sure if the serializer needed this data['refresh'] = "" if return_expiration_times: data['access_expiration'] = access_token_expiration data['refresh_expiration'] = refresh_token_expiration serializer = serializer_class( instance=data, context=self.get_serializer_context(), ) elif self.token: serializer = serializer_class( instance=self.token, context=self.get_serializer_context(), ) else: return Response(status=status.HTTP_204_NO_CONTENT) response = Response(serializer.data, status=status.HTTP_200_OK) if api_settings.USE_JWT: from .jwt_auth import set_jwt_cookies set_jwt_cookies(response, self.access_token, self.refresh_token) return response
dj_rest_auth/views.py
class LoginSerializer(serializers.Serializer): username = serializers.CharField(required=False, allow_blank=True) email = serializers.EmailField(required=False, allow_blank=True) password = serializers.CharField(style={'input_type': 'password'}) def validate(self, attrs): username = attrs.get('username') email = attrs.get('email') password = attrs.get('password') # 받은 username/email과 password를 기반으로 유저를 가져온다 user = self.get_auth_user(username, email, password) ... # allauth_account_settings.EMAIL_VERIFICATION 설정에 따라 # 필요하면 이메일 verify 여부를 검증한다. if 'dj_rest_auth.registration' in settings.INSTALLED_APPS: self.validate_email_verification_status(user, email=email) attrs['user'] = user return attrs def get_auth_user(self, username, email, password): # allauth 라이브러리를 같이 사용하는 지 여부에 따라 작동이 달라진다 if 'allauth' in settings.INSTALLED_APPS: ... return self.get_auth_user_using_allauth(username, email, password) return self.get_auth_user_using_orm(username, email, password) def get_auth_user_using_allauth(self, username, email, password): # 이메일을 사용하는지 username을 사용하는지에 따라 작동이 달라진다 # _validate_email()등의 검증 과정에서는 django.contrib.auth.authenticate()를 이용한다. # Authentication through email if allauth_account_settings.AUTHENTICATION_METHOD == allauth_account_settings.AuthenticationMethod.EMAIL: return self._validate_email(email, password) # Authentication through username if allauth_account_settings.AUTHENTICATION_METHOD == allauth_account_settings.AuthenticationMethod.USERNAME: return self._validate_username(username, password) # Authentication through either username or email return self._validate_username_email(username, email, password) def get_auth_user_using_orm(self, username, email, password): # 이메일을 사용하는지 username을 사용하는지에 따라 작동이 달라진다 if email: try: username = UserModel.objects.get(email__iexact=email).get_username() except UserModel.DoesNotExist: pass if username: return self._validate_username_email(username, '', password) return None
dj_rest_auth/serializers.py
위와 같이 dj-rest-auth는 username 로그인뿐만 아니라 email 로그인에도 대응하며, django-allauth를 잘 지원해주는 것을 알 수 있다. 그래서 dj-rest-auth와 django-allauth의 기능이 햇갈릴 수 있는데, dj-rest-auth는 REST API를 제공하는 역할만 해준다는 것을 다시 상기하자.

설정

REST_AUTH = { 'LOGIN_SERIALIZER': 'dj_rest_auth.serializers.LoginSerializer', 'TOKEN_SERIALIZER': 'dj_rest_auth.serializers.TokenSerializer', 'JWT_SERIALIZER': 'dj_rest_auth.serializers.JWTSerializer', 'JWT_SERIALIZER_WITH_EXPIRATION': 'dj_rest_auth.serializers.JWTSerializerWithExpiration', 'JWT_TOKEN_CLAIMS_SERIALIZER': 'rest_framework_simplejwt.serializers.TokenObtainPairSerializer', 'USER_DETAILS_SERIALIZER': 'dj_rest_auth.serializers.UserDetailsSerializer', 'PASSWORD_RESET_SERIALIZER': 'dj_rest_auth.serializers.PasswordResetSerializer', 'PASSWORD_RESET_CONFIRM_SERIALIZER': 'dj_rest_auth.serializers.PasswordResetConfirmSerializer', 'PASSWORD_CHANGE_SERIALIZER': 'dj_rest_auth.serializers.PasswordChangeSerializer', 'REGISTER_SERIALIZER': 'dj_rest_auth.registration.serializers.RegisterSerializer', 'REGISTER_PERMISSION_CLASSES': ('rest_framework.permissions.AllowAny',), 'TOKEN_MODEL': 'rest_framework.authtoken.models.Token', 'TOKEN_CREATOR': 'dj_rest_auth.utils.default_create_token', 'PASSWORD_RESET_USE_SITES_DOMAIN': False, 'OLD_PASSWORD_FIELD_ENABLED': False, 'LOGOUT_ON_PASSWORD_CHANGE': False, 'SESSION_LOGIN': True, 'USE_JWT': False, 'JWT_AUTH_COOKIE': None, 'JWT_AUTH_REFRESH_COOKIE': None, 'JWT_AUTH_REFRESH_COOKIE_PATH': '/', 'JWT_AUTH_SECURE': False, 'JWT_AUTH_HTTPONLY': True, 'JWT_AUTH_SAMESITE': 'Lax', 'JWT_AUTH_RETURN_EXPIRATION': False, 'JWT_AUTH_COOKIE_USE_CSRF': False, 'JWT_AUTH_COOKIE_ENFORCE_CSRF_ON_UNAUTHENTICATED': False, }
settings.py
dj-rest-auth의 설정은 REST_AUTH 내의 값을 수정하여 바꿀 수 있다. 각 serializer를 새롭게 설정하거나, JWT 사용설정을 조정할 수 있다.

실습

기본 로그인과 로그아웃등의 기능은 따로 커스터마이징 하지 않아도 이미 모두 대응이 되어있기에 그대로 사용할 수 있다. 그렇기 때문에 필요한 View만 불러와 사용해주었다.
말했듯이 email과 JWT를 대응할 수 있게 구현되어 있기에 별도의 수정이 필요없다.
from dj_rest_auth.views import LoginView, LogoutView from rest_framework_simplejwt.views import TokenRefreshView, TokenVerifyView urlpatterns = [ path("logout/", LogoutView.as_view, name="logout"), path("login/basic/", LoginView.as_view(), name="basic_login"), # JWT를 사용 path("token/refresh/", TokenRefreshView.as_view()), path("token/verify/", TokenVerifyView.as_view()), ]
urls.py
설정에서 일부 유저 정보 serializer와 JWT 관련 설정만 수정해주었다.
REST_AUTH = { "USER_DETAILS_SERIALIZER": "user.serializers.UserDetailsSerializer", "USE_JWT": True, # djangorestframework-simplejwt와 연동 "JWT_AUTH_COOKIE": "accessToken", "JWT_AUTH_REFRESH_COOKIE": "refreshToken", "JWT_AUTH_HTTPONLY": False, # 해제함으로써 body로 토큰을 전달 }
UserDetailsSerializer는 nickname등을 포함하게 커스터마이징 하였다.
class UserDetailsSerializer(serializers.ModelSerializer): nickname = serializers.CharField(source="profile.nickname", required=False) last_login = serializers.DateTimeField(format="%Y-%m-%dT%H:%M:%S", read_only=True) def update(self, instance, validated_data): profile = instance.profile profile.nickname = validated_data.get("profile", {}).get( "nickname", profile.nickname ) profile.save() return instance class Meta: model = get_user_model() fields = [ "email", "is_active", "date_joined", "last_login", "nickname", ] read_only_fields = [ "email", "is_active", "date_joined", "last_login", ]
user/serializers.py

dj-rest-auth와 django-allauth를 이용한 registration과 소셜 로그인

이때 까지는 dj-rest-auth의 기본 기능만 살펴보았다. 이제 dj-rest-auth와 django-allauth가 제공하는 회원가입과 소셜 로그인 기능을 알아보자.
pip install 'dj-rest-auth[with_social]'
INSTALLED_APPS = ( ..., 'django.contrib.sites', 'allauth', 'allauth.account', 'allauth.socialaccount', 'dj_rest_auth.registration', ..., # 제공할 소셜 로그인 provider 'allauth.socialaccount.providers.facebook', 'allauth.socialaccount.providers.twitter', ) SITE_ID = 1
settings.py
위와 같이 설치를 했다면, 우선 같은 방식으로 urls.py부터 확인해보자.
# urls.py urlpatterns = [ ..., path('dj-rest-auth/', include('dj_rest_auth.urls')), path('dj-rest-auth/registration/', include('dj_rest_auth.registration.urls')) ] # dj_rest_auth/registration/urls.py from django.urls import path, re_path from django.views.generic import TemplateView from .views import RegisterView, VerifyEmailView, ResendEmailVerificationView urlpatterns = [ path('', RegisterView.as_view(), name='rest_register'), path('verify-email/', VerifyEmailView.as_view(), name='rest_verify_email'), path('resend-email/', ResendEmailVerificationView.as_view(), name="rest_resend_email"), # This url is used by django-allauth and empty TemplateView is # defined just to allow reverse() call inside app, for example when email # with verification link is being sent, then it's required to render email # content. # account_confirm_email - You should override this view to handle it in # your API client somehow and then, send post to /verify-email/ endpoint # with proper key. # If you don't want to use API on that step, then just use ConfirmEmailView # view from: # django-allauth https://github.com/pennersr/django-allauth/blob/master/allauth/account/views.py re_path( r'^account-confirm-email/(?P<key>[-:\w]+)/$', TemplateView.as_view(), name='account_confirm_email', ), path( 'account-email-verification-sent/', TemplateView.as_view(), name='account_email_verification_sent', ), ]
회원가입 API와 회원가입시 이메일 주소 검증 관련 API를 추가해주는 것을 볼 수 있다. 소셜 로그인 API는 따로 추가해주어야 한다.
 
 
 

이메일 인증

# EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" # EMAIL_HOST = "smtp.gmail.com" # EMAIL_PORT = 587 # EMAIL_USE_TLS = True # EMAIL_HOST_USER = "" # email sending address # EMAIL_HOST_PASSWORD = ""

References