spring boot와 flutter를 연동한 소셜로그인 구현(feat. spring security)-1

김가빈·2023년 12월 12일
2

springsecurity

목록 보기
17/23

spring boot의 oauth를 사용해서 소셜로그인을 구현한 예제는 많았는데, flutter와 연동한 예시는 잘 없어서 많이 애를 먹었다.


소셜 로그인 구현법

플러터와 spring boot를 연동하여 소셜로그인을 구현하는 방법은 크게 2가지다.

  1. 플러터에서는 요청만 하고 spring boot에서 oauth2를 이용해 소셜로그인 처리를 하는 방법
  2. 플러터에서 소셜로그인 처리를 하고 유저 정보를 spring boot에 넘겨주고, spring boot에서 accessTokne을 반환하는 방법

선택 방법과 이유

각자의 장단점이 있겠으나 2번 방법을 선택하게 되었다. 그 이유로는

소셜로그인을 spring boot oauth2에서 처리하게 될 경우, 무조건 해당 어플을 실행한 후 요청이 들어가야한다.

예를들어서 카카오 로그인의 경우 플러터에서 처리하는 경우에 유저가 이미 카카오톡에 로그인이 되어있는 경우 바로 유저 정보를 받아올 수 있다.

하지만 spring boot에서 처리를 하게되면 서버에게 권한을 부여해주기 위해서 내가 만들 어플리케이션에 정보 공개를 하겠느냐는 화면이 뜨게 된다.

결국 유저가 로그인을 하기 위해서 한 번의 클릭을 더 거쳐야 하고, 이것이 모바일 로그인 과정에서 매우 불필요한 과정이라고 생각했기 때문이다.

구현

2번 방법의 경우에는 나와있는 예시가 더욱 더 없어서 많은 삽질을 했다..

결과적으로 말하자면 springsecurity에서 제공해 주는 기능을 최대한 쓰지 않고 대부분의 기능을 custom으로 구현해야 한다는 것이다.


1. 유저정보 받아오기

유저 정보를 받아오는 것 부터 쉽지 않았다. 문제는 크게 2가지였다.

  1. flutter로 소셜로그인을 진행하고 유저 정보만 넘겨주기 때문에 oauth2를 통해서 로그인을 구현할 수 없다.
  2. username과 password정보가 없기 때문에 spring filter의 UsernamePasswordAuthenticationFilter에서 에러가 발생한다.

이를 해결하기 위해 먼저 SpringFilterChain을 타지 않도록 통과시키는 설정을 해주었다.
https://velog.io/@kgb/spring-security-permit-all-%EB%AC%B4%EC%8B%9C%EC%95%88%EB%90%98%EB%8A%94-%EA%B2%BD%EC%9A%B0

그리고 flutter에서 넘겨준 유저 정보를 일반적으로 controller로 받아오는 것과 동일하게 받아오면 된다.

@PostMapping("/social-login")
	public ResponseEntity<Map<String, String>> socialLogin(@RequestBody @Valid SocialLoginDTO socialLoginDTO)
	{
		
		UserDTO savedOrFindUser = userService.socialLogin(socialLoginDTO);
		securityService.saveUserInSecurityContext(socialLoginDTO);
		Map<String, String> tokenMap = jwtUtil.initToken(savedOrFindUser);
	
		return ResponseEntity.ok(tokenMap);
	}

주목해야 할 부분은 securityService.saveUserInSecurityContext(socialLoginDTO); 부분으로 안의 메소드는 다음과 같다.

@Service
public class SecurityServiceImpl implements SecurityService{

private final UserRepository userRepository;
	private final JwtUtil jwtUtil;
	
	@Autowired
	private SecurityServiceImpl(UserRepository userRepository, JwtUtil jwtUtil) {
		this.userRepository = userRepository;
		this.jwtUtil = jwtUtil;
	}
    
    @Override
	public void saveUserInSecurityContext(SocialLoginDTO socialLoginDTO) {
		String socialId = socialLoginDTO.getSocialId();
		String socialProvider = socialLoginDTO.getSocialProvider();
		saveUserInSecurityContext(socialId, socialProvider);
	}
    
    private void saveUserInSecurityContext(String socialId, String socialProvider) {
		UserDetails userDetails = loadUserBySocialIdAndSocialProvider(socialId, socialProvider);
		Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
		UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
		
		if(authentication != null) {
			SecurityContext context = SecurityContextHolder.createEmptyContext();
			context.setAuthentication(authentication);
			SecurityContextHolder.setContext(context);
		}
	}
	
    private UserDetails loadUserBySocialIdAndSocialProvider(String socialId, String socialProvider) {
		User user = userRepository.findBySocialIdAndSocialProvider(socialId, socialProvider);
		
		if(user == null) {
			throw new TokenException(TokenErrorResult.TOKEN_EXPIRED);
		} else {
			UserDetailsImpl userDetails = new UserDetailsImpl();
			userDetails.setUser(user);
			return userDetails;
		}
		
	}

보통 spring security에서 인증과정은 다음과 같은 과정을 거친다.
1. AuthenticationProvider에서 UserDetailsService 인터페이스를 통해 loadUserByUsername을 호출한다.
2. loadUserByUsername은 유저 정보를 db에서 불러온 후 해당 유저 정보를 검증한다.
3. 인증이 성공할 경우 해당 유저정보로 authentication객체를 생성한 후 securityContext에 넣어준다. 그 후 AuthenticationSuccessHandle을 실행한다.
4. 실패할 경우 AuthenticationFailureHandler을 실행한다.
5. 그 뒤에는 토큰 검증 등의 작업이 이루어진다.

물론 이 과정에서 password를 디코딩하는 등 더 많은 과정을 filterChain이 하지만 크게는 이런 방식으로 동작한다.

따라서 우리가 구현해야 할 부분도 다음과 같다.

  1. 화면에서 받아온 유저 정보를 검증한다.
  2. 검증에 성공할 경우 securitycontext에 authentication객체를 생성해 넣어주고 토큰을 발급한다.
  3. 검증에 실패할 경우 에러를 던진다.
 @Override
	public void saveUserInSecurityContext(SocialLoginDTO socialLoginDTO) {
		String socialId = socialLoginDTO.getSocialId();
		String socialProvider = socialLoginDTO.getSocialProvider();
		saveUserInSecurityContext(socialId, socialProvider);
	}

먼저 유저 정보를 추출한다. 소셜로그인이므로 socialId와 socialProvider를 추출한다.

 private void saveUserInSecurityContext(String socialId, String socialProvider) {
		UserDetails userDetails = loadUserBySocialIdAndSocialProvider(socialId, socialProvider);
		Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
		UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
		
		if(authentication != null) {
			SecurityContext context = SecurityContextHolder.createEmptyContext();
			context.setAuthentication(authentication);
			SecurityContextHolder.setContext(context);
		}
	}
	
    private UserDetails loadUserBySocialIdAndSocialProvider(String socialId, String socialProvider) {
		User user = userRepository.findBySocialIdAndSocialProvider(socialId, socialProvider);
		
		if(user == null) {
			throw new TokenException(TokenErrorResult.TOKEN_EXPIRED);
		} else {
			UserDetailsImpl userDetails = new UserDetailsImpl();
			userDetails.setUser(user);
			return userDetails;
		}
		
	}

추출한 socialId와 socialProvider를 통해 검증을 진행한다. loadUserByUsername이 아니라 SocialId와 socialProvider를 통해서 검증을 진행하였다.

검증에 실패할 경우 exception을 던지고, 검증에 성공할 경우 UserDetails를 구현한 UserDetailsImpl 객체를 생성한다.

생성한 UserDetails객체를 이용해 UsernamePasswordAuthenticationToken를 발급하고 이를 spring securitycontext에 넣어준다.


참고로 UserDetailsImpl는 다음과 같이 생겼는데, 중요한 것은 role을 가져오는 부분이다.
package com.project.bookforeast.common.security.dto;

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import com.project.bookforeast.user.dto.UserDTO;
import com.project.bookforeast.user.entity.User;

@Component
public class UserDetailsImpl implements UserDetails {

	private User user;
	
	public void setUser(User user) {
		this.user = user;
	}
	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		String role = user.getRole();
		SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role);
		Collection<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(simpleGrantedAuthority);
		return authorities;
	}

	@Override
	public String getPassword() {
		return null;
	}

	@Override
	public String getUsername() {
		return null;
	}

	@Override
	public boolean isAccountNonExpired() {
		return false;
	}

	@Override
	public boolean isAccountNonLocked() {
		return false;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return false;
	}

	@Override
	public boolean isEnabled() {
		return false;
	}

}

가져온 유저 정보로 부터 role을 추출하여 Collection객체를 생성한다.

그 후 다시 controller로 돌아와서 initToken을 통해 token을 발급한다. 그 로직은 다음 글에서 알아보자

profile
신입 웹개발자입니다.

1개의 댓글

comment-user-thumbnail
2024년 4월 10일

안녕하세요. 이 방식으로 개발할 경우, "/social-login" 경로로 유저 정보를 전달하는 주체가 "우리" 플러터 앱이라는 것을 어떻게 검증할 수 있을까요? 악의적인 사용자가 유저 정보를 위조해서 전달하더라도 검증할 방법이 없을 것 같아서요.

답글 달기