spring boot의 oauth를 사용해서 소셜로그인을 구현한 예제는 많았는데, flutter와 연동한 예시는 잘 없어서 많이 애를 먹었다.
플러터와 spring boot를 연동하여 소셜로그인을 구현하는 방법은 크게 2가지다.
각자의 장단점이 있겠으나 2번 방법을 선택하게 되었다. 그 이유로는
소셜로그인을 spring boot oauth2에서 처리하게 될 경우, 무조건 해당 어플을 실행한 후 요청이 들어가야한다.
예를들어서 카카오 로그인의 경우 플러터에서 처리하는 경우에 유저가 이미 카카오톡에 로그인이 되어있는 경우 바로 유저 정보를 받아올 수 있다.
하지만 spring boot에서 처리를 하게되면 서버에게 권한을 부여해주기 위해서 내가 만들 어플리케이션에 정보 공개를 하겠느냐는 화면이 뜨게 된다.
결국 유저가 로그인을 하기 위해서 한 번의 클릭을 더 거쳐야 하고, 이것이 모바일 로그인 과정에서 매우 불필요한 과정이라고 생각했기 때문이다.
2번 방법의 경우에는 나와있는 예시가 더욱 더 없어서 많은 삽질을 했다..
결과적으로 말하자면 springsecurity에서 제공해 주는 기능을 최대한 쓰지 않고 대부분의 기능을 custom으로 구현해야 한다는 것이다.
유저 정보를 받아오는 것 부터 쉽지 않았다. 문제는 크게 2가지였다.
이를 해결하기 위해 먼저 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이 하지만 크게는 이런 방식으로 동작한다.
따라서 우리가 구현해야 할 부분도 다음과 같다.
@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에 넣어준다.
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을 발급한다. 그 로직은 다음 글에서 알아보자
안녕하세요. 이 방식으로 개발할 경우, "/social-login" 경로로 유저 정보를 전달하는 주체가 "우리" 플러터 앱이라는 것을 어떻게 검증할 수 있을까요? 악의적인 사용자가 유저 정보를 위조해서 전달하더라도 검증할 방법이 없을 것 같아서요.