Spring Security를 이용한 통합 OAuth2 소셜 로그인 기능 구현(Kakao, Google, Naver)

유승욱·2024년 3월 4일
0

이번 시간에는 이전 포스팅들에서 공부한 spring security와 카카오 소셜로그인에서 나아가 여러 소셜 로그인들을 통합해서 처리할 수 있는 코드를 구현하려고 한다.

시큐리티 설정

build.gradle

SecurityConfig

@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터체인에 등록됨
public class SecurityConfig {

// 해당 메서드의 리턴되는 오브젝트를 IOC로 등록해준다.
    @Bean
    public BCryptPasswordEncoder encodePwd() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(httpSecurityCsrfConfigurer -> httpSecurityCsrfConfigurer.disable());

        http.authorizeHttpRequests(authorize -> {
            authorize
                    .requestMatchers("/user/**").authenticated()
                    .requestMatchers("/manager/**").hasAnyRole("MANAGER", "ADMIN")
                    .requestMatchers("/admin/**").hasAnyRole("ADMIN")
                    .anyRequest().permitAll();
        });

        http.formLogin(form -> {
            form.loginPage("/loginForm");
        });

        return http.build();
    }
}

현재 url에 따른 필요한 권한들을 다음과 같이 설정하였고,로그인 페이지는 "/loginForm" 경로로 설정하였다.

실습용 컨트롤러
IndexController

@Controller
@RequiredArgsConstructor
public class IndexController {

   private final UserRepository userRepository;
   private final BCryptPasswordEncoder passwordEncoder;

    @GetMapping({"", "/"})
    public String index() {
        return "index";
    }

    @GetMapping("/user")
    public @ResponseBody String user() {
        return "user";
    }

    @GetMapping("/admin")
    public @ResponseBody String admin() {
        return "admin";
    }

    @GetMapping("/manager")
    public @ResponseBody String manager() {
        return "manager";
    }

    @GetMapping("/loginForm")
    public String loginForm() {
        return "loginForm";
    }

     @GetMapping("/joinForm")
    public String joinForm() {
        return "joinForm";
    }

    @PostMapping("/join")
    public String join(User user) {
   
        return "redirect:/loginForm";
    }
}

시큐리티 회원가입

먼저 로그인을 시도할 User 객체를 만들어준다.
User

@Entity
@Data
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String username;
    private String password;
    private String email;
    private String role;
    @CreationTimestamp
    private Timestamp timestamp;
}

이후 회원가입을 진행하는데, 이전 시간에 배운 것처럼 spring security를 이용하면 비밀번호를 암호화해서 저장해야하기 때문에 passwordEncoder를 이용해 비밀번호를 암호화해 저장화주었다.
IndexController

 @PostMapping("/join")
    public String join(User user) {
        System.out.println("user = " + user);
        user.setRole("ROLE_USER");
        String rawPassword = user.getPassword();
        String encPassword = passwordEncoder.encode(rawPassword);
        user.setPassword(encPassword);
        userRepository.save(user); // 회원가입 잘됨. 비밀번호:1234 => 시큐리티로 로그인을 할 수 없음. 이유는 패스워드가 암호화가 안되었기 때문
        return "redirect:/loginForm";
    }

시큐리티 로그인

스프링 시큐리티를 통한 로그인 과정은 다음과 같다.

시큐리티가 /login을 낚아채서 로그인을 진행시킨다.
로그인 진행이 완료되면 시큐리티 session을 만들어준다. (Security ContextHolder)
오브젝트 => Authentication 타입 객체
Authentication 안에 User정보가 있어야 됨
User오브젝트 타입 => UserDetails 타입 객체
Security Session => Authentication => UserDetails(PrincipalDetails)

시큐리티 세션 안에는 Authentication(UserDetails) 타입만 저장 될 수 있기 때문에 User 객체를 UserDetails 객체로 바꿔줘야한다.

PrincipalDetails

@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {

    private final User user; // 콤포지션

    // 해당 유저의 권한을 리턴하는 곳
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });

        return collect;
    }

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

    @Override
    public String getUsername() {
        return user.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;
    }
}

Security Config 변경

/login 요청이 오면 자동으로 UserDetailsService 타입으로 IoC되어 있는 loadUserByUsername 함수가 실행되어 로그인 처리를 한다.

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    // 시큐리티 session(내부 Authentication(내부 UserDetails))
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("username = " + username);
        User userEntity = userRepository.findByUsername(username);
        if (userEntity != null) {
            return new PrincipalDetails(userEntity);
        }
        return null;
    }
}

구글 로그인 준비

먼저 구글 api 콘솔(https://console.cloud.google.com/apis/library?hl=ko)로 이동한다.

새 프로젝트 생성

OAuth 동의화면 선택



사용자 인증 정보 만들기




OAuth2 라이브러리를 사용하면 해당 주소를 고정으로 사용해야한다.

이후 생성되는 클라이언트 ID와 클라이언트 보안 비밀번호를 통해 사용자 정보를 받아올 수 있다.

application.yml

이전에 얻은 클라이언트 ID와 클라이언트 보안 비밀번호를 다음과 같이 적어준다.

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 147521920274-9vtvlo25jcel8ua0etd6bib2kipgvo99.apps.googleusercontent.com
            client-secret: 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
            scope:
              - email
              - profile

loginForm.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<form action="/login" method="post">
    <input type="text" name="username", placeholder="Username"/><br/>
    <input type="password", name="password", placeholder="Password"/><br/>
    <button>로그인</button>
</form>
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/joinForm">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>

google 소셜 로그인을 통해 사용자 정보를 받으려면 "/oauth2/authorization/google"라는 주소를 통해 인증을 받아야한다.

구글 회원 프로필 정보 받아오기 및 자동 회원가입

구글 로그인을 통해 로그인이 완료되면 다음의 과정이 필요하다.

구글 로그인 완료된 뒤의 후처리가 필요함.
1. 코드받기(인증됨)
2. 엑세스 토큰(사용자 정보에 접근할 권한받음)
3. 사용자 프로필 정보를 가져오고
4-1. 그 정보를 토대로 회원가입을 자동으로 진행시키기도 함
4-2. (이메일, 전화번호, 이름, 아이디) 쇼핑몰 -> (집주소), 백화점몰 -> (vip등급, 일반등급)

우선 일반 로그인 사용자와 소셜 로그인 사용자를 구분하기 위해 User 객체에 다음을 추가해준다.

userInfoEndpoint를 설정하면 소셜 로그인이 성공했을때 사용자 정보에 대한 후처리를 할 수 있다.

PrincipalOauth2UserService

@Service
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {

    @Autowired
    private BCryptPasswordEncoder bCryptPasswordEncoder;

    @Autowired
    private UserRepository userRepository;

    // 구글로부터 받은 userRequest 데이터에 대한 후처리되는 함수
    // 함수 종료시 @AuthenticationPrincipal 어노테이션이 만들어진다.
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        System.out.println("getClientRegistration = " + userRequest.getClientRegistration()); // registrationId로 어떤 OAuth로 로그인 했는지 확인 가능
        System.out.println("getAccessToken = " + userRequest.getAccessToken().getTokenValue());

        OAuth2User oAuth2User = super.loadUser(userRequest);
        // 구글로그인 버튼 클릭 -> 구글로그인창 -> 로그인을 완료 -> code를 리턴(OAuth2-Client 라이브러리) -> AccessToken 요청
        // userRequest 정보 -> 회원 프로필 받아야함(loadUser함수 호출) -> 구글로부터 회원프로필 받아준다.
        System.out.println("getAttributes = " + oAuth2User.getAttributes());

        String provider = userRequest.getClientRegistration().getRegistrationId(); // google
        String providerId = oAuth2User.getAttribute("sub");
        String username = provider + "_" + providerId; // google_10021320120
        String password = bCryptPasswordEncoder.encode("겟인데어");
        String email = oAuth2User.getAttribute("email");
        String role = "ROLE_USER";

        User userEntity = userRepository.findByUsername(username);

        if (userEntity == null) {
            System.out.println("구글 로그인이 최초입니다.");
            userEntity = User.builder()
                    .username(username)
                    .password(password)
                    .email(email)
                    .role(role)
                    .provider(provider)
                    .providerId(providerId)
                    .build();
            userRepository.save(userEntity);
        } else {
            System.out.println("구글 로그인을 이미 한적이 있습니다. 당신은 자동회원가입이 되어 있습니다.");
        }

        // 회원 가입을 강제로 진행해볼 예정
        return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
    }
}

UserDetails, OAuth2User 통합시키기

현재 시큐리티 세션에 들어 갈 수 있는 객체는 다음과 같다.

타입이 다른 두 객체가 들어갈 수 있기 때문에 컨트롤러에서는 사용자를 조회할 때 일반 로그인과 소셜 로그인을 구분하여 컨트롤러를 2개 만들어줘야하는 번거로움이 있다.

이를 해결하기 이해 PrincipalDetails 객체가 UserDetails, OAuth2User를 둘 다 implementation하게 만들면 불편함을 해결할 수 있다.

PrincipalDetails

@Data
public class PrincipalDetails implements UserDetails, OAuth2User {

    private User user; // 콤포지션
    private Map<String, Object> attributes;

    // 일반 로그인
    public PrincipalDetails(User user) {
        this.user = user;
    }
    // OAuth 로그인
    public PrincipalDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }


    // 해당 유저의 권한을 리턴하는 곳
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });

        return collect;
    }

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

    @Override
    public String getUsername() {
        return user.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;
    }

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

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


다음과 같이 일반 로그인은 provider와 providerId 값이 비어있지만, 소셜 로그인은 어떤 provider를 통해 어떤 소셜 로그인 환경에서 로그인 했는지 확인할 수 있다.

통합 로그인 환경 준비

현재는 구글 소셜 로그인에 맞춰 코드가 설계되어있다. 이를 카카오, 네이버 등에도 적용할 수 있게 바꾸어보겠다.

먼저 공통적인 정보를 저장할 인터페이스를 만들어준다.

이후 OAuth2UserInfo를 구현한 GoogleUserInfo 클래스를 만들어준다.

public class GoogleUserInfo implements OAuth2UserInfo{

    private Map<String, Object> attributes; // getAttributes()
    public GoogleUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @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");
    }
}

각각의 소셜 로그인 값에 약간의 차이들이 있기 때문에 이렇게 공통 인터페이스를 만들어두고 이를 각각의 환경에 맞게 설정하면 유지보수가 편리해진다.

loadUser

@Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        System.out.println("getClientRegistration = " + userRequest.getClientRegistration()); // registrationId로 어떤 OAuth로 로그인 했는지 확인 가능
        System.out.println("getAccessToken = " + userRequest.getAccessToken().getTokenValue());

        OAuth2User oAuth2User = super.loadUser(userRequest);
        // 구글로그인 버튼 클릭 -> 구글로그인창 -> 로그인을 완료 -> code를 리턴(OAuth2-Client 라이브러리) -> AccessToken 요청
        // userRequest 정보 -> 회원 프로필 받아야함(loadUser함수 호출) -> 구글로부터 회원프로필 받아준다.
        System.out.println("getAttributes = " + oAuth2User.getAttributes());


        String provider = userRequest.getClientRegistration().getRegistrationId(); // google
        String providerId = oAuth2User.getAttribute("sub");
        String username = provider + "_" + providerId; // google_10021320120
        String password = bCryptPasswordEncoder.encode("겟인데어");
        String email = oAuth2User.getAttribute("email");
        String role = "ROLE_USER";

        User userEntity = userRepository.findByUsername(username);

        if (userEntity == null) {
            System.out.println("구글 로그인이 최초입니다.");
            userEntity = User.builder()
                    .username(username)
                    .password(password)
                    .email(email)
                    .role(role)
                    .provider(provider)
                    .providerId(providerId)
                    .build();
            userRepository.save(userEntity);
        } else {
            System.out.println("구글 로그인을 이미 한적이 있습니다. 당신은 자동회원가입이 되어 있습니다.");
        }

        // 회원 가입을 강제로 진행해볼 예정
        return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
    }

현재 loadUser() 함수는 다음과 같이 구글 로그인에 대해서만 적용되도록 코드가 적혀있다. 이를 통합 소셜 로그인이 가능하도록 바꾸어보자.

loadUser

 @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        System.out.println("getClientRegistration = " + userRequest.getClientRegistration()); // registrationId로 어떤 OAuth로 로그인 했는지 확인 가능
        System.out.println("getAccessToken = " + userRequest.getAccessToken().getTokenValue());

        OAuth2User oAuth2User = super.loadUser(userRequest);
        // 구글로그인 버튼 클릭 -> 구글로그인창 -> 로그인을 완료 -> code를 리턴(OAuth2-Client 라이브러리) -> AccessToken 요청
        // userRequest 정보 -> 회원 프로필 받아야함(loadUser함수 호출) -> 구글로부터 회원프로필 받아준다.
        System.out.println("getAttributes = " + oAuth2User.getAttributes());

        OAuth2UserInfo oAuth2UserInfo = null;
        if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
            System.out.println("구글 로그인 요청");
            oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
        } else {
            System.out.println("우리는 구글만 지원합니다.");
        }

        String provider = oAuth2UserInfo.getProvider(); // google
        String providerId = oAuth2UserInfo.getProviderId();
        String username = provider + "_" + providerId; // google_10021320120
        String password = bCryptPasswordEncoder.encode("겟인데어");
        String email = oAuth2UserInfo.getEmail();
        String role = "ROLE_USER";

        User userEntity = userRepository.findByUsername(username);

        if (userEntity == null) {
            System.out.println("구글 로그인이 최초입니다.");
            userEntity = User.builder()
                    .username(username)
                    .password(password)
                    .email(email)
                    .role(role)
                    .provider(provider)
                    .providerId(providerId)
                    .build();
            userRepository.save(userEntity);
        } else {
            System.out.println("구글 로그인을 이미 한적이 있습니다. 당신은 자동회원가입이 되어 있습니다.");
        }

        // 회원 가입을 강제로 진행해볼 예정
        return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
    }


제대로 적용된 것을 확인할 수 있다.

네이버 로그인 준비

다음의 절차대로 애플리케이션을 만들어준다.




application.yml

         naver:
           client-id: EaVXktplGUi9J_pJ0Tpt
           client-secret: WZoYctWcum
           scope:
             - name
             - email
           client-name: Naver
           authorization-grant-type: authorization_code
           redirect-uri: http://localhost:8080/login/oauth2/code/naver

다음과 같이 설정하고 프로그램을 실행하면 오류가 나는데, 그 이유는 네이버는 OAuth2 라이브러리가 관리하는 provider가 아니기 때문이다.

따라서 다음 정보를 추가로 적어준다.

여기서 맨 마지막줄의 response가 있는 이유는 네이버 로그인시 getAttributes에서 id와 email이 response 안에 있기 때문이다.

이후 이전에 만들어둔 OAuth2UserInfo를 구현하여 NaverUserInfo 클래스를 작성해주면 된다.

NaverUserInfo

public class NaverUserInfo implements OAuth2UserInfo{

    private Map<String, Object> attributes; // getAttributes()
    public NaverUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProvider() {
        return "naver";
    }

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

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

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

loadUser() 함수도 다음과 같이 간단하게 바꿔주면 된다.

정상적으로 작동하는 것을 확인할 수 있다.

이로서 스프링부트 기본 로그인 + OAuth2.0 통합 로그인 환경을 만들어보았다.

카카오 로그인 준비

다음의 절차대로 애플리케이션을 만들어준다.



REST API 키를 통해 카카오 로그인을 진행할 것이다.




Redirect URL은 카카오 로그인이 정상적으로 완료된 후 사용자 정보를 받을 주소이다. 이 주소는 고정적으로 위의 주소를 사용해야한다.





다음은 로그인 처리를 할 주소이다.

secert_key 발급

application.yml 설정

server:
  port: 8080
  servlet:
    context-path: /
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
    username: security
    password: 3865

  jpa:
    hibernate:
      ddl-auto: update
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    show-sql: true

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 147521920274-9vtvlo25jcel8ua0etd6bib2kipgvo99.apps.googleusercontent.com
            client-secret: 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
            scope:
              - email
              - profile

          naver:
            client-id: EaVXktplGUi9J_pJ0Tpt
            client-secret: WZoYctWcum
            scope:
              - name
              - email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/naver

          kakao:
            client-name: kakao
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/kakao
            client-id: 9fcee9537206e292e3b5b5c734a2c0dd
            client-secret: 2MqjkqExcBIFgKKMVDIoxFWntwB5HXSX
            client-authentication-method: client_secret_post
            scope:
              - profile_nickname
              - account_email

        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response # 회원정보를 json으로 받는데 response라는 키값으로 네이버가 리턴해줌

          kakao:
              authorization-uri: https://kauth.kakao.com/oauth/authorize
              user-name-attribute: id
              token-uri: https://kauth.kakao.com/oauth/token
              user-info-uri: https://kapi.kakao.com/v2/user/me

네이버와 마찬가지로 카카오 유저정보 클래스도 OAuth2UserInfo를 구현해 작성해준다.

KakaoUserInfo

public class KakaoUserInfo implements OAuth2UserInfo{

    private Map<String, Object> attributes; // getAttributes()
    public KakaoUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProvider() {
        return "kakao";
    }

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

    @Override
    public String getEmail() {
        Object object = attributes.get("kakao_account");
        LinkedHashMap accountMap = (LinkedHashMap) object;
        return (String) accountMap.get("email");
    }

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



정상적으로 동작하는 것을 확인할 수 있다.

이로서 카카오, 구글, 네이버를 통합한 소셜 로그인 환경을 만들어보았다.

0개의 댓글