🌱 Spring Security - 토큰방식 인증 (JWT)

Kim Dae HyunΒ·2021λ…„ 8μ›” 16일
4

Spring-Security

λͺ©λ‘ 보기
2/2
post-thumbnail

Github 전체 μ†ŒμŠ€μ½”λ“œ

μ΄λ²ˆμ— 토이 ν”„λ‘œμ νŠΈλ‘œ λ°±μ‹  μ˜ˆμ•½ μ›Ή μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ μ œμž‘ 쀑에 있고 이미 인증 뢀뢄은 κ΅¬ν˜„λœ μƒνƒœμž…λ‹ˆλ‹€. μ„Έμ…˜μ„ μ΄μš©ν•˜λŠ” Stateful μƒνƒœλ‘œ κ΅¬ν˜„λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

μ˜ˆμ•½ μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ˜ 경우 νŠΉμ • λ‚ μ§œ, νŠΉμ • μ‹œκ°„μ— μ‚¬μš©μžκ°€ λͺ°λ¦¬λŠ” κ²½μš°κ°€ λ§ŽμŠ΅λ‹ˆλ‹€. λ•Œλ¬Έμ— scale-out이 μš©μ΄ν•΄μ•Ό ν•©λ‹ˆλ‹€. ν•˜μ§€λ§Œ μ„Έμ…˜λ°©μ‹μ„ μ‚¬μš©ν•˜λŠ” 경우 ν™•μž₯에 μ „ν˜€ μš©μ΄ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

ν”„λ‘œμ νŠΈ μ™„μ„± ν›„ νƒˆμ„Έμ…˜(?)을 μ‹œλ„ν•˜κΈ° μœ„ν•΄ 토큰방식 인증의 κ°€μž₯ λŒ€ν‘œμ  ν‘œμ€€μΈ JWTλ₯Ό μ΄μš©ν•΄ 인증을 κ΅¬ν˜„ν•΄λ³΄κ³ μž ν•©λ‹ˆλ‹€.


πŸ“Œ ν”„λ‘œμ νŠΈ μ„€μ •

  • Springboot 2.5.x
  • Maven
  • Spring-security
  • Lombok
  • jjwt (jsonwebtoken) 0.9.1
  • jaxb-api 2.3.1

πŸ”Ž SeucirtyConfig μ„€μ •νŒŒμΌ μž‘μ„±

Spring Security의 섀정을 μ»€μŠ€ν…€ν•˜κΈ° μœ„ν•œ μ„€μ •νŒŒμΌμ„ μž‘μ„±ν•©λ‹ˆλ‹€.

/auth 경둜둜 μΈμ¦μš”μ²­μ„ ν•  κ²ƒμ΄λ―€λ‘œ 접근을 ν—ˆκ°€ν•˜κ³  κ·Έ μ™Έ λͺ¨λ“  μš”μ²­μ€ 인증이 ν•„μš”ν•˜λ„λ‘ ν–ˆμŠ΅λ‹ˆλ‹€.

sessionCreationPolicy STATELESS 둜 μ„€μ •ν•΄μ£Όλ―€λ‘œ μ„Έμ…˜μ„ μ‚¬μš©ν•˜μ§€ μ•Šλ„λ‘ μ„€μ •

λ‹€μŒμœΌλ‘œ 인증객체λ₯Ό λ§Œλ“€μ–΄μ€„ UserDetailsServiceλ₯Ό μ •μ˜ν•©λ‹ˆλ‹€.

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomUserDetailsService customUserDetailsService;
    private final JwtAuthenticateFilter jwtAuthenticateFilter;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(customUserDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.csrf().disable()
            .authorizeRequests().antMatchers("/auth").permitAll() 
            .anyRequest().authenticated();
        // Stateless (μ„Έμ…˜μ‚¬μš©X)
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        // UsernamePasswordAuthenticationFilter 에 λ„λ‹¬ν•˜κΈ° 전에 μ»€μŠ€ν…€ν•œ ν•„ν„°λ₯Ό λ¨Όμ € λ™μž‘μ‹œν‚΄
        http.addFilterBefore(jwtAuthenticateFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

πŸ”Ž UserDetailsService μž‘μ„±

보톡 이 λΆ€λΆ„μ—μ„œλŠ” DBμ—μ„œ username으둜 Userλ₯Ό μ‘°νšŒν•΄μ•Ό ν•˜λŠ”λ° ν…ŒμŠ€νŠΈλ‘œ κ°„λ‹¨ν•˜κ²Œ 직접 User 객체λ₯Ό λ§Œλ“€μ—ˆμŠ΅λ‹ˆλ‹€.

μ—¬κΈ°μ„œ Userκ°μ²΄λŠ” μ—”ν‹°ν‹°κ°€ μ•„λ‹™λ‹ˆλ‹€ !!

import org.springframework.security.core.userdetails.User;

userdetails의 User ν΄λž˜μŠ€μž…λ‹ˆλ‹€.

@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final PasswordEncoder encoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return new User("kim", encoder.encode("1234"), AuthorityUtils.createAuthorityList());
    }
}

πŸš€ JwtUtil μž‘μ„± (very μ€‘μš”)

κ΅¬ν˜„ν•œ κΈ°λŠ₯은 μ•„λž˜ 6개 μž…λ‹ˆλ‹€.

  • 토큰 생성 (generate - create)
  • 토큰 μœ νš¨μ„± 검사 (isValidToken)
  • Claim λ””μ½”λ”© (getAllClaims)
  • Claimμ—μ„œ νŠΉμ • keyκ°’ κ°€μ Έμ˜€κΈ° (username)
  • 토큰 λ§Œλ£ŒκΈ°ν•œ κ°€μ Έμ˜€κΈ°
  • 토큰 λ§Œλ£Œμ—¬λΆ€ 확인

μ—¬κΈ°μ„œ Claim은 JWT의 νŽ˜μ΄λ‘œλ“œ 뢀뢄에 ν•΄λ‹Ήν•©λ‹ˆλ‹€.
Map을 μƒμ†ν•˜κ³  있기 λ•Œλ¬Έμ— νŽΈν•˜κ²Œ key-value둜 λ„£μ–΄μ£Όλ©΄ λ©λ‹ˆλ‹€.
ν˜„μž¬λŠ” username 만 Claim으둜 μ„€μ •ν–ˆμŠ΅λ‹ˆλ‹€.

Claim에 값이 λ“€μ–΄κ°ˆ λ•Œ Base64둜 인코딩 λ©λ‹ˆλ‹€. Base64λŠ” 곡개된 인코딩 기법이기 λ•Œλ¬Έμ— λ””μ½”λ”© λ˜ν•œ κ°€λŠ₯ν•©λ‹ˆλ‹€. λ•Œλ¬Έμ— Claimμ—λŠ” critialν•œ μ •λ³΄λŠ” 담지 μ•Šλ„λ‘ μ£Όμ˜ν•©λ‹ˆλ‹€.

JWTλŠ” 3개 λΆ€λΆ„μœΌλ‘œ λ‚˜λˆ μ§‘λ‹ˆλ‹€.

  • Header (μ„œλͺ… μ•Œκ³ λ¦¬μ¦˜, ν† ν°νƒ€μž…)
  • Payload (μ˜λ―ΈμžˆλŠ” 정보 key-value)
  • Signature (μ„œλͺ… = Header+Payload+secret)
@Slf4j
@Component
public class JwtUtil {

    // μ„€μ •νŒŒμΌλ‘œ λΉΌμ„œ ν™˜κ²½λ³€μˆ˜λ‘œ μ‚¬μš©ν•˜λŠ” 것을 ꢌμž₯
    private final String SECRET = "secret";

    /**
     * 토큰 생성
     */
    public String generateToken(UserDetails userDetails) {
        Claims claims = Jwts.claims();
        claims.put("username", userDetails.getUsername());
        return createToken(claims, userDetails.getUsername()); // username을 subject둜 ν•΄μ„œ token 생성
    }

    private String createToken(Claims claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10)) // 1μ‹œκ°„
                .signWith(SignatureAlgorithm.HS256, SECRET)
                .compact();
    }

    /**
     * 토큰 μœ νš¨μ—¬λΆ€ 확인
     */
    public Boolean isValidToken(String token, UserDetails userDetails) {
        log.info("isValidToken token = {}", token);
        String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    /**
     * ν† ν°μ˜ Claim λ””μ½”λ”©
     */
    private Claims getAllClaims(String token) {
        log.info("getAllClaims token = {}", token);
        return Jwts.parser()
                .setSigningKey(SECRET)
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * Claim μ—μ„œ username κ°€μ Έμ˜€κΈ°
     */
    public String getUsernameFromToken(String token) {
        String username = String.valueOf(getAllClaims(token).get("username"));
        log.info("getUsernameFormToken subject = {}", username);
        return username;
    }

    /**
     * 토큰 λ§Œλ£ŒκΈ°ν•œ κ°€μ Έμ˜€κΈ°
     */
    public Date getExpirationDate(String token) {
        Claims claims = getAllClaims(token);
        return claims.getExpiration();
    }

    /**
     * 토큰이 λ§Œλ£Œλ˜μ—ˆλŠ”μ§€
     */
    private boolean isTokenExpired(String token) {
        return getExpirationDate(token).before(new Date());
    }
}

πŸ”Ž 토큰 λ°œκΈ‰ ν…ŒμŠ€νŠΈ

@Slf4j
@RequiredArgsConstructor
@RestController
public class HomeController {

    private final CustomUserDetailsService userDetailsService;
    private final JwtUtil jwtUtil;
    private final AuthenticationManager authenticationManager; // authenticate λ©”μ„œλ“œ : username, password 기반 인증 μˆ˜ν–‰

    @GetMapping("/home")
    public String home() {
        return "home";
    }

    @PostMapping("/auth")
    public ResponseEntity<LoginSuccessResponse> authenticateTest(@RequestBody LoginRequest loginRequest) {
        log.info("/auth 호좜");
        try {
            // username, password 인증 μ‹œλ„
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));
        } catch (BadCredentialsException e) {
            throw new BadCredentialsException("둜그인 μ‹€νŒ¨");
        }
        // 인증 성곡 ν›„ 인증된 user의 정보λ₯Ό κ°–κ³ μ˜΄
        UserDetails userDetails = userDetailsService.loadUserByUsername(loginRequest.username);
        // subject, claim λͺ¨λ‘ UserDetailsλ₯Ό μ‚¬μš©ν•˜λ―€λ‘œ 객체λ₯Ό κ·ΈλŒ€λ‘œ 전달
        String token = jwtUtil.generateToken(userDetails);

        // μƒμ„±λœ 토큰을 응닡 (Test)
        return ResponseEntity.ok(new LoginSuccessResponse(token));
    }
    // μΈμ¦μš”μ²­ 객체
    @AllArgsConstructor
    @Data
    static class LoginRequest{
        private String username;
        private String password;
    }
    // μΈμ¦μš”μ²­μ— λŒ€ν•œ 응닡 객체
    @AllArgsConstructor
    @Data
    static class LoginSuccessResponse {
        private String token;
    }
}

/auth 둜 username=kim, password=1234 μš”μ²­

토큰이 μ •μƒμ μœΌλ‘œ λ°œκΈ‰λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

이제 ν΄λΌμ΄μ–ΈνŠΈκ°€ λ°œκΈ‰λœ 토큰을 λ“€κ³  μΈμ¦μš”μ²­μ„ ν–ˆμ„ λ•Œ μ„œλ²„μΈ‘ 처리λ₯Ό κ΅¬ν˜„ν•΄λ³Όκ»˜μš”.


πŸ”Ž 토큰 검증 (JwtFilter)

이제 인증을 ν•„μš”λ‘œ ν•˜λŠ” 경둜둜 μš”μ²­μ΄ 듀어왔을 λ•Œ username, passwordλ₯Ό 기반으둜 μΈμ¦ν•˜λŠ” 방식이 μ•„λ‹ˆκ³  ν΄λΌμ΄μ–ΈνŠΈκ°€ λ“€κ³ μ˜¨ ν† ν°μ˜ μœ νš¨μ—¬λΆ€λ‘œ νŒλ‹¨ν•΄μ•Ό ν•©λ‹ˆλ‹€.

Spring securityλŠ” μ—¬λŸ¬ 개 ν•„ν„°κ°€ λ“±λ‘λ˜μ–΄ μžˆλŠ”λ° κ·Έ 쀑 ν•˜λ‚˜μΈ UsernamePasswordAuthenticationFilterκ°€ μ „λ‹¬λœ usernameκ³Ό password둜 인증을 μ§„ν–‰ν•©λ‹ˆλ‹€.

그럼 μš°λ¦¬λŠ” UsernamePasswordAuthenticationFilter μ•žμͺ½μ— 토큰을 κ²€μ¦ν•˜λŠ” ν•„ν„°λ₯Ό λ„£μ–΄μ£Όλ©΄ λ©λ‹ˆλ‹€. 일단 ν•„ν„°λΆ€ν„° λ§Œλ“€μ–΄λ³Όκ»˜μš”.

@Slf4j
@RequiredArgsConstructor
@Component
public class JwtAuthenticateFilter extends OncePerRequestFilter {

    private final CustomUserDetailsService userDetailsService;
    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorization = request.getHeader("Authorization"); // 헀더 νŒŒμ‹±
        String username = "", token = "";

        if (authorization != null && authorization.startsWith("Bearer ")) { // Bearer 토큰 νŒŒμ‹±
            token = authorization.substring(7); // jwt token νŒŒμ‹±
            username = jwtUtil.getUsernameFromToken(token); // username μ–»μ–΄μ˜€κΈ°
        } else {
            filterChain.doFilter(request, response);
        }
        // ν˜„μž¬ SecurityContextHolder 에 인증객체가 μžˆλŠ”μ§€ 확인
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            // 토큰 μœ νš¨μ—¬λΆ€ 확인
            log.info("JWT Filter token = {}", token);
            log.info("JWT Filter userDetails = {}", userDetails.getUsername());
            if (jwtUtil.isValidToken(token, userDetails)) {
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
                        = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        filterChain.doFilter(request,response);
    }
}

ν•„ν„° 진행 흐름
1. μš”μ²­ 헀더에 Authorization이 μžˆλŠ”κ°€ ?
2. Authorization의 값이 Bearer 토큰 νƒ€μž…μΈκ°€?
3. (1,2) λ§Œμ‘±μ‹œ μ „λ‹¬λœ 토큰을 λ°›μ•„μ˜€κ³  subjectλ₯Ό νŒŒμ‹±
4. ν˜„μž¬ SecurityContext에 인증된 객체가 μ—†λŠ”κ°€?
5. (4) λ§Œμ‘±μ‹œ UserDetails get
6. 토큰이 μœ νš¨ν•œκ°€ ?
7. 토큰이 μœ νš¨ν•˜λ‹€λ©΄ 인증객체λ₯Ό μƒμ„±ν•˜μ—¬ SecurityContext에 μ„ΈνŒ…

OncePerRequestFilter?
OncePerRequestFilter λŠ” 이름 κ·ΈλŒ€λ‘œ ν•œ 번의 μš”μ²­ λ‹Ή ν•œ 번만 싀행을 보μž₯ν•˜λŠ” ν•„ν„°μž…λ‹ˆλ‹€.
"같은 request 객체λ₯Ό μ‚¬μš©ν•˜λŠ” ν•œ 이 ν•„ν„°λ₯Ό λ‹€μ‹œ 타지 μ•ŠλŠ”λ‹€" 라고 이해해도 될 것 κ°™λ„€μš”.

Bearer Token?
ν† ν°μ˜ νƒ€μž… 쀑 ν•˜λ‚˜λ‘œ jwt, oauth2 μ—μ„œ Access token으둜 μ‚¬μš©λ©λ‹ˆλ‹€.

ν•„ν„° 적용

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.csrf().disable()
            .authorizeRequests().antMatchers("/auth").permitAll()
            .anyRequest().authenticated();
        // Stateless (μ„Έμ…˜μ‚¬μš©X)
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        // UsernamePasswordAuthenticationFilter 에 λ„λ‹¬ν•˜κΈ° 전에 μ»€μŠ€ν…€ν•œ ν•„ν„°λ₯Ό λ¨Όμ € λ™μž‘μ‹œν‚΄
        http.addFilterBefore(jwtAuthenticateFilter, UsernamePasswordAuthenticationFilter.class);
    }

πŸ”Ž 토큰검증 ν…ŒμŠ€νŠΈ

토큰 λ°œκΈ‰

λ°œκΈ‰λœ ν† ν°μœΌλ‘œ 인증 μš”μ²­ (/home 으둜 μš”μ²­)

μ•„μ£Ό κ°„λ‹¨ν•˜κ²Œ JWT에 λŒ€ν•΄ μ•Œμ•„λ³΄μ•˜μŠ΅λ‹ˆλ‹€.
λ‹€μŒμ—λŠ” DB, JPA, MVC 등을 μΆ”κ°€ν•΄μ„œ 보닀 싀무적인 예제λ₯Ό μ†Œκ°œν•΄λ³΄κ² μŠ΅λ‹ˆλ‹€.
κ°μ‚¬ν•©λ‹ˆλ‹€. πŸ˜„

profile
μ’€ 더 천천히 까먹기 μœ„ν•΄ κΈ°λ‘ν•©λ‹ˆλ‹€. 🧐

1개의 λŒ“κΈ€

comment-user-thumbnail
2022λ…„ 6μ›” 1일

쒋은글 κ°μ‚¬ν•©λ‹ˆλ‹€!

λ‹΅κΈ€ 달기