Tany
백문이불어일Tany
Tany
전체 방문자
오늘
어제
  • 분류 전체보기 (197)
    • JAVA TPC (1)
    • JAVA (10)
    • CS (3)
    • SPRING (5)
    • DFS BFS (12)
    • SQL (7)
    • 알고리즘 정리 (76)
    • Git, Github (3)
    • 학습 계획 (36)
    • 코드스쿼드 학습일지 (19)
    • Servlet (5)
    • VPC (2)
    • AWS (4)
    • JPA (5)
    • 취미생활 (2)
    • 프로젝트 기록 (7)
      • Issue Tracker 삽질 기록 (5)
      • 당근마켓 API 서버 기록 (2)
      • 나만의 블로그 제작 기록 (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 자바
  • 완전탐색
  • 해시
  • JPA
  • java
  • 알고리즘
  • JSP
  • 문자열
  • EC2
  • MVC
  • 이분탐색
  • 정렬
  • AWS
  • 인프런
  • 면접을 위한 CS 전공지식 노트
  • 이코테
  • hash
  • MySQL
  • 프로그래머스
  • 자료구조
  • GIT
  • 주간 학습 계획
  • 파이썬
  • Stack
  • 재귀
  • dfs
  • BFS
  • 백준
  • sql
  • github

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
Tany

백문이불어일Tany

[당근마켓 API 서버 Project] Form, OAuth2 로그인 통합 구현
프로젝트 기록/당근마켓 API 서버 기록

[당근마켓 API 서버 Project] Form, OAuth2 로그인 통합 구현

2023. 3. 8. 21:57

출처) 짤봇 - https://jjalbot.com/jjals/D1PYZTxcX

기록 이유

맨 처음 로그인 구현했을 때 어떻게 구현했는지 따로 기록을 남겨뒀는데, 블로그로 옮기기 위해 작성하게 되었다. 기록 시작 🙂

 

 

 

어떻게 구현했나 ?

나는 Spring Security 를 활용해 Form Login 과 OAuth2 로그인을 진행했다. 이전 토이 프로젝트에서는 Spring Security 를 사용해보지 않아서 이번 프로젝트에서는 꼭 적용해보겠다 다짐했고 실천을 위해 어느정도 학습 후 커스텀하게 인증 로직을 구현할 수 있었다. 먼저 Form Login 구현부터 차근차근 살펴보자.

 

0-1) Login 관련 디렉토리 구조

config 의 oauth2 디렉토리는 2번에서 설명

 

0-2) UserDetails , OAuth2User 타입 구현체

Form Login 과 OAuth2 Login 을 통합 관리하기 위해 UserDetails, OAuth2User 인터페이스를 다중 상속했다. 생성자가 2갠데, Map<String, Object> attributes 변수가 포함되있는 생성자는 OAuth2 Login 전용, 그 아래 생성자는 Form Login 전용 생성자이다.

package com.market.carrot.login.config.customAuthentication.common;

import ...

@Getter
public class MemberContext implements UserDetails, OAuth2User {

  private final Member member;
  private final Map<String, Object> attributes;

  public MemberContext(Member member, Map<String, Object> attributes) {
    this.member = member;
    this.attributes = attributes;
  }

  public MemberContext(Member member) {
    this(member, null);
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> roles = new ArrayList<>();
    roles.add(new SimpleGrantedAuthority(member.getRole().getKey()));

    return roles;
  }

  @Override
  public String getPassword() {
    return member.getPassword();
  }

  @Override
  public String getUsername() {
    return member.getUsername();
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }

  /**
   * getAttributes(), getName() 은 OAuth2User 추상 메서드
   */

  @Override
  public Map<String, Object> getAttributes() {
    return attributes;
  }

  @Override
  public String getName() {
    return null;
  }
}

 

 

 

1. Form Login Authentication 플로우 구성

[만약 해당 포스팅을 보는 분이 계시다면 이전 Spring Security 포스팅을 참조해주세요 ㅎ_ㅎ]

https://jeonboard.tistory.com/151

 

[Spring Security] Authentication 인증 처리 과정 알아보기

작성 이유 지금까지 Spring Security 프레임워크를 사용하지 않고 로그인, 인증, 인가를 구현했었다. 상당히 어려웠었는데, Spring Security 를 학습하면서 Spring Security 가 얼마나 잘 짜여진 보안 / 인증관

jeonboard.tistory.com

기본적인 Member Entity 는 건너뛰고 Form Login 의 Authentication 플로우 구성부터 살펴보자. 커스텀 할 인터페이스는AuthenticationProvider 와 Authentication 내부를 채우고 다시 Provider 로 Authentication 을 반환하는 UserDetailsService 두 인터페이스이다.

 

 

1-1) CustomProvider 생성

Provider 는 Manager 를 대신해 실제 인증 로직을 처리한다. CustomProvider 를 Bean 객체로 등록하면, Authentication Manager 에서 자동으로 해당 Provider 를 호출하게 되니 @Component 와 같은 어노테이션 등으로 꼭 Bean 객체로 등록했다.

  • Provider 는 loadUserByUsername(username); 메서드를 통해 UseDetails 구현체에 유저 정보 + 권한 정보를 셋팅한다.
  • 이후 알맞은 Authentication Token 객체에 UserDetails 구현체에 담아놓은 유저 정보 + 권한 정보를 담아 Manager 로 반환
  • Form Login 방식에서 사용되는 Authentication 구현체인 UsernamePasswordAuthenticationToken 객체 생성자를 통해 셋팅, 반환하도록 구현.
package com.market.carrot.login.config.customAuthentication.form;

import ...

@RequiredArgsConstructor
@Component
public class CustomProvider implements AuthenticationProvider {

  private final UserDetailsService userDetailsService;

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    String username = authentication.getName();
    String password = (String) authentication.getCredentials();
    MemberContext memberContext = (MemberContext) userDetailsService.loadUserByUsername(username);

    return new UsernamePasswordAuthenticationToken(
        memberContext, null, memberContext.getAuthorities());
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
  }
}

 

나는 패스워드 체크 예외 처리 로직을 추가했다.

 

 

1-2) CustomUserDetailsService 생성

CustomProvider 의 loadUserByUsername() 메서드 호출 후 UserDetails 타입 구현체를 반환을 담당하는 클래스이다. 이 클래스 또한 @Bean 으로 등록했고 간단한 예외 처리를 추가해서 구현했다. 

Security FilterChain() 에서 CustomUserDetailsService 를 꼭 등록해줘야 한다.

... 중략

    http.oauth2Login()
        .loginPage("/loginForm")
        .userInfoEndpoint()
        .userService(oAuth2UserDetailsService); // 이 부분 !!

... 중략
@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {

  private final LoginRepository loginRepository;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    /**
     * Provider 로 부터 호출받아 Authentication 내부를 채우고 다시 Provider 로 반환한다.
     */
    Member findMember = loginRepository.findByUsername(username)
        .orElseThrow(() -> new CustomException(ExceptionMessage.NOT_FOUND_MEMBER, HttpStatus.BAD_REQUEST));

    return new MemberContext(findMember);
  }
}

 

 

2-1) OAuth2 Login 을 위한 CustomOAuth2UserDetailsService 추가

CustomUserDetailsService 와 동일하게 역시 Manager 를 대신해 실제 인증 로직을 처리한다. Form Login 과 비슷하지만 다른데, Form Login 의 loadUsername() 메서드는 자체 회원가입 처리를 담당하지 않는 인증 과정이라면, OAuth2 Login 의 loadUser() 는 회원가입 처리 + 중복 유저 검증 + 인증 과정을 담당하는 것의 차이가 있다. 역시 Bean 객체로 등록하고 구현 ! 아래 코드는 다중 OAuth2 Login 구현을 위한 코드가 추가되어있는데, 2-2) 에서 자세히 작성해보자.

package com.market.carrot.login.config.customAuthentication.oauth2;

import ...

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserDetailsService extends DefaultOAuth2UserService {

  private final LoginRepository loginRepository;
  private Member member;

  @Override
  public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    OAuth2User oAuth2User = super.loadUser(userRequest);
    String providerName = userRequest.getClientRegistration().getRegistrationId();
    CheckOAuthProvider OAuthType = checkType(providerName);
    OAuth2UserInfo userInfo = OAuthType.getUserInfo(oAuth2User);

    String providerId = userInfo.getProviderId();
    String username = providerName + "_" + providerId;
    String password = "";
    String email = userInfo.getEmail();
    Role role = Role.USER;

    Optional<Member> findMember = loginRepository.findByUsername(username);

    // 없다면 회원을 만들고 DB 에 저장 후 MemberContext 에 넣기위해 반환.
    if (findMember.isEmpty()) {
      member = saveMemberInfo(username, password, email, role);
    }

    // 이미 있다면 그 회원을 가져오고 member 변수에 저장
    findMember.ifPresent(value -> member = value);

    return new MemberContext(member, oAuth2User.getAttributes());
  }

  private CheckOAuthProvider checkType(String provider) {
    return new OAuthFactory().getProvider(provider);
  }

  private Member saveMemberInfo(String username, String password, String email, Role role) {
    Member member = Member.createMember(
        username, password, email, role);

    loginRepository.save(member);

    return member;
  }
}

 

 

2-1) 다중 OAuth2 Login 구현

아래 코드는 다중 OAuth2 Login 을 구현하기 전 코드인데, attribute 값을 추출하기 위해 하드코딩이 되어있는 딱딱한 코드이다. 여러 플랫폼에서 OAuth2 Login 을 제공하고 있는데, 모두 Attribute 값이 다르기 때문에 하드코딩 해버리면 가독성이 굉장히 떨어지게 될 것이다. 따라서 다중으로 OAuth2 API 를 사용하게 되면 "sub", "email" 과 같이 직접적으로 문자열을 사용하지 않게 리팩토링이 필요하다.

... 중략

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
  OAuth2User oAuth2User = super.loadUser(userRequest);
  String providerName = userRequest.getClientRegistration().getRegistrationId();
  String providerId = oAuth2User.getAttribute("sub");
  String username = providerName + "_" + providerId;
  String password = "";
  String email = oAuth2User.getAttribute("email");
  Role role = Role.USER;
  
  ... 중략

 

 

2-2) 코드 리팩토링 과정

디자인 패턴 중 Factory pattern 을 사용해 리팩토링 해보자 !

먼저 어떤 플랫폼에서 로그인 하는지 판단해야한다. 플랫폼을 Provider 라고 생각하고, 여러 Provider 를 모아두는 Factory 를 만들자.

public interface ProviderFactory {

  CheckOAuthProvider getProvider(String provider);
}
public class OAuthFactory implements ProviderFactory {

  @Override
  public CheckOAuthProvider getProvider(String provider) {
    if (provider.equals("google")) {
      return new ProviderByGoogle();

    } else if (provider.equals("github")) {
      return new ProviderByGitHub();
    }

    return null;
  }
}

 

이렇게 하면 매개변수로 어떤 Provider 인지 받으면 getProvider() 를 통해 알맞은 Provider 객체를 생성할 수 있다.

이제 attribute 를 셋팅할 수 있는 ~UserInfo() 객체를 생성할 수 있도록 Factory 를 통해 생성된 구현체를 만들어주자.

public interface CheckOAuthProvider {

  OAuth2UserInfo getUserInfo(OAuth2User oAuth2User);
}
public class ProviderByGoogle implements CheckOAuthProvider {

  @Override
  public OAuth2UserInfo getUserInfo(OAuth2User oAuth2User) {
    return new GoogleUserInfo(oAuth2User.getAttributes());
  }
}

------

public class ProviderByGitHub implements CheckOAuthProvider {
	
  @Override
  public OAuth2UserInfo getUserInfo() {
    return new GitHubUserInfo(oAuth2user.getAttributes());
  }
}

 

 

 

마지막으로 attribute 를 셋팅할 수 있도록 ~UserInfo 객체를 만들어주면 끝!

public interface OAuth2UserInfo {

  String getProviderId();

  String getEmail();

  String getName();
}
@RequiredArgsConstructor
public class GoogleUserInfo implements OAuth2UserInfo {

  private final Map<String, Object> attributes;

  @Override
  public String getProviderId() {
    return (String) attributes.get("sub");
  }

  @Override
  public String getEmail() {
    return (String) attributes.get("email");
  }

  @Override
  public String getName() {
    return (String) attributes.get("name");
  }
}

 

 

2-3) 코드 리팩토링 결과

리팩토링을 거쳐 클라이언트 코드인 loadUser() 에서 if - else 와 같은 분기문 없이, 그리고 문자열 없이 알맞은 Provider 에 맞춤 객체를 생성해 처리할 수 있도록 개선되었다.

리팩토링 이전 코드)

... 중략

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
  OAuth2User oAuth2User = super.loadUser(userRequest);
  String providerName = userRequest.getClientRegistration().getRegistrationId();
  String providerId = oAuth2User.getAttribute("sub");
  String username = providerName + "_" + providerId;
  String password = "";
  String email = oAuth2User.getAttribute("email");
  Role role = Role.USER;
  
  ... 중략

 

리팩토링 이후 코드)

... 중략

@Override
  public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    OAuth2User oAuth2User = super.loadUser(userRequest);
    String providerName = userRequest.getClientRegistration().getRegistrationId();
    
    // 적용 부분
    CheckOAuthProvider OAuthType = checkType(providerName);
    OAuth2UserInfo userInfo = OAuthType.getUserInfo(oAuth2User);
    //

    String providerId = userInfo.getProviderId();
    String username = providerName + "_" + providerId;
    String password = "";
    String email = userInfo.getEmail();
    Role role = Role.USER;
    
... 중략

'프로젝트 기록 > 당근마켓 API 서버 기록' 카테고리의 다른 글

[당근마켓 API 서버 Project] 지금까지 구현 내역과 앞으로 구현 계획  (0) 2023.03.08
    '프로젝트 기록/당근마켓 API 서버 기록' 카테고리의 다른 글
    • [당근마켓 API 서버 Project] 지금까지 구현 내역과 앞으로 구현 계획
    Tany
    Tany
    내가 보려고 만드는 백엔드 기록장 Github https://github.com/juni8453

    티스토리툴바