스프링 시큐리티 4) - JWT + OAuth 2.0

TreeSick·2022년 4월 20일
2

스프링시큐리티

목록 보기
4/4

저번 3편에서는 스프링 시큐리티에서 JWT를 활용한 로그인방식 연습예제를 만들어 보았습니다.

요즘 많은 서비스들이 구글, 네이버, 카카오 로그인을 지원하는 것을 보셨을 텐데요.

그것이 OAuth를 사용한 로그인 방식이라 할 수 있습니다.

많은 블로그에서 세션방식과 OAuth를 결합한 예제를 많이 만들고 있는데요.

이번, 4편에서는 JWT 방식 위에 구글 OAuth2.0 로그인 방식을 추가해보겠습니다.

스프링시큐리티 3편 링크

새로 추가되는 코드를 제외하고는 3편을 기반으로 코드가 짜여져 있으니 꼭 보고 오세요~!

https://velog.io/@rainbowweb/%EC%8A%A4%ED%94%84%EB%A7%81%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-3-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%8B%9D

스프링시큐리티 기본 개념

깃헙에 있는 정리본을 참고해주세요!

https://github.com/namusik/TIL-SampleProject/blob/main/Spring%20Boot/%EC%8A%A4%ED%94%84%EB%A7%81%20%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0/OAuth2%20%EA%B0%9C%EB%85%90.md

구글 OAuth 등록하기

깃헙에 있는 정리본을 참고해주세요!

https://github.com/namusik/TIL-SampleProject/blob/main/Spring%20Boot/%EC%8A%A4%ED%94%84%EB%A7%81%20%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0/Google%20Oauth2.md

소스코드

https://github.com/namusik/TIL-SampleProject/tree/main/Spring%20Boot/%EC%8A%A4%ED%94%84%EB%A7%81%20%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0/%EC%8A%A4%ED%94%84%EB%A7%81%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%20OAuth2

작업환경

IntelliJ
Spring Boot
java 11
gradle

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    //Oauth
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
    //jwt
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

핵심적으로는 스프링시큐리티, jwt, oauth2 라이브러리를 추가해줍니다.

application.yml

#goole Oauth
  security:
    oauth2:
      client:
        registration:
          google:
            clientId: 발급받은 id
            clientSecret: 발급받은 secret key
            scope:
              - email
              - profile

구글 Oauth에서 발급받은 아이다와 시크릿을 작성해주면, 구글로 요청을 보낼 때 자동으로 인식이 됩니다.

WebSecurityConfig

@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //URL 인증여부 설정.
        http.authorizeRequests()
                .antMatchers( "/user/signup", "/", "/user/login", "/css/**", "/exception/**", "/favicon.ico", "/login/oauth2/code/google", "/user/oauth/password/**").permitAll()
                .anyRequest().authenticated();

        //Oauth2 설정
        http.oauth2Login().userInfoEndpoint().userService(new OAuth2UserServiceImpl());
        http.oauth2Login().successHandler(oAuth2SuccessHandler);

    }
}

Oauth 설정해주기.

userInfoEndpoint().userService()

구글로그인 성공후, 구글에서 사용자 정보를 보내오는데

이때 추가로 진행할 Service 클래스를 구현해서 명시해줍니다. 

successHandler()

인증이 성공시, 처리를 진행할 successHandler도 구현해서 추가해줍니다. 

OAuth2UserServiceImpl

@Service
@RequiredArgsConstructor
@Slf4j
public class OAuth2UserServiceImpl implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();

        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);

        //어떤 서비스인지 구분하는 코드.  google / naver / kakao
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        //OAuth2 로그인 진행시 키가 되는 필드값.
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        OAuthDto oAuthDto =
                OAuthDto.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        log.info("{}", oAuthDto);

        var memberAttribute = oAuthDto.convertToMap();

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                memberAttribute, "email");
    }
}

사용자가 구글로그인을 시도하면 성공하면, 구글에서 사용자 정보를 서버로 보내게 됩니다.

그때, OAuth2UserService의 loadUser()메서드가 실행되게 되는데, 이것을 구현한 OAuth2UserServiceImpl을 만들어줘서 Override를 해줍니다.

OAuth2User

DefaultOAuth를 통해 RestOperations으로 UserInfo 엔드포인트에 사용자 속성을 요청해서 사용자 정보를 가져와야하기

때문에 CustomOAuth2UserService.loadUserd의 동작을 대신 해주는 대리자를 만들어주었습니다.

registrationId

	현재 로그인이 진행되는 서비스가 어디인지 구분해주는 ID.
    ex) google, naver, kakao
    

userNameAttributeName

	OAuth2 로그인 진행시 키가 되는 필드값. 
    
    일종의 Primary Key.
    
    다만, 구글만 지원하는 점이 있다.
    

OAuthDto

	서비스에서 제공해준 사용자의 정보들을 담을 Dto이다. 
    
    직접 구현해줘야 함. 
    
    보통, OAuth를 여러 서비스로 로그인할 수 있도록 구현하기 때문에, registrationID에 따라 
    
    Dto를 생성해주는 방식을 다르게 해준다. 
    

return new DefaultOAuth2User

return 후, OAuth2LoginAuthenticationProvider 117번째 줄의 OAuth2User 객체로 정의됨. 

OAuth2LoginAuthenticationFilter를 거치면서 Authentication에 저장을 해준다. 

그래서 handler에서 꺼내 올 수 있는 것.
    

OAuthDto

@Getter
@ToString
@Builder
public class OAuthDto {
    private Map<String, Object> attributes;
    private String attributeKey;
    private String email;
    private String name;
    private String picture;

    public static OAuthDto of(String provider, String attributeKey,
                              Map<String, Object> attributes) {
        switch (provider) {
            case "google":
                return ofGoogle(attributeKey, attributes);
            case "kakao":
                return ofKakao("email", attributes);
            case "naver":
                return ofNaver("id", attributes);
            default:
                throw new RuntimeException();
        }
    }

    private static OAuthDto ofGoogle(String attributeKey, Map<String, Object> attributes) {
        return OAuthDto.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String)attributes.get("picture"))
                .attributes(attributes)
                .attributeKey(attributeKey)
                .build();
    }


    private static OAuthDto ofKakao(String attributeKey,
                                           Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");

        return OAuthDto.builder()
                .name((String) kakaoProfile.get("nickname"))
                .email((String) kakaoAccount.get("email"))
                .picture((String)kakaoProfile.get("profile_image_url"))
                .attributes(kakaoAccount)
                .attributeKey(attributeKey)
                .build();
    }

    private static OAuthDto ofNaver(String attributeKey,
                                           Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthDto.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .attributeKey(attributeKey)
                .build();
    }

    public Map<String, Object> convertToMap() {
        Map<String, Object> map = new HashMap<>();
        map.put("id", attributeKey);
        map.put("key", attributeKey);
        map.put("name", name);
        map.put("email", email);
        map.put("picture", picture);

        return map;
    }
}

OAuth 서버에서 넘어온 사용자 정보들을 담을 Dto를 만들어 주었습니다.

registrationId 값에 따라서, Builder를 서비스에 맞게 각각 만들어 주었습니다.

OAuth2SuccessHandler

@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {

    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
        System.out.println("oAuth2User = " + oAuth2User);

        String email = (String) oAuth2User.getAttributes().get("email");
        String nickname = (String) oAuth2User.getAttributes().get("name");
		
                
        //한글 닉네임인 경우 인코딩
        nickname = URLEncoder.encode(nickname);
        System.out.println("nickname = " + nickname);
        
        //패스워드 입력하도록 리다이렉트
        response.sendRedirect("/user/oauth/password/"+email+"/"+nickname);
    }
}

OAuth2UserServiceImpl에서 인증이 성공적을 끝난 후 이동하는 Handler.

여기서는 각자의 서비스에 맞게 custom해주면 된다.

서비스들마다 천차만별이지만, 이번 예제에서는 비밀번호를 입력하는 페이지로 이동한 후, 회원가입이 되도록 하였습니다.

이메일과 닉네임은 Oauth 서버에서 제공해주는 값을 그대로 사용하기 위해

redirect URI 뒤에서 파라미터로 붙여서 해당 controller로 보내줬습니다.

UserController

    //OAuth로 로그인 시 비밀번호 입력 창으로
    @GetMapping("/user/oauth/password/{email}/{nickname}")
    public String oauth(@PathVariable("email") String email, @PathVariable("nickname") String nickname, Model model) {
        System.out.println("email = " + email);
        System.out.println("nickname = " + nickname);
        model.addAttribute("email", email);
        model.addAttribute("nickname", nickname);
        return "oauthPassword";
    }

인증받은 구글 이메일과 닉네임을 담아 비밀번호 입력 html로 보내주었습니다.

이후 부터는 3편에서 다뤘던 DB에 회원 정보 넣고, JWT 토큰 발급 받아서 전달해주는 과정과 동일합니다.

login.html

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="preconnect" href="https://fonts.gstatic.com">
    <link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic&display=swap" rel="stylesheet">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>
    <link rel="stylesheet" type="text/css" href="/css/style.css">
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
    <script>

        $(document).ready(function () {
            /*<![CDATA[*/
            var user = "[[${user}]]";
            /*]]>*/
            console.log(user);
            if(user !== ""){
                alert("이미 로그인이 되어있습니다");
                window.location.href = "/";
            }
        });
    </script>
</head>
<body>
<div id="login-form">
    <div id="login-title">Log into Select Shop</div>
    <button id="login-id-btn" onclick="location.href='/user/signup'">
        회원 가입하기
    </button>
    <form action="/user/login" method="post">
        <div class="login-id-label">아이디</div>
        <input type="text" name="email" class="login-input-box">

        <div class="login-id-label">비밀번호</div>
        <input type="password" name="password" class="login-input-box">

        <button id="login-id-submit">로그인</button>
    </form>

    <div id="login-failed" style="display:none" class="alert alert-danger" role="alert">닉네임 또는 패스워드를 확인해주세요</div>

    <button onclick="location.href='/oauth2/authorization/google'" >Google Login</button>
</div>
</body>
<script>
    const href = location.href;
    const queryString = href.substring(href.indexOf("?")+1)
    if (queryString === 'error') {
        const errorDiv = document.getElementById('login-failed');
        errorDiv.style.display = 'block';
    }
</script>
</html>

로그인 페이지 하단에

Google Login

를 붙여주었습니다.

나머지

이외에 html과 예외처리 부분은 상단에 있는 깃허브 링크에서 참고해주시면 감사하겠습니다.

실행결과

구글 로그인 버튼을 클릭하면


이렇게 구글 로그인을 할 수 있는 창으로 이동합니다.


구글 로그인을 완료하면 redirect되어 OAuth2UserServiceImpl -> OAuth2SuccessHandler를 거쳐서

비밀번호를 입력하는 페이지로 가게 됩니다.

이때, 이메일과 닉네임은 인증받은 구글의 정보로 채워져있을 겁니다.

비밀번호를 입력하면 회원가입이 완료되고,

로그인이 완성되면, JWT 토큰을 리턴해주면 성공입니다.

참고

https://otrodevym.tistory.com/entry/spring-boot-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0-9-oauth2-%EC%84%A4%EC%A0%95-%EB%B0%8F-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%86%8C%EC%8A%A4

https://loosie.tistory.com/300

https://velog.io/@tmdgh0221/Spring-Security-%EC%99%80-OAuth-2.0-%EC%99%80-JWT-%EC%9D%98-%EC%BD%9C%EB%9D%BC%EB%B3%B4

https://velog.io/@swchoi0329/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0%EC%99%80-OAuth-2.0%EC%9C%BC%EB%A1%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84

https://codediary21.tistory.com/73

profile
깃헙에 올린 예제 코드의 설명을 적어놓는 블로그

0개의 댓글