GrowMe

OAuth2를 스프링부트 프로젝트에 적용하기(with JWT 토큰) 본문

Security

OAuth2를 스프링부트 프로젝트에 적용하기(with JWT 토큰)

오늘도 타는중 2022. 10. 2. 19:14
OAuth2를 스프링부트 프로젝트에 적용하기
# OAuth2란?
# OAuth2의 동작방식

# OAuth2를 적용해보기

*OAuth2란?

  • 위키백과 정의 : OAuth는 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다.
  • Google, Naver, Kakao 등 외부 소셜 계정을 기반으로 간편히 회원가입 및 로그인할 수 있으며 해당 소셜 계정과 연동되어있는 기능도 간편하게 사용할 수 있습니다. (ex : Google로 간편로그인 후, 연동된 계정의 Google Calendar 정보를 가져와 사용자에게 보여주기)

*들어가기 전에

https://grow-myself.tistory.com/37

 

[Spring] 스프링 시큐리티의 개념과 구조

Spring Security # Spring Security 개념 # 필터 # Spring Security 특징 # Spring Security 구조 # 필터 별 기능 *스프링 시큐리티란? 스프링 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 ..

grow-myself.tistory.com

https://grow-myself.tistory.com/38

 

[Spring] 스프링부트에서의 스프링 시큐리티 기본 세팅

Spring Security 기본 세팅 # Gradle 추가 및 SecurityConfig 파일 생성 # 예외처리 # 로그인 화면을 커스텀 페이지로 # id, pw 인증 처리 # 비밀번호 암호화 방식 커스텀 *Gradle 추가 / SecurityConfig - bu..

grow-myself.tistory.com

OAuth2를 이해하기 위해서는 시큐리티에 대한 기본적인 이해와, 사용법을 알고있을 필요가 있습니다. 이를 위해 위 글을 참조해보세요.🧐

그리고

https://grow-myself.tistory.com/49

 

JWT를 SpringBoot 프로젝트에 적용하기(2) - Refresh Token 적용

JWT(Json Web Token) 적용 - Refresh Token # 들어가기 전에 # Refresh Token # Refresh 토큰을 적용한 JWT 로그인 인증 절차 # JWT - Refresh Token 적용하기 # 동작 테스트 *들어가기 전에 https://gro..

grow-myself.tistory.com

기본 코드 토대가 위 글을 기반으로 이어서 작성되니 겹치는 코드들은 생략할 예정입니다. 해당 포스팅을 참고해서 글을 읽어주시면 감사하겠습니다. 또한 Google 기준으로 먼저 작성하였는데, 추후 네이버, 카카오 등도 추가 포스팅 예정입니다. 🙏 


*OAuth2의 동작 방식 (Authorization Code Grant 방식 기준)

- 용어

Resource Owner : 리소스를 가지고 있는 주인. 즉 Bob이라는 사용자가 구글 계정으로 로그인해서 Google의 서비스(Resource)를 이용하고 있다면 Bob이 Google 서비스라는 Resource에 대한 Resource Owner가 됩니다.

Client : Resource Owner를 대신해 보호된 Resource에 액세스하는 애플리케이션. 예를 들어, Bob이라는 사용자가 A라는 애플리케이션을 통해서 Google의 소셜로그인을 이용한다면 애플리케이션 A가 Client가 됩니다.

Authorization Server : Resource Server에 접근 가능하도록 권한을 부여하는 서버(여기서는 Google의 서버 중 하나)

Resource Server : Resource Owner의 Resource를 제공하는 서버(여기서는 Google의 서버 중 하나)

- 용어 정리

Bob(사용자) 로그인 요청 -> 클라이언트(프론트 담당) -> 서비스 운영 서버(백엔드 담당) : (구글입장에서는 클라이언트)

-> Authorization Server(구글 서버1) -> Resource Server(구글 서버2)

- 동작 과정

  1. Resource Owner는 소셜 로그인 버튼을 누르는 등의 서비스 요청을 Client(애플리케이션)에게 전송합니다.
  2. Client는 Authorization Server에 Authorization Code를 요청합니다. 이 때 미리 생성한 Client ID, Redirect URI, 응답 타입을 함께 전송합니다.
  3. Resource Owner는 로그인 페이지를 통해 로그인을 진행합니다.
  4. 로그인이 확인되면 Authorization Server는 Authorization Code를 Client에게 전달합니다. (이 전에 요청과 함께 전달한 Redirect URI로 Code를 전달합니다.)
  5. Client는 전달받은 Authorization Code를 이용해 Access Token 발급을 요청합니다. AccessToken을 요청할 때 미리 생성한 Client Secret, Redirect URI, 권한 부여 방식, Authorization Code를 함께 전송합니다.
  6. 요청 정보를 확인한 후 Redirect URI로 Access Token을 발급합니다.
  7. Client는 발급받은 Access Token을 이용해 Resource Server에 Resource를 요청합니다.
  8. Access Token을 확인한 후 요청 받은 Resource를 Client에게 전달합니다.

*OAuth2를 적용해보기(SpringBoot  Gradle 기준)

1. Google API Console에 서비스 등록

구글 API Console로 이동합니다.

- 새로운 프로젝트를 만듭니다.

- 사용 설정된 API 및 서비스 상단에 프로젝트 선택(or 이미지에 보이는 최근 프로젝트 선택)을 눌러줍니다.

생성된 프로젝트로 선택 시 API 및 서비스가 보이게 됩니다.

- 왼쪽 목록에서 OAuth 동의 화면을 클릭합니다.

  • OAuth 동의 화면에서 User Type은 외부로 해줍니다.
  • 앱 등록 수정 페이지에서 앱 이름을 작성해주시고 본인 이메일을 선택 후 저장 후 계속을 눌러 진행합니다.
  • 나머지는 별도 설정 해줄 것이 현재 없어서 저장 후 계속을 눌러서 진행합니다.

- 왼쪽 목록에서 사용자 인증 정보를 클릭합니다.

  1. 사용자 인증 정보 만들기 - OAuth 클라이언트 ID 클릭
  2. 애플리케이션 유형은 웹 애플리케이션입니다.
  3. 이름을 입력합니다.
  4. 승인된 리디렉션 URI에 http://localhost:8080/login/oauth2/code/google 를 입력합니다.
    • 서비스에서 파라미터로 인증 정보를 주었을 때, 인증이 성공하면 Google에서 리다이렉트할 URL입니다.
    • 스프링부트 2버전의 시큐리티에서는 기본적으로 {도메인}/login/oauth2/code/{소셜서비스코드}로 리다이렉트 URL을 지원하고 있습니다.
    • 사용자가 별도로 리다이렉트 URL과 매핑되는 Controller를 만들 필요가 없습니다. 시큐리티에서 이미 구현해 놓은 상태.
    • 지금은 적용해보기만 하는 것이기에, 로컬 도메인만 추가하였습니다. 추후 AWS 등에 배포 시에는 해당 도메인으로 추가하면 됩니다.
  5. 만들기 클릭 시 OAuth 클라이언트 ID와 보안 비밀번호가 발급됩니다.
    • 해당 정보를 다른 사람에게 노출/공유해서는 안됩니다.
    • 반드시 정보를 별도로 저장/기록 해두어야 합니다.

2. 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-mustache'
  • oauth2를 사용하기 위한 의존성을 추가해줍니다.
  • 테스트를 위한 간단한 페이지를 만들어 이용할 것이기에 이때 사용할 mustache를 추가해줍니다.

3. application.properties(환경 설정)

spring.security.oauth2.client.registration.google.client-id=62650287293-9d9l1m28qe92h0ek0d0ul6ghovkf834s.apps.googleusercontent.com
spring.security.oauth2.client.registration.google.client-secret=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
spring.security.oauth2.client.registration.google.scope=profile,email
  • 구글의 유저 정보를 가져오기 위해서는 구글에게 먼저 우리의 서버(구글입장에서는 클라이언트)가 인증된 사용자임을 알려야합니다.
  • 이를 위해 위에서 구글에 애플리케이션을 등록 후 지급되었던 client-id, client-secret을 해당 설정 파일에 올려두면 스프링 시큐리티에서 이 값을 기반으로 구글에 인가코드를 받고, 액세스 토큰을 요청하여 발급받고 그 토큰으로 다시 유저정보를 받아오는 동작을 자동으로 처리해줍니다.
  • scope : 유저정보를 받을 정보의 범위입니다. default에는 openid, profile, email인데, openid라는 scope가 포함되어 있으면 Open Id Provider로 인식하게 됩니다. 이렇게 되면 OpenId Provider인 서비스(구글)과 그렇지 않은 서비스(네이버/카카오 등)로 나눠서 각각 처리할 OAuth2Service를 별도로 만들어야합니다. 하나의 OAuth2Service로 처리하기 위해 openid scope를 빼고 등록하게하였습니다.

4. OAuthAttributes

@Getter
@NoArgsConstructor
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String memberName;
    private String email;
    private String picture;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String memberName, String email, String picture) {
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.memberName = memberName;
        this.email = email;
        this.picture = picture;
    }

    // of 메서드 사용 이유 : OAuth2User에서 반환하는 정보 : Map이며, 이용하는 소셜마다 프로퍼티값이 다르기에 일일이 넣어줘야함.
    public static OAuthAttributes of(String registrationId, String nameAttributeKey, Map<String, Object> attributes) {
        if("naver".equals(registrationId)) {
            return ofNaver(nameAttributeKey, attributes);
        }
        if("kakao".equals(registrationId)) {
            return ofKakao(nameAttributeKey, attributes);
        }
        return ofGoogle(nameAttributeKey, attributes);
    }

    private static OAuthAttributes ofGoogle(String nameAttributeKey, Map<String, Object> attributes) {
        HashMap<String, Object> hashAttributes = new HashMap<>();
        String memberName = (String) attributes.get("name");
        String email = (String) attributes.get("email");
        String profileImg = (String) attributes.get("picture");
        hashAttributes.put("profileImg", profileImg);
        hashAttributes.put("memberName", memberName);
        hashAttributes.put("email", email);
        hashAttributes.put("sub",nameAttributeKey);

        return OAuthAttributes.builder()
                .memberName(memberName)
                .email(email)
                .picture(profileImg)
                .attributes(hashAttributes)
                .nameAttributeKey(nameAttributeKey)
                .build();
    }

    private static OAuthAttributes ofNaver(String nameAttributeKey, Map<String, Object> attributes) {
        // naver 로그인 요청이 왔을 때 유저정보 저장 처리
		retun null;
    }

    private static OAuthAttributes ofKakao(String nameAttributeKey, Map<String, Object> attributes) {
        // kakao 로그인 요청이 왔을 때 유저정보 저장 처리
		retun null;
    }

    public Member toEntity() {
        return Member.builder()
                .memberName(memberName)
                .email(email)
                .memberRole(Member.MemberRole.ROLE_USER)
                .profileImg(picture)
                .build();
    }
}
  • OAuthAttributes 클래스는 OAuth2 인증이 성공하여 전달된 유저정보들을 통일된 형식으로 저장하여 사용하기 위한 클래스입니다.
  • 각 소셜들은 Map 타입인 attrubutes에 유저정보를 담고 있는데, 이 때 Map의 Key 값을 소셜 별로 다르게 지정하고 있기 때문에 소셜 별로 of 메서드를 따로 지정하여 담아야합니다.
  • 또한 인자로 들어온 Map을 그대로 넣어서 담지 않고, HashMap을 별도로 만들어서 값을 일일이 추가한 이유는 각 소셜에서 보내준 정보가 담겨있는 Map은 엔트리를 추가하거나 변경이 불가능한 unmodifiableMap입니다. 따라서 put 메서드를 사용할 수 없습니다.
  • 아래에서 로그인 성공 후 처리 중, 토큰을 헤더에 담는 부분이 있는데 거기서 여기서 만들고 있는 OAuthAttributes의 attributes 필드를 꺼내와 사용하게 될 겁니다. 인자로 들어온 Map을 그대로 넣어버리게되면 각 소셜 로그인 별 Map이 그대로 들어가 Key 값이 그때 그때 달라지게 됩니다. 이를 모두 같은 key 값으로 꺼내서 쓸 수 있게 만들기 위해 별도로 HashMap을 만들어 필요한 값을 별도로 저장되게 하였습니다.

5. CustomOAuth2UserService

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


    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 소셜 로그인 사전 작업
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
// 소셜 로그인한 사용자의 주체를 oAuth2User에 저장
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

// registrationId : 현재 로그인 진행중인 서비스 구분(네이버/구글/카카오)
// nameAttributeKey : 로그인 진행 시, 키가되는 필드 값 : 구글 'sub', 네이버 'response', 카카오 'id'
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String nameAttributeKey = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

// OAuthAttributes : OAuth2UserService를 통해 가져온 OAuth2User(소셜 로그인 유저)의 attribute(속성)을 담을 클래스
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, nameAttributeKey, oAuth2User.getAttributes());
// 저장된 유저의 정보를 실제 우리 서비스의 회원으로 업데이트
        Member member = saveOrUpdate(attributes);
// Collections.singleton : 단 한개의 객체만 저장 가능한 컬렉션을 만들고 싶을 때 사용
// SimpleGrantedAuthority : 인증 개체에 부여된 권한을 저장해둠
// DefaultOAuth2User : OAuth2User의 구현 클래스
        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority(member.getMemberRole().getRole())),
                attributes.getAttributes(),
                attributes.getNameAttributeKey()
        );
    }
// 우리 서비스에 등록되있는 유저가 아니면 회원가입 처리
// 이미 등록되있는 유저이면 변동된 값만 업데이트
    private Member saveOrUpdate(OAuthAttributes attributes) {
        Member member = memberRepository.findByEmail(attributes.getEmail())
                .map(entity ->

                        entity.updateMember(
                        Member.builder()
                                .memberName(attributes.getMemberName())
                                .profileImg(attributes.getPicture())
                                .build()))
                .orElse(attributes.toEntity()
                        .addMemberId(stringIdGenerator.createMemberId()));
        return memberRepository.save(member);
    }
}
  • OAuth2UserService는 소셜 인증이 완료된 유저의 정보를 받아와 처리하는 역할의 인터페이스입니다. 이 인터페이스를 구현한 CustomOAuth2UserService를 통해 소셜 인증 후의 처리를 커스텀합니다.
  • OAuth2UserRequest : OAuth2UserService가 유저정보 엔드포인트를 요청할 때 사용하는 요청 (소셜로그인한 사용자 정보가 담겨있음)
  • OAuth2User : OAuth 2.0 Provider(구글)에 등록된 사용자의 주체 표현
  • 기타 자세한 사항은 모두 주석처리하여 설명하였습니다.

6. CustomOAuth2SuccessHandler

@Component
@RequiredArgsConstructor
public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final TokenProvider tokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {


        DefaultOAuth2User oAuth2User = (DefaultOAuth2User) authentication.getPrincipal();

        String memberId = (String) oAuth2User.getAttributes().get("memberName");
        String email = (String) oAuth2User.getAttributes().get("email");

        String accessToken = tokenProvider.createAccessToken(memberId, email);
        String refreshToken = tokenProvider.renewalRefreshToken(memberId, email);

        System.out.println("소셜 로그인 성공 유저 : " + email);

        response.addHeader("Authorization", "Bearer " + accessToken);
        response.addHeader("refresh", "Bearer " + refreshToken);

        getRedirectStrategy().sendRedirect(request, response, "http://localhost:8080");
    }
}
  • SimpleUrlAuthenticationSuccessHandler : 서버에서 인증이 성공 후의 처리를 담당하는 클래스
  • 해당 클래스의 onAuthenticationSuccess()를 오버라이딩하면 인증 성공 후처리를 커스터마이징할 수 있다.
  • 인자에 담긴 Authentication 객체의 principal 필드가 바로 위의 CustomOAuth2UserService 클래스에서 생성하여 반환한 DefaultOAuth2User 객체이다. 저장되있는 유저정보를 꺼내와서 토큰을 생성하여 응답 헤더에 추가해주었다.
  • 그리고나서 최초 로그인을 요청했던 컴퓨터의 8080포트 기본 페이지로 리다이렉트하게끔 하였다.
  • 리다이렉트되어 도착한 그곳에 바로 우리의 토큰이 확인되면 구현완료라는 뜻이다.
  • TokenProvider의 내용은 *들어가기전에 쪽의 JWT 포스팅 내용을 읽어보면 이해가 될 것이라고 생각한다.

7. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
	// ... 이전 포스팅에서 추가했던 변수명
    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler;
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    	// ... 이전 포스팅에서 추가했던 필터 체인
    	.and()
        .oauth2Login()	// OAuth2 로그인 기능에 대한 여러 설정의 진입점
        .successHandler(customOAuth2SuccessHandler)	// 우리 서비스 내에서의 유저 인증 성공 후처리 커스텀 핸들러 등록
        .userInfoEndpoint()	// OAuth2 로그인 성공 이후 사용자 정보에 대한 엔드포인트 진입
        .userService(customOAuth2UserService);	// 소셜 로그인 성공 시 후속 조치 진행할 구현체 등록
    return httpSecurity.build();
    }
}
  • 만들어 둔 CustomOAuth2UserService와 CustomOAuth2SuccessHandler를 주입받는다.
  • 실제로 필터체인에서 사용이 되도록 OAuth2와 구현체들을 등록한다.

8. index.mustache

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div>
    <h1>Social Login Test Page</h1>
</div>
<div>
    <a href="http://localhost:8080/oauth2/authorization/google" role="button">Google Login</a>
</div>

</body>
</html>
  • 로그인 테스트를 위한 페이지이다.
  • {도메인}/oauth2/authorization/{소셜 서비스명} : 스프링 시큐리티에서 제공하는 기본 제공 OAuth2 로그인 요청 URL이다. 컨트롤러를 별도로 추가해주지 않아도 알아서 동작시켜 준다. 본래는 scope, clientId, redirectUrl 을 필수 파라미터로 추가하여 요청해야하지만, 이를 시큐리티에서 자동으로 지정해준다. (application.properties에 설정된 값을 이용)
  • 특히 redirectUrl이 중요한데, 이 또한 기본제공 로그인 요청 Url로 애초에 요청했기 때문에 redirectUrl 또한 시큐리티에서 기본적으로 제공해주는 Url로 자동 설정을 해주어 리다이렉트를 시켜주는 것 같다. 리다이렉트Url을 매핑시키는 컨트롤러 또한 작성할 필요가 없다. 위의 구글 API 콘솔에서 기본 리다이렉트Url을 추가해주었기에 문제없이 작동될 것이다.
  • 리다이렉트Url을 커스텀하여 컨트롤러에서 매핑시키면, 구글에서 인가코드를 발급받고 액세스 토큰 요청을 한 뒤에 받은 토큰으로 다시 유저정보를 요청해야한다. 이를 모두 자동으로 해준다고 생각하면 된다. (굉장히 편리하다.. 스프링 시큐리티.. ㅠㅠ)

9. IndexController

@Controller
public class IndexController {
    @GetMapping("/")
    public String index() {
        return "index";
    }
}
  • 위에서 만들어준 페이지를 사용하기 위해 컨트롤러를 만들어 주었다. 로그인 성공 후 리다이렉트도 여기로 되도록 지정했다.
  • 그렇다면 로그인 후에?? 페이지는 새로고침한번 한것처럼 변화가 없어보일 것임.

*OAuth2를 적용해보기(SpringBoot  Gradle 기준)

1. 인덱스 페이지 확인

2. 버튼을 누르면?

  • 우리가 발급한 토큰이 잘 도착해 있는 것을 확인 가능!
Comments