GDSC Sookmyung 활동/10 min Seminar

Spring boot와 Flutter로 구글 소셜 로그인 구현하기

y_yy_y 2023. 4. 3. 18:05

배경: 구글 소셜 로그인, 자체적인 JWT 토큰 발급, oauth2.0 전과정 백엔드에서 진행

구현: 프론트엔드에서 사용자가 구글 계정으로 로그인 버튼을 누른다 → 백엔드가 로그인 요청을 받고, 프론트에서 로그인 화면이 웹뷰로 띄워진다 → 로그인 성공하면 모바일 앱으로 이동한다


백엔드

Spring Security

  • 인증 및 인가 과정이 필터 체인으로 이루어짐
  • 인증: 기본적으로 ID-PW 인증 & 세션 영역에 있는 SecurityContext에 Authentication 객체 저장(세션-쿠키 인증)
  • UserDetailsService: 사용자 정보를 가져오는 인터페이스(loadUser)
  • UserDetails: 사용자 정보를 담는 인터페이스
    → OAuth2.0 인증 사용시 UsernamePasswordAuthenticationFilter, UserDeatilsService, UserDetails 대신 OAuth2LoginAuthenticationFilter, OAuth2UserService, OAuth2User를 사용한다고 보면 됨

구현

  • dependency 추가

      implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
      implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
  • application.properties: oauth2.0 client 정보 입력

      spring.security.oauth2.client.registration.google.client-id={client-id}
      spring.security.oauth2.client.registration.google.client-secret={client-secret}
      spring.security.oauth2.client.registration.google.scope=profile,email
  • User entity, UserRepository 생성

      @Getter
      @NoArgsConstructor
      @Entity
      public class User {
          @Id
          @Column(name="user_id")
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private Long id;
    
          @Column(nullable = false, unique = true)
          private String email;
    
          @Column(name="user_name", nullable = false)
          private String name;
    
          @Column
          private String picture;
    
          @Enumerated(EnumType.STRING)
          @Column(nullable = false)
          private Role role;
      }
      @Getter
      @RequiredArgsConstructor
      public enum Role {
          USER("ROLE_USER", "일반 사용자"),
          ADMIN("ROLE_ADMIN", "관리자");
    
          private final String key;
          private final String title;
      }
      public interface UserRepository extends JpaRepository<User, Long> {}
  • SecurityConfig: spring security 사용 설정

      @Configuration
      @RequiredArgsConstructor
      @EnableWebSecurity
      public class SecurityConfig {
          private final CustomOAuth2UserService customOAuth2UserService;
          private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler;
          private final JwtTokenProvider jwtTokenProvider;
    
          @Bean
          public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
              http
                      .csrf().disable()
                      .formLogin().disable()
                      .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                      .and()
                          .authorizeHttpRequests()
                          .requestMatchers("/", "/login", "/css/**", "/images/**", "/js/**").permitAll()
                          .requestMatchers("/api/v1/**").hasRole(Role.USER.name())
                          .anyRequest().authenticated()
                      .and()
                          .logout()
                              .logoutSuccessUrl("/")
                      .and()
                          .addFilterAfter(new JwtAuthenticationFilter(jwtTokenProvider), LogoutFilter.class) // JWT 인증 필터 등록
                          .oauth2Login()
                              .successHandler(customOAuth2SuccessHandler)
                              .userInfoEndpoint()
                                  .userService(customOAuth2UserService);
    
              return http.build();
          }
      }
    • JwtAuthenticationFilter 필터 체인에 등록
    • OAuth2 로그인 SuccessHadnler CustomOAuth2SuccessHandler로 변경, CustomOAuth2UserService 설정
  • CustomOAuth2UserService

      @RequiredArgsConstructor
      @Service
      public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
          private final UserRepository userRepository;
    
          @Override
          public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
              OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
              OAuth2User oAuth2User = delegate.loadUser(userRequest);
    
              String registrationId = userRequest.getClientRegistration().getRegistrationId();
              String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
    
              OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
    
              User user = saveOrUpdate(attributes);
    
              return new DefaultOAuth2User(
                      Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
                      attributes.getAttributes(),
                      attributes.getNameAttributeKey()
              );
          }
    
          public User saveOrUpdate(OAuthAttributes attributes){
              User user = userRepository.findByEmail(attributes.getEmail())
                      .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                      .orElse(attributes.toEntity());
    
              return userRepository.save(user);
          }
      }
  • OAuthAttributes

      @Getter
      public class OAuthAttributes {
          private Map<String, Object> attributes;
          private String nameAttributeKey;
          private String name;
          private String email;
          private String picture;
    
          @Builder
          public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email, String picture){
              this.attributes = attributes;
              this.nameAttributeKey = nameAttributeKey;
              this.name = name;
              this.email = email;
              this.picture = picture;
          }
    
          public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes){
              return ofGoogle(userNameAttributeName, attributes);
          }
    
          public static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes){
              return OAuthAttributes.builder()
                      .attributes(attributes)
                      .nameAttributeKey(userNameAttributeName)
                      .name((String) attributes.get("name"))
                      .email((String) attributes.get("email"))
                      .picture((String) attributes.get("picture"))
                      .build();
          }
    
          public User toEntity(){
              return User.builder()
                      .name(name)
                      .email(email)
                      .picture(picture)
                      .role(Role.USER)
                      .agreement(false)
                      .reward(0)
                      .build();
          }
      }
    • 구글 관련된 코드만 작성, 다른 provider 추가 가능
  • JwtTokenProvider: Jwt 토큰 생성, 검증

      @Slf4j
      @RequiredArgsConstructor
      @Component
      public class JwtTokenProvider {
          @Value("${jwt.secret}")
          private String secretKey;
    
          private long tokenValidTime = 30*60*1000L; // 만료 30분
    
          private final UserDetailsService userDetailsService;
    
          @PostConstruct
          protected void init(){
              secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
          }
    
          // 토큰 생성
          public String createToken(String userPk, Role role){
              Claims claims = Jwts.claims().setSubject(userPk);
              claims.put("role", role);
              Date now = new Date();
    
              return Jwts.builder()
                      .setClaims(claims)
                      .setIssuedAt(now)
                      .setExpiration(new Date(now.getTime() + tokenValidTime))
                      .signWith(SignatureAlgorithm.HS256, secretKey)
                      .compact();
          }
    
          // 토큰으로 인증 정보 조회
          public Authentication getAuthentication(String token){
              UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPK(token));
              return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
          }
    
          private String getUserPK(String token) {
              return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
          }
    
          public String resolveToken(HttpServletRequest request){
              return request.getHeader("X-AUTH-TOKEN");
          }
    
          public boolean validateToken(String token){
              try{
                  Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
                  return !claims.getBody().getExpiration().before(new Date());
              } catch (SignatureException ex){
                  log.error("Invalid JWT signature");
              } catch (MalformedJwtException ex){
                  log.error("Invalid JWT token");
              } catch (ExpiredJwtException ex){
                  log.error("Expired JWT token");
              } catch (UnsupportedJwtException ex){
                  log.error("Unsupported JWT token");
              } catch (IllegalArgumentException ex){
                  log.error("JWT claims string is empty");
              }
              return false;
          }
      }
    • 시간 없어서 refresh token은 구현 안함
  • JwtAuthenticationFilter: JwtTokenProvider를 사용해서 인증 작업을 진행

      @RequiredArgsConstructor
      public class JwtAuthenticationFilter extends GenericFilterBean {
          private final JwtTokenProvider jwtTokenProvider;
    
          @Override
          public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
              String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
    
              if(token != null && jwtTokenProvider.validateToken(token)){
                  Authentication authentication = jwtTokenProvider.getAuthentication(token);
                  SecurityContextHolder.getContext().setAuthentication(authentication);
              }
    
              chain.doFilter(request, response);
          }
      }
  • CustomOAuth2SuccessHandler: 토큰이 성공적으로 발급 되었으면, 토큰을 url에 붙여서 redirect

      @RequiredArgsConstructor
      @Component
      public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
          private final JwtTokenProvider jwtTokenProvider;
    
          @Value("${flutter.uri-scheme}")
          private String uri;
    
          @Override
          public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
              OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
              Map<String,Object> attributes = oAuth2User.getAttributes();
    
              String token = jwtTokenProvider.createToken(attributes.get("email").toString(), Role.USER);
              String targetUrl = uri+"://?route=main&token=" + token;
              getRedirectStrategy().sendRedirect(request, response, targetUrl);
          }
      }
    • 여기서 redirect 하는 url이 모바일 애플리케이션에 설정된 딥링크 ex) foobar://success?code=1337

프론트엔드

Deep link와 Dynamic link

  • 딥링크: 웹에서 처럼 앱에 url을 부여하여, 해당 url을 통해 원하는 화면으로 바로 이동할 수 있게 해주는 것
  • 다이나믹 링크: Firebase에서 제공하는 일종의 딥링크, ios와 android 모두 작동
  • 딥링크를 구현하는 여러가지 방식이 있으나, 이왕 flutter 쓰는 김에 ios, android 한 번에 적용하고자 다이나믹 링크 이용

구현

(다른 팀원이 맡은 부분이라 간단하게만 정리했으니 자세한 내용은 인앤아웃팀 깃헙 참고해주세요!)

  • firebase dynamic link 등록

  • android setup: AndroidManifest.xml

      <activity
          android:name="com.linusu.flutter_web_auth.CallbackActivity"
          android:exported="true">
          <intent-filter android:label="flutter_web_auth">
              <action android:name="android.intent.action.VIEW" />
              <category android:name="android.intent.category.DEFAULT" />
              <category android:name="android.intent.category.BROWSABLE" />
              <data android:scheme="{deep link/dynamic link}" />
          </intent-filter>
      </activity>
  • flutter_web_auth 사용

      import 'package:flutter_web_auth/flutter_web_auth.dart';
    
      // Present the dialog to the user
      final result = await FlutterWebAuth.authenticate(url: "https://my-custom-app.com/connect", callbackUrlScheme: "my-custom-app");
    
      // Extract token from resulting url
      final token = Uri.parse(result).queryParameters['token']

    (flutter_web_auth 깃헙 레포의 example 폴더에 정리된 사용 예시와, issue들 참고)

  • 토큰 받아서 저장


참고자료