Java - Spring &&n SpringBoot

SpringSecurity 와 소셜 로그인 : OAuth2, 카카오 로그인, 네이버 로그인

TerianP 2022. 9. 15.
728x90

SpringSecurity OAuth2 와 소셜 로그인

- 학원에서 진행했던 파이널 프로젝트에서 내가 담당한 파트!! 이전부터 정리하려고 했으나 채팅하고 이것저것 한다고 미루다가 드디어 해보려고한다.

- 이번 글은 추후에 정리할 웹 소켓 채팅 + 소셜 로그인 을 위해 미리 개념과 코드를 정리하기 위한 정리본!!

 

시큐리티와 session

  • 시큐리티는 /login 주소 요청이 오면 해당 요청을 낚아채서 로그인을 진행시킨다.
  • 로그인 진행이 완료 되면 시큐리티 session 을 만들어준다 ⇒ Security ContextHolder
  • 이때 시큐리티 session 에 들어갈 수 있는 정보는 아래와 같다
    • Authentication 타입 객체 ⇒ Object 타입 객체
    • Authentication 타입 객체 안에 UserDetails 타입 객체가 들어온다.
    • UserDetails 는 인터페이스이며,
    • Security 는 SecuritySession 이라는 뭔가 조금 다른(독자적인) Session 개념을 갖는다.
    • SecuritySession 안에는 Authentication 이라는 객체를 갖는다 ⇒ 일종의 인증된 Session 인지 검사?
    • Authentication 객체 안에 유저 정보를 저장하며 이때 객체의 이름은 UserDetails ⇒ UserDetails 안에 일반적으로 사용하던 Member 객체가 들어가면 됨
    • 결과적으로 SecuritySession 객체 안에 Authentication 객체안에 UserDetails 를 꺼내오면 된다
package kr.co.goodjobproject.auth;

import java.util.ArrayList;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import kr.co.goodjobproject.dto.MemberDTO;

public class PrincipalDetails implements UserDetails{

	// MemberDTO 
	private MemberDTO mdto;
	
	public PrincipalDetails(MemberDTO mdto) {
		this.mdto = mdto;
	}
	
	// 해당 유저의 권한을 리턴하는 곳
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> role = new ArrayList<>();
		
		role.add(new GrantedAuthority() {
			
			@Override
			public String getAuthority() {
				// TODO Auto-generated method stub
				return mdto.getRole();
			}
		});
//		System.out.println("role : "+role);
		return role;
	}

	// 해당 유저의 패스워드 리턴
	@Override
	public String getPassword() {
		// TODO Auto-generated method stub
		return mdto.getMpwd();
	}

	// 해당 유저의 mid 리턴
	@Override
	public String getUsername() {
		// TODO Auto-generated method stub
		return mdto.getMid();
	}

	// 계정 만료가 아니니?
	@Override
	public boolean isAccountNonExpired() {
		// TODO Auto-generated method stub
		return true;
	}

	// 계정 잠긴게 아니니?
	@Override
	public boolean isAccountNonLocked() {
		// TODO Auto-generated method stub
		return true;
	}

	// 계정 정보 변경해야하는거 아니니?
	@Override
	public boolean isCredentialsNonExpired() {
		// TODO Auto-generated method stub
		return true;
	}

	// 계정 활성화 되어있니?
	@Override
	public boolean isEnabled() {
		// 예를 들어서 사이트에서 1년동안 회원이 로그인 안하면 
		// 해당 계정 휴면 계정으로 전환하는 규정같은 것들이 있을때 사용!!
		// 현재시간 - 로긴시간 => 1년 초과시 return false
		return true;
	}

}

 

 

Service

package kr.co.goodjobproject.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import kr.co.goodjobproject.auth.PrincipalDetails;
import kr.co.goodjobproject.dao.MemberDAO;
import kr.co.goodjobproject.dto.MemberDTO;

// 시큐리티 설정에서 loginProcessingUrl 가 실행될때 
// "/login" 요청이 오면 자동으로 UserDetailsService 타입으로 IoC 되어있는
// loadUserByUsername 함수가 실행됨
@Service
public class PrincipalDetailsService implements UserDetailsService {

	@Autowired
	private MemberDAO mdao;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		// username 으로 유저 가져오기
		MemberDTO mdto = mdao.getOne(username);
//		System.out.println("userDetails : "+mdto);
		
		if(mdto != null) { 
			// mdto 가 null 이 아닌 경우 해당 유저가 있는것이고
			// PrincipalDetails 에 mdto 를 넣어 return
			// SecuritySession(Authentication(mdto))
//			System.out.println("빈 값이 아님");
			return new PrincipalDetails(mdto);
		}
		return null;
	}

}

 

권한설정하기

- SpringSecurity Config 설정하기



@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터 체인에 등록!
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	
	@Autowired
	DataSource dataSource;
	
	// Bcrypt 는 패스워드는 해쉬로 암호화해주는 클래스
	// autowire 하기 위해 bean 으로 등록
	@Bean
	PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	
	// 각각 유저가 있는지 확인하는 쿼리문, 유저의 권한을 확인하는 쿼리문
	// configure(AuthenticationManagerBuilder auth) 메서드 대신
	// PrincipalDetails 를 사용함
	
	// 로그인 인가에 관한 설정
	// 위에서는 로그인 가능한 아이디인지 여부를 확인한다면
	// 아래에서는 일반 유저, 로그인 유저 등등이 어디서부터 어디까지 접근 가능한지
	// 혹은 로그인과 로그아웃과 관련된 설정에 대한 부분을 여기서한다
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		// TODO Auto-generated method stub
		http.csrf().disable()
			.authorizeHttpRequests()
			.antMatchers("/commList").hasRole("USER")
			.antMatchers("/login/authtest").authenticated()
		.and()
			.formLogin().loginPage("/login/loginform").permitAll() // 커스텀 로그인 페이지
			.defaultSuccessUrl("/") // 로그인 성공시 기본 이동 페이지
			// 로그인 form action url => /login 이라는 요청이 들어오면 시큐리티가 낚아채서 대신 로그인 진행
			.loginProcessingUrl("/login") 
//			.usernameParameter("mid") // 로그인폼에서 id 를 적는 곳에 특정한 name을 사용한다면 여기서 변경
//			.passwordParameter("mpwd") // 로그인폼에서 password 를 적는 곳에 특정한 name을 사용한다면 여기서 변경
		.and()
			.logout().logoutUrl("/logout").permitAll()
			.logoutSuccessUrl("/") // 로그아웃 성공 시 이동 페이지
		.and()
			.oauth2Login().loginPage("/login/loginform"); // 외부 인증 
	
	}
	

}

 

controller 에서 어노테이션 권한 설정

단 Config 파일에 관련 어노테이션이 선행되어야함

  • config
@Configuration
@EnableWebSecurity // 스프링 시큐리티 필터가 스프링 필터 체인에 등록!
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) // Controller 에서 권한 설정을 위한 어노테이션!
public class SecurityConfig extends WebSecurityConfigurerAdapter{
	~~~~~~~~~~~~~~~~~~~~~~~~
}

 

// @Secured 와 PreAuthorize 를 사용해서도 권한 설정 가능
	@Secured("ROLE_ADMIN")
	@GetMapping("commList")
	public String commList1() {
		return "commList";
	}
	
	// @Secured 와 PreAuthorize 를 사용해서도 권한 설정 가능
	@PreAuthorize("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')")
	@GetMapping("commList")
	public String commList2() {
		return "commList";
	}

 


세션 정보 가져오기

DTO 를 사용하는 일반 회원의 경우

  • SecuritySession 은 아래와 같은 구조
    • Authentication(UserDetails(우리가 아는 DTO))
    • 따라서 DTO 를 가져오기 위해서는 Authentication 안에 있는 UserDetails 안에 있는 DTO 를 가져와야한다.
  • 이때 DTO 를 꺼내오는 방법은 매개변수 Authentication 를 활용하는 방법과 @AuthenticationPrincipal 를 사용하는 방법 2가지가 있다.
// 일반회원 세션 가져오기 1
	// 세션안에 있는 값을 가져오기 위해서는 Authentication 사용
	// 다시 dto 로 변환하기 위해서 Authentication 안에 있는 Principal 을 가져와서
	// 다시 PrincipalDetails 안에 있는 Mdto 를 가져온다.
	@RequestMapping("login/authtest")
//	OAuth2AuthenticationToken token
	public String authtest(Authentication auth,  Model model) {

		PrincipalDetails user = (PrincipalDetails) auth.getPrincipal();
		System.out.println("PrincipalDetails : "+user.getMdto());
		model.addAttribute("user", user.getMdto());
		return "login/authtest";
	}
	
	// 일반회원 세션 가져오기 2
	// 세션안에 있는 값을 가져오기 위해서는 @AuthenticationPrincipal 를 사용해서 세션안에 있는 정보를 PrincipalDetails 로 받아옴
	// 이때 원래 @AuthenticationPrincipal 는 UserDetails 로 받아야하나 PrincipalDetails 가 userDetails 를 구현한 구현 클래스이기 때문에
	// PrincipalDetails 로 받을 수 있음
	// 여기서부터는 userDetails.getMdto 로 dto 정보를 가져올 수 있음.
	@RequestMapping("login/authtest")
//	OAuth2AuthenticationToken token
	public String authtest(@AuthenticationPrincipal PrincipalDetails principalDetails,  Model model) {

		System.out.println("MDTO : "+userDetails.getMdto());
		model.addAttribute("user", userDetails.getMdto());
		return "login/authtest";
	}

 

SNS 로그인한 세션 정보 가져오기

  • SNS 로그인 한 사용자는 OAuth2User 를 통해 값을 가져온다.

 

SNS 를 사용하여 로그인 한 경우 세션에 담긴 정보 가져오기

// sns 세션 정보 가져오기
	// sns 은 @AuthenticationPrincipal Oauth2User 를 사용해서 
	// 세션 안의 정보를 가져 올 수 있다.
	@RequestMapping("login/authtest")
	public String authtest(@AuthenticationPrincipal PrincipalDetails principalDetails,  Model model) {

		System.out.println("MDTO : "+principalDetails.getMdto());
		model.addAttribute("user", principalDetails.getMdto());
		return "login/authtest";
	}

 

일반 사용자와 SNS 사용자 세션 구분하기

  • 이렇게 if 문으로 oauthUser 가 null 인지 아닌지 구분해도 되긴된다.
  • 근데 이러면 SNS 와 아닌 경우는 구분을 할 수 있을지언정 ‘어떤’ SNS 로 로그인했는지 여부를 판단 할 수 없다
  • 또한 사이트 로그인만 가능 할 뿐 사이트에 회원가입은 불가능하다.
@RequestMapping("login/authtest")
//	OAuth2AuthenticationToken token
	public String authtest(@AuthenticationPrincipal OAuth2User oauthUser, @AuthenticationPrincipal PrincipalDetails principalDetails,  Model model) {
		if(oauthUser!=null) {
			System.out.println("oauth2 : "+oauthUser.getAttributes());
			model.addAttribute("user", oauthUser.getAttributes());
		}else {
			System.out.println("MDTO : "+principalDetails.getMdto());
			model.addAttribute("user", principalDetails.getMdto());
		}
		return "login/authtest";
	}

 

SNS 사용자를 내 DB 에 저장하기

  • 앞에서 잠깐 봤지만 결국 SNS 로그인 한 사용자는 OAuth2User 를 사용하고, 일반 사용자는 UserDetails 를 상속받은 principalDetails 를 사용한다.
  • 때문에 SNS 사용자를 principalDetails 에 맞춰서 일반 사용자의 DTO 로 정보를 저장할 수 없다 = > 이를 해결하기 위해서 SNS 사용자를 principalDetails 안에 담긴 일반 사용자의 DTO 에 맞게 회원가입 하는 과정이 필요하다.

 

SecurityConfig 설정

.and()
			.oauth2Login() // 외부 인증
			.loginPage("/login/loginform") // oauth2Login 시 loginForm
			.userInfoEndpoint()
			.userService(principalOauth2UserService); // SNS 로그인이 완료된 뒤 후처리가 필요함. 엑세스토큰+사용자프로필 정보

 

PrincipalDetails 에 OAuth2User 를 implement

  • 이는 추후 OAuth2User 를 사용한 SNS 유저 로그인 사용자에 대한 정보를 내가 만든 principalDetails 에 담기 위함이다. 이를 통해서 SNS 유저와 일반 유저 모두 PrincipalDetails 라는 동일한 클래스의 객체 안에 유저 정보가 담기게된다.
package kr.co.goodjobproject.auth;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import kr.co.goodjobproject.dto.MemberDTO;
import lombok.Data;

@Data
public class PrincipalDetails implements UserDetails, OAuth2User{

	
	private MemberDTO mdto;
	private Map<String, Object> attributes;
	
	// 일반 유저 로그인 시 사용하는 생성자
	public PrincipalDetails(MemberDTO mdto) {
		this.mdto = mdto;
	}

	// OAuth2User 를 사용한 SNS 유저 로그인 시 사용하는 생성자
	public PrincipalDetails(MemberDTO mdto, Map<String, Object> attributes) {
		this.mdto = mdto;
		this.attributes = attributes;
	}
	
	// 해당 유저의 권한을 리턴하는 곳
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> role = new ArrayList<>();
		
		role.add(new GrantedAuthority() {
			
			@Override
			public String getAuthority() {
				// TODO Auto-generated method stub
				return mdto.getRole();
			}
		});
//		System.out.println("role : "+role);
		return role;
	}

	// 해당 유저의 패스워드 리턴
	@Override
	public String getPassword() {
		// TODO Auto-generated method stub
		return mdto.getMpwd();
	}

	// 해당 유저의 mid 리턴
	@Override
	public String getUsername() {
		// TODO Auto-generated method stub
		return mdto.getMid();
	}

	// 계정 만료가 아니니?
	@Override
	public boolean isAccountNonExpired() {
		// TODO Auto-generated method stub
		return true;
	}

	// 계정 잠긴게 아니니?
	@Override
	public boolean isAccountNonLocked() {
		// TODO Auto-generated method stub
		return true;
	}

	// 계정 정보 변경해야하는거 아니니?
	@Override
	public boolean isCredentialsNonExpired() {
		// TODO Auto-generated method stub
		return true;
	}

	// 계정 활성화 되어있니?
	@Override
	public boolean isEnabled() {
		// 예를 들어서 사이트에서 1년동안 회원이 로그인 안하면 
		// 해당 계정 휴면 계정으로 전환하는 규정같은 것들이 있을때 사용!!
		// 현재시간 - 로긴시간 => 1년 초과시 return false
		return true;
	}

	// OAuth2User 즉 sns 로그인 유저의 oauth2user 의 Attributes 정보를 확인하기 위한 메서드
	@Override
	public Map<String, Object> getAttributes() {
		// TODO Auto-generated method stub
		return attributes;
	}

	
	@Override
	public String getName() {
		// TODO Auto-generated method stub
		return null;
	}
	
	

}

 

principalOauth2UserService 클래스

  • Oauth2 를 사용하여 로그인 된 사용자 즉, SNS 사용자는 로그인처리 시 DefaultOAuth2UserService 아래 loadUser 메서드를 타고 로그인 처리가 완료된다.
  • 따라서 DefaultOAuth2UserService 를 구현한 구현 객체와 loadUser 를 오버라이딩하여 사용하면 OAuth2 로 로그인한 사용자에 대해서 추가 로직 처리 - 회원가입, 회원 정보 불러오기 등 - 이 가능하다.
  • 이후 매개변수로 들어오는 userRequest 를 oauth2user 로 변환하여 oauth2user 안에 있는 정보 - SNS 로그인 사용자 정보 - 를 활용 가능하다.
    • 여기서부터는 코드 주석 참고
  • 여기서 가장 주의해야 할 점은 loadUser 메서드가 OAuth2User 객체를 return 하는데 어째서 new PrincipalDetails(mdto, oAuth2User.getAttributes()) 가 가능한지 아는 것 이다.
  • ⇒ 이는 앞서 PrincipalDetails 에서 OAuth2User 를 implements 해서 PrincipalDetails 가 OAuth2User 의 구현 객체가 되었기 때문에 가능한 것이다.
package kr.co.goodjobproject.service;

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
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.Service;

import kr.co.goodjobproject.auth.PrincipalDetails;
import kr.co.goodjobproject.dao.MemberDAO;
import kr.co.goodjobproject.dto.MemberDTO;
import kr.co.goodjobproject.socialLogin.KakaoLogin;
import kr.co.goodjobproject.socialLogin.NaverLogin;
import kr.co.goodjobproject.socialLogin.SocialLogin;

// Oauth2 로 로그인 시 DefaultOAuth2UserService 아래의 loadUser 메서드가 실행됨
// 즉 Oauth2 로 로그인 후 후처리-회원가입 등 -에 관한 클래스와 메서드
@Service
public class principalOauth2UserService extends DefaultOAuth2UserService{
	
	@Autowired
	MemberDAO mdao;
	
	// SNS user 의 UserRequest 데이터에 대한 후처리 함수
	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//		 네이버 로그인 버튼 클릭 -> 네이버 로그인창 -> 로그인 완료 -> code 리턴(OAuth-client 라이브러리) -> AccessToken 요청
//		 userRequest 정보 -> loadUser 함수 호출 -> 네이버 회원 프로필 받아오기
//		System.out.println("ClientRegistration : "+userRequest.getClientRegistration()); // ClientRegistration 정보
//		System.out.println("AccessToken : "+userRequest.getAccessToken().getTokenValue()); // accessToken 가져오기
		
		// userRequest 를 loadUser 를 실행해서 유저 정보가 담긴 oauth2user 로 변환
		OAuth2User oauth2user = super.loadUser(userRequest);

//		System.out.println("userRequest : "+userRequest);
//		System.out.println("oauth2user : "+oauth2user);
		
		// code를 통해 구성한 정보
//		System.out.println("userRequest clientRegistration : " + userRequest.getClientRegistration());
		// token을 통해 응답받은 회원정보
//		System.out.println("oAuth2User : " + oAuth2User);

		return oAuth2UserLogin(userRequest, oauth2user);
	}
	
	
	private OAuth2User oAuth2UserLogin(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
		
		// Attribute 를 파싱해서 공통 객체로 묶는다 => 관리를 위해
		SocialLogin loginUser = null;
		
		// provider 정보 => 어떤 SNS 로 로그인했는지
		String provider = userRequest.getClientRegistration().getRegistrationId(); 
		
		
		if(provider.equals("naver")) {
			loginUser = new NaverLogin(oAuth2User.getAttributes());
		}else if(provider.equals("kakao")) {
			loginUser = new KakaoLogin(oAuth2User.getAttributes());
		}
		
//		Map<String, Object> map = oAuth2User.getAttributes();
//		System.out.println("map : "+map);
		
		// 이메일 가져오기
		String memail = loginUser.getEmail();
		
		// sns 로그인 사용자의 회원정보 가져오기
		MemberDTO mdto = mdao.getSnsOne(memail, provider);
//		System.out.println("mdto : "+mdto);
		
		// 만약 meail 과 provider 를 사용해서 회원 정보를 불러왔을 때 
		// 가입된 회원이 없다면 sns 로 로그인한 사용자에 대해서 회원가입하는 로직
		// builder 패턴 사용
		if(mdto == null) {
			mdto = mdto.builder()
					.mid(memail)
					.tno(1)
					.mname(loginUser.getNickName())
					.mpay(0)
					.snsCheck(provider)
					.build();
			
			// build 된 정보로 SNS 회원 회원가입
			mdao.insertSNSMember(mdto);
			
			// 회원가입된 정보를 다시 불러와서 mdto 에 담음
			mdto = mdao.getSnsOne(memail, provider);
		}
		
		
		// return OAuth2User 인데 return new PrincipalDetails 가 가능한 이유는
		// PrincipalDetails 가 OAuth2User 를 구현한 구현 클래스이기 때문
		// 또한 oauth2user 를 같이 들고 가기 때문에 sns 유저인 것을 함께 확인 가능
		// 이 정보들은 SecuritySession 의 Authentication 안에 담김
		return new PrincipalDetails(mdto, oAuth2User.getAttributes());
	}
}

 

SNS 사용자에 따른 회원가입 로직 처리

  • SNS 에 따라서 OAuth2User 와 userRequest 객체에서 받을 수 있는 정보는 서로 상이하다.
  • 따라서 SNS 에 따라 회원 정보를 구분하고 가져와서 회원가입하는 로직처리가 필요하다 ⇒ 회원가입 시 어떤 SNS 를 사용하는지, 이메일 정보, 닉네임만 필요하기 때문에 3가지만 가져오기 위한 공통 interface 를 만들었다.
package kr.co.goodjobproject.socialLogin;

import java.util.Map;

public interface SocialLogin {
	String getProvider();
	String getEmail();
	String getNickName();
}

 

Kakao 로그인 시 회원가입

package kr.co.goodjobproject.socialLogin;

import java.util.HashMap;
import java.util.Map;

public class KakaoLogin implements SocialLogin{
	
	private Map<String, Object> naverAttributes;

	
	public KakaoLogin(Map<String, Object> naverAttributes) {
		this.naverAttributes = naverAttributes;
	}
	
	
	@Override
	public String getProvider() {
		// TODO Auto-generated method stub
		
		return "kakao";
	}

	@Override
	public String getEmail() {
		// TODO Auto-generated method stub
		HashMap<String, Object> account = (HashMap<String, Object>) naverAttributes.get("kakao_account");
		
		String memail = (String) account.get("email"); // 이메일 가져오기
//		System.out.println("memail : "+memail);
		
		return memail;
	}

	@Override
	public String getNickName() {
		// TODO Auto-generated method stub
		HashMap<String, Object> properties = (HashMap<String, Object>) naverAttributes.get("properties");
		String mname = (String) properties.get("nickname"); // 닉네임 가져오기
		
		return mname;
	}
}

 

Naver 로그인 시 회원가입

package kr.co.goodjobproject.socialLogin;

import java.util.Map;

public class NaverLogin implements SocialLogin {

	private Map<String, Object> naverAttributes;

	
	public NaverLogin(Map<String, Object> naverAttributes) {
		
		this.naverAttributes = naverAttributes;
	}
	
	
	@Override
	public String getProvider() {
		// TODO Auto-generated method stub
		
		return "naver";
	}

	@Override
	public String getEmail() {
		// TODO Auto-generated method stub
		Map<String, Object> map = (Map<String, Object>) naverAttributes.get("response");
		return (String) map.get("email");
	}

	@Override
	public String getNickName() {
		// TODO Auto-generated method stub
		Map<String, Object> map = (Map<String, Object>) naverAttributes.get("response");
		return (String) map.get("nickname");
	}

}

댓글