이전 3주차 까지는 프로젝트 셋업 및 jpa 설정을 하였다. 이번 주차는 도메인 개발을 위해 회원가입, 로그인 기능을 중점으로 개발하였다.
dependencies {
implementation group: 'com.google.firebase', name: 'firebase-admin', version: '8.0.1'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
위 두개의 라이브러리는 필수적으로 추가해주어야 한다.
@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 키가 존재하지 않습니다");
}
}
구글에서 다운받은 파이어베이스 설정 파일을 환경변수로 등록해서 사용하는 것이 보안상 더 좋지만, 개발 편의성을 위하여 아래 사진과 같이 resources 밑에 .json 파일을 둔다.
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 를 살펴보자
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 이다.
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 파일을 분리한 이유는 개발서버와 배포서버에서의 환경이 다르기 때문이다. (배포서버는 AWS 의 RDS, Firebase 를 사용 등의 이유)
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
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