로그인이란 무엇이고, 어떻게 구현할 수 있을지 알아보자!
웹 페이지를 방문할때마다 컴퓨터는 HTTP(HyperText Transfer Protocol)를 사용하여 인터넷 어딘가 다른 컴퓨터에서 해당 페이지를 다운로드한다. 로그인이라는 정보 통신의 두 주체(클라이언트, 서버)간의 식별 행위에 있어 Http는 주의해야 할 특성을 가지고 있는데, 바로 무상태 프로토콜(Stateless)이다.
무상태 프로토콜(Statleless)이란 서버가 클라이언트를 식별할 수 있는 정보를 갖고있지 않는 것을 의미한다. 무상태 프로토콜에서, 하나의 요청과 하나의 응답이 트랜잭션으로 묶여 독립적으로 다뤄진다. 모든 요청이 불연속적이고 개별적으로 다뤄지므로, 클라이언트가 같더라도 서버는 이를 인식하지 못한다.
그러나 실제로 웹 사이트를 이용할 때, 서비스 제공자(서버)가 고객(클라이언트)을 식별하지 못한다면 제공할 수 있는 서비스는 매우 제한된다. 옷 한벌을 사더라도 고객이 누구인지 알지 못한다면 어떻게 거래가 가능할까?
로그인 기능 구현이란 이 "HTTP의 무상태성(Stateless)"이라는 특성 속에서 서버가 클라이언트를 식별하려는 다양한 노력을 의미한다. 모든 독립적이고 개별적인 통신들 사이에서 우리는 어떻게 사용자를 식별할 수 있을까? 그 구체적인 구현 방법을 알아보자!
로그인과 관련된 용어들을 알아보자.
인증(Authentication): (식별 가능한 정보로) 서비스에 등록된 유저의 신원을 입증하는 과정
인가(Authorization): 인증된 사용자에 대한 자원 접근 권한.
웹에서의 인증과 인가란, 자원을 적절한/유효한 사용자에게 전달/공개 하기 위한 방법이다.
사용자를 식별하기 위한 정보를 '신분증'에 비유해보자. 모든 통신이 단절되어 있는 http 통신 세상에서 우리는 어떻게 사용자를 구별할 수 있을까?
다른 여러 요구사항을 떠나서 "인증" 그 자체에만 초점을 맞춰보자. 사용자는 어떻게 자신을 서비스 제공자에게 알릴 수 있을까? 신분증 정보를 제공하면 된다. 사용자(클라이언트) 측에서 자신을 알리기 위해 선택할 수 있는 선택지는 다음과 같다.
그 중 Authorization에 대해 간단히 살펴보면 다음과 같다.
Authorization: <type> <credentials>
그러나 이 방법에는 여전히 HTTP의 무상태성이 유발하는 인증 유지라는 문제점이 남아있다. 모든 요청이 독립적이기에, 사용자는 자신이 수행하는 모든 요청에 자신의 신분증을 제출해야만 한다. 이 같은 경우를 해결하기 위해, 우리는 Browser의 힘을 빌릴 수 있다.
모든 요청에 신분증을 첨부하는 것은 정말 골치아픈 일일것이다. 우리는 Browser의 힘을 빌려 이 일을 위임할수있다!
브라우저에서 데이터를 저장하는 것에는 크게 3가지 방법이 있다. 각 방법들에 대해 대략적으로 알아보자.
Chrome의 개발자도구에서 Application으로 들어가면 웹 브라우저가 제공하고 있는 저장 공간들을 확인할 수 있다.
🚨 문제점: 보안
특히 쿠키의 힘을 빌리면 우리는 간단하게 사용자 식별정보(신분증)를 모든 요청에 첨부할 수 있다. 그러나, 이 방식에도 큰 문제점이 있다. 바로 보안의 문제이다. 쿠키에 첨부된 이 데이터들은 사용자에 대한 중요한 정보를 담고 있기 때문에, 이 쿠키가 탈취당하면 아주 간단히 사용자를 사칭할 수 있다. 이 보안 문제를 해결하기 위해 우리는 session을 이용할 수 있다.
앞서 언급된 쿠키 로그인 방식은 핵심 정보(신분증)의 보관 주체가 사용자이기 때문에 여러 문제가 야기되었다. HTTP 통신 프로토콜에서 모든 요청은 독립적이므로 웹에서 화면이 전환될때마다 로그인을 반복해야 하는데, 이를 해결하기 위해 우리는 Browser의 힘을 빌렸다. 그러나 브라우저에서 핵심적인 정보를 다루는 것은 보안적 측면에서 취약할 수 있다(브라우저는 서버에 비해 노출 위험도가 높으므로). 세션은 이 문제를 해결하기 위해, 핵심 정보(신분증)를 서버에서 다룬 다는 것이 주요 차이점이다.
📌 포인트
- 서버에서 유저 식별자(신분증)을 저장한다.
- 브라우저에 저장되고 통신에 오가는 정보는 사용자의 핵심정보를 담고있지 않은 임의의 값(세션 ID)이다.
우리는 세션을 사용하면서 다음과 같은 장점을 기대할 수 있다.
HttpSession 인터페이스는 두 개 이상의 웹사이트 방문에 대해 유저를 식별하는 방법과 유저 관련 정보를 제공한다. 이 HttpSession interface를 구현한 StandrdServlet은 대강 이런 형태로 생겼다.
public class StandardSession implements HttpSession {
private String id; // 세션ID
private Map<String, Object> attributes; // 세션에 저장된 데이터
// ...
}
여기서 id에 세션ID가 담기고, 세션에 부여하는 데이터는 attributes라는 Map에 담긴다. 여기서 id자체는 서블릿 컨테이너에서 만들어 넣어주고, 우리는 유저 식별정보를 attribute에 넣어두고 환자를 구별하는데 이용할 것이다.
HttpSession session = request.getSession();
request.getSession()
메서드를 호출하면 컨테이너가 직접 세션 ID를 생성하고, 새로운 Cookie 객체를 만든 후 쿠키안에 세션 ID 값을 채우며, Response의 헤더(Set-Cookie)에 쿠키를 설정한다. 모든 쿠키 관련된 일은 서블릿 컨테이너가 대신 수행해준다.reuqest.getSession(boolean)
메서드를 호출하면 세션이 이미 존재하는 경우 기존 세션을, 세션이 없는 경우 새로운 세션을 생성해 반환한다.session.setAttribute(String, Object);
이 메서드를 통해 앞서 살펴본 attributes Map 공간에 저장하고자 하는 사용자 관련 데이터를 담을 수 있다.
@PostMapping("/login")
public String login(@ModelAttribute LoginDTO loginDTO,
@RequestParam(defaultValue = "/") String redirectURL,
HttpServletRequest request) {
AdminDTO loginAdmin = loginService.loginAdmin(loginDTO.getId(), loginDTO.getPassword());
log.info("loginAdmin = {}", loginAdmin);
// 로그인 성공 처리
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_ADMIN, loginAdmin);
return "redirect:" + redirectURL;
}
// 로그인 성공 처리
HttpSession session = request.getSession();
session.setAttribute(SessionConst.LOGIN_ADMIN, loginAdmin);
유저가 적절한 id & password 정보를 입력했다면, request.getSession()
메서드를 호출하여 세션 ID를 쿠키로 반환하고, 세션에 유저와 관련된 정보도 추가한다.
HttpSession session = request.getSession();
AdminDTO adminDTO = (AdminDTO) session.getAttribute(SessionConst.LOGIN_ADMIN);
컨트롤러에서 이런식으로 부여한 session의 속성으로 값을 찾아 사용할 수 있다.
세션은 사용자가 로그아웃을 호출하여 session.invalidate()
가 호출되는 경우에만 삭제된다.
@PostMapping("/logout")
public String logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate(); // 세션 삭제
}
return "redirect:/";
}
request.getSession(false)
: false를 인수로 넘기며 해당 메서드 호출 시, 새로운 session을 생성하지 않는다.session.invalidate()
를 호출한다.tip) 세션은 invalidate()가 호출되는 경우에만 삭제된다. 세션은 톰켓의 메모리에 저장되므로 적절하게 삭제되지 않으면 서버에 큰 부하가 갈 수 있다. 보통 대부분 유저는 로그아웃을 하지 않고 웹 사이트를 종료한다. 따라서 세션 사용시 적절하게 유효기간을 설정해야 한다.
🚨 문제점: 스케일 문제
세션은 쿠키가 가지는 보안의 문제점에 대한 훌륭한 해결책이다. 그러나 유저의 식별 정보를 서버에 저장한다는 아이디어는 새로운 문제점을 야기한다. 세션을 통한 로그인 방식은, 로그인한 모든 유저들의 세션 ID를 서버에 저장하고, 요청이 들어올때마다 쿠키의 세션 ID와 일치하는 유저를 조회해야한다. 즉 사용자가 증가할수록 서버에서 요구되는 리소스 부담 역시 증가함을 의미한다. 이 스케일 문제를 해결하기 위해 우리는 Token을 이용할 수 있다.
핵심 정보의 보관처가 사용자 일때에는 보안의 문제점이, 서버일때는 리소스의 부담이 있었다. 이 문제를 해결하기 위해 Token(JWT)을 사용할 수 있는데, 이는 각 통신 주체들 사이에 오가는 데이터(요청과 응답)에 핵심 정보를 담는 것이다. 단, 이 Token은 해당 정보를 암호화하며, 세션의 유효기간이라는 장점도 함께 가져간다.
🚨 문제점: 사용자 편의성 & 탈취
토큰은 정보 보관주체가 서버가 아니므로 악의적으로 토큰을 탈취하여 사용자를 사칭하더라도 서버가 대응할 수 없다는 단점이 있다. (토큰 자체가 권한을 입증하니까) 이 문제를 해결하기 위해 토큰 유효기간을 짧게 설정해 빈번히 갱신하도록 할 수 있다. 그러나 이는 사용자 편의성 저하와 직결된다.(토큰 유효기간이 1분일때, 이는 1분마다 재로그인을 의미하고, 그 누구도 1분마다 로그인을 해야하는 사이트를 이용하지 않을 것이다..) 이 문제를 해결하기 위해 Access Token과 Refresh Token을 함께 사용할 수 있다.
Access Token과 Refresh Token 모두 동일한 JWT이다. 다만 토큰의 저장 위치와 유효기간에 차이를 둔다.
Access Token과 Refresh Token을 함께 사용할 경우 시나리오는 다음과 같다.
🤔 ..? 어차피 refresh token이 탈취되면 계속 재발급이 가능하니 access token은 의미가 없는거 아닌가? 그럼 그냥 access token 유효기간을 2주로 정하는거랑 뭐가 달라? 그리고 DB에 데이터를 저장할거면 이게 session이랑 다를게 뭐지? 그럼 세션 사용하면 되지 뭐하러 토큰을 사용해?
처음 refrsh 토큰을 알게되면서 위와 같이 필요성에 대한 의문을 가졌었다. 그래서 프로젝트를 진행하면서 하나의 토큰만 사용했었다.(access token만 사용했다고 생각할 수 있겠다.) 그리고 프로젝트를 진행하면서 refresh 토큰 도입의 필요성을 느꼈다.
문제점
사용자 편의성
가장 처음 토큰에 설정한 유효기간은 1시간이었다. 이정도면 적절하다는 판단이었으나 프로젝트를 진행하면서 끊임없이 반복되는 로그인 지옥이었다. 그래서 유효기간을 2시간, 8시간, 12시간 늘려가면서 느꼈다. 나는 하루에 한 번 로그인 하는것도 번거로웠다. 실제로 서비스되고 있는 웹 사이트라면 사용자들은 이처럼 로그인을 자주 요구하는 웹 사이트를 사용하지 않을 것이다.
탈취 위험성
그래서 이번에는 유효기간을 2주로 늘렸다. 반복되지 않는 로그인에 스트레스를 덜었으나, 로그인 기능을 구현해 내가면서 이런식으로 구현하면 큰일나겠다는 생각이 들었다. 프로젝트를 수행하면서 토큰은 뭐든지 할 수 있는 만능키였다. 웹 브라우저에서 토큰만 복사하면 나는 뭐든지 다 할 수 있었다.(실제로 만들어둔 토큰을 활용하면서 프로젝트를 구현해나갔다.) 토큰을 탈취당한다는 것은 악의적 공격자에게 2주간 뭐든지 사용 가능한 프리패스를 발급해주는 것이나 마찬가지였다.
왜 refresh token을 사용해야 할까?
훌륭한 절충안
refresh token 사용 시, 사용자는 2주간 재로그인을 하지 않아도 된다.(사용자 편의성) 또한 access token은 계속 갱신되므로 특정 시점에 access 토큰이 탈취되더라도 갱신으로 인한 보안상의 장점도 가져갈 수 있다.
서버 측 대응 가능
refresh token은 db에 payload에 담길 정보를 저장해둔다. 따라서 탈취가 의심되는 경우 db에 사용자와 매핑되어있는 정보를 삭제함으로써 서버가 악의적 사용에 조치를 취할 수 있다. JWT 토큰은 그 자체로 신분증 역할을 하고 서버측에서 관련된 정보를 갖고있지 않으므로 한번 발행된 토큰은 유효기간 만료 이전까지는 계속 사용 가능하다. 그러나 session처럼 db에 정보를 가지고 있으면 이미 발행한 토큰을 무효화시키는 것이 가능해진다.
더 나아가, RTR(Refresh Token Rotation)
🤔 그래봐야 refresh token이 탈취되면 계속 사용 가능한거 아닌가?
이를 예방하고 싶은 경우, Refresh Token Rotation을 사용할 수 있다. access token 갱신을 위해 refresh token을 확인할때, access token뿐 아니라 refresh token도 갱신하는 것이다.
만약 특정 시점에 access token(3분), refresh token(2주)을 모두 탈취당한 상황이라면, 해당 공격자에게는 2주가 아니라 6분만 사용 가능한 token 정보들이 된다. 여전히 부족한 점이 있지만, 보안상으로 크게 강화되는 방법이 아닐까 생각된다.
부족한 수준에서 혼자 고민해가며 구현한 코드라 엉망이고 부족한 부분이 매우 많습니다.
@PostMapping("/signup")
public ResponseEntity<APIResult> signUp(@RequestBody MemberDTO memberDTO) {
// 회원 정보 저장
UUID uuid = UUID.randomUUID();
memberService.saveMember(memberDTO);
refreshService.createRefresh(memberDTO.getId());
APIResult apiResult = new APIResult();
apiResult.setState(State.SUCCESS);
return ResponseEntity.ok(apiResult);
}
로그인 시,
@PostMapping("/login")
public ResponseEntity<APIResult> login(@RequestBody MemberDTO memberDTO) {
String accessToken = jwtUtil.createAccessToken(memberDTO.getId());
String refreshToken = refreshService.updateToken(memberDTO.getId());
APIResult apiResult = new APIResult();
apiResult.putResult("accessToken", accessToken);
apiResult.putResult("refreshToken", refreshToken);
apiResult.setState(State.SUCCESS);
return ResponseEntity.ok(apiResult);
}
updateToken()
public String updateToken(String memberId) {
UUID uuid = UUID.randomUUID();
refreshRepository.update(memberId, uuid); // db에 uuid 갱신
return jwtUtil.createRefreshToken(uuid); // 생성한 토큰 반환
}
@PostMapping("/logout")
public ResponseEntity logout(@RequestBody String memberId) {
// 회원 id로 저장된 refreshToken을 삭제합니다.
refreshService.deleteByMemberId(memberId);
return ResponseEntity.ok().build();
}