[Spring Security] Spring Security & OAuth2 Kakao 로그인 적용

·2024년 2월 13일

Spring Boot

목록 보기
2/4
post-thumbnail

지난 설 동안 Spring Security 에 대해 공부하고 프로젝트에 간략하게 적용해 보았다.

통상적인 방법의 적용이 아니었기에 (@Controller 와 jsp 사용) 삽질을 진심으로 많이 했고..... 따로 공부도 해야 했지만 (한 단계마다 오류가 났음 ㅠㅠ) 결과적으로 구현을 성공해 간단히 정리해 보고자 한다.

Spring Security 의 흐름을 알고자 한다면 -> 옆에 글 개요를 먼저 보길 바란다.

기본적으로 참고한 책은 '구멍가게 코딩단' 의 '자바 웹 개발 워크 북' 이며 이를 기반으로 jsp 적용, 빌더 패턴 적용 등은 타 블로그 등을 참고하여 정리해 보았다. (참고한 블로그 목록 아래 작성)

1. Spring Security 의 개념

(1) Spring Security

⭐ Spring Security

  • Spring Security- 스프링 프레임워크 기반의 애플리케이션에서 보안과 인증을 처리하기 위한 모듈
  • Spring Securtiy 는 애플리케이션의 보안을 간단하고 유연하게 구현할 수 있도록 다양한 기능을 제공한다

(2) Spring Security 의 특징

  • 스프링 시큐리티 전체를 관통하는 "가장 중요한 개념은 인증과 인가" 이다.
  • 인증(Authentication) : '스스로를 증명하다' 흔히 말하는 로그인 개념이다. 사용자가 신원을 증명하고 로그인하는 과정. 사용자의 로그인 정보를 기반으로 인증을 처리한다.
  • 인가(Authorization) : '허가나 권한' 인증된 사용자의 '접근 권한' 을 확인하는 과정. 특정 리소스나 자원에 접근하려면 확인되어야 하는 권한을 확인한다. Spring Security는 사용자의 권한을 관리하고 보호된 리소스에 대한 접근을 허용하거나 거부하는데 사용
  • 보안 필터 체인 (Security Filter Chain) : Spring Security 는 여러 개의 보안 필터로 구성된 필터 체인을 제공. 각 필터는 특정한 보안 작업을 처리하며, 이 필터 체인은 요청의 인증 및 인가 과정을 처리하고 보안 관련 기능을 구현한다.
  • 세션 관리 (Session Management) : Spring Security 는 세션 관리를 통해 사용자의 인증 상태를 유지하고 관리한다. 세션 공격을 방지하거나 세션 유지 정책을 구성할 수 있다.
  • CSRF(Cross-Site Request Forgery) 보호 : Spring Security 는 CSRF 공격을 방지하기 위한 기능을 제공
  • 다양한 인증 및 인가 방식 지원 : Spring Securtiy 는 다양한 인증 및 인가 방식을 지원한다. 예를 들어 폼 인증, 기본 인증, OAuth, OpenID Connect, JWT 등

(3) 사용자 정보를 다루는 class & 인터페이스

  • User : User 클래스는 Spring Security 에서 제공하는 디폴트 사용자 모델
    User 클래스는 UserDetails 인터페이스를 구현하고 있어 사용자의 인증 정보와 권한 정보를 제공한다.
  • UserDetails : 인증과 관련된 사용자 정보를 추상화한 인터페이스. User 클래스와 같이 사용자 정보와 권한 정보를 제공하는 기능을 정의
  • UserDetailsService : Spring Securtiy 에서 사용자 정보를 가져오기 위한 메서드를 정의한다

일반적으로 Spring Security 에서는 UserDetailService 를 구현하여 사용자 정보를 가져오고, 이 정보를 User 클래스, 혹은 유사한 클래스로 변환하여 인증 및 인가에 사용한다.

2. Spring Security : 프로젝트로의 적용

(1) Security Config 설정

package com.oz.ozHouse.client.config;

import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.context.annotation.PropertySources;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import com.oz.ozHouse.client.security.CustomSocialLoginSuccessHandler;

import static org.springframework.security.config.Customizer.withDefaults;


import jakarta.servlet.DispatcherType;
import lombok.extern.log4j.Log4j2;
 
@Configuration
@EnableWebSecurity
@PropertySources({
    @PropertySource("classpath:kakao.properties"),
    @PropertySource("classpath:application.properties")
})
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {    
	@Bean
	public AuthenticationSuccessHandler authenticationSuccessHandler() {
		return new CustomSocialLoginSuccessHandler();
	}
	
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    	
        http
	        .csrf().disable() // csrf 토큰 사용 X
	        .cors().disable() // cors 방지
	        .headers().frameOptions().disable();
        
        
        http
	        .formLogin(login -> login	// form 방식 로그인 사용
	        		.loginPage("/member/login")
    				.loginProcessingUrl("/member/login")
			        .usernameParameter("memberId")	
			        .passwordParameter("memberPasswd")
    				.defaultSuccessUrl("/main", true)
	        )
	        .logout(withDefaults()); 

        return http.build();
    }
    
    @Bean
    public WebSecurityCustomizer webSecurity() {
    	
    	// 정적 리소스 (css) 등 로그인 권한 필요 X
    	return (web) -> web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    

}

처음 config 를 생성할 때 중요한 것은

        http
	        .csrf().disable() // csrf 토큰 사용 X
	        .cors().disable() // cors 방지
	        .headers().frameOptions().disable();

csrf 토큰을 사용하지 않는다는 설정. csrf 는 스택 오버플로우를 방지하기 위해 토큰으로 API 통신을 한다는 것인데, 지금 나의 단계에서는 설정하지 않을 것이다.

        http
	        .formLogin(login -> login	// form 방식 로그인 사용
	        		.loginPage("/member/login") // 로그인할 페이지 url
    				.loginProcessingUrl("/member/login") //post API 가 작동할 url  
			        .usernameParameter("memberId") // id 파라미터 이름값
			        .passwordParameter("memberPasswd") // password 이름값
    				.defaultSuccessUrl("/main", true) // 성공한다면 이동할 경로
	        )
	        .logout(withDefaults()); // 로그아웃할 시 설정 값

.formLogin(login -> login // form 방식 로그인 사용 : 로그인 설정을 해 준다.
기본 시큐리티에서는 /login 으로 들어가면 아래의 기본 로그인 창이 뜬다.

해당 창에서 로그인을 할 수 있게 해 준다. 그런데 우리는 Custom 로그인 창을 설정할 것이므로 해당 url 을 따로 설정해 준다.

내가 설정한 url 에서 post 로 넘어간 값을 받아서 로그인을 시켜 준다.

	@GetMapping("/member/login")
	public String loginGet() {
		return "client/member/member_login";
	}

그럼 로그인 컨트롤러는 이게 끝이다. 그렇다면 기본적인 spring security 의 로그인 동작 원리를 알아 보자.

📍 Security 동작 원리

  1. 요청 수신

    • 사용자가 form Post API 를 통해 로그인 정보가 담긴 Request 를 보낸다.
  2. 토큰 생성

    • AuthenticationFilter 가 요청을 받아서 UsernamePasswordAuthenticationToken(인증용 객체)를 생성한다. 인증용 토큰이라고 이해하면 된다.
    • 이 토큰은 해당 요청을 처리할 수 있는 provider 을 찾는데 사용한다.
  3. AuthenticationFilter 가 Authentication Manager<< interface >> 에게 인증용 객체 전달

    • Authentication Manager에게 처리 위임
    • Authentication Manager는 List형태로 Provider들을 갖고 있다.
  4. Token을 처리할 수 있는 Authentication Provider 선택

    • 실제 인증을 할 AuthenticationProvider에게 인증용 객체를 다시 전달한다.
  5. 인증 절차

    • 인증 절차가 시작되면 AuthenticationProvider 인터페이스가 실행되고 DB에 있는 사용자의 정보와 화면에서 입력한 로그인 정보를 비교
  6. UserDetailsService의 loadUserByUsername메소드 수행

    • AuthenticationProvider 인터페이스에서는 authenticate() 메소드를 오버라이딩 하게 되는데 이 메소드의 파라미터인 인증용 객체로 화면에서 입력한 로그인 정보를 가져올 수 있다.
  7. AuthenticationProvider 인터페이스에서 DB에 있는 사용자의 정보를 가져오려면, UserDetailsService 인터페이스를 사용한다.

  8. UserDetailsService 인터페이스는 화면에서 입력한 사용자의 username으로 loadUserByUsername() 메소드를 호출하여 DB에 있는 사용자의 정보를 UserDetails 형으로 가져온다. 만약 사용자가 존재하지 않으면 예외를 던진다. 이렇게 DB에서 가져온 이용자의 정보와 화면에서 입력한 로그인 정보를 비교하게 되고, 일치하면 Authentication 참조를 리턴하고, 일치 하지 않으면 예외를 던진다.

  9. 인증이 완료되면 사용자 정보를 가진 Authentication 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandle를 실행한다.(실패시 AuthenticationFailureHandler를 실행한다.)

(2) MemberSecurityDTO.java

== LoginBean 이라고 생각하면 된다.

@Getter
public class MemberSecurityDTO extends User {
	private int memberNum;
	private String memberId;
	private String memberPasswd;
    private String memberNickname;
    private String memberEmail;
    private LocalDate memberDeleteDate;
    private boolean social;
        
    public MemberSecurityDTO(int memberNum, String username, String password, 
    						String memberNickname, String memberEmail, 
    						LocalDate memberDeleteDate, boolean social,
    						Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
        this.memberNum = memberNum;
        this.memberId = username;
        this.memberPasswd = password;
        this.memberNickname = memberNickname;
        this.memberEmail = memberEmail;
        this.memberDeleteDate = memberDeleteDate;
        this.social = social;
    }

Security 의 User 를 extends 한 본 DTO 는 가입에 사용되며, 필요할 경우 security 를 불러올 때도 이 객체에 담아 불러올 수 있다. 사용자의 인증 정보를 담고 있는 DTO 라고 보면 된다.

참고로 @Builder 대신 생성자를 사용했는데, User 가 복잡해 빌더를 만들기 어렵기 때문이라고 한다.

(3) CustomUserDetailsService.java

package com.oz.ozHouse.client.security;

import java.util.Optional;
import java.util.stream.Collectors;

import org.springframework.security.core.authority.SimpleGrantedAuthority;
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 com.oz.ozHouse.client.repository.MemberRepository;
import com.oz.ozHouse.domain.Member;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService{

	private final MemberRepository memberRepository;
    
	@Override
	public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException{
		Optional<Member> result = memberRepository.getWithRole(memberId);
		
		if (result.isEmpty()) {	// 해당 아이디를 가진 사람이 없다면?
			throw new UsernameNotFoundException("username no found....");
		}
		
		Member member = result.get();
		
		// 새로운 MemberSecurityDTO 만들어 주기
		MemberSecurityDTO memberSecurityDTO = 
				new MemberSecurityDTO (
						member.getMemberNum(),
						member.getMemberId(),
						member.getMemberPasswd(),
						member.getMemberNickname(),
						member.getMemberEmail(),
						member.getMemberDeletedate(),
						false,
						member.getRoleSet()
								.stream().map(memberRole -> new SimpleGrantedAuthority("ROLE_"+memberRole.name()))
								.collect(Collectors.toList())
						);
		
		return memberSecurityDTO;
	}
}

스프링 시큐리티에서 가장 중요한 객체는 실제로 인증을 처리하는 UserDetailService 라는 인터페이스의 구현체이다. UserDetail interface 는 loadUserByUsername 이라는 단 하나의 메소드를 가지는데, 해당 메서드가 실제로 인증을 처리할 때 호출되는 부분이다.

실제 개발 작업은 UserDetailService 인터페이스를 구현하여 username 이라고 부르는 사용자의 아이디 인증을 코드로 구현하는 것이다.

반환은 당연히 org.springframework.security.core.userdetails 패키지의 UserDetails 라는 인터페이스 타입으로 지정되어 있다. 우리는 UserDetails 을 가진 User 를 extends 하여 만든 MemberSecurityDTO 로 return 하여 user 를 가질 수 있다.

(4) jsp 설정

그렇다면 jsp 는 어떻게 설정해야 할까?

우선 gradle 에 taglib sec 을 사용할 수 있는 의존성을 추가해야 한다.

    implementation 'org.springframework.security:spring-security-taglibs'

이렇게 인가 ROLE 로 구분할 수 있는 jsp 코드 완성이다.

<%@ taglib uri="http://www.springframework.org/security/tags" prefix="sec" %>
<sec:authorize access="hasAnyRole('ROLE_CLIENT')">
  <a class="css-1g5o6hv" href="/logout">로그아웃</a>
  <a class="css-1g5o6hv" href="/mypage/profile">마이페이지</a>
  <a class="css-1tlac5g" href="merchant-main.do">판매자센터</a>
</sec:authorize>
<sec:authorize access="!hasAnyRole('ROLE_CLIENT')">
  <a class="css-1g5o6hv" href="/member/login">로그인</a>
  <a class="css-1g5o6hv" href="/member/join">회원가입</a>
  <a class="css-1tlac5g" href="${pageContext.request.contextPath}/merchant/main">판매자센터</a>
</sec:authorize>

3. OAuth2 설정

(1) 의존성 추가

    // OAuth2
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

(2) kakao.properties 추가

spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me

spring.security.oauth2.client.registration.kakao.client-name=kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.client-id=(클라이언트 아이디)

spring.security.oauth2.client.registration.kakao.client-secret=(secret 코드)
spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email

(3) Config 추가


	@Bean
	public AuthenticationSuccessHandler authenticationSuccessHandler() {
		return new CustomSocialLoginSuccessHandler();
	}
    
        http
	        .oauth2Login()
	        		.loginPage("/member/login")
	        		.successHandler(authenticationSuccessHandler()); 

config 에는 본 코드를 추가해 준다. 핸들러를 추가해 만약 내가 로그인에 성공했다? 아래의 핸들러로 이동하게 해 주는 것이다.

(4) CustomSocialLoginSuccessHandler


@RequiredArgsConstructor
public class CustomSocialLoginSuccessHandler implements AuthenticationSuccessHandler {
	
	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		MemberSecurityDTO memberSecurityDTO = (MemberSecurityDTO) authentication.getPrincipal();
		
		response.sendRedirect("/login/message");
	}

}

로그인에 성공하면 login/message 경로로 이동하라.

(5) OAuth2.ver UserDetails 구현

코드는 아래 구성으로 짰다.

memberJPQL

    @Transactional
    @Modifying
    @Query("UPDATE Member m SET m.social = true WHERE m.memberId = :memberId")
    void updateSocialStatusByMemberId(@Param("memberId") String memberId);
package com.oz.ozHouse.client.security;

@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService{
	
	private final MemberRepository memberRepository;
	private final PasswordEncoder passwordEncoder;
	
	// DefaultOAuth2UserService
	// Spring Security에서 OAuth2 사용자 정보를 가져오는 데 사용되는 기본 서비스
	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		ClientRegistration clientRegistration = userRequest.getClientRegistration();
		String clientName = clientRegistration.getClientName();
		
		OAuth2User oAuth2User = super.loadUser(userRequest);
		Map<String, Object> paramMap = oAuth2User.getAttributes();
		
		String email = null;
		
		switch (clientName) {
			case "kakao" : 
				email = getKakaoEmail(paramMap);
				break;
		}
		
		return generateDTO(email, paramMap);
	}
	
	private MemberSecurityDTO generateDTO(String email, Map<String, Object> params) {
		
		Optional<Member> result = memberRepository.findByMemberEmail(email);
		
		if(result.isEmpty()) { // new 이메일 ? 새 계정 생성
			Member member = Member.builder()
					.memberId(email)
					.memberPasswd(passwordEncoder.encode("1111"))
					.social(true)
					.memberEmail(email)
	                .memberPoint(0)
	                .memberLevel(MemberLevel.NORMAL)
					.build();
			member.addRole(MemberRole.CLIENT);
			memberRepository.save(member);
			
			//MemberSecurityDTO 구성 및 반환
			MemberSecurityDTO memberSecurityDTO = gernerateConsDTO(member);
			// builder 로 props 따로 설정
			memberSecurityDTO = memberSecurityDTO.props(params);
			
			return memberSecurityDTO;
		}else {	// old 이메일 ? 소셜 회원인지 아닌지 확인
			Member member = result.get();
			
			// 소셜 회원으로 되어 있지 않을 경우 social 회원으로 업데이트
			if (member.isSocial() == false) 
				memberRepository.updateSocialStatusByMemberId(member.getMemberId());
			
			//MemberSecurityDTO 구성 및 반환
			MemberSecurityDTO memberSecurityDTO = gernerateConsDTO(member);
			return memberSecurityDTO;
		}
	}
	
	private MemberSecurityDTO gernerateConsDTO (Member member) {
		MemberSecurityDTO memberSecurityDTO =
				new MemberSecurityDTO(
						member.getMemberNum(),
						member.getMemberId(),
						member.getMemberPasswd(),
						member.getMemberEmail(),
						member.getMemberNickname(),
						member.getMemberDeletedate(),
						member.isSocial(),
						member.getRoleSet()
								.stream().map(memberRole -> new SimpleGrantedAuthority("ROLE_" + memberRole.name())).collect(Collectors.toList())
								);
		return memberSecurityDTO;
	}
	
	private String getKakaoEmail(Map<String, Object> paramMap) {
		Object value = paramMap.get("kakao_account");
		LinkedHashMap accountMap = (LinkedHashMap) value;
		String email = (String) accountMap.get("email");
		return email;
	}

}

⭐ LickedHashMap
삽입 순서 또는 액세스 순서를 기반으로 요소들을 유지
내부적으로 해시 테이블과 연결 리스트를 사용하여 요소들을 저장하며, 이 때 연결 리스트는 요소들의 삽입 순서나 액세스 순서를 기억

  • 순서 유지: 요소들을 삽입한 순서대로 유지한다.
  • 액세스 순서 유지: 요소들에 대한 액세스 순서를 유지할 수 있습니다. 액세스 순서를 유지하도록 생성자를 호출할 수 있으며, 이 때 가장 최근에 액세스한 요소가 가장 뒤쪽으로 이동

⭐ loadUser()

  • loadUser() 메서드는 OAuth2UserRequest 객체를 매개변수로 받아들이고, 이를 처리하여 OAuth2User 객체를 반환
  • 먼저, userRequest 객체에서 OAuth2 클라이언트 등록 정보(ClientRegistration)를 가져온다
  • 이 정보를 사용하여 클라이언트의 이름을 얻을 수 있다

⭐ Controller 에서 User 객체 가져오기

@GetMapping("/hi")
    public String index(@AuthenticationPrincipal MemberSecurityDTO member) {
        System.out.println("이렇게 받아 올 수 있음 = " + member.getUsername());
        System.out.println("이렇게 받아 올 수 있음 = " + member.isSocial());
        return "client/member/member_join";
    }

참고한 블로그 글 목록
1. builder 패턴 정리 끝판왕
https://inpa.tistory.com/entry/GOF-%F0%9F%92%A0-%EB%B9%8C%EB%8D%94Builder-%ED%8C%A8%ED%84%B4-%EB%81%9D%ED%8C%90%EC%99%95-%EC%A0%95%EB%A6%AC

  1. DTO 와 Entity 를 분리해서 사용하는 이유
    https://hstory0208.tistory.com/entry/SpirngJPA-Dto%EC%99%80-Entity%EB%A5%BC-%EB%B6%84%EB%A6%AC%ED%95%B4%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0

  2. @Autowired 대신 final 사용하는 이유 (편함!!!!)
    https://juns-lee.tistory.com/entry/Spring-Boot-JPA-%ED%99%9C%EC%9A%A91

  3. JPA repository 메서드 정리
    https://sjh9708.tistory.com/83

  4. jsp 에서 security 사용하기
    https://oingdaddy.tistory.com/76

  5. @Controller(view 로 리턴하는) 로 security 사용하기
    https://nahwasa.com/entry/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-30%EC%9D%B4%EC%83%81-Spring-Security-%EA%B8%B0%EB%B3%B8-%EC%84%B8%ED%8C%85-%EC%8A%A4%ED%94%84%EB%A7%81-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0

  6. JPA 영속성 컨텍스트
    https://velog.io/@devtel/JPA-%EC%98%81%EC%86%8D%EC%84%B1persistence%EC%9D%B4%EB%9E%80

profile
자바 백엔드 개발자 개인 위키

0개의 댓글