

jwt token 은 토큰을 서버측에서 생성을 하고, 클라이언트에게 토큰을 http 메시지 header에 "Authorization"라는 이름으로 전달을 해준다stateless 하다는 장점이 있다



UsernamePasswordAuthentication 필터에서 검증을 진행하게 된다UsernamePasswordAuthenticationFilter 호출한 AuthenticationManager을 통해 진행하게 된다UsernamePasswordAuthentication Filter는 작동하지 않는다@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtUtil jwtUtil;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
return http
// csrf disable
.csrf((auth) -> auth.disable())
// Form 로그인 방식 disable -> jwt 인증 방식을 사용할 것이기 때문에
.formLogin((auth) -> auth.disable())
// http basic 인증 방식 disable
.httpBasic((auth) -> auth.disable())
// 경로별 인가 작업
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated()
// jwt는 세션을 stateless하게 관리한다
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
}
@Configuration : 설정파일로 등록@EnableWebSecurity : spring security 설정을 하겠단 어노테이션authorizeHttpRequests 를 통해 경로별 인가 작업을 따로 해준다permitAll()을 통해 인가 작업 없이 동작하게 해주었고.anyRequest().authenticated() 그 외의 주소들은, 인가를 모두 받아야 한다bCryptPasswordEncoder로 비밀번호를 암호화 하기 위해 Bean으로 등록해준다
BCryptPasswordEncoder를 통해 비밀번호를 암호화해서 등록하겠다authenticationManager에서 토큰의 검증을 담당한다UserDetailService를 이용해서, db에서 로그인 정보가 맞는지를 검증한다SucessfulAuthentication으로 이동하고, 실패하면 AuthenticationFailureHandler 로 이동을 한다SucessfulAuthentication 에서 jwt token을 만든 후, response의 header에 담아서 client에게 전달해 주면 된다@Entity
@Table
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class UserEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String password;
private String role;
}
@Data
public class JoinDTO {
private String email;
private String password;
}
@Data
@Builder
public class JoinResponse {
private String email;
}
public interface UserRepository extends JpaRepository<UserEntity,Long> {
UserEntity findByEmail(String email);
}
@Service
@RequiredArgsConstructor
public class JoinService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
public UserEntity joinProcess(JoinDTO joinDTO){
UserEntity findByEmail = userRepository.findByEmail(joinDTO.getEmail());
if (findByEmail!=null){
throw new ApiException(UserError.DUPLICATE_USER_EMAIL);
}
UserEntity saveUser = UserEntity.builder()
.email(joinDTO.getEmail())
.password(bCryptPasswordEncoder.encode(joinDTO.getPassword()))
.role("ROLE_ADMIN")
.build();
return userRepository.save(saveUser);
}
}
exceptionHandler 에서 한번에 처리해준다builder 패턴을 이용하고(비밀번호는 bCryptPasswordEncoder를 통해 암호화 해서 저장한다)@RestController
@RequiredArgsConstructor
public class JoinController {
private final JoinService joinService;
@PostMapping("/join")
public Api<JoinResponse> joinProcess(JoinDTO joinDTO){
UserEntity userEntity = joinService.joinProcess(joinDTO);
return Api.OK(JoinResponse
.builder()
.email(userEntity.getEmail())
.build());
}
}


UsernamePasswordAuthenticationFilter을 구현한 LoginFilter을 만들고, config에 등록해줄 것이다@RequiredArgsConstructor
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//클라이언트 요청에서 username, password 추출
String email = request.getParameter("email");
String password = request.getParameter("password");
//스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null);
return authenticationManager.authenticate(authToken);
}
//로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
}
//로그인 실패시 실행하는 메소드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
}
}

UsernamePasswordAuthenticationToken을 만든다UserDetailService를 통해 아이디, 비밀번호가 맞는지 확인하고, 맞다면 authenticationManager를 통해서, Authentication을 만든다**AuthenticationException 예외를 던지고, 이 예외는 unsuccessfulAuthentication에서 다시 로그인 페이지로 이동하는 등의 예외처리를 할 수 있다AuthenticationFailureHandler 곳에서 예외에 대한 메시지를 전달해 줄 수 있다.UserDetails 인터페이스를 구현한다@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final UserEntity userEntity;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collections = new ArrayList<>();
collections.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userEntity.getRole();
}
});
return collections;
}
@Override
public String getPassword() {
return userEntity.getPassword();
}
@Override
public String getUsername() {
return userEntity.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UsernameNotFoundException의 예외처리를 해줘야 한다UsernameNotFoundException는 AuthenticationException을 상속받고 있으므로, 예외처리시 AutheticationException을 처리해도 된다!@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userEntity = userRepository.findByEmail(username);
if (userEntity != null){
return new CustomUserDetails(userEntity);
}
return null;
}
}
자 이제 SpringSecurity의 로그인 과정에 대한 모든 부분에 대해서 구현을 하였다
- 이제 만든 LoginFilter을 SecurityConfig에 등록시켜주자!
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
return http
// csrf disable
.csrf((auth) -> auth.disable())
// Form 로그인 방식 disable -> jwt 인증 방식을 사용할 것이기 때문에
.formLogin((auth) -> auth.disable())
// http basic 인증 방식 disable
.httpBasic((auth) -> auth.disable())
// 경로별 인가 작업
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)), UsernamePasswordAuthenticationFilter.class)
// jwt는 세션을 stateless하게 관리한다
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
// authenticationManager을 Bean으로 등록!
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception{
return configuration.getAuthenticationManager();
}
}
addFilterAt() : 원하는 자리에 필터 등록addFilterBefore() : 해당 필터 전에addFilterAfter() : 해당 필터 후에.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration)),
UsernamePasswordAuthenticationFilter.class)
authenticationManager를 사용하므로 넣어주고authenticationConfiguration사용하므로 @Bean으로 등록 후 추가해준다이제 로그인 성공을 한다면, successfulAuthentication()를 통해 Jwt를 생성하는 로직을 추가하자
@Value를 이용해서 사용할 수 있다spring:
jwt:
secret-key: springbootstudyjwttoken20240321thiskeyshouldbelong
@Component
public class JwtUtil {
private final SecretKey secretKey ;
public JwtUtil(@Value("${spring.jwt.secret-key}") String secret){
this.secretKey =new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
// 전달 받은 토큰에서 payLoad의 claims에서 username(email)을 얻기
public String getUsername(String token){
JwtParser parser = makeParser();
return parser.parseSignedClaims(token)
.getPayload()
.get("username", String.class);
}
// 전달 받은 토큰에서 payLoad의 claims에서 role을 얻기
public String getRole(String token){
JwtParser parser = makeParser();
return parser.parseSignedClaims(token)
.getPayload()
.get("role", String.class);
}
// 전달 받은 토큰이 만료되었는지 확인 -> Bool 반환
public Boolean isExpired(String token){
JwtParser parser = makeParser();
return parser.parseSignedClaims(token)
.getPayload()
.getExpiration()
.before(new Date());
}
// 토큰을 생성 하는 메서드
public String createJwt(String username, String role, Long expiredMs){
return Jwts.builder()
.claim("username", username)
.claim("role",role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
// parser을 이용해서, jwt token 검증 메서드
public boolean validToken(String token){
try{
makeParser()
.parseClaimsJws(token);
return true;
} catch (Exception e){
if (e instanceof SignatureException){
throw new TokenException(TokenErrorCode.INVALID_TOKEN);
}
else if (e instanceof ExpiredJwtException) {
// 만료된 토큰
throw new TokenException(TokenErrorCode.EXPIRED_AT,e);
}
else {
// 그 외 처리
throw new TokenException(TokenErrorCode.INVALID_TOKEN,e);
}
}
}
private JwtParser makeParser() {
return Jwts.parser().verifyWith(secretKey).build();
}
}
@Component 등록을 해준다createJwt 메서드를 통해, Jwt token을 만들고 claim에 username, role을 넣어주고, 시간, 만료시간을 지정해주고 sercrekey를 통해 서명해주고 compact를 해준다Jwts.parser를 이용해 claim과 정보를 통해 token에서 원하는 정보를 뽑는 메서드이다validToken은 token에 대해서 해당 예외들이 나면 예외를 던져 주는 메서드이다클라이언트가 처음 로그인 할 때는, 요청 request 메시지에 token이 없다
즉, 로그인 과정에서는 token을 검증하는 JwtFilter가 동작할 필요가 없다
그런데, 특정 경로와 함께 token을 전달한다면? 이 경우에는 request 메시지에 token이 있다.
이 경우에는 token을 검증하는 JwtFilter가 동작해야 한다
RFC 7235 정의에 따라 아래 인증 헤더 형태를 가져야 한다.Authorization: 타입 인증토큰
//예시
Authorization: Bearer 인증토큰string
successfulAuthentication에서 토큰을 발행해줘야 하기 때문에SecurityConfig의 LoginFilter을 추가하는 부분에더 JwtUtil을 주입받고 넣어줘야 한다!@RequiredArgsConstructor
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
//클라이언트 요청에서 username, password 추출
String email = request.getParameter("email");
String password = request.getParameter("password");
System.out.println(email);
//스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 함
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null);
return authenticationManager.authenticate(authToken);
}
//로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) {
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
// username(email)가져오기
String email = customUserDetails.getUsername();
// 권한 가져오기
Collection<? extends GrantedAuthority> authorities = customUserDetails.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority authority = iterator.next();
String role = authority.getAuthority();
// jwt 생성
String token = jwtUtil.createJwt(email, role, 60 * 60 * 10L);
response.addHeader("Authorization","Bearer " + token);
}
//로그인 실패시 실행하는 메소드
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {
response.setStatus(401);
}
}
Authentication에서 getPrincipal을 통해 + downcation으로 CustonUserDetails를 가져온다RFC 7235 정의에 따라 Autorization 헤더 이름에 Bearer +token으로 header에 토큰을 넣어서 전달해준다
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request에서 Authorization 헤더를 찾기
String authorization = request.getHeader(HEADER_AUTHORIZATION);
// Authorization 헤더 검증 -> jwt token인지
if (authorization == null || !authorization.startsWith("Bearer ")){
log.info("token null");
filterChain.doFilter(request,response);
// 다음 조건이 해당하면 -> 메서드 종료
return;
}
// 가져온 값에서 접두사 제거 -> 토큰 꺼내 오기
String token = getAccessToken(authorization);
if (jwtUtil.validToken(token)){
// 토큰에서 username, role 획득
String email = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
// userEntity를 생성해서 해당 값을 넣어준다
UserEntity userEntity = UserEntity.builder()
.email(email)
.role(role)
.password("temppassword")
.build();
//UserDetails에 회원 정보 객체 담기
CustomUserDetails customUserDetails = new CustomUserDetails(userEntity);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
}
filterChain.doFilter(request, response);
}
private String getAccessToken(String authorization){
if (authorization != null && authorization.startsWith(TOKEN_PREFIX)){
return authorization.substring(TOKEN_PREFIX.length());
}
return null;
}
}
Authentication이라는 header가 있을 때만 실행한다.getAccessToken메서드를 통해 실제 token만 꺼내고validToken을 통해 true이면, 토큰에서 이름과, role을 꺼내 인증 객체를 하나 만들어 주고UsernamePasswordAuthenticationToken을 생성해 SecurityConextHolder안의 SecurityConext안에 Authentication을 저장한다!!!Stateless 상태로 관리되므로 해당 요청이 끝나면 소멸된다OncePerRequestFilter : 요청에 대해서 한번만 작동하는 필터@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtUtil jwtUtil;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
return http
// csrf disable
.csrf((auth) -> auth.disable())
// Form 로그인 방식 disable -> jwt 인증 방식을 사용할 것이기 때문에
.formLogin((auth) -> auth.disable())
// http basic 인증 방식 disable
.httpBasic((auth) -> auth.disable())
// 경로별 인가 작업
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/login", "/", "/join").permitAll()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(new JwtFilter(jwtUtil), LoginFilter.class)
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class)
// jwt는 세션을 stateless하게 관리한다
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
// authenticationManager을 Bean으로 등록!
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception{
return configuration.getAuthenticationManager();
}
addFilterBefore() 메서드를 사용했다@RestController
public class AdminController {
@GetMapping("/admin")
public String adminP(){
return "admin Controller";
}
}
로그인을 성공한 후, header로 받은 token값을 이용해서
인가가 필요한 admin 페이지에 해당 token값을 header에 넣은 후 요청을 보내본다

정상 작동하는 모습을 볼 수 있다!!!
- SpringSecurity + JwtToken에 대해서 이제 알아보았다
- cors, crfs에 대해서도 추가적으로 알아보자!
- 지금은 token이 유효시간이 지나면, TokenException이나는 것으로 예외처리를 했지만, refresh token을 발급받는 부분도 공부를 해보자!