[security] JWT 로그인 구현

yrok·2023년 12월 31일

spring security login

목록 보기
3/3
post-thumbnail

🤔 개요

JWT가 무엇이고 왜 사용하는지, 어떻게 사용하는지 알아보자.

세션 로그인

세션을 이용한 로그인은 다음과 같은 순서로 진행된다.

  1. 클라이언트가 서버에 최초 요청 시도
  2. 서버는 세션ID를 헤더에 담아 반환하고 서버에 세션 공간을 마련
  3. 클라이언트는 다음 요청에 세션ID를 담아서 서버에 로그인 요청
  4. 서버는 받은 세션ID가 유효한 세션ID인지 검증 -> 로그인 정보를 세션에 저장

세션 방식은 서버가 여러 개일 경우 문제가 발생한다. 로드밸런싱을 통해 A,B,C 서버가 있다고 가정하면 서버 마다 세션이 있을 것이다. 만약 A서버에서 로그인 요청을 보내 A서버의 세션에 유저 정보가 담겨있을 때 다음 요청에서 로드밸런싱을 통해 B서버로 요청을 보내게 된다면 B서버에서는 세션ID 검증에 실패할 것이다.
이를 해결하기 위해서는 여러가지 방법이 있는데 대표적인 방법으로 Redis와 같은 메모리 공유 서버에 세션 값과 유저 정보를 저장하는 방법이 있다.

CIA

정보보안의 목적은 정보가 생성되어 소멸되기까지 그 처리 및 유통의 생명 주기 전반에 걸쳐 기밀성(Confidentiality), 무결성(Integrity), 가용성(Availability)을 확보하는 데 있다.

  • 기밀성 : 사용 승인받은 사람만 해당 정보에 접근할 수 있는 성질
  • 무결성 : 접근 권한이 적절한 것인지 완전성과 정확성을 보장하는 성질
  • 가용성 : 언제든 정보에 접근할 수 있는 성질

  1. 해커가 전달되는 문서를 중간에서 가로채서 읽는다. -> 기밀성 깨짐
  2. 해커가 전달되는 문서를 중간에서 가로채 다른 문서로 바꾼다. -> 무결성, 가용성 깨짐

위의 문제를 해결하기 위해서 문서를 암호화 시키는 방법을 사용한다. 어떠한 key가 있어야 문서를 열 수 있는데 이러한 방법을 사용해도 몇 가지 문제가 발생한다.

  1. A가 문서를 전달할 때 key를 등록하면 B도 key를 가지고 있어야한다. -> 어떻게 전달하지?
  2. 해커가 문서를 탈취한다면 key가 없어서 열 수는 없지만 다른 문서로 바꾸어서 B로 보낼 수 있다. -> B는 이 문서가 어디서 왔는지 알아야 한다.

위 문제를 RSA 암호화 알고리즘을 사용하여 해결할 수 있다.

RSA

RSA는 공개키 암호화 시스템 중 하나이다. 간단하게 설명하면 공개키 + 개인키로 복호화 할 수 있다.

  • 공개키 : 말 그대로 누구에게나 공개된 키
  • 개인키 : 타인이 알 수 없고 개인이 가지고 있는 키

  1. B 공개키로 암호화 된 문서 (키 전달 문제 해결)
  • A가 문서를 보낼 때 B의 공개키로 암호화 한 문서를 전달한다면 B는 B의 개인키를 사용해서 문서를 열 수 있다.
  • 해커는 B의 개인키를 소장하지 않았기에 문서를 열람할 수 없다.
  1. A 개인키로 암호화 된 문서 (인증 문제 해결)
  • B가 A의 공개키를 사용해서 문서를 열 때 문서가 열린다면 A가 보낸 문서가 확실하고, 만약 열리지 않는다면 A가 보낸 문서가 아니다.

JWT (Json Web Token)

jwt 공식 사이트 : https://jwt.io/

jwt는 세션 기반 인증 방식의 문제점과 위에서 설명한 문제점들을 해결할 수 있다.

구조

jwt는 header, payload, signature 로 이루어져 있다. 실제 jwt을 보면 .으로 구분되어 있는데 순서대로 header, payload, signature이다.

  • header : 암호화 알고리즘과 타입에 대한 정보가 담겨있다.
  • payload : 저장한 데이터가 담겨있다.
  • siganature : 인코딩한 header,payload,secret key 정보가 담겨있다.

흐름

  1. 클라이언트가 서버에 로그인 요청
  2. 서버는 jwt 토큰을 생성하고 클라이언트에 반환
  3. 클라이언트는 인증이 필요한 요청을 할 때 jwt 토큰을 헤더에 담아 요청
  4. 서버는 jwt 토큰이 자기가 만든 토큰인지만 검증 -> jwt의 signatureheader+payload+서버의 secret key를 HS256 방식으로 암호화한 값이 같은지 검증

코드

전체 코드 : https://github.com/yryryr96/jwt

jwt 토큰을 쉽게 만들기위해 라이브러리 설정을 해준다.
implementation group: 'com.auth0', name: 'java-jwt', version: '4.4.0'build.gradle에 추가해준다.

폼 로그인과, 소셜 로그인을 구현하며 시큐리티의 로그인 과정을 설명했기에 중복되는 내용은 생략한다.

시큐리티는 /loginusername,password를 전송하면 UsernamePasswordAuthenticationFilter가 동작한다.
로그인을 시도하면 attemptAuthentication 메서드에서 유저 정보를 검증한다.

// /login 요청 시 로그인 시도를 위해 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    System.out.println("로그인 시도중 attemptAuthentication()");

    ObjectMapper objectMapper = new ObjectMapper();
    LoginRequestDto loginRequestDto = null;
    try {
        loginRequestDto = objectMapper.readValue(request.getInputStream(), LoginRequestDto.class);
    } catch (IOException e) {
        e.printStackTrace();
    }

    UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(loginRequestDto.getUsername(), loginRequestDto.getPassword());

    // PrincipalDetailsService의 loadUserByUsername() 함수 실행 -> authentication 반환
    Authentication authentication
            = authenticationManager.authenticate(authenticationToken);

    // authentication 객체가 session 영역에 저장된다. -> 로그인 성공
    PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
    System.out.println(principalDetails.getUser());
    System.out.println(principalDetails.getUser().getUsername());

    return authentication;
}

성공적으로 로그인이 완료됐다면 successfulAuthentication 메서드가 실행되는데 여기서 jwt 토큰을 만들고 response의 헤더에 담아서 클라이언트에게 반환하는 로직을 작성한다.

// attemptAuthentication 실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수가 실행된다.
// JWT 토큰을 만들어 request 요청한 사용자에게 JWT 토큰을 response 해주면 된다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult)
        throws IOException, ServletException {
    System.out.println("successfulAuthentication() = " + "인증이 완료되었다는 뜻");

    PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

    // Hash 암호 방식
    String jwtToken = JWT.create()
            .withSubject("cos토큰")
            .withExpiresAt(new Date(System.currentTimeMillis() + (EXPIRATION_TIME)))
            .withClaim("id", principalDetails.getUser().getId())
            .withClaim("username", principalDetails.getUser().getUsername())
            .sign(Algorithm.HMAC512(SECRET));

    response.addHeader(HEADER_STRING, TOKEN_PREFIX+jwtToken);
}

여기까지가 유저 정보를 검증하고 클라이언트에게 jwt 토큰을 반환하는 필터를 만드는 과정이었다.

다음으로는 클라이언트가 헤더에 jwt 토큰을 담아서 요청했을 때 토큰에 있는 유저 정보를 추출하여 인증하는 로직을 작성해보자.

시큐리티는 권한,인증이 필요한 요청이 들어오면 BasicAuthenticationFilter가 무조건 동작한다.

// 시큐리티 필터중 BasicAuthenticationFilter 가 있다.
// 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있다.
// 만약 권한이나 인증이 필요한 주소가 아니라면 BasicAuthenticationFilter를 타지 않는다.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private UserRepository userRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {

        super(authenticationManager);
        this.userRepository = userRepository;
    }

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

        System.out.println("권한이나 인증이 요청됨");

        String jwtHeader = request.getHeader("Authorization");
        System.out.println("jwtHeader = " + jwtHeader);

        // header가 있는지 확인
        if (jwtHeader == null || !jwtHeader.startsWith("Bearer")) {
            chain.doFilter(request, response);
            return;
        }

        //JWT토큰을 검증해서 정상적인 사용자인지 확인
        String jwtToken = request.getHeader(HEADER_STRING).replace(TOKEN_PREFIX,"");

        String username =
                JWT.require(Algorithm.HMAC512(SECRET)).build().verify(jwtToken).getClaim("username").asString();

        // 서명이 정상적으로 됨
        if (username != null) {

            User findUser = userRepository.findByUsername(username);

            PrincipalDetails principalDetails = new PrincipalDetails(findUser);

            //JWT 토큰 서명을 통해 서명이 정상이면 Authentication 객체를 만든다.
            Authentication authentication =
                    new UsernamePasswordAuthenticationToken(principalDetails,null, principalDetails.getAuthorities());
            // 강제로 시큐리티 세션에 접근하여 Authentication 객체 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);

    }
}
profile
공부 일기장

0개의 댓글