728x90
개요
이번에는 OAuth2 로그인 인증 인가 부분을 구현할 예정이다.
관련해서 세션 방식으로는 아래 블로그에 정리해두었고 이번에는 토큰으로 저장하는 방법이다.
Spring Security + OAuth2 + Session 를 사용한 로그인, 로그아웃 구현 - Kakao, Naver :: 미정 (tistory.com)
1. Oauth2 응답 객체
- NaverResponse.java
public class NaverResponse implements OAuth2Response{
private final Map<String, Object> attribute;
public NaverResponse(Map<String, Object> attribute) {
this.attribute = (Map<String, Object>) attribute.get("response");
System.out.println("naver attributes: " + attribute);
}
@Override
public String getProvider() {
return "naver";
}
@Override
public String getProviderId() {
return attribute.get("id").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
@Override
public Map<String, Object> getAttributes() {
return attribute;
}
}
- KakaoResponse.java
public class KakaoResponse implements OAuth2Response{
private Map<String, Object> attribute;
public KakaoResponse(Map<String, Object> attribute) {
this.attribute = attribute;
System.out.println("Kakao attributes: " + attribute);
}
@Override
public String getProvider() {
return "kakao";
}
@Override
public String getProviderId() {
return attribute.get("id").toString();
} // 2632890179
@Override
public String getEmail() {
Map<String, Object> kakaoAccount = (Map<String, Object>) attribute.get("kakao_account");
return kakaoAccount.get("email").toString();
}
@Override
public String getName() {
Map<String, Object> properties = (Map<String, Object>) attribute.get("properties");
return properties.get("nickname").toString();
}
@Override
public Map<String, Object> getAttributes() {
return attribute;
}
}
- googleResponse.java
public class GoogleResponse implements OAuth2Response{
private final Map<String, Object> attribute;
public GoogleResponse(Map<String, Object> attribute) {
this.attribute = attribute;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderId() {
return attribute.get("sub").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
@Override
public Map<String, Object> getAttributes() {
return attribute;
}
}
- CustomOAuth2User.java
@Slf4j
public class CustomOAuth2User implements OAuth2User {
private final OAuth2Response oAuth2Response;
private final String role;
public CustomOAuth2User(OAuth2Response oAuth2Response, String role) {
this.oAuth2Response = oAuth2Response;
this.role = role;
}
@Override
public Map<String, Object> getAttributes() {
return oAuth2Response.getAttributes();
}
// role에 해당하는 값
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new SimpleGrantedAuthority(role));
return collection;
}
@Override
public String getName() {
return oAuth2Response.getName();
}
public String getUsername() {
return oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();
}
/**
* 각 소셜 로그인마다 OAuth2 제공자의 ID 형식이 다르기 때문에 각 ID에 맞는 처리
* @return getProviderId
* Google : 매우 큰 숫자 BigInt
* Naver : 문자열 String
* Kakao : 작은 숫자 Long
*/
public Long getUserIdAsLong() {
String providerId = oAuth2Response.getProviderId();
if (providerId == null) {
return null;
}
try {
// providerId가 정수인지 확인
if (providerId.matches("\\d+")) {
BigInteger bigInt = new BigInteger(providerId);
if (bigInt.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) {
return Long.MAX_VALUE;
} else if (bigInt.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) {
return Long.MIN_VALUE;
} else {
return bigInt.longValue();
}
} else {
// providerId가 정수가 아닌 경우 문자열의 해시코드를 사용하여 Long으로 변환
return (long) providerId.hashCode();
}
} catch (NumberFormatException e) {
log.error("user ID parsing 오류 :: {}", providerId, e);
return null;
}
}
public List<String> getRoles() {
return Collections.singletonList(role);
}
}
- 시큐리티 세션 방식처럼 UserDetails 역할을 하는 것
- 카카오, 네이버, 구글 서비스로부터 받은 특정 사이트의 응답 값과 롤에 대한 값을 받는 클래스이다.
- 이 클래스를 통해 특정 값과 롤에 대해 정의한다.
2. OAuth2UserService 구현
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("OAuth2User attributes: {}", oAuth2User.getAttributes());
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response = null;
switch (registrationId) {
case "naver":
log.info("naver 로그인");
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
break;
case "kakao":
log.info("kakao 로그인");
oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
break;
case "google":
log.info("google 로그인");
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
break;
default:
log.error("로그인 실패: 지원하지 않는 로그인 제공자입니다. 등록 ID: {}", registrationId);
throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
}
String provider = oAuth2Response.getProvider();
String providerId = oAuth2Response.getProviderId();
String username = provider + " " + providerId; // 유저명 생성 (OAuth2 공급자명 + 공급자ID)
Optional<User> userOptional = userRepository.findByUsername(username);
if (userOptional.isPresent()) {
// 기존 유저가 이미 존재하는 경우, 추가 작업 없이 바로 반환
log.info("기존 유저 로그인: {}", username);
return new CustomOAuth2User(oAuth2Response, userOptional.get().getRoles().stream().map(Role::getName).collect(Collectors.joining(",")));
}
// 새로운 유저에 대한 처리
String roleName = "ROLE_USER";
Optional<Role> roleOptional = roleRepository.findByName(roleName);
Role role;
if (roleOptional.isEmpty()) {
role = new Role(roleName);
role = roleRepository.save(role);
} else {
role = roleOptional.get();
}
User newUser = User.builder()
.name(oAuth2Response.getName())
.username(username)
.roles(Set.of(role))
.providerId(oAuth2Response.getProviderId())
.provider(oAuth2Response.getProvider())
.password("")
// 마이페이지에서 직접 설정할 필드들
.phoneNumber("01000000000")
.birthdate(LocalDate.from(LocalDateTime.now()))
.gender("여자")
.registrationDate(LocalDateTime.now())
.usernick(oAuth2Response.getEmail())
.build();
userRepository.save(newUser);
log.info("새로운 유저 생성: {}", username);
return new CustomOAuth2User(oAuth2Response, roleName);
}
}
- Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
- 주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
- loadUser는 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드이다.
- 외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.
3. 성공 핸들러
@Component
@Slf4j
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtTokenizer jwtTokenizer;
private final RefreshTokenService refreshTokenService;
private final UserRepository userRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
try {
CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();
String username = customUserDetails.getUsername();
Optional<User> user = userRepository.findByUsername(username);
if (user.isEmpty()) {
System.out.println("유저기ㅏ 없어요");
throw new UsernameNotFoundException("User not found with username: " + username);
}
Long userId = user.get().getId();
System.out.println("userId :: " + userId);
String name = customUserDetails.getName();
List<String> roles = customUserDetails.getRoles();
log.info("Oauth2 로그인 성곻했습니다. ");
log.info("jwt 토큰 생성 :: userId: {}, username: {}, name: {}, roles: {}", userId, username, name, roles);
String accessToken = jwtTokenizer.createAccessToken(userId, username, name, roles);
String refreshToken = jwtTokenizer.createRefreshToken(userId, username, name, roles);
log.info("Access Token :: {}", accessToken);
log.info("Refresh Token :: {}", refreshToken);
// 쿠키에 토큰 저장
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT / 1000));
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
// 리프레시 토큰 DB 저장
Date date = new Date(System.currentTimeMillis() + jwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT);
RefreshToken refreshTokenEntity = new RefreshToken();
refreshTokenEntity.setValue(refreshToken);
refreshTokenEntity.setUserId(userId);
refreshTokenEntity.setExpiration(date.toString());
refreshTokenService.addRefreshToken(refreshTokenEntity);
// 추가 정보가 없을 때만 oauthPage로 리다이렉트
if (user.get().getPhoneNumber().equals("01000000000")) {
response.sendRedirect("/oauthPage");
} else {
response.sendRedirect("/my"); // 이미 추가 정보가 있을 경우 메인 페이지로 리다이렉트
}
} catch (Exception e) {
log.error("Oauth2 로그인에 실패했습니다.", e);
if (!response.isCommitted()) {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "An error occurred during authentication");
}
}
}
}
- 나머지는 기존 세션 방식의 소셜로그인과 동일하다면 jwt 토큰 발급에서는 이 부분이 필요하다.
- 로그인에 성공하면 jwt 토큰을 발급해줘야 하기 때문이다.
4.SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final JwtBlacklistService jwtBlacklistService;
private final RefreshTokenService refreshTokenService;
private final JwtTokenizer jwtTokenizer;
private final CustomOauth2UserService customOAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
// 모든 유저 허용 페이지
String[] allAllowPage = new String[] {
"/", // 메인페이지
"/signup", // 회원가입 페이지
"/signin", // 로그인 페이지
"/css/**", "/js/**", "/files/**", // css, js, 이미지 url
"/api/users/login", // 로그인 페이지
"/api/users/signup", // 회원가입 페이지
"/api/users/mail","/api/users/verify-code", "/api/users/check-email","/api/users/check-usernick", // 인증 메일 페이지
"/oauth2/**", "/login/oauth2/**", // OAuth2 로그인 허용
"/api/users/randomNickname", // 랜덤 닉네임 생성
"/api/users/reset-password", "/api/users/verify-temporary-password", "/my/change-password" // 임시 비밀번호 발급
};
// 관리자 페이지
String[] adminAllowPage = new String[] {
"/admin",
"/admin/**"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.requestMatchers(allAllowPage).permitAll()
.requestMatchers(adminAllowPage).hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(new JWTFilter(jwtTokenizer, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class) // JWT 필터 사용
.formLogin(form -> form.disable()) // 로그인 폼 비활성화
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 관리 Stateless 설정(서버가 클라이언트 상태 저장x)
.csrf(csrf -> csrf.disable()) // cors 허용
.httpBasic(httpBasic -> httpBasic.disable()) // http 기본 인증(헤더) 비활성화
.cors(cors -> cors.configurationSource(configurationSource()))
.exceptionHandling(exception -> exception
.authenticationEntryPoint(customAuthenticationEntryPoint)
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/signin")
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2UserService))
.successHandler(customSuccessHandler)
.failureUrl("/loginFailure")
.authorizationEndpoint(authorization -> authorization.baseUri("/oauth2/authorization"))
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource configurationSource(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*"); // 모든 도메인 허용
config.addAllowedOrigin("http://localhost:3000"); // 프론트의 주소
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowedMethods(List.of("GET", "POST", "DELETE"));
config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
config.setAllowCredentials(true);
source.registerCorsConfiguration("/**", config);
return source;
}
}
- OAuth2 설정을 해준다.
그럼 JWT + OAuth2 + Security 에 대한 인증/인가는 끝이 났다.
728x90
'Spring > Security' 카테고리의 다른 글
Spring : JWT 토큰 저장 DB 변경 (MySQL -> Redis) (0) | 2024.08.23 |
---|---|
JWT 로그인 시 Refresh-Token 저장소를 Redis로 변경 (0) | 2024.08.20 |
Spring Security + OAuth2 + JWT 를 사용한 로그인, 로그아웃 구현 - 2 (0) | 2024.08.13 |
Spring Security + OAuth2 + JWT 를 사용한 로그인, 로그아웃 구현 - 1편 (0) | 2024.08.09 |
Spring Security + OAuth2 + Session 를 사용한 로그인, 로그아웃 구현 - google (0) | 2024.08.08 |