본문 바로가기
web/맛슐랭 Project

맛슐랭 - 스프링부트 OAuth2 로그인 구현 (소셜로그인,간편로그인)

by su0a 2024. 3. 20.

- intro -

저번 프로젝트에서 일반 회원가입&로그인을 구현해보았기 때문에 이번 프로젝트에서는 소셜로그인을 사용해보고자 한다.

국내에서 많이 사용하는 네이버와 카카오 로그인을 사용해볼 예정이다.

OAuth2에 관해 알아햘 내용들은 2024.03.07 - [spring] - Spring Security (스프링 시큐리티) OAuth2 정리 1 에 정리해놓았다.

 

스프링부트에서 OAuth 2.0을 설정할 때, Spring Security는 인증 및 권한 부여를 담당하는 필터 및 프로세스를 구성한다.

따라서 스프링부트가 자동으로 authorization code를 요청하고 받아오는 작업부터 access token을 사용하여 사용자 정보에 접근하는 작업까지를 수행해준다.

=> 개발자는 복잡한 로직을 직접 구현할 필요 없이 사용자 정보를 가져올 수 있다.

 

 

- 구현 -

1. 먼저 네이버와 카카오 API를 사용하기 위해서는 앱등록이 필요하다.

https://developers.kakao.com/console/app 와 https://developers.naver.com/main/ 에서 앱등록을 해주자

redirect Url은 네이버: localhost:8080/login/oauth2/code/naver , 카카오:  localhost:8080/login/oauth2/code/kakao 를 사용했다.

(참고: https://bcp0109.tistory.com/379 )

 

2. 스프링부트 구현

1) build.gradle 설정

dependencies 내에 아래 코드를 추가해주었다.

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

 

2) yml 설정

spring security 를 사용하여 로그인한 멤버만 접근 가능한 페이지를 만들 예정이기 때문에 security 하위에 oauth2를 넣어주었다.

# JPA
spring:
  security:
    oauth2:
      client:
        registration:
          naver:
            client-id: <발급받은 client-id>
            client-secret: <발급받은 client-secret>
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            authorization-grant-type: authorization_code
            scope:
              - nickname
              - email
          kakao:
            client-id: <발급받은 client-id>
            client-secret: <발급받은 client-secret>
            client-name: Kakao
            client-authentication-method: client_secret_post
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            authorization-grant-type: authorization_code
            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
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            tokenUri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

 

3) securityConfig 설정

로그인 한 유저만 접근 가능한 url과 로그인하지 않은 유저만 접근 가능한 url 리스트를 만든 후 securityFilterChain 내 authorizeHttpRequests 부분에 원하는 기능을 추가해주었다.

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {
	private final CustomOAuth2UserService customOAuth2UserService;
    private final MemberRepository memberRepository;

    //로그인하지 않은 유저들만 접근 가능한 URL
    private static final String[] anonymousMemberUrl = {"/members/loginPage"};
    //로그인한 유저들만 접근 가능한 URL
    private static final String[] authenticatedMemberUrl = {"/members/myPage/**","/members/wishlist","/reviews/write"};

	@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, HandlerMappingIntrospector introspector) throws  Exception{
        return httpSecurity
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests((authorizeRequests)->
                        authorizeRequests
                                //anonymousMemberUrl에 대해서는 익명의 사용자만 접근 가능
                                .requestMatchers(anonymousMemberUrl).anonymous()
                                //authenticatedUserUrl에 대해서는 로그인을 요구
                                .requestMatchers(authenticatedMemberUrl).authenticated()
                                .anyRequest().permitAll()
                )
                .exceptionHandling((exceptionConfig)->
                        exceptionConfig
                                .authenticationEntryPoint(new MyAuthenticationEntryPoint()) //인증 실패

                ) //로그인한 멤버만 접근할 수 있는 url에 로그인하지 않은 사용자가 접근할 경우 로그인 페이지로 이동시킴
                .oauth2Login((oauth2Login)->
                        oauth2Login
                                .loginPage("/loginPage") //로그인 페이지 url
                                .userInfoEndpoint(userInfoEndpointConfig ->
                                        userInfoEndpointConfig.userService(customOAuth2UserService))
                                .successHandler(new OAuth2LoginSuccessHandler())


                ) //로그아웃 시 세션 무효화 및 쿠키삭제
                .logout((logoutConfig)->
                        logoutConfig
                                .logoutUrl("/logout")
                                .invalidateHttpSession(true).deleteCookies("JSESSIONID")
                                .logoutSuccessHandler(new OAuth2LogoutSuccessHandler())
                )
                .build();

    }

 

4) Member 클래스

간편로그인을 통해 이메일, 닉네임 정보만 가져올 예정이라 단순하게 구현하였다.

@Entity
@Getter
@NoArgsConstructor
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String nickname;

    @Enumerated(EnumType.STRING)
    private Role role;


    @Builder
    public Member(String email, String nickname, Role role){
        this.email=email;
        this.nickname=nickname;
        this.role=role;
    }

    public Member update(String nickname){
        this.nickname=nickname;
        return this;
    }

    public String getRoleKey(){
        return this.role.getKey();
    }
}
@Getter
@RequiredArgsConstructor
public enum Role {
    //스프링 시큐리티에서는 권한 코드에 항상 ROLE이 앞에 있어야 함
    GUEST("ROLE_GUEST","손님"),
    USER("ROLE_USER","일반사용자");

    private final String key;
    private final String title;
}

 

5) CustomOAuth2UserService 클래스

유저가 간편로그인 페이지에서 로그인을 완료하면 해당 사용자의 정보를 소셜 로그인 제공업체로부터 가져오는데 이를 CustomOAuth2UserService에서 진행한다.

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final MemberRepository memberRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        //DefaultOAuth2UserService는 OAuth2UserService 구현체 중 하나로 OAuth2 사용자 정보를 가져오는데 사용됨
        OAuth2UserService<OAuth2UserRequest,OAuth2User> delegate = new DefaultOAuth2UserService();
        // 소셜 로그인 제공업체로부터 받은 사용자 정보를 OAuth2User 객체에 저장
        OAuth2User oAuth2User = delegate.loadUser(userRequest);
        // registrationId: naver 또는 kakao
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        // 사용자의 소셜로그인에 대한 세부 정보를 attributes 객체에 저장
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        // attributes 객체의 email 속성을 사용하여 멤버객체를 생성하거나 업데이트
        Member member = saveOrUpdate(attributes);

        //세션에 사용자 정보를 저장
        httpSession.setAttribute("user",new SessionUser(member));
        //세션 유지시간을 60분으로 제한
        httpSession.setMaxInactiveInterval(3600);

        //DefaultOAuth2User 객체를 생성하여 인증된 사용자를 나타냄 -> 인증 및 권한을 부여할 때 사용
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(member.getRoleKey())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey()
        );
    }

    // 이메일을 통해 회원인지 확인 후 회원이 아니면 memberRepository 에 저장
    private Member saveOrUpdate(OAuthAttributes attributes) {
        Member member = memberRepository.findByEmail(attributes.getEmail())
                .map(entity -> entity.update(attributes.getNickname()))
                .orElse(attributes.toEntity());
        return memberRepository.save(member);
    }
}

 

6) OAuthAttributes 클래스

사용자의 소셜로그인에 대한 세부정보를 저장하기 위한 객체이다.

@Getter
@Builder
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String email;
    private String nickname;

    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String,Object> attributes){
        // naver 와 kakao 로부터 사용자 정보를 받아올 때 json 내에 이메일과 닉네임이 들어있는 키값이 일치하지 않기 때문에 함수를 각각 따로 만들었다.
        if("naver".equals(registrationId)){
            return ofNaver("id",attributes);
        } else{
            return ofKakao("id",attributes);
        }
    }

    private static OAuthAttributes ofNaver(String userNameAttributeName,Map<String,Object>attributes){
        // attributes 를 콘솔에 찍어보면 response내에 email과 nickname 정보가 들어있음을 확인할 수 있다.
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return OAuthAttributes.builder()
                .email((String)response.get("email"))
                .nickname((String) response.get("nickname"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
    private static OAuthAttributes ofKakao(String userNameAttributeName,Map<String,Object>attributes){
        Map<String, Object> response = (Map<String, Object>) attributes.get("kakao_account");
        // 카카오는 profile 내에 nickname 정보가 있어 한번 response.get 을 진행한다.
        Map<String, Object> account = (Map<String, Object>) response.get("profile");
        return OAuthAttributes.builder()
                .email((String) response.get("email"))
                .nickname((String) account.get("nickname"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
    public Member toEntity(){
        return Member.builder()
                .nickname(nickname)
                .email(email)
                .role(Role.USER)
                .build();
    }
}

 

7) SessionUser 클래스

세션에 사용자 정보를 저장하기 위한 dto 클래스이다.

@Getter
public class SessionUser implements Serializable {
    private String nickname;
    private String email;

    public SessionUser(Member member){
        this.email=member.getEmail();
        this.nickname=member.getNickname();
    }
}

 

8) OAuth2LoginSuccessHandler 클래스

로그인 성공시 발생하는 핸들러이다. 로그인에 성공하면 'alert창에 로그인에 성공하였습니다' 문구를 띄워준다.

@AllArgsConstructor
@Slf4j
@Component
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    //로그인 성공시 발생하는 핸들러
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        //성공 시 메세지 출력 후 홈 화면으로 redirect
        response.setContentType("text/html; charset=UTF-8");
        PrintWriter pw = response.getWriter();
        String prevPage = (String) request.getSession().getAttribute("prevPage");
        if(prevPage!=null){
            pw.println("<script>alert('로그인에 성공하였습니다!'); location.href='" + prevPage + "';</script>");
        } else{
            pw.println("<script>alert('로그인에 성공하였습니다!'); location.href='/';</script>");
        }

        pw.flush();
    }
}

 

9) OAuth2LogoutSuccessHandler 클래스

public class OAuth2LogoutSuccessHandler implements LogoutSuccessHandler {

    //로그아웃 시 사용되는 핸들러
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("text/html");
        PrintWriter out = response.getWriter();
        out.println("<script>alert('로그아웃 되었습니다.'); location.href='/';</script>");
        out.flush();
    }
}

 

10) Controller 클래스

MemberController 내에 로그인 url을 처리하는 함수를 구현하였다.

이전 uri를 저장해놓고 네이버 로그인 또는 카카오톡 로그인을 할 수 있는 페이지를 반환한다.

@GetMapping("/loginPage")
public String loginPage(Model model, HttpServletRequest request) {
    // 로그인 성공 시 이전 페이지로 redirect 되게 하기 위해 세션에 저장
    String uri = request.getHeader("Referer");
    if (uri != null && !uri.contains("/login") && !uri.contains("/join")) {
        request.getSession().setAttribute("prevPage", uri);
    }
    return "/members/loginPage";
}

 

11) loginPage.html

부트 스트랩을 이용하여 간편하게 로그인 할 수 있는 페이지를 만들었다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head th:replace="fragments/header.html :: head"/>
<body style="background-color:#F0EAB7;">
<div th:replace="fragments/header.html :: header ('')"/>
<section class="features-icons bg-light text-center">
    <div class="container position-relative">
        <div class="row justify-content-center">
            <div class="row justify-content-center" style="width:800px; height:500px; background-color:#6D88CE; border-radius:15px;">
                <div class="text-center text-white" style="margin-top:70px;">
                    <!-- Page heading-->
                    <h3>로그인 및 회원가입</h3>
                </div>
                <div class="d-grid gap-2 col-9 mx-auto" style="margin-bottom:100px;">
                    <button class="btn btn-success" type="button" onclick="location.href= '/oauth2/authorization/naver'">네이버 로그인</button>
                    <button class="btn btn-warning" type="button" onclick="location.href= '/oauth2/authorization/kakao'">카카오 로그인</button>
                </div>
            </div>
        </div>
    </div>
</section>
<!-- Bootstrap core JS-->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script src="js/scripts.js"></script>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *-->
<!-- * *                               SB Forms JS                               * *-->

<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *-->
<script src="https://cdn.startbootstrap.com/sb-forms-latest.js"></script>
</body>
</html>

 

- 결과 -

로그인 페이지