이번시간엔 JWT 로그인을 구현해보자.
- 의존성 추가
- application.yml 에 Jwt Secret 키를 등록해준다.
- UserDetails 를 구현한 CustomUserDetails
- 사용자 인증 인가를 처리할 UserDetailService
- JWT 토큰을 생성하고 검증하는 JwtProvider 를 만든다.
- JWT 토큰을 사용하여 요청에 대한 인증 인가를 처리하는 OncePerRequestFilter 를 확장한 필터를 구현한다.
- SpringSecurityConfig 클래스에 SecurityFilterChain 빈을 등록한다.
build.gradle 파일 dependencies 에
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
jjwt 라이브러리의 클래스를 사용하여 Key 타입 값을 설정하기 위해 사용할 예정.
application.yml 파일에 Jwt Secret 을 추가해준다.
jwt: secret: VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHb11
SpringSecurity 에서 사용되는 인터페이스 UserDetails 의 구현체로 사용자의 인증 정보와 권한 정보를 제공하는 메서드를 정의한다.
package com.hello.hello.utils;
import com.hello.hello.domain.entity.Member;
import java.util.Collection;
import java.util.stream.Collectors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class CustomUserDetails implements UserDetails {
private final Member member;
public CustomUserDetails(Member member) {
this.member = member;
}
// Member Entity 를 필드로 가지고 생성자 주입을 이용해 초기화 해준다.
public Member getMember() {
return member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return member.getRoles().stream().map(o -> new SimpleGrantedAuthority(o.name()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getEmail();
}
// Member Entity 의 권한 , password , username 에 대한 Getter
// 계정 만료
@Override
public boolean isAccountNonExpired() {
return true;
}
// 계정 락
@Override
public boolean isAccountNonLocked() {
return true;
}
// 암호의 유효기간 확인
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 계정 활성화
@Override
public boolean isEnabled() {
return true;
}
}
SpringSecurity 에서 사용되는 인터페이스 UserDetailsService의 구현체로
사용자 정보를 가져오는 역할을 담당한다.
package com.hello.hello.service;
import com.hello.hello.domain.entity.Member;
import com.hello.hello.repository.MemberJpaRepository;
import com.hello.hello.utils.CustomUserDetails;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
// 생성자 주입 방식으로 MemberJpaRepository 를 주입받는다.
private final MemberJpaRepository memberJpaRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// email 을 받아 memberjparepository 를 사용하여 member entity 를 찾는다.
Member member = memberJpaRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(email + "회원을 찾을수 없습니다."));
// 찾은 member 를 인자로 CustomUserDetails 를 생성하여 리턴해준다.
return new CustomUserDetails(member);
}
}
JWT의 생성 유효성 검증, 정보 추출 등을 하는 클래스 이며 SpringSecurity 와 함께 사용된다.
package com.hello.hello.utils;
import com.hello.hello.domain.Authority;
import com.hello.hello.service.CustomUserDetailsService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Set;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import javax.transaction.Transactional;
import lombok.RequiredArgsConstructor;
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;
@RequiredArgsConstructor
@Component
@Transactional
public class JwtProvider {
// application.yml 에서 등록한 jwt.secret 값을 넣어준다.
@Value("${jwt.secret}")
private String secretKey;
// 우리가 사용할 비밀 키값이다.
private Key key;
private final long exp = 1000L * 60 * 60;
private final CustomUserDetailsService customUserDetailsService;
// @PostConstruct 는 아래 메서드를 해당 클래스의 인스턴스가 생성된 후에 자동으로 호출되도록 해준다.
@PostConstruct
protected void init() {
// init 메서드를 통해 application.yml 에 등록한 String jwt secret 을
// Key 타입의 key 값으로 설정해준다.
key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String createToken(String username, Set<Authority> roles) {
// username, roles 를 받아 토큰을 생성한다.
//jjwt library 를 사용하여 claims 를 생성하고 username 을 subject 값으로 설정
Claims claims = Jwts.claims().setSubject(username);
// 생성한 클래임에 키 "role" 값 role 설정.
claims.put("roles", roles);
Date now = new Date();
// Jwts란 ?
// JWT 토큰을 생성하고 처리하기 위한 Java 의 라이브러리인 jjwt 의 클래스로
// JWT의 생성 파싱 검증을 쉽게 처리할수 있는 기능을 제공해준다.
/*
Jwts.builder(): JWT를 생성하기 위한 빌더 객체를 생성합니다.
setHeaderParam(): JWT 헤더에 파라미터를 추가합니다.
setSubject(): JWT의 subject(주제) 클레임을 설정합니다. 주로 사용자 식별 정보를 포함합니다.
claim(): JWT의 클레임을 설정합니다. 클레임은 payload에 포함되는 정보를 의미하며, 사용자 정의 데이터를 포함할 수 있습니다.
setExpiration(): JWT의 만료 시간을 설정합니다.
signWith(): JWT를 서명하는 알고리즘과 비밀키를 설정합니다.
compact(): 최종적으로 JWT를 생성하여 문자열 형태로 반환합니다.
*/
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + exp))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/*
getAuthentication() 은 Jwt 토큰을 인자로 받아
customUserDetailsService 의 loadUserByUsername() 메서드를 호출하여
UserDetails 객체를 받아
UsernamePasswordAuthenticationToken(userDetails,"{password}",userDetails.getAuthorities()) 를 반환.
userDetails : 인증된 사용자 정보
"" : JWT 로그인 방식에선 실제 비밀번호가 필요하지 않으므로 ""
userDetails.getAuthorities() : 사용자의 권한 정보
*/
public Authentication getAuthentication(String token) {
UserDetails userDetails = customUserDetailsService.loadUserByUsername(this.getMember(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
/*
getMember() 는 JWT 토큰을 인자로 받아
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)
를 통해 토큰 유효성 검사를 하고 토큰의 클레임을 추출한다.
추출한 클레임에 getBody().getSubject() 를 호출하여 사용자의 식별자 subject 를 반환.
*/
public String getMember(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
} catch (ExpiredJwtException e) {
e.printStackTrace();
return e.getClaims().getSubject();
} catch (Exception e) {
e.printStackTrace();
}
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject();
}
/*
HttpServletRequest 에서 토큰을 추출하는 메서드이다.
요청에 대해 Authorization 이라는 헤더 값 (토큰)을 추출한다.
*/
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
/*
토큰을 인자로 받아 유효성 검사를 하는 메서드이다.
token 이 "BEARER" 로 시작하지 않는 경우에는 유효한 토큰으로 간주하고
"BEARER" 로 시작할 경우 "BEARER" 을 제외한 토큰값을 얻어
토큰 구문을 분석하고 클레임을 추출한 후
토큰의 만료 시간이 지났는지 판단한다.
*/
public boolean validateToken(String token) {
try {
if (!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
return true;
} else {
token = token.split(" ")[1].trim();
}
Jws<Claims> claimsJws = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return !claimsJws.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
SpringSecurity 의 OncePerRequestFilter 의 자식 클래스로
JWT 토큰을 추출하고 토큰이 유효한 경우에만 인증 정보를 설정하여 보안 컨텍스트에 저장한다.
package com.hello.hello.utils;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// jwtProvider.revolveToken() 메서드로 토큰 추출
String token = jwtProvider.resolveToken(request);
// token 이 null 이 아니고 validateToken 을 통과하면
// 인증정보를 현재 보안 컨텍스트에 설정한다.
if (token != null && jwtProvider.validateToken(token)) {
token = token.split(" ")[1].trim();
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request,response);
}
}
SpringSecurity 의 설정을 담당하는 클래스로
구성 요소를 설정하고 보안 규칙과 인증/인가 방식을 정의한다.
package com.hello.hello.config;
import com.hello.hello.utils.JwtAuthenticationFilter;
import com.hello.hello.utils.JwtProvider;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/*
@Configuration
@EnableWebSecurity 을 사용하여 Spring Security 구성을 활성화한다.
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SpringSecurityConfig {
private final JwtProvider jwtProvider;
// 앞서 한번 언급했지만 User 정보인 Password 를 그대로 저장하는 것은 보안상 좋지 않다.
// PasswordEncoder 를 통해 변경하여 저장할 예정이라 Bean으로 수동 등록해주었다.
// 처음 사용한 수동 빈 등록
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 기존의 WebSecurityConfigurerAdapter 를 상속받아 구현하는 방법이 deprecated 되면서
// 수동 빈으로 등록하는 방법을 사용하겠다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
/* http 객체를 통해 다양한 보안 설정을 구성할 것이다.
.csrf().disable() csrf 보호 기능을 비활성화 한다.
csrf 란 : 정상적인 사용자가 의도치 않은 위조 요청을 보내는것을 의미한다.
그럼 사용해야 하는것 아니냐 ?
rest api 를 이용한 서버에서 jwt 로그인 방식을 사용하면 stateless 하기 때문에
csrf 공격으로부터 안전하다.
*/
http.csrf().disable()
/*
Spring Security HTTP 응답 헤더에 대한 설정이며 H2 Console 을 사용하기 때문에 비활성화
*/
.headers().frameOptions().disable()
.and()
/*
세션 관리 설정으로 세션 관리 방식을 stateless 로 설정한 것이다.
*/.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
/*
.authorizeRequests() 메서드로 URL에 대한 접근 권한을 설정한다.
*/
.authorizeRequests()
/*
아래 URL에 대해
*/
.antMatchers("/member","/h2-console/**")
/*
모든 요청에 대해 허용한다.
*/
.permitAll()
/*
그 외의 요청에 대해선
*/
.anyRequest()
/*
인증된 사용자만 접근을 허용한다.
*/
.authenticated()
.and()
/*
JWT 인증 필터를 추가한다.
*/
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
/*
인증 및 인가 예외에 대한 처리 방식을 설정한다.
*/
.exceptionHandling()
/*
권한이 없는 사용자의 처리를 담당한다.
*/
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException, ServletException {
response.setStatus(403);
response.setCharacterEncoding("utf-8");
response.setContentType("test/html; charset=UTF-8");
response.getWriter().write("권한이 없는 사용자입니다.");
}
})
/*
인증되지 않은 사용자의 처리를 담당한다.
*/
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
response.setStatus(401);
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("인증되지 않은 사용자입니다.");
}
});
return http.build();
}
}