OAuth 개념, 동작원리, 네이버/카카오 애플리케이션 등록방법은 이전글에서 정리하였다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
spring:
security:
oauth2:
client:
registration:
naver:
client-id: {client-id}
client-secret: {client-secret}
redirect-uri: https://localhost:8080/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope:
- name
- email
client-name: Naver
kakao:
client-id: {client-id}
client-secret: {client-secret}
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
authorization-grant-type: authorization_code
scope:
- profile_nickname
- account_email
client-name: Kakao
client-authentication-method: client_secret_post
provider:
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
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
client - registration
앞선 Config 설정에 OAuth관련 설정을 추가한다.
protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
...
/*추가*/
.oauth2Login((oauth2) -> oauth2
.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
.userService(customOauth2UserService()))
.successHandler(oAuth2AuthenticationSuccessHandler())
.failureHandler(oAuth2AuthenticationFailureHandler()))
...
return httpSecurity.build();
}
private CustomOauth2UserService customOauth2UserService() {
return customOauth2UserService;
}
private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() {
return oAuth2AuthenticationSuccessHandler;
}
private OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() {
return oAuth2AuthenticationFailureHandler;
}
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import com.devlog.common.entity.User;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class PrincipalDetails implements UserDetails, OAuth2User {
private User user;
private Map<String, Object> attributes;
//일반 로그인
public PrincipalDetails (User user) {
this.user = user;
}
//OAuth 로그인
public PrincipalDetails(User user,Map<String,Object> attributes) {
this.user = user;
this.attributes = attributes;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collectors = new ArrayList<>();
for (String role : user.getAuthority().split(",")) {
collectors.add(new SimpleGrantedAuthority(role));
}
return collectors;
}
/**
* 계정 만료 여부 반환
*
* true -> 만료되지 않았음
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 계정 잠금 여부 반환
*
* true -> 잠금되지 않았음
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 패스워드의 만료 여부 반환
*
* true -> 만료되지 않았음
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 계정 사용 가능 여부 반환
*
* true -> 사용 가능
*/
@Override
public boolean isEnabled() {
return true;
}
public String getAuthority() {
return user.getAuthority();
}
public String getEmail() {
return user.getEmail();
}
public long getUserId() {
return user.getUserId();
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getName() {
return null;
}
}
주된 이유는 애플리케이션에서 동일한 사용자 객체를 사용하여 다양한 인증 방식을 처리할 수 있게 하기 위함이다.
따라서, 애플리케이션이 전통적인 로그인 방식과 OAuth2 로그인 방식을 모두 지원할 수 있으며, 사용자 정보를 일관되게 관리할 수 있다.
이를 통해 코드 중복을 줄이고, 사용자 관리가 간편해진다.
import java.util.Map;
import java.util.Optional;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import com.devlog.common.entity.User;
import com.devlog.repository.UserRepository;
import com.devlog.security.PrincipalDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@RequiredArgsConstructor
public class CustomOauth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.debug("getClientRegistration: {}", userRequest.getClientRegistration());
log.debug("getAccessToken: {}", userRequest.getAccessToken());
log.debug("getAttributes: {}", super.loadUser(userRequest).getAttributes());
OAuth2User oAuth2User = super.loadUser(userRequest);
//클라이언트 등록 ID(google, naver, kakao)
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// OAuth2User 정보로 OAuth2Attribute 객체를 만든다.
OAuth2Attribute oAuth2Attribute = OAuth2Attribute.generate(registrationId, oAuth2User.getAttributes());
// PrincipalDetails(OAuth2User) 객체에 넣어주기 위해서 Map으로 값들을 반환한다.
Map<String, Object> memberAttribute = oAuth2Attribute.convertToMap();
String email = (String) memberAttribute.get("email");
Optional<User> user = userRepository.findByEmail(email);
//이미 소셜로그인을 한적이 있는지 없는지
if (user.isEmpty()) {
memberAttribute.put("exist", false);
//회원이 존재하지 않으므로 기본권한인 USER를 넣어준다
User newUser = new User();
newUser.setAuthority("USER");
newUser.setUsername(email); //springsecurity와 동일함 username은 사용자를 식별할 값 null이면 오류남
return new PrincipalDetails(newUser, memberAttribute);
} else {
memberAttribute.put("exist", true);
return new PrincipalDetails(user.get(), memberAttribute);
}
}
}
getClientRegistration: ClientRegistration{registrationId='naver', clientId='yaml에 설정한 clientId', clientSecret='yaml에 설정한 clientSecret', clientAuthenticationMethod=org.springframework.security.oauth2.core.ClientAuthenticationMethod@4fcef9d3, authorizationGrantType=org.springframework.security.oauth2.core.AuthorizationGrantType@5da5e9f3, redirectUri='http://localhost:28081/login/oauth2/code/naver', scopes=[name, email], providerDetails=org.springframework.security.oauth2.client.registration.ClientRegistration$ProviderDetails@16005d05, clientName='Naver'}
getAccessToken: org.springframework.security.oauth2.core.OAuth2AccessToken@be8051ff
getAttributes: {resultcode=00, message=success, response={id=S9nyJj7tDRSaiCfUeKt6fGCiqlDrOCmRmo3DfRpjIfs, email=xxxxxxx@naver.com, name=아무개}}
OAuth2Attribute.java
import java.util.HashMap;
import java.util.Map;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
@Builder(access = AccessLevel.PRIVATE) // Builder 메서드를 외부에서 사용하지 않으므로, Private 제어자로 지정
@Getter
public class OAuth2Attribute {
private Map<String, Object> attributes; // 사용자 속성 정보를 담는 Map
private String oAuthId; //
private String email; // 이메일 정보
private String name; // 이름 정보
private String picture; // 프로필 사진 정보
private String provider; // 제공자 정보
/**
* provider (Google, Kakao, Naver 등)에 따라 OAuth2Attribute 객체를 생성
*/
public static OAuth2Attribute generate(String provider, Map<String, Object> attributes) {
switch (provider) {
case "google":
return generateByGoogle(provider, attributes);
case "kakao":
return generateByKakao(provider, attributes);
case "naver":
return generateByNaver(provider, attributes);
default:
throw new IllegalArgumentException("Invalid Provider Type.");
}
}
/**
* Google 로그인일 경우 사용하는 메서드, 사용자 정보가 따로 Wrapping 되지 않고 제공되어, 바로 get() 메서드로 접근이 가능하다.
**/
private static OAuth2Attribute generateByGoogle(String provider, Map<String, Object> attributes) {
return OAuth2Attribute.builder()
.email((String) attributes.get("email"))
.name((String) attributes.get("name"))
.oAuthId((String) attributes.get("sub"))
.provider(provider)
.attributes(attributes)
.build();
}
/**
* Kakao 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 kakaoAccount -> kakaoProfile 두번 감싸져 있어서,
* 두번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
**/
private static OAuth2Attribute generateByKakao(String provider, 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 OAuth2Attribute.builder()
.email((String) kakaoAccount.get("email"))
.name((String) attributes.get("name"))
.oAuthId((String) attributes.get("id"))
.provider(provider)
.attributes(kakaoAccount)
.build();
}
/**
* Naver 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 response Map에 감싸져 있어서,
* 한번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다.
**/
private static OAuth2Attribute generateByNaver(String provider, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuth2Attribute.builder()
.email((String) response.get("email"))
.name((String) response.get("name"))
.oAuthId((String) response.get("id"))
.attributes(response)
.provider(provider)
.build();
}
/**
* OAuth2User 객체에 넣어주기 위해서 Map으로 값들을 반환한다.
**/
public Map<String, Object> convertToMap() {
Map<String, Object> map = new HashMap<>();
map.put("oAuthId", oAuthId);
map.put("email", email);
map.put("name", name);
map.put("provider", provider);
return map;
}
}
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import com.devlog.common.entity.User;
import com.devlog.common.jwt.JwtProvider;
import com.devlog.mapper.UserMapper;
import com.devlog.repository.UserRepository;
import com.devlog.security.PrincipalDetails;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
private final UserRepository userRepository;
private final UserMapper userMapper;
private final ApplicationContext applicationContext;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("OAuth2 Login Success");
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
String oAuthId = principalDetails.getAttribute("oAuthId");
String name = principalDetails.getAttribute("name");
String email = principalDetails.getAttribute("email");
String provider = principalDetails.getAttribute("provider");
boolean bExist = principalDetails.getAttribute("exist");
if (bExist) {
log.info("OAuth Authentication Success - Exist member");
} else {
log.info("OAuth Authentication Success - New Member");
User newUser = principalDetails.getUser();
newUser.setUserId(userMapper.getMaxId() + 1);
newUser.setEmail(email);
newUser.setPassword(passwordEncoder().encode(RandomStringUtils.randomAlphanumeric(20)));// 20자리 랜덤 문자열
newUser.setUsername(name);
newUser.setProviderId(oAuthId);
newUser.setProvider(provider);
userRepository.save(newUser);
}
String sAccessToken = jwtProvider.generateAccessToken(authentication);
String sRefreshToken = jwtProvider.generateRefreshToken(principalDetails.getUserId());
log.debug("oauth generate jwtToken = {}", sAccessToken);
log.debug("oauth generate RefreshToken = {}", sRefreshToken);
String redirectUrl = "/login-success.html?token=" + sAccessToken;
response.sendRedirect(redirectUrl);
response.setCharacterEncoding("UTF-8");
}
private PasswordEncoder passwordEncoder() {
return (PasswordEncoder) applicationContext.getBean("passwordEncoder");
}
}
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
@Component
@Slf4j
public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,AuthenticationException exception) throws IOException, ServletException {
log.error(exception.getMessage());
response.sendRedirect("/login.html");
}
}
'/oauth2/authorization/*' 패턴은 Spring Security에서 OAuth2 인증 프로세스를 시작하기 위한 표준화된 URL 패턴이다.
이 패턴을 사용함으로써 개발자는 복잡한 OAuth2 프로토콜 구현을 직접 처리할 필요 없이 Spring Security의 자동화된 기능을 활용할 수 있다.
OAuth2LoginAuthenticationFilter는 기본적으로 '/login/oauth2/code/*' 패턴의 URL만을 처리하도록 설계되어 있다.
이 필터는 OAuth2 인증 프로세스에서 중요한 역할을 한다.
Spring Security의 기본 설정을 그대로 활용할 수 있어 개발자가 복잡한 OAuth2 인증 로직을 직접 구현할 필요가 없다.
리다이렉트 후 Spring Security 동작 순서
OAuth 이전글
Spring Security, JWT 이전글
OAuth Authorization Code Grant 동작 원리 (3/4) (0) | 2024.12.20 |
---|---|
OAuth 네이버, 카카오 애플리케이션 등록 (2/4) (51) | 2024.12.19 |
OAuth 개념 파해치기 (1/4) (56) | 2024.12.18 |
AuthroizationFilter를 이용한 인가(AuthorizationManager, RequestMatcherDelegatingAuthorizationManager) (0) | 2024.12.01 |
Spring Security - FilterSecurityInterceptor 이해 및 Example (2) | 2024.11.11 |
댓글 영역