개발자의 오르막

SpringBoot 에 Firebase Admin Auth SDK 적용하기 본문

Toy Project/Tortee

SpringBoot 에 Firebase Admin Auth SDK 적용하기

계단 2022. 12. 12. 00:22

이번 프로젝트는 안드로이드 APP 과 API 가 먼저 Beta 버전으로 런칭예정이다. 팀 내에서 안드로이드 개발자만 있었기 때문에 거의 모든 기능을 안드로이드 앱으로 해결을 하려했기 때문에 인증 또한 Firebase Auth 를 기준으로 개발에 착수하였다.

 

 

 

 

Firebase Admin SDK 받기


  • 왼쪽 상단의 설정 단추 (톱니바퀴) 를 누른 후 프로젝트 설정 화면의 서비스 계정 탭으로 들어갑니다.

Admin SDK 를 활용할 언어에 맞게 비공개 키를 생성합니다.

  • Admin SDK 를 활용할 언어에 맞게 비공개 키를 생성합니다.

 

 

 

 

SpringBoot 프로젝트에 Firebase SDK 적용하기


  • Firebase sdk json 파일을 프로젝트에 추가합니다.
  • 해당 파일은 service-api 의 resources 디렉터리에 위치하여 .gitignore 에 등록해줍니다.
  • 추후 Docker 로 배포시 Volume Mount 로 해당 파일을 ignore 처리 예정입니다.

 

Firebase 의존성 추가

implementation group: 'com.google.firebase', name: 'firebase-admin', version: '7.1.0'

implementation 'org.springframework.boot:spring-boot-starter-security'
  • build.gradle 에 firebase-admin 의존성을 추가해줍니다.
  • spring-boot security 의존성도 추가해줍니다.

 

 

 

 

 

Spring Configuration Bean 추가

@Configuration
@RequiredArgsConstructor
public class FirebaseConfig {

    @Value("${firebase.sdk.path}")
    private String sdkPath;

    private FirebaseApp firebaseApp;

    @PostConstruct
    public FirebaseApp initializeFCM() throws IOException {
        FirebaseOptions options = new FirebaseOptions.Builder()
                .setCredentials(GoogleCredentials.fromStream(getFirebaseInfo()))
                .build();
        firebaseApp = FirebaseApp.initializeApp(options);
        return firebaseApp;
    }

    @Bean
    public FirebaseAuth initFirebaseAuth() {
        FirebaseAuth instance = FirebaseAuth.getInstance(firebaseApp);
        return instance;
    }

    private InputStream getFirebaseInfo() throws IOException {
        Resource resource = new ClassPathResource(sdkPath);
        if (resource.exists()) {
            return resource.getInputStream();
        }
        throw new RuntimeException("firebase 키가 존재하지 않습니다.");
    }
}
  • FirebaseAuth 객체를 통한 인증을 위해 @Bean 생성을 진행
  • @PostConstruct 를 통해 해당 Bean 이 생성되기 전에 firebase.json 파일을 읽고 설정하도록 진행
  • sdkPath 는 @Value 를 통해 application.yml 에 지정한 경로에서 가져옵니다.
  • Resource 는 해당 모듈의 resources 경로부터 시작하기 때문에, path 를 아래와 같이 설정해주면 된다.

firebase:
  sdk:
    path: config/chat-app-firebase-adminsdk.json

 

 

 

 

 

 

Filter 에서 인증토큰 검증하기

  • 백엔드에서 firebase ID Token 을 인증하는 부분입니다.
  • Filter 는 사용자 요청의 전후 처리를 할 수 있는 구성요소입니다. 사용자 요청이 들어오면 Controller 에 접근하기 전 먼저 Request 를 인터셉트 해서 전처리 역할 및 후처리 역할을 할 수 있습니다.

 

  • 또한 Spring Security 설정과 결합하면 특정 Request 와 결합할때만 사용자 요청을 처리할 수 있습니다.
  • 토큰을 검증하는 Filter 를 만들고 Security 요청에 따라 검증하도록 처리해보겠습니다.

 

 

 

 

SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final FirebaseTokenFilter firebaseTokenFilter;
    private final AuthServiceImpl authService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests()
                .antMatchers("/api/health-check", "/api/members/sign-up/**", "/api/email-verify", "/api/nickname-verify")
                .permitAll()
                .anyRequest().authenticated().and()
                .addFilterBefore(firebaseTokenFilter,
                        UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling()
                .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
    }

    @Bean
    public RegistrationBean firebaseAuthTokenRegister(FirebaseTokenFilter filter) {
        FilterRegistrationBean registrationBean = new FilterRegistrationBean(filter);
        registrationBean.setEnabled(false);
        return registrationBean;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(authService);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
                .antMatchers("/resources/**");
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}
  • Security 에서 적용되는 필터 전에 적용될 수 있도록 설정한다. .addFilterBefore(firebaseTokenFilter, UsernamePasswordAuthenticationFilter.class)
  • http 기본 설정과 csrf 는 사용하지 않도록 disable 처리를 해준다.

 

 

 

 

 

FirebaseTokenFilter

@Component
@RequiredArgsConstructor
public class FirebaseTokenFilter extends OncePerRequestFilter {

    private final FirebaseAuth firebaseAuth;
    private final AuthServiceImpl authService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // get the token from the request

        if (ValidateRequestHeader.checkAuthorization(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        FirebaseToken decodedToken;
        String token;
        try {
            token = ValidateRequestHeader.getAuthorizationToken(request);
        } catch (IllegalArgumentException e) {
            setUnauthorizedResponse(response, "INVALID_TOKEN");
            return;
        }

        try {
            decodedToken = firebaseAuth.verifyIdToken(token);
        } catch (FirebaseAuthException e) {
            setUnauthorizedResponse(response, "INVALID_TOKEN");
            return;
        }

        if (decodedToken == null) {
            setUnauthorizedResponse(response, "INVALID_TOKEN");
            return;
        }

        // User를 가져와 SecurityContext에 저장한다.
        try{
            UserDetails user = authService.loadUserByUsername(decodedToken.getUid());
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    user, null, user.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch(NoSuchElementException e){
            setUnauthorizedResponse(response, "USER_NOT_FOUND");
            return;
        }

        filterChain.doFilter(request, response);
    }

    private void setUnauthorizedResponse(HttpServletResponse response, String code) throws IOException {
        response.setStatus(HttpStatus.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write("{\\"code\\":\\"INVALID_TOKEN\\"}");
    }
}
  • doFilterInternal 을 오버라이드한 상태에서 Request 가 들어오면 해당 로직을 타게 된다.
  • 이때 요청의 header 에서 Authentication 의 Bearer 로 Firebase 의 ID Token 이 올때만 해당 로직을 타도록 Validation 을 적용한다.
  • firebaseAuth.verifyIdToken(token) 을 통해 firebase sdk 로 적합한 ID Token 이 잇는지 체크한다.
  • 이후 authService 로 해당 uuid 를 가진 회원이 있는지 검사하고, 회원의 로그인이 적합한지 loadUserByUsername 으로 객체를 가져온다.
  • SecurityContextHolder 에 로그인을 성공한 객체를 Set 해준다.

전체로직

  • Authorization Header 에서 Token 을 가져온다.
  • FirebaseAuth 를 이용하여 Token 을 검증한다.
  • UserDetailsService 에서 사용자 정보를 가져와 SecurityContext 에서 추가해준다.
    • id 를 firebase 에서 제공하는 uid 를 사용하였다.
    • UserDetails 와 UserDetailsService 는 Interface 를 구현한다.
    • Context 에 추가한 User 정보는 Controller 에 Principal principal 을 추가해 받아올 수 있다.
  • 인증 실패시 HttpStatus 403과 json 으로 code 를 response 하게 하였다.

 

 

 

Postman ID Token 테스트


  • 서버에서 ID Token 을 발급받는 방법은 Postman 으로 할 수 있다.
  • firebase 설정 화면에 가면 webKey 를 받을 수 있다.
  • 그리고 Postman 으로 회원가입 API 를 등록하면 아래와 같은 Response 를 얻을 수 있다.

Firebase admin sdk Sign-up

- URL : https://identitytoolkit.googleapis.com/v1/accounts:signUp?key={webKey}
- Method : POST

- Request

{
    "email": "kjuiop222@naver.com",
    "password":"",
    "returnSecureToken":true
}

- Response

{
    "email": "kjuiop222@naver.com",
    "password":"",
    "returnSecureToken":true
}
  • 웹 API 키는 프로젝트 설정 화면의 일반탭에 명시되어 있다.
  • 이후 인가가 필요한 API 요청 Headers 에 idToken 을 기입하면 된다.

 

 

 

 

 

 

 

Reference


 

Comments