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");
}
}
'Java - Spring &&n SpringBoot' 카테고리의 다른 글
Spring - 예외 상황 처리 1) Servlet 예외 처리 : 404 error, 500 error (0) | 2022.08.19 |
---|---|
웹 네트워크 기본 공부 2) HTTP 알아보기 (0) | 2022.08.17 |
Spring - ArgumentResolver (feat.커스텀 어노테이션, 세션) (0) | 2022.08.17 |
Spring - 스프링 인터셉터(3) 스프링 인터셉터 개념과 로그 남기기 (0) | 2022.08.13 |
Spring - 서블릿 필터 다루기(2) : 로그인 여부 체크, 로그인 여부에 따른 페이지 접근 (0) | 2022.08.12 |
댓글