동현 유
척척석사
동현 유
전체 방문자
오늘
어제
  • 분류 전체보기 (181)
    • BlockChain (48)
      • [paper] Consensus (13)
      • [paper] Execution (19)
      • [paper] Storage (5)
      • [paper] ZKP (1)
      • [paper] Oracle (1)
      • Blockchains (9)
    • Java (19)
      • Java의 정석 (13)
      • Java 파헤치기 (5)
    • Python (20)
      • Python 뜯어보기 (6)
      • 데이터 분석 기초 (5)
      • Python 기초 강의 (6)
      • Python 기초 강의 부록 (3)
    • Golang (0)
    • MySQL (3)
      • programmers (2)
      • 기본 문법 (0)
    • 웹 프로젝트 (IBAS) (36)
      • Django 레거시 (14)
      • SpringBoot api 개편 (14)
      • Infra (3)
      • 서버 장애 기록 (4)
      • 신입팀원 교육 자료 (1)
    • CS (30)
      • Operating System (22)
      • Computer Security (3)
      • Network (4)
      • DBMS (1)
    • 책 (10)
      • 도메인 주도 설계 철저 입문 (9)
      • Real MySQL 8.0 (1)
    • BOJ 문제 풀이 (3)
    • 이러쿵저러쿵 (10)
    • 회고 (1)

인기 글

최근 댓글

최근 글

hELLO · Designed By 정상우.
동현 유

척척석사

[Django 웹프로젝트] 10. 호환성을 고려한 소셜로그인 버그 수정  (2022-03-14~15)
웹 프로젝트 (IBAS)/Django 레거시

[Django 웹프로젝트] 10. 호환성을 고려한 소셜로그인 버그 수정 (2022-03-14~15)

2022. 3. 16. 01:12

(문제상황)

  - 소셜로그인을 통해서만 로그인이 가능하도록 구현되어있다.

  - OAuth2 인증 후 제공받는 사용자의 이메일 정보와, 회원가입 시 기재한 프로필 정보를 매핑해서 보관하고 있었다.

  - (로그인 과정) OAuth2 로그인 시도 => 이메일 받아서 db 에 프로필 정보와 매핑되어 있는지 확인 => 로그인 처리

  - 네이버는 OAuth2 를 통해 제공하는 이메일이 달라질 수 있다. => 연락처이메일이 달라져서 로그인이 안되는 경우 발생

  - 또 처음부터 정보제공 동의를 하지 않더라도, 로그인이 진행되어 필수로 받아야하는 이메일 값이 넘어오지 않는 경우 발생

  - 이슈 : https://github.com/InhaBas/Inhabas.com/issues/102

 

(해결방안)

  1.  provider 와 uid 값으로 소셜 계정을 관리해야한다.
    => OAuth2 provider 와 해당 provider 에서 관리하는 유저 uid 는 고유한 식별값이다.
    => 이메일 검사 >>> (provider, uid) 검사로 전환 (db 수정 필요) - 원본 테이블에 그대로 작업하면 롤백도 안됨.
    => 로그인, 회원가입, 소셜계정 추가 연동 로직 수정 필요
  2. OAuth2 인증 이후 필수 값이 넘어오는 지 확인

 

(작은 단위로 나누기)

  1. db table 새로 복사 생성 - orm 모델 생성
  2. 로그인 로직 : OAuth2 인증 후 (provider, uid)로 사용자를 검색하도록 수정
  3. 회원가입 로직 : OAuth2 인증 후 uid 값을 저장하도록 수정
  4. 소셜계정 연동 로직 : 위의 두가지를 다 적용

    (호환성 작업)

  1. 프로시저를 통해 최대한 uid 채워주기
  2. uid 가 없는 데이터가 있기 때문에, 로그인이 안되는 유저가 발생.
    => 기존 로직을 남겨두고, 로그인 진행될 때 uid 를 추가하는 방향으로 진행.

    (기타)

  1. 롤백 스크립트 짜기

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 이다.

 

 

[bugfix/#102] 네이버 소셜 로그인 관련 이슈 by Dong-Hyeon-Yu · Pull Request #103 · InhaBas/Inhabas.com

resolve : #102

github.com

 

 

 

 

 

 

 

'웹 프로젝트 (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
    동현 유
    동현 유
    Fault Tolerant System Researcher for more Trustful World and Better Lives. (LinkedIn: https://www.linkedin.com/in/donghyeon-ryu-526b8a276/)

    티스토리툴바