Django CBV 프로젝트 3. 소셜 로그인 (간편로그인)

참고 사이트

구조 변경(리팩토링)

1
2
3
4
5
# static 파일설정
STATIC_URL = '/static/'
STATIC_ROOT = './static'
# STATIC_URL: 이 경로로 시작되는 요청은 static 핸들러로 라우팅
# STATIC_ROOT: collectstatic 커맨드로 static 파일들을 모을 때 저장될 디렉토리 경로

프로젝트 구성

# Project
static              # python manage.py collectstatic 명령어 수행시 파일이 저장되는 디렉토리입니다.
|      
minitutorial/        # 프로젝트의 루트 디렉토리입니다. 디렉토리이름은 변경하셔도 됩니다.
├── manage.py       # CLI에서 장고 프로젝트의 다양한 기능들을 사용할 수 있게 해주는 유틸리티입니다.
└── minitutorial/   # 실제 프로젝트 디렉토리입니다. 프로젝트의 설정을 할 수 있으며, 파이썬 패키지로 사용됩니다.
    ├── __init__.py # 파이썬 패키지에 필수로 들어있는 초기화 파일입니다. 프로젝트를 패키지로 불러올 때 가장 먼저 실행되는 스크립트입니다.
    ├── settings.py # 프로젝트 설정파일입니다.
    ├── urls.py     # 웹 url들을 view와 매칭시켜주는 파일입니다.
    └── wsgi.py     # WSGI 호환 웹 서버로 서비스할 때 실행되는 시작점입니다.


-------------------------------------------------

# APP
bbs/
├── __init__.py      # 앱 패키지 초기화 스크립트입니다.
├── admin.py         # 장고 어드민 설정파일입니다. 어드민에 등록된 모델은 장고에서 자동 생성하는 어드민 페이지에서 관리할 수 있습니다.
├── apps.py          # 앱 설정 파일입니다.
└── migrations/      # 데이터베이스 마이그레이션 디렉토리. 장고 ORM은 모델 스키마의 변화가 생길 때마다 migration 파일을 생성하고 이것을 통해 스키마를 업데이트 합니다. migration 파일을 통해 협업자들과 함께 데이터베이스의 스키마를 동기화할 수 있습니다.
    ├── __init__.py  # 마이그레이션 패키지 초기화 스크립트입니다.
    ├── models.py        # 앱 모델 파일입니다. 게시판의 모든 데이터를 저장할 데이터베이스를 장고 ORM을 통해 모델화합니다.
    ├── tests.py         # 앱 내의 기능들을 테스트하는 기능을 구현하는 파일입니다.
    ├── views.py         # 앱의 화면(template)과 데이터(model) 사이에서 사용자의 요청을 처리하여 모델에 저장하고, 모델에 저장된 데이터를 화면에 전달하는 역할을 합니다.
├── static
        └──css/
            └──  bss.css    # 게시판 관련 css 파일입니다.
└── templates/
        ├── base.html                # 기본 틀이 되는 html
        ├── article_list.html        # 게시글 목록이 보이는 html        
        ├── article_detail.html      # 특정 게시글이 보이는 html
        └── article_update.html      # 새로운 게시글이나 특정 글을 update 하는 html

-----------------------------------------------------

user/
├── __init__.py      # 앱 패키지 초기화 스크립트입니다.
├── admin.py         # 장고 어드민 설정파일입니다. 어드민에 등록된 모델은 장고에서 자동 생성하는 어드민 페이지에서 관리할 수 있습니다.
├── apps.py          # 앱 설정 파일입니다.
└── migrations/      # 데이터베이스 마이그레이션 디렉토리. 장고 ORM은 모델 스키마의 변화가 생길 때마다 migration 파일을 생성하고 이것을 통해 스키마를 업데이트 합니다. migration 파일을 통해 협업자들과 함께 데이터베이스의 스키마를 동기화할 수 있습니다.
    ├── __init__.py  # 마이그레이션 패키지 초기화 스크립트입니다.
    ├── models.py        # 앱 모델 파일입니다. 회원의 모든 데이터를 저장할 데이터베이스를 장고 ORM을 통해 모델화합니다.
    ├── tests.py         # 앱 내의 기능들을 테스트하는 기능을 구현하는 파일입니다.
    ├── views.py         # 앱의 화면(template)과 데이터(model) 사이에서 사용자의 요청을 처리하여 모델에 저장하고, 모델에 저장된 데이터를 화면에 전달하는 역할을 합니다.
    ├── mixins.py        # 기본 메소드(인증보내기)를 지정해서 코드를 단순화 합니다.
    ├── validators.py    # 객체를 검증할 수 있는 기능을 제공 합니다.           
    ├── forms.py         # 앱의 form의 인자와 화면단을 구성해주는 파일입니다.
├── static
        └──css/
            └──  user.css    # 회원 관련 css 파일입니다. 
        └──js
            └── social_login.js # 로그인 관련 js 파일입니다.
        └──img
            ├──  kakao.img    # 카카오 소셜로그인 관련 파일입니다.    
            └──  naver.img    # 네이버 소셜로그인 관련 파일입니다.
├── oauth
    ├── /providers
            └── naver.py      # 
    └── backends.py           # 네아로 계정을 local계정에 저장하도록 하는 
└── templates/user/
    ├── login_form.html                # 로그인 form html
    ├── resend_verify_email.html       # 재 인증 메일 form html       
    ├── user_form.html                 # 회원가입 form html
    ├── particals
            ├── social_login_panel.html # 소셜로그인 form 부분 html 입니다.
            └── form_field.html         # 공통 부분을 나눠 include 하기위한 html 파일입니다.
    └── /email
        └── registration_verification.html      # 사용자 email 보낼 때 form html

소셜 로그인 구현

1
2
pip install requests
pip install oauth

네아로 지정 (settings.py)

1
2
3
4
5
6
7
8

NAVER_CLIENT_ID = 'your client id'
NAVER_SECRET_KEY = 'your secret key'

AUTHENTICATION_BACKENDS = [
'user.oauth.backends.NaverBackend', # 네이버 인증백엔드
'django.contrib.auth.backends.ModelBackend'
]

소셜 로그인 폼 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{% load static %}

{% static '/img/kakao_login.png' as kakao_button %}
{% static '/img/kakao_login_ov.png' as kakao_button_hover %}
{% static '/img/naver_login_green.png' as naver_button %}
{% static '/img/naver_login_white.png' as naver_button_hover %}

<div class="panel panel-default user-panel">
<div class="panel-heading">
{{ panel_name }}
</div>
<div class="panel-body text-center">
<div class="pull-left">
<a>
<img src="{{ kakao_button }}"
onmouseover="this.src='{{ kakao_button_hover }}'"
onmouseleave="this.src='{{ kakao_button }}'"height="34">
</a>
</div>
<div class="pull-right">
<a href="#" onclick="naverLogin()">
<img src="{{ naver_button }}"
onmouseover="this.src='{{ naver_button_hover }}'"
onmouseleave="this.src='{{ naver_button }}'"height="34">
</a>
</div>
</div>
</div>
<script src="{% static 'js/social_login.js' %}"></script>


<!-- login / register form 에 include -->
{% include 'user/partials/social_login_panel.html' with panel_name='소셜로그인' %}

form

클릭시 동작하는 js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

// user/static/user/js/social_login.js

function buildQuery(params) {
return Object.keys(params).map(function (key) {return key + '=' + encodeURIComponent(params[key])}).join('&')
}
function buildUrl(baseUrl, queries) {
return baseUrl + '?' + buildQuery(queries)
}

function naverLogin() {
params = {
response_type: 'code',
client_id:'lkfcHFxyz5UGC0gF81Ym',
redirect_uri: location.origin + '/user/login/social/naver/callback/' + location.search,
state: document.querySelector('[name=csrfmiddlewaretoken]').value
}
url = buildUrl('https://nid.naver.com/oauth2.0/authorize', params)
location.replace(url)
}

form1

CallBackView 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from django.conf import settings
from django.views.generic.base import TemplateView, View
from django.middleware.csrf import _compare_salted_tokens
from user.oauth.providers.naver import NaverLoginMixin


class SocialLoginCallbackView(NaverLoginMixin, View):

success_url = settings.LOGIN_REDIRECT_URL
failure_url = settings.LOGIN_URL
required_profiles = ['email', 'nickname']

model = get_user_model()

def get(self, request, *args, **kwargs):

provider = kwargs.get('provider')
success_url = request.GET.get('next', self.success_url)

if provider == 'naver': # provider가 naver 인경우
csrf_token = request.GET.get('state')
code = request.GET.get('code')
if not _compare_salted_tokens(csrf_token, request.COOKIES.get('csrftoken')):
messages.error(request, '잘못된 경로로 로그인하셨습니다.', extra_tags='danger')
return HttpResponseRedirect(self.failure_url)
is_success, error = self.login_with_naver(csrf_token, code)
if not is_success: # 로그인이 실패할 경우
messages.error(request, error, extra_tags='danger')
return HttpResponseRedirect(success_url if is_success else self.failure_url)

return HttpResponseRedirect(self.failure_url)

def set_session(self, **kwargs):
for key, value in kwargs.items():
self.request.session[key] = value
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# user/oauth/providers/naver.py

from django.conf import settings
from django.contrib.auth import login
import requests


class NaverClient:
client_id = settings.NAVER_CLIENT_ID
secret_key = settings.NAVER_SECRET_KEY
grant_type = 'authorization_code'

auth_url = 'https://nid.naver.com/oauth2.0/token'
profile_url = 'https://openapi.naver.com/v1/nid/me'

__instance = None

def __new__(cls, *args, **kwargs):
if not isinstance(cls.__instance, cls):
cls.__instance = super().__new__(cls, *args, **kwargs)
return cls.__instance

def get_access_token(self, state, code):
res = requests.get(self.auth_url, params={'client_id': self.client_id, 'client_secret': self.secret_key, 'grant_type': self.grant_type, 'state': state, 'code': code})

return res.ok, res.json()

def get_profile(self, access_token, token_type='Bearer'):
res = requests.get(self.profile_url, headers={'Authorization': '{} {}'.format(token_type, access_token)}).json()

if res.get('resultcode') != '00':
return False, res.get('message')
else:
return True, res.get('response')

class NaverLoginMixin:
naver_client = NaverClient()

def login_with_naver(self, state, code):

# 인증토근 발급
is_success, token_infos = self.naver_client.get_access_token(state, code)

if not is_success:
return False, '{} [{}]'.format(token_infos.get('error_desc'), token_infos.get('error'))

access_token = token_infos.get('access_token')
refresh_token = token_infos.get('refresh_token')
expires_in = token_infos.get('expires_in')
token_type = token_infos.get('token_type')

# 네이버 프로필 얻기
is_success, profiles = self.get_naver_profile(access_token, token_type)
if not is_success:
return False, profiles

# 사용자 생성 또는 업데이트
user, created = self.model.objects.get_or_create(email=profiles.get('email'))
if created: # 사용자 생성할 경우
user.set_password(None)
user.name = profiles.get('nickname')
user.is_active = True
user.save()

# 로그인
login(self.request, user, 'user.oauth.backends.NaverBackend') # NaverBackend 를 통한 인증 시도

# 세션데이터 추가
self.set_session(access_token=access_token, refresh_token=refresh_token, expires_in=expires_in, token_type=token_type)

return True, user

def get_naver_profile(self, access_token, token_type):
is_success, profiles = self.naver_client.get_profile(access_token, token_type)

if not is_success:
return False, profiles

for profile in self.required_profiles:
if profile not in profiles:
return False, '{}은 필수정보입니다. 정보제공에 동의해주세요.'.format(profile)

return True, profiles


인증 백엔드 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# user/oauth/backends.py
# NaverBackend 백엔드는 기본인증백엔드(ModelBackend) 를 상속받아 대부분의 기능들을 그대로 사용

from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import AnonymousUser

UserModel = get_user_model()


class NaverBackend(ModelBackend):
def authenticate(self, request, username=None,**kwargs):
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
pass
else:
if self.user_can_authenticate(user):
return user

urls.py 등록

1
2
3
4
5
6
7
8

from user.views import SocialLoginCallbackView

urlpatterns = [
# 생략
path('user/login/social/<provider>/callback/', SocialLoginCallbackView.as_view()),
]

결과

게시판 글쓴이 연동

models.py 수정

1
2
# author     = models.CharField('작성자', max_length=16, null=False)->
author = models.ForeignKey('user.User', related_name='articles', on_delete=models.CASCADE)

views.py 수정

1
2
3
4
5
6
7
8
9
10
#생략
if pk:
if not article:
raise Http404('invalid pk')
elif article.author != self.request.user: # 작성자가 수정하려는 사용자와 다른 경우
raise Http404('invalid user')
return article

# 작성자를 현재 사용자로 설정
post_data['author'] = self.request.user