팀프로젝트/SpringBoot

스프링부트 팀플) 20240411 스프링시큐리티+소셜로그인(네이버/카카오

일일일코_장민기 2024. 4. 11. 22:00
728x90
기존의 소셜로그인에서 스프링시큐리티를 더하는 시스템을 구현했다.

생각보다 너무 복잡하고 어렵더라...
심지어 아직 구글은 구현이 안 된다...이유는 몰루
Redirect_Uri가 다르다고 뜨다가 결국 token이 만료되었다고 뜨기에 이르렀다.
아직 시스템도 손볼 것이 많지만 일단은 포스팅

 

<!-- OAuth2LoginAuthenticationFilter -->

<dependency>

<groupId>org.springframework.security</groupId>

<artifactId>spring-security-oauth2-client</artifactId>

<version>5.7.7</version>

</dependency>

우선 pom.xml을 수정해서 oauth2 의존성을 추가한다.

 

#Registeration

#Naver Login

spring.security.oauth2.client.registration.naver.client-name=naver

spring.security.oauth2.client.registration.naver.client-id=XXX

spring.security.oauth2.client.registration.naver.client-secret=XXX

spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8090/acorn/login/oauth2/code/naver

spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code

spring.security.oauth2.client.registration.naver.scope=XXX

 

#provider

#Naver

spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize

spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token

spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me

spring.security.oauth2.client.provider.naver.user-name-attribute=response

XXX친 부분과 uri를 수정하면 된다.

uri의 경우, 이렇게 안 하면 어떻게 해도 에러가 나더라...

http://localhost:8090/acorn: 기본주소

login: 기본 oauth2 주소(default 주소도 여기에서 연결된다)

code: authorization_code로 받아온다는 뜻

naver: 어디 소셜사이트인지 구별

 

package com.moonBam.springSecurity;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
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.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;


@SuppressWarnings("deprecation")
@Configuration
@EnableWebSecurity
public class SecurityConfig { // WebSecurityConfigurerAdapter는 securityFilterChain과 동시 사용 불가

	@Autowired
	private SpringSecurityService springSecurityService;
	
	private final OAuthService oAuthService;
	
	public SecurityConfig(OAuthService oAuthService) {
		this.oAuthService = oAuthService;
	}
	
	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}
	
   @Bean
   public RoleHierarchy roleHierarchy() {						//계층형 권한 부여
	   RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
	   	hierarchy.setHierarchy("ROLE_ADMIN > ROLE_MEMBER");		//ADMIN:	MEMBER의 모든 권한 + ADMIN 고유의 권한
	   return hierarchy;
   }
		
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity security) throws Exception {

										//configure는 Override의 기본값
		
		security.csrf().disable();		//csrf:	요청을 위조하여 사용자가 원하지 않아도 서버 측으로 특정 요청을 강제로 보내는 방식
										//		회원 정보 변경 / 게시글 CRUD를 사용자 모르게 요청
										//spring.mustache.servlet.expose-request-attributes=true
										//POST요청:	Form에서 <input type="hidden" name="_csrf" value="{{_csrf.token}}"/>
										//AJAX요청:	HTML <head> 아래에 <meta name="_csrf_header" content="{{_csrf.headerName}}"/>
		
		//권한따라 허용되는 url 설정
		security.authorizeHttpRequests().antMatchers("/").permitAll();
//		security.authorizeHttpRequests().antMatchers("/memberList").hasRole("ADMIN");		//ROLE은 무조건 // ADMIN은 관례
//		security.authorizeHttpRequests().anyRequest().permitAll();
		
		//로그인 설정
		security.formLogin().loginPage("/mainLogin")
				.loginProcessingUrl("/loginProc")			//	/loginProc으로 Mapping
															//	자동으로 @Service가 붙은 Service 중에 loadUserByUsername가 있는 곳을 찾아가서 실행
															//	jsp에서 Method="POST", Action="/loginProc"으로 처리
				.usernameParameter("userId")				//	username Param을 userId로 사용 가능
				.passwordParameter("userPw")				//	password Param을 userPw로 사용 가능
//				.successHandler								//	로그인 성공 시, 작동(세션이랑 쿠키 작동 예정)
//              .failureHandler								//	로그인 실패 시, 작동
				.defaultSuccessUrl("/", true);				//	로그인 성공 시, 이동하는 URL

		//소셜 로그인 설정
		security.oauth2Login(oauth2 -> oauth2
				.loginPage("/mainLogin")
				.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig.userService(oAuthService)));

		//세션관리
		security.sessionManagement()
				.maximumSessions(1)											//	최대 동시 접속 1개
				.maxSessionsPreventsLogin(true);							//	True:	동시 접속 시 새로운 로그인 차단

		security.sessionManagement().sessionFixation().changeSessionId();	//로그인 시 동일한 세션에 대한 id 변경
		
		//로그인 실패
		security.exceptionHandling().accessDeniedPage("/NotAuthentic");		//권한 없으면 가는 페이지

		//로그아웃
		security.logout().logoutUrl("/Logout").logoutSuccessUrl("/").invalidateHttpSession(true);
		
		
		
		security.userDetailsService(springSecurityService);
		
		return security.build();
	}
	
    public void configure(WebSecurity web) throws Exception {
    		web.ignoring().antMatchers("/static/**");
    }
}

 

 

기존 기능에서 추가된 부분

private final OAuthService oAuthService;

 

public SecurityConfig(OAuthService oAuthService) {

this.oAuthService = oAuthService;

}

- oauth2를 받기 위한 서비스 연결

 

//소셜 로그인 설정

security.oauth2Login(oauth2 -> oauth2

.loginPage("/mainLogin")

.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig.userService(oAuthService)));

- .loginPage("/mainLogin")이 없으면 oauth2 기본 로그인 페이지로 연결된다(contextPath + /login)

- .loginPage("/mainLogin")이 있으면 /mainLogin으로 oauth2 로그인 페이지가 연결된다.

--> 확인하기에 가장 좋은 방법은 비로그인 상태에서 권한을 요구하는 페이지 요청을 할 때

.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig.userService(oAuthService)));

- 각 사이트 별로 데이터를 가져온다음에 oAuthService로 전송된다.

- 자동으로 json데이터로 뽑아올 수 있다(진짜 엄청난 기능이다) 

 

기타 추가된 기능

@Bean

public RoleHierarchy roleHierarchy() { //계층형 권한 부여

RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();

hierarchy.setHierarchy("ROLE_ADMIN > ROLE_MEMBER"); //ADMIN: MEMBER의 모든 권한 + ADMIN 고유의 권한

return hierarchy;

}

- 계층형 권한 부여 기능

 

@Bean

public SecurityFilterChain securityFilterChain(HttpSecurity security) throws Exception {

- 기존의 configure에서 보다 사용하기 편한 SecurityFilterChain로 변경

- configure는 고정 메소드 이름이기 때문에 다른 걸로 바꿔야 한다.

 

/세션관리

security.sessionManagement()

.maximumSessions(1) // 최대 동시 접속 1개

.maxSessionsPreventsLogin(true); // True: 동시 접속 시 새로운 로그인 차단

 

security.sessionManagement().sessionFixation().changeSessionId(); //로그인 시 동일한 세션에 대한 id 변경

- 세션 관리용(아직 사용하지는 않음)

 

 

 

기존에 소셜로그인을 해서 json데이터를 뽑아올 때는 말도 안되는 절차를 거쳐야 했다.
HTTP요청, ACCESS요청, 토큰 요청을 모두 통과해야 json데이터를 불러올 수 있었고,
과정 하나하나가 쉽지 않았다.
그런데 oauth2는 쉽게 데이터를 불러와준다는 점에서 소셜로그인을 한다면 꼭 알아야 할 부분이라 생각한다.

 

package com.moonBam.springSecurity;

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 com.moonBam.controller.member.AnonymousBoardController;
import com.moonBam.controller.member.MailController;
import com.moonBam.controller.member.OpenApiController;
import com.moonBam.controller.member.SecurityController;
import com.moonBam.dao.member.LoginDAO;
import com.moonBam.dao.member.OpenApiDAO;
import com.moonBam.dto.MemberDTO;
import com.moonBam.service.member.OpenApiService;

@Service
public class OAuthService extends DefaultOAuth2UserService {

    @Autowired
    OpenApiService oas;
    
    @Autowired
    OpenApiDAO oad;
    
    @Autowired
	MailController mc;
	
	@Autowired
	LoginDAO dao;
	
	@Autowired
	OpenApiController oac;
	
	@Autowired
	SecurityController sc;
	
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    	
    	//소셜에서 JSON데이터 받아오기
    	OAuth2User oAuth2User = super.loadUser(userRequest);
    	
    	//소셜에서 받아오는 JSON데이터 확인
    	System.out.println("oAuth2User: " + oAuth2User.getAttributes());

    	//등록을 대비하여 미리 선언
    	MemberDTO register = new MemberDTO();
    	
    	//어떤 인증 Provider인지 확인
    	String registrationId = userRequest.getClientRegistration().getRegistrationId();
    	
    	//어디 소셜인지 확인
    	System.out.println("registrationId: " + registrationId);
    	
    	OAuth2Response oAuth2Response = null;
    	
    	//소셜에 따라 다른 방법으로 데이터 추출
        if(registrationId.equals("naver")){
        	oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
        } else
        
        if(registrationId.equals("google")){
        	oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
        } else

        if(registrationId.equals("kakao")){
        	oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
        }
        
        //유저 아이디를 JSON데이터에서 추출
        String userId = oAuth2Response.getEmail();
        
        //유저 아이디가 DB에 있는 것인지 검증
        MemberDTO dto = dao.userDetail(userId);
        System.out.println("dto: "+dto);
        
        String role = "";
        
        if(dto == null) {
	    		register.setUserId(userId);
	    		register.setUserPw(sc.encrypt(AnonymousBoardController.getNum(16)));
	    		register.setNickname(oac.randomNickname());
	    		register.setSecretCode(sc.encrypt(AnonymousBoardController.getNum(8)));
	    		
	    		// 소셜에 따라 연동 체크
	    		if(registrationId.equals("naver")){
	            	register.setNaverConnected(1);
	            } else
	            
	            if(registrationId.equals("google")){
	            	register.setGoogleConnected(1);
	            } else

	            if(registrationId.equals("kakao")){
	            	register.setKakaoConnected(1);
	            }

	    		//회원가입
	    		oas.insertAPIMember(register);	
				
	    		//회원가입 메일 송신
	    		try {
					mc.RegisterCompleteEmail(register.getUserId(), register.getNickname(), register.getSecretCode());
				} catch (Exception e) {
					e.printStackTrace();
				}
		    	
	    		//새로 회원가입한 유저의 Role 전송(기본값: MEMBER)
				role = "ROLE_MEMBER";
        }
        
        //이미 가입한 유저일 경우
        if(dto != null) {
        	
        	System.out.println("기존유저1: "+dto);
        	
        	// 소셜에 따라 추가 연동 체크
    		if(registrationId.equals("naver")){
    			oad.updateAPIMemberNaverConnected(dto.getUserId());
            } else
            
            if(registrationId.equals("google")){
            	oad.updateAPIMemberGoogleConnected(dto.getUserId());
            } else

            if(registrationId.equals("kakao")){
            	oad.updateAPIMemberKakaoConnected(dto.getUserId());
            }
    		
    		System.out.println("기존유저2: "+dto);
    		
    		//기존 유저의 Role 전송
    		role = dto.getRole();
        }
    	
    	return new OAuthUser(oAuth2Response, role);
    }
}

 

- 과정 하나하나를 써놨기 때문에 자세한 설명은 생략

- 비효율적인 코드지만 일단 돌아간다. 추후 개선이 필요한 부분

- @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

 --> 여기서 @Override가 아닌 다른 형태로 만들면 인식을 못한다...다른 방법이 있을 텐데...역시 공부가 필요한 부분

 

package com.moonBam.springSecurity;

 

public interface OAuth2Response {

 

String getProvider(); //제공자 이름(naver / google / kakao)

String getEmail();

String getName();

 

}

- 각 Response에서 사용할 인터페이스

 

package com.moonBam.springSecurity;

 

import java.util.Map;

 

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");

}

 

@Override

public String getProvider() {

return "naver";

}

 

@Override

public String getEmail() {

return attribute.get("email").toString();

}

 

@Override

public String getName() {

return attribute.get("name").toString();

}

 

}

 

package com.moonBam.springSecurity;

 

import java.util.Map;

 

public class KakaoResponse implements OAuth2Response {

 

private Map<String, Object> attribute;

 

public KakaoResponse(Map<String, Object> attributes) {

this.attribute = attributes;

}

 

@Override

public String getProvider() {

return "kakao";

}

 

@Override

public String getEmail() {

Map<String, Object> kakaoAccount = (Map<String, Object>) attribute.get("kakao_account");

return (String) kakaoAccount.get("email");

}

 

@Override

public String getName() {

Map<String, Object> properties = (Map<String, Object>) attribute.get("properties");

return (String) properties.get("nickname");

}

 

}

 

package com.moonBam.springSecurity;

 

import java.util.Map;

 

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 getEmail() {

return attribute.get("email").toString();

}

 

@Override

public String getName() {

return attribute.get("name").toString();

}

 

}

 

각 Response에서 데이터를 뽑아서 oAuth2Response 형태로 만든다.

여기서 만든 데이터로 신규/기존 유저 확인, 신규 회원가입 로직도 이루어진다.

 

package com.moonBam.springSecurity;

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

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

public class OAuthUser implements OAuth2User {

	private final OAuth2Response oAuth2Response;
	
	private final String role;

	public OAuthUser(OAuth2Response oAuth2Response, String role) {
		this.oAuth2Response = oAuth2Response;
		this.role = role;
	}

	@Override
	public Map<String, Object> getAttributes() {
		return null;
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> col = new ArrayList<>();
		col.add(new GrantedAuthority() {
			
			@Override
			public String getAuthority() {
				return role;
			}
		});
		return col;
	}

	@Override
	public String getName() {
		return oAuth2Response.getName();
	}
	
	public String getUsername() {
		return oAuth2Response.getEmail();
	}
}

 

여기서 각 칼럼에 넣을 데이터를 정리한다

대표적으로 유저 아이디인 username을 email을 가져와 넣는 것 등

 

 

 

 

이런 과정을 통해 소셜 사이트 로그인과 스프링시큐리티를 융합할 수 있었다.

다음에 구글까지 제대로 구현이 되면 jsp까지 포함해서 포스팅할 예정

일단 사이트 연결이 기존 소셜 사이트 로그인보다 좀 어렵긴 하다

 

개선점

- 구글 사이트 로그인 구현

- 회원가입일 경우, 닉네임 변경 페이지로 이동 / 로그인일 경우, 메인으로 이동

- JWT를 구현했을 때, JWT토큰을 통해 로그인 여부 판단하도록 구현