백엔드 개발자 블로그

Spring Security + OAuth2.0 + JWT 본문

Spring/Spring Security

Spring Security + OAuth2.0 + JWT

backend-dev 2024. 4. 20. 15:21

보통 사용자들은 인증으로 구글이나 카카오 로그인을 선호한다. 보안에 대한 신뢰도와 id, pwd를 일일히 기억하기 힘들기 때문이다. 이런 사용자들의 니즈를 파악하고 OAuth2를 사용하여 구글이나 카카오 로그인을 직접 구현해 보면서 글을 적어보았다.


용어 정리

  • Resource Owner : 개인 정보의 소유자를 말한다 (서비스 사용자)
  • Resource Server : 개인 정보를 저장하고 있는 서버를 의미한다. (구글, 카카오, 네이버)
  • Client : Resource Server로부터 인증을 받고자 하는 서버다. (우리가 개발중인 서비스의 서버)

OAuth2 로그인 요청 처리 흐름

  1. Resource Owner가 Client에 접근하기 위해 Resource Server의 로그인 창을 클릭한다.
  2. 로그인에 성공하면 Resource Server에 등록해놓은 Redirect URI(http://{Client IP 주소}/login/oauth2/code/{registrationId}?{code}) 경로로 요청이 들어온다.
  3. Client는 {code} 값을 이용해서 다시 Resource Server로 Access Token을 요청한다.
  4. Client는 Access Token으로 다시 Resource Server로 scope에 해당하는 사용자 정보를 요청한다. (profile, email 등)
  5. 사용자 정보를 이용해 서비스의 회원인지 아닌지 판단하고 회원이라면 JWT로 Access Token과 Refresh Token을 만들어서 반환하고 웹사이트의 사용자가 아니라면 간단한 회원가입을 진행한다.
  6. 만약 JWT의 Access Token이 만료된 경우 Refresh Token으로 Resoure Server에게 Access Token의 재발행을 요청한다.

구현

1. 의존성 추가

Spring Security OAuth2 라이브러리와 OAuth2 클라이언트를 사용하기 위한 의존성을 추가했다.

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

 

2. OAuth2 클라이언트 등록

OAuth2 클라이언트를 등록한다.

각 서비스 별로 신규 서비스를 생성해줘야 한다. 여기서 발급된 client-id와 client-sercret을 통해서 로그인 기능과 소셜 서비스 기능을 사용할 수 있다.

인증 정보는 application.yml에 추가해주었다.

  • scope의 기본값은 openId, profile, email이지만 openId가 scope에 있으면 OpenId Provider로 인식하기 때문에 OpenId Provider 서비스(구글)가 아닌 서비스(네이버, 카카오)로 나누어서 OAuth2Service를 만들어야해서 openId를 제외했다.
  • OpenId Provider인 서비스(구글)의 로그인 기능만 추가할거라면 openId를 넣으면 될 것 같다. 
  • client-id와 client-secret 데이터는 외부에 노출되면 안되기때문에 jasypt ********암호화했다. ENC(암호) 이게 암호화된 데이터다. ([참고 링크](https://green-bin.tistory.com/30))
  • Spring Boot 2.0부터 CommonOAuth2Provider가 추가되어 구글, 깃허브, 페이스북, 옥타는 기본 설정값을 스프링이 제공해준다. 카카오, 네이버는 직접 추가해줘야한다..ㅠㅠ
spring:
	security:
	    oauth2:
	      client:
	        registration:
	          google:
	            client-id: ENC(YROBiwrWocFI0OGZCEDse+j/iHoWN4eO216IVv3+HaZP0tsMPfXIMkJqWGfyYFuty/rb4f7UDi9B/OByAgeNxGQbRgwcC7nHTi/lhasIXaRfW/ETFNQo4g==)    # REST API 키
	            client-secret: ENC(7a9NSzNM7KRlnEW5/UmYcKvcDNrbah0tPTV43EzKUZ1xJi1objqQLUANPHdpp6rv)
	            scope: profile, email # 기본값이 openid, profile, email이지만 openid를 등록하게 되면 서비스마다(카카오, 네이버) OAuth2Service를 만들어야하기 때문에 profile, email만 scope로 지정
	            redirect-uri: "<https://66challenge-server.store/login/oauth2/code/google>"
	          kakao:
	            client-id: ENC(1HIZn2QzKn4nRunWgWlDbmi6D948IfN/0vuLeoPwwc8oJxlsWOfoU7/K3pOUWG4v)   # REST API 키
	            client-secret: ENC(w/Ip7Zu91manw2kqhhRPO6St73p2GOaOhtigoc+qT4pNbH+FKvuALyd7cq9nv1Zt)
	            redirect-uri: "<https://66challenge-server.store/login/oauth2/code/kakao>"
	            client-authentication-method: POST
	            authorization-grant-type: authorization_code
	            scope: profile_nickname, profile_image, account_email, gender, age_range  # 동의 항목
	            client-name: Kakao
	          naver:
	            client-id: ENC(FuYscwF2S65D3gZ1dFQP2VNW6gd5ZxGs5yqltpIvHTw=)   # REST API 키
	            client-secret: ENC(35dAwGDL/QoXHR0wUz7dxtlrjk6qk8Bq)
	            redirect-uri: "<https://66challenge-server.store/login/oauth2/code/naver>"
	            authorization-grant-type: authorization_code
	            scope: name, email, profile_image, gender, age  # 동의 항목
	            client-name: Naver
	        provider:
	          kakao:
	            authorization-uri: <https://kauth.kakao.com/oauth/authorize>
	            token-uri: <https://kauth.kakao.com/oauth/token>
	            user-info-uri: <https://kapi.kakao.com/v2/user/me>
	            user-name-attribute: id
	          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
  • Client ID : Resource Server에서 발급해주는 ID이다. 66Challenge에 할당한 ID를 말한다.
  • Client Secret : Resource Server에서 발급해주는 비밀번호다. 66Challenge에 할당된 비밀번호를 말한다.
  • Authorized Redirect Uri : Client에서 등록하는 Uri다. 이 Uri에서 인증을 요구하는게 아니라면, Resource Server는 해당 요청을 무시한다.

 

3. 인증 권한 부여 및 사용자 정보 가져오기

OAuth2 인증을 완료 후 전달 받은 데이터로 서비스에 접근할 수 있는 인증 정보를 생성해주고 사용자 정보를 가져온다. Resource Server(구글, 카카오, 네이버)마다 보내주는 데이터가 다르기 때문에 OAuth2Attribute에 전달하여 처리해준다.

loadUser 메서드는 이런 정보가 들어왔는데 회원이 맞는지 확인하는 메서드라고 생각하면 된다. 이 때, OAuth2User를 반환하면 Spring에서 알아서 Session에 저장해준다.

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final UserRepository userRepository;
    private final CustomAuthorityUtils authorityUtils;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2UserService<OAuth2UserRequest, OAuth2User> service = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = service.loadUser(userRequest);  // OAuth2 정보를 가져옵니다.

        Map<String, Object> originAttributes = oAuth2User.getAttributes();  // OAuth2User의 attribute

        // OAuth2 서비스 id (google, kakao, naver)
        String registrationId = userRequest.getClientRegistration().getRegistrationId();    // 소셜 정보를 가져옵니다.

        // OAuthAttributes: OAuth2User의 attribute를 서비스 유형에 맞게 담아줄 클래스
        OAuthAttributes attributes = OAuthAttributes.of(registrationId, originAttributes);
        User user = saveOrUpdate(attributes);
        String email = user.getEmail();
        List<GrantedAuthority> authorities = authorityUtils.createAuthorities(email);

        return new OAuth2CustomUser(registrationId, originAttributes, authorities, email);
    }

		/**
		 * 이미 존재하는 회원이라면 이름과 프로필이미지를 업데이트해줍니다.
		 * 처음 가입하는 회원이라면 User 테이블을 생성합니다.
		**/
    private User saveOrUpdate(OAuthAttributes authAttributes) {
        User user = userRepository.findByEmail(authAttributes.getEmail())
                .map(entity -> entity.update(authAttributes.getName(), authAttributes.getProfileImageUrl()))
                .orElse(authAttributes.toEntity());

        return userRepository.save(user);
    }
}
import challenge.server.user.entity.User;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

import java.util.List;
import java.util.Map;

@Getter
@ToString
public class OAuthAttributes {
    private Map<String, Object> attributes;     // OAuth2 반환하는 유저 정보
    private String nameAttributesKey;
    private String name;
    private String email;
    private String gender;
    private String ageRange;
    private String profileImageUrl;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributesKey,
                           String name, String email, String gender, String ageRange, String profileImageUrl) {
        this.attributes = attributes;
        this.nameAttributesKey = nameAttributesKey;
        this.name = name;
        this.email = email;
        this.gender = gender;
        this.ageRange = ageRange;
        this.profileImageUrl = profileImageUrl;
    }

    public static OAuthAttributes of(String socialName, Map<String, Object> attributes) {
        if ("kakao".equals(socialName)) {
            return ofKakao("id", attributes);
        } else if ("google".equals(socialName)) {
            return ofGoogle("sub", attributes);
        } else if ("naver".equals(socialName)) {
            return ofNaver("id", attributes);
        }

        return null;
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .name(String.valueOf(attributes.get("name")))
                .email(String.valueOf(attributes.get("email")))
                .profileImageUrl(String.valueOf(attributes.get("picture")))
                .attributes(attributes)
                .nameAttributesKey(userNameAttributeName)
                .build();
    }

    private static OAuthAttributes ofKakao(String userNameAttributeName, 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 OAuthAttributes.builder()
                .name(String.valueOf(kakaoProfile.get("nickname")))
                .email(String.valueOf(kakaoAccount.get("email")))
                .gender(String.valueOf(kakaoAccount.get("gender")))
                .ageRange(String.valueOf(kakaoAccount.get("age_range")))
                .profileImageUrl(String.valueOf(kakaoProfile.get("profile_image_url")))
                .nameAttributesKey(userNameAttributeName)
                .attributes(attributes)
                .build();
    }

    public static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name(String.valueOf(response.get("nickname")))
                .email(String.valueOf(response.get("email")))
                .profileImageUrl(String.valueOf(response.get("profile_image")))
                .ageRange((String) response.get("age"))
                .gender((String) response.get("gender"))
                .attributes(response)
                .nameAttributesKey(userNameAttributeName)
                .build();
    }

    public User toEntity() {
        return User.builder()
                .username(name)
                .email(email)
                .roles(List.of("USER"))
                .build();
    }
}
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import java.util.Map;

@AllArgsConstructor
public class OAuth2CustomUser implements OAuth2User, Serializable {

    private String registrationId;
    private Map<String, Object> attributes;
    private List<GrantedAuthority> authorities;
    private String email;

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getName() {
        return this.registrationId;
    }

    public String getEmail() {
        return email;
    }
}

 

4. JWT 토큰 생성

OAuth2MemberSuccessHandler 에서 Access Token과 Refresh Token을 생성한 후 Redirect Uri에 쿼리 파라미터로 담아 보내준다.

import challenge.server.security.jwt.JwtTokenizer;
import challenge.server.security.oauth.dto.OAuth2CustomUser;
import challenge.server.security.utils.CustomAuthorityUtils;
import challenge.server.user.entity.User;
import challenge.server.user.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Slf4j
public class OAuth2MemberSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtTokenizer jwtTokenizer;
    private final CustomAuthorityUtils authorityUtils;
    private final UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        OAuth2CustomUser oAuth2User = (OAuth2CustomUser) authentication.getPrincipal();

        String email = oAuth2User.getEmail(); // OAuth2User로부터 Resource Owner의 이메일 주소를 얻음 객체로부터
        List<String> authorities = authorityUtils.createRoles(email);           // 권한 정보 생성
        authorities.stream().map(authoritie -> authoritie.replaceFirst("a", "")).collect(Collectors.toList());

        redirect(request, response, email, authorities);  // Access Token과 Refresh Token을 Frontend에 전달하기 위해 Redirect
    }

    private void redirect(HttpServletRequest request, HttpServletResponse response, String email, List<String> authorities) throws IOException {
        log.info("Token 생성 시작");
        String accessToken = delegateAccessToken(email, authorities);  // Access Token 생성
        String refreshToken = delegateRefreshToken(email);     // Refresh Token 생성
        User user = userService.findByEmail(email);
        Long userId = user.getUserId();
        String username = user.getUsername();
        user.setRefreshToken(refreshToken);
        userService.saveUser(user);

        String uri = createURI(accessToken, refreshToken, userId, username).toString();   // Access Token과 Refresh Token을 포함한 URL을 생성
        getRedirectStrategy().sendRedirect(request, response, uri);   // sendRedirect() 메서드를 이용해 Frontend 애플리케이션 쪽으로 리다이렉트
    }

		// Access Token 생성
    private String delegateAccessToken(String username, List<String> authorities) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        claims.put("roles", authorities);

        String subject = username;
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

        return accessToken;
    }

		// Refresh Token 생성
    private String delegateRefreshToken(String username) {
        String subject = username;
        Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
        String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());

        String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);

        return refreshToken;
    }

		// Redirect URI 생성. JWT를 쿼리 파라미터로 담아 전달한다.
    private URI createURI(String accessToken, String refreshToken, Long userId, String username) {
        MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
        queryParams.add("user_id", String.valueOf(userId));
        queryParams.add("username", username);
        queryParams.add("access_token", accessToken);
        queryParams.add("refresh_token", refreshToken);

        return UriComponentsBuilder
                .newInstance()
                .scheme("https")
                .host("66challenge.shop")
                .path("/oauth")
                .queryParams(queryParams)
                .build()
                .toUri();
    }
}

 

5. Security 설정

SecurityConfig에 OAuth2 로그인을 처리하기 위한 설정을 추가한다. 인증 및 권한 부여에 대한 설정을 추가한다.

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
		@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                ...
                .oauth2Login(oauth2 -> oauth2
                        .successHandler(new OAuth2MemberSuccessHandler(jwtTokenizer, authorityUtils, userService))
                        .userInfoEndpoint() // oauth2 로그인 성공 후 가져올 때의 설정들
                        // 소셜로그인 성공 시 후속 조치를 진행할 UserService 인터페이스 구현체 등록
                        .userService(customOAuth2UserService));  // 리소스 서버에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능 명시
		}
}

 

결과

성공!

'Spring > Spring Security' 카테고리의 다른 글

Spring Security  (0) 2024.04.20