구글 OAuth를 이용하기 위해서는 우선 구글 서비스를 등록해야 한다.
구글 클라우드 플랫폼 ← 여기로 들어간다.
프로젝트 선택 > 새 프로젝트 > 프로젝트 생성
왼쪽 상단의 메뉴 > API 및 서비스 > 사용자 인증 정보 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID 선택
애플리케이션 유형을 선택한 후 만들기
네이버도 마찬가지로 서비스를 등록해야 한다.
네이버 애플리케이션 등록 ← 여기로 들어간다.
약관 동의 > 계정 정보 등록 > 애플리케이션 등록
애플리케이션 이름과 받아올 정보들 설정
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
소셜별로 반환하는 타입은 JSON 타입이므로 Map<String, Object>
의 형식으로 받아올 수 있다.
해당 클래스를 상속받아 각 소셜 타입별로 구현할 수 있다.
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();
}
해당 클래스는 각 소셜 타입별 반환하는 형식이 달라 데이터를 분기처리 해주는 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}",
...
}
}
@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;
}
}
로그인된 사용자의 정보를 담는 클래스로 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;
}
}
해당 클래스는 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()
);
}
}
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);
}
}
로그인에 성공하는 경우 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);
}
}
로그인에 실패하면 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());
}
}
@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();
}
}