정리하다 보니 기네요. 그래도, 부수적으로 많이 나눠 놨습니다 :)
보안인증
개발을 하는 사람이건 아니건,
해당 부분은 굉장히 흥미롭고 눈길이 가는 단어입니다.
하지만 해당 부분을 조금 자세히 들여다 보기만 해도,
이걸 개발해준 개발자에게 감사하고, 쉽게 이해하지 못하니
철저한 보안이 되는 거야! 라고 생각하고 넘어가게 될 겁니다.
우선 무슨 말인지 코드 하나 보고 가시죠.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJldUxqVlJKSzhrcTV2OXJQdnowbjNRLzRRM08xMTZZTkdrbkpsckdpZlJrPSIsImp0aSI6IlJGaStTTmh0anRVb3ZXbk9ubzJ5QUE9PSIsInJvbGVzIjoiVVNFUiIsImlhdCI6MTY4MjMzOTE0OSwiZXhwIjoxNjgyNDI1NTQ5fQ.ktUKRi8Zx5dmFH8gxB_ZSYtoyTD1chdlcb6RIJVZdnY
기...길져? 해당 코드를 가지고
테스트를 할 때, 특히 스크래치 파일 .http 로
intellij에서 검증을 하려고 하면 너무 ... 힘듭니다. ㅠ.ㅠ
우선 위의 코드가 무엇인가 하면!
Jwt 토큰이라고 하며,로그인하면 해당 로그인의 응답값으로 위와 같은 인증 코드가 발급됩니다.
위의 보안 코드를 활용해 로그인한 유저를 확인하고 검증할 수 있습니다.
단순 로그인 Id로 검증을 하게 되면 누구나 접근을 하는 문제가 발생할 수 있습니다.
일반적으로 primaryKey를 가지는 id의 경우 Long으로 처리를 하는데
264 / 2 - 1, 만큼의 데이터를 저장할 수 있어서 충분하지만, 문제는 단순한 숫자기 때문에 해당 숫자로 로그인 로그아웃 처리를 관리하면 누구나 조금만 조작을 하면 쉽게 접근이 가능합니다.
이러한 보안이 낮은 형태의 값을 일반적인 바법으로 접근할 수 없는
방식으로 만들어 주는 방식 중 하나가 Jwt 토큰이고, 이외에도 Spring Security
입니다.
주저리는 이 정도만 해두고 책의 내용을 조금 보고 가시죠!
개발자라면 해당 내용에 관심은 없더라도 과정의 이해와 대화를 할 수 있어야 하니까요!
프로그래밍을 공부하면서 늘 생각하는 거지만 일상어들이 정말 낯설게 느껴질 때가 많다는 겁니다.
가령, 의존성 주입
이런 단어는 뭐 그렇다 하더라도, 보안 쪽은 더
인증(authentication)은 사용자가 누구인지 확인하는 단계입니다.
대표적인 예로는 제가 위에 말한 '로그인'이 있고 이를 전달하는 토큰이
제가 위에 말한 Jwt 토큰 입니다 :)
'인증'을 통해 검증된 사용자가 내부 리소스에 접근 시
권리가 있는지를 확인하는 과정으로 권한이 있는지 '유효성'을 검증하는 과정입니다.
적절하게 게시글의 수정, 삭제 등의 접근할 권한이 있는지를 확인해서
승인 또는 거절을 하는 것이죠. :)
애플리케이션의 기능을 사용하는 주체를 의미한다고 하네요.
접근 주체는 사용자, 디바이스, 시스템 등이 될 수 있습니다.
애플리케이션은 위의 과정들을 통해 접근 주체가 신뢰할 수 있는지
확인하고, 인가 과정을 통해 `접근 주체`에게 부여된 권한을 확인합니다.
뭐, 솔직히 말은 어려운데, 위의 내용들은 하나의 연속적인 과정에서 나타납니다. 그걸 저렇게 나눠 놨으니 어렵고 복잡해 보이지만
위와 같은 내용으로 서비스를 나누고 코드를 작성해야 하는 순간 또는
과정의 이해가 필요할 수 있으니 알아두고 가는 거죠 :)
다음으로 스프링 시큐리티를 알아 보겠습니다.
애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나인데 위의 과정을 직접 코딩 하지 않아도 된다니 참 좋은 것 같습니다.
구조에 관한 설명이 길게 되어 있는데 해당 내용의 이해를 간단하게만 소개하고자 합니다. 필터체인
이라는 것을 활용해 이를 활용합니다.
관련해서 WebSecurityConfigurerAdapter
클래스를 상속해야 합니다.
이 부분을 주목해야 합니다. 특히 인텔리제이에서 상속받을 때
WebSecurityConfigure
로 잘못 자동완성될 수 있기 때문에
꼭 이 부분을 확인하셔야 합니다!
필터의 실행순서에 관한 내용이 다른 곳에 잘 안 나오는 유의미한 자료라 정리해 보았습니다.
일단 왜 정리를 잘 안하나,
그리고 처음 말씀드렸던 사람들이 도망간다는 내용을 짧게 보고 가시죠!
짱...복잡하죠...ㅎ
자 이제 스압이 올 겁니다. 아래가 필터의 실행 순서입니다.
- ChannelProcessingFilter
- WebAsyncManagerIntegerationFilter
- SecurityContextPersitenceFilter
- HeaderWriterFilter
- CorsFilter
- CsrfFilter
- LogoutFilter
- OAuth2AuthorizationRequestRedirectFilter
- Saml2WebSsoAuthenticationRequestFilter
- X509AuthenticationFilter
- AbstractPreAuthenticatedProcessingFilter
- CasAuthenticationFilter
- OAuth2LoginAuthenticationFilter //이거 책에는 오타.
- Saml2WebSsoAuthenticationFilter
- UsernamePasswordAuthenticationFilter
- OpenIDAuthenticationFilter
- DefaultLoginPageGeneratingFilter
- DefaultLogoutPageGeneratingFilter
- ConcurrentSessionFilter
- DigestAuthenticationFilter
- BearerTokenAuthenticationFilter
- BasicAuthenticationFilter
- SecurityContextHolderAwareRequestFilter
- JaasApiIntergrationFilter
- RememberMeAuthenticationFilter
- AnonymousAuthenticationFilter
- OAuth2AuthorizationCodeGrantFilter
- SessionManagementFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
- SwitchUserFilter
왜 정리를 안 하는지는 위의 내용의 어마어마한 양만 봐도 알 수 있습니다.
엄청난 인사이트가 있는 것도 아니지만, 추후에 해당 내용이 자연스럽게 몸에 익는다고 생각하면 될 거 같습니다. 연산자가 몸에 자연스럽게 익듯이요.
그럼 이제 JWT를 알아 보겠습니다. 해당 내용이 주요합니다 :)
JWT는 UIRL로 이용할 수 있는 문자열로만 구성
돼 있습니다.
디지털 서명이 적용
돼 있어 신뢰
할 수 있구요.
JWT는 제가 가장 먼저 올리고 위에 쭈욱 이야기 해온 내용의 핵심입니다.
로그인을 통해 아이디와 비밀번호가 매칭되면 해당 데이터가
일회용으로 사용할 수 있는 JWT 토큰이 발급되고,
해당 토큰을 기반으로 다른 페이지의 접근 및 권한을 가집니다 :)
JWT의 구조는 점('.')
으로 구분이 됩니다.
점에 따라
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJldUxqVlJKSzhrcTV2OXJQdnowbjNRLzRRM08xMTZZTkdrbkpsckdpZlJrPSIsImp0aSI6IlJGaStTTmh0anRVb3ZXbk9ubzJ5QUE9PSIsInJvbGVzIjoiVVNFUiIsImlhdCI6MTY4MjMzOTE0OSwiZXhwIjoxNjgyNDI1NTQ5fQ.ktUKRi8Zx5dmFH8gxB_ZSYtoyTD1chdlcb6RIJVZdnY
간단하게 내용을 정리해 보면
헤더(Header)
헤더의 경우, SHA256 또는 RSA를 사용해 암호화 되고, 완성은 Base64Url 형식으로 인코딩 됩니다 :)
내용(Payload)
이 곳에 포함된 속성들은 클레임(Claim)이라 한다고 합니다.(이것도 익숙하지 않게 느껴지더라구요.)
- 등록된 클레임(Registered Claims)
- 공개 클레임(Public Claims)
- 비공개 클레임(Private Claims)
위와 같은 속성을 가집니다. :) 필수는 아니지만 관련해 상태를 확인할 수 있는 정보 입니다.
서명(Signature)
인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성됩니다. 즉, 앞의 값들을 참고해서 만들어지는 것이죠 :)
JWT 사용하기
의존성 주입.
gradle
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
maven
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
참고 사이트
https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt/0.9.1
해당 페이지가 기본적인 확인 페이지여서 위를 사용할 줄 아는게 주요해서 :) 공유합니다. :) 버전을 누르고 아래로 가면 됩니다 :)
Spring Security
를 사용하기 위한 의존성도 함께 알아 보죠 :)
gradle
implementation group: 'org.springframework.security', name: 'spring-security-web'
maven
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-web</artifactId>
</dependency>
참고 사이트 https://mvnrepository.com/artifact/org.springframework.security/spring-security-web/6.0.3
gradle의 경우 형태가 조금 다를 수 있습니다.
인텔리제이로 만들면요 :)
User
엔티티 생성
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {
private static final long serialVersionUID = 6014984039564979072L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false, unique = true)
private String uid; // 회원 ID (JWT 토큰 내 정보)
@JsonProperty(access = Access.WRITE_ONLY) // Json 결과로 출력하지 않을 데이터에 대해 해당 어노테이션 설정 값 추가
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String name;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
/**
* security 에서 사용하는 회원 구분 id
* @return uid
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public String getUsername() {
return this.uid;
}
/**
* 계정이 만료되었는지 체크하는 로직
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 계정이 잠겼는지 체크하는 로직
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 계정의 패스워드가 만료되었는지 체크하는 로직
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 계정이 사용가능한지 체크하는 로직
*/
@JsonProperty(access = Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
UserRepository
는 아래와 같이 Uid를 가져올 수 있는 형태로 구현합니다.
import com.springboot.security.data.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
User getByUid(String uid);
}
UserDetailsServiceImpl
의 구현
해당 서비스의 구현은 레포지토리를 통해 User 엔티티의 id를 가져오는 서비스를 생성합니다. :)
import com.springboot.security.data.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) {
LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
return userRepository.getByUid(username);
}
}
위까지가 SpringSecurity를 사용하기 위한 설정입니다 :)
다음으로 JwtToken을 가져오는 걸 생성해 보죠 :)
JwtTokenProvider
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
/**
* JWT 토큰을 생성하고 유효성을 검증하는 컴포넌트 클래스
* JWT 는 여러 암호화 알고리즘을 제공하고 알고리즘과
* 비밀키를 가지고 토큰을 생성
* claim 정보에는 토큰에 부가적으로 정보를 추가할 수 있음
* claim 정보에 회원을 구분할 수 있는 값을 세팅하였다가
* 토큰이 들어오면 해당 값으로 회원을 구분하여 리소스 제공
* JWT 토큰에 expire time을 설정할 수 있음
*/
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
private final UserDetailsService userDetailsService; // Spring Security 에서 제공하는 서비스 레이어
@Value("${springboot.jwt.secret}")
private String secretKey = "secretKey";
private final long tokenValidMillisecond = 1000L * 60 * 60; // 1시간 토큰 유효
/**
* SecretKey 에 대해 인코딩 수행
*/
@PostConstruct
protected void init() {
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
System.out.println(secretKey);
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
System.out.println(secretKey);
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
// JWT 토큰 생성
public String createToken(String userUid, List<String> roles) {
LOGGER.info("[createToken] 토큰 생성 시작");
Claims claims = Jwts.claims().setSubject(userUid);
claims.put("roles", roles);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret 값 세팅
.compact();
LOGGER.info("[createToken] 토큰 생성 완료");
return token;
}
// JWT 토큰으로 인증 정보 조회
public Authentication getAuthentication(String token) {
LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}",
userDetails.getUsername());
return new UsernamePasswordAuthenticationToken(userDetails, "",
userDetails.getAuthorities());
}
// JWT 토큰에서 회원 구별 정보 추출
public String getUsername(String token) {
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody()
.getSubject();
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
return info;
}
/**
* HTTP Request Header 에 설정된 토큰 값을 가져옴
*/
public String resolveToken(HttpServletRequest request) {
LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
return request.getHeader("X-AUTH-TOKEN");
}
// 예제 13.16
// JWT 토큰의 유효성 + 만료일 체크
public boolean validateToken(String token) {
LOGGER.info("[validateToken] 토큰 유효 체크 시작");
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
LOGGER.info("[validateToken] 토큰 유효 체크 완료");
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
}
어우 뭐가 많죠... 그런데 저런 형태가 공식적인 형태 중 하나입니다.
위에서는 LOGGER를 통해 확인하고 있는데요.
학습을 하면서 해당 내용까지 원활하게 이해하시게 되셨으면 좋겠습니다.
뭐 저도 원활하게 이해가 된다기 보다는 익숙해져서
아, logger로 해서 구현했구나. 이런 게 보이네요 :)
예외 처리는 try catch로만 했네요 :)
이제 필터 체인 위에 엄청 스압을 주기 시작했던 Filter 관련한 클래스를 구현해 보겠습니다.
JwtAuthenticationFilter
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest servletRequest,
HttpServletResponse servletResponse,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(servletRequest);
LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
위는
OncePerRequestFilter
를 상속 받아 구현했습니다.
GenericFilterBean
를 상속받아 구현한 경우
public class JwtAuthenticationFilter extends GenericFilterBean{
private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider){
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest servletRequest, HttpServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken((HttpServletRequest) servletRequest);
LOGGER.info("[doFilterInternal] token 값 추출 완료, token: {}", token);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
if(token != null && jwtTokenProvider.validateToken(token)){
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
2개의 차이가 조금 있습니다.
GenericFilterBean
은 기존 필터에서 가져올 수 없는 스프링의 설정 정보를 가져올 수 있게 확장된 추상 클래스 입니다.
다만 서블릿은 사용자의 요청을 받으면 서블릿을 생성해서 메모리에 저장하고 동일한 클라이언트의 요청을 받으면 재활용하는 구조
GenericFilterBean
->OncePerRequestFilter
이렇게
GenericFilterBean
이 부모인 관계입니다.
더 상위의 개념인 셈이죠. 하지만GenericFilterBean
은 RequestDispatcher에 의해 다른 서블릿으로 디스패치 되면서 필터가 두번 실행
될 수 있습니다.
-> 이를 해결하기 위해 등장한 것이 OncePerRequestFilter
이고
한 번
만 실행되는 걸 구현한 셈입니다.
해당 서적에서는 OncePerRequestFilter
를 통한 구현만을 다루고 있습니다.
그 전까지는 스프링 시큐리티를 적용하기 위한 컴포넌트 구현하였음
스프링 시큐리티 관련 설정을 진행하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것
다음으로 SecurityConfiguration
를 구현해 보겠습니다.
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
@Autowired
public SecurityConfiguration(JwtTokenProvider jwtTokenProvider){
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception{
httpSecurity.httpBasic().disable()
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/sign-api/sign-in", "/sign-api/sign-up",
"/sign-api/exception").permitAll()
.antMatchers(HttpMethod.GET, "/product/**").permitAll()
.antMatchers("**exception**").permitAll()
.anyRequest().hasRole("ADMIN")
.and()
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
}
@Override
public void configure(WebSecurity webSecurity){
webSecurity.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "/webjars/**", "/swagger/**", "/sign-api/exception");
}
}
지금까지 스프링 시큐리티를 적용하기 위한 컴포넌트
를 구현했습니다.
스프링 시큐리티 관련 설정을 진행하는 대표적인 방법은 WebSecurityConfigureAdapter
를 상속받는 Configuration
(어노테이션) 클래스 구현
위에서 가장 먼저 보이는 어노테이션이죠 :)
관련한 설정의 대부분은 HttpSecurity
를 통해 진행하며 대표적 기능은 아래 4가지가 있습니다.
이번엔 메서드에 관해 살표 보죠 :) 모든 설정은 전달 받은 HttpSecurity
에서 설정합니다.
httpBasic().disable()
UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화
csrf().disable()
REST API에서는 CSRF(사이트 간 요청 위조) 보안이 필요 없기 때문에 비활성화 하는 로직
CSRF는 Cross-Site Request Forgery 줄임말로 '사이트 간 요청 위조' 의미
'사이트 간 요청 위조'란 웹 애플리케이션의 취약점 중 하나.
사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로
공격자가 의도한 행동을 함으로써
특정 페이지의 보안을 취약하게 한다거나 수정, 삭제 등 작업을 하는 공격 방법.
스프링 시큐리티의 csrf() 메서드는 기본적으로 CSRF 토큰을 발급
클라이언트로부터 요청을 받을 때 마다 토큰을 검증하는 방식으로 동작
브라우저 사용환경이 아니면 비활성화 해도 크게 문제는 없습니다.
얘만 왤케 긴 거죠...
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
RESTAPI 기반 애플리케이션의 동작 방식 설정
현재는 JWT 토큰으로 인증을 처리
세션을 사용하지 않기에 STATELESS로 진행
authorizeRequest()
애플리케이션에 들어오는 요청에 대한 사용 권한 체크
이어서 사용한 antMatchers() 메서드는 antPattern을 통해 권한 설정
- "/sign-api/sign-in",
"/sign-api/sign-up",
"/sign-api/exception" 경로에 대해 모두에게 허용
- /product로 시작하는 경로의 GET요청은 모두 허용
- exception 단어가 들어간 경로는 모든 허용
- 기타 요청은 인증된 권한을 가진 사용자에게 허용
exceptionHandling().accessDeniedHandler()
권한을 확인하는 과정에서 통과하지 못하는 예외가 있을 경우 예외 전달
exceptionHandling().authenticationEntryPoint()
인증 과정에서 예외가 발생하는 경우 예외 전달
각 메서드는 CustomAccessDeniedHandler
와 CustomAuthenticationEntryPoint
로 예외를 전달
스프링 시큐리티는 각각의 역할을 수행하는 필터들이 체인 형태로 구성
돼 순서대로 동작
addFilterBefore()
메서드를 통해 어느 필터 앞에 추가할지 설정
위 코드에서는 스프링 시큐리티에서 인증을 처리하는 필터인 UsernamePasswordAuthenticationFilter 앞에 앞에서 생성한 JwtAuthenticationFilter를 추가하겠다는 의미
WebSecurity를 사용하는 configure() 메서드
WebSecurity는 HttpSecurity 앞단에 적용
전체적으로 스프링 시큐리티 영향 밖
즉 인증과 인가가 모두 적용되기 전에 동작하는 설정
인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용
위 코드는 Swagger 관련된 경로에 대한 예외 처리를 수행 합니다. 즉 인증과 인가를 무시하는 경로를 설정하는 것이죠 :)
앞선 과정에서 인증과 인가 과정의 예외 상황에서AccessDeniedHandler
,
AuthenticationEntryPoint
로 예외를 전달 중이었습니다.
이를 구체적으로 구현해 보겠습니다. :)
기본적인 예외가 아닌, 직접 만든 예외인데,
한 번 다룬 적이 있는 내용인데 자세히 있어서
한 번 더 공유합니다.
AccessDeniedHandler
interface 구현체 클래스
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
AccessDeniedException
엑세스 권한이 없는 리소스에 접근 시 발생하는 예외
이를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용
SecurityConfiguration에도
exceptionHandling() 메서드를 통해 추가
handle()
메서드를 오버라이딩 했습니다.
response
에서 리다이렉트하는
sendRedirect()메서드
를 활용하는 방식으로 구현했습니다.
접근이 막혔을 경우
경로 리다이렉트 출력 후 다른 스레드에서 동작
인증이 실패한 상황을 처리하는 AuthenticationEntryPoint interface를 CustomAuthenticationEntryPoint
구현한 클래스
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class)
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ObjectMapper objectMapper = new ObjectMapper();
LOGGER.info("[commence] 인증 실패로 response.sendError 발생");
EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
entryPointErrorResponse.setMsg("인증이 실패하였습니다");
response.setStatus(401);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
}
}
위 예제에서 사용한 EntryPointErrorResponse
는
아래와 같이 생성 합니다. :)
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}
구조가 앞선 AccessDeniedHandler와 크게 다르지 않으며, commence()
을 오버라이딩.
commence()
메서드는 HttpServletReques
, HttpServletResponse
, AuthenticationException
을 매개변수로 받으며,
책에서는 리다이렉트가 아니라, 예외 처리를 위해 리다이렉트가 아니라
직접 Response
를 생성해서 클라이언트에게 응답하는 방식으로 구현
컨트롤러에서 응답을 위한 설정
자동으로 구현해서 별도의 작업을 하지 않았지만
여기서 응답값을 설정해 주어야 할 필요가 있습니다.
메세지 담기 위해 EntryPointErrorResponse
객체 사용해 메세지 설정
response에 상태 코드(status)
와
콘텐츠 타입(Content-type)
을 설정 후
ObjectMapper
를 사용
EntryPointErrorResponse
객체를 바디 값으로 파싱합니다.
메시지를 설정할 필요가 없다면 commence()
메서드 내부에 아래와 같이 한 줄만 작성하면 됩니다.
@Override
public void commence(HttpServletRequest request, HttpServletResponse response
, AuthenticationException ex) throws IOExcpetion{
reponse.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
자 이제 Spring Security의 구현을 마무리 짓는
과정을 하겠습니다 :)
아래의 내용은 정말 많이 활용하니 저장해서 활용하시면 좋습니다 :)
서비스 레이어 부분의 구현입니다. Sign 부분입니다.
public interface SignService {
SignUpResultDto signUp(String id, String password, String name, String role);
SignInResultDto signIn(String id, String password) throws RuntimeException;
}
SignService, interface를 구현하는 클래스.
@Service
public class SignServiceImpl implements SignService {
private final Logger LOGGER = LoggerFactory.getLogger(SignServiceImpl.class);
public UserRepository userRepository;
public JwtTokenProvider jwtTokenProvider;
public PasswordEncoder passwordEncoder;
@Autowired
public SignServiceImpl(UserRepository userRepository, JwtTokenProvider jwtTokenProvider,
PasswordEncoder passwordEncoder){
this.userRepository = userRepository;
this.jwtTokenProvider = jwtTokenProvider;
this.passwordEncoder = passwordEncoder;
}
@Override
public SignUpResultDto signUp(String id, String password, String name, String role) {
LOGGER.info("[getSignUpResult] 회원 가입 정보 전달");
User user;
if(role.equalsIgnoreCase("admin")){
user = User.builder()
.uid(id)
.name(name)
.password(passwordEncoder.encode(password))
.roles(Collections.singletonList(password))
.build();
} else{
user = User.builder()
.uid(id)
.name(name)
.password(passwordEncoder.encode(password))
.roles(Collections.singletonList("ROLE_USER"))
.build();
}
User savedUser = userRepository.save(user);
SignUpResultDto signUpResultDto = new SignInResultDto();
LOGGER.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과 주입");
if(!savedUser.getName().isEmpty()){
LOGGER.info("[getSignUpResult] 정상 처리 완료");
setSuccessResult(signUpResultDto);
} else{
LOGGER.info("[getSignUpResult] 실패 처리 완료");
setFailResult(signUpResultDto);
}
return signUpResultDto;
}
@Override
public SignInResultDto signIn(String id, String password) throws RuntimeException {
LOGGER.info("[getSignInResult] signDataHandler로 회원 정보 요청");
User user = userRepository.getByUid(id);
LOGGER.info("[getSignInResult] Id : {}", id);
LOGGER.info("[getSignInResult] 패스워드 비교 수행");
if(!passwordEncoder.matches(password, user.getPassword())){
throw new RuntimeException();
}
LOGGER.info("[getSignInResult] 패스워드 일치");
LOGGER.info("[getSignInResult] SignInResultDto 객체 생성");
SignInResultDto signInResultDto = SignInResultDto.builder()
.token(jwtTokenProvider.createToken(String.valueOf(user.getUid()),
user.getRoles()))
.build();
LOGGER.info("[getSignInResult] SignInResultDto 객체에 값 주입");
setSuccessResult(signInResultDto);
return signInResultDto;
}
private void setSuccessResult(SignUpResultDto result){
result.setSuccess(true);
result.setCode(CommonResponse.SUCCESS.getCode());
result.setMsg(CommonResponse.SUCCESS.getMsg());
}
private void setFailResult(SignUpResultDto result){
result.setSuccess(false);
result.setCode(CommonResponse.FAIL.getCode());
result.setMsg(CommonResponse.FAIL.getMsg());
}
}
회원가입과 로그인을 구현하기 위해 세가지 객체에 대한 의존성 주입 받습니다.
signUp(회원가입 구현)
ADMIN및 USER 권한으로 구분된 role 객체를 확인
User 엔티티의 roles 변수에 추가해서 엔티티 생성
패스워드는 암호화해서 저장
하는 스킬은 PasswordEncoder
를 활용! 비밀번호가 그대로 드러난 상태로 유출되면 안 되니까요!
PassWordEncoder는 별도의 @Configuration 클래스
를 생성 @Bean 객체로 등록
구현
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
위 코드는 빈 객체를 등록하기 위해서 생성된 클래스입니다.
SecurityConfiguration
클래스처럼 이미 생성된 @Configuration 클래스 내부에
passwordEncoder() 메서드만 정의해도 충분합니다 :)
이렇게 생성된 엔티티를 UserRepository
에 저장
이후 SignIn 메서드
에서 로그인 기능 구현
로그인 : 미리 저장돼 있는 계정 정보
요청을 통해 전달된 계정 정보가 일치하는지 확인
SignIn 내부 로직
1. id를 기반으로 UserRepository에서 User엔티티를 가져옴
2. PassWordEncoder를 사용해 DB에 저장돼 있던 패스워드와 입력받은 패스워드가 일치하는지 확인
위에선 RuntimeException을 사용했지만 별도의 커스텀 예외로 처리하는 경우가 실무에서 잘 사용하는 방식입니다.
3. 패스워드 일치해 인증을 통과하면 JwtTokenProvider를 통해 id와 role 전달해
토큰 생성 후 Response
에 담아 전달.
아래는 코드 마지막 부분에 사용된 CommonResponse
enum (열거체) class 입니다.
public enum CommonResponse {
SUCCESS(0, "Success"), FAIL(-1, "Fail");
int code;
String msg;
CommonResponse(int code, String msg){
this.code = code;
this.msg = msg;
}
public int getCode(){
return code;
}
public String getMsg(){
return msg;
}
}
이제 회원가입
과 로그인을
API로 노출
하는 컨트롤러 생성해야 하는데,
사실상 서비스 레이어로 요청을 전달하고 응답하는 역할만 수행해서 책에서는 코드만 소개합니다. :)
SignController
@RestController
@RequestMapping("/sign-api")
public class SignController {
private final Logger LOGGER = LoggerFactory.getLogger(SignController.class);
private final SignService signService;
@Autowired
public SignController(SignService signService) {
this.signService = signService;
}
@PostMapping(value = "/sign-in")
public SignInResultDto signIn(
@ApiParam(value = "ID", required = true) @RequestParam String id,
@ApiParam(value = "Password", required = true) @RequestParam String password)
throws RuntimeException {
LOGGER.info("[signIn] 로그인을 시도하고 있습니다. id : {}, pw : ****", id);
SignInResultDto signInResultDto = signService.signIn(id, password);
if (signInResultDto.getCode() == 0) {
LOGGER.info("[signIn] 정상적으로 로그인되었습니다. id : {}, token : {}", id,
signInResultDto.getToken());
}
return signInResultDto;
}
@PostMapping(value = "/sign-up")
public SignUpResultDto signUp(
@ApiParam(value = "ID", required = true) @RequestParam String id,
@ApiParam(value = "비밀번호", required = true) @RequestParam String password,
@ApiParam(value = "이름", required = true) @RequestParam String name,
@ApiParam(value = "권한", required = true) @RequestParam String role) {
LOGGER.info("[signUp] 회원가입을 수행합니다. id : {}, password : ****, name : {}, role : {}", id,
name, role);
SignUpResultDto signUpResultDto = signService.signUp(id, password, name, role);
LOGGER.info("[signUp] 회원가입을 완료했습니다. id : {}", id);
return signUpResultDto;
}
@GetMapping(value = "/exception")
public void exceptionTest() throws RuntimeException {
throw new RuntimeException("접근이 금지되었습니다.");
}
@ExceptionHandler(value = RuntimeException.class)
public ResponseEntity<Map<String, String>> ExceptionHandler(RuntimeException e) {
HttpHeaders responseHeaders = new HttpHeaders();
//responseHeaders.add(HttpHeaders.CONTENT_TYPE, "application/json");
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
LOGGER.error("ExceptionHandler 호출, {}, {}", e.getCause(), e.getMessage());
Map<String, String> map = new HashMap<>();
map.put("error type", httpStatus.getReasonPhrase());
map.put("code", "400");
map.put("message", "에러 발생");
return new ResponseEntity<>(map, responseHeaders, httpStatus);
}
}
클라이언트는 위와 같이 계정을 생성하고 로그인 과정을 거쳐 토큰값을 전달받음으로써 이 애플리케이션에서 제공하는 API 서비스를 사용할 준비를 마칩니다.
Response
로 전달되는 SignUpResultDto
클래스는
아래와 같습니다.
SignUpResultDto
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignInResultDto extends SignUpResultDto {
private String token;
@Builder
public SignInResultDto(boolean success, int code, String msg, String token) {
super(success, code, msg);
this.token = token;
}
}
SignInResultDto
는 아래와 같습니다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {
private boolean success;
private int code;
private String msg;
}
여기까지가!
스프링 시큐리티가 동작하는
애플리케이션 환경의 완성입니다.
어후 기네요... 그리고 여기서 끝내겠습니다.
남은 걸로는
테스트 관련해서는 Swagger를 활용해서
결과를 정리한 걸 책에서 소개하는데
요게 크게 의미가 있진 않아서 패스합니다...
이걸로 스프링부트 핵심 가이드는 끝내겠습니다.
Clean Code
스프링 퀵 스타트
Effective Java
라는 개인적으로 가지고 있고,
또 부트캠프에서 진행하는 책들을 정리해 보겠습니다 :)