본문 바로가기

Proj/Tripot

Spring OAuth2를 사용하여 소셜 로그인 구현하기 (feat.추가 정보 입력)

1. 개요

Tripot 프로젝트에서는 소셜 로그인만을 사용하여 회원의 정보를 받고자 한다. 이에 따른 구현 과정을 작성한다.

2. 개발 과정

2-1 추가 정보 입력 로직

해당 로직을 어떻게 구현해야 하는지 떠올리는 데 생각보다 오래 걸렸다. 만약 어떤 회원이 추가정보 입력 화면에서 앱을 종료해버릴 경우 이를 확인할 방법이 없기 때문이다. 그러다 프론트 분과 대화해 본 결과 응답 바디 값에 따라 서로 다른 페이지로 이동하는 것이 가능하다고 했다. 이에 따라 다음과 같이 로직을 작성했다.

 

이후 개발을 하다보니 소셜 로그인 진행중에 회원 가입 처리가 되어야 한다는 것을 알고 이를 조금씩 다듬었다.

2-2 로그인 진행 과정: 설정

우선 카카오 로그인에 관련된 주소 및 정보를 yml 파일에 저장한다.

  security:
    oauth2:
      client:
        registration:
          kakao:
            client-name: kakao
            client-id: ${KAKAO_CLIENT_ID}
            client-secret: ${KAKAO_SECRET_KEY}
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            scope: # https://developers.kakao.com/docs/latest/ko/kakaologin/common#user-info
              - profile_nickname
            redirect-uri: "http://localhost:8080/login/oauth2/code/kakao"
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

 

authorization-uri에서 로그인 진행, 코드를 받아 token-uri에 보내 resource server에 접근하기 위한 코드를 받아오고, 이를 user-info-uri에 보내 사용자 정보를 가져온다. Spring OAuth2는 이 과정을 자동으로 진행해준다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

	...

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)

                //oauth2
                .oauth2Login((oauth2) -> oauth2.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
                                .userService(customOauth2UserService))
                        .successHandler(customSuccessHandler)
                        .loginProcessingUrl("/login/oauth2/**")
                )


                .sessionManagement((session) -> session.
                        sessionCreationPolicy(SessionCreationPolicy.STATELESS))

                //filter
                .addFilterAfter(new JWTFilter(jwtUtil, userDetailsService), OAuth2LoginAuthenticationFilter.class)

                //uri 권한 설정
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/login/**").permitAll()
                        .requestMatchers("/oauth2/**").permitAll()
                        .anyRequest().authenticated());


        return httpSecurity.build();

    }


}

이와 관련된 설정들을 SecurityConfig.java에 작성해주었다.

2-3. 로그인 진행 과정: 진행

소셜 로그인에 성공하면 DefaultOAuth2UserService.loadUser(OAuth2UserRequest userRequest)에서 과정이 완료되어 받아온 사용자 정보를 스프링 시큐리티 세션에 넣어준다. 이를 재정의해주자.

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        log.info("[CustomOAuth2UserService] loadUser 호출");

        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        log.info("[CustomOAuth2UserService] 소셜로그인 등록ID: {}", registrationId);
        OAuth2UserInfo oAuth2UserInfo;
        if (registrationId.equals("kakao")) {
            oAuth2UserInfo = new KakaoOAuth2UserInfo(userRequest.getAccessToken().getTokenValue(), oAuth2User.getAttributes());
        } else {
            return null;
        }

        //카카오 공식문서: 인증되지 않은 이메일 주소는 서비스에서 발송한 이메일을 전달받지 못할 수 있습니다. 또한 이메일은 사용자 요청에 따라 변경될 수 있으므로, ID 또는 동일 사용자 여부 판단 기준으로 사용하는 것을 권장하지 않습니다.
        String username = oAuth2UserInfo.getProvider() + " " + oAuth2UserInfo.getId();




        //새 회원이면 추가 정보 입력 후 1차 회원가입, 기존 회원이면 정보만 업데이트
        boolean existMember = memberRepository.existsByUsername(username);

        Member member;

        if (!existMember) {
            //PREACTIVE 상태 회원 생성

            log.info("[CustomOAuth2UserService] 신규 회원 생성 username: {}, status: {}", username, MemberStatus.PREACTIVE);

            member = Member.builder()
                    .nickname(oAuth2UserInfo.getNickname())         //일단 전송 후 수정하는 방식
                    .username(username)
                    .role(MemberRole.USER)
                    //사용자 동의 정보: activeMember 기능에 추가
                    .signUpType(SignUpType.valueOf(registrationId.toUpperCase()))
                    .build();

            memberRepository.save(member);

        } else {
            //조건문에서 있는지 검증했음
            member = memberRepository.findByUsername(username).get();
            log.info("[CustomOAuth2UserService] 기존 회원 username: {}, status: {}", username, member.getStatus());
        }


        return new UserPrincipal(member, oAuth2UserInfo);
    }


}

하나씩 뜯어 살펴보자.

        log.info("[CustomOAuth2UserService] loadUser 호출");

        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        log.info("[CustomOAuth2UserService] 소셜로그인 등록ID: {}", registrationId);
        OAuth2UserInfo oAuth2UserInfo;
        if (registrationId.equals("kakao")) {
            oAuth2UserInfo = new KakaoOAuth2UserInfo(userRequest.getAccessToken().getTokenValue(), oAuth2User.getAttributes());
        } else {
            return null;
        }

        //카카오 공식문서: 인증되지 않은 이메일 주소는 서비스에서 발송한 이메일을 전달받지 못할 수 있습니다. 또한 이메일은 사용자 요청에 따라 변경될 수 있으므로, ID 또는 동일 사용자 여부 판단 기준으로 사용하는 것을 권장하지 않습니다.
        String username = oAuth2UserInfo.getProvider() + " " + oAuth2UserInfo.getId();

우선 사용자 정보에서 어떤 경위로 로그인했는지에 따른 registrationId를 받고, 이에 따라 OAuth2UserInfo를 상속받은 객체를 생성한다. KakaoOAuth2UserInfo는 다음과 같다.

@Getter
public class KakaoOAuth2UserInfo implements OAuth2UserInfo {

    private final Map<String, Object> attributes;
    private final String accessToken;
    private final String id;
    private final String email;
    private final String nickname;


    public KakaoOAuth2UserInfo(String accessToken, Map<String, Object> attributes) {
        this.accessToken = accessToken;
        // attributes 맵의 kakao_account 키의 값에 실제 attributes 맵이 할당되어 있음
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
        this.attributes = kakaoProfile;

        this.id = ((Long) attributes.get("id")).toString();
        this.email = (String) kakaoAccount.get("email");
        this.nickname = (String) kakaoProfile.get("nickname");
        this.attributes.put("id", id);
        this.attributes.put("email", this.email);
        this.attributes.put("nickname", this.nickname);
    }

    @Override
    public OAuth2Provider getProvider() {
        return OAuth2Provider.KAKAO;
    }


}

제공자에 따라 유저 정보의 구조가 다르므로 이처럼 상속받아 따로 처리해주었다.

 //카카오 공식문서: 인증되지 않은 이메일 주소는 서비스에서 발송한 이메일을 전달받지 못할 수 있습니다. 또한 이메일은 사용자 요청에 따라 변경될 수 있으므로, ID 또는 동일 사용자 여부 판단 기준으로 사용하는 것을 권장하지 않습니다.
        String username = oAuth2UserInfo.getProvider() + " " + oAuth2UserInfo.getId();




        //새 회원이면 추가 정보 입력 후 1차 회원가입, 기존 회원이면 정보만 업데이트
        boolean existMember = memberRepository.existsByUsername(username);

        Member member;

        if (!existMember) {
            //PREACTIVE 상태 회원 생성

            log.info("[CustomOAuth2UserService] 신규 회원 생성 username: {}, status: {}", username, MemberStatus.PREACTIVE);

            member = Member.builder()
                    .nickname(oAuth2UserInfo.getNickname())         //일단 전송 후 수정하는 방식
                    .username(username)
                    .role(MemberRole.USER)
                    //사용자 동의 정보: activeMember 기능에 추가
                    .signUpType(SignUpType.valueOf(registrationId.toUpperCase()))
                    .build();

            memberRepository.save(member);

        } else {
            //조건문에서 있는지 검증했음
            member = memberRepository.findByUsername(username).get();
            log.info("[CustomOAuth2UserService] 기존 회원 username: {}, status: {}", username, member.getStatus());
        }


        return new UserPrincipal(member, oAuth2UserInfo);

받았다면 회원에 따른 처리를 진행한다. 신규 사용자일 경우 회원가입을, 그렇지 않을 경우 해당 회원을 repository에서 꺼내어 리턴한다.

2-4 로그인 성공 시 로직

    //oauth 로그인 성공 시 로직
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        //인증된 사용자 정보 가져오기
        UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
        log.info("[{}} 사용자 정보 로딩: {}", getClass().getName(), principal);

        Member member = principal.getMember();
        String username = principal.getUsername();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        //JWT 생성
        LoginCreateJwtDto loginCreateJwtDto = LoginCreateJwtDto.builder()
                .id(member.getId())
                .username(username)
                .role(role)
                .requestTimeMs(LocalDateTime.now())
                .build();

        String accessToken = jwtUtil.createJwt(loginCreateJwtDto, "access");
        String refreshToken = jwtUtil.createJwt(loginCreateJwtDto, "refresh");
        log.info("[{}} JWT 토큰 생성 access: {}, refresh: {}", getClass().getName(), accessToken, refreshToken);


        //응답에 JWT 추가
        response.addHeader("Authorization", "Bearer " + accessToken);
        response.addHeader("Refresh_token", "Bearer " + refreshToken);
        log.info("[{}} 응답 헤더에 토큰 담기", getClass().getName());

        //응답에 해당 회원의 추가정보 기입 여부 추가
        log.info("[{}} 응답에 해당 회원의 추가정보 기입 여부 추가", getClass().getName());
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        CheckActiveMemberDto checkActiveMemberDto;

        if (member.getStatus() == MemberStatus.PREACTIVE) {
            checkActiveMemberDto = new CheckActiveMemberDto(member.getNickname(), false);
        }else{
            checkActiveMemberDto = new CheckActiveMemberDto(member.getNickname(), true);
        }

        response.getWriter().write(objectMapper.writeValueAsString(checkActiveMemberDto));

        //redis에 refreshToken 저장하기((key, value): (token, username))
        //Bearer을 포함하지 않음
        redisUtil.setDataExpire(refreshToken, username, 8640_0000L * 30 * 6);

    }

loadUser에서 세션에 담은 유저 정보를 가져와, 이를 기반으로 JWT를 만들어 헤더에 담는다. 

        //응답에 JWT 추가
        response.addHeader("Authorization", "Bearer " + accessToken);
        response.addHeader("Refresh_token", "Bearer " + refreshToken);
        log.info("[{}} 응답 헤더에 토큰 담기", getClass().getName());

        //응답에 해당 회원의 추가정보 기입 여부 추가
        log.info("[{}} 응답에 해당 회원의 추가정보 기입 여부 추가", getClass().getName());
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        CheckActiveMemberDto checkActiveMemberDto;

        if (member.getStatus() == MemberStatus.PREACTIVE) {
            checkActiveMemberDto = new CheckActiveMemberDto(member.getNickname(), false);
        }else{
            checkActiveMemberDto = new CheckActiveMemberDto(member.getNickname(), true);
        }

        response.getWriter().write(objectMapper.writeValueAsString(checkActiveMemberDto));

우선 회원이 추가 정보를 입력하지 않은 상태를 PREACTIVE, 입력한 상태를 ACTIVE로 정의하였다. 

public record CheckActiveMemberDto (
        String nickname,
        Boolean isActivate

)

DTO는 위와 같다. 추가 정보를 입력하지 전이라면 불린 값을 false, 입력하였다면 true를 반환하도록 하였다.

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    /**
     * PREACTIVE 상태의 회원을 활성화시키는 기능
     * @param userPrincipal
     * @param activateMemberDto
     * @return 회원 활성화 완료
     */
    @PatchMapping("/api/v1/members/activate")
    public CommonResponse<String> activeMember(@AuthenticationPrincipal UserPrincipal userPrincipal, @RequestBody ActivateMemberDto activateMemberDto) {
        memberService.activateMember(userPrincipal, activateMemberDto);

//        return CommonResponse.of(ACTIVATE_MEMBER.getCustomCode(), ACTIVATE_MEMBER.getCustomMessage(), null);
        return CommonResponse.success(ACTIVATE_MEMBER, null);
    }


}

이후 추가 정보를 받아 회원을 활성화하는 기능을 추가해주었다.

 

3. 여담

이렇게 구현을 하니 프론트에서 응답을 인식하지 못하고, 리다이렉트가 되지 않는 문제가 발생했다. 이에 대해 얘기해본 결과 프로젝트에 Spring OAuth2를 사용하지 않기로 하였다. 그러나 이를 사용하여 구현해보고 구구모 프로젝트의 코드에서 좀 개선할 여지를 찾은 것 같다고 느꼈다.