OAuth2 - 소셜 로그인

2024. 1. 30. 17:15
728x90

1. OAuth2 (Open Authorization)

: 다른 서비스 제공자를 통해 사용자의 정보를 안전하게 위임받기 위한 표준.

- 사용자가 어떤 서비스에 소셜 로그인을 진행하고 싶을 때 해당 서비스에 직접적으로 인증을 위한 정보 (아이디, 비밀번호)를 제공하지 않더라도 서비스 제공자 측에 기록된 나의 정보를 조회할 수 있도록 권한을 위임하는 기술. 

 

1) 사용자가 로그인이 필요한 서비스 요청

2) 사용자가 소셜 로그인 제공자 선택

3) 사용자가 선택한 소셜 로그인 화면으로 redirect

4) 제공자(소셜 서비스) 인증 화면에 사용자가 인증정보 전달

5) 정상적인 인증 정보일 경우 > access token 발급하여 미리 설정된 url로 전달

6) access token을 사용하여 제공자의 자원 서버로 전달

7) 접속 요청 사용자의 정보 전달

8) 사용자의 정보를 기반으로 서비스 제공.

 

>> 여기서 실제로 서비스에 로그인을 하는 등의 기능을 만드는 것이 아니라, 전달받은 사용자 정보를 바탕으로 사용자 인증 정보를 서비스에 맞게 조절하는 과정이 필요하다. 

 

- HTTP 통신과 Redirect를 활용해서 만든다. 

- HTTP 통신하는 부분을 직접 만들거나,

- spring boot oauth2 client 의존성 활용하여 구현.

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

 

 

2. 네이버 아이디로 로그인 구성

: 실제로는 OAuth 엔드포인트! 네이버에 등록된 사용자 정보를 조회하는 API이다. 

 

 

1) 애플리케이션 등록 (API 이용신청)

- 가져오고 싶은 정보를 선택 (사용자가 정보제공 동의를 할 때 확인하게 된다)

- 로그인 환경 pc웹으로 선택. 

- 서비스 url : 개발용으로 http://localhost:8080

- 네이버 로그인 Callback url : http://localhost:8080/login/oauth2/code/naver

- Client Id와 Client Secret은 사용자 정보를 요청하기 위해 필요한 인증 정보 (spring boot에서 설정 진행)

 

2) application.yaml 구성

- 서비스 제공자의 엔드포인트에 대한 정보

- 해당 엔드포인트로 요청을 보내기 위한 정보를 작성.

 

- spring.security.oauth2.client.provider : OAuth 서비스 제공자에 대한 정보를 작성한다.

  • spring.security.oauth2.client.provider.{제공자}
  • 해당 서비스 제공자의 문서를 확인해서 작성!!
  • authorization-uri : 사용자를 redirect 하기 위한 url
  • token-uri : 사용자 정보 요청을 위한 Access Token을 받기 위한 url을 작성

  • user-info-uri : 사용자 정보를 조회하기 위한 url

  • user-name-attribute : 서비스 제공자로부터 받은 사용자 정보 중 어떤 부분을 활용하는지를 작성.

// application.yaml
spring:
  security:
    oauth2:
      client:
        provider:
          naver:
            # 인증 요청 url
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            # Access Token요청 url
            token-uri: https://nid.naver.com/oauth2.0/token
            # 사용자 정보 조회 url
            user-info-uri: https://openapi.naver.com/v1/nid/me
            # 응답받은 사용자 정보 중 사용자 이름이 무엇인지 담겨있는 JSON Key
            user-name-attribute: response

 

- spring.security.oauth2.client.registration : Provider를 사용하기 위한 설정 진행

  • spring.security.oauth2.client.registration.{제공자}
  • client-id, client-secret : 서비스 제공자 측에 어떤 서비스인지를 인증하기 위한 값. (아까 만들었을때 보았다)
  • redirect-uri : Callback URL
  • authorization-grant-type : 어떤 방식으로 Access Token을 받을지를 정의하는 설정. (일반적으로 authorization_code로 유지)
  • client-authentication-method : Client Id, Client Secret을 요청의 어디에 포함할지를 정의. client_secret_post는 POST 요청의 Body에 데이터를 포함해 보내는 것.
// application.yaml
spring:
  security:
    oauth2:
      client:
        # ...
        registration:
          naver:
            client-id: <아이디>
            client-secret: <시크릿>
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            client-authentication-method: client_secret_post
            client-name: Naver
            # 이 부분은 없어도 되긴 함.
            scope:
              - name
              - nickname
              - email
              - profile_image

- WebSecurityConfig 에서 oauth2Login 설정

// WebSecurityConfig - securityFilterChain 내부
                .oauth2Login(oauth2Login->oauth2Login
                        .loginPage("/users/login")
                )

- 프론트에서 링크 추가

// login-form.html
                            <div style="margin-top: 16px; text-align: right">
                                <a href="/oauth2/authorization/naver">네이버로 로그인</a>
                                <a href="/users/register">회원가입</a>
                            </div>

결과가 이렇게 나온다! 그러나, 실제로 데이터가 전해지는 것이 아니다!!!

 

 

3. Spring Boot OAuth2 Client

1) OAuth2UserServiceImpl

: 사용자가 정상적인 인증 이후 사용자 데이터를 조회하고 처리하기 위한 서비스 구현.

- 사용자 정보 받아오는 Service 클래스 생성 (DefaultOAuth2UserService를 상속받는다.)

// OAuth2UserServiceImpl class

@Slf4j
@Service
public class OAuth2UserServiceImpl
        // 기본적인 OAuth2 인증 과정을 진행해주는 클래스
        extends DefaultOAuth2UserService {
        
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest)
            throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        // 어떤 서비스 제공자를 사용했는지?
        String registrationId = userRequest
                .getClientRegistration()
                .getRegistrationId();
        // todo : 서비스 제공자에 따라 데이터 처리를 달리 하고 싶을 때
        // OAuth2 제공자로부터 받은 데이터를 원하는 방식으로 다시 정리하기 위한 Map
        Map<String, Object> attributes = new HashMap<>();

        String nameAttributes = "";

        // naver 관련 처리 로직!!
        if (registrationId.equals("naver")) {
            // Naver에서 받아온 정보다.
            attributes.put("provider", "naver");

            // naver가 반환한 JSON에서 response를 회수
            Map<String, Object> responseMap
                    = oAuth2User.getAttribute("response");
            attributes.put("id", responseMap.get("id"));
            attributes.put("email", responseMap.get("email"));
            attributes.put("nickname", responseMap.get("nickname"));
            attributes.put("name", responseMap.get("name"));
            attributes.put("profile_image", responseMap.get("profile_image"));
            nameAttributes = "email";
        }
        log.info(attributes.toString());
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                attributes,
                nameAttributes
        );
    }
}

- WebSecurityConfig 에서 또 설정 추가 (userInfoEndPoint)

// WebSecurityConfig class - securityFilterChain
                .oauth2Login(oauth2Login -> oauth2Login
                        .loginPage("/users/login")
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(oAuth2UserService))
                )

 

2) OAuth2SuccessHandler

: OAuth2UserServiceImpl에서 성공적으로 OAuth2 과정을 마무리 했을 때 넘겨받은 사용자 정보를 바탕으로 JWT 생성 & 클라이언트에게 JWT 전달하는 목적. 

// OAuth2SuccessHandler class

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler
        //인증에 성공했을 때 특정 URL로 리다이렉트 하고 싶은 경우 활용 가능한 SuccessHandler
        extends SimpleUrlAuthenticationSuccessHandler {
    // JWT 발급을 위해 JwtTokenUtils
    private final JwtTokenUtils jwtTokenUtils;
    // 사용자 정보 등록을 위해 USerDetailsManager
    private final UserDetailsManager userDetailsManager;
    
    // 인증 성공하면 이 메서드가 실행됨.
    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException, ServletException {
        // OAuth2UserServiceImpl의 반환값이 할당된다.
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();

        // 넘겨받은 정보를 바탕으로 사용자 정보를 준비
        String email = oAuth2User.getAttribute("email");
        String provider = oAuth2User.getAttribute("provider"); //가입 했었는지 정보
        String username = String.format("{%s}%s", provider, email);
        String providerId = oAuth2User.getAttribute("id");
        String phone = oAuth2User.getAttribute("phone");

        // 처음으로 이 소셜 로그인으로 로그인을 시도했다면
        if (!userDetailsManager.userExists(username)) {
            CustomUserDetails userDetails = CustomUserDetails.builder()
                    .username(username)
                    .email(email)
                    .phone(phone)
                    .password(providerId)
                    .authorities("ROLE_USER")
                    .build();
            userDetailsManager.createUser(userDetails);
        }

        // 데이터베이스에서 사용자 계정 회수
        UserDetails details = userDetailsManager.loadUserByUsername(username);

        // JWT 생성
        String jwt = jwtTokenUtils.generateToken(details);
        // 어디로 리다이렉트 할지 지정
        String targetUrl = String.format("http://localhost:8080/token/validate?token=%s", jwt);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

}

- 이미 컨트롤러에 "token/validate" url이 존재한다

// TokenController
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("token")
public class TokenController {
    // JWT를 발급하기 위한 Bean
    private final JwtTokenUtils jwtTokenUtils;
    // 사용자 정보를 회수하기 위한 Bean
    private final UserDetailsManager manager;
    // 사용자가 제공한 아이디 비밀번호를 비교하기 위한 클래스
    private final PasswordEncoder passwordEncoder;

	@GetMapping("/validate")
    public Claims validateToken(
            @RequestParam("token")
            String token
    ) {
        if (!jwtTokenUtils.validate(token)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        return jwtTokenUtils.parseClaims(token);
    }
}

- 마지막으로 WebSecurityConfig에서 설정 마무리. (OAuth2UserServiceImpl 객체와 OAuth2SuccessHandler 객체를 가져와 구성)

  • .loginPage() : 이전의 formLogin과 마찬가지로, 비인증 사용자를 이동시킬 로그인 페이지! 
  • .userInfoEndPoint() : 사용자 정보를 조회하는 Endpoint 설정. (사용자 정보 조회 후 동작을 정의하기 위해 userService를 등록하는 용도로 사용.
  • .successHandler() : 인증 성공시에 사용할 Handler 객체를 설정.
// WebSecurityConfig 
@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {
    private final JwtTokenUtils jwtTokenUtils;
    private final UserDetailsManager manager;
    // 추가
    private final OAuth2UserServiceImpl oAuth2UserService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;

    //Bean : 메서드의 결과를 Bean 객체로 관리해주는 어노테이션
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http
                // csrf 보안 해제
                .csrf(AbstractHttpConfigurer::disable)
                // url에 따른 요청 인가
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                "/no-auth",
                                "/users/home",
                                "/tests",
                                "/token/validate",
                                "/token/issue"
                        )
                        .permitAll()
                        .requestMatchers("/users/my-profile",
                                "/articles")
                        .authenticated()

                        .requestMatchers("/auth/user-role")
                        .hasRole("USER")
                        .requestMatchers("/auth/admin-role")
                        .hasAnyRole("ADMIN")

                        .requestMatchers("/auth/read-authority")
                        .hasAuthority("READ_AUTHORITY")
                        .requestMatchers("/auth/write-authority")
                        .hasAuthority("WRITE_AUTHORITY")

                        .requestMatchers("/users/login",
                                "/users/register")
                        .anonymous()
                        .anyRequest().permitAll()
                )
                
                // 여기부터
                .oauth2Login(oauth2Login -> oauth2Login
                        .loginPage("/users/login")
                        .userInfoEndpoint(userInfo -> userInfo
                                .userService(oAuth2UserService))
                        .successHandler(oAuth2SuccessHandler)
                )
                // 여기까지 추가
                
                // JWT를 사용하기 때문에 보안 관련 세션 해제
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                // JWT 필터를 권한 필터 앞에 삽입
                .addFilterBefore(new JwtTokenFilter(jwtTokenUtils, manager),
                        AuthorizationFilter.class)
        ;
        return http.build();
    }
}

로그인 했더니~ 바로 정보들이 나왔다. 오옝!!!! 카카오 로그인도 도전해보아야겠다.........과연..ㅎ ㅎ ㅎ ㅎ 

 

 

 

728x90

'Programming > Spring, SpringBoot' 카테고리의 다른 글

[JPA] MappedSuperclass / JpaAuditing  (0) 2024.01.31
OAuth2 - Kakao Login 구현  (1) 2024.01.30
Authorization  (0) 2024.01.30
JWT  (1) 2024.01.29
HandlerInterceptor & Filter / SecurityFilterChain  (0) 2024.01.27

BELATED ARTICLES

more