JWT 처음에 공부하면서 프로젝트에 적용한 경험이 있었지만
Application 서버와 API 서버를 나눠서 구현을 해보게 되면서 좀 다르게 쓰게 되지 않을까 싶어서 사용해 보게 되었다.
이 포스팅의 중점은 기록정도.
프로젝트는 간단한 이미지 게시판, 텍스트만 작성이 가능한 게시판으로 구성되어 있고,
Application 서버와 API 서버로 구분해서 처리를 한다.
Application 서버에서는 API 서버에 데이터 처리 요청을 보내고,
API서버에서는 사실상 거의 모든 처리를 한 뒤 Application 서버에 결과를 리턴해주는 형태로 구현했다.
프로젝트 환경은 아래와 같다.
- Spring boot 2.7.6
- Spring Data JPA
- Spring Security
- MySQL
- JWT 4.2.1
기존 프로젝트를 그대로 가져다가 리펙토링 하자는 생각으로 처리했고 기존에는 SpringSecurity로 처리하고 있었다.
사실 그대로 Security만 써서 쓰려다가 세션 리턴도 좀 애매하고 'RESTAPI는 Stateless 해야 한다' 라는 점에서도 좀 그렇지 않나.... 하는 생각해 JWT 써볼까??? 해서 쓰게 되었다..ㅎㅎ
그럼에도 SpringSecurity를 살려두고 같이 사용한 이유는 권한관리 때문이었다.
그리고 그 외에도 SpringSecurity에 유용한 기능이 많아 이렇게 복합적으로 사용하는 경우가 많다고 한다.
JWT 토큰 구성
토큰은 AccessToken과 RefreshToken 두가지를 사용해 처리했다.
AccessToken은 사용자 아이디를 담아 생성하고
RefreshToken은 난수로 생성하는 rIndex를 담아 생성한다.
이 두 토큰은 모두 클라이언트에서 보관하게 되고 RefreshToken의 경우는 DB에 저장을 했다.
그래서 DB에 refreshToken이라는 테이블을 생성해 저장했고 구조는 아래와 같다.
- rtIndex = PK. 아무 의미없는 난수
- userId = 사용자 아이디
- tokanVal = refreshToken 값
- expires = refreshTokne 만료 시기.
token값을 PK로 잡지 않고 굳이 난수로 만든 rtIndex를 PK로 잡은데는 이유가 있다.
현재 프로젝트처럼 하나의 서비스를 여러 환경에서 제공하기 위한 분리된 서버에서는 여러 환경에서 로그인을 하는 경우가 발생할 수 있을것이라고 생각했다.
물론 이 경우 보안을 위해 로그인 시 해당 사용자의 기존 refreshToken값을 새로운 값으로 변경해 다른 환경에서 재 로그인을 해야 하도록 만들 수도 있겠지만,
데스크탑 웹에서 로그인을 해두고 모바일로 또 로그인을 하게 되면 한쪽을 사용하지 못하게 하기 보다는 둘다 유지할 수 있도록 하는것이 좋지 않겠나 싶어서였다.
물론 현재 상태라면 부작용이 있다. refreshToken 만료기간이 되기 전까지는 사실상 Remember me와 같은 형태가 되기 때문이다.
이 문제에 대해서는 프론트에서 브라우저 종료 이벤트를 통해 클라이언트가 갖고 있는 토큰값을 삭제하도록 하는 방법으로 해결할 수 있을것으로 생각한다.
토큰이 발급 되어 클라이언트에서 토큰을 받으면 클라이언트는 두 토큰 모두 쿠키에 저장하도록 했다.
몇일을 여기저기 찾아보면서 고민을 해보며 결정한 것이긴 한데 아무래도 장단점이 좀 있다.
보통 JWT 토큰의 경우 클라이언트 localStorage에 저장하거나 아니면 쿠키에 저장을 한다.
또는, RefreshToken은 쿠키에 저장, AccessToken은 JavaScript의 private Variable에 저장해 매 요청시마다 AccessToken을 재발급 받는 방법이 있다.
그럼 첫번째,
스토리지에 저장하는 경우.
사용자가 요청하는 경우 알아서 담기는것이 아닌 코드에 의해 header에 담겨 전송되기 때문에 CSRF공격에 안전하다.
하지만 localStorage에 접근하는 코드 한줄이면 바로 확인이 가능하기 때문에 XSS 공격에 취약하다.
두번째,
쿠키에 저장하는 경우.
httpOnly 설정으로 자바스크립트에서 쿠키에 접근이 불가능하다. 그래서 XSS 공격에 안전하다.
하지만 쿠키는 자동으로 request에 실려 전송되기 때문에 100% 안전하다고 할 수 없다.
그리고 자동으로 전송된다는 특징때문에 사용자가 관련 링크를 누르도록 유도하면 위조하기가 쉬워진다.
그렇기 때문에 csrf 공격에 취약하다.
세번째,
RefreshToken은 쿠키에 저장하고 AccessToken은 자바스크립트 private variable에 저장한다.
매 요청마다 AccessToken이 재발급 되기 때문에 refreshToken이 탈취되더라도 공격자가 AccessToken을 알 수 없다.
CSRF 공격은 요청 위조로 사용자가 의도하지 않은 요청을 처리하도록 하는 공격방법이지 컴퓨터 자체를 제어할 수 없기 때문에 응답으로 오는 AccessToken은 알 수 없다.
또한 RefreshToken이 쿠키에 저장되어 있기 때문에 XSS 공격을 막을 수 있다.
이 세가지 방법중에서 가장 좋아보이는 방법은 세번째 방법이다.
근데 두번째 방법을 사용한 이유는
저 세번재 방법을 프로젝트를 모두 마무리한 다음에 발견했고, private variable을 아직 한번도 못써봤기 때문...
그래서 여기에 정리는 쿠키에 저장하는 방법으로 정리를 하고 세번째 방법으로 구현을 해본 뒤에
다시 정리 할 예정.
쿠키 방식은 csrf 공격에 취약하다고 했다.
그럼 그 csrf 공격을 어느정도 방어하기 위해서 필요한것은 same-site 설정이다.
동일 도메인 요청에 대해서만 해당 쿠키를 전송하도록 하는 설정이다.
로컬에서 테스트했으니 그걸 토대로 예시를 들자면
localhost:8080/ 이 도메인에서 발생하는 요청에 대해서만 쿠키가 전달되는 것이다.
localhost:9090/ 이 도메인에서 발생하는 요청에서는 쿠키가 전달되지 않는다.
또 csrf 방지에 대해 알아보면 가장 많이 나오는 방법이 referer 체크다.
요청이 온 페이지를 확인해 처리하는 방법이다.
보통이라면 host와 referer가 일치하기 때문에 이 둘을 비교하는 방법으로 체크할 수 있다.
public class RefererInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request
, HttpServletResponse response
, Object Handler) throws Exception {
String referer = request.getHeader("Referer");
if(referer == null || !referer.contains(request.getHeader("host"))
new Exception();
}
}
host는 포트 포함 서버의 도메인 네임이 나타나는 부분이고, referer는 이전 페이지의 주소가 담겨있기 때문에 확인할 수 있다.
그래서 same-site 설정과 referer 두가지 모두 사용하기로 했다.
사실 둘다 비슷한 조건의 설정이기 때문에 둘다 사용하는것이 의미가 있긴 한가 싶었지만 너무 딥하게 생각을 했는지 인터셉터에서 검증을 좀 더 심화해서 하는 방법으로 구현했다.
referer 체크를 할 때 애매하게 localhost:8080/** 형태가 아닌 정확한 url을 체크하는 방법이다.
예를들어 board의 상세 페이지 주소는 localhost:8080/board/boardDetail/3
이런 형태로 구성되어있다. 맨끝은 boardNo.
그럼 상세페이지에서는 글 수정, 삭제 기능이 이루어질 수 있다.
그럼 3번글을 삭제 요청을 하게 되면
localhost:8080/board/boardDelete/3
이 주소로 요청이 오게 될것이다.
이 요청에 대한 정상적인 referer는 localhost:8080/board/boardDetail/3 이어야 할것이고.
그래서 이걸 체크하기로 했다.
만약 게시글 삭제 요청이 들어왔다면 host인 8080/까지 유효한지 체크가 될것이고,
유효하다면 requestURL와 referer를 비교한다.
requestURL이 /board/boardDelete/3 으로 들어왔다면 referer가 /board/boardDeatil/3인지 확인하는것이다.
만약 referer가 /board/boardDetail/2라면 false를 리턴해 요청이 처리되지 않게 했다.
솔직히 하다가 중간에 너무 과한가 싶었지만 확실하게 하기에는 낫지 않나 싶은 생각으로 했다...
보안은 배울수록 신경써야 할게 많기 때문에 일단은 여기까지만 하고...
다시 JWT로 돌아가 정리.
그럼 토큰들을 쿠키에 저장하는데 있어서 보안 문제는 어느정도 해결을 했다.
쿠키 설정에서는 자바스크립트에서 접근하지 못하도록 httpOnly 설정을 걸어두고,
https로만 전송되도록 secure 설정과 동일 도메인에 대해서만 전송되도록 same-site 속성을 설정했다.
same-site의 경우는 Strict로 어차피 동일 도메인에서만 요청이 오는게 정상인 프로젝트이므로 가장 강한 속성으로 걸었다.
그럼 이제부터 코드정리.
코드 정리
//API Server - JwtProperties
public interface JwtProperties {
//token secretKey
String SECRET = "cocos";
//AccessToken Expires. 1시간
long ACCESS_EXPIRATION_TIME = 60000 * 60;
//token prefix
String TOKEN_PREFIX = "Bearer";
//AccessToken Header
String ACCESS_TOKEN_HEADER = "Authorization";
//RefreshToken Header
String REFRESH_TOKEN_HEADER = "Authorization_Refresh";
//RefreshToken Expires. 2주
long REFRESH_EXPIRATION_TIME = 60000 * 60 * 24 * 14;
}
//API Server - JwtDTO
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToString
@Getter
public class JwtDTO {
private String accessTokenHeader;
private String accessTokenValue;
private String refreshTokenHeader;
private String refreshTokenValue;
}
//API Server - RefreshToken(Entity)
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
@EqualsAndHashCode
@Getter
public class RefreshToken {
@Id
private String rtIndex;
private String userId;
private String tokenVal;
private Date expires;
}
//API Server - RefreshDTO
@AllArgsConstructor
@NoArgsConstructor
@Builder
@ToSTring
public class RefreshDTO {
private String refreshIndex;
private String tokenVal;
private Date tokenExpires;
private String originIndex;
}
//API Server - RefreshTokenRepository
public interface RefreshTokenRepository extends JapRepository<RefreshToken, String> {
//Refresh재발급 후 DB 수정
@Modifying
@Transactional
@Query(value = "UPDATE refreshToken " +
"SET tokenVal = ?1" +
", expires = ?2" +
", rtIndex = ?3 " +
"WHERE rtIndex = ?4"
, nativeQuery = true)
void patchToken(String refreshToken, Date refreshExpires, String rIndex, String originIndex);
//tokenVal + rtIndex로 데이터가 존재한다면 userId를 반환
@Query(value = "SELECT userId " +
"FROM refreshToken " +
"WHERE rtIndex = ?1 " +
"AND tokenVal = ?2"
, nativeQuery = true)
String existsByRtIndexAndUserId(String rtIndex, String refreshTokenVal);
}
//API Server - TokenProvider
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private final RefreshTokenRepository refreshTokenRepository;
//AccessToken 발급
public String issuedAccessToken(String userId) {
String accessToken = JWT.create()
.withSubject("cocoToken")
.withExpiresAt(new Date(System.currentTimeMillis() +
JwtProperties.ACCESS_EXPIRES_TIME))
.withClaim("userId", userId)
.sign(Algorithm.HMAC512(JwtProperties.SECRET));
return accessToken;
}
//RefreshToken 발급 요청 후 DB에 저장
public String issuedRefreshToken(String userId) {
RefreshDTO dto = createRefreshToken();
refreshTokenRepository.save(
RefreshToken.builder()
.rtIndex(dto.getRefreshIndex())
.userId(userId)
.expires(dto.getTokenExpires)
.tokenVal(dto.getTokenVal())
.build()
);
return dto.getTokenVal();
}
//RefreshToken 발급
public RefreshDTO createRefreshToken() {
StringBuilder sb = new StringBuilder();
String rIndex = sb.append(new SimpleDateFormat("yyyyMMddHHmmss")
.format(System.currentTimeMillis()))
.append(UUID.randomUUID()).toString();
Date refreshExpires = new Date(System.currentTimeMillis() +
JwtProperties.REFRESH_EXPIRATION_TIME);
String refreshToken = Jwt.create()
.withExpiresAt(refreshExpires)
.withClaim("refresh", rIndex)
.sign(Algorithm.HMAC512(JwtProperties.SECRET));
RefreshDTO refreshDTO = RefreshDTO.builder()
.refreshIndex(rIndex)
.tokenVal(refreshToken)
.tokenExpires(refreshExpires)
.build();
return refreshDTO;
}
//RefreshToken 재발급 요청 후 DB 수정
public String reIssuedRefreshToken(String originIndex) {
RefreshDTO dto = createRefreshToken();
refreshTokenRepository.patchToken(dto.getTokenVal()
, dto.getTokenExpires()
, dto.getRefreshIndex()
, originIndex);
return dto.getTokenVal();
}
//AccessToken 검증 후 userId 리턴
public String verifyAccessToken(Cookie accessToken) {
String tokenVal = accessToken.getValue().replace(JwtProperties.TOKEN_PREFIX, "");
String claimByUserId = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET))
.build()
.verify(tokenVal)
.getClaim("userId")
.asString();
return claimByUserId;
}
//RefreshToken 검증. rIndex와 tokenValue 리턴
public Map<String, String> verifyRefreshToken(HttpServletRequest request) {
Cookie refreshToken = WebUtils.getCookie(request, JwtProperties.REFRESH_HEADER_STRING);
if(refreshToken == null || !refreshToken.getValue().startsWith(JwtProperties.TOKEN_PREFIX))
return null;
String refreshTokenVal = refreshToken.getValue().replace(JwtProperties.TOKEN_PREFIX, "");
String rIndex = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET))
.build()
.verify(refreshTokenVal)
.getClaim("refresh")
.asString();
if(refreshTokenVal != null && rIndex != null) {
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("rIndex", rIndex);
tokenMap.put("refreshTokenValue", refreshTokenVal);
return tokenMap;
}
return null;
}
//AccessToken, RefreshToken 전체 재발급
public JwtDTO reIssuanceAllToken(Map<String, String> reIssuedData) {
String userId = refreshTokenRepository.existsByRtIndexAndUserId(
reIssuedData.get("rIndex")
, reIssuedData.get("refreshTokenValue")
);
if(userId != null) {
JwtDTO dto = JwtDTO.builder()
.accessTokenHeader(JwtProperties.ACCESS_HEADER_STRING)
.accessTokenValue(JwtProperties.TOKEN_PREFIX
+ issuedAccessToken(userId))
.refreshTokenHeader(JwtProperties.REFRESH_HEADER_STRING)
.refreshTokenValue(JwtProperties.TOKEN_PREFIX
+ reIssuedRefreshToken(reIssuedData.get("rIndex")))
.build();
return dto;
}
return null;
}
}
토큰 정보에 대해 하나하나 다 작성하다보면 오타도 생기고 찾기도 힘들어지니 JwtProperties를 만들어서 가져다 사용하는 방법을 택했다.
JwtDTO는 애플리케이션 서버에 두 토큰을 반환해주기 위한 DTO이고, RefreshToken은 DB에 저장이 되기 때문에 Entity를 생성했다.
RefreshToken이 재발급 되면 DB 데이터를 수정해줘야 하기 때문에 RefreshDTO를 만들어 담아서 처리하도록 했고,
Repository에서는 재발급 후 DB 데이터를 수정하는것과 토큰 검증 후 해당 값들이 DB에도 존재하는지 체크해주는 두가지를 만들어두었다.
TokenProvider는 토큰의 생성 및 재발급, 검증을 담당한다.
AccessToken은 재발급과 첫 발급의 차이가 존재하지 않기 때문에 하나만 존재하지만
RefreshToken은 첫 발급시 DB에 저장해야 하고, 재발급시에는 수정만 해주면 되기 때문에 따로 분리했다.
AccessToken의 만료시간은 1시간, RefreshToken의 만료시간은 2주이며 AccessToken을 재발급 해야 할 때 RefreshToken 역시 재발급 된다.
AccessToken이 만료되었을 때 두 토큰 모두 클라이언트와 서버에서 삭제를 하고 다시 로그인을 하도록 하는 방법이 있었고, 구현한것 처럼 두 토큰 모두 재발급을 받도록 하는 방법이 있었다.
전자의 경우 사용자가 로그인을 너무 자주하게 해야 한다거나 AccessToken 만료시간을 늘려 보안 위험이 생길 가능성이 있다고 생각해 후자로 선택하게 되었다.
그럼 애플리케이션 서버에서 요청을 보낼때 토큰을 어떻게 보내고 어떻게 재발급을 받아 처리하는지 정리한다.
간단하게 먼저 정리하면 애플리케이션 서버에서 사용자에게 요청이 들어오면 가장 먼저 토큰 여부를 체크한다.
Remember me를 구현하지 않은 이 프로젝트에서 RefreshToken이 없다면 무조건 비 로그인상태라고 볼 수 있기 때문에 이 경우는 로그인 페이지로 유도한다.
두 토큰 모두 정상적으로 존재한다면 당연히 사용자의 요청을 처리하게 될것이고.
그래서 AccessToken이 없다면 RefreshToken이 있어야 하고 이 조건을 만족한다면 API 서버에 RefreshToken을 보내 재발급 요청을 한다.
재발급 받은 토큰이 리턴되어 들어오면 토큰을 쿠키로 저장하고 이 재발급 받은 토큰들로 사용자의 요청을 처리하게 된다.
//Application server - JwtProperties
public interface JwtProperties {
String ACCESS_HEADER_STRING = "Authorization";
String REFRESH_HEADER_STRING = "Authorization_Refresh";
//refreshToken cookie 만료시간 2주
int REFRESH_MAX_AGE = 60 * 60 * 24 * 14;
//accessToken cookie 만료시간 59분
int ACCESS_MAX_AGE = 60 * 59;
String LSC_HEADER_STRING = "lsc";
}
//Application server - JwtDTO
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class JwtDTO {
private String accessTokenHeader;
private String accessTokenValue;
private String refreshTokenHeader;
private String refreshTokenValue;
}
//Application Server - TokenServiceImpl
@Service
@RequiredArgsConstructor
public class TokenServiceImpl implements TokenService {
private final WebClientConfig webClientConfig;
//토큰 체크
@Override
public JwtDTO checkExistsToken(HttpServletRequest request, HttpServletResponse response) {
//AccessToken
Cookie at = WebUtils.getCookie(request, JwtProperties.ACCESS_HEADER_STRING);
//RefreshToken
Cookie rt = WebUtils.getCookie(request, JwtProperties.REFRESH_HEADER_STRING);
if(at == null && rt != null) //RefreshToken은 존재하고 AccessToken만 만료된 상태라면
return reIssuedToken(request, response); //두 토큰의 재발급을 요청
else if(at != null && rt != null) //두 토큰 모두 존재한다면
return JwtDTO.builder() //토큰 정보를 JwtDTO에 담아 리턴
.accessTokenHeader(at.getName())
.accessTokenValue(at.getValue())
.refreshTokenHeader(rt.getName())
.refreshTokenValue(rt.getValue())
.build();
return null;
}
//토큰 재발급 요청
@Override
public JwtDTO reIssuedToken(HttpServletRequest request, HttpServletResponse response) {
WebClient client = webClientConfig.useWebClient();
Cookie rt = WebUtils.getCookie(request, JwtProperties.REFRESH_HEADER_STRING);
JwtDTO dto = client.post()
.uri(uriBuilder -> uriBuilder.path("/token/reissued").build())
.cookie(rt.getName(), rt.getValue()) //쿠키에 refreshToken만 담아 요청
.retrieve()
.bodyToMono(JwtDTO.class)
.block();
saveToken(dto, response); //토큰 저장 요청
return dto;
}
//토큰을 쿠키에 저장
@Override
public void saveToken(JwtDTO jwtDTO, HttpServletResponse response) {
//AccessToken Cookie
ResponseCookie at = ResponseCookie.from(jwtDTO.getAccessTokenHeader()
, jwtDTO.getAccessTokenValue())
.path("/")
.maxAge(JwtProperties.ACCESS_MAX_AGE)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.build();
//RefreshToken Cookie
ResponseCookie rt = ResponseCookie.from(jwtDTO.getRefreshTokenHeader()
, jwtDTO.getRefreshTokenValue())
.path("/")
.maxAge(JwtProperties.REFRESH_MAX_AGE)
.httpOnly(true)
.secure(true)
.sameSite("Strict")
.build();
//login check Cookie.
ResponseCookie lsc = ResponseCookie.from(JwtProperties.LSC_HEADER_STRING
, UUDI.randomUUID().toString())
.path("/")
.maxAge(JwtProperties.REFRESH_MAX_AGE)
.build();
response.addHeader("Set-Cookie", at.toString());
response.addHeader("Set-Cookie", at.toString());
response.addHeader("Set-Cookie", at.toString());
}
//모든 쿠키 삭제(로그아웃 시)
@Override
public void deleteCookie(HttpServletRequest request, HttpServletResponse response) {
Cookie at = WebUtils.getCookie(request, JwtProperties.ACCESS_HEADER_STRING);
Cookie rt = WebUtils.getCookie(request, JwtProperties.REFRESH_HEADER_STRING);
Cookie lsc = WebUtils.getCookie(request, JwtProperties.LSC_HEADER_STRING);
deleteCookieProc(at, response);
deleteCookieProc(rt, response);
deleteCookieProc(lsc, response);
}
//쿠키 삭제 처리
public void deleteCookieProc(Cookie cookie, HttpServletResponse response) {
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
}
}
애플리케이션 서버에서는 모든 요청이 들어오면 무조건 checkExistsToken을 통해 토큰 체크를 먼저 한다.
토큰이 필요없는 권한이 없어도 되는 페이지라도 로그인 여부를 확인하기 위해서 체크를 하게 된다.
로그인을 해 토큰을 발급받는 경우에도 여기서 saveToken을 통해 쿠키를 생성하게 된다.
재발급을 받은 후에나 토큰이 둘다 존재하는 경우 모두 JwtDTO에 담아 리턴하도록 했는데,
재발급을 받은 뒤 사용자 요청을 처리할 때 재발급 받은 토큰이 필요하기 때문에 DTO에 담아 리턴하도록 했다.
둘다 존재하는 경우에는 DTO에 굳이 담지 않아도 되지만 토큰이 있는것과 없는것의 차이가 발생할 수 있는 요청에 대해 구분해 처리하도록 하기 위해서 DTO에 담아 리턴하도록 했다.
예를들어 게시글 상세페이지에서 토큰여부에 따라 사용자 검증을 하고 게시글 수정, 삭제 등의 버튼을 출력한다거나 하는 차이가 발생할 수 있는데 토큰이 없다면 기능 버튼이 출력이 안되고 있다면 출력이 가능하도록 처리해야 한다.
그럼 여기서 재발급을 받은 경우에는 DTO에 담겨있을 것이고, 둘다 존재해서 DTO에 담지 않았다면 쿠키에 있을것이고, 로그인하지 않았다면 쿠키가 존재하지 않을것이다.
그럼 요청 전 토큰 여부를 확인할 때 쿠키를 체크하고 하나만 있다면 DTO를 다시 체크하고 하는 형태로 여러번 거쳐야 하기 때문에 아예 DTO에 담아서 DTO에 값이 있느냐 없느냐에 따라 처리하도록 하기 위함이었다.
saveToken에서 보면 두 토큰 말고 lsc라는 쿠키를 하나 더 생성하도록 하고 있는데 이건 로그인 여부를 확인하기 위한 임시 쿠키로 생성하게 되었다.
프로젝트 상단 네비게이션바에서 로그인 or 로그아웃을 출력하도록 하는데 lsc 쿠키가 존재하면 로그인을 한 상태로 로그아웃을 출력하도록 하기 위해 만들었다.
SpringSecurity로 프로젝트를 진행할때는 세션을 통해 확인할 수 있었지만 JWT를 사용하며 체크를 하려다보니 세션을 통해 처리할 수가 없었다.
CSRF 토큰까지 사용할겸 애플리케이션 서버에다가 security 적용해서 세션 통해 처리할까 하다가 그럼 굳이 JWT 사용해야할 이유도 없는것 같고... 해서 다른 방법을 좀 알아봤다.
근데 딱히 방법을 찾지 못해 고민한 내용들 중에서만 골라야 했다.
첫번째,
매 요청마다 리턴되는 데이터에 사용자 상태를 리턴해준다.(정보 x)
사용자에 대한 정보말고 현재 로그인한 사용자라는 임의의 데이터를 리턴해 그 데이터가 조건에 따라 로그인 여부를 출력해주도록 하는 방법.
이 방법을 사용하게 되면 모든 요청에서 해당 데이터를 처리해 리턴해줘야 한다.
두번째,
RefreshToken을 localStorage에 저장하고 페이지 로딩시마다 이걸 체크해 처리한다.
근데 그럼 RefreshToken이 탈취되기 쉽다.
세번째,
localStorage에 담으면 탈취되기 쉬우니까 그냥 임의의 쿠키를 하나 만들어 해당 쿠키가 존재하면 로그인을 했다고 판단하여 처리.
쿠키값은 난수이기 때문에 탈취되더라도 문제가 없고 페이지 로딩 시 이 쿠키가 존재하는지 체크해주면 된다.
하지만 요청에 자동으로 실려가는 쿠키의 특성 상 요청마다 불필요하게 같이 전송되어야 하긴 하다.
또한, 괜한 쿠키를 하나 더 생성하는 꼴이 된다.
이렇게 세가지 중에서 고민을 많이 했다.
두번째는 사실 떠오르기만 했지 사용은 못하겠다 싶었었고 첫번째와 세번째를 계속 고민하다가 세번째 방법을 택하게 되었다.
쿠키를 더 늘리면 안된다는 조건이 붙었다거나 사용해서는 안된다는 조건이 붙지 않는다면
첫번째 방법보다는 세번째 방법이 좀 더 빠르고 편하게 처리할 수 있지 않을까 싶어서 였다.
첫번째 방법을 만족하기 위해서는 모든 요청에 대해 API서버에서 토큰을 받아야 한다.
하지만 게시글 목록 같은데에서는 굳이 API서버에 토큰을 보내 검증처리를 하도록 해야 할 필요가 없다.
그래서 불필요한 처리가 늘어나겠거니 싶어서 세번째 방법을 택하게 되었다.
lsc 쿠키의 만료시간은 refreshToken과 같은 시간을 갖게 되고 토큰 재발급시에는 refreshToken 쿠키의 만료시간이 수정되기 때문에 lsc 쿠키 역시 만료시간을 수정하도록 했다.
또한 로그아웃시 이 쿠키 역시 삭제되도록 처리했다.
그럼 이제 로그인처리를 정리.
처음에는 SpringSecurity로 만들어진 프로젝트이기도 하고 이전에 JWT 적용해서 만들때는 단일 서버로 처리했으니 필터만 추가해놓으면 security에서 알아서 요청 가로채 처리하니 문제가 없었다.
그리고 이 프로젝트를 수정하면서도 거기에 대해서 아무런 생각도 없이 그냥 그렇게 만들었다.
그랬더니??
당연히 클라이언트에 리턴을 안한다..
요청을 가로채서 추가한 필터 다 처리하고 로그인 처리 한 다음에 그냥 끝내버리니까..
그래서 요청을 가로채도록 하는게 아니라 컨트롤러에서 요청을 받도록 하고 서비스단에서 처리를 한 뒤 토큰을 리턴해주도록 했다.
// API server - member(Entity)
@Entity
@Getter
@ToString
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
@Id
private String userId;
private String userpw;
private String userName;
@OneToMany(mappedBy = "userId", fetch = FetchType.EAGER, cascade = CascadeType.REMOVE)
@ToString.Exclude
private List<Auth> auths;
public void setUserId(String userId) {
this.userId = userId;
}
}
//API server - Auth(Entity)
@Entity
@Getter
@ToString
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConsructor
@Builder
public class Auth {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long authNo;
private String userId;
private String auth;
}
//API server - CorsConfig
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfigurtaion("/**", config);
return new CorsFilter(source);
}
}
//API server - JwtAuthorizationFilter
//서버로 들어오는 모든 요청을 가로채서 토큰 검증
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokneProvider;
public JwtAuthorizationFilter(AuthenticationManager authenticationManger
, MemberRepository memberRepository
, JwtTokenProvider jwtTokenProvider) {
super(authenticationManager);
this.memberRepository = memberRepository;
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request
, HttpServletResponse response
, FilterChain chain)
throws IOException, ServletException {
Cookie jwtCookie = WebUtils.getCookie(request, JwtProperties.ACCESS_HEADER_STRING);
//cookie가 존재하지 않거나 cookie값의 시작이 Bearer로 시작하지 않으면 검증을 하지 않고 넘김.
if(jwtCookie == null || !jwtCookie.getValue().startsWith(JwtProperties.TOKEN_PREFIX)) {
chain.doFilter(request, response);
return;
}
String username = jwtTokenProvider.verifyAccessToken(jwtCookie);
if(username != null) {
Member memberEntity = memberRepository.findByUserId(username);
CustomUser customUser = new CustomUser(memberEntity);
Authentication authentication =
new UsernamePasswordAuthenticationToken(customUser, null, customUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
}
//API server - SecurityConfig
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final CorsFilter corsFilter;
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
private AuthenticationManager authenticationManager;
private final AuthenticationManagerBuilder localConfigureAuthenticationBldr;
private boolean authenticationManagerInitialized;
private boolean disableLocalConfigureAuthenticationBldr;
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers("/token/reissued");
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilter(corsFilter)
.formLogin().disable()
.httpBasic().disable()
.logout().logoutSuccessHandler(logoutSuccessHandler())
.and()
.addFilter(new JwtAuthorizationFilter(authenticationManager()
, memberRepository
, jwtTokenProvider))
.authorizeRequests()
.antMatchers("/", "/resources/**")
.permitAll();
return http.build();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
if(!this.authenticationManagerInitialized) {
this.configure(this.localConfigureAuthenticationBldr);
if(this.disableLocalConfigureAuthenticationBldr)
this.authenticationManager = this.authenticationConfiguration.getAuthenticationManager();
else
this.authenticationManager = (AuthenticationManager) this.localConfigureAuthenticationBldr.build();
this.authenticationMangerInitialized = true;
}
return this.authenticationManager;
}
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
this.disableLocalConfigureAuthenticationBldr = true;
}
}
이렇게 이전 JWT 포스팅에서 정리했듯이 SecurityConfig를 작성하고 CorsFilter와 JwtAuthorizationFilter를 추가해줬다.
이 필터들은 JWT 포스팅에서 정리하기도 했고 로그인에는 굳이 간섭되지 않기 때문에 여기서는 따로 정리 안함.
SecurityConfig에서 설정한것으로 로그인요청이 가로채지지 않고 직접 처리된다 정도.
그럼 이제 로그인에 진짜 필요한 코드들 정리.
//API server - CustomUser
@Getter
public class CustomUser extends User {
private Member member;
public CustomUser(String username
, String password
, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
public CustomUser(Member member) {
super(member.getUserId(), member.getUserPw(), member.getAuths().stream().map(auth ->
new SimpleGrantedAuthority(auth.getAuth())).collect(Collectors.toList()));
this.member = member;
}
}
//API server - CustomUserDetailsService
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Setter(onMethod_ = {@Autowired})
private MemberRepository repository;
@Override
@Transactional
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = repository.findByUserId(username);
return member == null ? null : new CustomUser(member);
}
}
//API server - MemberController
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {
private final MemberService memberService;
@PostMapping("/login")
public ResponseEntity<JwtDTO> loginProc(@RequestBody Member member
, HttpServletRequest request)
throws Exception {
return new ResponseEntity<>(memberService.memberLogin(member), HttpStatus.OK);
}
}
//API server - MemberServiceImpl
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
private final PasswordEncoder passwordEncoder;
private final MemberRepository memberRepository;
private AuthenticationManager authenticationManager;
private final JwtTokenProvider tokenProvider;
@Override
public JwtDTO memberLogin(Member member) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(member.getUserId(), member.getUserPw());
Authentication authentication =
authenticationManager.authenticate(authenticationToken);
CustomUser customUser = (CustomUser) authenticateion.getPrincipal();
String uid = customUser.getMember().getUserId();
String accessToken = tokenProvider.issuedAccessToken(uid);
String refreshToken = tokenProvider.issuedRefreshToken(uid);
JwtDTO dto = JwtDTO.builder()
.accessTokenHeader(JwtProperties.ACCESS_HEADER_STRING)
.accessTokenValue(JwtProperties.TOKEN_PREFIX + accessToken);
.refreshTokenHeader(JwtProperties.REFRESH_HEADER_STRING)
.refreshTokenValue(JwtProperties.TOKEN_PREFIX + refreshToken)
.build();
return dto;
}
}
애플리케이션 서버에서 로그인 요청을 보내면 AuthorizationFilter에서 일단 요청을 가로채 토큰 여부를 검사한다.
하지만 토큰이 없으니 토큰 검증을 하지 않고 리턴을 하지만 로그인 요청은 권한이 필요한 요청이 아니기 때문에 계속 처리를 진행하게 된다.
컨트롤러에서는 memberLogin을 호출해 로그인 처리를 진행한다.
서비스단에서는 UsernamePasswordAuthenticationToken을 생성하고 이걸 authencationManager.authenticate()에 담아 요청 정보를 체크하게 된다.
authenticate에서는 넘겨받은 데이터인 요청이 들어온 아이디와 비밀번호를 갖고 UserDetailsService를 호출해 로그인을 시도한다.
정상적으로 처리가 되었다면 다시 서비스단으로 빠져나오게 되고 authentication에서 사용자 정보를 꺼내 CustomUser타입으로 담아둔다.
AccessToken에서는 사용자 아이디가 필요하기 때문이다.
그럼 CustomUser 타입으로 담아둔 데이터에서 아이디를 꺼내서 그걸로 토큰을 생성하고
생성된 토큰은 JwtDTO에 담아 리턴하게 된다.
그럼 컨트롤러에서도 이걸 그대로 애플리케이션 서버로 리턴해준다.
//Application server - MemberWebClient
@Service
@RequiredArgsConstructor
public class MemberWebClient {
private final WebClientConfig webClientConfig;
private final TokenService tokenService;
public int loginProc(Map<String, String> loginData
, HttpServletRequest request
, HttpServletResponse response)
throws JsonProcessingException {
WebClient client = webClientConfig.useWebClient();
Member member = Member.builder()
.userId(loginData.get("userId"))
.userPw(loginData.get("userpw"))
.build();
JwtDTO responseVal = client.post()
.uri(uriBuilder -> uriBuilder.path("/member/login").build())
.bodyValue(member)
.retrieve()
.onStatus(
HttpStatus::is4xxClientError, clientResponse ->
Mono.error(
new CustomNotFoundException(ErrorCode.USER_NOT_FOUND)
)
)
.bodyToMono(JwtDTO.class)
.block();
if(responseVal != null) {
tokenService.saveToken(responseVal, response);
return 1;
}else{
return 0;
}
}
}
JwtDTO 타입으로 리턴을 받아온 애플리케이션 서버는 saveToken을 통해 리턴받은 토큰을 클라이언트에 쿠키로 저장하도록 해서 정상적으로 로그인 처리를 마무리한다.
REST 방식의 프로젝트에서 JWT를 적용하기가 생각보다 어려웠다.
REST도 공부하는 중이라 아직 어렵다는것도 한몫 했겠지만 토큰 관리를 고민하는데에서 시간을 엄청 소모했다.
단일 서버에서 JWT를 적용해 구현하는것과 차이도 컸고 그로인해 발생하는 또 다른 문제점들을 해결하는데도 생각나는대로 적용해보기에는 보안과 처리과정에 따른 성능 이슈를 고려해야 하는 부분이 너무 많았다.
아주 작은 미니프로젝트였지만 그래도 RESTAPI와 JWT를 활용하는데 있어서 조금은 더 배울 수 있었지 않았나 싶다.