SpringBoot JWT 인증&인가처리(Security 5 버전)

devdo·2022년 6월 11일
0

SpringBoot

목록 보기
24/35
post-thumbnail

그전 포스팅 때
https://velog.io/@mooh2jj/Security-인증인가처리 블로그에서

Spring Secuirty에 대해서 알아보았다. 이 기반으로 jwt를 통한 인증 & 인가 처리를 정리해 볼려고 한다. JWT로 인증&인가처리를 할려면
Spring Security 디팬더시와 java설정이 필요하다.
=> Spring Security 설정 없이도 jwt 구현은 가능하다.

이 포스팅은 Spring Security 설정과 함께 jwt구현과 jwt검증을 할 것이다.


JWT(Json Web Token)

암호 알고리즘

  • 대칭키 : 개인키 하나. ex) 해쉬알고리즘-HS256, HS512 (각각 숫자는 256Byte, 512Byte 라는 뜻)
  • 비대칭키 : 암/복호화키가 서로 다름. 각 개인당 한쌍의 키를 갖고 있다.(개인키-공개키) ex) RS256

JWT란?

  • JWT는 서버와 클라이언트 간 정보를 주고 받을 때 Http Request header에 JSON 토큰을 넣은 후 서버는 별도의 인증 과정없이 header에 포함되어 있는 JWT 정보를 가지고 인증합니다.

  • JWT는 속성 정보 (Claim)를 JSON 데이터 구조로 표현한 Token으로 RFC7519 표준이다.

  • 이때 사용되는 JSON 데이터는 URL-Safe 하도록 URL에 포함할 수 있는 문자만으로 만든다.

  • JWT는 해시 암호화 알고리즘(HMAC/SHA256/RSA)로 공용키/비밀키 쌍을 사용하여 서명할 수 있다.


✨ JWT를 사용하는 이유

  • CSRF, 기존 시스템의 보안 문제 -> jwt를 사용하면 검증이 돼서 해결됨.
  • CORS, 도메인 확장시 api로서의 문제에 자유로움 -> jwt는 사용자 인증에 필요한 모든 정보(Claim)를 토큰에 넣음, 그걸 http header에 넣어서 클라이언트가 가지게 함.
    즉, JWT를 사용하면, 사용자 인증 정보를 HTTP 헤더에 넣어서 클라이언트가 가지게 함으로써, CORS 문제를 해결할 수 있다.
  • Mobile 클라이언트에게도 사용가능
  • Session 의 한계 - 서버 메모리 리소스 낭비
  • Scale out에 용이 -> 서버 확장시 세션정보 동기화가 힘듦. -> 그에 비해 jwt는 별도의 인증 저장소가 필요없음. 인증정보를 담을 서버 필요가 없기에 MSA 환경에 맞는 인가방법임.
  • REST API는 Stateless를 지향

자세한 내용은 : https://velog.io/@mooh2jj/JWT와-session-기반-인증의-차이점


CSRF vs CORS

  • CSRF(Cross site request forgery) : 웹사이트의 인증된 사용자가 의도하지 않은 요청을 서버에 보내게 되는 공격 기법
  • CORS(Cross-origin resource sharing) : 도메인이 다른 웹 사이트에서 API를 호출할 때 발생, 외부(특히, 자바스크립트에서) 온 자원소스들이 접근하는 것

✨ JWT 구조

JWT를 이용하면 서버는 별도의 인증 과정없이 Header에 포함되어 있는 token을 통해 인증이 가능하다.

JWT는 RSA, HMAC 등 해시 암호화 알고리즘을 사용해 서명할 수 있다.

  • HMAC은 비밀키를 이용한 대칭키 암호화 방식으로, 서버와 클라이언트 모두에게 동일한 비밀키를 가지고 있어야 합니다.
  • RSA는 공개키/비밀키 쌍으로 비대칭키 암호화 방식으로, 서버는 비밀키를 가지고 있고, 클라이언트는 공개키를 가지고 있습니다.

  • header : Token의 타입(jwt, jws 등)과 해시 암호화 알고리즘(HMAC/SHA256/RSA)으로 구성
    Base64Url로 되어있지만 실제 데이터는 JSON 형태로 되어있음
  • payload : Token에 담을 Claim 정보(사용자 정보)를 포함, key / value의 한 쌍으로 이루어져 있다. Token에는 여러개의 Claim을 넣을 수 있음
  • signature : Header에 대한 Base64Url를 적용한 값 + payload에 대한 Base64Url를 적용한 값들을 Header에 구성된 알고리즘으로 적용한 값 => secret key로 암호화 (secret key는 서버측에 존재)

    jwt를 수정할려면 secret key를 알고 있어야 한다. 설사, 변경할지라도 서명이 다르기 때문에 받지 않게 된다.

Header, Payload, Signature 를 합치면 JWT를 위한 토큰이 되며, 이를 통해 데이터를 주고 받을 수 있다.

const header = {
 "alg": "HS256",
 "typ" : "JWT"
}

const payload = {
 'id' : '1'
 'role':'admin',
 'compoany': 'pepsi'
} 

const signature = HMAC_SHA256(
 secret, // secret 은 암호화를 위한 임의의 비밀키 입니다.
 header + '.' + payload
)

// token 생성
const token = base64urlEncoding(header) + '.' + base64urlEncoding(payload) + '.' + base64urlEncoding(signature)

JWT 인증&인가처리 과정

1) 이미 회원가입한 클라이언트가 login을 하면, 서버로부터 token을 부여받는다.(secret key 사용)
2) 이후 클라이언트가 모든 api요청을 할 때 header에 token을 포함하게 된다.
3) 서버는 token을 해독해 확인하고 검증하면 해당 api 기능을 수행하게 한다.
4) token이 기한이 expire(만료)되었으면 token을 지워주고 재로그인을 하게 한다.

문제점 : 토큰 만료시간! <=> 해결법 : Refresh Token
(토큰이 만료되었을 때, Refresh 토큰으로 서버에 새로운 토큰을 발급받을 수 있다.)

  • Access Token 만료기간 : 30분 ~ 1시간
  • Refresh Token 만료기간 : 1일 ~ 1달으로 설정하는 듯 하다.

Refresh Token

사실 클라이언트는 토큰을 2개(accessToken, refreshToken) 받는다.

왜냐? 사용자가 5분마다 재로그인? 너무 고통스럽다. 그래서 임시비번으로서 토큰을 사용하는데
그게 refresh Token!

refresh Token은 보통 만료기간 1년 잡는다.

accessToken이 5분이 지나 만료 -> refreshToken을 통해서 새로운 accessToken을 받을 수 있다.
이 refresh 토큰은 DB에 저장해두어야 한다.


JWT를 Spring Security에 구현하는 과정

1) SecurityConfig에 jwt EntryPoint(JwtAuthenticationEntryPoint), jwt필터(JwtAuthenticationFilter) 등록 -> jwt설정을 위해 config 설정
2) 클라이언트 username, password 로그인 시도(json으로 보냄)
3) 로그인시, 서버는 JWT을 생성, 클라이언트 쪽으로 JWT토큰을 사용자에게 응답
4) 사용자는 이제 요청할 때마다 JWT토큰을 가지고 Authrization : Bearer {jwt토큰} 으로 등록된 상태로 페이지요청 (ex. /user)
5) 서버는 JWT토큰이 유효한지를 검증(ex. verify(jwtToken), 필터가 처리 : JwtAuthorizationFilter(인가) )


Bearer 란?

Token에는 많은 종류가 있고 서버는 다양한 종류의 토큰을 처리하기 위해 전송받은 type에 따라 토큰을 다르게 처리합니다. BearerToken 인증타입 종류 중의 하나이다.

✅ 인증 타입 종류

Basic
사용자 아이디와 암호를 Base64로 인코딩한 값을 토큰으로 사용한다. (RFC 7617)

Bearer
JWT 혹은 OAuth에 대한 토큰을 사용한다. (RFC 6750)

Digest
서버에서 난수 데이터 문자열을 클라이언트에 보낸다. 클라이언트는 사용자 정보와 nonce를 포함하는 해시값을 사용하여 응답한다 (RFC 7616)

HOBA
전자 서명 기반 인증 (RFC 7486)

Mutual
암호를 이용한 클라이언트-서버 상호 인증 (draft-ietf-httpauth-mutual)

AWS4-HMAC-SHA256
AWS 전자 서명 기반 인증 (링크)


구현소스

구버전

  • build.gradle 추가
// https://mvnrepository.com/artifact/com.auth0/java-jwt
implementation group: 'com.auth0', name: 'java-jwt', version: '3.18.1'
  • JWT 생성
  var jwtToken = JWT.create()
             .withSubject("cos토큰")
             .withExpiresAt(new Date(System.currentTimeMillis() + 60000 * 5))        // 토큰 만료시간
             .withClaim("id", principalDetails.getUser().getId())
             .withClaim("username", principalDetails.getUser().getUsername())
             .sign(Algorithm.HMAC512("cos"));
  • JWT 검증
// 사용자 request 헤더의 token을 가져오고
String jwtToken = request.getHeader("Authorization").replace("Bearer ", "");

// token 검증
String username = JWT.require(Algorithm.HMAC512("cos")).build()
                        .verify(jwtToken)
                        .getClaim("username")
                        .asString();

신버전

  • build.gradle 추가
// jwt
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

JwtTokenProvider

@Component
public class JwtTokenProvider {

    //    @Value("${jwt.secret}")
    private static final String jwtSecret = "c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK";

    //    @Value("${jwt.token-validity-in-seconds}") // mils 
    private static final int jwtExpirationInMs = 604800000;

    // generate token
    public String generateToken(Authentication authentication) {
        String username = authentication.getName();
        Date currentDate = new Date();
        Date expireDate = new Date(currentDate.getTime() + jwtExpirationInMs);

        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }

    // get username from the token
    public String getUsernameFromJWT(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody();

        return claims.getSubject();
    }

    // validate JWT token
    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        } catch (SignatureException ex){
            throw new APIException("Invalid JWT signature", HttpStatus.BAD_REQUEST);
        } catch (MalformedJwtException ex) {
            throw new APIException("Invalid JWT token", HttpStatus.BAD_REQUEST);
        } catch (ExpiredJwtException ex) {
            throw new APIException("Expired JWT token", HttpStatus.BAD_REQUEST);
        } catch (UnsupportedJwtException ex) {
            throw new APIException("Unsupported JWT token", HttpStatus.BAD_REQUEST);
        } catch (IllegalArgumentException ex) {
            throw new APIException("JWT claims string is empty.", HttpStatus.BAD_REQUEST);
        }
    }

}

JwtAuthenticationFilter

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtTokenProvider jwtTokenProvider;
    @Autowired
    private UserService customUserDetailsService;


    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // get JWT(token) from http request
        String token = getJWTFromToken(request);
        // validate token
        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            // get username from token
            String username = jwtTokenProvider.getUsernameFromJWT(token);
            // load user associated with token
            UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities()
            );
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            // set spring security
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        filterChain.doFilter(request, response);
    }

    // Bearer <accessToken>
    private String getJWTFromToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7, bearerToken.length());
        }
        return null;
    }
}

JwtAuthenticationEntryPoint

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
    }
}

JWTAuthResponse

@Getter
@Setter
public class JWTAuthResponse {
    private String accessToken;
    private String tokenType = "Bearer";

    public JWTAuthResponse(String accessToken) {
        this.accessToken = accessToken;
    }


}

SecurityConfig

    private final JwtAuthenticationEntryPoint authenticationEntryPoint;

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }

configure(HttpSecurity http) 에 추가

    ...
    
            http
                .csrf().disable()
                // jwt
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers(HttpMethod.GET, "/todo/**").permitAll()
                .antMatchers("/users/**").permitAll()
                .anyRequest().authenticated();   // 그외는 인증을 해야 한다.
              
//              .and()
//              .httpBasic();
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

🍀 참고) SpringSecurity 스프링시큐리티 세션정책
https://fenderist.tistory.com/342

http
      .sessionManagement()
      .sessionCreationPolicy(SessionCreationPolicy.정책상수)

1) SessionCreationPolicy.ALWAYS - 스프링시큐리티가 항상 세션을 생성

2) SessionCreationPolicy.IF_REQUIRED - 스프링시큐리티가 필요시 생성(기본)

3) SessionCreationPolicy.NEVER - 스프링시큐리티가 생성하지않지만, 기존에 존재하면 사용
4) SessionCreationPolicy.STATELESS - 스프링시큐리티가 생성하지도않고 기존것을 사용하지도 않음 ->JWT 같은토큰방식을 쓸때 사용하는 설정


테스트

1) signin시(로그인 POST api 요청)

=> Create Token with secret key

2) 권한이 필요한 insert POST api 요청시

=> Request With Token on Header

Authentication 탭에 Type: Bearer Token에 signin시 서버에서 생성한 jwt Token인 accessToken 값을 넣어주면 권한인증이 되어 insert가 실행될 수 있게 된다!

or

Headers 탭에서 Authorization: Bearer XXXX...(accesToken) 넣어주고 & Content-Type: application/json 넣어주기


✨ JWT 특성 정리

  • Self-contained: 토큰 자체에 정보를 담고 있음.
  • 토큰 길이 : 토큰의 페이로드(Payload)에 3종료의 클레임을 저장하기 때문에, 정보가 많아질수록 토큰의 길이가 늘어나 네트워크에 부하를 줄 수 있다.
  • Payload 인코딩: 페이로드(Payload) 자체는 암호화 된 것이 아니라, BASE64로 인코딩 된 것!
    중간에 Payload를 탈취하여 디코딩하면 데이터를 볼 수 있으므로, JWE로 암호화하거나 Payload에 중요 데이터(claim:사용자정보) 가 있다.
  • Stateless: JWT는 상태를 저장하지 않기 때문에 한번 만들어지면 제어가 불가능하다. 즉, 토큰을 임의로 삭제하는 것이 불가능하므로 토큰 만료시간을 꼭 넣어줘야 한다.
  • Tore Token: 토큰은 클라이언트 측에서 관리해야 하기 때문에, 토큰을 저장해야 한다.

순수 JWT만 구성

jwt로 token을 만들고 프론트에 Cookie안에 넣어서 전달하는 방식

참고)
https://velog.io/@mooh2jj/사용자-정보-인증시-필요한-기술-Vuex-Cookie-JWT

JwtService

@Slf4j
@Service
public class JwtServiceImpl implements JwtService {

    @Value("${jwt.secretKey}")
    private String secretKey;

	// token 생성
    @Override
    public String getToken(String key, Object value) {

        log.info("getToken, secretKey: {}", secretKey);

        Date expTime = new Date();
        expTime.setTime(expTime.getTime() + 1000 * 60 * 5);     // 1000 * 60: 1분, 1000 * 60 * 5: 5분

        byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
        Key signKey = new SecretKeySpec(secretByteKey, SignatureAlgorithm.HS256.getJcaName());

        Map<String, Object> headerMap = new HashMap<>();
        headerMap.put("type", "JWT");
        headerMap.put("alg", "HS256");

        Map<String, Object> map = new HashMap<>();
        map.put(key, value);

        JwtBuilder jwtBuilder = Jwts.builder().setHeader(headerMap)
                .setClaims(map)
                .setExpiration(expTime)
                .signWith(signKey, SignatureAlgorithm.HS256);

        return jwtBuilder.compact();
    }

	// Claims 정보 가져오기
    @Override
    public Claims getClaims(String token) {

        if (!StringUtils.isEmpty(token)) {
            try {
                byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
                Key signKey = new SecretKeySpec(secretByteKey, SignatureAlgorithm.HS256.getJcaName());

                return Jwts.parserBuilder()
                        .setSigningKey(signKey).build()
                        .parseClaimsJws(token).getBody();

            } catch (ExpiredJwtException e) {
                // 만료됨
            } catch (JwtException e) {
                // 유효하지 않음
            }
        }

        return null;
    }
}

jwt 공부하면서 질문 생긴거

1) jwt만 사용하는데 왜 userdetailsServiec를 왜 구현하는가?

jwt 토큰을 UserDetails 객체로 변환하는 방법은 여러가지가 있을 수 있고, 변할 수 있습니다.

토큰을 바로 UserDetails로 변환할수도 있고, DB에서 읽어올수도 있고, 외부 API를 통할수도, 캐시에서 읽어올수도 있습니다.

변할 수 있는 부분을 따로 분리해둔 것 뿐이고, 필요 없다면 안써도 됩니다.

다만, 안쓸거면 AuthenticationProvider를 직접 구현해줘야합니다.

AuthenticationProvider 구현체들을 보면 알겠지만, 그걸 베껴서 재구현하느니 저라면 그냥 UserDetailsService를 간단하게 구현하겠습니다.

2) jwt 왜 loadbyusername에서 db유저정보를 조회하느가?

jwt는 토큰에 저장된 정보만 사용할수도 있지만, 때로는 그것만으로 부족해서 추가로 저장해둔 정보를 읽어와야합니다.

DB를 조회하는 이유는 jwt는 인증 서비스에서 발급해주는 것이고, 각 서비스별로 추가 유저 데이터를 가지고 있을 수 있기 때문입니다.

인증 서비스와 비즈니스 서비스가 하나이더라도 토큰에 저장하기엔 너무 민감한 정보들이 있어서 DB를 추가로 조회할수도 있습니다.

서비스가 하나인데 왜 jwt를 쓰느냐? 나중에 분리될수도 있으니까요.



참고

profile
배운 것을 기록합니다.

0개의 댓글