
이전 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=localyml 파일이 위와 같이 구성되어 있을 경우 @Profile 어노테이션이 붙은 빈 중에서 value 가 local 인 빈만 생성이 된다.
이번에는 포스팅에서는 Firebase, spring-security, 환경 별 .yml 파일 세팅에 대해서 알게되었다. 배포를 위해 ec2 리눅스 환경에서 jar 파일로 실행할 아래의 명령어를 입력하여 spring 을 실행하면 된다.
java -jar 프로젝트명.jar -spring.active.profiles=prod