(문제상황)
- 소셜로그인을 통해서만 로그인이 가능하도록 구현되어있다.
- OAuth2 인증 후 제공받는 사용자의 이메일 정보와, 회원가입 시 기재한 프로필 정보를 매핑해서 보관하고 있었다.
- (로그인 과정) OAuth2 로그인 시도 => 이메일 받아서 db 에 프로필 정보와 매핑되어 있는지 확인 => 로그인 처리
- 네이버는 OAuth2 를 통해 제공하는 이메일이 달라질 수 있다. => 연락처이메일이 달라져서 로그인이 안되는 경우 발생
- 또 처음부터 정보제공 동의를 하지 않더라도, 로그인이 진행되어 필수로 받아야하는 이메일 값이 넘어오지 않는 경우 발생
- 이슈 : https://github.com/InhaBas/Inhabas.com/issues/102
(해결방안)
- provider 와 uid 값으로 소셜 계정을 관리해야한다.
=> OAuth2 provider 와 해당 provider 에서 관리하는 유저 uid 는 고유한 식별값이다.
=> 이메일 검사 >>> (provider, uid) 검사로 전환 (db 수정 필요) - 원본 테이블에 그대로 작업하면 롤백도 안됨.
=> 로그인, 회원가입, 소셜계정 추가 연동 로직 수정 필요 - OAuth2 인증 이후 필수 값이 넘어오는 지 확인
(작은 단위로 나누기)
- db table 새로 복사 생성 - orm 모델 생성
- 로그인 로직 : OAuth2 인증 후 (provider, uid)로 사용자를 검색하도록 수정
- 회원가입 로직 : OAuth2 인증 후 uid 값을 저장하도록 수정
- 소셜계정 연동 로직 : 위의 두가지를 다 적용
(호환성 작업)
- 프로시저를 통해 최대한 uid 채워주기
- uid 가 없는 데이터가 있기 때문에, 로그인이 안되는 유저가 발생.
=> 기존 로직을 남겨두고, 로그인 진행될 때 uid 를 추가하는 방향으로 진행.
(기타)
- 롤백 스크립트 짜기
1. table 및 orm 모델 생성
(1) orm 모델 생성
@PendingDeprecationWarning # 기존 모델 deprecation 처리
class UserEmail(models.Model):
user_email = models.CharField(max_length=100, db_column="USER_EMAIL", primary_key=True)
provider = models.CharField(max_length=20, db_column="PROVIDER")
user_stu = models.ForeignKey(User, on_delete=models.CASCADE, db_column='user_stu', related_name="deprecated_email")
class Meta:
managed = False
db_table = 'USER_EMAIL'
# 추가된 테이블 orm 객체
class UserSocialAccount(models.Model):
id = models.PositiveIntegerField(primary_key=True, editable=False, db_column="id")
email = models.CharField(max_length=100, db_column="EMAIL")
provider = models.CharField(max_length=20, db_column="PROVIDER")
uid = models.CharField(max_length=191, db_column="UID")
user = models.ForeignKey(User, on_delete=models.CASCADE, db_column='user_stu', related_name="useremail_set")
class Meta:
managed = False
db_table = 'USER_SOCIALACCOUNT'
unique_together = (('provider', 'uid'),)
related_name 을 이용해서 사용자 이메일의 역참조 키워드(useremail_set)를 새로운 ORM model에 연결시켰다. 이렇게 하면 프로젝트 전역에 퍼져있는 역참조 키워드를 일일이 찾아내지 않아도 쉽게 수정가능하다.
(2) 테이블 생성
create table USER_SOCIALACCOUNT
(
id int auto_increment
primary key,
email varchar(100) not null,
uid varchar(191) not null,
provider varchar(20) not null,
user_id int not null,
constraint user_socialaccount_UID_PROVIDER_uindex
unique (uid, provider),
constraint user_socialaccount_user_USER_STU_fk
foreign key (user_id) references user (user_id)
on delete cascade
);
(3) 기존 테이블 정보를 복사
: uid 와 provider 의 조합을 unique 인덱스로 정해놔서, 중복되지 않도록 uid 를 음수로 지정해주었다.
set @uid = 0;
insert into SOCIALACCOUNT(EMAIL, UID, PROVIDER, USER_ID)
select USER_EMAIL, (@uid := @uid-1), PROVIDER, USER_ID from USER_EMAIL;
2. 로그인 로직 수정
: uid와 provider 로 소셜계정을 찾는 로직을 추가. 없으면 예외처리
(...생략...)
social_dict = None
try:
social_dict = get_social_login_info(user_token)
user_social_account = UserSocialAccount.objects.get(uid=social_dict.get("uid"), # 수정된 부분
provider=social_dict.get("provider"))
(...생략...)
except UserSocialAccount.DoesNotExist: # 수정된 부분
if is_user_recruiting():
return render(request, 'std_or_pro.html', social_dict)
else:
messages.warning(request, "입부 신청 기간이 아닙니다.")
except AuthUser.DoesNotExist or SocialAccount.DoesNotExist:
messages.warning(request, "소셜 로그인에 실패했습니다. 다시 시도해주세요!")
3. 회원가입 로직 수정
여러 템플릿과 산재된 코드를 부분적으로 많이 수정해서 생략.
자세한 코드는 맨 아래 첨부된 PR 주소 참고.
4. 소셜계정 연동 수정
(1) 암호화 방식 변경
소셜 계정을 추가로 연동하기 위해 다른 소셜 아이디로 OAuth2을 시도하게 되면
django-allauth에 의해 기존의 세션이 변경되어서, 로그인을 유지할 수 없다는 문제가 있었다.
기존에는 이 문제를 해결하기 위해, 콜백url get 파라미터에 회원id 암호화하여 같이 넘기는 방식으로 해결했었다.
단방향 md5 로 암호화를 하다보니, OAuth2 인증이 끝나고 해시값으로 회원을 찾으려면
아래와 같이 모든 회원 id 를 다 불러와서 일일이 암호화 후 비교해야했다.
for user in User.objects.all():
if target_user_stu == get_ecrypt_value(str(user.user_stu)):
current_user = user
break
회원 id 를 외부에 노출하지 않으면서, 단 하나의 객체만 쿼리로 가져올 수 있지 않을까?
Django 는 Secret_key 가 있어서 해당 키로 암복호화가 가능하다.
정확히 말하면 아래와 같이 서명을 추가할 수 있다.
from django.core.signing import Signer, BadSignature
signer = Signer()
signer.sign("string!") # "string!:(대충암호화된서명이라는뜻)"
signer.signature("string!") # "(대충암호화된서명이라는뜻)"
signer.unsign("string!:(대충암호화된서명이라는뜻)") # string! 서명값이 유효하지 않으면 BadSignature 오류!
이 서명을 get 파라미터로 넘기고 OAuth2 인증이 끝나면 다시 받는다.
그러면 어떻게 회원의 id 값을 받아올까?
인메모리 캐시 방식을 이용해서 (서명)-(회원id) 쌍으로 저장한다.
서명을 키 값으로 회원의 id 를 찾아올 수 있다.
멀티쓰레드 환경에서 인메모리 캐시를 사용할 때는, atomic 을 보장해야하는데
파이썬의 딕셔너리는 concurrent 하지는 않지만 기본적으로 thread-safe 하다. GIL(Global Interpreter Lock) 때문.
자원 경합이 많이 일어나지 않는 기능이라 성능 이슈는 없을 거 같지만,,,
uwsgi worker - python app 이 멀티 프로세스 기반이라,,,
OAuth2 인증 후 다시 응답을 받아올 때 같은 인스턴스가 받지 못한다면 캐쉬 사용을 당연히 못하게 된다..
외부 캐시 서버나, db 를 사용해야하나...? 허허변경해야겠다.. 쓰다보니 깨달았다... => [Django 웹프로젝트] 11.인메모리 캐시 사용해도 될까? 심층분석 포스팅 확인
(...생략...)
signature = request.POST.get("user_stu") # 파라미터에서 서명을 갖고온다.
current_user_id = waiting_queue_for_adding_social_account[signature] # 딕셔너리에서 아이디를 꺼낸다.
waiting_queue_for_adding_social_account.pop(user_signature, None) # 아이템 제거
current_user, social_dict = None, None
try:
signer = Signer()
signer.unsign(f"{current_user_id}:{user_signature}") # verify credential
(...생략...)
except BadSignature or User.DoesNotExist:
messages.warning(request, "데이터가 손상되었습니다. 다시 시도해주세요!")
return redirect(reverse("index"))
(...생략...)
(2) 소셜 계정을 uid 이용해 접근하도록 변경
(...생략...)
signature = request.POST.get("user_stu") # 파라미터에서 서명을 갖고온다.
current_user_id = waiting_queue_for_adding_social_account[signature] # 딕셔너리에서 아이디를 꺼낸다.
waiting_queue_for_adding_social_account.pop(user_signature, None) # 아이템 제거
current_user, social_dict = None, None
try:
signer = Signer()
signer.unsign(f"{current_user_id}:{user_signature}") # verify credential
########## 수정된 부분#########
if UserSocialAccount.objects\
.filter(uid=social_dict.get("uid"), provider=social_dict.get("provider")).count():
messages.warning(request, "이미 해당 이메일로 등록되어 있습니다.")
else:
UserSocialAccount.objects.create(user=current_user,
email=social_dict.get("email"),
provider=social_dict.get("provider"),
uid=social_dict.get("uid"))
########## 수정된 부분#########
(...생략...)
5. 호환성 작업
(1) table 에 uid 값을 최대한 채워주기 (프로시저 이용)
: django allauth 에서 관리하는 테이블에 사용자 uid 가 들어있는 테이블이 있다. 아래처럼 생겼다.
extra_data 에 OAuth2 인증 끝나고 요청한 사용자의 정보가 담겨서 들어오는데, 이메일이 여기에 들어있다.
따라서 기존에 (이메일-회원) 매핑 테이블 정보에 위 테이블을 이용하여 uid 를 추가해주면 된다.
아래와 같이 프로시저를 작성한 후, 실행시켰다.
DROP PROCEDURE IF EXISTS setUID;
delimiter //
CREATE PROCEDURE setUID()
BEGIN
DECLARE _email VARCHAR(100) DEFAULT '';
DECLARE _provider VARCHAR(20) DEFAULT '';
DECLARE _uid VARCHAR(191) DEFAULT '';
DECLARE _id INT DEFAULT 0;
DECLARE done INT DEFAULT FALSE;
DECLARE cursor_existUserAccountInfo CURSOR FOR SELECT id, EMAIL, PROVIDER FROM USER_SOCIALACCOUNT;
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION SET done=true;
OPEN cursor_existUserAccountInfo;
my_loop: LOOP
/* ...생략... */
END LOOP;
CLOSE cursor_existUserAccountInfo;
End;
//
delimiter ;
(2) 호환성을 위한 코드 추가
이전까지 작성된 코드는 아래와 같다.
(...생략...)
try:
social_dict = get_social_login_info(user_token) # OAuth2 정보 및 필수동의항목 검증
user_social_account = UserSocialAccount.objects.get(uid=social_dict.get("uid"), # 기존 회원 정보
provider=social_dict.get("provider"))
(...생략...)
except UserSocialAccount.DoesNotExist: # 기존 회원 정보가 없으면
if not user_has_already_joined(request, social_dict): # <-- 호환성을 위해 추가된 코드!!!!!!!
if is_user_recruiting():
return render(request, 'std_or_pro.html', social_dict)
else:
messages.warning(request, "입부 신청 기간이 아닙니다.")
except AuthUser.DoesNotExist or SocialAccount.DoesNotExist:
messages.warning(request, "소셜 로그인에 실패했습니다. 다시 시도해주세요!")
uid 가 없어서 조회되지 않는 문제가 생길 수 있으므로,
회원 정보가 조회되지 않을 때, 레거시의 방법으로 한번 더 검사를 진행해야한다..!
이 때 발견된다면, 기존 회원인데 uid 가 없는 경우이므로 uid 를 추가해준다.
레거시의 코드는 나중에 쉽게 제거할 수 있도록 아래와 같이 분리해주었다.
# @PendingDeprecationWarning
def user_has_already_joined(request, social_dict) -> bool:
"""
기존에는 OAuth2 인증 후, 해당 이메일과 회원 학번을 연결하여 회원가입된 유저인지 확인했었다.
하지만 naver oauth2 의 경우 사용자 연락처 이메일을 변경할 수 있음이 발견되어,
(provider, uid) 로 소셜계정을 구분하도록 변경했다.
이미 회원이지만 이 변경사항 적용 후에 아직 uid 가 매핑되지 않은 회원이 존재할 수 있다.
따라서 호환성을 위해 email 로 소셜 계정을 한번 더 찾고, 있을 경우 uid 를 추가하도록 했다.
https://github.com/InhaBas/Inhabas.com/issues/102
"""
if user_social_account := UserSocialAccount.objects.select_related("user") \
.filter(email=social_dict.get("email"), provider=social_dict.get("provider")).first():
user_social_account.uid = social_dict.get("uid")
user_social_account.save() # uid 설정 후 저장
(...대충 로그인 처리...)
return True
return False
6. 롤백 전략
: 기존에 사용하던 table 이 그대로 남아있어서, 커밋만 이전으로 돌리고 다시 배포하면 된다.
기존 배포 스크립트에서 pull 받는 부분을 reset 으로 돌렸다.
지금 생각해보니 git tag 를 사용하면 더 깔끔하게 진행할 수 있을 거 같다.
해당 PR 이다.
'웹 프로젝트 (IBAS) > Django 레거시' 카테고리의 다른 글
[Django 웹프로젝트] 11. 인메모리 캐시 사용해도 될까? 심층분석 - (1) 파이썬 스레드, GIL (0) | 2022.03.21 |
---|---|
[Django 웹프로젝트] 11. 인메모리 캐시 사용해도 될까? 심층분석 (0) | 2022.03.19 |
[Django 웹 프로젝트] 트래픽 및 방문자 수 (2022.02.11 ~ 2022.03.12) (2) | 2022.03.13 |
[Django 웹 프로젝트] 9. 소셜 로그인 관련 오류 수정 (2022-03-13) (0) | 2022.03.13 |
[Django 웹 프로젝트] 8. 점진적으로 api 로 교체 가능? (2021-11-21) (0) | 2022.03.13 |