REST API 방식에서의 Kakao Login과 SpringSecurity에 대한 글을 찾다가 들어오신 분을 위해 말씀드립니다.
이 글은 Thymeleaf를 통해 뷰를 구성하고, Spring MVC @Controller 방식의 formLogin과 KakaoLogin을 혼용하기 위한 방법에 대해 설명하고 있습니다. REST API 방식과는 많이 다른것으로 알고있으니 참고하세요!
이전 글..
Kakao Login API 사용하기
2023.10.15 - [Spring/SpringBoot] - [Spring] 카카오 로그인 API 사용 방법
얼마 전 Kakao Login API를 처음 사용해 봤다. 기본적인 방법을 이용해서 Kakao Developers가 제공하는 방법대로 Kakao 유저의 사용자 정보 받기까지 완료했다. 그러나 해당 유저 정보를 바탕으로 Spring security 로그인 처리를 하고, 회원가입을 진행하는 방법을 찾기가 너무 어려웠다.
코드를 보시려면 바로 [ KakaoLogin Controller ] 부터 봐주세요.
Thymeleaf와 formLogin을 이용한 Spring MVC 프로젝트
이번 프로젝트는 REST API 방식이 아닌 Thymeleaf를 이용한 Spring MVC 프로젝트로 진행하려고 했다.
그러나 formLogin 방식의 spring security에 대한 정보도 부족하고, 최신 글이 없어서 여러 가지를 시도해 보며 며칠간의 시간을 소모했다. 결국 며칠간의 고군분투 끝에 방법을 찾았고, 막상 찾아보니 별거 없었고, 정말 쉽다...
다만, 이 방법이 보안 관점에서 안전한지, 좋은 방법인지는 모르겠다. 이후 Spring Security를 더 자세히 공부하면서 알고 싶다. 지금은 일단 카카오 로그인 API를 이용하여 로그인, 회원가입 처리를 해보고 싶었다.
시행착오
일단 내가 사용한 방식은 OAuth2를 제대로 SpringSecurity에서 사용하는 것은 아닌 것 같다.
처음엔 이 OAuth2 라이브러리를 이용해서 Spring Security에서 사용하려고 해 봤지만, 쉽지 않았다.
맨 처음에 카카오 로그인 API를 따라서 사용자 정보를 받아오는데 까지는 성공했다.
그러나 이걸 Spring Security의 OAuth2 User로 변환하여 SpringSecurityContext에 담는 걸 하지 못했다.
중간에 JWT 토큰 방식으로 싹 바꿔봤다. 그런데 JWT 토큰을 매번 HTTP Header에 포함시켜야 되는데, 이것도 쉽지 않았고, 애초에 JWT 토큰의 사용 이유에 부적합한 방식이었다.
JWT 토큰은 대부분 REST API 방식에서만 사용하는 게 좋다고 한다.
그래서 다시 기존 방식으로 바꿨다.
그리고 또다시 SpringSecurity에 대한 정보도 찾아보며 여러 가지 방법을 찾던 중에,
SpringSecurity가 관리하는 SecurityContext에 직접 UserDetails를 올리는 방법을 찾게 되었고,
이 글에서 이 방법을 설명해보려 한다.
KakaoLogin Controller
이 부분은 지난 글과 겹친다. 지난 글의 카카오 로그인 API를 이용하여 사용자 정보를 받아오는 것까지 했다.
그리고, 지난 글의 컨트롤러에서 리팩터링을 조금 해서 KakaoApi 클래스의 메서드 반환값이 조금 바뀌었으나 결과는 똑같다.
KakaoAuthController
KakaoApi.getOAuthToken, KakaoApi.getUserInfo 메서드로 카카오톡 사용자 정보를 얻어오는 방법이 궁금하신 분은 글 상단의 이전 글을 참고해 주세요.
"지난 글의 결과 " 이후의 코드는 한줄한줄 설명드립니다.
@Controller
public class KakaoAuthController {
private final KakaoApi kakaoApi;
private final UserService userService;
private final AuthService authService;
@RequestMapping("/login/oauth2/code/kakao")
public String kakaoLogin(@RequestParam String code, HttpSession session){
// ===== 카카오 api =====
// 1. 인가 코드 받기(controller)
// 2. 토큰 받기
OAuthToken oAuthToken = kakaoApi.getOAuthToken(code);
// 3. 사용자 정보 받기
KakaoProfile kakaoProfile = kakaoApi.getUserInfo(oAuthToken.getAccess_token());
// ===== 카카오 api 끝 =====
// ===== Spring Security 로그인 처리 =====
// 1. 유저 존재 여부 확인
boolean result = userService.userExists(kakaoProfile.getEmail());
PrincipalDetails principalDetails;
// 2-1. result == false : 회원 가입
if(!result){
SocialUserRegister socialUserRegister = SocialUserRegister.fromKakao(kakaoProfile);
principalDetails = new PrincipalDetails(userService.socialUserRegister(socialUserRegister));
}// 2-2. result == true : 로그인 처리
else{
principalDetails = userService.getPrincipalDetails(kakaoProfile.getEmail());
}
log.info("[카카오 로그인] {}", principalDetails);
// 3. 로그인 처리
authService.loadUserDirectly(principalDetails, session);
// 4-1. 이번에 회원가입 했다면, 회원가입 완료 안내페이지로 이동
if(!result){
return "redirect:/register/social";
}
// 4-2. 로그인만 했다면, home으로 이동
return "redirect:/board/home";
}
}
class KakaoProfile
Kakao Login API를 통해 위와 같은 KakaoProfile 객체를 생성했다.
이 객체가 Kakao 사용자의 모든 것을 담고 있다.
@Data
public class KakaoProfile {
private Integer id;
private LocalDateTime connectedAt;
private String email;
private String nickname;
public KakaoProfile(String jsonResponseBody){
//JSON response를 받아서 KakaoProfile의 값을 설정하는 생성자
}
}
1. 유저 중복 확인
이 프로젝트에서 User 엔티티는 이메일을 userId로 사용하고, unique 제약조건이 있다.
간단하게 user 중복 확인을 해준다. 이미 해당 email로 가입된 사용자가 있다면 result = true로 반환된다.
boolean result = userService.userExists(kakaoProfile.getEmail());
public boolean userExists(String userId) {
return userRepository.existsByUserId(userId);
}
2-1. 회원가입 ( 기존 유저가 존재하지 않을 때 )
result == false 라면, 이 유저는 처음으로 사이트에 카카오 로그인을 시도했기 때문에 회원가입 로직을 따른다.
if(!result){
//SocialUserRegister Model로 변환
SocialUserRegister socialUserRegister = SocialUserRegister.fromKakao(kakaoProfile);
principalDetails = new PrincipalDetails(userService.socialUserRegister(socialUserRegister));
authService.loadUserDirectly(principalDetails, session);
return "redirect:/register/social";
}
우선 이후 다른 방식( naver, google 등)으로 로그인할 수 있도록 할 수도 있기 때문에, 확장성을 위해 kakaoProfile객체를 SocialUserRegister 객체로 변환해 준다.
1) 소셜 유저 회원가입 메서드
//AuthService
public User socialUserRegister(SocialUserRegister socialUserRegister) {
User user = SocialUserRegister.toEntity(socialUserRegister);
return userRepository.save(user);
}
socialUserRegister객체를 받아서 User 엔티티로 변환한 디 DB에 저장해 주는 로직을 수행한다.
저장한 User 엔티티를 반환한다.
2) PrincipalDetails( implements UserDetails ) 생성
principalDetails = new PrincipalDetails(userService.socialUserRegister(socialUserRegister));
UserDetails를 구현한 PrincipalDetails를 생성해 준다. (이 클래스는 아래서 다룹니다.)
2-2. 로그인 처리 (기존 유저가 존재할 때)
1) email로 유저를 찾아서 PrincipalDetails로 변환하여 반환
principalDetails = userService.getPrincipalDetails(kakaoProfile.getEmail());
2) getPrincipalDetails(String email) 메서드
public PrincipalDetails getPrincipalDetails(String email) {
User user = userRepository.findByUserId(email)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND, "UserService.getPrincipalDetails"));
return new PrincipalDetails(user);
}
3. 로그인 처리
위에서 설명했듯이 Spring이 SecurityContext에 직접 principalDetails를 넣어준다.
authService.loadUserDirectly(principalDetails, session);
이 부분은 SpringSecurity 관련이므로 아래서 따로 다룹니다.
4-1. 이번 로그인 시에 회원가입이 되었을 때
회원 가입 결과 안내 창으로 이동시킨다.
// 4-1. 이번에 회원가입 했다면, 회원가입 완료 안내페이지로 이동
if(!result){
return "redirect:/register/social";
}
4-2. 로그인만 했다면, home으로 이동시킨다.
// 4-2. 로그인만 했다면, home으로 이동
return "redirect:/board/home";
Spring SecurityContext
Spring Security 구조
Spring Security에서 로그인 시에 여러 가지 로직을 거쳐서 로그인한 유저가 UserDetail 타입이 SecurityContext에 담기게 된다. 이게 바로 로그인 상태라고 판단하는 근거이다.
10번의 SecurityContextHolder가 SecurityContext를 보관하고,
SecurityContext의 Authentication 객체가 보관된다.
해당 객체가 실질적인 로그인 유저의 인증을 담당한다.
우리는 이 Authentication을 직접 생성해서 SecurityContext에 넣어주는 방식으로 로그인 처리를 할 것이다.
PrincipalDetails
UserDetails를 구현한 PrincipalDetails는 SpringSecurity에서 관리하는 UserDetails 타입 대신에 사용할 수 있다.
해당 클래스는 User user, String nickname 필드를 가지고, 생성할 때 user엔티티를 매개변수로 받아서 생성된다.
public class PrincipalDetails implements UserDetails{
private User user;
private String nickname;
public PrincipalDetails(User user){
this.user = user;
this.nickname = user.getNickname();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER"));
if(this.user.getUserType().equals(UserType.ADMIN)){
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
return grantedAuthorities;
}
}
위에서 설명한 Authentication 객체는 UserDetails 객체와 credential(비밀번호, 토큰 등), authorities(권한)을 받아 생성할 수 있다. UserDetails 대신 UserDetails를 구현한 PrincipalDetails를 사용할 것이다.
PrincipalDetails 클래스 전체 코드
public class PrincipalDetails implements UserDetails{
private User user;
private String nickname;
public PrincipalDetails(User user){
this.user = user;
this.nickname = user.getNickname();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER"));
if(this.user.getUserType().equals(UserType.ADMIN)){
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
return grantedAuthorities;
}
@Override
public String getPassword() {
return this.user.getPassword();
}
@Override
public String getUsername() {
return this.user.getUserId();
}
//계정 만료 여부
@Override
public boolean isAccountNonExpired() {
return true;
}
//계정 잠김 여부
@Override
public boolean isAccountNonLocked() {
return true;
}
//계정 정보 변경 필요 여부
@Override
public boolean isCredentialsNonExpired() {
return true;
}
//계정 활성화 여부
@Override
public boolean isEnabled() {
return true;
}
}
[중요] SecurityContext에 직접 로그인 유저 넣기
public void loadUserDirectly(PrincipalDetails principalDetails, HttpSession session) {
Authentication authentication =
new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
SecurityContext securityContext = SecurityContextHolder.getContext();
securityContext.setAuthentication(authentication);
session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext);
}
Authentication
Authentication은 토큰을 통해 생성할 수 있다.
해당 UsernamePasswordAuthenticationToken는 principalDetails, null, authorities가 필요로 한다.
Authentication authentication =
new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
이 부분을 생성한 것이다.
SecurityContext
SecurityContext는 SecurityContextHolder란 곳에서 관리하고, 여기서 꺼내올 수 있다.
SecurityContext securityContext = SecurityContextHolder.getContext();
이 securityContext에 위에서 생성한 authentication을 넣어주면 된다.
securityContext.setAuthentication(authentication);
이후, 브라우저 내에서 로그인 상태가 유지되려면 세션에 해당 컨텍스트를 넣어줘야 한다.
session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext);
"SPRING_SECURITY_CONTEXT"란 이름으로 저장되는 것 같다.
그림을 보면 SecurityContextHolder에서 SecurityContext를 꺼내서, 거기에 Authentication을 넣어준 것이다.
Principal
Authentication.getPrincipal을 통해 principal을 꺼내올 수 있다.
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
@AuthenticationPrincipal
@AuthenticationPrincipal 어노테이션을 통해 컨트롤러 단에서 아래와 같이 현재 로그인된 PrincipalDetails를 불러다 사용할 수 있다.
@GetMapping("/home")
public String list(Model model,
@AuthenticationPrincipal PrincipalDetails principalDetails){
System.out.println(principalDetails);
return "board/home";
}
(SecurityConfig)
package com.example.hellomovie.global.auth.security;
import com.example.hellomovie.global.auth.security.errorhandle.LoginSuccessHandler;
import com.example.hellomovie.global.auth.security.errorhandle.UserAuthenticationFailureHandler;
import com.example.hellomovie.global.auth.service.PrincipalDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final PrincipalDetailsService principalDetailsService;
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
UserAuthenticationFailureHandler getFailureHandler() {
return new UserAuthenticationFailureHandler();
}
@Bean
LoginSuccessHandler getSuccessHandler() {
return new LoginSuccessHandler();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.headers().frameOptions().sameOrigin();
//페이지 접근 관련
http.authorizeRequests()
//인증 요구
.antMatchers("/user/**").authenticated()
//ADMIN만 접근 가능
.antMatchers("/admin/**").hasAuthority("ROLE_ADMIN");
//접근 허용
http.authorizeRequests()
.antMatchers("/", "/login/**", "/register/**", "/board/home")
.permitAll();
//로그인
http.formLogin()
.loginPage("/login-page")
.defaultSuccessUrl("/")
.loginProcessingUrl("/login")
.failureUrl("/login-page")
.failureHandler(getFailureHandler())
.successHandler(getSuccessHandler())
.permitAll();
//로그아웃
http.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/board/home");
//접근 거부 시 에러 페이지 설정
http.exceptionHandling()
.accessDeniedPage("/error/denied");
super.configure(http);
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/favicon.ico", "/files/**", "/images/**");
super.configure(web);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(principalDetailsService).passwordEncoder(passwordEncoder());
}
}
'JAVA & Spring > Spring Security' 카테고리의 다른 글
[Spring Security / JWT] Spring Security - JWT 토큰 인증/인가 (0) | 2024.01.12 |
---|---|
[Spring security] JWT 토큰이란? (0) | 2023.11.26 |
[Spring] 카카오 로그인 API 사용 방법 (17) | 2023.10.15 |