길었던 로그인 방식 처리의 마지막이다. 앞선 글에서 설명했던것처럼, 프로젝트에서는 결과적으로 Client Credential 방식을 사용하기로 하였다.
이때, 서버에서는 프론트가 보내주는 정보들을 받아서 회원가입 처리 및 검증처리 등등을 해주면 된다.
jwt관련 정보는 JWTProperties라는 인터페이스에서 관리한다. 물론 jwt 만들때마다 직접 설정해 줄 수도 있다.
public interface JwtProperties {
String SECRET = ""; // 우리 서버만 알고 있는 비밀값
int EXPIRATION_TIME = 60000*10; // 10분
String TOKEN_PREFIX = "Bearer ";
String HEADER_STRING = "Authorization";
}
우선 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 정보를 따로 저장하지는 않는데 이 부분 관련해서 프론트와 협의를 거쳐서 수정을 해야 한다고 본다.
클라이언트에서 OAuth2 로그인을 진행하고, 이를 서버단으로 보낸다.
@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를 이용해서 처리하거나, 다른 보안적 사항을 고려해야 한다고 생각한다.
@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을 발행할 수 있다.
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);
}
}
이렇게 처리된 authentication은 각 컨트롤러로 전달되고, 컨트롤러에서는 authenticaiton안에 잇는 principal에서 유저를 식별해서 사용한다.
우여곡절끝에 JWT를 이용한 로그인 처리 및 OAUTH로그인을 완성하였다. 물론 아직 보안적인 해결점이 남아있고, 서비스 확장을 대비해서 수정할 요소가 많이 있지만, 일단 예정되어있던 서비스 출시에는 맞출 수 있었다.