일단 씻고 나가자
[스프링 부트 핵심 가이드] 13. 서비스 인증과 권한 부여 본문
(본 포스팅은 해당 도서의 정리 및 개인 공부의 목적 포스팅임을 밝힙니다.)
장정우, 『스프링 부트 핵심 가이드 : 스프링 부트를 활용한 애플리케이션 개발 실무』, 위키북스, 2022
13. 서비스 인증과 권한 부여
회원가입 혹은 유저별 이용 구분에는 인증과 인가 등의 보안 기능이 필수이므로
이번 장에서는 보안 관련 용어, 개념 및 스프링의 보안을 담당하는 기능인 스프링 시큐리티(Spring Security)에 대해 알아본다.
단, 여태까지의 실습은 화면이 없는 REST 방식으로 진행하였기 때문에 로그인을 통한 일반적인 인증과 인가 방식이 아닌 요청마다 토큰값을 활용한 보안 기법에 대해 알아본다.
13.1 보안 용어 이해
스프링 시큐리티를 사용하기 이전에 보안과 관련된 용어를 살펴본다.
13.1.1 인증
인증(authentication)은 사용자가 누구인지 확인하는 단계로, 쉽게 로그인이라 생각하면 된다.
사용자가 입력한 아이디와 비밀번호를 데이터 베이스와 비교하여 일치 여부를 확인하고, 성공하면 서버는 응답으로 사용자에게 토큰(token)을 전달한다. 로그인에 실패한다면 토큰을 전달받지 못하고 원하는 리소스에 접근할 수 없다.
13.1.2 인가
인가(authorization)는 인증 후의 사용자가 특성 리소스에 접근할 때 그에 맞는 권한이 있는지 확인하는 과정이다.
일반적으로 인증 단계에서 발급받은 토큰에 인가 내용이 포함돼 있으며, 리소스에 접근하면 토큰을 전달하고 서버는 토큰을 통해 인가 과정을 수행한다.
13.1.3 접근 주체
접근 주체(principal)는 애플리케이션의 기능을 사용하는 주체로서, 사용자, 디바이스, 시스템 등이 될 수 있다.
애플리케이션은 인증을 통해 접근 주체가 신뢰할 수 있는지 확인하고, 인가를 통해 권한을 확인한다.
13.2 스프링 시큐리티
스프링 시큐리티는 애플리케이션의 인증, 인가 등 보안 기능을 제공하는 스프링 하위의 프로젝트이다.
해당 기능을 이용하면 편리한 기능 설계가 가능해진다.
13.3 스프링 시큐리티의 동작 구조
스프링 시큐리티는 서블릿 필터(Servlet Filter)를 기반으로 동작하며, DispatcherServlet 앞단에 필터가 배치된다.
필터는 클라이언트의 요청을 먼저 받아, 서블릿에서 매번 구현해야 하는 동작을 앞단에서 수행하고 변경된 정보를 전달한다.
참고 사이트
https://atoz-develop.tistory.com/entry/Servlet-Filter-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0
클라이언트와 DispatcherServlet 사이에 필터(필터 체인)가 존재하게 되고, DispathcerServlet은 필터로 변경된 데이터를 핸들러, RestController, MessageConverter, HTTP 응답에 전해준다.
이때 필터 체인(Filter Chain)이란 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미하며, 서블릿 컨테이너는 요청을 받으면 URI를 확인하고 필터와 서블릿을 매핑한다.
스프링 시큐리티는 사용하려는 필터 체인을 서블릿 컨테이너 필터 사이에 동작시키기 위하여 DelegatingFilterProxy를 사용한다.
DelegatingFilterProxy는 서블릿 컨테이너 생명주기와 스프링 애플리케이션 컨텍스트 사이에서 다리 역할을 수행하는 필터 구현체이며, 표준 서블릿 필터를 구현하고 역할을 위임할 필터 체인 프록시(FilterChainProxy)를 내부에 가진다. 필터 체인 프록시는 스프링 부트의 자동 설정으로 자동 생성된다.
필터 체인 프록시는 스프링 시큐리티에서 제공하는 필터로, 보안 필터 체인(SecurityFilterChain)을 통해 여러 보안 필터를 사용할 수 있게 한다. 필터 체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형태로 담을 수 있어 URI 패턴에 따라 특정 보안 필터 체인을 선택해서 사용하게 된다. 보안 필터 체인은 WebSecurityConfigurerAdapter 클래스를 상속받은 클래스로 설정할 수 있는데, 여러 보안 필터 체인을 만들기 위해선 해당 클래스를 상속받은 클래스를 여러 개 생성하면 된다. 이렇게 여러 필터 체인이 존재할 경우 @Order 어노테이션으로 우선순위를 지정해주지 않으면 예외를 발생시키며, 별도의 설정이 없다면 SecurityFilterChain의 필터 중 UsernamePasswordAuthenticationFilter를 이용해 인증을 처리한다.
UsernamePasswordAuthenticationFilter의 인증 수행 과정은 다음과 같다.
- 클라이언트로부터 요청을 받음. (HttpServletRequest 요청 객체)
- 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고, 그 중 UsernamePaswordAuthenticationFilter (위 그림에선 AuthenticationFilter에 해당)에서 인증을 처리. AuthenticationFilter는 HttpServletRequest 객체에서 username과 password를 추출하여 토큰을 생성.
- username과 password를 담고 있는 토큰을 AuthenticManager (인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager)에게 전달.
- AuthenticManager (ProviderManager)는 AuthenticationManager.authenticate를 호출하고, 이때 AuthenticationProvider 내의 authenticate 로직이 수행되며 실제 인증이 시작.
- AuthenticationProvider은 내부에서 UserDetailService를 주입받는데, 이를 통해 UserDetailService에게 토큰의 정보를 전달.
- UserDetailService는 토큰의 정보로 데이터베이스에서 일치하는 사용자를 찾아 해당 정보를 담은 UserDetails 객체를 생성.
- UserDetails 객체는 AuthenticationProvicer로 전달되어 인증을 수행.
- 인증이 성공했다면 AuthenticManager (ProviderManager)으로 권한을 담은 토큰을 전달.
- 검증된 토큰을 AuthenticationFilter에게 전달.
- AuthenticationFilter는 검증된 토큰을 SecurityContextHolder의 SecurityContext에 저장.
참고 사이트
UsernamePaswordAuthenticationFilter는 접근 권한을 확인하고 인증 실패 시 로그인 폼의 화면으로 보내는 역할을 수행하는데, 본 책의 실습은 화면이 없는 RESTful 애플리케이션이므로 JWT 토큰을 사용하여 인증을 수행할 것이다.
앞으로의 실습은 JWT 관련 필터를 생성하고 UsernamePaswordAuthenticationFilter 앞단에 배치하여, 보다 먼저 인증을 수행할 수 있도록 진행된다.
13.4 JWT
JWT는 JSON Web Token으로, 디지털 서명이 돼 있는 안전한 JSON 형태의 토큰이다.
URL로 이용할 수 있는 문자열로만 구성돼 있으며, 따라서 HTTP 구성요소 어디든 위치할 수 있다.
주로 서버와의 통신에서 권한 인가를 위해 사용된다.
13.4.1 JWT의 구조
JWT는 점으로 구분된 세 부분으로 구성된다. 앞부터 헤더(Header), 내용(Payload), 서명(Signature)을 담는다.
헤더
검증과 관련된 정보를 담는다.
JSON의 형태로 alg, typ 두 가지 속성을 담는데, 각각 해싱 함수의 종류와 토큰의 타입을 지정한다.
해싱 알고리즘은 보통 SHA256("HS256"으로 작성. HMAC SHA 256) 혹은 RSA를 사용하며, 토큰을 검증할 때 사용되는 서명 부에 활용된다.
토큰의 타입은 typ을 통해 지정하며, "typ" : "JWT" 등으로 토큰의 타입을 지정한다.
헤더는 Base64Url 형식으로 인코딩 되는데, 이때 Base64는 "A-Z, a-z, 0-9, +, /" 의 64개 문자를 사용하여 ASCII 코드를 문자열(텍스트)로 엔코딩하는 방식이며, "+, /" 두 문자는 URL로 사용하기에는 무리가 있기에 각각 "-, _"로 변환하여 작성하는 것이 Base64Url 방식이다.
참고 사이트
https://negabaro.github.io/archive/ruby-base64url
내용
토큰에 담는 정보를 포함한다.
이곳에 포함된 속성은 클레임(Claim)이라고 하며, 세 가지로 분류된다.
- 등록된 클레임(Registered Claims) : 이미 이름이 정해진 클레임. 필수는 아니지만 토큰의 정보를 담을 때 사용.
- 공개 클레임(Public Claims) : 공개용 정보를 담는 클레임. 같은 이름의 충돌을 방지하기 위해 URI 형식의 이름 사용.
- 비공개 클레임(Private Claims) : 실제 데이터인 비공개용 정보를 담는 클레임. 통신 간 상호 합의 하에 사용.
위의 JSON 코드에서 위쪽부터 각각 등록, 공개, 비공개 클레임의 예시이다.
공개 클레임은 다음과 같은 종류가 있으며, 정의에 따른 정보를 담는다.
종류 (클레임) | 원 단어 | 정의 (담기는 정보) |
iss | Issuer | 발급자 주체. 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열. |
sub | Subject | 제목. |
aud | Audience | 수신인. 요청을 처리하는 주체가 'aud' 값으로 자신을 식별하지 않으면 거부. |
exp | Expiration | 만료시간. NumericDate 형식으로 지정되어야 함. |
nbf | Not before | |
iat | Issued at | 발급된 시간. |
jti | JWT ID | 식별자. 중복 처리 방지를 위해 활용. |
완성된 내용은 Base64Url 형식으로 인코딩 된다.
참고 사이트
서명
메세지의 전송 도중 변경되지 않았는지에 대한 확인에 사용된다.
인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 합하여 생성된다.
위의 예시는 HMAC SHA256 알고리즘을 통한 서명 예시이다.
13.4.2 JWT 디버거 사용하기
JWT의 공식사이트에서 JWT 디버그에 대해 체험해볼 수 있다. (https://jwt.io/ 혹은 JSON Web Tokens - jwt.io)
해당 사이트에 접속해보자.
우측에 적은 정보를 인코딩한 값을 좌측에서 보여준다.
우측의 값을 변경하면 좌측의 값도 변하며, 해당 사이트에서 양측 내용의 일치 여부도 확인할 수 있다.
13.5 스프링 시큐리티와 JWT 적용
실습을 위해 새로운 프로젝트를 생성한다. start.spring.io 에 접속해 다음과 같이 설정하고 [Generate], IntelliJ로 open 한다.
이후 이전에 사용했던 SwaggerConfiguration 클래스를 비롯한 그 외의 다수의 클래스를 가져온다.
가져올 클래스들은 다음과 같다.
역시 import 한 경로를 가져온 이전 패키지에서 현재 패키지로 변경해주지 않으면 모든 곳에서 에러를 띄우니 유의하자.
이후 pom.xml 파일에서 다음과 같이 의존성을 추가해준다.
위의 두 의존성 springfox는 swagger를 위한 의존성이고, 밑의 두 의존성은 실습을 위한 새로운 의존성이다.
버전의 충돌 가능성도 있으니 최상단의 <parent> 의 <version> 도 2.5.2로 바꾸어주도록 하자.
이전에 언급한 것처럼 스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성돼 있지만, 해당 필터는 인증이 실패하면 로그인 폼의 화면을 전달하므로, 화면이 없는 해당 실습에서는 JWT를 사용하는 인증 필터를 구현하여 UsernamePasswordAuthenticationFilter 앞단에 배치하고 인증 주체를 변경하는 작업을 수행하는 방식으로 진행한다.
13.5.1 UserDetails와 UserDetailsService 구현
entity 패키지에 User 클래스를 생성하고 다음과 같이 작성한다.
@Entity
@Table
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
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;
}
}
아래에 @Override 된 메서드들은 모두 Security의 UserDetails를 구현하는 메서드들이며, 역할은 다음과 같다.
- getAuthorities() : 계정의 권한 목록 리턴
- getPassword() : 계정의 비밀번호 리턴
- getUsername() : 계정의 이름을 리턴 (일반적으로 아이디)
- isAccountNonExpired() : 계정의 만료 여부 리턴 (true == 만료되지 않음)
- isAccountNonLocked() : 계정의 잠김 여부 리턴 (true == 잠기지 않음)
- isCredentialNonExpired() : 계정의 비밀번호 만료 여부 리턴 (true == 만료되지 않음)
- isEnabled() : 계정의 활성화 여부 리턴 (true == 활성화 상태)
이번 실습에서는 계정의 상태 변경은 다루지 않을 것이므로 true를 반환한다.
이 엔티티는 토큰 생성 시 토큰의 정보로 사용될 정보와 권한 정보를 갖게 된다.
실습을 위해 추가적인 repository와 service 코드를 작성한다.
먼저 repository 패키지에 UserRepository를 다음과 같이 작성한다.
이후 서비스 관련 코드를 작성하기 위해 service 패키지에 UserDetailService 인터페이스를,
그리고 service 패키지 내부의 impl 패키지에 UserDetailServiceImpl 클래스를 다음과 같이 작성한다.
UserDetails는 스프링 시큐리티에서 제공하는 개념으로, UserDetails의 username을 통해 각 사용자를 구분할 수 있다. (ID의 역할)
13.5.2 JwtTokenProvider 구현
이제 UserDetails의 정보를 통해 JWT 토큰을 생성할 수 있게 되었다. JWT 토큰을 생성하는 클래스를 실습해보자.
config 패키지 내에 security 패키지를 생성하고, 다음과 같이 JwtTokenProvider 클래스를 작성한다.
package com.springboot.security.config.security;
import com.springboot.security.service.UserDetailService;
import io.jsonwebtoken.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.slf4j.*;
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.stereotype.Component;
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
private final UserDetailService userDetailService;
@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 = userDetailService.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;
}
}
}
추가적으로 @Value() 활용을 위하여 resources/application.properties에 다음과 같이 적어준다.
이제 하나씩 코드를 분석해보자.
토큰을 생성하기 위한 secret key의 생성부이다. secretKey는 application.properties에 앞서 작성한 문자열 내용을 참고하며, 만약 해당 내용을 가져올 수 없다면 기본값으로 설정한 "secretKey"로 설정된다.
@PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행할 메서드에 선언하며,
현재 클래스는 @Component를 통해 애플리케이션 가동 후 빈으로 자동 주입되는데, 이때 @PostConstruct 어노테이션을 통해 init() 메서드가 수행되게 된다.
init() 메서드는 앞에서 지정된 secretKey 문자열을 Base64 형식으로 인코딩한다.
JWT 토큰에 정보를 담기 위해 createToken() 메서드 내에서 Claims 객체를 생성해주었다. setSubject() 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid 값을 사용한다. 해당 토큰의 주인인 사용자의 권한을 표기하기 위해 "roles"를 별도로 추가했다.
이후 Jwts의 Builder를 통해 token을 생성한다. signWith() 메서드를 통해 사용할 알고리즘과 secretKey를 부여한다.
해당 메서드는 필터에서 인증이 성공했을 시 SecurityContextHolder에 저장할 Authentication을 생성한다.
Authentication의 구현 방법은 가장 쉽게는 UsernamePasswordAuthenticationToken 클래스를 활용하는 방법이 있으며, 해당 클래스를 사용하려면 UserDetails 객체가 필요하기 때문에 앞선 실습에서 작성했던 메서드들로 처리한다. 즉, getUsername 메서드를 통해 현재 token을 사용하고 있는 사용자의 user id를 알아내고, 그 uid가 service와 repository를 거쳐 현재 이용중인 사용자의 정보를 UserDetails의 형태로 가져온다.
앞선 getAuthentication() 메서드에서 활용된 getUsername() 메서드는 다음과 같다. 해당 메서드는 Jwts의 parser()를 통해 클레임을 추출하며, 클레임의 추출에는 setSigningKey() 메서드를 통해 주입되는 secretKey가 활용된다. 이후 getSubject()를 통해 토큰의 생성 시 sub에 넣었던 uid 값을 추출하는 과정으로 진행된다.
resolveToken() 메서드는 HttpServletRequest를 파라미터로 받아 헤더 값으로 전달된 "X-AUTH-TOKEN" 값을 가져와서 리턴한다. 클라이언트가 헤더를 통해 애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출이 가능하다.
해당 메서드는 토큰을 받아 클레임의 유효기간이 남아 있는지에 대한 정보를 boolean 타입으로 반환한다.
13.5.3 JwtAuthenticationFilter 구현
JwtAuthenticationFilter는 토큰으로 인증하고 SecurityContext에 추가하는 필터를 설정하는 클래스이다.
config/security 패키지 내부에 JwtAuthenticationFilter 클래스를 다음과 같이 작성한다.
코드를 하나씩 분석해보자.
public class JwtAuthenticationFilter extends OncePerRequestFilter
OncePerRequestFilter 클래스를 상속받아 필터를 구현하는 것은 대표적인 필터의 구현 방법 중 하나로,
OncePerRequestFilter 클래스와 GenericFilterBean 클래스가 대표적인 상속 객체이다.
만약 GenericFilterBean을 상속받아 사용한다면 다음과 같이 작성할 수 있다.
GenericFilterBean 클래스는 기존 필터에서 가져올 수 없는 스프링의 정보를 가져올 수 있게끔 확장된 추상 클래스이다.
서블릿은 서블릿의 생성 후 메모리에 저장해놨다가 동일한 클라이언트의 요청은 해당 서블릿을 재활용하게끔 구성되어 있는데, GenericFilterBean을 상속받을 시 RequestDispatcher에 의해 다른 서블릿으로 디스패치되면서 필터가 두 번 실행되는 현상이 발생할 수 있다.
이를 해결하기 위한 클래스가 OncePerRequestFilter이며, 이 역시 GenericFilterBean을 상속받고, 필터는 한 번 실행된다.
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 값 유효성 체크 완료");
}
JwtTokenProvider 클래스에서 작성해둔 함수를 이용하여
request에서의 토큰을 추출하고, if 문으로 토큰의 유효성을 검사한다.
만약 유효하다면 Authentication 객체를 생성하여 SecurityContextHolder에 추가하는 작업을 수행한다.
filterChain.doFilter(servletRequest, servletResponse);
해당 부분에서 서블릿이 실행된다.
13.5.4 SecurityConfiguration 구현
여태까지의 실습은 스프링 시큐리티를 적용하기 위한 컴포넌트 구현이었고, 이젠 시큐리티 관련 설정 실습을 지행한다.
config/security 패키지 내에 SecurityConfiguration 클래스를 다음과 같이 작성한다.
package com.springboot.security.config.security;
import org.springframework.beans.factory.annotation.Autowired;
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.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@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");
}
}
CustomAccessDeniedHandler 클래스와 CustomAuthenticationEntryPoint 클래스는 다음 장에서 직접 작성할 예정이다.
해당 클래스의 주요 함수는 configure()으로, 매개변수에 따라 오버로딩 되어 있다. 코드를 분석해보자.
먼저 HttpSecurity를 매개변수로 받는 configure 메서드부터 확인해본다.
스프링 시큐리티의 설정은 대부분 HttpSecurity를 통해 진행한다. HttpSecurity의 대표적인 기능은 리소스 접근 권한 설정, 인증 실패 시 발생하는 예외 처리, 인증 로직 커스터마이징, csfr/cors 등의 스프링 시큐리티 설정 등이 있다.
해당 코드에서 진행되는 모든 설정은 HttpSecurity에 설정하게 된다.
.httpBasic().disable()
UI 사용을 기본값으로 가진 시큐리티 설정을 비활성화 한다.
.csrf().disable()
CSRF는 Cross-Site Request Forgery의 준말로 '사이트 간 요청 위조'를 의미한다. 이는 웹 애플리케이션의 취약점 중 하나로, 사용자가 자신의 의지와 무관하게 공격자의 의도한 행동을 함으로써 특정 페이지의 보안을 건든다거나 수정, 삭제 등의 작업을 하는 공격 방법이다. 일례로 A 사이트에 자동 로그인이 된 상태로 공격 메일을 열람한다면 내 아이디로 A 사이트에 특정 홍보글을 올리는 것 등이 있다. (인스타그램 혹은 페이스북에 갑자기 자신 혹은 친구의 아이디로 특정 사이트를 홍보하는 글이 올라오는 것을 본 적이 있을 것이다)
스프링은 csrf() 메서드를 통해 CSRF 토큰을 발급하여 클라이언트의 요청 시마다 토큰을 검증하는 방식으로 동작되며,
브라우저 사용 환경이 아니라면 비활성화해도 큰 문제를 일으키지 않는다. (REST API에서는 csrf 보안이 필요하지 않다)
해당 코드는 csrf 기능을 비활성화 시킨다.
참고 사이트
https://tibetsandfox.tistory.com/11
.sessionManagement()
.sessionCreationPolicy(
SessionCreationPolicy.STATELESS
)
REST API 기반 애플리케이션의 동작 방식을 설정한다.
현재 프로젝트는 JWT 토큰으로 인증을 처리하고 세션은 사용하지 않으므로 STATELESS로 설정한다.
.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")
authorizeRequests()에 이어서 antPattern을 통해 권한을 설정하는 부분이다.
첫 번째 줄에서는 해당 "/sign-api/ ~~"의 세 가지 경로에 대해서 모두에게 권한을 허용하며,
두 번째 줄에서는 "/product"로 시작하는 경로는 GET 요청만 모두에게 권한을 허용하고,
세 번째 줄에서는 "exception" 단어가 들어 있는 경로에 대해서 모두에게 권한을 허용한다는 의미이다.
마지막 줄은 앞선 세 설정 이외의 요청은 인증된 권한을 가진 사용자에게만 허용한다는 의미이다.
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
권한 확인의 과정에서 통과하지 못할 시 발생시킬 예외(실습 코드에선 CunsomAccessDeniedHander())를 설정한다.
.exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
인증의 과정에서 통과하지 못할 시 발생시킬 예외(실습 코드에선 CuntomAuthenticationEntryPoint())를 설정한다.
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
두 번째 매개변수의 필터 이전에 첫 번째 매개변수의 필터를 적용시킨다.
필터 체인은 필터들이 연속하여 검증하는 과정을 거치는데, 지금의 실습에선 JWT로 인증을 진행하는 필터를 생성했었다.
필터의 등록은 HttpSecurity에서 하게 되며,
만일 JwtAuthenticationFilter을 통과하게 되면 UsernamePasswordAuthenticationFilter는 자동으로 통과하게 된다.
다음으로 WebSecurity를 매개변수로 받는 configure 메서드를 확인해본다.
@Override
public void configure(WebSecurity webSecurity){
webSecurity.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
"/swagger-ui.html", "/webjars/**", "/swagger/**", "/sign-api/exception");
}
WebSecurity는 HttpSecurity 앞단에서 적용되며, 따라서 스프링 시큐리티의 영향권 밖에 있다.
즉, 인증과 인가가 진행되기 전에 동작하는 설정으로, 인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용한다.
해당 코드는 실습의 원활함을 위해 Swagger의 접근 시에 발생할 경로에 대해서 ignoring() 메서드를 적용하여,
인증, 인가를 무시하는 경로를 설정한 부분이다.
13.5.5 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현
이전 실습에서 exception이 발생했을 때 전달했던 커스텀 예외를 작성한다.
먼저 권한 확인 시 실패할 경우 발생하는 예외 관련 코드를 작성한다.
config/security 패키지 내에 CunstomAccessDeniedHandler 클래스를 다음과 같이 작성한다.
package com.springboot.security.config.security;
import java.io.IOException;
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.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
AccessDeniedHandler를 구현하여 구성하기 때문에 handle() 메서드를 오버라이드하여 작성한다.
AccessDeniedException은 기본적으로 액세스 권한이 없는 리소스 접근 시 발생하는 예외이며,
처리를 위해 AccessDeniedHandler 인터페이스를 구현하여 사용된다.
이전 시큐리티 설정 코드 부에서도 exceptionHandling() 메서드를 통해 추가했었다.
.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
권한 확인에 실패했을 때 발생하므로, 실습 코드에선 리다이렉션을 통해 예외 관련 경로로 보내준다.
리다이렉트 시엔 다른 스레드에서 동작하게 된다.
다음으로 인증 실패 시 실패할 경우 발생하는 예외 관련 코드를 작성한다.
우선 data/dto 패키지 내에 EntryPointErrorResponse 클래스를 다음과 같이 작성한다.
package com.springboot.security.data.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}
이후 config/security 패키지 내에 CunstomAuthenticEntryPoint 클래스를 다음과 같이 작성한다.
package com.springboot.security.config.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.springboot.security.data.dto.EntryPointErrorResponse;
import java.io.IOException;
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.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
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));
}
}
AuthenticationEntryPoint 인터페이스는 commence() 메서드를 오버라이딩 해야 하며,
이전 실습과 조금 다르게 리다이렉트가 아닌 직접 response로 클라이언트에게 응답하는 방식으로 구성하였다.
컨트롤러에서는 응답을 위한 설정들이 자동으로 구현되어 별도의 작업이 필요치 않지만,
여기선 응답값 설정의 필요성으로 EntryPointErrorResponse 클래스를 생성하여
상태 코드, 콘텐츠 타입 등을 설정하고 EntryPointErrorResponse 객체를 ObjectMapper을 통해 response의 body 값으로 파싱한다. 이때 ObjectMapper는 JSON과 Java 객체를 서로 변환 및 직렬화 시켜주는 객체이다.
만일 메세지가 굳이 필요하지 않다면 다음과 같은 한 줄로 메서드를 구성하여 인증 실패 코드만 전달할 수도 있다.
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
13.5.6 회원가입과 로그인 구현
이번 실습에선 회원가입 및 로그인을 구현하여 User 객체 생성 및 해당 객체로 인증을 시도하는 로그인을 구현해본다.
회원가입과 로그인의 도메인은 sign-up/in 으로, sign으로 통합하여 구분한다.
책에서는 핵심 기능의 코드를 먼저 소개하고, 이후 필요한 클래스들을 추가하는 순서로 내용을 구성했지만,
본 포스팅에서는 가장 하위의 클래스부터 작성하여 코드 작성의 용이함에 초점을 두었다.
먼저 data/dto 패키지 내부에 다음과 같이 dto 클래스를 두 개 작성한다.
package com.springboot.security.data.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {
private boolean success;
private int code;
private String msg;
}
package com.springboot.security.data.dto;
import lombok.*;
@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;
}
}
두 dto 객체는 응답을 위해 사용되는 dto 객체로서, 어떠한 요청에 대해 서버에서 처리한 후 해당 요청이 정상적인지에 대한 여부, code 값, 메세지, token 등을 담아 보내준다. SignInResultDto는 회원가입 후의 반환값인 SignUpResultDto를 상속받는데, 로그인 시 프론트 단에서 처리해야 할 token을 추가적으로 담아 응답하는 역할을 담당한다.
위의 두 dto는 응답값이기에, 프론트에서 서로 다른 응답값을 받을 때 혼동이 없도록 통일해주어야 할 필요성이 생긴다.
따라서 응답값에 담길 성공 여부, code 값 등을 통일해주기 위한 response enum 클래스를 하나 생성해주어야 한다.
config/security 내부에 common 패키지를 생성하고, CommonResponse 클래스를 다음과 같이 작성한다.
다음으로 service 단에서 사용할 password encoder 기능을 수행할 config 클래스를 하나 작성한다.
config/security 패키지 내부에 PasswordEncoderConfiguration 클래스를 다음과 같이 작성한다.
package com.springboot.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
해당 클래스는 security 에서 지원하는 PasswordEncoder 기능을 활용한 기능으로, 해당 코드는 단순히 새로운 password encoder 객체를 반환하여 bean으로 등록하는 기능을 담당한다.
이후 코드 상에서 passwordEncoder 객체를 주입받아 활용할 수 있으며, 이 passwordEncoder는 문자열로 들어온 비밀번호를 encode하여 다른 문자열로 바꾸어주거나, 비밀번호끼리 서로 일치하는지 등의 역할을 제공하는 함수를 제공한다.
이제 앞에서 작성한 코드들을 활용하여 로직을 처리하는 service 단의 코드를 작성한다.
service 패키지 내부에 SignService interface를 다음과 같이 작성하고,
해당 interface를 구현하는 SignServiceImpl 코드를 다음과 같이 작성한다.
package com.springboot.security.service;
import com.springboot.security.data.dto.SignInResultDto;
import com.springboot.security.data.dto.SignUpResultDto;
public interface SignService {
SignUpResultDto signUp(String id, String password, String name, String role);
SignInResultDto signIn(String id, String password) throws RuntimeException;
}
package com.springboot.security.service.impl;
import com.springboot.security.config.security.JwtTokenProvider;
import com.springboot.security.config.security.common.CommonResponse;
import com.springboot.security.data.dto.SignInResultDto;
import com.springboot.security.data.dto.SignUpResultDto;
import com.springboot.security.data.entity.User;
import com.springboot.security.data.repository.UserRepository;
import com.springboot.security.service.SignService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Collections;
@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("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 signUpResultDto) {
signUpResultDto.setSuccess(true);
signUpResultDto.setCode(CommonResponse.SUCCESS.getCode());
signUpResultDto.setMsg(CommonResponse.SUCCESS.getMsg());
}
private void setFailResult(SignUpResultDto signUpResultDto) {
signUpResultDto.setSuccess(true);
signUpResultDto.setCode(CommonResponse.FAIL.getCode());
signUpResultDto.setMsg(CommonResponse.FAIL.getMsg());
}
}
signUp 메서드의 경우, 컨트롤러에서 회원의 id, password, name, role을 넘겨 받아 repository를 통해 저장한다. 이때 password는 이전에 설정한 passwordEncoder를 통해 encode된 비밀번호로 데이터베이스에 저장하게 된다.
singIn 메서드의 경우, 컨트롤러에서 로그인하고자 하는 회원의 id를 받아 repository에서 검색하여 객체로 가져온다. 이후 passwordEncoder.matches()를 통해 비밀번호 일치 여부를 검증하고, 회원이 맞다면 token을 담아 프론트엔드로 넘겨준다. 비밀번호가 일치하지 않을 때 코드 상에서는 RuntimeException을 발생하도록 되어 있지만, 보통 새로운 custom exception을 터트리게 하여 에러를 확인하고 에러에 따라 다른 반응을 줄 수 있도록 구현한다.
마지막으로 이렇게 service 단에서 작성한 코드를 활용한 controller 코드를 작성한다.
controller 패키지 내부에 SignController 클래스를 다음과 같이 작성한다.
package com.springboot.security.controller;
import com.springboot.security.data.dto.SignInResultDto;
import com.springboot.security.data.dto.SignUpResultDto;
import com.springboot.security.service.SignService;
import io.swagger.annotations.ApiParam;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@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 singIn(
@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 : {}, roles : {}", 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);
}
}
해당 컨트롤러 코드에는 exception handler까지 포함하여, 회원가입 중 발생하는 exception에 대해 다른 response를 주도록 설정하고 있다.
13.5.7 스프링 시큐리티 테스트
이제 swagger를 활용하여 시큐리티 동작 시 클라이언트(프론트엔드)가 받는 결과를 테스트해보자.
security를 설정하면 모든 api에 대하여 로그인이 필요해지게 되지만, 앞서 WebSecurity를 설정하는 SecurityConfiguration에서 configure 메서드에 swagger에 대한 접근을 해제해두었기 때문에 정상적인 테스트가 가능하다.
애플리케이션 가동 로그
우선 애플리케이션을 실행하고 콘솔창의 로그를 살펴보며 작동 여부를 확인해보자.
다음과 같이 로그에서 secretKey에 대한 초기화가 진행되는 것을 확인할 수 있다.
또한 앞서 언급했듯 security가 권한을 확인하지 않고 접근을 허용하는 경로에 대한 설정도 잘 이루어지고 있음을 확인할 수 있다.
정상적인 동작 시나리오
swagger에 접속하여 sign-up API로 다음과 같이 회원가입을 진행한다.
이후 데이터베이스에 접속하면 작성된 정보로 회원 정보가 저장됨을 확인할 수 있다.
이제 swagger의 sign-in API로 회원가입한 정보를 통해 로그인을 다음과 같이 진행해본다.
결과처럼 token 값이 함께 잘 전달된 것을 확인할 수 있으며, 앞으로는 해당 token을 통해 인증을 진행할 수 있다.
이제 product 관련 API를 토큰을 통해 접근하는 테스트를 진행해보자.
SecurityConfiguration의 configure() 메서드를 다시 한번 확인해보면
.antMatchers(HttpMethod.GET, "/product/**").permitAll()
다음과 같이 /product/ 로 시작하는 API 중 GET 메서드에 대해서만 security를 허용하였으므로,
product에서 @PostMapping으로 선언되어 있는 createProduct() 메서드를 token으로 접근하여 테스트해본다.
테스트에 앞서, swagger에서 token을 입력하는 란을 만들기 위해 ProductController의 createProduct 메서드 상단에 다음과 같이 @ApiImplictParams 어노테이션을 추가해준다.
@ApiImplicitParams({
@ApiImplicitParam(name = "X-AUTH-TOKEN",
value = "로그인 성공 후 발급 받은 access token",
required = true,
paramType = "header")
})
@PostMapping()
public ResponseEntity<ProductResponseDto> createProduct(@RequestBody ProductDto productDto){
ProductResponseDto productResponseDto = productService.saveProduct(productDto);
return ResponseEntity.status(HttpStatus.OK).body(productResponseDto);
}
작성 후 서버를 재실행하고, swagger에서 동일하게 회원가입과 로그인을 진행하여 token을 발급받고, 해당 token을 통해 product의 createProduct에 접근해보자. 방법은 로그인 시 발급받은 token을 복사하여 swagger의 X-AUTH-TOKEN 란에 붙여넣으면 된다.
그러면 다음과 같이 성공적인 응답을 받을 수 있게 된다.
비정상적인 동작 시나리오 - 인증 예외 발생
이제 시큐리티에서 인증 과정에서의 예외 발생에 대해 테스트해보자.
위의 token 입력 부에 발급 받지 않은, 정상적이지 않은 문자열(임의의 문자열)을 적어 넣고 다음과 같이 테스트해본다.
그러면 다음과 같이 비정상적인 token임을 알리는 결과가 나오며, 이는 이전에 CustomAuthenticationEntryPoint에 설정한대로 도출됨을 확인할 수 있다.
비정상적인 동작 시나리오 - 인가 예외 발생
이제 시큐리티에서 인가 과정에서의 예외 발생에 대해 테스트해보자.
인가는 권한에 관련된 설정이므로, 앞서 SecurityConfiguration에서의 설정에 따라 ADMIN이 아닌 USER의 권한으로 회원가입을 진행한 후 발급 받은 token을 통해 createProduct에 접근하면 발생한다.
.anyRequest().hasRole("ADMIN")
다음과 같이 USER 권한으로 회원가입을 진행하고, 로그인을 통해 token을 발급 받는다.
이전과 동일하게 발급 받은 token을 복사하여 createProduct에 접근하면 다음과 같은 결과를 확인할 수 있다.
이러한 결과는 앞서 CustomAccessDeniedHandler의 handle()을 통해 접근이 막혔을 때 /sign-api/exception으로 접근하고,
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException e) throws IOException, ServletException {
LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
이후 SignController의 /sign-api/exception의 로직에 따라 RuntimeException을 터트리며,
@GetMapping(value = "/exception")
public void exceptionTest() throws RuntimeException {
throw new RuntimeException("접근이 금지되었습니다.");
}
SignController의 @ExceptionHandler 매핑이 되어 있는 ExceptionHandler() 메서드를 통해 최종적으로 프론트에게 결과가 전송된다.
@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);
}
13.6 정리
이번 장에선 스프링 시큐리티에 대해 개략적으로 알아보았다.
추가로 스프링 시큐리티는 다양한 구현 방법을 제공하며, OAuth와 소셜 로그인을 연동하여서도 구현할 수 있다.
'Backend > Spring' 카테고리의 다른 글
[스프링 부트 핵심 가이드] 12. 서버 간 통신 (0) | 2023.06.28 |
---|---|
[스프링 부트 핵심 가이드] 11. 액추에이터 활용하기 (0) | 2023.06.27 |
[스프링 부트 핵심 가이드] 10. 유효성 검사와 예외 처리 (0) | 2023.06.21 |
[스프링 부트 핵심 가이드] 09. 연관관계 매핑 (1) | 2023.06.14 |
[스프링 부트 핵심 가이드] 08. Spring Data JPA 활용 (0) | 2023.06.08 |