컬쥐네 다락방
Spring Security 심화 공부 - JWT 회원가입, 로그인 기능 본문
Spring Security
최근 Spring Security를 사용해 회원 가입과 로그인을 구현하는 것을 공부했었는데, 이 과정에서 JWT를 이용해 사용자를 인증하는 방법에 대해 공부했다. 그땐 예제를 가지고 로그인과 회원가입 기능을 구현하는 것에 집중했었고, 이번에는 응용을 위해 좀 더 깊게 파고들어봤다.
스프링 시큐리티에서 어플리케이션 보안을 구성하는 영역은 크게 두 가지가 있다.
인증(Authentication)은 보호된 리소스에 접근하는 대상, 즉 사용자에게 적절한 접근 권한이 있는지 확인하는 일련의 과정을 의미한다. 이 때 보호된 리소스에 접근하는 대상(사용자)을 접근 주체(Principal)이라고 하며 권한(Authorization)은 인증절차가 끝난 접근 주체가 보호된 리소스에 접근 가능한지를 결정하는 것을 의미한다. 이 때 권한을 부여하는 작업을 인가(Authorize)라고 한다.
인증은 아이디와 비밀번호를 입력 받아 로그인 하는 과정 자체를 의미하고, 권한이 필요한 리소스에 접근하려면 이러한 인증 과정을 거쳐야 한다. 스프링 시큐리티는 이런 매커니즘을 간단하게 만들 수 있도록 우리에게 여러 옵션을 제공한다.
Spring Security Filter
Spring Security 에서 사용하는 다양한 필터 기능들.
- SecurityContextPersistentFilter : SecurityContextRepository에서 SecurityContext를 가져와서 SecurityContextHolder에 주입하거나 반대로 저장하는 역할을 합니다.
- LogoutFilter : logout 요청을 감시하며, 요청시 인증 주체(Principal)를 로그아웃 시킵니다.
- UsernamePasswordAuthenticationFilter : login 요청을 감시하며, 인증 과정을 진행합니다.
- DefaultLoginPageGenerationFilter : 사용자가 별도의 로그인 페이지를 구현하지 않은 경우, 스프링에서 기본적으로 설정한 로그인 페이지로 넘어가게 합니다.
- BasicAuthenticationFilter : HTTP 요청의 (BASIC)인증 헤더를 처리하여 결과를 SecurityContextHolder에 저장합니다.
- RememberMeAuthenticationFilter : SecurityContext에 인증(Authentication) 객체가 있는지 확인하고 RememberMeServices를 구현한 객체 요청이 있을 경우, RememberMe를 인증 토큰으로 컨텍스트에 주입합니다.
- AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 익명 사용자로 취급합니다.
- SessionManagementFilter : 요청이 시작된 이후 인증된 사용자인지 확인하고, 인증된 사용자일 경우 SessionAuthenticationStrategy를 호출하여 세션 고정 보호 매커니즘을 활성화 하거나 여러 동시 로그인을 확인하는 것과 같은 세션 관련 활동을 수행합니다.
- ExceptionTranslationFilter : 필터체인 내에서 발생되는 모든 예외를 처리합니다.
- FilterSecurityInterceptor : AccessDecisionManager로 권한부여처리를 위임하고 HTTP 리소스의 보안 처리를 수행합니다.
JWT(Json Web Token)
JWT는 json 객체를 통해 안전하게 정보를 전송하는 웹표준으로 '.'을 구분자로 세 부분으로 된 문자열로 이루어져 있습니다. 헤더는 토큰 타입과 알고리즘을 저장, 내용은 실제 전달할 정보, 서명은 위변조를 방지하기 위한 값이 들어갑니다.
JWT는 JSON 객체를 암호화 하여 만든 문자열 값으로 위, 변조가 어려운 정보라고 할 수 있습니다. 또, 다른 토큰들과 달리 토큰 자체에 데이터를 가지고 있다는 특징이 있습니다. JWT의 이러한 특징 때문에 사용자의 인증 요청시 필요한 정보를 전달하는 객체로 사용할 수 있습니다.
API 서버는 로그인 요청이 완료되면 클라이언트에게 회원을 구분할 수 있는 정보를 담은 JWT를 생성하여 전달합니다. 그러면 클라이언트는 이 JWT를 헤더에 담아서 요청을 하게 됩니다. 권한이 필요한 요청이 있을 때 마다 API 서버는 헤더에 담긴 JWT 값을 확인하고 권한이 있는 사용자인지 확인하고 리소스를 제공하게 됩니다.
이렇게 기존의 세션-쿠키 기반의 로그인이 아니라 JWT같은 토큰 기반의 로그인을 하게 되면 세션이 유지되지 않는 다중 서버 환경에서도 로그인을 유지할 수 있게 되고 한 번의 로그인으로 유저정보를 공유하는 여러 도메인에서 사용할 수 있다는 장점이 있습니다.
이 때 회원을 구분할 수 있는 정보가 담기는 곳이 바로 JWT의 payload 부분이고 이곳에 담기는 정보의 한 '조각'을 Claim 이라고 합니다. Claim은 name / value 한 쌍으로 이루어져 있으며 당연히 여러개의 Claim들을 넣을 수 있습니다.
(현재 진행중인 항해 하우스 프로젝트에서는 jwt의 payload에서 email이나 닉네임을 읽어와 사용자에 대한 정보를 찾거나 대화명으로 설정해주는 작업을 진행했어요 ! )
구현
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
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;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;
import java.util.List;
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "webfirewood";
// 토큰 유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
private final UserDetailsService userDetailsService;
// 객체 초기화, secretKey를 Base64로 인코딩한다.
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
토큰의 유효시간을 설정하고 사용자의 정보를 jwt 토큰에 담아 토큰을 생성해줍니다. 그 후 필요할때 토큰을 이용해서 유효성을 검사하거나 회원 정보를 추출하기 위한 컴포넌트를 만들어 줍니다.
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 를 받아옵니다.
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 유효한 토큰인지 확인합니다.
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
해당 컴포넌트를 이용해서 토큰을 이용해 유효성을 검증하고 JWT로부터 유저 정보를 받아 UsernamePasswordAuthenticationFilter로 전달해줍니다.
그리고 작성한 필터를 SecurityConfig 파일에 등록해주는 과정이 필요!
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
// 암호화에 필요한 PasswordEncoder 를 Bean 등록합니다.
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// authenticationManager를 Bean 등록합니다.
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
.csrf().disable() // csrf 보안 토큰 disable처리.
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
.and()
.authorizeRequests() // 요청에 대한 사용권한 체크
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
// JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
}
}
이후에는 User 엔티티와 Repository등을 만들어 유저를 관리해주도록 만들어주면 끝 !
확실히 기본적인 스프링 시큐리티의 기능을 사용하다 jwt 기능을 연결해 사용하니 보안 문제에서도 더욱 안전성이 느껴지고 클라이언트와 통신할 때 헤더에 토큰만 담아서 보내주면 현재 이용하고 있는 사용자를 판단하기가 쉽다보니 더 많은 기능을 간편하게 구현할 수 있다고 생각한다.
생각보다 Spring의 기능을 응용하니 구현 난이도도 쉽다고 생각되고 !
참고 사이트
아래는 JWT 기능 공부를 위해 참고했던 사이트 목록.
https://daddyprogrammer.org/post/636/springboot2-springsecurity-authentication-authorization/
https://coding-start.tistory.com/153
https://sjh836.tistory.com/165
https://okky.kr/article/382738
https://siyoon210.tistory.com/32
'웹 개발' 카테고리의 다른 글
2021.04.07~08 Spring 회원가입,로그인 기능 공부 (0) | 2021.04.08 |
---|