JWT로 TOKEN발급하기

강한친구·2022년 8월 22일
0

길었던 로그인 방식 처리의 마지막이다. 앞선 글에서 설명했던것처럼, 프로젝트에서는 결과적으로 Client Credential 방식을 사용하기로 하였다.

이때, 서버에서는 프론트가 보내주는 정보들을 받아서 회원가입 처리 및 검증처리 등등을 해주면 된다.

JWT 정보

jwt관련 정보는 JWTProperties라는 인터페이스에서 관리한다. 물론 jwt 만들때마다 직접 설정해 줄 수도 있다.


public interface JwtProperties {

    String SECRET = ""; // 우리 서버만 알고 있는 비밀값
    int EXPIRATION_TIME = 60000*10; // 10분
    String TOKEN_PREFIX = "Bearer ";
    String HEADER_STRING = "Authorization";
}

JWT 생성 코드

우선 build.gradle 에서 jwt를 import 해야한다

	implementation 'com.auth0:java-jwt:4.0.0'

물론 이거 말고도 다른 라이브러리도 존재한다.
이 방법 관련해서는 이 글을 참고하자.

implementation 'io.jsonwebtoken:jjwt-api:0.11.2' 
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' 
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2'
import Focus_Zandi.version1.domain.Member;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;

public class CreateJwt {

    public static String createAccessToken(Member memberEntity) {
        return JWT.create()
                .withSubject(memberEntity.getUsername())
//                .withExpiresAt(new Date(System.currentTimeMillis()+ JwtProperties.EXPIRATION_TIME))
//                .withExpiresAt(new Date(System.currentTimeMillis() + 60000*10))
                .withClaim("id", memberEntity.getId())
                .withClaim("username", memberEntity.getUsername())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));
    }

    public static String createRefreshToken(Member memberEntity, String AccessToken) {
        return JWT.create()
                .withSubject(memberEntity.getUsername())
//                .withExpiresAt(new Date(System.currentTimeMillis()+ 60000*100))
                .withClaim("AccessToken", AccessToken)
                .withClaim("username", memberEntity.getUsername())
                .sign(Algorithm.HMAC512(JwtProperties.SECRET));
    }
}

AccessToken, RefreshToken을 만들어주는 함수이다. Refresh토큰의 경우 access보다 수명이 길고, payload에 access를 넣어서 access가 expire하더라도 refresh로 재발급 받을 수 있도록 설정하였다.

아마 accessToken 부분은 필요없어서 나중에 지울듯하다.

함수 설명

withSubject : jwt의 이름을 정한다.
withExpireAt : jwt 만료시간을 지정한다. 설정하지 않으면 기본적으로 무한지속 jwt가 된다. 보안상 문제가 생기겠지만, 일단 테스트용으로 프론트에서 열어달라고 요청이 와서 열어두었다.
withClaim : jwt의 payload 부분에서 private을 설정한다. private의 이름과 그 내용을 적어넣을 수 있다. 여기에 유저이름을 넣어서 이를 기반으로 유저식별을 진행한다.

sign : 어떤 해싱 알고리즘으로 해시를 하는지, 어떤 시크릿키를 사용하는지 결정한다. 

소셜 멤버 인터페이스, 구글 클래스

public interface OAuthUserInfo {
    String getProviderId();
    String getEmail();
    String getName();
}

OAuth 서비스로 로그인을 하면 공통적으로 provider가 누군지, email 정보, 유저 이름 정도는 넘겨받아야 한다. 따라서 이를 인터페이스로 만들고, 각 서비스별로 구체화된 클래스로 구현하면 된다.

package Focus_Zandi.version1.web.config.oauth.provider;

import java.util.Map;

public class GoogleUser implements OAuthUserInfo{

    private Map<String, Object> attribute;

    public GoogleUser(Map<String, Object> attribute) {
        this.attribute = attribute;
    }

    @Override
    public String getProviderId() {
        return (String)attribute.get("userToken");
    }

    @Override
    public String getEmail() {
        return (String)attribute.get("email");
    }

    @Override
    public String getName() {
        return (String)attribute.get("fullName");
    }

}

지금은 구글만 구현해서 provider 정보를 따로 저장하지는 않는데 이 부분 관련해서 프론트와 협의를 거쳐서 수정을 해야 한다고 본다.

로그인 흐름

1. client에서 로그인

클라이언트에서 OAuth2 로그인을 진행하고, 이를 서버단으로 보낸다.

2. oauth/google 로 정보 전송

@RestController
@RequiredArgsConstructor
public class OauthJwtController {

    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @PostMapping("/oauth/google")
    public JwtReturner jwtCreate(@RequestBody Map<String, Object> data) {
        OAuthUserInfo googleUser =
                new GoogleUser((Map<String, Object>)data.get("profileObj")); // 프론트가 보내준 json 받기

        Member memberEntity = memberRepository.findByUserToken(googleUser.getProviderId());

//        String provider = googleUser.getProvider();
        String providerId = googleUser.getProviderId();
        String name = googleUser.getName();
        String username = providerId + "_" + name;
        String password = bCryptPasswordEncoder.encode("CommonPassword");
        String email = googleUser.getEmail();
        MemberDetails memberDetails = new MemberDetails();

        if(memberEntity == null) {
            Member memberRequest = Member.builder()
                    .username(username)
                    .userToken(providerId)
                    .password(password)
                    .name(name)
                    .email(email)
                    .memberDetails(memberDetails)
                    .build();
            memberEntity = memberRepository.save(memberRequest);
        }

        String accessToken = CreateJwt.createAccessToken(memberEntity);
        String refreshToken = CreateJwt.createRefreshToken(memberEntity, accessToken);

//        JwtReturner returner = CreateTokens.createAccessToken(memberEntity);

        return new JwtReturner(accessToken, refreshToken);
    }

사전에 합의된 json 형식으로 oauth/google url로 유저정보를 보내면, 서버단에서는 이를 받아서 회원가입 및 jwt 발행으로 로그인처리 해준다.

package Focus_Zandi.version1.domain.dto;

import lombok.Data;

@Data
public class JwtReturner {

    private String accessToken;
    private String RefToken;

    public JwtReturner(String accessToken, String refToken) {
        this.accessToken = accessToken;
        RefToken = refToken;
    }
}

반환용 DTO이다.

개선사항

물론 보안을 위해서는 json에 생으로 데이터를 넣어서 보내는것이 아니라 google에서 발급한 jwt를 이용해서 처리하거나, 다른 보안적 사항을 고려해야 한다고 생각한다.

Security Filter 설정

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .addFilter(corsConfig.corsFilter())
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()

                .addFilter(new JwtAuthorizationFilter(authenticationManager(), memberRepository))
    }

security를 보면, JwtAuthorizationFilter를 추가한것을 볼 수 있다. 이제 필터로 보호되는 모든 요청은 이 jwt 필터를 거쳐서만 들어갈 수 있고, 우리는 이 필터를 설정해서 Authentication을 발행할 수 있다.

JwtAuthentication


package Focus_Zandi.version1.web.config.jwt;

import java.io.IOException;
import java.util.Date;
import java.util.Enumeration;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import Focus_Zandi.version1.domain.Member;
import Focus_Zandi.version1.web.config.auth.PrincipalDetails;
import Focus_Zandi.version1.web.repository.MemberRepository;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;

@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private MemberRepository memberRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, MemberRepository memberRepository) {
        super(authenticationManager);
        this.memberRepository = memberRepository;
    }

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

        String access_token = request.getHeader("ACCESS_TOKEN");
        String refresh_token = request.getHeader("REFRESH_TOKEN");

//        //header에 있는 jwt bearer 토큰 검증
//        if (header == null || !header.startsWith(JwtProperties.TOKEN_PREFIX)) {
//            chain.doFilter(request, response);
//            return;
//        }
//
//        //bearer 부분 자르기
//        String token = request.getHeader(JwtProperties.HEADER_STRING).replace(JwtProperties.TOKEN_PREFIX, "");

        // 토큰 검증 (이게 인증이기 때문에 AuthenticationManager도 필요 없음)
        // 내가 SecurityContext에 집적접근해서 세션을 만들때 자동으로 UserDetailsService에 있는
        // loadByUsername이 호출됨.
        String username = null;
        String restoreAccessToken = null;

        try {
            username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(access_token)
                    .getClaim("username").asString();

            restoreAccessToken = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(refresh_token)
                    .getClaim("AccessToken").asString();

        } catch (TokenExpiredException e) {
            String restoreUsername = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(refresh_token)
                    .getClaim("username").asString();
            if (restoreUsername != null && restoreAccessToken == access_token) {
                Member member = memberRepository.findByUsername(restoreUsername);
                String accessToken = CreateJwt.createAccessToken(member);
                String refreshToken = CreateJwt.createRefreshToken(member, accessToken);

                response.setHeader("ACCESS_TOKEN", accessToken);
                response.setHeader("REFRESH_TOKEN", refreshToken);
            }
        }

        if (username != null) {
            Member member = memberRepository.findByUsername(username);

            // 인증은 토큰 검증시 끝. 인증을 하기 위해서가 아닌 스프링 시큐리티가 수행해주는 권한 처리를 위해
            // 아래와 같이 토큰을 만들어서 Authentication 객체를 강제로 만들고 그걸 세션에 저장!
            PrincipalDetails principalDetails = new PrincipalDetails(member);
            Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, // 나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함.
                    null, // 패스워드는 모르니까 null 처리, 어차피 지금 인증하는게 아니니까!!
                    principalDetails.getAuthorities());

            // 강제로 시큐리티의 세션에 접근하여 값 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

}

1. header에서 token 꺼내기

2. 토큰 유효성 확인하기

3-1. 유효하다면, 토큰안의 username 정보로 member를 찾고, PrincipalDetail을 만들어서 이 값을 SecurityContextHolder에 넣음

3-2 유효하지 않다면, refresh토큰을 검사하고, 유효하다면 토큰 재발급 유효하지 않다면 에러처리

이렇게 처리된 authentication은 각 컨트롤러로 전달되고, 컨트롤러에서는 authenticaiton안에 잇는 principal에서 유저를 식별해서 사용한다.

결론

우여곡절끝에 JWT를 이용한 로그인 처리 및 OAUTH로그인을 완성하였다. 물론 아직 보안적인 해결점이 남아있고, 서비스 확장을 대비해서 수정할 요소가 많이 있지만, 일단 예정되어있던 서비스 출시에는 맞출 수 있었다.

0개의 댓글