[OAuth] 프로젝트에 OAuth 2.0 적용하기

zini9188·2023년 4월 24일
0

프로젝트

목록 보기
2/3

1. OAuth를 이용할 사이트에 서비스를 등록하여야 한다.

Google

구글 OAuth를 이용하기 위해서는 우선 구글 서비스를 등록해야 한다.

  • 구글 클라우드 플랫폼 ← 여기로 들어간다.

  • 프로젝트 선택 > 새 프로젝트 > 프로젝트 생성

  • 왼쪽 상단의 메뉴 > API 및 서비스 > 사용자 인증 정보 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID 선택

  • 애플리케이션 유형을 선택한 후 만들기

  • 이후 생성되는 클라이언트 ID와 비밀번호는 추후 연동하는데 사용된다.

네이버도 마찬가지로 서비스를 등록해야 한다.

  • 네이버 애플리케이션 등록 ← 여기로 들어간다.

  • 약관 동의 > 계정 정보 등록 > 애플리케이션 등록

  • 애플리케이션 이름과 받아올 정보들 설정

  • 사용할 앱의 주소와 네이버로 로그인할 시 리다이렉트될 URL

  • Client ID와 Client Secret를 확인할 수 있다.

yml 파일 설정하기

application-oauth.yml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: GOOGLE_CLIENT_ID
            client-secret: GOOGLE_SECRET
            scope:
              - profile
              - email
          naver:
            client-id: NAVER_CLIENT_ID
            client-secret: NAVER_SECRET
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email
              - profile_image
            client-name: Naver            
        provider:
          naver:
            authorizationUri: https://nid.naver.com/oauth2.0/authorize
            tokenUri: https://nid.naver.com/oauth2.0/token
            userInfoUri: https://openapi.naver.com/v1/nid/me
            userNameAttribute: response         

OAuth2UserInfo - 소셜 타입별로 JSON 정보 받기

소셜별로 반환하는 타입은 JSON 타입이므로 Map<String, Object> 의 형식으로 받아올 수 있다.

OAuth2UserInfo

해당 클래스를 상속받아 각 소셜 타입별로 구현할 수 있다.

public abstract class OAuth2UserInfo {

    protected Map<String, Object> attributes;

    public OAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    public Map<String, Object> getAttributes(){
        return attributes;
    }

    public abstract String getProviderId();
    public abstract String getEmail();
    public abstract String getName();
}

OAuthAttributes

해당 클래스는 각 소셜 타입별 반환하는 형식이 달라 데이터를 분기처리 해주는 DTO 클래스이다.

@Getter
public class OAuthAttributes {

    private String providerId;
    private OAuth2UserInfo oAuth2UserInfo;

    @Builder
    public OAuthAttributes(String providerId, OAuth2UserInfo oAuth2UserInfo) {
        this.providerId = providerId;
        this.oAuth2UserInfo = oAuth2UserInfo;
    }

    public static OAuthAttributes of(ProviderType providerType,
                                     String providerId,
                                     Map<String, Object> attributes) {
        if (providerType == ProviderType.GOOGLE) {
            return ofGoogle(providerId, attributes);
        }
        if (providerType == ProviderType.KAKAO) {
            return ofKakao(providerId, attributes);
        }
        if (providerType == ProviderType.NAVER) {
            return ofNaver(providerId, attributes);
        }

        throw new CustomException(ExceptionCode.PROVIDER_NOT_FOUND);
    }

    private static OAuthAttributes ofGoogle(String providerId, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .providerId(providerId)
                .oAuth2UserInfo(new GoogleUserInfo(attributes))
                .build();
    }

    private static OAuthAttributes ofKakao(String providerId, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .providerId(providerId)
                //.oAuth2UserInfo(new KakaoUserInfo(attributes))
                .build();
    }

    private static OAuthAttributes ofNaver(String providerId, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .providerId(providerId)
                .oAuth2UserInfo(new NaverUserInfo(attributes))
                .build();
    }

    public User toEntity(ProviderType providerType, OAuth2UserInfo oAuth2UserInfo, PasswordEncoder passwordEncoder) {
        return User.builder()
                .provider(providerType.getProvider())
                .providerId(oAuth2UserInfo.getProviderId())
                .email(oAuth2UserInfo.getEmail())
                .username(oAuth2UserInfo.getName())
                .roles(Collections.singletonList(Role.GUEST.getRole()))
                .password(passwordEncoder.encode("NO_PASS" + UUID.randomUUID()))
                .build();
    }
}

네이버

public class NaverUserInfo extends OAuth2UserInfo {

    public NaverUserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getProviderId() {
        Map<String, Object> response = getStringObjectMap();
        if (response == null) return null;

        return (String) response.get("id");
    }

    @Override
    public String getEmail() {
        Map<String, Object> response = getStringObjectMap();
        if (response == null) return null;

        return (String) response.get("email");
    }

    @Override
    public String getName() {
        Map<String, Object> response = getStringObjectMap();
        if (response == null) return null;

        return (String) response.get("name");
    }

    private Map<String, Object> getStringObjectMap() {
        return (Map<String, Object>) attributes.get("response");
    }
}
{
  "resultcode": "00",
  "message": "success",
  "response": {
    "email": "이메일@naver.com",
    "nickname": "닉네임",
    "profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif",
    "age": "40-49",
    "gender": "F",
    "id": "32742776",
    "name": "이름",
    "birthday": "10-01"
  }
}

구글

public class GoogleUserInfo extends OAuth2UserInfo {

    public GoogleUserInfo(Map<String, Object> attributes) {
        super(attributes);
    }

    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }
}
{
   "sub": "식별값",
   "name": "name",
   "given_name": "given_name",
   "picture": "https//lh3.googleusercontent.com/~~",
   "email": "email",
   "email_verified": true,
   "locale": "ko"
}

카카오

카카오에 대한 코드는 아직 추가하지 않았고 추후 필요하다면 추가 예정

{
    "id":123456789,
    "connected_at": "2022-04-11T01:45:28Z",
    "kakao_account": { 
        // 프로필 또는 닉네임 동의 항목 필요
        "profile_nickname_needs_agreement": false,
        // 프로필 또는 프로필 사진 동의 항목 필요
        "profile_image_needs_agreement	": false,
        "profile": {
            // 프로필 또는 닉네임 동의 항목 필요
            "nickname": "홍길동",
            // 프로필 또는 프로필 사진 동의 항목 필요
            "thumbnail_image_url": "http://yyy.kakao.com/.../img_110x110.jpg",
            "profile_image_url": "http://yyy.kakao.com/dn/.../img_640x640.jpg",
            "is_default_image":false
        },
        // 이름 동의 항목 필요
        "name_needs_agreement":false, 
        "name":"홍길동",
        // 카카오계정(이메일) 동의 항목 필요
        "email_needs_agreement":false, 
        "is_email_valid": true,   
        "is_email_verified": true,
        "email": "sample@sample.com",
        // 연령대 동의 항목 필요
        "age_range_needs_agreement":false,
        "age_range":"20~29",
        // 출생 연도 동의 항목 필요
        "birthyear_needs_agreement": false,
        "birthyear": "2002",
        // 생일 동의 항목 필요
        "birthday_needs_agreement":false,
        "birthday":"1130",
        "birthday_type":"SOLAR",
        // 성별 동의 항목 필요
        "gender_needs_agreement":false,
        "gender":"female",
        // 카카오계정(전화번호) 동의 항목 필요
        "phone_number_needs_agreement": false,
        "phone_number": "+82 010-1234-5678",   
        // CI(연계정보) 동의 항목 필요
        "ci_needs_agreement": false,
        "ci": "${CI}",
        "ci_authenticated_at": "2019-03-11T11:25:22Z",
    },
    "properties":{
        "${CUSTOM_PROPERTY_KEY}": "${CUSTOM_PROPERTY_VALUE}",
        ...
    }
}

User

@Entity(name = "member")
@Getter
@Setter
@NoArgsConstructor
public class User extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;
    @Email
    @Column(nullable = false, unique = true)
    private String email;
    @NotBlank
    @Column(nullable = false)
    private String password;
    @NotBlank
    @Column(nullable = false)
    private String username;
    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();
    private String provider; // 소셜 타입
    private String providerId; // 식별값

    public void addRoles(List<String> roles) {
        this.roles = roles;
    }

    @Builder
    public User(String email, String password, String username, List<String> roles, String provider, String providerId) {
        this.email = email;
        this.password = password;
        this.username = username;
        this.roles = roles;
        this.provider = provider;
        this.providerId = providerId;
    }
}

UserPrincipal

로그인된 사용자의 정보를 담는 클래스로 OAuth2User를 추가적으로 구현

@Getter
public class UserPrincipal implements UserDetails, OAuth2User {

    private User user;
    private Map<String, Object> attributes;

    public UserPrincipal(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    public UserPrincipal(User user) {
        this.user = user;
    }

    public static UserPrincipal of(User user) {
        return new UserPrincipal(user);
    }

    public static UserPrincipal of(User user, Map<String, Object> attributes) {
        return new UserPrincipal(user, attributes);
    }

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return AuthorityUtils.getAuthorities(user.getRoles());
    }

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

    @Override
    public String getName() {
        return attributes.get("sub").toString();
    }

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

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

DefaultOAuth2User를 상속하는 CustomOAuth2User

해당 클래스는 OAuth2UserService에서 사용하는 클래스로 OAuth2User 객체를 커스텀하는 클래스이다.

애플리케이션에서 필요로하는 추가적인 정보를 가지고 있기 위해 생성하며 Resource Server에서 제공하는 정보만으로 충분하다면 구현하지 않아도 된다.

@Getter
public class CustomOAuth2User extends DefaultOAuth2User {

    private String email;
    private List<String> role;

    public CustomOAuth2User(Collection<? extends GrantedAuthority> authorities,
                            Map<String, Object> attributes,
                            String nameAttributeKey,
                            String email,
                            List<String> role) {
        super(authorities, attributes, nameAttributeKey);
        this.email = email;
        this.role = role;
    }

    public static CustomOAuth2User of(User user,
                                      Map<String, Object> attributes,
                                      OAuthAttributes oAuthAttributes) {
        return new CustomOAuth2User(
                AuthorityUtils.getAuthorities(user.getRoles()),
                attributes,
                oAuthAttributes.getProviderId(),
                user.getEmail(),
                user.getRoles()
        );
    }
}

CustomOAuth2UserService

OAuth2User는 OAuth 서비스에서 받아온 유저 정보를 담고 있는 객체이다.
소셜 타입을 조회하여 Attributes Dto 클래스로 정보를 가져오고
OAuth2User 객체에 담겨있는 유저의 이메일로 DB를 조회해 새로운 유저를 만들거나 기존의 유저 정보를 가져온다.
이후 CustomOAuth2User 객체를 만들어 반환한다.

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

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        String provider = userRequest.getClientRegistration()
                .getRegistrationId();
        ProviderType providerType = getProviderType(provider);
        String providerId = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();
        Map<String, Object> attributes = oAuth2User.getAttributes();

        OAuthAttributes oAuthAttributes = OAuthAttributes.of(providerType, providerId, attributes);

        User user = getUser(oAuthAttributes, providerType);

        return CustomOAuth2User.of(user, attributes, oAuthAttributes);
    }

    private ProviderType getProviderType(String provider) {
        if (provider.equals("naver")) {
            return ProviderType.NAVER;
        }
        if (provider.equals("kakao")) {
            return ProviderType.KAKAO;
        }
        return ProviderType.GOOGLE;
    }

    private User getUser(OAuthAttributes attributes, ProviderType providerType) {
        return userRepository.findByEmail(attributes.getOAuth2UserInfo().getEmail())
                .orElseGet(() -> createUser(attributes, providerType));
    }

    private User createUser(OAuthAttributes attributes, ProviderType providerType) {
        User user = attributes.toEntity(providerType,
                attributes.getOAuth2UserInfo(),
                passwordEncoder);
        return userRepository.save(user);
    }
}

OAuth2LoginSuccessHandler

로그인에 성공하는 경우 SuccessHandler의 로직이 실행된다.

받아온 CutomOAuth2User의 정보로 JWT를 발급하여 헤더에 담아 보낸다.

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

    private final JwtTokenProvider jwtTokenProvider;

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

    private void loginSuccess(HttpServletResponse response, CustomOAuth2User oAuth2User) throws IOException {
        String accessToken = jwtTokenProvider.generateAccessToken(oAuth2User.getEmail(), oAuth2User.getRole());
        String refreshToken = jwtTokenProvider.generateRefreshToken();
        response.setHeader("Authorization", "Bearer " + accessToken);
        response.setHeader("Refresh", refreshToken);

        jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken);
    }
}

OAuth2LoginFailureHandler

로그인에 실패하면 FailureHandler의 로직이 실행된다.

@Slf4j
@Component
public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
        response.getWriter().write("소셜 로그인 실패! 서버 로그를 확인하세요");
        log.info("소셜 로그인에 실패했습니다. 에러 메세지 : {}", exception.getMessage());
    }
}

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final CorsConfig corsConfig;
    private final CustomOAuth2UserService oAuth2UserService;
    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
    private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;

    @Bean
    protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic().disable()
                .formLogin().disable()
                .csrf().disable()
                .headers().frameOptions().disable()

                ...
                ...
                // 권한 설정..
                ...

                .and()
                .oauth2Login()
                .successHandler(oAuth2LoginSuccessHandler)
                .failureHandler(oAuth2LoginFailureHandler)
                .userInfoEndpoint()
                .userService(oAuth2UserService);

        httpSecurity.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                UsernamePasswordAuthenticationFilter.class);
        httpSecurity.addFilter(corsConfig.corsFilter());
        return httpSecurity.build();
    }

    @Bean
    public static PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
profile
똑같은 짓은 하지 말자

0개의 댓글

관련 채용 정보