코드로 배우는 스프링 부트 웹 프로젝트/Security & API

API Server 구성 (4) (AuthenticationManager, JWT 생성 및 적용, CORSFilter)

soohykeee 2023. 2. 1. 20:54
728x90

 

API Server 구성 (3) (OncePerReqeustFilter, AbstractAuthenticationProcessingFilter, Authorization Header)

 

API Server 구성 (3) (OncePerReqeustFilter, AbstractAuthenticationProcessingFilter, Authorization Header)

API Server 구성 (2) (Note_Controller) API Server 구성 (2) (Note_Controller) API Server 구성 (1) (Note entity, dto, service, controller, repository) API Server 구성 (1) (Note entity, dto, service, controller, repository) Spring Security 연동 (7) (

soohykeee.tistory.com

 


 

AuthenticationManager를 이용하여 인증처리

앞서 만들고 구현해주었던, ApiLoginFilter가 정상적으로 동작하기 위해서는 내부적으로 AuthenticationManager를 이용하여 동작하도록 수정해야 한다. AuthenticationManager는 authenticate() 메소드를 가지고 있는데, 해당 메소드는 파라미터와 리턴타입 모두 Authentication 타입이다.

직접 Authentication 타입의 객체를 생성하여 넘겨줄 수 있지만, 간단한 예제 구현을 위해서 UsernamePasswordAuthenticationToken 객체를 생성하여 인증에 사용한다.

@Log4j2
public class ApiLoginFilter extends AbstractAuthenticationProcessingFilter {

    public ApiLoginFilter(String defaultFilterProcessesUrl) {
        super(defaultFilterProcessesUrl);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        log.info("--------------ApiLoginFilter----------------");
        log.info("attemptAuthentication");

        String email = request.getParameter("email");
        String pw = "1111";
        
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, pw);

        return getAuthenticationManager().authenticate(authToken);
    }
    
}

위 코드처럼 변경한 내용은 email, pw를 파라미터로 받아서 실제 인증을 처리하는 것이다. 브라우저 URL에 '/api/login?email=~~~&pw=~~~' 와 같이 실제 사용자 계정 정보로 파라미터를 넘겨주어 로그인 시도를 하면 정상적으로 로그인이 되는 것을 확인할 수 있다.

http://localhost:8080/api/login?email=user10@zerock.org&pw=1111

을 입력하면 localhost:8080 으로 이동이 된다. 하지만 '/sample/member'로 접속하게 되면 로그인이 성공적으로 되어있는것을 확인할 수 있다.

 

ApiLoginFilter로 직접 인증처리를 했다면, 이제 해줘야할 작업들은 인증 실패, 성공에 따른 처리가 필요하다. 이를 위한 방법은 크게 2가지가 존재한다.

  • 기존의 메서드를 Override해서 재구현
  • 별도의 클래스를 생성하여 구현

ApiLoginFilter에서 인증에 실패하는 경우에는, API 서버는 일반 화면이 아니라 JSON 결과가 전송되도록 수정해야 하고, 인증에 성공하는 경우에는, 클라이언트가 보관할 인증 토큰이 전송되어야 한다.

 

인증 실패 처리

AbstractAuthenticationProcessingFilter에는 setAuthenticationFailureHandler() 를 이용하여 인증에 실패했을 경우 처리를 지정해줄 수 있다. 이를 위해서 security 디렉토리 하위 handler 디릭토리에, ApiLoginFailHandler 클래스를 추가해준다.

 

@Log4j2
public class ApiLoginFailHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.info("login fail handler......................");
        log.info(exception.getMessage());

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        response.setContentType("application/json; charset=utf-8");
        JSONObject json = new JSONObject();
        String message = exception.getMessage();
        json.put("code", "401");
        json.put("message", message);

        PrintWriter out = response.getWriter();
        out.print(json);
        
    }
}

 

SecurityConfig 클래스의 코드도 수정해줘야 한다. 

@Bean
public ApiLoginFilter apiLoginFilter() throws Exception {
    ApiLoginFilter apiLoginFilter = new ApiLoginFilter("/api/login");
    apiLoginFilter.setAuthenticationManager(authenticationManager());

    apiLoginFilter.setAuthenticationFailureHandler(new ApiLoginFailHandler());

    return apiLoginFilter;
}

 

 

인증 성공 처리

인증 성공 또한 별도의 클래스를 생성하여 구현해줄 수 있지만, AbstractAuthenticationProcessingFilter 클래스에 있는 successfulAuthentication() 메서드를 override 해서 구현해 줄 것이다.

ApiLoginFilter 클래스에 successfulAuthentication()를 아래와 같이 Override 해주면 된다.

@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
    log.info("---------------ApiLoginFilter-----------------");
    log.info("successfulAuthentication: " + authResult);

    log.info(authResult.getPrincipal());
}

 

위의 successfulAuthentication() 메소드의 마지막 파라미터인 Authentication타입의 authReulst 객체는 인증에 성공한 사용자의 정보를 가지고 있다. 따라서 위처럼 코드 작성 시, 인증에 성공한 사용자의 정보를 consol에서 확인이 가능하다.

 

 


 

JWT Token 생성 및 검증

[개념] JWT란 무엇인가?

 

[개념] JWT란 무엇인가?

JWT를 공부하기 전, 우선으로 Session 인증 방식과 Token 인증 방식에 대해 알아둘필요가 있다. [개념] Session 기반 인증 vs Token 기반 인증 [개념] Session 기반 인증 vs Token 기반 인증 인증 방식 종류 JWT에

soohykeee.tistory.com

 

앞서 JWT에 대해 간단하게 정리해놓았었다. 위를 참고하고 해당 예제를 하는것이 도움이 될 것이다.

인증이 성공한 후, 사용자가 '/notes/xx~~/' 와 같은 API를 호출할 때 사용할 적절한 데이터를 만들어서 전송해줘야 한다. 이를 위해서 JWT를 이용할 것이다. 간단하게 말하자면, 인증이 성공한 사용자에게 JWT를 전송해주고, API를 호출할 때 JWT와 같이 보내면 해당 문자열을 읽어서 정상적인 Request인지 확인하는 방식이다.

기본적으로 JWT는 '.' 으로 Header, Payload, Signature가 구분된다. 

  • Header : Token 타입과 알고리즘을 의미한다. 주로 HS256, RSA를 사용
  • Payload : 이름(name)과 값(value)의 쌍을 Claim이라하고, claims를 모아둔 객체를 의미한다.
  • Signature : Header의 인코딩 값과 Payload의 인코딩 값을 합쳐 비밀 키로 해시 함수로 처리된 결과

아래의 JWT 공식 페이지를 통해 미리 JWT를 볼 수 있다.

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

우리 해당 예제에서는 'io:jsonwebtoken' 라이브러리를 사용하여 구현할 것이다. 이를 위해 build.gradle에 아래와 같이 의존성을 추가해준다. 해당 프로젝트에서 JWT가 사용되는 경우는 다음과 같다.

  1. 인증에 성공했을 때, JWT 문자열을 만들어서 클라이언트에게 전송하는 것
  2. 클라이언트가 보낸 Token의 값을 검증하는 경우
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'

 

security 디렉토리 하위에 util 디렉토리 생성 후, JWTUtil 클래스를 생성해준다.

 

package com.example.club.security.util;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.impl.DefaultClaims;
import io.jsonwebtoken.impl.DefaultJws;
import lombok.extern.log4j.Log4j2;

import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.util.Date;

@Log4j2
public class JWTUtil {

    private String secretKey = "zerock12345678";    // 실사용시에는, 더 복잡하고 보안이 있게 설정 필요

    private long expire = 60 * 24 * 30;     // 유효기간 1달
    
    public String generateToken(String content) throws Exception {
        return Jwts.builder()
                .setIssuedAt(new Date())
                .setExpiration(Date.from(ZonedDateTime.now().plusMinutes(expire).toInstant()))
                .claim("sub",content)
                .signWith(SignatureAlgorithm.HS256, secretKey.getBytes(StandardCharsets.UTF_8))
                .compact();
    }

    public String validateAndExtract(String tokenStr) throws Exception {
        String contentValue = null;

        try {
            DefaultJws defaultJws = (DefaultJws) Jwts.parser().setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)).parseClaimsJws(tokenStr);

            log.info(defaultJws);
            log.info(defaultJws.getBody().getClass());

            DefaultClaims claims = (DefaultClaims) defaultJws.getBody();

            log.info("-----------------------------");
            contentValue = claims.getSubject();

        } catch (Exception e) {
            e.printStackTrace();
            log.error(e.getMessage());
            contentValue = null;
        }

        return contentValue;
        
    }
    
}

 

위의 구현한 코드에서 generateToken()은 JWT 토큰을 생성하는 역할을 하고, validateAndExtract()는 인코딩된 문자열에서 원하는 값을 추출하는 용도로 작성했다. generateToken()은 JWT 문자열 자체를 알면 누구든 API를 사용할 수 있다는 문제가 생기므로, 만료기간 (expire)을 설정하였고, 'zerock12345678' 이라는 키를 이용해서 Signature을 생성한다. 또한 'sub' 라는 이름의 claim에 사용자의 이메일주소를 입력해주어 나중에 사용할 수 있도록 구성했다.

이제 해당 JWTUtils 클래스가 제대로 구현이 되었는지 확인하기 위해서 JWTTests 테스트 클래스를 만들어서 확인해보겠다. 

 

public class JWTTests {

    private JWTUtil jwtUtil;

    @BeforeEach
    public void testBefore() {
        System.out.println("testBefore.................");
        jwtUtil = new JWTUtil();
    }

    @Test
    public void testEncode() throws  Exception {
        String email = "user95@zerock.org";
        String str = jwtUtil.generateToken(email);

        System.out.println(str);
    }
}

 

 

생성된 JWT를 Encoded에 넣기 전, Decoded에 있는 하단에 입력하는 곳에 미리 작성해주었던 SecretKey를 작성해준 후, 생성된 JWT를 넣어주면 Signature Verified 메세지가 출력되는 것을 확인할 수 있다.

 


 

JWT 적용

위 처럼 정상적으로 JWT가 생성이 되는 것을 확인했으면, 이젠 해당 프로젝트에 ApiLoginFilter, ApiCheckFilter에 적용시켜줘야 한다. ApiLoginFilter에서 로그인이 성공한 후 JWT문자열을 사용자에게 전송하도록 수정해줘야 한다. 따라서 ApiLoginFilter의 생성자에 JWTUtil을 넣어준다.

JWTUtil을 이용해서 successfulAuthentication() 내에서 문자열을 발행해주도록 코드를 수정해준다.

@Log4j2
public class ApiLoginFilter extends AbstractAuthenticationProcessingFilter {

    private JWTUtil jwtUtil;

    public ApiLoginFilter(String defaultFilterProcessesUrl, JWTUtil jwtUtil) {
        super(defaultFilterProcessesUrl);
        this.jwtUtil = jwtUtil;
    }

	@Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        log.info("---------------ApiLoginFilter-----------------");
        log.info("successfulAuthentication: " + authResult);

        log.info(authResult.getPrincipal());

        //email address
        String email = ((ClubAuthMemberDTO) authResult.getPrincipal()).getUsername();
        String token = null;

        try {
            token = jwtUtil.generateToken(email);

            response.setContentType("text/plain");
            response.getOutputStream().write(token.getBytes());
            log.info(token);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    // ... 생략
    
    
}

 

SecurityConfig 클래스에서 JWTUtil을 @Bean으로 등록하고, ApiLoginFilter를 생성하는 부분에 JWTUtil을 생성자에서 사용할 수 있도록 수정해준다.

@Bean
public JWTUtil jwtUtil() {
    return new JWTUtil();
}

@Bean
public ApiLoginFilter apiLoginFilter() throws Exception {
    ApiLoginFilter apiLoginFilter = new ApiLoginFilter("/api/login", jwtUtil());
    apiLoginFilter.setAuthenticationManager(authenticationManager());

    apiLoginFilter.setAuthenticationFailureHandler(new ApiLoginFailHandler());

    return apiLoginFilter;
}

 

위처럼 코드를 작성해준 후, 'http://localhost:8080/api/login?email=user10@zerock.org&pw=1111'을 실행하면 JWT가 생성되는 것을 확인할 수 있다.

 

이제 ApiCheckFilter에 Authorization 헤더 메시지를 통해서 JWT를 확인하도록 수정해줘야 한다. ApiCheckFilter는 JWTUtil이 필요하므로 생성자를 통해 주입하도록 수정해줘야 한다. 또한 ApiCheckFilter 내부의 checkAuthHeader()에서는 JWTUtil의 validateAndExtract()를 호출하도록 수정해준다.

@Log4j2
public class ApiCheckFilter extends OncePerRequestFilter {

    private AntPathMatcher antPathMatcher;
    private String pattern;
    private JWTUtil jwtUtil;

    public ApiCheckFilter(String pattern, JWTUtil jwtUtil) {
        this.antPathMatcher = new AntPathMatcher();
        this.pattern = pattern;
        this.jwtUtil = jwtUtil;
    }
    
    private boolean checkAuthHeader(HttpServletRequest request) {
        boolean checkResult = false;

        String authHeader = request.getHeader("Authorization");

        if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
            log.info("Authorization Exist : " + authHeader);

            try {
                String email = jwtUtil.validateAndExtract(authHeader.substring(7));
                log.info("validate result : " + email);
                checkResult = email.length() > 0;
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        return checkResult;
    }
    
    // ... 생략

}

 

checkAuthHeader()는 내부에서 Authorization헤더를 추출해서 이를 검증하는 역할을 한다.
SecurityConfig에서는 ApiCheckFilter를 이용할 때 JWTUtil을 사용하도록 수정해준다.

@Bean
public ApiCheckFilter apiCheckFilter() {
    return new ApiCheckFilter("/notes/**/*", jwtUtil());
}

 

위와 같이 코드를 작성해 준 후, 로그인에 성공했을 때 주어지는 JWT로 요청을 보냈을 시 정상적으로 접근이 가능한지 확인해주기위해서 Postman을 사용했다.

앞서 'http://localhost:8080/api/login?email=user10@zerock.org&pw=1111'을 사용하여 JWT 값을 가져오고, postman에서 Get 방식을 사용하는 'http://localhost:8080/notes/5' 경로에 Authorization에 대한 value 값을 앞에 Bearer 을 추가해준 후 JWT 값을 붙여넣기 하고 실행해보면 정상적으로 접근이 가능한 것을 확인할 수 있다.
만일, Authorization 값이 다르거나 없이 요청을 보낸다면 접근이 불가능한것도 확인할 수 있다.

 


 

CORS 필터 처리

[개념] CORS(Cross-Origin Resource Sharing) 란 무엇인가?

 

[개념] CORS(Cross-Origin Resource Sharing) 란 무엇인가?

CORS란? CORS는 Cross-Origin Resource Sharing 의 약자로, 다른 자원들을 공유한다는 뜻이다. 웹페이지 상의 제한된 리소스를 최초 자원이 서비스된 도메인(origin) 밖의 다른 도메인(cross-origin) 으로부터 요

soohykeee.tistory.com

 

위와 같이 모두 작성해준다면, REST 방식의 테스트는 모두 성공했지만 결정적으로 외부에서 Ajax를 이용해서 API를 사용하기 위해서는 CORS (Cross-Origin Resource Sharing) 문제를 해결해줘야 한다.

CORS 문제를 해결하는 방식은 다양하게 존재하지만, 해당 프로젝트에서는 이를 해결해주기위해 추가로 작성해줄 것이다. filter 디렉토리 하위에 CORSFilter 클래스를 생성해준다.

 

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CORSFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Methods", "*");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Key, Authorization");

        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
        } else {
            filterChain.doFilter(request, response);
        }
    }
}

 

 

 

 


 

728x90