camp-us 캠핑장 커뮤니티 프로젝트-4(Firebase, spring-security, 환경 별 .yml 파일 세팅)

김상운(개발둥이)·2022년 2월 21일
0

camp-us

목록 보기
4/6
post-thumbnail

개요

이전 3주차 까지는 프로젝트 셋업 및 jpa 설정을 하였다. 이번 주차는 도메인 개발을 위해 회원가입, 로그인 기능을 중점으로 개발하였다.

oAuth 세팅

build.gradle

dependencies {
  implementation group: 'com.google.firebase', name: 'firebase-admin', version: '8.0.1'
  implementation 'org.springframework.boot:spring-boot-starter-security'
}

위 두개의 라이브러리는 필수적으로 추가해주어야 한다.

Firebase 세팅

FirebaseConfig.class

@Configuration
public class FirebaseConfig {

    @Bean
    public FirebaseAuth firebaseAuth() throws IOException {
        FirebaseOptions options = new FirebaseOptions.Builder()
                .setCredentials(GoogleCredentials.fromStream(getFirebaseIs()))
                .build();

        FirebaseApp.initializeApp(options);
        return FirebaseAuth.getInstance(FirebaseApp.getInstance());
    }

    private InputStream getFirebaseIs() throws IOException {

        ClassPathResource resource = new ClassPathResource("serviceAccountKey.json");
        if(resource.exists()) {
            return resource.getInputStream();
        } throw new RuntimeException("firebase 키가 존재하지 않습니다");
    }
}

serviceAccountKey 파일 설정

구글에서 다운받은 파이어베이스 설정 파일을 환경변수로 등록해서 사용하는 것이 보안상 더 좋지만, 개발 편의성을 위하여 아래 사진과 같이 resources 밑에 .json 파일을 둔다.

spring-security 세팅

SecurityConfig.class

package couch.camping.config;

import couch.camping.config.auth.AuthFilterContainer;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final AuthFilterContainer authFilterContainer;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제
                .csrf().disable() // csrf 보안 토큰 disable 처리.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests() // 요청에 대한 권한 지정
                .anyRequest().authenticated() // 모든 요청이 인증되어야한다.
                .and()
                .addFilterBefore(authFilterContainer.getFilter(),
                        UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        //인증 예외 URL 설정
        web.ignoring()
                .antMatchers(HttpMethod.GET ,"/test")
                .antMatchers(HttpMethod.POST ,"/members/local")//로컬용 회원가입
                .antMatchers(HttpMethod.POST, "/members")//배포용 회원가입
                .antMatchers(HttpMethod.POST, "/camps")
                .antMatchers(HttpMethod.GET ,"/camps/**")
                .antMatchers(HttpMethod.GET, "/reviews/**")
                .antMatchers("/css/**")
                .antMatchers("/static/**")
                .antMatchers("/js/**")
                .antMatchers("/img/**")
                .antMatchers("/fonts/**")
                .antMatchers("/vendor/**")
                .antMatchers("/favicon.ico")
                .antMatchers("/pages/**")
                .antMatchers("/v2/api-docs", "/configuration/**", "/swagger*/**", "/webjars/**");
    }
}

위의 코드에서 authFilterContainer 를 의존성 주입을 받아 사용한다.
이는 개발 서버, 배포 서버에서의 환경에 맞는 .yml 파일에 해당하는 필터를 사용하기 위함이다. AuthFilterContainer.class 를 살펴보자

AuthFilterContainer.class

package couch.camping.config.auth;

import org.springframework.web.filter.OncePerRequestFilter;

public class AuthFilterContainer {

    private OncePerRequestFilter authFilter;

    public void setAuthFilter(OncePerRequestFilter authFilter) {
        this.authFilter = authFilter;
    }

    public OncePerRequestFilter getFilter() {
        return authFilter;
    }
}

위의 AuthFilterContainer 클래스는 클래스 명에서 확인할 수 있듯이 필터를 담고있는 container 이다.

AuthConfig.class

package couch.camping.config.auth;


import com.google.firebase.auth.FirebaseAuth;
import couch.camping.domain.member.service.MemberService;
import couch.camping.filter.JwtFilter;
import couch.camping.filter.MockJwtFilter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@Slf4j
@RequiredArgsConstructor
public class AuthConfig {

    private final MemberService userService;

    private final FirebaseAuth firebaseAuth;

    @Bean
    @Profile("local")
    public AuthFilterContainer mockAuthFilter() {
        log.info("Initializing local AuthFilter");
        AuthFilterContainer authFilterContainer = new AuthFilterContainer();
        authFilterContainer.setAuthFilter(new MockJwtFilter(userService));
        return authFilterContainer;
    }

    @Bean
    @Profile("prod")
    public AuthFilterContainer firebaseAuthFilter() {
        log.info("Initializing Firebase AuthFilter");
        AuthFilterContainer authFilterContainer = new AuthFilterContainer();
        authFilterContainer.setAuthFilter(new JwtFilter(userService, firebaseAuth));
        return authFilterContainer;
    }
}

위의 코드는 스프링 프로젝트가 어떤 .yml(local, prod) 파일에 구동되는지에 따라서 AuthFilterContainer 객체 내부 필드에 OncePerRequestFilter 인터페이스를 구현한 구현체 객체를 AuthFilterContainer 의 setter 메서드를 사용해 초기화한다.

필터

개발 필터

package couch.camping.filter;

import couch.camping.exception.CustomException;
import couch.camping.util.RequestUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class MockJwtFilter extends OncePerRequestFilter{

    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        // get the token from the request
        String header;
        try{
            header = RequestUtil.getAuthorizationToken(request.getHeader("Authorization"));
        } catch (CustomException e) {
            // ErrorMessage 응답 전송
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.getMessage() + "\"}");
            return;
        }

        // User를 가져와 SecurityContext에 저장한다.
        try{
            UserDetails user = userDetailsService.loadUserByUsername(header);//user? id 를 통해 회원 엔티티 조회
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    user, null, user.getAuthorities());//인증 객체 생성        
            SecurityContextHolder.getContext().setAuthentication(authentication);//securityContextHolder 에 인증 객체 저장
        } catch(UsernameNotFoundException e){
            // ErrorMessage 응답 전송
            response.setStatus(HttpStatus.SC_NOT_FOUND);
            response.setContentType("application/json");
            response.getWriter().write("{\"code\":\"USER_NOT_FOUND\"}");
            return;
        }
        filterChain.doFilter(request, response);
    }
}

배포용 필터

package couch.camping.filter;

import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.auth.FirebaseAuthException;
import com.google.firebase.auth.FirebaseToken;
import couch.camping.exception.CustomException;
import couch.camping.util.RequestUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter{

    private final UserDetailsService userDetailsService;
    private final FirebaseAuth firebaseAuth;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        // get the token from the request
        FirebaseToken decodedToken;
        try{
            String header = RequestUtil.getAuthorizationToken(request.getHeader("Authorization"));
            decodedToken = firebaseAuth.verifyIdToken(header);//디코딩한 firebase 토큰을 반환
        } catch (FirebaseAuthException | IllegalArgumentException | CustomException e) {
            // ErrorMessage 응답 전송
            response.setStatus(HttpStatus.SC_UNAUTHORIZED);
            response.setContentType("application/json");
            response.getWriter().write("{\"code\":\"INVALID_TOKEN\", \"message\":\"" + e.getMessage() + "\"}");
            return;
        }

        // User를 가져와 SecurityContext에 저장한다.
        try{
            UserDetails user = userDetailsService.loadUserByUsername(decodedToken.getUid());//uid 를 통해 회원 엔티티 조회
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    user, null, user.getAuthorities());//인증 객체 생성        
            SecurityContextHolder.getContext().setAuthentication(authentication);//securityContextHolder 에 인증 객체 저장
        } catch(UsernameNotFoundException e){
            // ErrorMessage 응답 전송
            response.setStatus(HttpStatus.SC_NOT_FOUND);
            response.setContentType("application/json");
            response.getWriter().write("{\"code\":\"USER_NOT_FOUND\"}");
            return;
        }
        filterChain.doFilter(request, response);
    }
}

위 두 필터의 차이는 Firebase 에서의 추가적인 인증여부에서 차이가 있다.

firebaseAuth.verifyIdToken(header)

위와 같이 배포용 필터는 Firebase 에서 인증을 추가적으로 한다.

yml 파일 세팅

환경설정 파일인 .yml 파일을 분리한 이유는 개발서버와 배포서버에서의 환경이 다르기 때문이다. (배포서버는 AWS 의 RDS, Firebase 를 사용 등의 이유)

application.yml(배포용)

spring:
  datasource:
    url: jdbc:mysql://DB이름.RDS주소:3306/campus?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: username
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 100
  config:
    activate:
      on-profile: prod //@Profile("prod")
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher


logging.level:
  org.hibernate.SQL: debug

application-local.yml


spring:
  datasource:
    url: jdbc:mysql://localhost:3306/스키마 이름?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: 아이디
    password: 비밀번호
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        #show_sql: true -> 프린트
        ##use_sql_comments : true -> jpql
        format_sql: true
        default_batch_fetch_size : 100
  config:
    activate:
      on-profile: local// @Profile("local")
  mvc:
    pathmatch:
      matching-strategy: ant_path_matcher

logging.level:
  org.hibernate.SQL: debug

위 두개의 차이는 데이터베이스 설정과 profile 이름의 차이이다.

spring.config.activate.on-profile=local

yml 파일이 위와 같이 구성되어 있을 경우 @Profile 어노테이션이 붙은 빈 중에서 value 가 local 인 빈만 생성이 된다.

정리

이번에는 포스팅에서는 Firebase, spring-security, 환경 별 .yml 파일 세팅에 대해서 알게되었다. 배포를 위해 ec2 리눅스 환경에서 jar 파일로 실행할 아래의 명령어를 입력하여 spring 을 실행하면 된다.

java -jar 프로젝트명.jar -spring.active.profiles=prod

깃허브: https://github.com/Couch-Coders/6th-camp_us-be

profile
공부한 것을 잊지 않기 위해, 고민했던 흔적을 남겨 성장하기 위해 글을 씁니다.

0개의 댓글