JWT토큰을 사용해서 로그인을 간단하게 구현해 보자!
들어가며
이전 포스팅까지 JWT토큰의 대해서 이론적으로 설명했습니다. 이번 포스팅에서는 이론적인 부분은 설명하지 않고 코드로만 JWT토큰을 사용해 간단하게 로그인 기능을 구현해 보도록 하겠습니다. 만약 이론적은 부분이 궁금하시다면 이전 포스팅을 참고해 주시면 감사하겠습니다.
프로젝트환경
springboot -version : 2.7.18
java -version : 17
DB : Mysql_8.0.33
IDE : IntelliJ _Ultimate
기본적인 SpringBoot, SpringSecurity, MySQL등과 같은 지식이 있다는 전재로 설명하도록 하겠습니다.
JWT 로그인 FLOW
위와 같은 흐름으로 로그인을 구성할 예정입니다. 사용자가 로그인 요청을 보내면 Spring은 내부에서 사용자가 맞는지 검사를 한 후에 로그인 성공 응답으로 Header에 JWT토큰을 실어 사용자에게 토큰을 발급해 주는 방식으로 구현할 것입니다.
(현재 FLOW에서는 사용자라고 작성했지만, 실제는 리엑트가 될 것입니다.)
기본설정
1. Member.class를 만들어 username과 password, role모두 String으로 된 엔티티를 생성합니다.
2.JPA를 사용하여 간단한 회원가입 로직을 만들어줍니다.
3.Get매핑으로 ("/")과 ("/user/test")을 RestController으로 만들어줘 간단하게 페이지게 작성될 값을 작성해 매핑해 줍니다.
구현내용
사용자 A는 로그인이 필요한 페이지에 진입하기 위해서 회원가입을 통해 아이디가 "test"로 회원가입을 성공했다.(권한은 ROEL_USER로 서버에서 설정) 이제 로그인을 진행하면 서버에서 DB에 가입된 유저가 있는지 검증을 하고 일치한다면 Header에 JWT 토큰을 실어서 사용자에 보내준다. 로그인이 성공한 사용자는 로그인이 필요한 페이지에 접근하게 되면 서버에 토큰을 보내고 서버는 토큰 값을 검증하고 유효한 토큰이라면 해당 페이지에 접근이 가능하도록 해보겠습니다.
"/"누구나 접속 가능한 페이지 "/user/test"는 로그인이 필요한 페이지
Config작성
스프링 시큐리티를 사용하기 위해서는 시큐리티 의존성을 추가해야 하며, Config 파일을 작성해줘야 합니다. 먼저 시큐리티 의존성을 먼저 추가해 줍니다.
implementation 'org.springframework.boot:spring-boot-starter-security'
그 후 시큐리티를 사용하기 위해 설정파일을 다음과 같이 작성해 줍니다.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.csrf().disable()
.httpBasic().disable();
http
.authorizeRequests()
.antMatchers("/user/**").access("hasRole('ROLE_USER')")
.anyRequest().permitAll();
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
- @EnableWebSecurity을 사용해 시큐리티 기능을 활성화해 줍니다.
- SessionCreationPolicy.STATELESS를 통해 세션을 무상태로 설정해 줍니다.
- 그리고 현재 구현할 내용은 api 서버를 만드는 거기 때문에 form 로그인과 httpBasic와 csrf기능을 비활성화해 줍니다.
- "/user/**"은 로그인한 사용자만이 접속할 수 있도록 합니다.
- 회원가입을 만든 로직에 passwordEncoder을 주입받아서 비밀번호를 passwordEncoder.encode("비밀번호 값")을통해 회원가입을 진행해 줍니다.
(그림과 같이 password필드는 인코딩 되어서 저장되어야지 시큐리티에서 오류가 발생하지 않습니다.)
그럼 이제 해당 서버를 실행하고 "/" , "user/test" url에 접속해 봅니다. "/"페이지는 아무나 접속이 가능하지만 권한 다음과 같이 403 Foribidden 상태 코드가 뜨는 것을 확인할 수 있습니다.
그리고 현재 formLogin을 비활성화했기 때문에 로그인을 할 수 없습니다. 그래서 우리는 시큐리티의 필터를 커스텀하여 로그인 기능을 구현해야 합니다.
커스텀 기본 로그인 설정
UserDetails을 구현한 PricipalDetails를 생성해 줍니다.
UserDetails는 시큐리티의 인증을 담당해 주는 객체입니다.
PrincipalDetails.class
@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {
private final Member member;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
ArrayList<GrantedAuthority> authorities = new ArrayList<>();
for (String role : member.getRoleList()) {
authorities.add(new SimpleGrantedAuthority(role));
}
return authorities;
}
@Override
public String getPassword() {return member.getPassword();}
@Override
public String getUsername() {return member.getUsername();}
@Override
public boolean isAccountNonExpired() {return true;}
@Override
public boolean isAccountNonLocked() {return true;}
@Override
public boolean isCredentialsNonExpired() {return true;}
@Override
public boolean isEnabled() {return true;}
}
다음과 같이 로그인이 사용될 객체를 클래스를 생성해 줍니다.
- getAuthorities() : Member 클래스에서 "ROLE_USER"로 설정된 권한을 Spring Security에서 사용되는 GrantedAuthority 객체로 설정합니다.
UserDetailsService를 구현한 PrincipalDetailService 클래스를 작성해 줍니다.
"UserDetailsService는 Spring Security가 사용자를 인증하는 데 사용되는 인터페이스입니다. 사용자 정보를 제공하여 AuthenticationManager에 전달하는 역할을 합니다. AuthenticationManager는 전달받은 사용자 정보를 기반으로 사용자를 인증하고 로그인 절차를 진행합니다."
PrincipalDetailService.class
@RequiredArgsConstructor
@Service
public class PrincipalDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByUsername(username);
if (member!=null) {
return new PrincipalDetails(member);
}
return null;
}
}
로그인 시에 아이디가 있다면 이전에 만들었던 PrincipalDetails객체를 생성해 반환해 주고 없다면 null을 반환하도록 합니다. 나중에 예외를 따로 처리하시면 됩니다.
이렇게 하면 커스텀 로그인 하는 로직은 모두 구현했습니다. 하지만 여기서 문제가 하나 발생합니다.
기본적으로는 formLogin을 사용하면 "UsernamePasswordAuthenticationFilter" 시큐리티가 자동으로 구성해서 로그인 요청을 타게 되지만 현재 SecurityConfig에서 formLogin(). disable()을 설정한 상태이기 때문에 " UsernamePasswordAuthenticationFilter"을 확장해서 직접 설정파일에 필터를 등록해줘야 합니다.
대략적인 요청의 흐름은 다음과 같은 흐름을 타면서 로그인을 실행합니다.
이제 UsernamePasswordAuthenticationFilter을 확장한 LofinFilter을 생성해 줍니다.
@RequiredArgsConstructor
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
@SneakyThrows
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
log.info("로그인 진행중");
ObjectMapper objectMapper = new ObjectMapper();
Member member =null;
try{
member=objectMapper.readValue(request.getInputStream(), Member.class);
}catch (IOException e){
e.printStackTrace();;
}
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(member.getUsername(), member.getPassword());
Authentication authenticate = authenticationManager.authenticate(token);
return authenticate;
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
log.info("로그인 성공");
}
}
저는 필터명을 로그인과 관련되어 있다 생각해 LofinFilter로 만들었습니다.
사용자가 로그인 요청을 보내면 해당 필터가 실행됩니다. 이 필터는 요청에서 사용자 정보를 가져와서 UsernamePasswordAuthenticationToken을 생성하고, 이를 authenticationManager에 전달하여 인증을 시도합니다. 인증이 성공하면 생성된 인증 객체를 반환합니다. 로그인 성공 시 successfulAuthentication 메서드가 실행됩니다. 이 메서드는 인증이 성공한 경우에만 호출되어 JWT 토큰을 생성하고 응답 헤더에 추가합니다. 그러고 나서 다음 필터로 요청을 전달합니다.
이제 SecurityConfig에 다음과 같이 필터를 직접 등록해 주는 코드를 추가해 줍니다.
//addFilterAt = 등록될 필터의 위치를 지정합니다.
http.addFilterAt(new LoginFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
로그인 요청을 보내면 HTTP 상태코드가 200 으로 나오며 로그를 보면 해당 LoginFilter을 타고 로그인이 성공하면 successfulAuthentication을 메서드가 실행되 로그인 성공됐다고 나옵니다.
커스텀 JWT 토큰을 사용한 로그인 처리
앞서 커스텀해 로그인처리 하는 방법을 알아보았습니다. 이제는 JWT토큰을 사용한 로그인 처리를 구현해 볼 것입니다. 먼저 직접 JWT토큰을 만들어서 보내줄 수 있지만, 라이브러리를 사용해서 간편하게 토큰을 생성해서 보내보도록 하겠습니다.
먼저 JWT 라이브러리를 사용하기 위해서 의존성을 추가해 줍니다.
implementation group: 'com.auth0', name: 'java-jwt', version: '3.10.2'
이전에 만들어두었던 successfulAuthentication 메서드를 이동해 Jwt토큰을 생성하는 로직을 작성해 줍니다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
PrincipalDetails principal = (PrincipalDetails) authResult.getPrincipal();
String JwtToken = JWT.create()
.withSubject("테스트용 로그인 jwt")
.withClaim("loginId", principal.getUsername())
.withClaim("password", principal.getPassword())
.withExpiresAt(new Date(System.currentTimeMillis() + 60 * 10 * 1000))
.sign(Algorithm.HMAC512("blog"));
response.addHeader("Authorization", "Bearer "+ token);
log.info("발급된 jwt 토큰 = {}", JwtToken);
log.info("로그인 성공");
}
jwt토큰의 대한 알고리즘과 구성은 이전 포스팅에 작성했으니 참고하시면 좋을 것 같습니다.
※ 절대 클레임에 민감한 정보 포함 NO!! ※
로그인을 요청해 보면 위와 같이 헤더 부분에 토큰이 발행된 것을 볼 수 있습니다. 이제 JWT토큰을 통해 해당페이지에 접속할 때 JWT토큰을 검증해 주는 필터를 등록해 보겠습니다.
httpBasic().disable()를 통해 기본적인 HTTP Basic 인증을 비활성화하면 관련된 BasicAuthenticationFilter도 함께 비활성화됩니다. 이렇게 하면 Spring Security 필터 체인에서 해당 필터가 동작하지 않게 됩니다.
그 후에 비활성화된 BasicAuthenticationFilter를 JWT 토큰 검증 필터로 대체하여 구현하고, 이 필터를 JwtLoginFilter보다 앞에 위치시켜 JWT 토큰 검증을 구현했습니다.
하지만 이러한 방법이 올바른 구현방법이 아닐 수 있습니다. 그 점 유의해 주셨으면 감사하겠습니다.
BasicAuthenticationFilter을 확장한 JwtAuthorizationFilter을 생성해 줍니다.
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private MemberRepository memberRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, MemberRepository memberRepository) {
super(authenticationManager);
this.memberRepository = memberRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
log.info("JWT 검증필터 실행");
String jwtHeader = request.getHeader("Authorization");
log.info("jwtr={}",jwtHeader);
if (jwtHeader == null || !jwtHeader.startsWith("Bearer ")) {
log.info("해당 요청에는 jwt토큰이 없어서 다음필터로 이동");
chain.doFilter(request, response);
return;
}
String jwtToken = jwtHeader.replace("Bearer ", "");
String username = JWT.require(Algorithm.HMAC512("blog")).build()
.verify(jwtToken)
.getClaim("username")
.asString();
if (username!=null){
Member byUsername = memberRepository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(byUsername);
Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails,null,principalDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("검증필터 성공후 다음 필터로 로그인 필터로 이동");
chain.doFilter(request,response);
}
if (username==null){
log.error("해당토큰은 유효하지 않습니다.");
}
}
}
위와 같이 request 헤더의 jwt토큰이 존재하지 않으면 다음 필터를 타도록 한다.
만약 헤더에 jwt 토큰이 존재한다면 jwt토큰의 클레임 값을 디코딩해 획득합니다. 그 후 db에 저장된 값이 일치한다면 해당 토큰은 유효한 토큰이라 판단해 SecurityContextHolder에 해당 회원을 넣어줍니다.
그후 다음 필터로 이동합니다.
SecurityConfig에 다음과 같이 필터를 등록합니다.
//addFilterBefore를 통해 LoginFilter 보다 먼저 동작하도록 설정
http.addFilterBefore(new JwtAuthorizationFilter(authenticationManager(),memberRepository),LoginFilter.class)
이렇게 하면 기본적인 jwt토큰을 이용해 로그인을 구현해 봤습니다.
실행
이제 구현한 코드가 정상적으로 작동하는지 한번 실행해 보도록 하겠습니다.
로그인실행 시
로그인을 실행하면 먼저 JwtAuthorizationFilter 가먼저 실행되면서 토큰을 검증합니다. 토큰이 없기 때문에 다음 필터는 LoginFilter로 이동하게 됩니다. 그 후 로그인을 성공하면 JWT토큰을 발급해 주는 것을 볼 수 있습니다.
발급해 줬다는 것은 로그인을 성공했다고 볼 수 있습니다.
로그인 사용자만 들어올 수 있는 페이지 접근 시
초기에 "/user/test" 로그인한 사용자만 접속할 수 있는 페이지를 만들어놨습니다.
토큰을 Header에 넣지 않고 접속을 하게 되면
다음과 같이 검증을 실패해 403 권한 오류가 발생하는 것을 볼 수 있습니다.
토큰을 Header에 넣고 접속을 하게 되면
해당 검증 후 해당 토큰은 유효한 토큰이라고 판단하여 해당 페이지에 접근할 수 있습니다.
정리
이번 포스팅에서는 JWT 토큰을 이용한 로그인 방법을 최대한 간단하게 구현해 보았습니다. 그러나 이 구현에서는 한 가지 문제가 발생합니다. 바로 검증하는 필터 부분이 너무 많은 책임을 갖고 있습니다.
Spring은 단일 책임 원칙(SRP)을 지향하고 있으므로, 이러한 많은 책임을 갖는 코드는 좋지 않다고 볼 수 있습니다. 이를 해결하기 위해 검증 부분의 책임을 담당하는 JwtProvider 클래스를 만들어서 해당 클래스에서 JWT 토큰 기능을 담당하도록 분리함으로써 유연하고 유지 보수성이 높은 코드를 작성할 수 있습니다.
그러나 이번 포스팅에서는 JWT 토큰을 사용하는 정도라면 어느 정도의 이해를 가지고 계실 것으로 생각되어, 별도의 구현을 하지 않았습니다.
다음에 기회가 된다면 JwtProvider를
그리고 이번글을 읽어주셔서 감사합니다. 틀린 부분이 있다면 댓글로 알려주시면 바로 정정할 수 있도록 하겠습니다. 감사합니다.
'Spring > SpringSecurity' 카테고리의 다른 글
[Spring Security] JWT(JSON Web Token) 토큰 [2] - JWT 토큰이란 무엇이며 사용되는 대칭키(AES) ,공개키(RSA) ,HMAC 알고리즘 이해하기 (0) | 2024.03.15 |
---|---|
[Spring Security] JWT(JSON Web Token) 토큰 [1] - JWT 토큰의 등장 배경 (0) | 2024.03.13 |
[SpringBoot-Security] 스프링 시큐리티 동작방식 간단하게 알아보기 (0) | 2023.12.16 |
[SpringBoot-Security] 스프링 시큐리티 AJAX 통신 사용 (0) | 2023.12.13 |