배경: 구글 소셜 로그인, 자체적인 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들 참고)
토큰 받아서 저장
참고자료
'GDSC Sookmyung 활동 > 10 min Seminar' 카테고리의 다른 글
알아두면 쓸데있는 신비한 ChatGPT 익스텐션 (0) | 2023.04.03 |
---|---|
도커와 쿠버네티스 알아보기 (0) | 2023.04.03 |
Clean Architecture (0) | 2023.04.02 |
블록체인과 암호화폐 (0) | 2023.03.27 |
Tailwind CSS + CSS Resource (0) | 2023.03.20 |