[Spring security] JWT login API 구현하기(3)

오영선·2023년 8월 9일
1

javaSpring

목록 보기
4/6

2편에 이어,
웹서비스의 로그인, 회원가입을 제외한 모든 API사용을 로그인이 된 후에만 이용할 수 있도록 해보자.
(완성코드는 아래에)

사용자가 로그인을 하게 되면 토큰과 함께 권한(authentication)을 부여받게 된다.

이 권한은 일종의 "filter"를 거쳐 유효성을 검사한다.
이후 사용자가 API를 요청할때, header에 jwt 토큰을 주게 되면 웹서버는 해당 토큰이 filter에서 유효한지 확인 후 RestApi를 실행할 것이다.

JwtTokenFilter class 생성하기


JwtTokenFilter는 API요청이 들어올때 요청이Servlet Filter거치며 실행되고, 사용자가 request와 함께 요청한 token을 확인 후 권한을 넘긴다.
따라서

        1. Resolve Header

        2. Token Validation

        3. get Username from token
         -> user valid check

        4. save Object in security context holer

        5. filter 넘기기

다섯단계를 순서대로 구현해보자

Filter 등록하기

addFilterBefore에 대해 더 참고 :
https://iseunghan.tistory.com/365
https://velog.io/@gwichanlee/Filter-FilterChain

addFilterBefore()을 사용하면 SpringSecurityFilter 보다 먼저 실행되게 된다.

SpringSecurityFilter (ex doFilter() )
공식문서 :
https://docs.spring.io/spring-security/site/docs/2.0.7.RELEASE/apidocs/org/springframework/security/ui/SpringSecurityFilter.html

Resolve Header

        final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if(header == null || !header.startsWith("Bearer ") )//jwt token은 bearer안에 담겨있음으로 헤더에도 bear담겨야함
        {
            log.error("Error occurs while getting header. header is null or invalid"); //header파싱 실패시
            filterChain.doFilter(request, response); //: 다음 필터로 넘어가시오
            return;
        }
       * log : lombok의 @Slf4j에서 추가, log 찍어서 error원인 파악하기 위함.

Token Validation

        final String token = header.split(" ")[1].trim(); //header : Bearer+" "+token으로 구성됨
        //token 만료 여부 확인
        if(isExpired(token, key)){
            //ture-> 만료됨
            log.error("Error occurs bcs key is expired.");
            filterChain.doFilter(request, response); //다음 필터로 넘김
            return;
    
   }

토큰 유효시간 검증 시 필요한 기능 구현하기
(물론, (2) 에서 구현해둔 JwtTokenUtils에 구현해도 무방함)

    private static boolean isExpired(String token, String key) {
        Date expiredAt = extractClaims(token, key).getExpiration();
        return expiredAt.before(new Date()); //현재에 비해 만료시간이 더 빠르면 true
    }

    private static Claims extractClaims(String token, String key) {
        //key로 token파싱해 claim추출 후 Body 반환
        return Jwts.parserBuilder().setSigningKey(getKey(key))
                .build().parseClaimsJws(token).getBody();
    }

    private static Key getKey(String key) {
        byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }

참고로 Claims(Interface)의 구조는 다음과 같다

  • iss: 토큰 발급자 (issuer)
  • sub: 토큰 제목 (subject)
  • aud: 토큰 대상자 (audience)
  • exp: 토큰의 만료시간 (expiraton)
  • nbf: Not Before 이 날짜가 지나기 전까지는 토큰이 처리되지 않습니다.
  • iat: 토큰이 발급된 시간 (issued at)
  • jti: JWT의 고유 식별자로서(일회용 토큰 사용시 유용)

get Username from token


   private static String getUserName(String token, String key) {
        return extractClaims(token, key).get("userName", String.class);
    }


hash 값 형태로 key(string) : value(object)를 Claims에 넣고, 가져올 수 있다.

save Object in security context holer

UsernamePasswordAuthenticationToken 에 User정보, User 권한 정보를 얻기 위해서는 User에 조금 변경이 필요하다.
User.getAuthorities()을 구현하기 위해 UserDetails를 impelment 해준다.

package com.haedal.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.haedal.model.entity.User;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true) //직렬화 속성무시
public class UserDto implements UserDetails {

    Long userId;
    String name;
    String phone;
    String password;
    UserRole role;


    public static UserDto of(Long userId,String name,String phone, String password, UserRole role){
        return new UserDto(userId, name, phone, password, role);
    }

    public static UserDto from(User user) {
        return new UserDto(
                user.getUserId(),
                user.getName(),
                user.getPhone(),
                user.getPassword(),
                user.getRole()
        );
    }
    public User toEntity(UserDto userDto){
        return User.builder()
                .userId(userId)
                .name(name)
                .phone(phone)
                .password(password)
                .build();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(this.getRole().toString()));
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.name;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}
            //save Object in security context holer
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    //complete principal, credentials, authorities
                    user, null, user.getAuthorities());
            //enum.toString -> ADMIN(0) -> Admin 으로 변경
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            //request 정보 넣어서 보내줌.
            SecurityContextHolder.getContext().setAuthentication(authentication);

UsernamePasswordAuthenticationFilter

username, password를 쓰는 form기반 인증을 처리하는 필터.
AuthenticationManager를 통한 인증 실행
- 인증 전 : 로그인아이디/패스워드 제공
(현재 사용X)

  • 토큰 유효성 성공하면, Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
  • 실패하면 AuthenticationFailureHandler 실행
  • setAuthentication(UsernamePasswordAuthenticationToken) :

UsernamePasswordAuthenticationToken :

principal - 인증이 완료된 사용자 객체(UserDetails의 구현체)
credentials - 인증 완료후 유출 가능성을 줄이기 위해 삭제(null)
authorities - 인증된 사용자가 가지는 권한 목록

프로젝트에는 User의 권한을 검사하는 항목을 위해 UserRole을 넣었지만 필요없다면 User.getAuthorities()에 return null로 설정하면 될듯함

filter 넘기기

 filterChain.doFilter(request, response); //다음 필터로 넘김
            return;

JwtTokenFilter 등록하기

SecurityConfig에 JwtTokenFilter를 등록해준다.

import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final UserService userService;
    @Value("${jwt.secret-key}")
    private String key;
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/users/join", "/login").permitAll()
                        //로그인 하지 않아도 접근 가능한 주소 설정해주기
                .antMatchers("/**").authenticated()
                        //그 외에는 로그인 필요(토큰 필요)
        		 .and()
                .sessionManagement()
               	.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(new JwtTokenFilter(key, userService), UsernamePasswordAuthenticationFilter.class)
                .        ;
        //JWTTOkenFilter : 토큰을 분석해 유저의 정보 받아옴
        // addFilter : UsernamePasswordAuthenticationFilter.class 필터 대신 직접만든 인증로직을 가진 필터를 생성하고 사용한다.
        //.addFilterBefore : 지정된 필터 앞에 커스텀 필터를 추가 new JwtTokenFilter()Username...보다 먼저 실행된다.

        super.configure(http);
    }
}

완성코드

JwtTokenFilter

package com.haedal.config.filter;

import com.haedal.model.UserDto;
import com.haedal.service.UserService;
import com.haedal.util.JwtTokenUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter{

    private final String key;
    private final UserService userService;

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //Resolve Header
        final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if(header == null || !header.startsWith("Bearer ") )//jwt token은 bearer안에 담겨있음으로 헤더에도 bear담겨야함
        {
            log.error("Error occurs while getting header. header is null or invalid"); //header파싱 실패시
            filterChain.doFilter(request, response); //다음 필터로 넘김
            return;
        }
        try{
        //Token Validation
            final String token = header.split(" ")[1].trim(); //header : Bearer+" "+token으로 구성됨
            //token 만료 여부 확인
            if(JwtTokenUtils.isExpired(token, key)){
                //ture-> 만료됨
                log.error("Error occurs bcs key is expired.");
                filterChain.doFilter(request, response); //다음 필터로 넘김
                return;
            }
            //get Username from token(claims)
            String userName = JwtTokenUtils.getUserName(token, key);
            // user valid check
            UserDto user = userService.getUserbyUserName(userName);
            if(user==null){
                log.error("user is not exist"); //header파싱 실패시
                filterChain.doFilter(request, response); //다음 필터로 넘김
                return;
            }
            //save Object in security context holer
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    //complete principal, credentials, authorities
                    user, null, user.getAuthorities());
            //enum.toString -> ADMIN(0) -> Admin 으로 변경
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            //request 정보 넣어서 보내줌.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }catch (RuntimeException e){
            log.error("Error occurs while validating.{}", e.toString());
            filterChain.doFilter(request, response); //마저 필터에 추가
            return;
        }
        //filter 넘기기
        filterChain.doFilter(request, response);
    }
}

JwtTokenUtils

package com.haedal.util;

import io.jsonwebtoken.Claims;
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;

public class JwtTokenUtils {
    public static String generateToken(String userName, String key, long exporedTimeMs){
        Claims claims = Jwts.claims();
        claims.put("userName", userName);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis()+exporedTimeMs))
                .signWith(getKey(key), SignatureAlgorithm.HS256)
                .compact();
    }

    public static boolean isExpired(String token, String key) {
        Date expiredAt = extractClaims(token, key).getExpiration();
        return expiredAt.before(new Date()); //현재에 비해 만료시간이 더 빠르면 true
    }

    public static Claims extractClaims(String token, String key) {
        //key로 token파싱해 claim추출 후 Body 반환
        return Jwts.parserBuilder().setSigningKey(getKey(key))
                .build().parseClaimsJws(token).getBody();
    }

    public static Key getKey(String key) {
        byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public static String getUserName(String token, String key) {
        return extractClaims(token, key).get("userName", String.class);
    }
}

이렇게 Jwt 설정이 완료되었음으로
4편에서는 PostMan을 이용해 Join/Login이 Jwt를 이용해 정상적으로 작동하고, 다른 API가 JwtTokenFilter를 거쳐 수행되는지 확인해보도록 하겠다.

0개의 댓글