개발/JAVA

[자바웹개발워크북] 8-1. 소셜 로그인

독코더 2023. 2. 15. 14:04
반응형

/*

 *

 * 자바웹개발워크북의 내용을 정리하기 위한 포스팅입니다.

 */

 

이번엔 흔히 소셜로그인이라고 불리는 외부 서비스로 사용자 연동을 처리해보겠습니다. 대부분의 소셜로그인은 OAuth2라는 방식을 이용해서 데이터를 주고 받아 사용자의 정보를 전달하는 방식입니다. 국내에서 많이 사용하는 카카오서비스를 이용해서 처리해 보겠습니다.

 

카카오 로그인 설정

카카오 로그인을 이용하기 위해서는 우선 'kakao developers(https://developers.kakao.com/)'에서 애플리케이션을 등록해야 합니다.

'kakao developers'상단에 [내 애플리케이션]메뉴를 이용해서 새로운 애플리케이션을 추가합니다.

애플리케이션이 추가되면 연동에 필요한 여러종류의 키가 생성되는데 이 중 REST API키는 뒤에서 사용할일이 있으므로 따로 보관합니다.

화면 아래쪽에 플랫폼을 지정하는 부분이 있는데 'Web'을 지정하고 '사이트도메인'은 'http:localhost:8080'을 지정합니다.(나중에 변경)

본인은 8082로 사용;

화면 왼쪽 메뉴의 '카카오 로그인'항목을 살펴보면 '카카오 로그인'은 'OFF'상태로 되어있는것을 볼수 있습니다.

아직 설정된 항목들이 없으므로 [취소]를 선택합니다.

카카오 로그인을 진행하려면 '동의항목'을 지정합니다. '닉네임''카카오 계정'을 지정합니다.

'동의항목'을 지정한 후에 변경사항을 반영하기 위해서 '카카오 로그인'이 활성화 되어있다면 한번 [OFF]한 후 다시 [ON]하면 됩니다.

로그인을 활성화 한 후에 가장 중요한 부분은 'Redirect URI'를 지정하는것입니다.

'http://localhost:8080/login/oauth2/code/kakao'로 지정합니다.

본인은 8082;

로그인을 활성화 한 후에는 '카카오 로그인'의 '보안'항목에서 'Client Secret'으로 '키'를 생성해야 합니다.

이때 생성된 키는 나중에 다시 사용해야하므로 따로 보관해두는것이 좋습니다.

 

프로젝트를 위한 설정

프로젝트에서 소셜로그인을 이용하려면 OAuth2 Client라는 라이브러리를 이용해야합니다.

프로젝트 생성시에 Security항목에서 선택할수 있습니다. 이미 프로젝트를 생성한 이후라면 buile.gradle에 라이브러리를 추가합니다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

 

소셜 로그인이 처리되는 과정 OAuth2

대부분의 소셜로그인은 OAuth2방식으로 데이터를 처리합니다.

OAuth2는 문자열로 구성된 '토큰'을 주고받는 방식으로 토큰을 발행하거나 검사하는 방식을 통해서 서비스 간 데이터를 교환합니다.

Application ----------------①-------------->
<-------------인가코드-----------
/oauth/authorize
------------------------------>
<--------Access Token----------
https://kauth.kakao.com/oauth/token
------------------------------>
<--------------------------------
https://kapi.kakao.com/v2/user/me

 

①의 과정은 놀이공원에서 '입장권'을 사는것과 유사합니다. 설정할때 받은 'REST API키'를 이용해서 인가코드를 받습니다.

 

인가코드는 '리다이렉트 URI'로 지정된곳으로 전달됩니다.스프링부트의 OAuth2 Client를 이용하면 '리다이렉트URI'는 정해진 패턴을 사용하게 됩니다. ①에서 받은 인가코드는 ②에서 자신의 비밀키(Client Secret)와 같이 이용되어 Access Token을 생성할때 사용됩니다.

 

Access Token은 놀이공원에 있는 특정한 놀이기구를 타기위한 승차권과 같습니다.

이름그대로 '원하는 데이터에 접근할수있는 권한' 역할을 합니다. 탈취당할 위험이 있기에 유효기간은 짧게 설정하는것이 일반적입니다.

 

Access Token을 얻으면 ③과 같이 사용자정보를 요청합니다. 이때 사용자가 동의했던 정보들을 얻어오는데 주로 이메일을 얻습니다.

 

스프링부트에서 로그인 연동 설정

application.properties에 다음과 같은 내용들을 추가합니다.

spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me

spring.security.oauth2.client.registration.kakao.client-name=kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8082/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.client-id=REST키

spring.security.oauth2.client.registration.kakao.client-secret=설정된 비밀키
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email

REST키, 설정된 비밀키, profile_nickname, account_email은 위 작업에서 생성된 키값들과 닉네임, 카카오계정값을 넣어주세요.

 

CustomSecurityConfig설정변경

스프링부트의 OAuth2 Client를 이용할때는 설정관련 코드에 OAuth2로그인을 사용한다는 설정을 추가해야 합니다.

CustomSecurityConfig설정에 oauth2Login()을 추가합니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    log.info("--------------- config ---------------");

    // 커스텀 로그인 페이지
    http.formLogin().loginPage("/member/login");
    // CSRF토큰 비활성화
    http.csrf().disable();

    ... 생략 ...
    
    http.oauth2Login().loginPage("/member/login");

    return http.build();
}

 

login.html에서 적용

login.html에는 카카오 로그인을 위한 링크를 처리합니다.

</form>
<div>
    <a href="/oauth2/authorization/kakao">KAKAO</a>
</div>

앞선 설정이 모두 반영되면 로그인화면 링크로 카카오의 로그인 서비스와 연동이 가능해집니다.

 

로그인 연동 후 이메일 구하기

앞선 과정을 통해서 카카오 서비스의 로그인까지 성공해도 실제 게시물 작성에는 문제가 생기게 됩니다.

로그인된 후에 전달하는 정보가 UserDetails타입이 아니기 때문입니다. 이를 처리하려면 UserDetailsService인터페이스를 구현하듯이 OAuth2UserService인터페이스를 구현해야 합니다.

OAuth2UserService인터페이스는 그자체를 구현할수도 있겠지만 하위 클래스인 DefaultOAuth2UserService를 상속해서 구현하는 방식이 가장 간단합니다. 프로젝트의 security패키지에 CustomOAuth2UserService클래스를 선언합니다.

 

loadUser()에서는 카카오 서비스와 연동된 결과를 OAuth2UserRequest로 처리하기때문에 이를 이용해서 원하는 정보(이메일)를 추출해야 합니다. 우선 카카오 로그인 후에 어떤 정보들이 전달되었는지 확인해보겠습니다.

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

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

        log.info("userRequest ...");
        log.info(userRequest);
        
        log.info("oaurh2 user....................");

        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        String clientName = clientRegistration.getClientName();
        
        log.info("NAME :" + clientName);
        
        OAuth2User oAuth2User = super.loadUser(userRequest);
        Map<String, Object> paramMap = oAuth2User.getAttributes();
        
        paramMap.forEach((k, v) -> {
            log.info(".....................................");
            log.info(k + ":" + v);
        });

        return oAuth2User;
    }
}

카카오 서비스의 경우 kakao_account라는 키로 접근하는 정보중에 이메일 관련 정보를 가지고 있습니다.

DefaultOAuth2UserService는 카카오뿐아니라 구글이나 페이스북 등 다양한 소설로그인을 사용 가능하므로 이를 각각의 소셜서비스에 맞게 clientResgistration.getClientName()를 통해 처리하도록 CustomOAuth2UserService를 수정합니다.

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

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

        log.info("userRequest ...");
        log.info(userRequest);

        log.info("oaurh2 user....................");

        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        String clientName = clientRegistration.getClientName();

        log.info("NAME :" + clientName);

        OAuth2User oAuth2User = super.loadUser(userRequest);
        Map<String, Object> paramMap = oAuth2User.getAttributes();
        
        String email = null;
        
        switch (clientName) {
            case "kakao" :
                email = getKakaoEmail(paramMap);
                break;
        }
        
        log.info("=================================");
        log.info(email);
        log.info("=================================");
        
        return oAuth2User;
    }
    
    private String getKakaoEmail(Map<String, Object> paramap) {
        log.info("KAKAO====================================");
        
        Object value = paramap.get("kakao_account");

        LinkedHashMap accountMap = (LinkedHashMap) value;
        
        String email = (String) accountMap.get("email");
        
        return email;
    }
}

 

소셜 로그인 처리

소셜로그인에 사용한 이메일이 존재하는 경우와 그렇지않은 경우에 어떻게 처리해야할지 결정이 필요합니다.

 

만일 소셜 로그인에 사용한 이메일과 같은 이메일을 가진 회원이 있다면 소셜로그인만으로 로그인 자체가 완료되어야 합니다.

이건 MemberSecurityDTO를 UserDetails인터페이스뿐만 아니라 OAuth2User인터페이스도 같이 사용할수 있도록 구성하면 됩니다.

 

해당 이메일을 가진 사용자가 없을때는 새로운 회원으로 간주하고 Member도메인객체를 직접 생성해서 저장한 후 MemberSecurityDTO를 생성해서 반환합니다. 자동으로 회원 데이터가 추가될때는 social값을 true로 지정합니다.

 

만일 악의적인 사용자가 현재 사용자의 이메일을 안다고 해도 직접 로그인을 할때는 Member의 social설정이 false인 경우만 조회되므로 로그인이 되지않습니다. 대신에 소셜서비스를 통해 로그인한 사용자의 경우 일반 로그인을 하기위해서는 일반회원으로 전환할수 있는 화면이 제공되어야 합니다.

 

MemberRepository에는 email을 이용해서 회원정보를 찾을수있도록 메소드를 추가합니다.

@EntityGraph(attributePaths = "roleSet")
Optional<Member> findByEmail(String email);

 

MemberSecurityDTO의 수정

MemberSecurityDTO는 UserDetails타입만을 구현했지만 소셜로그인에서도 사용할수 있도록 OAuth2User인터페이스를 추가합니다.

@Getter
@Setter
@ToString
public class MemberSecurityDTO extends User implements OAuth2User {

    public String mid;
    public String mpw;
    public String email;
    public boolean del;
    public boolean social;
    
    private Map<String, Object> props; // 소셜 로그인 정보

    public MemberSecurityDTO(String username, String password, String email, boolean del, boolean social, Collection<? extends GrantedAuthority> authorities){
        super(username, password, authorities);

        this.mid = username;
        this.mpw = password;
        this.email = email;
        this.del = del;
        this.social = social;
    }

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

    @Override
    public String getName() {
        return this.mid;
    }
    
}

 

CustomOAuth2UserService 수정

CustomOAuth2UserService는 카카오 서비스에서 얻어온 이메일을 이용해 같은 이메일을 가진 사용자를 찾아보고,

없는 경우에는 자동으로 회원가입을 하고 MemberSecurityDTO를 반환하도록 구성합니다.

@Log4j2
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

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

        log.info("userRequest ...");
        log.info(userRequest);

        log.info("oauth2 user....................");
        
        ClientRegistration clientRegistration = userRequest.getClientRegistration();
        
        String clientName = clientRegistration.getClientName();

        log.info("NAME :" + clientName);
        OAuth2User oAuth2User = super.loadUser(userRequest);
        Map<String, Object> paramMap = oAuth2User.getAttributes();

        String email = null;

        switch (clientName) {
            case "kakao" :
                email = getKakaoEmail(paramMap);
                break;
        }

        log.info("=================================");
        log.info(email);
        log.info("=================================");

        return generatedDTO(email, paramMap);
    }
    
    private MemberSecurityDTO generatedDTO(String email, Map<String, Object> params) {

        Optional<Member> result = memberRepository.findByEmail(email);
        
        // 데이터베이스에 해당 이메일 사용자가 없다면
        if (result.isEmpty()) {
            // 회원추가 -- mid는 이메일주소 / 패스워드는 1111
            Member member = Member.builder()
                    .mid(email)
                    .mpw(passwordEncoder.encode("1111"))
                    .email(email)
                    .social(true)
                    .build();
            member.addRole(MemberRole.USER);
            memberRepository.save(member);
            
            // MemberSecurityDTO 구성 및 반환
            MemberSecurityDTO memberSecurityDTO = new MemberSecurityDTO(
                    email, "1111", email, false, true, Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
            memberSecurityDTO.setProps(params);
            
            return memberSecurityDTO;
        } else {
            Member member = result.get();
            MemberSecurityDTO memberSecurityDTO = new MemberSecurityDTO(
                    member.getMid(), member.getMpw(), member.getEmail(), member.isDel(), member.isSocial(),
                    member.getRoleSet().stream().map(memberRole -> new SimpleGrantedAuthority("ROLE_" + memberRole.name())).collect(Collectors.toList())
            );
            
            return memberSecurityDTO;
        }
    }

    private String getKakaoEmail(Map<String, Object> paramap) {
        log.info("KAKAO====================================");

        Object value = paramap.get("kakao_account");

        LinkedHashMap accountMap = (LinkedHashMap) value;

        String email = (String) accountMap.get("email");

        return email;
    }
}

generateDTO()는 이미 회원가입이 된 회원에 대해서는 기존 정보를 반환하고 새롭게 소셜로그인된 사용자는 자동으로 회원가입을 처리하게 됩니다. 어떤 상황이든 MemberSercurityDTO를 반환하기 때문에 사용자는 추가적인 작업없이 서비를 이용할수 있습니다.

소셜로그인 사용자의 패스워드가 '1111'로 고정되기는 하지만 일반 로그인을 통해서는 소셜사용자는 로그인이 제한됩니다.

그러므로 소셜로그인 사용자는 소셜로그인으로 로그인한 후에 사용자 정보를 일반 회원으로 수정하도록 해야합니다.

 

AuthenricationSuccessHandler를 이용한 로그인 후처리

스프링시큐리티는 로그인 성공과 실패를 커스터마이징할수 있도록 AuthenticationSuccessHandler와 AuthenticationFaileHandler 인터페이스를 제공합니다. 소셜로그인성공 후에 현재 사용자의 패스워드에 따라서 사용자정보를 수정하거나 특정한 페이지로 이동하는 방법을 처리해야 하는데 AuthenticationSuccessHandler를 이용해서 이를 처리하겠습니다.

 

security의 handler패키지에 CustomSocialLoginSuccessHandler클래스를 추가합니다.

소셜로그인 사용자의 패스워드가 '1111'로 처리된 경우를 구분해서 처리하도록 다름과 같이 구성합니다.

@Log4j2
@RequiredArgsConstructor
public class CustromSocialLoginSuccessHandler implements AuthenticationSuccessHandler {
    
    private final PasswordEncoder passwordEncoder;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        
        log.info("----------------------------------------------------------");
        log.info("CustomLoginSuccessHandler onAuthenticationSuccess.........");
        log.info(authentication.getPrincipal());

        MemberSecurityDTO memberSecurityDTO = (MemberSecurityDTO) authentication.getPrincipal();
        
        String encodePw = memberSecurityDTO.getMpw();
        
        // 소셜로그인이고 회원의 패스워드가 1111
        if (memberSecurityDTO.isSocial() && (memberSecurityDTO.getMpw().equals("1111") || passwordEncoder.matches("1111", memberSecurityDTO.getMpw()))) {
            
            log.info("Redirect to Member modify");
            response.sendRedirect("/member/modify");
            
            return;
            
        } else {
            response.sendRedirect("/board/list");
        }
    }
}

시큐리티설정에 CustomSocialLoginSuccessHandler를 추가해야하므로 CustomSecurityConfig를 수정해야 합니다.

@Bean
public AuthenticationSuccessHandler authenticationSuccessHandler() {
    return new CustromSocialLoginSuccessHandler(passwordEncoder());
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    log.info("--------------- config ---------------");

    // 커스텀 로그인 페이지
    http.formLogin().loginPage("/member/login");
    // CSRF토큰 비활성화
    http.csrf().disable();

    http.rememberMe()
            .key("12345678")
            .tokenRepository(persistentTokenRepository())
            .userDetailsService(userDetailsService)
            .tokenValiditySeconds(60*60*24*30);

    http.exceptionHandling().accessDeniedHandler(accessDeniedHandler()); // 403

    http.oauth2Login()
            .loginPage("/member/login")
            .successHandler(authenticationSuccessHandler());

    return http.build();
}

프로젝트를 실행하고 소셜로그인으로 로그인하면 '/member/modify'로 이동하게 됩니다.

 

MemberRepository의 패스워드 업데이트

소셜로그인으로 로그인하면 패스워드가 '1111'을 인코딩한 값으로 저장되므로 패스워드가 변경된 상황에서의 테스트가 불가능합니다.

이를위해서 MemberReposiroty에 사용자의 패스워드를 수정할수있는 기능을 추가합니다.

@Modifying
@Transactional
@Query("update Member m set m.mpw = :mpw where m.mid = :mid")
void updatePassword(@Param("mpw") String password, @Param("mid") String mid);

@Query는 주로 select할때 이용하지만 @Modifying과 같이 사용하면 DML(insert/update/delete)처리도 가능합니다.

반응형