행복을 담는 블로그

[Spring Boot / Vue] 로그인 JWT 인증/인가 기능 구현 본문

BackEnd/Spring Boot

[Spring Boot / Vue] 로그인 JWT 인증/인가 기능 구현

hyun0zin 2024. 11. 20. 18:29

JWT 인증(Authentication) / 인가(Authorization)

  • 인증(Autentication) : “신원을 확인하는 과정”
    • JWT 토큰이 유효한지 확인하는 과정
    • User DB에서 이 사람이 회원가입 한 사람이 맞는지 확인하는 과정
  • 인가(Autentication) : “접근을 허가 또는 거절하는 과정”
    • SpringSecurity Context의 Autentication 객체를 확인하여 접근을 허가할지 말지 선택하는 과정
    • 지금 들어오는 요청을 이 사람이 해도 되는지 확인하는 과정
      • ex) userId=4인 사람이 userId=5인 사람 페이지 가서 todo를 등록할 수 없게 하기

출처 [Spring Security / JWT] Spring Security - JWT 토큰 인증/인가


발행되는 Token의 타입

  1. Bearer 토큰

    • OAuth 2.0에서 가장 많이 사용되는 인증 방식
    • 서버에서 클라이언트가 접근 권한을 받으면, Bearer 토큰을 발급하고, 클라이언트는 이를 HTTP 요청 헤더에 포함해 자원 서버에 요청을 보낸다.
    • Bearer 토큰 자체가 인증 자격을 의미한다. 즉, 토큰을 가진 사람이 해당 자원에 접근할 수 있게 된다.
    • BUT! 토큰을 탈취 당했을 때, 제 3자가 토큰을 사용하여 인증을 수행할 수 있기에 보안에 취약
      • 이를 방지하기 위해 https를 통해 안전하게 전송해야함
  2. MAC 토큰

    • 메세지 인증 코드 방식으로 Bearer 토큰보다 높은 보안을 제공
    • 요청 시 마다, MAC 토큰과 함께 서명(Signature)를 생성해 서버로 전송하며, 서명에는 토큰뿐만아니라 http 메서드, url 요청 타임 스탬프 등이 포함된다.
    • MAC 토큰은 요청마다 서명을 추가하기 때문에 중간에서 요청을 가로채더라도 제3자가 서명을 재생성하지 않는 한 요청을 변조할 수 없어 보안이 더 뛰어남

정리

  • Bearer 토큰은 간편하지만 탈취 위험이 있으므로 HTTPS와 함께 사용하는 것이 중요
  • MAC 토큰은 Bearer에 비해 보안이 강화된 방식으로, 요청마다 서명 검증을 통해 위조를 방지

출처 OAuth 2.0 동작 방식의 이해




JWT 토큰 인증/인가 Spring Boot로 구현하기

0. pom.xml 의존성 주입하기

JWT 토큰을 사용하기 위한 의존성을 주입한다.

<dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.12.6</version>
</dependency>
<dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.12.6</version>
            <scope>runtime</scope>
</dependency>
<dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.12.6</version>
            <scope>runtime</scope>
</dependency>



1. TokenInfo DTO 만들기

클라이언트에서 토큰을 보내기 위한 DTO 생성

/* TokenInfo */

public class TokenInfo {
    private String grantType; // 발행되는 토큰의 타입
    private String accessToken; // access-token
    private String refreshToken; // refresh-token
}
  • grantType은 JWT 인증 타입으로, 반환되는 토큰의 타입을 작성한다.
  • 주로 Bearer과 MAC이 존재한다.



2. JwtUtil

: JWT 토큰 생성, 토큰 암호화 및 정보 추출, 토큰 유효성 검사 등의 기능이 구현된 클래스


1) JWT_SECRET_KEY 만들기

토큰의 암호화/ 복호화를 위한 secret key를 만들어야 한다.

  • .env 파일
/* .env */ 

JWT_SECRET_KEY="여기에 시크릿키 작성" // " "는 작성할 필요 X
  • application.properties
 /* application.properties */ 

 spring.config.import=optional:file:.env[.properties] // env에 있는 모든 속성을 import 
 JWT_SECRET_KEY=${JWT_SECRET_KEY} // env 파일의 JWT_SECRET_KEY 불러오기
  • import만 해오면 env 파일 내의 정보를 모두 읽어 올 것이라고 생각했으나, JWT_SECRET_KEY=${JWT_SECRET_KEY} 이 부분을 작성해주어 그 값을 읽어와서 JWT_SECRET_KEY 이 값으로 사용할 수 있었다.
  • JWT_SECRET_KEY 값을 활용하여 암호화 알고리즘을 사용하여 암호화를 진행한다.
    • 256비트보다 커야한다.
    • 알파벳은 한 단어 당 8bit이므로 전체가 32글자 이상이어야 한다.
  • JwtUtil
/* JwtUtil */

@Component
public class JwtUtil {

    /* 토큰 SECRET KEY */
    @Value("${JWT_SECRET_KEY}")
    private String JWT_SECRET_KEY;
    private SecretKey secretKey; 

    // @PostConstruct로 secretKey 초기화
                            // Spring Bean의 초기화 후, 딱 한 번만 실행되는 메서드를 지정할 때 사용
    @PostConstruct
    private void initSecretKey( ) {
        this.secretKey = Keys.hmacShaKeyFor(JWT_SECRET_KEY.getBytes(StandardCharsets.UTF_8));
    }

}
  • JwtUtil을 @Component 어노테이션을 이용하여 빈으로 등록한다.

  • @Value 어노테이션을 사용하여 application.properties의 JWT_SECRET_KEY 를 가져온다.

  • @PostConstruct 어노테이션을 사용

    : Spring에서 Bean이 생성된 후, 초기화 단계에서 실행될 메서드를 지정할 때 사용한다.

    • @PostConstruct로 표시된 메서드는 객체가 생성되고 의존성 주입이 완료된 후 자동으로 한 번만 호출된다.

    • JWT_SECRET_KEY를 기반으로 초기화가 필요한 secretKey 변수를 설정하기 위해서 사용함

    • 이 초기화 작업은 JWT_SECRET_KEY 값이 주입된 후에만 수행할 수 있기 때문에, Spring의 @PostConstruct를 통해 secretKey가 한 번만 설정되도록 한 것


    1. 초기화 순서 보장 : JWT_SECRET_KEY가 주입된 후 secretKey를 생성하도록 순서를 보장
    2. 초기화 작업의 단일성 보장 : secretKey 초기화가 Bean 생성 시 한 번만 수행하도록하여, 불필요한 재설정 방지와 효율적인 리소소 관리가 가능하다.
<br><br>

하지만 여기서 문제가 발생했다...!

❗❗❗ SignatureAlgorithm.HS256 is deprecated!!!

이는 원시 문자열과 Base64로 인코딩된 문자열 사이 혼란이 있어서 이를 방지하고자 jjwt 0.10.0버전부터 사라졌다고 한다.

새로 암호키를 생성하는 방법은 다음과 같다.

SecretKey secretKey = Keys.hamacShaKeyFor(Decoders.BASE64.decode(base64EncodedSecretKey))

따라서 다음과 같은 방법으로 수정하였다.

    @PostConstruct
    private void initSecretKey( ) {
        byte[] keyBytes = Decoders.BASE64.decode(JWT_SECRET_KEY); // deprecated 메서드 새로 작성 방법
         this.secretKey =  Keys.hmacShaKeyFor(keyBytes); 
    }



2) 토큰 생성하기

/* JwtUtil */

// 2. 토큰을 생성하는 메서드
    // access-token 생성
    public String createAccessToken(User user) {
        String nickname = user.getUserNickname();
        int userId = user.getUserId();
        // 토큰 유효기간 설정
        Date exp = new Date(System.currentTimeMillis() + 1000*60*60); // 1시간

        return  Jwts.builder()
                .header().add("type", "JWT").and()
                .claim("nickname", nickname).claim("userId", userId).expiration(exp)
                .signWith(secretKey).compact();
    }


    // refresh-token 생성
    public String createRefreshToken() {
        Date exp = new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 14); // 2주일

        return Jwts.builder()
                .header().add("type", "JWT").and()
                .expiration(exp)
                .signWith(secretKey).compact();
    }

    // 생성한 access-token과 refresh-token을 TokenInfo에 넣기
    public TokenInfo createToken (User user) {
        String accessToken = createAccessToken(user);
        String refreshToken = createRefreshToken();

        TokenInfo tokenInfo = new TokenInfo();
        tokenInfo.setGrantType("Bearer");
        tokenInfo.setAccessToken(accessToken);
        tokenInfo.setRefreshToken(refreshToken);

        return tokenInfo;
    }

이때 생성된 createToken은 AuthService에서 login 시 불러와 사용한다.



3. 로그인 인증(Authentication) 과정

  • 로그인 시 입력 받은 아이디(userEmail)와 비밀번호(userPassword)로 등록된 User를 찾는다.
  • User의 특정 정보로 JWT 토큰을 생성한다. (accessToken)
  • 클라이언트에서 응답갑으로 JWT accessToken을 반환한다.
  • 클라이언트는 accessToken을 보관하며, 모든 요청의 HttpHeader에 포함시킨다.
    • Vue에서 accessToken 받아서 처리하기
    • sessionStorage에 accessToken 저장하기

1) AuthController

/* AuthController */

@RestController
@RequestMapping("/auth")
@Tag(name = "Auth API", description = "로그인 인증/인가 ")
public class AuthController {

    private AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    @Operation(summary = "사용자 로그인 ", description = "로그인을 합니다.")
    public ResponseEntity<?> logIn(@RequestBody User user) {

        try {
            TokenInfo tokenInfo = authService.login(user);
            return new ResponseEntity<>(tokenInfo, HttpStatus.OK);

        } catch (IllegalArgumentException e) {
            return new ResponseEntity<>(e.getMessage(), HttpStatus.UNAUTHORIZED);
        }
    }

}

사용자의 입력을 @RequestBody로 User에 넣어서 이를 바탕으로 user login 메서드를 실행한다.


2) AuthService

/* AuthService */

@Service
public class AuthService {

    private UserService userService;
    private JwtUtil jwtUtil;
    private PasswordEncoder passwordEncoder;

    public AuthService(UserService userService, JwtUtil jwtUtil, PasswordEncoder passwordEncoder) {
        this.userService = userService;
        this.jwtUtil = jwtUtil;
        this.passwordEncoder = passwordEncoder;
    }

    public TokenInfo login(User user) {
        User loginUser = null;

        if (user.getUserEmail() == null || user.getUserPassword() == null)
            throw new IllegalArgumentException("로그인 입력 정보 없음");

        // 입력된 이메일 날림 => user 정보 받아오기
        User getUser = userService.searchByEmail(user.getUserEmail());
        System.out.println(getUser);

        // 아이디 또는 비밀번호 없음
        String encodePw = getUser.getUserPassword();
        if (getUser == null || !passwordEncoder.matches(user.getUserPassword(), encodePw)) { // user가 입력한 pw와 db의 암호화된
            throw new IllegalArgumentException("아이디 또는 비밀번호 틀림");
        }

        // 로그인 성공
        loginUser = getUser;

        // 회원가입 정보가 맞을 경우, 로그인 요청 시 => JWT 발급
        // 여기서 토큰을 발급하여 token을 login의 반환값으로 return 한다.
        return jwtUtil.createToken(loginUser); 

    }

}

이렇게 요청을 날리면,,,

이렇게 성공 요청과 함께 token이 생성된다.


이렇게하면 인증을 위한 토큰 생성을 할 수 있고, 이제 이 토큰이 유효한지, 이 토큰으로 들어오는 요청의 접근을 허용할지 말지를 결정하는 인가 과정을 거쳐야한다.



4. 로그인 인가(Authorization)

참고) 🔒 Spring security


Spring Security

  • 메서드의 이름을 filterChain으로 설정하면, Spring이 해당 빈을 자동으로 인식하고 처리한다.
  • @Configuration, @EnableWebSecurity 어노테이션 설정 : 보안 설정 활성화
  • Spring Security의 대부분 설정을 HttpSecurity로 이루어진다.
    • URL 접근 권한 설정 인증
    • 로그아웃 페이지 인증 성공 및 실패 시 페이지 이동
    • csrf 보호, https 강제 호출 등…


인가 처리

: Filter를 통해 token이 유효한지 아닌지 확인 →유효하다면, Dispatcher Servlet을 통과 → 해당 token을 가진 user가 접근할 수 있는 페이지 권한 설정하기 : 인터셉터로 진행

  • Filter : 토큰이 유효한지 아닌지, 디스패쳐 서블릿을 통과해도 되는지 판단하고 걸러주기
  • Interceptor : 유효한 사람임은 확인함. 이제 이 사람이 이 페이지로 들어와도 되는지 아닌지 판단하고, 접근하면 안 되는 경우 제한 걸기

flow는 다음과 같이 움직인다.

  1. 클라이언트에서 로그인한다.
  2. 서버는 클라이언트에게 Access Token과 Refresh Token을 발급한다. 동시에 Refresh Token은 서버에 저장된다.
  3. 클라이언트는 local 저장소에 두 Token을 저장한다.
  4. 매 요청마다 Access Token을 헤더에 담아서 요청한다.
  5. 이 때, Access Token이 만료가 되면 서버는 만료되었다는 Response를 하게 된다.
  6. 클라이언트는 해당 Response를 받으면 Refresh Token을 보낸다.
  7. 서버는 Refresh Token 유효성 체크를 하게 되고, 새로운 Access Token을 발급한다.
  8. 클라이언트는 새롭게 받은 Access Token을 기존의 Access Token에 덮어쓰게 된다.

Jwt Refresh Token 적용기



access-token 유효성 검사

  1. 디스패쳐 서블릿에 들어가기 전에, Header에 담겨서 넘어오는 access-token이 유효한지 아닌지를 확인하기 위해서 Filter에서 유효성 검사를 한다.
  2. 원래라면, access-token이 유효하지 않다면, refresh-token을 확인하여 만료 전이라면 바로 access-token을 발급해주는 과정이 필요하지만…
  3. 일단은 access-token이 유효하지 않다면, 바로 sessionStorage에서 access-token을 삭제하여 로그아웃되도록 하였다.

그러기 위해서는 filter를 만들어주어야 한다.

JwtAuthFilter 클래스는 JWT 인증 필터를 구현한 클래스로, Spring Security에서 OncePerRequestFilter를 상속하여, 매 요청마다 한 번만 실행되는 필터로 동작한다.

즉, 이 필터의 주요 목적은 들어오는 요청에 대한 JWT 토큰을 검사하여 유효한 경우 인증을 처리하고, 유효하지 않은 경우 401 Unauthorized 응답을 반환한다.

/* JwtAuthFilter */

@Component
public class JwtAuthFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;

    public JwtAuthFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // 1. 요청 HTTP Header의 access-token을 추출한다. 
        String accessToken = request.getHeader("access-token"); 
        // System.out.println("accessToken : " + accessToken);

        // 2. 토큰이 없다면, 인증이 필요없는 요청이므로 필터 체인에 요청을 넘긴다. 
        // return을 사용하여, 필터를 종료하고 후속 필터로 넘어간다. 
        if (accessToken == null) {
            filterChain.doFilter(request, response);
            return;
        }


        try {
            // 3. 토큰이 있다면, 토큰이 유효한지 확인하고 인증을 처리한다. 
            // 토큰 위조 검사 및 인증 완료 처리 == 유효성 검사
            if (accessToken != null) {

                // 3-1. jwtUtil의 validate 메서드를 통해 유효성 검사를 진행하고,
                // 토큰에 포함된 클레임을 추출한다. 
                // Jws<Claims>은 JWT의 헤더, 페이로드, 서명 등을 포함하는 객체이다. 
                Jws<Claims> claims = jwtUtil.validate(accessToken);

                // 3-2. 사용자 인증 정보를 담을 User 객체를 생성
                User userInfo = new User();
                // 클레임 안에 담겨 있는 userNickname 값 얻어오기 => String 타입으로
                String userNickname = claims.getBody().get("userNickname", String.class); 

                // 클레임 안에 담겨 있는 userId 값 얻어오기 => Integer 타입으로
                Integer userId = claims.getBody().get("userId", Integer.class); 

                userInfo.setUserNickname(userNickname);
                userInfo.setUserId(userId);

                //3-3. 권한 정보(인가 정보) 리스트
                // 인증된 사용자에게 부여할 권한 정보를 담을 authorityList 를 생성한다.
                // 현재는 권한 정보가 비어있으므로, 추후 권한이 필요한 경우 추가할 수 있다. 
                List<GrantedAuthority> authorityList = new ArrayList<>();

                // 3-4. SecurityContextHolder 를 사용하여 현재 요청의 인증 정보를 설정한다. 
                // UsernamePasswordAuthenticationToken 객체는 인증된 사용자 userInfo와 권한을 설정한다. 
                // 비밀번호는 넣을 필요 없으므로 null로 처리한다. 

                // 이를 SecurityContextHolder에 setAuthentication하여
                // Spring Security가 인증된 사용자를 처리하도록 한다. 

                // principal : 사용자 이름 또는 사용자 객체 , credential : 비밀번호, 주로 null
                SecurityContextHolder.getContext()
                        .setAuthentication(new UsernamePasswordAuthenticationToken(userInfo, null, authorityList));

            }

        // 4. JWT 토큰이 유효하지 않거나, 토큰 처리 중 예외 발생 시 실행
        } catch (Exception e) {
            // 토큰이 유효하지 않으면 401 응답
            System.out.println("토큰이 위조 되었습니다.");
            e.printStackTrace(); // 에러 추적

            // 4-1. 401 Unauthorized 응답코드
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); 

            // 4-2. Content-Type을 application/json으로 설정하여, 
            // 클라이언트가 JSON 형식으로 응답을 받을 수 있게 설정한다. 
            response.setContentType("application/json"); 

            // 4-3. CORS 정책에 따라 응답을 허용할 도메인 명시
            // 클라이언트가 실행 중인 port 번호 작성 (Vue port number)
            response.setHeader("Access-Control-Allow-Origin", "http://localhost:5173");

            // 4-4. 클라이언트가 인증 정보를 포함하여 요청을 보낼 수 있도록 설정
            response.setHeader("Access-Control-Allow-Credentials", "true");

            // 4-5. 응답 본문에 JSON 형태의 에러 메시지를 작성 
            response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
            response.getWriter().flush(); // 버퍼에 기록된 데이터를 클라이언트로 즉시 전송

            // 4-6. 메서드의 실행을 중단하고, 더 이상의 작업을 진행하지 않도록
            // filterChain.doFilter()를 호출하지 않아 요청 처리가 끝난다.
            return;

        }
        // 모든 작업이 완료되면 필터 체인을 통해 다음 필터나 요청 처리를 계속 진행
        filterChain.doFilter(request, response);
    }

}

이제 클라이언트가 응답 요청을 보내게 되면, 이 Filter가 먼저 실행을 하게 된다.

응답 Header에 access-token이 담겨 있을 경우, 이 Filter를 통해 토큰 유효성 검사가 우선적으로 실행된다.

클라이언트 vue의 코드를 확인해보면 다음과 같다.

  • todo와 관련한 js코드를 전역으로 관리하는 파일이다.
/* todo.js */

// 투두 추가하기
  const addTodo = (todo, userId) => {
    const REST_API_URL = getRestApiUrl(userId) + `/${todo.date}`;
    axios
      .post(REST_API_URL, todo, {
        headers: {
          "access-token": sessionStorage.getItem("access-token"),
          "Content-Type": "application/json",
        },
        withCredentials: true,
      })
      .then((res) => {
        console.log("투두 추가하기", res.data);
        window.location.reload();
      })
      .catch((error) => {
        if (error.response && error.response.status === 401) {
          // 토큰이 만료되었으므로 access-token을 삭제
          sessionStorage.removeItem("access-token");

          // 로그인 페이지로 리다이렉트
          router.push("/login");
        }
      });
  };

axios로 POST 요청을 보낼 때, 요청 header에 access-token를 담아서 보내게 되고,

filter에서 유효성 검사를 잘 통과할 경우 투두가 잘 추가된 것을 확인할 수 있다.

반면, 토큰이 만료되었다면, error가 발생하였으므로 sessionStorage에서 access-token을 삭제하고 로그인 페이지로 보낸다.