~☆~ 우하하!!~ 개발블로그

[SpringBoot] diary - Sign in with Google 본문

SpringBoot

[SpringBoot] diary - Sign in with Google

iwoohaha 2024. 11. 13. 16:31
728x90
반응형

Google Cloud 설정은 https://iwoohaha.tistory.com/318 포스트를 참고하자.

 

소셜 로그인/회원가입 기능은 OAuth 라는 기능을 사용하는 것이다.

더보기

OAuth :

Open Authorization 즉, 제 3자 애플리케이션이 사용자의 인증 정보를 공유하지 않고도 제한된 접근 권한을 통해 특정 자원에 접근할 수 있도록 하는 권한 위임 방식의 표준 프로토콜이다. 여기에서 제 3자 애플리케이션은 내가 개발하고 있는 애플리케이션을 말한다.

Google, Apple, Facebook 등은 사용자정보를 보유하고 있는데, 내가 개발하고 있는 애플리케이션이 제한된 접근 권한을 통해서 이 사용자정보에 접근할 수 있도록 OAuth 기능을 제공하는 것이다.

내가 개발하고 있는 애플리케이션은 자원 저장서버 입장에서는 클라이언트가 된다.

종속성 추가

우선 SpringBoot 프로젝트의 build.gradle 파일에 종속성을 추가한다.

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

 

build.gradle 파일이 수정되므로 Gradle Sync 작업을 반드시 진행해 주어야 한다.

 

로그인폼 수정 (/src/main/resources/templates/login.html)

로그인 폼에 "Sign in with Google" 이라는 제목의 버튼을 추가한다. 

...
        <div class="d-grid gap-2">
            <button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
            <a href="/signup">
                <button class="btn btn-success w-100 py-2" type="button">Sign up</button>
            </a>
            <a href="/oauth2/authorization/google">
                <button class="btn btn-info w-100 py-2" type="button">Sign in with Google</button>
            </a>
        </div>
...

 

새로 추가하는 버튼의 link 주소는 /oauth2/authorization/google 로 고정이다.

 

SecurityConfig.java 수정

SecurityConfig.java 에서 oauth 로그인이 가능하도록 설정을 추가한다.

.oauth2Login(Customizer.withDefaults()) 를 추가

 

이 상태로 프로젝트를 빌드하여 실행시키면 google oauth2 관련 설정값이 없어서 프로젝트가 제대로 실행되지 않는 것을 볼 수 있다.

DEBUG 24-11-14 09:16:25[restartedMain] [LoggingFailureAnalysisReporter:37] - Application failed to start due to an exception
org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' available
...
***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of method setFilterChains in org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration required a bean of type 'org.springframework.security.oauth2.client.registration.ClientRegistrationRepository' that could not be found.


Action:
...

 

Google Client 설정값

application.yml 에 google oauth2 관련 설정값을 추가한다. 이 설정값은 https://iwoohaha.tistory.com/318 포스트에서 확인할 수 있는 Client ID 와 Client 비밀키 값 등이다. 주의할 것은 맨 마지막 openid 항목은 추가하지 않는다는 것이다.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 528898101025-895po5vpn68pdkmuqj4aq314h2h0eob4.apps.googleusercontent.com
            client-secret: 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
            scope:
              - profile
              - email
#              - openid

 

정상적으로 실행은 되는데 http://localhost:8080 으로 접속했을 때 login.html 이 아닌 화면이 표시가 된다.

이렇게 되는 이유는 oauth2Login 의 기본 로그인 폼이 동작했기 때문이다.

SecurityConfig.java 에 추가한 .oauth2Login(Customizer.withDefaults()) 대신에 아래와 같이 수정해서 작성한다.

...
                .oauth2Login(oauth2Login ->
                        oauth2Login
                            .loginPage("/login")
                )
...

 

로그인폼 URL 을 직접 명시적으로 지정하는 방법이다.

 

수정한 결과 직접 작성한 login.html 화면이 표시된다.

맨 아래에 추가한 "Sign in with Google" 버튼을 클릭하면 로그인한 Google 계정 목록을 보여주는 화면으로 이동된다. 

이렇게 이동되는 이유는 "Sign in with Google" 버튼에 할당한 아래 url 이 oauth2 에 동작하였기 때문이다.

/oauth2/authorization/google

 

이 화면에서 diary(으)로 이동 처럼 diary 로 표시되는 값은 구글 클라우드 콘솔에서 앱 정보 중 앱 이름에 입력한 값이다.

 

Google 계정으로 로그인 화면에서 로그인할 계정을 선택하면 아래와 같은 정보 공유 안내 화면이 표시되고,

"계속" 버튼을 누르면 diary 로그인 화면으로 다시 돌아온다.

아직 구글에서 넘겨준 정보를 처리하는 로직이 작성되어 있지 않기 때문에 로그인 처리를 하지 못하기 때문이다.

그러나 IDE 의 로그를 보면 구글에서 선택한 계정에 대한 정보를 정상적으로 전달해주고 있는 것을 확인할 수가 있다.

 

이제 구글에서 넘겨주는 정보를 처리하는 로직을 구현해보자.

 

로그인폼에 입력한 이메일 주소를 이용해서 회원정보 목록을 확인하는 로직을 https://iwoohaha.tistory.com/330 에서 UserDetailsService 인터페이스를 구현한 LoginService 의 loadUserByUsername 에 작성했었다.

 

OAuth2 프로토콜을 통하여 전달받은 정보를 처리하기 위해서는 DefaultOAuth2UserService 를 상속받는 클래스를 작성해야 한다.

그리고 loadUser 함수를 override 하여 사용자정보를 처리한다.

 

service 패키지 아래에 CustomOAuth2UserService 클래스를 신규로 작성하고, 아래와 같이 DefaultOAuth2UserService 를 확장해보자.

package com.woohahaapps.study.diary.service;

import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.stereotype.Service;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    
}

 

사용자정보를 커스텀 처리하기 위해서 loadUser 함수를 override 해야 하는데, DefaultOAuth2UserService 에서 loadUser 함수는 아래 그림과 같이 구현되어 있다.

 

그래서 다음과 같이 기본 골격을 작성해봤다.

package com.woohahaapps.study.diary.service;

import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        
        return oAuth2User;
    }
}

 

이제 loadUser 함수에서 구글에서 전달해준 정보를 뽑아 내 프로젝트에서 관리하고 있는 회원정보와 비교해야 한다.

설명을 위해서 loadUser 함수로 들어오는 값을 미리 확인해보자면,

userRequest 값은 다음과 같이 구성되어 있다.

 

그리고 oAuth2User 의 값은 다음과 같이 구성되어 있다.

 

이 정보제공자가 누구인지를 구분하기 위해서 userRequest 의 clientRegistration.registrationId 값을 사용하면 되고, 사용자정보는 oAuth2User 의 attributes 중 email, name 값을 사용하면 될 것 같다.

email 주소를 구하면 회원정보 테이블에서 조회하여 존재 여부에 따라 로그인 처리할 수 있겠다.

기본적인 흐름은 LoginService 의 loadUserByUsername 함수의 내용과 비슷할 것이다.

package com.woohahaapps.study.diary.service;

import com.woohahaapps.study.diary.domain.Member;
import com.woohahaapps.study.diary.mapper.MemberMapper;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberMapper memberMapper;

    public CustomOAuth2UserService(MemberMapper memberMapper) {
        this.memberMapper = memberMapper;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        String email = oAuth2User.getAttribute("email");
        String name = oAuth2User.getAttribute("name");
        Optional<Member> member = memberMapper.GetMember(email);
        if (member.isEmpty())
            throw new UsernameNotFoundException("해당 이름의 사용자가 존재하지 않습니다: " + email);
        return oAuth2User;
    }
}

 

입력폼에 입력된 정보를 이용해서 사용자인증을 처리하는 로직은 LoginController 에 작성한 signin URL 핸들러에 작성되어 있지만, OAuth2 프로토콜을 이용하는 경우 SpringBoot 에서 OAuth2UserService 로 정보가 전달되고, 인증 성공에 대한 핸들러가 호출되는 방식으로 처리된다.

이제 사용자인증이 성공했을 때 동작할 핸들러클래스를 작성해보겠다.

com.woohahaapps.study.diary 패키지 아래에 oauth2.handler 패키지를 생성하고, 그 아래에 CustomOAuth2LoginSuccessHandler.java 클래스를 생성한다.

이 클래스는 SimpleUrlAuthenticationSuccessHandler 클래스를 상속받고, onAuthenticationSuccess 함수를 override 하여 로그인 성공시에 대한 처리 로직을 구현해주면 된다.

package com.woohahaapps.study.diary.oauth2.handler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;

import java.io.IOException;

public class CustomOAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        
    }
}

 

LoginController 의 signin URL 핸들러에서는 사용자인증에 성공하면, jwt 토큰을 발급하는 로직을 구현한바 있다. 이 로직을 참고하여 다음과 같이 작성할 수 있다.

package com.woohahaapps.study.diary.oauth2.handler;

import com.woohahaapps.study.diary.jwt.JwtUtil;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomOAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;

    public CustomOAuth2LoginSuccessHandler(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String jwtToken = jwtUtil.createToken(authentication);
        System.out.println(jwtToken);

        Cookie cookie = new Cookie("Authorization", "Bearer=" + jwtToken);
        cookie.setMaxAge(24 * 60 * 60);// 1일동안 유효
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        response.addCookie(cookie);
    }
}

 

이제 CustomOAuth2UserService 와 CustomOAuth2LoginSuccessHandler 를 사용하도록 SecurityConfig 를 수정해보자.

...
@Configuration
@EnableWebSecurity
public class SecurityConfig {
...
    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomOAuth2LoginSuccessHandler customOAuth2LoginSuccessHandler;

    public SecurityConfig(CustomAuthFailureHandler customAuthFailureHandler, LoginService loginService, JwtUtil jwtUtil, CustomOAuth2UserService customOAuth2UserService, CustomOAuth2LoginSuccessHandler customOAuth2LoginSuccessHandler) {
        ...
        this.customOAuth2UserService = customOAuth2UserService;
        this.customOAuth2LoginSuccessHandler = customOAuth2LoginSuccessHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
...
                .oauth2Login(oauth2Login ->
                        oauth2Login
                                .loginPage("/login")
                                .userInfoEndpoint(userInfo -> userInfo
                                        .userService(customOAuth2UserService))
                                .successHandler(customOAuth2LoginSuccessHandler)
                )
                .httpBasic(Customizer.withDefaults())// swagger 에서는 필수로 기록해야 한다.
...

 

수정하는 내용만을 발췌하여 기록하였으니 기존 코드와 잘 비교해서 작성하면 되겠다.

 

이제 실행시켜보면 로그인 성공 후에 http://localhost:8080/login/oauth2/code/google?state=... 화면에서 멈춰버리는 현상이 나타나는 것을 확인할 수가 있다. 그 이유는 로그인 성공 후에 이동할 URL 이 지정되어 있지 않기 때문이다.

CustomOAuth2LoginSuccessHandler 클래스의 onAuthenticationSuccess 함수 끝 부분에 리디렉션 코드를 추가하도록 하자.

...
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String jwtToken = jwtUtil.createToken(authentication);
        System.out.println(jwtToken);

        Cookie cookie = new Cookie("Authorization", "Bearer=" + jwtToken);
        cookie.setMaxAge(24 * 60 * 60);// 1일동안 유효
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        response.addCookie(cookie);

        // 리디렉션 설정 (로그인 성공 후 홈으로 이동)
        setDefaultTargetUrl("/");
        super.onAuthenticationSuccess(request, response, authentication);
    }
}

 

이렇게 수정하고나서 다시 실행시켜보면 아래와 같이 오류가 표시된다.

반면에 기존과 같이 폼에 이메일 주소와 패스워드를 입력한 뒤에 로그인하는 과정은 문제없이 성공한다.

분명 전달하는 사용자정보의 차이 때문일 것이라는 의심이 간다.

지금 CustomOAuthUserService 의 loadUser 에서 OAuth2User 형의 변수를 리턴하고 있는데, LoginService 의 loadUserByUsername 에서는 User 형의 변수를 리턴하고 있는것과의 차이일 것이다.

그래서 User 클래스를 UserDetails 뿐만 아니라 OAuth2User 인터페이스를 구현하도록 변경해본다.

OAuth2User 인터페이스를 추가하면 아래 그림과 같이 추가로 구현해야 할 항목이 있다는 것을 알려준다.

 

implement methods 를 클릭하면 구현할 메소드를 보여준다.

 

추가로 구현할 메소드 3개를 모두 선택하고 OK 버튼을 누르면 자동으로 코드가 채워진다.

package com.woohahaapps.study.diary.domain;

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

@Data
public class User implements UserDetails, OAuth2User {

    private Member member;

    public User(Member member) {
        this.member = member;
    }

    @Override
    public <A> A getAttribute(String name) {
        return OAuth2User.super.getAttribute(name);
    }

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();

        for(String role : member.getRole().split(",")){
            if (!StringUtils.hasText(role)) continue;
            authorities.add(new SimpleGrantedAuthority(role));
        }
        return authorities;
    }

    public Boolean hasRole(String role) {
        return this.getAuthorities().contains(new SimpleGrantedAuthority(role));
    }

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

    @Override
    public String getUsername() {
        return member.getEmail();
    }

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

 

이제 CustomOAuth2UserService 의 loadUser 함수가 OAuth2User 인터페이스를 구현한 User 형태로 리턴되도록 수정한다.

package com.woohahaapps.study.diary.service;

import com.woohahaapps.study.diary.domain.Member;
import com.woohahaapps.study.diary.domain.User;
import com.woohahaapps.study.diary.mapper.MemberMapper;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberMapper memberMapper;

    public CustomOAuth2UserService(MemberMapper memberMapper) {
        this.memberMapper = memberMapper;
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        String provider = userRequest.getClientRegistration().getRegistrationId();
        String email = oAuth2User.getAttribute("email");
        String name = oAuth2User.getAttribute("name");
        Optional<Member> member = memberMapper.GetMember(email);
        if (member.isEmpty())
            throw new UsernameNotFoundException("해당 이름의 사용자가 존재하지 않습니다: " + email);
        //return oAuth2User;
        return new User(member.get());
    }
}

 

이 상태에서 프로젝트를 다시 실행시키면 구글 계정으로 로그인하는 작업 역시 깔끔하게 성공하게 된다.

구글 로그인을 진행하면서 회원정보에 해당 이메일이 존재하지 않는 경우 신규 회원으로 등록한 뒤에 로그인처리하려면 CustomOAuth2UserService 의 loadUser 에서 처리하면 될 것이다.

 

디버깅시 쿠키 삭제하는 방법

무언가 잘못된 jwt 토큰이 쿠키로 저장되어 다음번 실행에 영향을 미칠 경우 정상적으로 테스트가 불가능해진다.

이럴 경우 크롬 브라우저에서 F12 를 눌러서 개발자 모드로 전환한 후 Application 탭에서 Cookies 항목 아래의 URL 을 클릭하여 표시되는 모든 쿠키 항목을 삭제한 후에 테스트 URL 을 호출하면 해결된다.

반응형