이번 장에서는 보안과 관련된 용어와 개념을 알아보고 스프링에 보안을 적용할 때 사용하는 스프링 시큐리티에 대해 알아보겠습니다. 로그인을 통한 일반적인 인증과 인가 방식이 아닌 매 요청마다 토큰값을 활용하는 보안 기법을 확인해 봅니다.
스프링 시큐리티를 배우기에 앞서 보안과 관련된 용어를 간단하게 살펴봅니다.
인증(authentication): 사용자가 누구인지 확인하는 단계
대표적인 예로 '로그인'. 로그인에 성공하면 애플리케이션 서버는 응답으로 사용자에게 토큰을 전달합니다. 로그인에 실패한 사용자는 토큰을 전달받지 못해 원하는 리소스에 접근할 수 없게 됩니다.
인가(authorization): 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정
예를 들어, 로그인한 사용자가 특정 게시판에 접근해서 글을 보려고 하는 경우 게시판 접근 등급을 확인해 접근을 허가하거나 거부하는 것. 일반적으로 사용자가 인증 단계에서 발급받은 토큰은 인가 내용을 포함하고 있습니다. 사용자가 리소스에 접근하면서 토큰을 함께 전달하면 애플리케이션 서버는 토큰을 통해 권한 유무 등을 확인해 인가를 수행합니다.
접근 주체(principal): 애플리케이션의 기능을 사용하는 주체. 사용자가 될 수도 있고, 디바이스, 시스템 등이 될 수도 있음
스프링 시큐리티: 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나
보안과 관련된 많은 기능을 제공하기 때문에 스프링 시큐리티를 활용하면 더욱 편리하게 원하는 기능을 설계할 수 있습니다.
스프링 시큐리티는 서블릿 필터를 기반으로 동작합니다.
필터체인은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미합니다. 클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑합니다. 스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 아래 그림과 같이 DelegatingFilterProxy를 사용합니다.
DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트 사이에서 다리 역할을 수행하는 필터 구현체입니다. 표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시를 내부에 가지고 있습니다. 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성됩니다. 또한 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter) 를 사용할 수 있습니다. 사용할 수 있는 보안 필터체인은 List 형식으로 담을 수 있게 설정돼 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용하게 됩니다. 보안필터 체인에서 사용하는 필터는 여러 종류가 있으며, 각 필터마다 실행되는 순서가 다릅니다. 공식문서를 통해 필터의 실행 순서를 확인할 수 있습니다.
위 그림의 인증 수행 과정을 설명하면 다음과 같습니다.
위 과정에서 사용된 UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경우 로그인 폼이라는 화면을 보내는 역할을 수행합니다. 실습 중인 프로젝트는 화면이 없는 RESTful 애플리케이션이기 때문에 다른 필터에서 인증 및 인가 처리를 수행해야 합니다. JWT 토큰을 사용해 인증을 수행할 예정이라 JWT와 관련된 필터를 생성하고 UsernamePasswordAuthenticationFilter 앞에 배치해서 먼저 인증을 수행할 수 있게 설정하겠습니다.
* 스프링 시큐리티에 대한 자세한 내용은 공식 문서를 참고하기 바랍니다.
https://docs.spring.io/spring-security/site/docs/5.5.3/reference/html5/
JWT(JSON Web Token): 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰
JWT는 URL로 이용할 수 있는 문자열로만 구성돼 있으며, 디지털 서명이 적용돼 있어 신뢰할 수 있습니다. 주로 서버와의 통신에서 권한 인가를 위해 사용되며, URL에서 사용할 수 있는 문자열로만 구성돼 있기 때문에 HTTP 구성요소 어디든 위치할 수 있습니다.
JWT는 점(.)으로 구분된 아래의 세 부분으로 구성됩니다.
JWT의 헤더는 검증과 관련된 내용을 담고 있습니다. 헤더에는 alg와 typ 속성 두 가지 정보를 가지고 있습니다.
{
"alg": "H2256"
"typ": "JWT"
}
alg 속성에서는 해싱 알고리즘을 지정합니다. 해싱 알고리즘은 보통 SHA256 또는 RSA를 사용하며, 토큰을 검증할 때 사용되는 서명 부분에서 사용됩니다. 위 예제에 작성돼 있는 HS256은 HMAC SHA256 알고리즘을 사용한다는 의미입니다.
typ 속성에는 토큰의 타입을 지정합니다.
이렇게 완성된 헤더는 Base64Url 형식으로 인코딩돼 사용됩니다.
JWT의 내용에는 토큰에 담는 정보를 포함합니다. 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 크게 세 가지로 분류됩니다.
등록된 클레임은 필수는 아니지만 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻합니다. 등록된 클레임은 다음과 같이 정의돼 있습니다.
공개 클레임은 키 값을 마음대로 정의할 수 있습니다. 다만 충돌이 발생하지 않을 이름으로 설정해야 합니다.
비공개 클레임은 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미합니다.
{
"sub": "wikibooks payload",
"exp": "1603076498",
"userId": "wikibooks",
"username": "flature"
}
이렇게 완성된 내용은 Base64Url 형식으로 인코딩되어 사용됩니다.
JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성합니다.
예를 들어, HMAC SHA256 알고리즘을 사용해서 서명을 생성한다면 아래와 같은 방식으로 생성됩니다.
HMACSHA256(
base64UrlEncode(headr) + "." +
base64UrlEncode(payload),
secret
)
서명은 토큰의 값들을 포함해서 암호화하기 때문에 메시지가 도중에 변경되지 않았는지 확인할 때 사용됩니다.
JWT 공식 사이트(https://wt.io/#debugger-io)에서는 더욱 쉽게 JWT를 생성할 수 있습니다.
이 화면은 Encoded와 Decoded로 나눠져 있으며, 양측의 내용이 일치하는지 사이트에서 확인할 수도 있고 Decoded의 내용을 변경하면 Encoded의 콘텐츠가 자동으로 반영됩니다.
아래와 같은 설정으로 프로젝트를 생성합니다.
그리고 이전 장에서 사용했던 SwaggerConfiguration 클래스와 그에 따른 의존성을 추가합니다. 또한 기본 프로젝트 틀을 가져가기 위해 7장에서 사용한 프로젝트 코드를 가져옵니다.
<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>
아래와 같이 사용자 정보를 담는 엔티티를 생성합니다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
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 false;
}
@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;
}
}
엔티티를 조회하는 기능을 구현하기 위해 아래와 같이 리포지터리와 서비스를 구현합니다.
public interface UserRepository extends JpaRepository<User, Long> {
User getByUid(String uid);
}
리포지터리를 통해 User 엔티티의 id를 가져오는 서비스를 아래와 같이 생성합니다.
@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);
}
}
이제 JWT 토큰을 생성하는데 필요한 정보를 UserDetails에서 가져올 수 있기 때문에 JWT 토큰을 생성하는 JwtTokenProvider를 생성합니다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
private final UserDetailsService userDetailsService;
/* 토큰을 생성하기 위해서 secretKey 값 정의 */
@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] 토큰 기반 회원 구별 정보 추출 완료, inof: {}", 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);
LOGGER.info("[validateToken] 토큰 유효 체크 완료");
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
return false;
}
}
}
@PostConstruct
protected void init() {
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
secretKey = Base64.getEncoder()
.encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
}
구현한 코드 안에 init() 메서드를 자세히 살펴봅시다. 여기서 사용한 @PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드입니다. JwtTokenProvider 클래스에는 @Component가 지정돼 있어 애플리케이션이 가동되면 빈으로 자동 주입됩니다. 그때 @PostConstruct 가 지정되어 있는 init() 메서드가 자동으로 실행됩니다. init() 메서드에서는 secretKey를 Base64 형식으로 인코딩합니다.
다음으로 createToken() 메서드를 살펴보겠습니다.
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;
}
JWT 토큰의 내용에 값을 넣기 위해 Claims 객체를 생성합니다.
setSubject() 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid 값을 사용합니다.
해당 토큰을 사용하는 사용자의 권한을 확인할 수 있는 role 값을 별개로 추가했으며, Jwts. builder()를 사용해 토큰을 생성하게 됩니다.
다음으로 getAuthentication() 메서드입니다.
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());
}
이 메서드는 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할을 합니다.
Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것입니다.
UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있는데, Abstract AuthenticationToken은 Authentication 인터페이스의 구현체입니다.
이 토큰 클래스를 사용하려면 초기화를 위한 UserDetails가 필요합니다. 이 객체는 UsersDetailsService를 통해 가져오게 됩니다. 이때 사용되는 Username 값은 아래와 같이 구현합니다.
public String getUsername(String token) {
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
String info = Jwts.parser().setSigningKey(secretKey)
.parseClaimsJws(token).getBody().getSubject();
LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, inof: {}", info);
return info;
}
Jwts.parser()를 통해 secretKey를 설정하고 클레임을 추출해서 토큰을 생성할 때 넣었던 sub 값을 추출합니다.
JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터 설정 클래스 JwtAuthenticationFilter를 아래와 같이 작성합니다.
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);
}
}
지금까지 실습을 통해 스프링 시큐리티를 적용하기 위한 컴포넌트를 구현했습니다. 이제 스프링 시큐리티와 관련된 설정을 진행하겠습니다. 스프링 시큐리티를 설정하는 대표적인 방법은 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");
}
}
그러나 스프링 시큐리티 5.7.0-M2 부터 WebSecurityConfigurerAdapter는 deprecated 되었습니다.
공식 블로그를 참고하여 변경할것을 권장합니다. 위의 코드 관련 내용은 생략하겠습니다.
앞선 예제 코드에서는 인증과 인가 과정의 예외 상황에서 CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint로 예외를 전달하고 있었습니다. 이번 절에서는 이러한 클래스를 작성하는 방법을 알아보겠습니다.
먼저 아래와 같이 AccessDeniedHandler 인터페이스의 구현체 클래스를 생성합니다. 기본적으로 handler() 메서드를 오버라이딩해서 구현하게 됩니다.
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException exception) throws IOException {
LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
다음은 인증이 실패한 상황을 처리하기 위한 AuthenticationEntryPoint 인터체이스를 구현한 CustomAuthenticationEntryPoint 클래스입니다.
@Component
public class CustomAuthenticationEntryPoint implements 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));
}
}
위 코드에서 사용된 EntryPointErrorResponse는 dto 패키지에 아래와 같이 생성합니다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}
컨트롤러에서는 응답을 위한 설정들이 자동으로 구현되기 때문에 별도의 작업이 필요하지 않았지만 여기서는 응답값을 설정할 필요가 있습니다. 메시지를 담기 위해 EntryPointErrorResponse 객체를 사용해 메시지 설정하고, response에 상태 코드(status)와 콘텐츠 타입(Content-type) 등을 설정한 후 ObjectMapper를 사용해 EntryPointErrorResponse 객체를 바디 값으로 파싱합니다.
먼저 서비스 레이어를 구현합니다.
public interface SignService {
SignUpResultDto signUp(String id, String password, String name, String role);
SignInResultDto signIn(String id, String password) throws RuntimeException;
}
SignService 인터페이스를 구현한 SignServiceImpl 클래스의 전체 코드는 아래와 같습니다.
@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 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());
}
}
PasswordEncoder는 아래와 같이 별도의 @Configuration 클래스를 생성하고 @Bean 객체로 등록하도록 구현합니다.
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
자세한 내부 로직은 다음과 같습니다.
위의 코드에서 setSucessResult() 메서드는 결과 데이터를 설정하는 메서드. 회원가입과 로그인 메서드에서 사용 할 수 있게 설정돼 있으며, 각 메서드는 DTO를 전달받아 값을 설정합니다. 이때 사용된 CommonResponse 클래스는 다음과 같이 작성돼 있습니다.
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로 노출하는 컨트롤러를 생성해야 하는데 사실상 서비스 레이어로 요청을 전달하고 응답하는 역할만 수행하기 때문에 코드만 소개하겠습니다. 클라이언트는 계정을 생성하고 로그인 과정을 거쳐 토큰값을 전달받음으로써 이 애플리케이션에서 제공하는 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);
}
}
아래의 코드는 Response로 전달되는 SignUpResultDto와 SignInResultDto 클래스입니다.
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {
private boolean success;
private int code;
private String msg;
}
@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;
}
}
1. Spring Security의 UserDetails 인터페이스를 구현하는 사용자 엔티티에서, 사용자의 계정 활성화 상태를 반환하는 메서드를 구현하려고 합니다. 아래는 User 클래스의 일부 코드입니다. 주어진 요구사항에 따라 isEnabled() 메서드를 올바르게 구현하세요. 사용자의 활성화 상태를 항상 true로 반환하도록 구현합니다.
/* ... 생략 */
public class User implements UserDetails {
/* ... 생략 */
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
// isEnabled() 메서드를 작성하세요.
}
2. JWT 인증을 위해 OncePerRequestFilter를 상속받아 JwtAuthenticationFilter를 구현하려고 합니다. doFilterInternal() 메서드는 요청이 들어올 때마다 실행되며, 서블릿 실행 전후의 작업을 설정할 수 있습니다. 아래 코드의 빈칸을 채워 doFilterInternal() 메서드를 완성하세요.
이 메서드는 다음의 작업을 수행해야 합니다.
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(_________)) { // 빈칸 1
// 인증 정보 생성 및 SecurityContextHolder에 추가
Authentication authentication = jwtTokenProvider.getAuthentication(_________); // 빈칸 2
SecurityContextHolder.getContext().setAuthentication(authentication);
LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
}
// 다음 필터 실행
filterChain._________(servletRequest, servletResponse); // 빈칸 3
}
}
인가(authorization), JWT, isCredentialNonExpired(), 해싱 알고리즘, 공개 클레임, 서명, @PostConstruct
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
token, token, doFilter
[출처] 장정우, 「스프링부트 핵심 가이드」 13장
Corner Spring 3
ⓒ Nini
[스프링 3팀] 12장. 서버 간 통신 (0) | 2025.01.24 |
---|---|
[스프링 3팀] 10장. 유효성 검사와 예외처리 ~ 11장. 액추에이터 활용하기 (0) | 2025.01.10 |
[스프링 3팀] 9장. 연관관계 매핑 (0) | 2025.01.10 |
[스프링 3팀] 8장. Spring Data JPA 활용 (0) | 2025.01.08 |
[스프링 3팀] 7장. 테스트 코드 작성하기 (0) | 2024.12.27 |