이전 글에서 Spring Security 개념에 대해서 정리했다면 이번 글은 Spring Security + JWT 를 적용해보고자 합니다. 앞서 말했다시피 학습과 병행하며 진행된 만큼 아직 개선한 여지가 있는 부분이 있습니다. 글을 읽으시다가 잘못 작성되거나 개선할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.🙌🏻
Spring Security에서 제공하는 인증, 인가를 위한 필터들의 모임으로 사용하는 목적에 맞게 커스텀하여 필터 체인으로 포함시켜 사용할 수 있다.
1.1 SecurityContextPersistenceFilter
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // JWT 토큰을 사용으로 비활성화
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.addFilterBefore(jwtAuthorizationFilter(), BasicAuthenticationFilter.class) //JWT Filter
//JWT 이용하여 인증
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// form 기반의 로그인을 비활성화
.formLogin(login -> login.disable())
// cumstom으로 구성한 필터 사용
.addFilterBefore(customAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class )
.httpBasic(withDefaults());
return http.build();
}
JwtAuthorizationFilter
API 요청이 들어왔을 때, JWT토큰 검증을 위해 Spring Security 필터 체인에 Custom Filter를 추가했습니다.
JwtAuthorizationFilter 는 토큰이 필요하지 않는 API를 제외하고는 모두 수행되며, Authorization 헤더를 통해 JWT의 유효성 검사를 진행합니다.
@Component
@Slf4j
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private String Bearer = "Bearer ";
@Autowired
private JwtTokenUtil jwtTokenUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 토큰이 필요하지 않는 API의 경우 다음 필터로 이동
if(request.getRequestURI().startsWith("/api/auth")) {
response.addHeader("X-Custom-Filter", "Public Access");
filterChain.doFilter(request, response);
return;
}
try {
// Client에서 API를 요청할 때 Header 확인
String token = request.getHeader(HttpHeaders.AUTHORIZATION);
if(token == null || "".equals(token)) {
log.error("Token is null");
setErrorResponse(response, ErrorCode.NOT_EXISTS_TOKEN,null);
}
token = token.substring(Bearer.length());
if(!jwtTokenUtil.isTokenValid(token)) {
setErrorResponse(response, ErrorCode.INVALID_TOKEN, null);
}
filterChain.doFilter(request,response);
} catch (Exception e) {
setErrorResponse(response,null, e);
}
}
public static void setErrorResponse(HttpServletResponse response, ErrorCode errorCode, Exception e) throws IOException {
response.setContentType("application/json;charset=UTF-8");
if(!"null".equals(e)) {
if(e instanceof ExpiredJwtException) {
errorCode = ErrorCode.EXPIRED_TOKEN;
} else if (e instanceof SignatureException | e instanceof JwtException) {
errorCode = ErrorCode.INVALID_TOKEN;
} else if (e instanceof NestedServletException || e instanceof Exception) {
errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
}
}
PrintWriter writer = response.getWriter();
response.setStatus(errorCode.getStatus().value());
HashMap<String, String> jsonMap = new HashMap<>();
jsonMap.put("status", String.valueOf(errorCode.getStatus().value()));
jsonMap.put("message", errorCode.getMessage());
writer.print(JSONObject.toJSONString(jsonMap));
writer.flush();
writer.close();
}
}
CustomAuthenticationFilter
로그인 API인 /api/auth/login 으로 요청이 들어오면 해당 필터에 들어오게 되는데, 인증 요청을 처리하는 핵심 인터페이스로 실제 인증 로직을 실행하는 부분입니다.
@Slf4j
@Component
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
// login 요청을 하면 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
log.info("1. CustomAuthenticationFilter");
UsernamePasswordAuthenticationToken authenticationToken = null;
try {
// request 파라미터로 임시 토큰 발급
ObjectMapper objectMapper = new ObjectMapper();
User user = objectMapper.readValue(request.getInputStream(), User.class);
authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserId(), user.getPassword());
log.info("Principal : {}", authenticationToken.getPrincipal());
log.info("Credentials : {}", authenticationToken.getCredentials());
setDetails(request,authenticationToken);
} catch (Exception e) {
log.error(e.getMessage());
}
return this.getAuthenticationManager().authenticate(authenticationToken);
}
}
CustomAuthenticationProvider
전달받은 사용자의 아이디와 비밀번호를 기반으로 비즈니스 로직을 처리하여 사용자의 '인증'에 대해서 검증을 수행하는 클래스 입니다.
CustomAuthenticationFilter로부터 생성한 토큰을 통해 'CustomUserDetailsService'를 통해 데이터베이스 내에서 사용자 정보를 조회합니다.
@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Resource
private CustomUserDetailsService customUserDetailsService;
@NonNull
private PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
log.info("2. CustomAuthenticationProvider");
String userId = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
//사용자 인증에 대해서 검증 수행
UserDetails userDetails = customUserDetailsService.loadUserByUsername(userId);
// DB정보랑 일치한 지 확인
if(!passwordEncoder.matches(password, userDetails.getPassword())){
throw new BadCredentialsException("User Info Mismatch");
}
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities());
return authenticationToken;
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
CustomUserDetailsService
사용자 정보를 데이터베이스에서 가져오는 부분입니다.
@Service
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
log.info("UserDetailsService {}" , userId);
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + userId));
return new PrincipalDetails(user);
}
}
CustomAuthSuccessHandler
사용자의 '인증'에 대해 성공했을 경우 수행되는 Handler로 성공에 대한 사용자에게 반환값을 구성하여 전달하는 클래스입니다.
해당 글은 올해 팀 내 PoC로 진행했던 E2E 테스트 자동화 솔루션 개발 프로젝트를 진행했던 내용을 기록하고자 합니다.
프로젝트 초기 구축을 맡아 진행하면서 인증과 권한 권리를 위해 Spring Security와 JWT를 도입했으며, 회원가입과 로그인 같은 기본적인 인증 절차를 구축하며 프레임워크를 학습하고 적용하는 과정을 통해 많은 것을 배울 수 있었습니다.
다만, 이번 프로젝트는 학습과 병행하며 진행된 만큼 아직 개선한 여지가 있는 부분이 있습니다. 글을 읽으시다가 잘못 작성되거나 개선할 부분이 있다면 댓글로 남겨주시면 감사하겠습니다.🙌🏻
목차
1. Spring Security 란?
2. Spring Security 사용하면 뭐가 좋을까?
3. Spring Security 동작 구조를 알아보자.
4. 더 자세히 알아보자.
Spring Security 란?
Java 애플리케이션 인증(Authentication)과 권한 부여(Authorization)를 모두 제공하는 데 중점을 둔 보안 프레임워크로, Spring Framework를 기반으로 한 애플리케이션에 사용되며, 다양한 보안 관련 요구 사항을 쉽게 해결할 수 있도록 설계되었습니다.
주요 특징으로는
1. 인증(Authentication)
사용자가 누구인지 확인하는 과정으로, 예를 들어 사용자가 로그인 폼에서 입력한 아이디와 비밀번호를 통해 본인 여부를 확인합니다.
2. 권한 부여(Authorization)
인증된 사용자가 특정 리소스에 접근할 수 있는 권한이 있는지 확인하는 과정입니다. 부여받은 권한에 따라 접근할 수 있는 페이지가 다를 수 있는데 예를 들어, 관리자는 모든 페이지에 접근할 수 있지만 일반 사용자는 특정 페이지에만 접근할 수 있도록 설정할 수 있습니다.
3. 유연성
데이터베이스 기반 인증, OAuth2, SMAL, LDAP 등의 다양한 인증 방식을 지원하며, 사용자 정의 인증 및 권한 부여 로직을 쉽게 구현할 수 있습니다.
4. 보안 기능 내장
세션 고정, CSRF(Cross-Site Request Forgery) 등과 같은 공격으로부터 보호받을 수 있습니다.
Spring Security 사용하면 뭐가 좋을까?
애플리케이션 보안 기능을 효과적으로 추가하고 관리할 수 있습니다.
1. 로그인과 인증 기능을 간편하게 구현
예를 들어, 한 명의 사용자가 쇼핑몰 웹 사이트에 아이디와 비밀번호로 로그인한다고 가정해보면
Spring Security는 로그인 요청을 처리하고 사용자 정보를 데이터베이스에서 확인한 뒤, 인증이 성공하면 자동으로 세션을 생성하고 인증상태를 유지하게 됩니다. 이 과정에서 별도의 인증 로직을 처음부터 구현할 필요 없이, Spring Security에서 제공하는 기능을 활용할 수 있습니다.
즉, 인증 로직에 대한 개발 시간 절약과 표준화된 방식으로 높은 안정성을 제공할 수 있습니다.
2. URL 별 접근 권한 제어
권한 별 접근할 수 있는 페이지를 세부적으로 설정할 수 있습니다.
예를 들어 쇼핑몰 관리자 페이지 (/admin)은 관리자 권한을 가진 사용자만 접근할 수 있어야하며, 그 외 일반 사용자는 상품 정보, 자신의 주문 내역 등 볼 수 있는 페이지를 제한해야 합니다.
Spring Security를 사용하면 아래와 같이 간단한 설정으로 권한 관리를 할 수 있습니다.
http
.authorizeRequests()
.antMatchers("/admin").hasRole("ADMIN") // 관리자만 접근 가능
.antMatchers("/user/**").hasRole("USER") // 일반 사용자 접근 가능
.anyRequest().authenticated(); // 나머지 요청은 인증만 되면 접근 가능
3. 다양한 인증 방식 지원
OAuth 2.0 및 JWT와 같은 인증 방식을 통합하여 사용자 인증을 쉽게 처리할 수 있습니다.
REST API를 개발하면서 JWT을 사용해 인증 토큰을 발급하고, 이를 통해 클라이언트와 서버 간 인증을 처리할 수 있습니다.
이처럼 Spring Security는 개발자에게 반복적인 보완 관련 작업을 간소화하고, 표준화된 방식으로 높은 보안 수준을 제공하기 때문에 개발효율을 높일 수 있습니다.
Spring Security 동작 구조에 대해서 알아보자.
Spring Security는 요청(Request)이 애플리케이션에 도달하기 전에 보안 필터(Security Filters)를 통해 인증과 권한 부여를 처리하는 구조로 설계되어 있습니다. 동작 구조를 주요 단계별로 보면
1단계. 요청(Request)이 Security Filter Chain에 진입
Spring Security는 HTTP 요청이 들어오면 이를 처리하기 위해 FilterChainProxy라는 메인 필터를 통해 요청을 가로챕니다.
필터 체인에서 인증 및 권한 부여를 수행하고, 검증이 완료된 이후에 다음 필터 또는 컨트롤러로 전달됩니다.
2단계. 인증(Authentication) 처리
사용자가 애플리케이션에 접근하려는 경우, Spring Security는 사용자 인증하는 과정을 거칩니다.
2.1 로그인 요청 처리
사용자가 /login과 같은 엔트포인트로 로그인 요청을 보낼 경우, UsernamePasswordAuthenticationFilter가 이를 처리합니다. 이 필터는 아이디와 비밀번호를 읽고, AuthenticationManager에게 인증을 위임합니다.
2.2 AuthenticationManager 동작
사용자가 입력한 정보를 기반으로 실제 인증을 수행합니다. 일반적으로 UserDetailsService를 호출하여 사용자 정보를 데이터베이스에서 서 가져와 저장된 정보와 비교하여 인증 성공 여부를 결정합니다.
2.3 인증 성공/실패
인증에 성공할 경우, 사용자의 인증 정보를 SecurityContext에 저장하고 다음 필터로 요청을 전달합니다.
인증에 실패할 경우, 에러 응답(401 Unauthorized 등)을 반환합니다.
3. 권한 부여 (Authorization) 처리
인증이 성공한 후, 사용자가 요청한 리소스에 접근할 권한이 있는지 확인하는 단계로
3.1 SecurityContext에서 인증 정보 확인
SecurityContext에 저장된 인증 정보를 확인하여 사용자의 역할(Role) 또는 권한(Authority)를 가져옵니다.
3.2 AccessDecisionManager 동작
사용자가 요청한 URL, 메서드(GET, POST등), 리소스 등에 대해 접근 가능한지 확인합니다. 권한이 없으면 403 Forbidden 에러를 반환합니다.
4. FilterChain 완료 및 컨트롤러 호출
모든 필터를 성공적으로 통과한 요청은 최종적으로 애플리케이션에 컨트롤러로 전달되며, 컨트롤러는 요청을 처리하고 응답을 반환합니다.
5. 응답 (Response) 반환 및 세션/토큰 관리
응답을 반환할 때, Spring Security는 인증 정보를 관리하거나 상태를 업데이트 합니다.
세션 기반 인증의 경우, 인증 정보를 세션에 저장하고 클라이언트는 세션 쿠키를 통해 인증 상태를 유지합니다.
JWT 기반 인증의 경우, 인증 성고 시 JWT 토큰을 생성하여 클라이언트에 반환합니다. 클라이언트는 이후 요청마다 이 토큰을 헤더에 포함시켜 인증을 유지합니다.
더 자세히 알아보자.
먼저 사용자 인증 요청이 오면
1. DelegatingFilterProxy
Servlet Container 와 Spring loC Container를 연결해주는 필터로 springSecurityFilterChain 이름으로 생성된 Bean을 찾아 요청을 위임하는 역할을 한다.
2. FilterChainProxy
springSecurityFilterChain의 이름으로 생성되는 필터 빈으로 DelegatingFilterProxy로부터 요청을 위임 받고 실제로 보안을 처리하는 역할을 한다. (Spring Security 초기화 시 생성되는 필터들을 관리하고 제어)
현재 사용자 계정으로 인증을 받은 사용자가 두 명 이상힐 때 실행되는 필터로 매 요청마다 사용자의 세션 만료 여부를 체크하고 세션이 만료되었을 경우 즉시 만료처리 하는 역할
2-5 RememberMeAuthenticationFilter
세션이 만료되거나 무효화되어서 세션안에 있는 SecurityContext 내 인증 객체가 null일 경우 실행되는 필터로 사용자가 요청하는 Reqeust Header에 remeber-me cookie 값을 헤더에 저장한 상태로 요청이 왔을 때 접속한 사용자 대신 인증처리를 시도
2-6 AnonymousAuthenticationFilter
인증 시도를 하지 않고 권한도 없이 어떤 자원에 바로 접속을 시도하는 경우 실행되는 필터로 annonymouseAuthenticationToken을 만들어 SecurityContext 객체에 저장하는 역할
2-7 SessionManagementFilter
현재 세션에 SecurityContext가 없거나 세션이 null인 경우에 동작되는 필터로 아래 3가지 작업 수행
Register SessionInfo : 사용자 세션 정보 등록
인증에 성공한 SecurityContext를 세션에 저장
SessionFixation : 인증을 시도하기 전 이전 쿠키가 삭제되고 성공한 시점에 새로운 쿠키 발급
ConcurrentSession : 사용자 계정으로 동일한 세션이 존재하는지 확인
2-8 ExceptionTranslationFilter
Filter Chain을 거치면서 발생하는 인증, 인가 예외가 발생할 때 실행되는 필터
AuthenticationException : 인증 예외 처리
AccessDeniedException : 인가 예외 처리
2-9 FilterSecurityInterceptor
권한 부여와 관련한 결정을 AccessDecisionManager에게 위임해 권한부여 결정 및 접근 제어 처리
오픈소스로 제공하고 있는 Elasticsearch + Kibana를 사용해 데이터 시각화 대시보드를 구성해 보고자 개인 서버에 설치하려는데 아래와 같은 오류가 발생했다. 일주일 전만 해도 패키지 설치 시, yum명령어로 설치했었는데 오늘 갑자기 오류가 발생해서 네트워크 문제 있은가 싶어 여러 설정을 변경해 봤는데... CentOS EOL 때문이었다.. 그것도 모르고 몇 시간 동안 삽질했는지😂😂😂
해당 오류를 어떻게 해결했는지 기록으로 남겨두려고 한다.
💡 혹시 네트워크 문제로 패키지 설치가 안되는 걸 수도 있으니, ping은 잘 되는지 `nmcli d` 명령어로 네트워크가 잘 설정되어 있는지 DNS 설정은 되어있는 지 등.. 먼저 확인해 보시길 추천드려요!
[root@instance-juyeon ~]# cd /etc/yum.repos.d
## 기존 yum 관련 레파지토리 백업 폴더 생성 및 이동
[root@instance-juyeon yum.repos.d]# mkdir backup
[root@instance-juyeon yum.repos.d]# mv CentOS-* backup
## 새 레파지토리 파일 생성
[root@instance-juyeon yum.repos.d]# vi CentOS-BASE.repo
[base]
name=CentOS-$releasever - Base
baseurl=http://centos.mirror.cdnetworks.com/7/os/x86_64
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7
#released updates
[updates]
name=CentOS-$releasever - Updates
baseurl=http://centos.mirror.cdnetworks.com/7/updates/x86_64
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7
#additional packages that may be useful
[extras]
name=CentOS-$releasever - Extras
baseurl=http://centos.mirror.cdnetworks.com/7/extras/x86_64
gpgcheck=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7
#additional packages that extend functionality of existing packages
[centosplus]
name=CentOS-$releasever - Plus
baseurl=http://centos.mirror.cdnetworks.com/7/centosplus/x86_64
gpgcheck=1
enabled=0
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7
## yum cache 삭제
[root@instance-juyeon yum.repos.d]# yum clean all
위 코드와 같이 yum cache 까지 삭제 후, yum 명령어로 패키지 설치하면 정상 설치된다!
감사하게도 옆에 앉으신 분이 해당 설정 변경하는 방법을 알려주셨는데, 내 컴퓨터에는 설정을 변경해도 반영이 안돼서 시험 중간중간 마우스 사용하고 싶을 때 ctrl + alt + delete로 마우스를 계속 잡아줬다ㅠㅠㅠㅠㅠ ( 혹시 모르니 마우스 커서 설정 변경하는 방법 찾아보고 가세요! )
또, 작성한 답을 수정할 경우에는 수정할 답 마다 감독관님 싸인 이 필요하기 때문에 답안 작성 시 신중히 작성해야 한다. 수정 1개당 싸인 1개가 필요하므로 번거로울 수 있다😂
답은 수험표 뒤에 적어올 수 있으며, 시험 정답은 1주일 뒤에 KAIT 자격검정 사이트에서 확인할 수 있다.
이번 리눅스마스터 1급 자격증을 준비하면서 리눅스 관련해서 많이 배울 수 있었고, 현재 회사에서 업무 효율성도 높아진 거 같아 취득하길 잘한 거 같다는 생각이 든다.
마지막으로 실기 준비 꿀팁으로
- find / man 명령어를 잘 사용하자!
- 대부분의 서비스(apache,mail, NFS.. 등등) 은 기본적으로 설치가 되어있는데 혹시 모르니 공부할 때는 한 번씩 직접 rpm, yum으로 설치해 보길 추천합니다!
AWS Certified DevOps Engineer - Professional(DOP-C02) 시험 후기를 남겨보려고 합니다.
✍🏻DOP-C02가 어떤 자격증인데?✍🏻
AWS Certified DevOps Engineer - Professional (DOP-C02) 시험은 DevOps 엔지니어 역할을 수행하는 전문가를 위한 자격입니다. 이 시험은 AWS 기반 분산 시스템과 서비스를 프로비저닝, 운영 및 관리하는 기술적 역량을 검증합니다. 또한, 응시자가 다음과 같은 작업을 수행할 수 있는지를 평가합니다:
AWS에서 지속적 전달(CD) 시스템 및 방법론 구현 및 관리
보안 제어, 거버넌스 프로세스, 규정 준수 검증의 자동화
모니터링, 지표, 로깅 시스템 설계 및 배포
높은 가용성, 확장성, 자가 복구 기능을 갖춘 시스템 구현
운영 프로세스를 자동화하는 도구 설계 및 유지 관리
🔍 왜 취득했어? 🔍
AWS 솔루션은 21년도에 SAFFY교육을 들으면서 사이드 프로젝트를 통해 EC2, S3, CDN.. 등의 기초적인 서비스와 몇몇 솔루션에 대해서만 사용해본게 다였고, 현업에서 인프라는 해당 인프라팀에서 전적으로 담당해서 맡아주다보니 실질적으로 개발자가 인프라를 직접 다루는 경우는 단순 서버 설정 같은 코드 작성 외에는 거의 경험할 일이 없었습니다.
하지만 몇년 전부터 클라우드(Cloud)와 MSA가 IT업계에서 중요한 이슈로 떠오르면서 많은 기업들이 클라우드로 이전하고 있고, Cloud 환경에서 운용하는 다양한 사례도 접할 수 있었다.
그 과정을 보며 자연스럽게 Cloud 에 관심을 갖게 되었고, 인프라를 직접 다루는 직무는 아니지만 AWS 관련 지식이 있으면 아무래도 더 효율적인 소통을 할 수 있지 않을까 싶어 작년에 SAA-C03자격증을 시작으로 올해도 DOP-C02까지 도전하게 되었습니다.