이번 장에서는 서비스와 보안에 관련하여 정리를 진행하려고 한다.
스프링에서 보안을 적용하고 싶을 때는 스프링 시큐리티(Spring Security)를 사용한다.
스프링 시큐리티를 통해서 토큰을 활용하여 요청을 처리하는 보안 기법에 대해서 공부해 보겠다.
스프링 시큐리티를 이해하기 위해서는 먼저 보안에 관련된 용어들에 대해서 공부할 필요가 있다.
먼저 용어들에 대해서 정리해 보고 시작하겠다.
인증은 사용자가 누구인지 확인하는 단계를 말한다.
인증의 대표적인 예시로는 아이디와 비밀번호를 이용하는 로그인이 있다.
로그인에 성공하면 애플리케이션의 서버는 응답으로 사용자에게 토큰(Token)을 전달한다. 이 토큰을 통해서 사용자는 원하는 리소스에 접근할 수 있다.
인가(Authorization)는 인증을 통해서 접근한 사용자가 내부 리소스에 접근할 때 그 리소스에 대해서 권한이 있는지 확인하는 과정을 말한다. 예를 들면 로그인한 사용자가 특정 게시판에 접근할 때, 그 게시판에 대해 접근 여부를 결정하는 것이 대표적이다.
일반적으로 인증시에 발급 받게 되는 토큰에는 인가 내용을 포함하고 있다. 사용자가 리소스에 접근할 때 이 토큰 정보를 함께 전달하면 서버는 토큰으로 권한을 확인하여 인가를 수행한다.
접근 주체(Principal)은 애플리케이션을 사용하는 주체를 말한다. 접근 주체는 사용자, 디바이스, 시스템 등이 될 수 있으며 애플리케이션은 인증을 통해 접근 주체를 확인하고 인가 과정을 통해서 접근 주체에 부여된 권한을 확인한다.
스프링 시큐리티는 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링의 하위 프로젝트이다. 스프링 시큐리티를 통해서 보안과 관련한 많은 기능을 편하게 활용하여 기능을 설계할 수 있다.
스프링 시큐리티는 서블릿 필터를 기반으로 동작한다.
클라이언트 ↔ 필터체인 (필터&서블릿) ↔ Dispatcher Servlet ↔ 핸들러 매핑, Rest Controller, HTTP응답, MessageConverter
위는 서블릿 필터의 배치이다.
필터체인은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미한다. 클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑한다. 스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 DelegatingFilterProxy를 사용한다.
클라이언트 ↔ 필터체인(필터↔DelegatingFilterProxy(필터체인 프록시) /↔ 보안 필터 체인/)
위는 DelegatingFilterProxy 내 FilterChainProxy의 구조이다.
DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트 사이에서 중간 역할을 수행하는 필터 구현체다. 표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시를 내부에 가지고 있다. 필터체인 프록시는 스프링 부트의 자동설정에 의해 자동 생성된다.
필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인을 통해 보안 필터를 사용할 수 있다. 필터체인 프록시에서 사용할 수 있는 보안필터 체인은 List 형식으로 담을 수 있게 설정되어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용하게 된다.
보안필터 체인에서 사용하는 필터의 종류는 여러개며, 각 필터마다 실행되는 순서가 다르다. 필터의 순서는 공식문서를 참고하여 확인해보자.
보안필터 체인은 WebSecurityCinfigurerAdapter 클래스를 상속받아서 설정할 수 있다. 필터체인 프록시는 여러 보안 필터체인을 가질 수 있는데, 여러 보안필터 체인을 만들기 위헤서는 WebSecurityCinfigurerAdapter 클래스를 상속받는 클래스를 여러개 생성하면 된다. 이때 WebSecurityCinfigurerAdapter 클래스에 @Order 어노테이션을 통해서 우선순위를 지정하는데, 2개 이상의 클래스를 생성했을 때 우선순위를 동일하게 설정하면 예외가 발생하기 때문에 @Order 어노테이션으로 순서를 정의하는 것이 중요하다.
별도의 설정이 없다면 SecurityFilterChain에서 사용하는 필터 중 UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다.
< UsernamePasswordAuthenticationFilter를 통한 인증 과정>
위 과정에서 UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경우 로그인 폼 화면을 보내는 역학을 수행한다. 이번 장의 프로젝트는 별도의 화면이 없는 RESTFul 애플리케이션이기 때문에 다른 필터에서의 인증, 인가 처리를 해야 한다. 그래서 JWT 토큰을 사용하여 JWT와 관련된 필터를 생성하고 UsernamePasswordAuthenticationFilter 앞에 먼저 배치하여 먼저 인증을 수행할 수 있도록 하겠다.
JWT(JSON Web Token)은 당사자 간 정보를 JSON 형태로 안전하게 전송하기 위한 토큰이다.
JWT는 URL로 사용 가능한 문자열로만 구성되어 있으며, 디지털 서명이 적용되어 신뢰할 수 있다. JWT는 서버와의 통신에서 권한 인가를 위해 사용된다. URL로 사용할 수 있는 문자열로만 구성되어 있어 HTTP 구성 요소 어디든 위치 가능하다.
JWT는 다음과 같이 ( . )을 기준으로 3부분으로 구성된다.
xxxxx . yyyyy . zzzzz
(헤더) (내용) (서명)
▶ 헤더
JWT의 헤더는 검증과 관련된 내용을 담고 있다. 헤더에는 alg와 typ 속성 2가지 정보를 포함하고 있다.
{
"alg": "HS256",
"typ": "JWT"
}
alg 속성은 해싱 알고리즘을 지정한다. 해싱 알고리즘은 보통 SHA256 혹은 RSA를 사용하며, 토큰을 검증할 때 서명부분에 사용된다. HS256은 HMAC SHA256 알고리즘을 사용한다는 뜻이다.
typ 속성은 토큰의 타입을 지정한다.
▶ 내용
JWT의 내용은 토큰에 담는 정보를 포함한다. 내용에 포함된 속성들을 클레임(Claim)이라고 하며, 크게 다음 세가지로 분류된다.
등록된 클레임은 필수는 아니지만 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻한다. 등록된 클레임은 아래와 같이 정의되어 있다.
공개 클레임의 키 값은 충돌이 발생하지 않는 이름이라면 마음대로 정의할 수 있다.
비공개 클레임은 통신 간 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미한다.
{
"sub": "wikibooks payload",
"exp": "1602076408",
"userId": "wikibooks",
"username": "flature"
}
▶ 서명
JWT의 서명은 인코딩된 헤더 및 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성된다.
예로 HMAC SH256 알고리즘을 사용하여 서명을 생성하면 아래와 같이 생성된다.
HMACSHA256(
base64UrlEncode(header) + " . " +
base64UrlEncode(payload)
secret
)
서명은 토큰의 값들을 포함해서 암호화하기 때문에 메세지가 중간에 변경되지 않았는지 확인할 때 사용한다.
JWT 공식 사이트를 통해서 JWT를 더 쉽게 생성해 볼 수 있다.
사이트에 접속하면 위와 같은 화면을 확인할 수 있다. Encoded와 Decoded로 나누어져 있으며, 양측의 내용이 일치하는지 사이트에서 확인할 수 있다. Decoded의 내용을 변경하면 Encoded가 자동으로 반영된다.
프로젝트를 생성하여 스프링 시큐리티와 JWT 적용해 실습해 보겠다.
먼저 pom.xml에 다음과 같은 의존성을 추가해 준다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
먼저 사용자 정보를 담을 수 있는 엔티티 User.java를 생성한다.
@Entity
@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "product")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@Column(nullable = false, unique = true)
private String uid;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@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());
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public String getUsername() {
return this.uid;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
User 엔티티는 UserDetails 인터페이스를 구현한다. UserDetails는 UserDetailService를 통해 입력된 로그인 정보로 데이터베이스에서 사용자 정보를 가져온다. UserDeatils는 다음과 같은 메서드를 가진다.
이번 예제 프로젝트에서 상태 변경은 다루지 않을 예정으로 true로 리턴한다. 이 User엔티티는 이제 토큰을 생성할 때 토큰의 정보로 사용될 정보와 권한 정보를 가진다.
이제 이 엔티티를 조회하기 위해 리포지토리와 서비스를 구현해보겠다.
public interface UserRepository extends JpaRepository<User, Long> {
User getByUid(String uid);
}
( ↑ UserRepository.java)
@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);
}
}
( ↑ UserDetailsServiceImpl.java )
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
( ↑ UserDetailsService.java )
UserDetailsServiceImpl는 UserDetailsService의 인터페이스를 구현하고 있다. UserDetails는 스프링 시큐리티에서 제공하는 개념으로 UserDetails의 username은 사용자를 구분할 수 있는 ID를 의미한다. username을 가지고 UserDetails 객체를 반환하게끔 정의되어 있다. UserDetails의 구현체로는 현재 User 엔티티를 생성했기 때문에 User 객체를 반환하도록 구현되어 있는 것이다.
JWT 토큰을 생성하는데 필요한 정보를 UserDetails에서 가져왔다. 그렇다면 이제는 JWT 토큰을 생성하기 위해서 JwtTokenProvider를 생성해보자.
public class JwtTokenProvider {
private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
private final UserDetailsService userDetailsService;
@Value("${springboot.jwt.secret}")
private String secretKey = "secretKey";
private final long tokenValidMillisecond = 1000L * 60 * 60;
@PostConstruct
protected void init() {
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
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)
.compact();
LOGGER.info("[createToken] 토큰 생성 완료");
return token;
}
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());
}
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;
}
public String resolveToken(HttpServletRequest request) {
LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
return request.getHeader("X-AUTH-TOKEN");
}
public boolean validateToken(String token) {
LOGGER.info("[validateToken] 토큰 유효 체크 시작");
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
}
( ↑ JwtTokenProvider.java)
토큰을 생성하기 위해서는 secretKey가 필요하기 때문에 값을 정의해준다. @value의 값은 application.properties 파일에서 아래와 같이 정의할 수 있다.
springboot.jwt.secret=flature!@#
JwtTokenProvider에 작성되어 있는 init()메서드에서 사용한 @PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드를 가리킨다. JwtTokenProvider 클래스에는 @Component 어노테이션이 지정되어 있어 애플리케이션이 실행되면 자동으로 빈에 주입된다. 그때 @PostConstruct가 지정되어 있는 init() 메서드가 자동으로 실행된다. init()메서드는 sercretKey를 Base64 형식으로 인코딩한다.
인코딩한 문자는 ( flature!@# → ZmxhdHVyZSAIw==) 과 같이 변화한다.
createToken() 메서드는 JWT 토큰 내용에 값을 넣기 위해 Claims 객체를 생성한다. setSubject() 메서드를 통해 sub 속성에 값을 추가하기 위해서는 User의 uid값을 사용한다. 또한 role 값을 두어 토큰을 사용하는 사용자의 권한을 확인할 수 있다. 그리고 Jwts.builder()를 사용해 토큰을 생성한다.
getAuthentication() 메서드는 필터에서 인증이 성공했을때 SecurityContextHolder에 저장할 Authentocation을 생성하는 역할을 한다. Authentication을 구현하는 쉬운 방법은 UsernamePasswordAuthenticationToken을 사용하는 것이다. UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있는데 AbstractAuthenticationToken은 Authentication 인터페이스의 구현체다.
이 토큰 클래스를 사용하려면 초기화를 위한 UserDetails가 필요한데, 이것은 UserDetailsService를 통해 가져온다. 이때 사용되는 username값은 getUsername() 메서드를 통해서 가져온다. Jwts.parser()를 통해 secretkey()를 설정하고 클레임을 추출해 토큰을 생성할 때 넣었던 sub값을 추출한다.
resolveToken() 메서드는 HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 가져와 반환한다. 클라이언트는 헤더를 통해 애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출이 가능해진다.
validateToken() 메서드는 토큰을 전달받아 클레임의 유효기간을 확인하고 boolean 타입으로 값을 반환한다.
JwtAuthenticationFilter는 JWT 토큰으로 인증하고 SecurityHolder에 추가하는 필터를 설정하는 클래스이다.
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);
}
}
( ↑ JwtAuthenticationFilter.java)
스프링부트는 가장 손쉬운 방법으로 필터를 상속받아서 구현한다. GenericFilterBean과 OncePerRequestFilter는 대표적으로 많이 이용되는 상속객체이다.
doFilter() 메서드는 서블릿을 실행하는 메서드다. doFilter() 메서드를 기준으로 앞은 서블릿이 실행되기 전에 실행된다. 메서드의 내부 로직을 살펴 보면 JwtTokenProvider를 통해 servletRequest에서 토큰을 추출하고 토큰에 대한 유효성 검사를 진행한다. 토큰이 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가하는 작업을 수행한다.
위에서는 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
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
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);
}
}
앞의 코드를 통해 스프링 시큐리티를 적용하기 위한 컴포넌트를 구현했다면, 지금부터는 스프링 시큐리티와 관련된 설정을 진행해보겠다. 스프링 시큐리티를 설정하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것이다.
@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");
}
}
SecurityConfiguration 클래스의 주요메서드는 WebSecurity 파라미터를 받은 configure()과 HttpSecurity 파라미터를 받은 configure()메서드 두 가지이다.
스프링 시큐리티의 설정은 대부분 HttpSecurity를 통해서 진행된다. 대표적인 기능은 아래와 같다.
< HttpSecurity 파라미터 configure() 메서드 설정 코드 설명>
각 메서드는 CustomAccessDeniendHandler와 CustomAuthenticationEntryPoint로 예외를 전달한다.
스프링 시큐리티는 각각의 역할을 수행하는 필터들이 체인 형태로 구성되어 순서대로 동작한다. JWT로 인증하는 필터를 생성해 보았는데, 이 필터의 등록은 HttpSecurity 설정에서 진행하고 addFilterBefore메서드를 사용해 어느 필터 앞에 추가할지 설정한다. 현재의 설정은 UsernamePasswordAuthenticationFilter 앞에서 생성한 JwtAuthenticationFilter를 추가하겠다는 의미이다. 추가된 필터에서 인증이 정상적으로 처리되면 UsernamePasswordAuthenticationFilter는 자동으로 통과된다.
WebSecurity를 사용하는 configure()메서드는 HttpSecurity의 앞에 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있다. 인증과 인가가 적용되기 전에 동작하는 설정이다, 그렇기에 인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용한다.
인증과 인가 과정의 예외상황에서 CustomAccessDeniendHandler와 CustomAuthenticationEntryPoint로 예외를 전달하고 있는데, 이 클래스에 대해서 작성해보겠다.
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{
LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
( ↑ CustomAccessDeniendHandler.java )
AccessDeniedException은 엑세스 권한이 없는 리소스에 접근할 경우 발생하는 예외다. AccessDeniendHandler 인터페이스를 사용하여 예외를 처리할 수 있으며, SecurityConfiguration에도 exceptionHandling() 메서드를 통해 추가했다. AccessDeniendHandler의 구현 클래스인 CustomAccessDeniendHandler는 handle()메서드를 오버라이딩한다.
response에서는 sendRedirect()를 활용하는 방식으로 구현했다.
그러면 인증이 실패한 상황을 처리하는 AuthenticationEntryPoint 인터페이스를 구현한 CustomAuthenticationEntryPoint 클래스를 살펴보겠다.
public class CustomAuthenticationEntryPoint extends AuthenticationEntryPoint {
private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException ex) throws IOException {
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));
}
}
( ↑ CustomAuthenticationEntryPoint.java )
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}
( ↑ EntryPointErrorResponse.java )
앞의 AccessDeniendHandler와 크게 다르지 않고 commence() 메서드를 오버라이딩해서 구현한다. commence()는 HttpServletRequest, HttpServletResponse, AuthenticationException을 매개변수로 받는데, 위의 코드에서는 리다이렉트 대신 Response를 생성해 클라이언트에게 응답하고 있다.
여기는 컨트롤러가 아니라서 자동으로 응답을 위한 설정이 구현되지 않기 때문에 응답값을 설정해 주어야 한다. 메세지를 담기 위해 EntryPointErrorResponse 객체를 사용해 메세지를 설정하고 status와 Content-type을 설정한 후 ObjectMapper를 통해 바디값으로 파싱한다.
User 객체를 생성하기 위해 회원가입을 구현하고 User 객체로 인증을 시도하는 로그인을 구현해보겠다.
회원가입 로그인 도메인은 sign으로 통일하고 각각sign-up, sign-in으로 기능을 구분해서 구현해보자.
public interface SignService {
SignUpResultDto signup(String id, String password, String name, String role);
SignUpResultDto signIn(String id, String password) throws RuntimeException;
}
( ↑ SignService.java )
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("ROLE_ADMIN"))
.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());
}
}
( ↑ SignServiceImpl.java )
회원가입과 로그인을 구현하기 위해 세 가지 객체에 대한 의존성을 주입 받는다.
이 예제에서는 회원가입 권한을 ADMIN과 USER로 구분하고 있는데, signUp() 메서드는 그에 맞게 전달받은 role 객체를 확인해 User 엔티티에 roles 변수를 추가해 엔티티를 생성한다. 패스워드는 반드시 암호화 하기 위해서 PasswordEncoder를 활용해 인코딩을 수행한다.
@Configuration
public class PasswordEncoderCofiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
( ↑ PasswordEncoderCofiguration.java )
위의 코드들을 통해 이제 사용자의 아이디와 패스워드를 통해 로그인을 수행할 수 있다. 로그인은 미리 저장되어 있는 계정 정보와 요청을 통해 전달된 계정의 정보가 일치하는지 확인하는 작업이다. SignIn()메서드는 아이디와 패스워드를 입력받아 처리하게 된다. 더 자세한 내부 로직은 다음과 같다.
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;
}
}
( ↑ commonResponse.java )
회원가입과 로그인을 API로 노출하는 컨트롤러는 아래와 같다.
@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);
}
}
( ↑ SignController.java )
클라이언트는 위와 같이 계정을 생성하고 로그인을 통해 토큰 값을 전달 받음으로 애플리케이션에서 제공하는 API를 사용할 준비를 마치게 된다. Response로 전달되는 SignUpResultDto와 SignInResultDto클래스는 아래와 같다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {
private boolean success;
private int code;
private String msg;
}
( ↑ SignUpResultDto.java )
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignInResultDto extends SignUpResultDto{
private String token;
@Builder
public SignInResultDto(boolean sucess, int code, String msg, String token) {
super(sucess, code, msg);
this.token = token;
}
}
( ↑ SignInResultDto.java )
이렇게 스프링 시큐리티가 동작하는 애플리케이션 환경이 완성되었다.
클라이언트 입장에서 스프링 시큐리티가 동작하는 상황에서 테스트를 수행해 보겠다. Swagger를 활용하여 진행해 볼 것이다.
▶ 애플리케이션 가동 로그
애플리케이션이 가동되면 스프링 시큐리티와 관련된 빈도 초기화 되어 등록 되면서 몇가지 로그의 확인이 가능하다.
01 [INFO ] [main] cohttp://m.springboot.security.config.security.JwtTokenProvider [init] JwtTokenProvider 내 secretKey 초기화 시작
02 flature!@#
03 ZmxhdHVyZSFAIw==
04 [INFO ] [main] cohttp://m.springboot.security.config.security.JwtTokenProvider [init] JwtTokenProvider 내 secretKey 초기화 완료
JwtTokenProvider 클래스는 @Component로 등록되어 있고 @PostConstruct로 init() 메서드가 정의 되어 있다. init() 메서드는 application.properties 파일에 정의되어 있는 secretKey의 값을 가져와 인코딩하는 작업을 수행한다.
01 [INFO ] [main] org.springframework.security.web.DefaultSecurityFilterChain Will secure Ant [pattern='/v2/api-docs'] with []
02 [INFO ] [main] org.springframework.security.web.DefaultSecurityFilterChain Will secure Ant [pattern='/swagger-resources/**'] with []
03 [INFO ] [main] org.springframework.security.web.DefaultSecurityFilterChain Will secure Ant [pattern='/swagger-ui.html'] with []
04 [INFO ] [main] org.springframework.security.web.DefaultSecurityFilterChain Will secure Ant [pattern='/webjars/**'] with []
05 [INFO ] [main] org.springframework.security.web.DefaultSecurityFilterChain Will secure Ant [pattern='/swagger/**'] with []
06 [INFO ] [main] org.springframework.security.web.DefaultSecurityFilterChain Will secure Ant [pattern='/sign-api/exception'] with []
07 [INFO ] [main] org.springframework.security.web.DefaultSecurityFilterChain Will secure any request with
08 [
09 org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@350d 9d23,
10 org.springframework.security.web.context.SecurityContextPersistenceFilter@7945986a,
11 org.springframework.security.web.header.HeaderWriterFilter@436d2bb9,
12 org.springframework.security.web.authentication.logout.LogoutFilter@5b4880b3,
13 cohttp://m.springboot.security.config.security.JwtAuthenticationFilter@530a46f4,
14 org.springframework.security.web.savedrequest.RequestCacheAwareFilter@aca2a0b,
15 org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@4936fbc6,
16 org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3180131e,
17 org.springframework.security.web.session.SessionManagementFilter@62158618,
18 org.springframework.security.web.access.ExceptionTranslationFilter@20608ef4,
19 org.springframework.security.web.access.intercept.FilterSecurityInterceptor@6859bbd4 20 ]
(애플리케이션 가동 로그 - DefaultSecurityFilterChain)
DefaultSecurityFilterChain은 SecurityFilterChain 인터페이스의 구현체 클리스이다. DefaultSecurityFilterChain은 HttpSecurity에 싀해 호출되며, 생성자를 통해 사용될 Filter를 전달받는다.
DefaultSecurityFilterChain의 로그에서 1~6은 SecurityConfiguration 클래스에서 설정한 WebSecurity를 활용하는 configure() 메서드에서 제외한 경로를 표현하고 있다. 7~20은 제외된 경로 외에 모든 요청에 대해 나열된 필터를 거친다는 것을 보여준다.
▶ 정상적인 동작 시나리오
정상적으로 동작하는 시나리오 테스트의 절차는 아래와 같다.
▶ 비정상적인 동작 시나리오 - 인증 예외 발생
스프링 시큐리티 동작 확인에서 발생하는 비정상적인 동작은 인증이 실패한 경우 혹은 인가가 실패한 경우 크게 두 가지가 존재한다. 인증과정에서 예외가 발생하는 경우의 시나리오는 아래와 같다.
▶ 비정상적인 동작 시나리오 - 인가 예외 발생
Swagger를 통해 해당 시나리오들에 맞추어 입력값을 넣어주고 Response를 보며 테스트를 진행하면 된다. 테스트의 진행과정은 책에 자세히 나와 있으니 생략하도록 하겠다.
작성자: 니나노
[스프링 1팀] 12장. 서버 간 통신, [인프런] 섹션 0. 스프링 시큐리티 기본 (0) | 2023.12.29 |
---|---|
[스프링 1팀] 10-11장. 유효성 검사와 예외처리 및 액츄에이터 (0) | 2023.12.22 |
[스프링 1팀] 9장 연관관계 매핑 (1) | 2023.12.20 |
[스프링 1팀] 8장 Spring Data JPA 활용 (2) | 2023.11.24 |
[스프링1] 7장. 테스트 코드 작성하기 (0) | 2023.11.17 |