JWT token 발급하기

김명래·2023년 3월 28일
0

JWT token 이란 ?

Json Web Token은 선택적 서명 및 선택적 암호화를 사용하여 데이터를 만들기 위한 인터넷 표준으로이다.

구성

  • 헤더 :
  • 페이로드 : 페이로드는 몇몇 클레임(claim) 표명(assert)을 처리하는 JSON을 보관하고 있다.
  • 서명 : Base64로 복화한 key를 저장하고 있다.

로직

로그인 이후에 성공한다면 해당 계정정보가 담긴 JWT Token을 발급받고

jwt의 인증 유효사항을 ServletFilter를 이용해 관리할 수 있을거라고 생각했다.

servletFilter란 ?

dispatcher servlet에 도달하기 전에 servletFilter가 위치해 있어, dispatcher servlet에 향하거나 거쳐 돌아온 요청들에 부가작업을 진행할 수 있다.
개념만 보았을때는 interceptor와 비슷하지만 interceptor는 spring container 안에 있고 dispatcher servlet 이후에 위치해 있다.

먼저 gradle에 의존성부터 추가해준다.

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'

주의해야할 것은 starter-security 를 depedencies의 추가하면 자동으로 DefaultAuthenticationEventPublisher가 Bean으로 등록된다. 따라서 인증이 없는 컨트롤러 테스트가 다 무용지물이 돼버리니 참고하자.

JwtTokenProvider

JWT Token에 access/refresh token을 생성/관리 해주는 class 이다.


@Slf4j
@Component
public class JwtTokenProvider {
    private final Key key;

    private final long accessTokenValidTime = Duration.ofMinutes(30).toMillis();
    private final long refreshTokenValidTime = Duration.ofDays(14).toMillis();


    /**
     * @param secretKey
     * 지정한 secretKey를 base64로 디코딩하여 저장한다.
     * 이 decoding 한 key는 token을 암호화, 복호화할 때 사용된다.
     */
    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }


    /**
     * @param authentication
     *  유저 정보를 받고 accessToken 과 refreshToken을 생성하는 메소드이다.
     *  기본 설정시간은 accessTokenValidTime, refreshTokenValidTime에 설정된시간 으로 적용되고
     *  각 시간은 accessToken은 30분 refreshToken은 14일이다.
     *  그렇게 적용된 시간은 Member객체에 추가하여 반환한다.
     *  각 객체에 서명은 HS256으로 암호화 한다.
     * @return TokenInfo 객체
     */
    public TokenInfo generateToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));
        long now = (new Date()).getTime();

        Date accessTokenExpiresIn = new Date(now + this.accessTokenValidTime);

        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + this.refreshTokenValidTime))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    /**
     * @param accessToken
     * JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드이다.
     * accessToken에 claims(사용자 ID, 전자 메일 주소, 역할 또는 권한, 인증 또는 부여 프로세스와 관련된
     * 기타 속성과 같은 정보가 있다.)을 파싱(복호화)하여 권한정보들을 List객체에 답아놓고 해당 List에 있는 값들로
     * 계정 정보를 생성한 뒤 반환한다.
     * @return token
     */
    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .toList();

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }


    /**
     * @param token
     * 토큰 정보를 검증하는 메서드
     *
     * @return boolean
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {
    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException, ServletException, IOException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

이후 filter를 적용하기 위한

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeHttpRequests()
                .requestMatchers("/member/login").permitAll()
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}
  1. 토큰 기반 인증과 같은 안전한 매커니즘을 위해 HTTP기본 인증을 비활성화 한다.
  2. CSRF(Cross-Site-RequestForgery)를 비활성화한다.
  3. session을 사용하지 않겟다는 의미
  4. permitAll은 모든 사용자의 요청을 허용한다는 의미이고 그 외에 요청은 허용하지 않는다는 의미이다.
  5. 생성한 필터를 적용한다.
  6. 사용할 PasswordEncoder를 빈에 등록한다.

사용할 Member entity에 UserDetails를 implements해주고 해당 interface를 상속하기위해 override할 method들을 override해준다.

@Table(name = "MEMBER")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@EqualsAndHashCode(of="userId")
@ToString
@DynamicUpdate
@DynamicInsert
public class MemberEntity implements UserDetails {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "SEQ_NO")
    private Long seqNo;
    @Column(name = "USER_ID", unique = true, nullable = false)
    private String userId;
    @Column(name = "USER_PWD", nullable = false)
    private String userPwd;
    @Column(name = "USER_NAME", nullable = false)
    private String userName;
    @Column(name = "EMAIL", nullable = false)
    private String email;
    @Column(name = "PHONE", nullable = false)
    private String phone;
    @Column(name = "CREATE_DATE")
    private LocalDateTime createDate;
    @Column(name = "MODIFY_DATE")
    private LocalDateTime modifyDate;
    @Column(name = "STATUS", columnDefinition = "varchar(1) default 'Y'", nullable = false)
    @Check(constraints = "(STATUS IN ('Y', 'N'))")
    private String status;
    @Column(name = "CREATE_ID", nullable = false)
    private String createName;
    @Column(name = "MODIFY_ID", nullable = false)
    private String modifyName;
    @Column(name = "DEPARTMENT")
    private String department;
    @Column(name = "LIFE_DATE", nullable = false)
    private LocalDateTime lifeDate;

    @Column(name = "AUTHORITY", nullable = false)
    private String authority;

    @PrePersist
    protected void onCreate(){
        this.modifyDate = LocalDateTime.now();
        this.createDate = LocalDateTime.now();
        this.status = "Y";
    }
    @PreUpdate
    protected void onUpdate(){
        modifyDate = LocalDateTime.now();
    }
    public Member toDefaultDto(){
        return Member.builder()
                .userId(this.userId)
                .userPwd(this.userPwd)
                .createDate(this.createDate)
                .modifyDate(this.modifyDate)
                .status(this.status)
                .email(this.email)
                .phone(this.phone)
                .createName(this.createName)
                .modifyName(this.modifyName)
                .department(this.department)
                .lifeDate(this.lifeDate)
                .seqNo(this.seqNo)
                .userName(this.userName)
                .authority(this.authority)
                .build();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        ArrayList<SimpleGrantedAuthority>list = new ArrayList<SimpleGrantedAuthority>();
        list.add(new SimpleGrantedAuthority(this.authority));
        return list;
    }

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

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

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

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

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

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

Member Service

/**
     * @param userId
     * @param password
     * authenticationToken객체를 생성합니다. 이때 생성한 객체는 "인증"을 거친 객체는 아니며 이를 authenticationManagerBuilder에 전달할 때
     * 인증이 진행됩니다. 이떼 CustomUserDetailService에서 만든 loadUserByUsername 메소드가 실행됩니다.
     * @return TokenInfo
     */
    @Transactional
    public TokenInfo loginMember(String userId, String password){
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userId, password);
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        return jwtTokenProvider.generateToken(authentication);
    }

CustomUserDetailService

해당 클래스는 memberRepository로 멤버 id로 정보를 조회해 온다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        MemberEntity entity = memberRepository.findByUserId(username);
        if(entity == null) throw new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다.");
        return createUserDetails(entity);
    }

    private UserDetails createUserDetails(MemberEntity member) {
        return User.builder()
                .username(member.getUsername())
                .password(passwordEncoder.encode(member.getPassword()))
                .roles(member.getAuthority())
                .build();
    }
}

MemberController

@PostMapping("/login")
    public HashMap<String, Object> loginMember(@RequestBody @Validated Member m){
        response = new HashMap<String, Object>();
        String userId = m.getUserId();
        String userPwd = m.getUserPwd();
        TokenInfo tokenInfo = ms.loginMember(userId, userPwd);
        if(tokenInfo != null){
            response.put("status", "success");
            response.put("Member", tokenInfo);
        }else{
            response.put("status", "false");
        }
        System.out.println(response);
        return response;
    }
profile
독자보다 필자를 위해 포스팅합니다

0개의 댓글