본문 바로가기
Spring/SpringBoot

[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (3) - API와 JWT Token에 활용하기

by J4J 2024. 2. 22.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 spring security를 이용하여 인증/인가 처리하기 마지막인 api와 jwt token에 활용하는 방법에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

이전 글

 

[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (1) - 개념 및 기본 설정

[SpringBoot] Spring Security를 이용하여 인증/인가 처리하기 (2) - MVC와 Session에 활용하기

 

 

반응형

 

 

들어가기에 앞서

 

해당 글에서 사용되는 spring boot 버전은 다음과 같습니다.

 

  • boot → 3.2.2
  • java → 17

 

 

 

 

JWT Token을 이용한 설정

 

이전 글들을 확인해 보면 spring security가 무엇인지와 security 인증 처리 방식 중 하나인 mvc 구조와 session을 활용하는 방법에 대해 소개를 해드렸습니다.

 

이번에는 spring security에 적용 가능한 다른 인증 처리 방식인 jwt token을 활용하는 방법에 대해 소개드리겠습니다.

 

 

 

spring security의 인증 처리 방식에 jwt token을 사용하는 것은 api 개발이 많이 이루어지는 요즘 가장 많이 활용되는 방법입니다.

 

다만, 개인적으로 spring security는 session을 활용하는 것에 더 초점이 맞춰진 기능이라고 생각되며 그 이유로는 jwt token을 사용할 때 security에서 제공해 주는 여러 기능들을 모두 미 사용 처리를 해야 하기 때문입니다.

 

그러다 보니 spring security를 사용함에도 불구하고 다양한 추가적인 설정들이 session을 활용할 때 보다 더 많이 필요합니다.

 

 

 

 

이전에도 얘기드린 것처럼 spring에서 인증/인가 처리를 위해 항상 spring security를 사용해야 하는 것은 아닙니다.

 

그래서 jwt token을 사용하는 상황에서는 session을 사용할 때보다 spring security를 이용하지 않고 인증/인가 처리를 위한 여러 가지 커스텀 기능들을 직접 만들어 보는 것을 더 고려할 수도 있습니다.

 

하지만 spring security에서 활용될 수 있는 주요 기능들은 여전히 존재합니다.

 

그렇기 때문에 특별한 사유가 없다면 spring security에서 제공해 주는 기능들을 활용하여 인증/인가 처리를 하는 것을 권장드립니다.

 

 

 

이제는 spring security에 jwt token을 활용하는 간단한 구성을 해보겠습니다.

 

로그인을 할 때 jwt token을 발급받고 token을 기반으로 인증/인가 처리가 이루어진 api들만 정상적으로 호출될 수 있도록 코드를 작성해 보겠습니다.

 

 

 

 

[ 1. dependency 추가 ]

 

dependencies {
	// spring security
	implementation 'org.springframework.boot:spring-boot-starter-security'
	// jwt
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly('io.jsonwebtoken:jjwt-impl:0.11.5')
	runtimeOnly('io.jsonwebtoken:jjwt-jackson:0.11.5')
}

 

 

 

[ 2. jwt utility 클래스 추가 ]

 

package com.jforj.springsecurityjwt.util;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import java.util.Date;
import java.util.Map;

@Component
public class JwtUtil {

    private final String SECRET_KEY = "123456781234567812345678123456781234567812345678"; // jwt token 생성에 사용될 secretKey
    private final long EXPIRE_TIME = 1000L * 60 * 60; // token 사용 시간, 1시간
    private final String USER_ID = "userId"; // claim에 담길 사용자 아이디 key

    /**
     * token 생성
     *
     * @param userId 사용자 아이디
     * @return 생성된 token
     */
    public String createToken(String userId) {
        return Jwts
                .builder()
                .setSubject("accessToken") // token 제목 설정
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME)) // token 유효 시간 설정
                .claim(USER_ID, userId) // token에 담아 둘 데이터 설정
                .signWith(Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(SECRET_KEY)), SignatureAlgorithm.HS256) // token 암호화 처리 (해싱 알고리즘)
                .compact(); // 직렬화 처리 (string으로 변경)
    }

    /**
     * token을 이용하여 사용자 아이디 조회
     *
     * @param token 발급된 token
     * @return token에 담겨있는 사용자 아이디
     */
    public String getUserId(String token) {
        return getTokenClaim(token).get(USER_ID).toString();
    }

    /**
     * token의 claim 정보 조회
     *
     * @param token 발급된 token
     * @return token에 담겨있는 claim 정보
     */
    private Map<String, Object> getTokenClaim(String token) {
        return Jwts
                .parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(Decoders.BASE64URL.decode(SECRET_KEY)))
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

 

 

 

[ 3. 인증/인가 exception handling 클래스 추가 ]

 

spring security를 사용하여 인증/인가 처리에 exception이 발생될 경우 handling 할 수 있는 클래스를 설정할 수 있습니다.

 

필요 없으신 분들도 있겠지만 보통 발생된 exception에 대한 handling을 많이들 하시기 때문에 간단하게 설정해 봤습니다.

 

// 인증 handling
package com.jforj.springsecurityjwt.exception;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8"); // response encoding 설정
        response.setStatus(HttpStatus.UNAUTHORIZED.value()); // response http status 설정
        response.setContentType(MediaType.APPLICATION_JSON_VALUE); // response content-type 설정
        response.getWriter().write("인증에 실패했습니다."); // response data 설정
    }
}


// 인가 handling
package com.jforj.springsecurityjwt.exception;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8"); // response encoding 설정
        response.setStatus(HttpStatus.UNAUTHORIZED.value()); // response http status 설정
        response.setContentType(MediaType.APPLICATION_JSON_VALUE); // response content-type 설정
        response.getWriter().write("권한이 존재하지 않습니다."); // response data 설정
    }
}

 

 

 

 

[ 4. authentication filter 클래스 추가 ]

 

session을 이용할 땐 존재하지 않지만 jwt token을 사용할 땐 필요한 클래스 중 하나입니다.

 

이전 session 글을 확인해 보면 userDetails에서 해주던 과정을 jwt token에서는 authentication filter에서 동일하게 한다고 생각해 주시면 됩니다.

 

authentication filter를 통해 이루어지는 flow는 다음과 같습니다.

 

  • client로부터 전달받은 jwt token을 확인
  • jwt token이 없는 경우 인증/인가 정보를 확인하지 않고 이후 request 처리 진행
  • jwt token이 있는 경우 token에 담겨 있는 사용자 아이디를 확인
  • 사용자 아이디를 이용하여 데이터베이스 및 외부 api를 활용하여 사용자 정보 및 권한 조회
  • 조회된 데이터를 이용하여 Authentication 객체 생성
  • Authentication 객체를 SecurityContext에 보관

 

 

 

이런 과정을 통해 client로부터 api 요청이 넘어올 때마다 요청된 사용자의 정보를 SecurityContext에 보관해 둡니다.

 

그러면 이후 작성될 SecurityConfig 클래스에 넣어둔 인가 처리를 자동으로 제공해 줍니다.

 

package com.jforj.springsecurityjwt.filter;

import com.jforj.springsecurityjwt.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

@RequiredArgsConstructor
public class AuthenticationFilter extends GenericFilterBean {

    private final String AUTHORIZATION = "Authorization";
    private final String BEARER = "Bearer";

    private final JwtUtil jwtUtil;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // header에서 token 정보를 확인
        // token은 header 내부에 { Authorization: BEARER qkwne9Asd~~ } 와 같이 담김
        String token = ((HttpServletRequest) request).getHeader(AUTHORIZATION);

        // token이 있는 경우에만 securityContext에 Authentication 저장하는 로직 동작
        if (StringUtils.hasText(token)) {
            // BEARER을 제거한 token 값을 이용하여 사용자 아이디 조회
            String userId = jwtUtil.getUserId(token.substring(BEARER.length() + 1));

            // *** DB or 외부 API로부터 사용자 정보 및 권한 정보를 조회하는 구간 ***
            // *** 해당 코드에서는 구현하지 않기 때문에 정보를 조회하지 않고 더미 데이터를 활용 ***

            List<GrantedAuthority> userRoles = new ArrayList<>();
            switch (userId) {
                // 로그인한 사용자가 admin인 경우 ROLE_ADMIN, ROLE_USER 권한 부여
                case "admin": {
                    userRoles = Arrays.asList(new GrantedAuthority[]{new SimpleGrantedAuthority("ROLE_ADMIN"), new SimpleGrantedAuthority("ROLE_USER")});
                    break;
                }

                // 로그인한 사용자가 user인 경우 ROLE_USER 권한 부여
                case "user": {
                    userRoles = Arrays.asList(new GrantedAuthority[]{new SimpleGrantedAuthority("ROLE_USER")});
                    break;
                }
            }

            // securityContext에 보관해 둘 Authentication 정보 구성
            Authentication authentication =
                    new UsernamePasswordAuthenticationToken(
                            userId,
                            null, // 비밀번호는 로그인 할 때 확인하기 때문에 담아둘 필요 없음
                            userRoles
                    );

            // securityContext에 Authentication 보관
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // filter 처리 이후 요청 수행
        chain.doFilter(request, response);
    }
}

 

 

 

 

[ 5. spring security 설정 클래스 추가 ]

 

package com.jforj.springsecurityjwt.config;

import com.jforj.springsecurityjwt.exception.SecurityAccessDeniedHandler;
import com.jforj.springsecurityjwt.exception.SecurityAuthenticationEntryPoint;
import com.jforj.springsecurityjwt.filter.AuthenticationFilter;
import com.jforj.springsecurityjwt.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final SecurityAuthenticationEntryPoint securityAuthenticationEntryPoint;
    private final SecurityAccessDeniedHandler securityAccessDeniedHandler;

    private final JwtUtil jwtUtil;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic(AbstractHttpConfigurer::disable) // 기본적인 security 설정은 사용하지 않음
                .csrf(AbstractHttpConfigurer::disable) // csrf 설정은 사용하지 않음
                .sessionManagement(AbstractHttpConfigurer::disable) // 기본적인 session 관리 설정 사용하지 않음
                .authorizeHttpRequests(
                        authorizeHttpRequests ->
                                authorizeHttpRequests
                                        // /api/login API는 모든 사용자가 접근 가능
                                        .requestMatchers("/api/login").permitAll()
                                        // /api/admin API는 ROLE_ADMIN 권한이 있는 사용자만 접근 가능
                                        .requestMatchers("/api/admin").hasAuthority("ROLE_ADMIN")
                                        // /api/user API는 ROLE_ADMIN과 ROLE_USER 권한이 있는 사용자만 접근 가능
                                        // hasRole, hasAnyRole은 "ROLE_"을 prefix로 사용하지 않음
                                        .requestMatchers("/api/user").hasAnyRole("ADMIN", "USER")
                                        // 그 외 모든 요청에 대해서 인증된 사용자만 접근 가능
                                        .anyRequest().authenticated()
                )
                .formLogin(AbstractHttpConfigurer::disable) // 로그인 페이지 설정은 사용하지 않음
                .logout(AbstractHttpConfigurer::disable) // 로그아웃 페이지 설정은 사용하지 않음
                .addFilterBefore(
                        // spring security에 설정되어 있는 UsernamePasswordAuthenticationFilter 동작 전 커스텀 한 authenticationFilter 수행되도록 설정
                        new AuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class
                )
                .exceptionHandling(
                        exceptionHandling ->
                                exceptionHandling
                                        // 인증 문제가 발생될 경우 exception handling
                                        .authenticationEntryPoint(securityAuthenticationEntryPoint)
                                        // 인가 문제가 발생될 경우 exception handling
                                        .accessDeniedHandler(securityAccessDeniedHandler)
                )
        ;

        return httpSecurity.build();
    }
}

 

 

 

[ 6. 호출될 API 구성 ]

 

// dto
package com.jforj.springsecurityjwt.dto;

public record LoginRequestDto(
        String userId,
        String password
) {
}


// controller
package com.jforj.springsecurityjwt.controller;

import com.jforj.springsecurityjwt.dto.LoginRequestDto;
import com.jforj.springsecurityjwt.util.JwtUtil;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.security.sasl.AuthenticationException;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiController {
    private final String AUTHORIZATION = "Authorization";

    private final JwtUtil jwtUtil;

    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody LoginRequestDto loginRequestDto, HttpServletResponse response) throws AuthenticationException {
        // *** DB or 외부 API로부터 사용자 로그인 처리를 하는 구간 ***
        // *** 해당 코드에서는 구현하지 않기 때문에 정보를 조회하지 않고 더미 데이터를 활용 ***

        if (!"password".equals(loginRequestDto.password())) {
            throw new AuthenticationException("로그인에 실패했습니다.");
        }

        // userId 기반으로 token 생성
        String token = jwtUtil.createToken(loginRequestDto.userId());
        // header에 token 정보를 담아 client에 전달
        response.setHeader(AUTHORIZATION, token);

        return ResponseEntity.ok("로그인 되었습니다.");
    }

    @GetMapping("/admin")
    public ResponseEntity<String> admin() {
        return ResponseEntity.ok("admin");
    }

    @GetMapping("/user")
    public ResponseEntity<String> user() {
        return ResponseEntity.ok("user");
    }
}

 

 

 

 

테스트 (1) - user

 

위와 같이 설정을 모두 완료했다면 테스트를 해보겠습니다.

 

먼저 사용자 아이디가 user인 경우에 대해 확인하기 위해 다음과 같이 postman을 이용하여 로그인 요청을 보내보겠습니다.

 

로그인이 올바르게 이루어지면 header의 Authorization에 생성된 jwt token을 확인할 수 있습니다.

 

user login api

 

 

 

 

그리고 지금 시도한 user 계정은 다음과 같이 비밀번호가 "password" 로만 넘어와야 올바르게 로그인이 되도록 설정해 놨습니다.

 

user 계정 password 구성

 

 

 

또한 로그인이 될 경우 다음과 같이 ROLE_USER 권한만 부여될 수 있도록 설정해 놨습니다.

 

user 계정 권한 구성

 

 

 

 

그러므로 로그인을 통해 전달받은 jwt token을 header에 다시 담아 권한이 존재하는 /api/user에 api를 요청하면 다음과 같이 response가 전달되는 것을 확인할 수 있습니다.

 

/api/user API 요청 결과

 

 

 

하지만 권한이 존재하지 않는 /api/admin에 api를 요청하면 다음과 같이 response가 전달되는 것을 확인할 수 있습니다.

 

/api/admin API 요청 결과

 

 

 

 

테스트 (2) - admin

 

이번에는 사용자 아이디가 admin인 경우를 확인해 보겠습니다.

 

admin에 대해서도 user와 동일하게 postman을 이용하여 로그인 요청을 보내보겠습니다.

 

그러면 다음과 같이 header의 Authorization에 생성된 jwt token을 확인할 수 있습니다.

 

user login api

 

 

 

 

지금 시도한 admin 계정도 user와 동일하게 비밀번호가 "password"로 넘어와야 올바르게 로그인이 되도록 설정해 놨습니다.

 

admin 계정 password 구성

 

 

 

하지만 user와는 달리 권한이 ROLE_ADMIN / ROLE_USER가 부여되도록 설정해 놨습니다.

 

admin 계정 권한 구성

 

 

 

 

그러므로 사용자 아이디가 admin인 계정을 이용하여 로그인했을 때 전달받은 jwt token을 활용하면 user 와는 달리 /api/user와 /api/admin 요청이 모두 올바르게 응답받는 것을 확인할 수 있습니다.

 

/api/user API 요청 결과

 

/api/admin API 요청 결과

 

 

 

 

로그아웃

 

jwt token에서는 session때와 달리 로그아웃에 대한 개념이 api로 존재하지 않습니다.

 

왜냐하면 token의 단점 중 하나로 얘기될 수 있는 한번 발급된 token은 기한이 만료되기 전까지 항상 유효하기 때문입니다.

 

그러다 보니 로그아웃 처리를 하기 위해서는 FE 쪽에서 보관하고 있는 token 정보를 삭제시켜 주는 방법 밖에 없습니다.

 

로그아웃 기능을 구현해야 할 때 해당 내용을 참고해 주시면 될 것 같습니다.

 

 

 

 

 

 

 

 

 

 

이상으로 spring security를 이용하여 인증/인가 처리하기 마지막인 api와 jwt token에 활용하는 방법에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글