팀 프로젝트에서 Oauth2.0 프로토콜과 JWT를 사용한 소셜 로그인을 구현하다가 다음과 같은 의문점이 들었습니다.
Oauth가 정확히 무엇일까?
JWT를 사용한 로그인 방식은 세션을 이용한 로그인 방식과 어떤 차이점이 있을까? 장단점은 무엇일까?
401 Error가 발생하는 이유는 무엇일까..?
이러한 의문점들을 해결하고자, 로그인 관련 정보들을 정리해서 공부해야겠다는 생각이 들었습니다.
다음과 같은 순서로 진행됩니다.
- HTTP 프로토콜의 특성
- 로그인 - 쿠키
- 로그인 - 세션
- 로그인 - 필터, 인터셉터
- Oauth: Open Authorization
- JWT
- Oauth 2.0 소셜 로그인 과정
- CORS
1. HTTP 프로토콜의 특성
기본적으로 HTTP 프로토콜 환경은 “connectionless, statelss”한 특성을 가지기 때문에 서버는 클라이언트가 누구인지 매번 확인해야 한다.
무상태 프로토콜(stateless)
서버가 클라이언트의 상태를 보존하지 않는다.
통신이 끝나면 상태를 유지하지 않는 특징
- 클라이언트와 서버가 요청과 응답을 주고 받으면 연결이 끊어진다.
- 클라이언트가 다시 요청하면 서버는 이전 요청을 기억하지 못한다.
- 클라이언트와 서버는 서로 상태를 유지하지 않는다.
- 장점: 서버 확장성 높음(스케일 아웃). 응답 서버를 쉽게 바꿀 수 있다.
- 단점: 클라이언트가 추가 데이터 전송
비연결성(connectionless)
클라이언트가 요청을 한 후 응답을 받으면 그 연결을 끊어 버리는 특징
HTTP는 먼저 클라이언트가 request를 서버에 보내면, 서버는 클라이언트에게 요청에 맞는 response를 보내고 접속을 끊는 특성이 있다.
헤더에 keep-alive라는 값을 줘서 커넥션을 재활용하는데 HTTP1.1에서는 이것이 디폴트다.
https://darkstart.tistory.com/178
최소 특정시간동안(timeout) 최대 요청 request(max)의 수를 알려줄 수 있다.
최소 5초동안 최대 1000번의 요청을 할 경우에는 http connection이 끊어지지 않을 것으로 보인다.
HTTP 가 tcp위에서 구현되었기 때문에 네트워크 관점에서 keep-alive는 옵션으로 connectionless의 연결비용(TCP/IP 연결 - 3 way handshake)을 줄여 네트워크 부하를 줄일 수 있다.
[HTTP persistent conneection 및 keep alive] https://etloveguitar.tistory.com/137
stateless 라는 HTTP 프로토콜의 특성 때문에,
모든 요청에 사용자 정보를 넘기는 방식으로 개발을 진행해야 한다.
쿼리 파라미터를 계속 유지하면서 보내는 것은 매우 어렵고 번거로운 작업이다.
2. 로그인 - 쿠키
🍪쿠키
- 클라이언트(브라우저) 로컬에 저장되는 키와 값이 들어있는 작은 데이터 파일
- 사용자 인증이 유효한 시간을 명시할 수 있다.
- 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지(여기서의 세션은 쿠키의 종류 중 하나)
- 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
- 쿠키는 클라이언트의 상태 정보를 로컬에 저장했다가 참조합니다.
- Response Header에 Set-Cookie 속성을 사용하면 클라이언트에 쿠키를 만들 수 있다.
- 쿠키는 사용자가 따로 요청하지 않아도 브라우저가 Request 시에 Request Header를 넣어서 자동으로 서버에 전송한다.
동작 방식
- 클라이언트가 페이지를 요청
- 서버에서 쿠키를 생성
- HTTP 헤더에 쿠키를 포함 시켜 응답(Set-Cookie)
- 브라우저가 종료되어도 쿠키 만료 기간이 있다면 클라이언트에서 보관하고 있음
- 같은 요청을 할 경우 HTTP 헤더에 쿠키를 함께 보냄(Cookie:
- 서버에서 쿠키를 읽어 이전 상태 정보를 변경할 필요가 있을 때 쿠키를 업데이트하여 변경된 쿠키를 HTTP 헤더에 포함시켜 응답
💻Code
서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하자. 그러면 브라우저는 앞으로 해당 쿠키를 지속해서 보내준다.
로그인 성공시 세션 쿠키
를 생성하자. (주의: 최소한의 정보만 사용- 세션 id , 인증 토큰)
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie); //HttpServletResponse
로그인에 성공하면 쿠키를 생성하고 HttpServletResponse 에 담는다. 쿠키 이름은 memberId 이고, 값은 회원의 id를 담아둔다. 웹브라우저는 종료 전까지 회원의 id를 서버에 계속 보내줄 것이다.
이후 요청마다 Request Header를 확인하면 Cookie를 보내는 것을 확인할 수 있다.
쿠키를 사용한 로그아웃 로직은 다음과 같다.
Cookie cookie = new Cookie("memberId", null);
cookie.setMaxAge(0);
response.addCookie(cookie); //HttpServletResponse
세션 쿠키이므로 웹 브라우저 종료시 서버에서 해당 쿠키의 종료 날짜를 0으로 지정
Max-Age=0
을 확인할 수 있다. 해당 쿠키는 즉시 종료된다.
쿠키의 보안 문제
쿠키 값은 임의로 변경할 수 있다.
- 웹브라우저 개발자모드 → Aplication → Cookie 변경으로 확인
Cookie: memberId=1
→Cookie: memberId=2
쿠키에 보관된 정보는 훔쳐갈 수 있다.
- 만약 쿠키에 개인정보나, 신용카드 정보가 있다면?
- 이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
- 쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다.
해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
- 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.
🤔대안
- 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측
불가능한 임의의 토큰(랜덤 값)
을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다. - 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.
- 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예:30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.
3. 로그인 - 세션
쿠키의 보안 이슈를 해결하려면 중요한 정보를 모두 서버에 저장해야 한다.
그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.
이렇게 서버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.
사용자가 loginId
, password
정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
세션 ID를 생성하는데, 추정 불가능해야 한다.
UUID는 추정이 불가능하다.
- Cookie: mySessionId = zz0101xx-bab9-4b92-9b32-dab280f4b61
생성된 세션 ID와 세션에 보관할 값(memberA)을 서버의 세션 저장소에 보관한다.
클라이언트와 서버는 결국 쿠키
로 연결이 되어야 한다.
서버는 클라이언트에 mySessionId라는 이름으로 세션Id만 쿠키에 담아서 전달한다.
클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.
😄중요
- 여기서 중요한 포인트는 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것이다.
- 오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.
클라이언트의 세션id 쿠키 전달
클라이언트는 요청시 항상 mySessionId
쿠키를 전달한다.
서버에서는 클라이언트가 전달한 mySessionId
쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.
정리
세션을 사용해서 서버에서 중요한 정보를 관리하게 되었다. 덕분에 다음과 같은 보안 문제들을 해결할 수 있다.
- 쿠키 값을 변조 가능 → 예상 불가능한 복잡한 세션Id를 사용한다.
- 쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다. → 세션Id가 털려도 여기에는 중요한 정보가 없다.
- 쿠키 탈취 후 사용 → 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료시간을 짧게(예: 30분)유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다.
💻Code
세션 관리는 크게 다음 3가지 기능을 제공하면 된다.
세션 생성
- sessionId 생성 (임의의 추정 불가능한 랜덤 값)
- 세션 저장소에 sessionId와 보관할 값 저장
- sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
세션 조회
- 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
세션 만료
- 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거
사실 세션이라는 것이 뭔가 특별한 것이 아니라 단지 쿠키를 사용하는데, 서버에서 데이터를 유지하는 방법일 뿐이다.
HttpSession 사용하기
서블릿을 통해 HttpSession을 생성하면 다음과 같은 쿠키를 생성한다. 쿠키 이름이 JSESSIONID 이고, 값은 추정 불가능한 랜덤 값이다.
Cookie: JSESSIONID = 5B78E23B513F50164D6FDD8C97B0AD05
세션 생성과 조회
HttpSession session = request.getSession(); //HttpServletRequest
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
세션을 생성하려면 request.getSession(true)
를 사용하면 된다.
request.getSession(true)
- 세션이 있으면 기존 세션을 반환한다.
- 세션이 없으면 새로운 세션을 생성해서 반환한다.
request.getSession(false)
- 세션이 있으면 기존 세션을 반환한다.
- 세션이 없으면 새로운 세션을 생성하지 않는다. null을 반환한다.
request.getSession()
와 request.getSession(true)
는 동일하다. (default)
세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
하나의 세션에 여러 값을 저장할 수 있다.
로그아웃
HttpSession session = request.getSession(false); //HttpServletRequest
if (session != null) {
session.invalidate();
}
주의점
request.getSession()
을 사용하면 기본 값이 create:true
이므로, 로그인 하지 않을 사용자도 의미없는 세션이 만들어진다. 따라서 세션을 찾아서 사용하는 시점에는 create: false
옵션을 사용해서 세션을 생성하지 않아야 한다.
@GetMapping("/")
public String login(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(false); //로그인 하지 않은 사용자도 있으므로
if (session == null) {
return "home";
}
Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
session.getAttribute(SessionConst.LOGIN_MEMBER)
: 로그인 시점에 세션에 보관한 회원 객체를 찾는다.
@SessionAttribute를 사용해 다음과 같이 리팩토링 가능하다. 이 기능은 세션을 생성하지 않는다.
@SessionAttribute(name= “loginMember”, required=false) Member loginMember
@GetMapping("/")
public String login(
@SessionAttribute(name = SessionConst.LOGIN_MEMBER ,required = false) Member loginMember, Model model) {
if (loginMember == null) {
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
로그인을 처음 시도하면 URL이 다음과 같이 jsessionid
를 포함하는 것을 확인할 수 있다.
http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872
이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다.
서버 입장에서 웹 브라우저가 쿠키를 지원하는지 하지 않는지 최초에는 판단하지 못하므로, 쿠키 값도 전달하고, URL에 jessionid
도 함께 전달한다.
URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 다음 옵션을 넣어주자.
application.properties
server.servlet.session.tracking-modes=cookie
세션 타임아웃
세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()
가 호출 되는 경우에 삭제된다. 그런데 대부분의 사용자는 로그아웃을 선택하지 않고, 그냥 웹 브라우저를 종료한다.
문제는 HTTP가 비연결성이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다.
→ 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.
이 경우 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.
- 세션과 관련되 쿠키(
JSESSIONID
)를 탈취 당했을 경우 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다. - 세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.
세션의 종료 시점 좋은 대안
→ 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것이다. 이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다. 따라서 30분 마다 로그인해야 하는 번거로움이 사라진다. HttpSession
은 이 방식을 사용한다.
세션 타임아웃 발생
세션의 타임아웃 시간은 해당 세션과 관련된 JSESSIONID
를 전달하는 HTTP 요청이 있으면 현재 시간으로 다시 초기화 된다. 이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있다.
session.getLastAccessedTime();
:최근 세션 접근 시간
session.getMaxInactiveInterval();
: 세션의 유효 시간(기본 30분)
LastAccessdTime
이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.
🤔세션이 만능일까?
세션에는 최소한의 데이터만 보관해야 한다는 점이다.
보관한 데이터 용량 * 사용자 수
로 세션의 메모리 사용량이 급격하게 늘어나서 장애로 이어질 수 있다. (OOM)
추가로 세션의 시간을 너무 길게 가져가면 메모리 사용이 계속 누적 될 수 있으므로 적당한 시간을 선택하는 것이 필요하다. 기본이 30분이라는 것을 기준으로 고민하면 된다.
4. 로그인 - 필터, 인터셉터
필터나 인터셉터를 사용하면 로그인을 하지 않은 사용자도 URL을 직접 호출하면 인증이 필요한 페이지에 들어갈 수 있는 문제를 해결할 수 있다.
컨트롤러에서 로그인 여부를 체크하는 로직을 하나하나 작성할 수도 있지만, 모든 컨트롤러 로직에 공통으로 로그인 여부를 확인해야 한다.
더 큰 문제는 향후 로그인과 관련된 로직이 변경될 때 이다. 작성한 모든 로직을 다 수정해야 할 수 있다.
이렇게 애플리케이션 여러 로직에서 공통으로 관심이 있는 있는 것을 공통 관심사(cross-cutting concern)라고 한다. 여러 로직에서 공통으로 인증에 대해서 관심을 가지고 있다.
이러한 공통 관심사는 스프링의 AOP로도 해결할 수 있지만, 웹과 관련된 공통 관심사는 지금부터 설명할 서블릿 필터 또는 스프링 인터셉터를 사용하는 것이 좋다.
웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL의 정보들이 필요한데, 서블릿 필터나 스프링 인터셉터는 HttpServletRequest 를 제공한다.
참고로 필터는 서블릿이, 인터셉터는 스프링이 제공한다.
서블릿 필터
필터는 서블릿이 지원하는 수문장이다. 필터의 특성은 다음과 같다.
필터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러
필터를 적용하면 필터가 호출 된 다음에 서블릿이 호출된다. 그래서 모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 된다. 참고로 필터는 특정 URL 패턴에 적용할 수 있다. /* 이라고 하면 모든 요청에 필터가 적용된다.
참고로 스프링을 사용하는 경우 여기서 말하는 서블릿은 스프링의 디스패처 서블릿으로 생각하면 된다.
필터 제한
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터(적절하지 않은 요청이라 판단, 서블릿 호출X) //비 로그인 사용자
필터에서 적절하지 않은 요청이라고 판단하면 거기에서 끝을 낼 수도 있다. 그래서 로그인 여부를 체크하기에 딱 좋다.
필터 체인
HTTP 요청 -> WAS -> 필터1 -> 필터2 -> 필터3 -> 서블릿 -> 컨트롤러
필터는 체인으로 구성되는데, 중간에 필터를 자유롭게 추가할 수 있다. 예를 들어 로그를 남기는 필터를 먼저 적용하고, 그 다음에 로그인 여부를 체크하는 필터를 만들 수 있다.
필터 인터페이스
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException {}
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException;
public default void destroy() {}
}
필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 싱글톤 객체로 생성하고, 관리한다.
init(): 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출된다.
doFilter(): 고객의 요청이 올 때 마다 해당 메서드가 호출된다. 필터의 로직을 구현하면 된다.
destroy(): 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.
로그 필터 예시
package hello.login.web.filter;
import java.io.IOException;
import java.util.UUID;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LogFilter implements Filter {
@Override
public void init(final FilterConfig filterConfig) throws ServletException {
log.info("log filter init");
}
@Override
public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain) throws IOException, ServletException {
log.info("log filter doFilter");
HttpServletRequest httpRequest = (HttpServletRequest)request;
final String requestURI = httpRequest.getRequestURI();
final String uuid = UUID.randomUUID().toString();
try {
log.info("REQUEST [{}][{}]", uuid, requestURI);
chain.doFilter(request, response); // 다음 필터가 있으면 다음 필터 호출, 없으면 서블릿 호출
} catch (Exception e) {
throw e;
} finally {
log.info("RESPONSE [{}][{}]", uuid, requestURI);
}
}
@Override
public void destroy() {
log.info("log filter destroy");
}
}
doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
- HTTP 요청이 오면 doFilter가 호출된다.
- ServletRequest request는 HTTP 요청이 아닌 경우까지 고려해서 만든 인터페이스이다. HTTP를 사용하면 HttpServletRequest httpRequest = (HttpServletRequest) request; 와 같이 다운 캐스팅하면 된다.
chain.doFilter(request, response);
- 이 부분이 가장 중요하다. 다음 필터가 있으면 필터를 호출하고, 필터가 없으면 서블릿을 호출한다. 만약 이 로직을 호출하지 않으면 다음 단계로 진행되지 않는다.
필터 등록
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean logFilter() {
FilterRegistrationBean<Filter> filterRegistrationBean = new
FilterRegistrationBean<>();
filterRegistrationBean.setFilter(new LogFilter());
filterRegistrationBean.setOrder(1);
filterRegistrationBean.addUrlPatterns("/*");
return filterRegistrationBean;
} }
필터를 등록하는 방법은 여러가지가 있지만, 스프링 부트를 사용한다면 FilterRegistrationBean을 사용해서 등록하면 된다.
스프링 인터셉터
스프링 인터셉터도 서블릿 필터와 같이 웹과 관련된 공통 관심 사항을 효과적으로 해결할 수 있는 기술이다. 서블릿 필터가 서블릿이 제공하는 기술이라면, 스프링 인터셉터는 스프링 MVC가 제공하는 기술이다. 둘다 웹과 관련된 공통 관심 사항을 처리하지만, 적용되는 순서와 범위, 그리고 사용방법이 다르다.
스프링 인터셉터 흐름
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러
스프링 인터셉터는 디스패처 서블릿과 컨트롤러 사이에서 컨트롤러 호출 직전에 호출 된다.
스프링 인터셉터는 스프링 MVC가 제공하는 기능이기 때문에 결국 디스패처 서블릿 이후에 등장하게 된다. 스프링 MVC의 시작점이 디스패처 서블릿이라고 생각해보면 이해가 될 것이다.
스프링 인터셉터에도 URL 패턴을 적용할 수 있는데, 서블릿 URL 패턴과는 다르고, 매우 정밀하게 설정할 수 있다.
스프링 인터셉터 제한
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러 //로그인 사용자
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터(적절하지 않은 요청이라 판단, 컨트롤러 호출 X) // 비 로그인 사용자
스프링 인터셉터 체인
HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터1 -> 인터셉터2 -> 컨트롤러
스프링 인터셉터 인터페이스
public interface HandlerInterceptor {
default boolean preHandle(HttpServletRequest request, HttpServletResponse
response,Object handler) throws Exception {}
default void postHandle(HttpServletRequest request, HttpServletResponse
response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {}
default void afterCompletion(HttpServletRequest request, HttpServletResponse
response,Object handler, @Nullable Exception ex) throws Exception {}
서블릿 필터의 경우 단순하게 doFIlter() 하나만 제공된다. 인터셉터는 컨트롤러 호출 전(preHandle), 호출 후(postHandle), 요청 완료 이후(afterCompletion)와 같이 단계적으로 잘 세분화 되어 있다.
서블릿 필터의 경우 단순히 request, response 만 제공했지만 인터셉터는 어떤 컨트롤러(handler)가 호출되는지 호출 정보도 받을 수 있다. 그리고 어떤 modelAndVIew 가 반환되는지 응답 정보도 받을 수 있다.
스프링 인터셉터 호출 흐름(정상 흐름)
preHandle: 컨트롤러 호출 전에 호출된다. (더 정확히는 핸들러 어댑터 호출 전에 호출된다.)
- preHandle의 응답값이 true이면 다음으로 진행하고, false이면 더는 진행하지 않는다. false인 경우 나머지 인터셉터는 물론이고, 핸들러 어댑터도 호출하지 않는다. 1번에서 끝난다.
postHandle: 컨트롤러 호출 후에 호출된다. (더 정확히는 핸들러 어댑터 호출 후에 호출된다.)
afterCompletion: 뷰가 렌더링 된 이후에 호출된다.
스프링 인터셉터 예외 상황
예외가 발생 시
preHandle: 컨트롤러 호출 전에 호출된다.
postHandle: 컨트롤러에서 예외가 발생하면 postHandle은 호출되지 않는다.
afterCompletion: afterCompletion은 항상 호출된다. 이 경우 예외(ex)를 받아서 어떤 예외가 발생했는지 로그로 출력할 수 있다.
afterCompletion은 예외가 발생해도 호출된다.
예외와 무관하게 공통 처리를 하려면 afterCompletion()을 사용해야 한다.
인터셉터는 스프링 MVC 구조에 특화된 필터 기능을 제공한다고 이해하면 된다.
스프링 MVC를 사용하고, 특별히 필터를 꼭 사용해야 하는 상황이 아니라면 인터셉터를 사용하는 것이 더 편리하다.
인터셉터도 싱글톤처럼 사용되기 때문에 멤버변수를 사용하면 위험하다.
로그 인터셉터 예시
package hello.login.web.interceptor;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class LogInterceptor implements HandlerInterceptor {
public static final String LOG_ID = "logId";
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) throws Exception {
final String requestURI = request.getRequestURI();
final String uuid = UUID.randomUUID().toString();
request.setAttribute(LOG_ID, uuid);
// @RequestMapping: HandlerMethod
// 정적 리소스: ResourceHttpRequestHandler
if (handler instanceof HandlerMethod) {
final HandlerMethod hm = (HandlerMethod)handler; // 호출할 컨트롤러 메서드의 모든 정보가 포함되어 있음
}
log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
return true;
}
@Override
public void postHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler,
final ModelAndView modelAndView) throws Exception {
log.info("postHandle [{}]", modelAndView);
}
@Override
public void afterCompletion(final HttpServletRequest request, final HttpServletResponse response,
final Object handler, final Exception ex) throws Exception {
final String requestURI = request.getRequestURI();
final String logId = (String)request.getAttribute(LOG_ID);
log.info("RESPONSE [{}][{}][{}]", logId, requestURI, handler);
if (ex != null) {
log.error("afterCompletion error!!", ex);
}
}
}
스프링 인터셉터 등록
package hello.login;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import hello.login.web.argumentresolver.LoginMemberArgumentResolver;
import hello.login.web.interceptor.LogInterceptor;
import hello.login.web.interceptor.LoginCheckInterceptor;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LoginMemberArgumentResolver());
}
@Override
public void addInterceptors(final InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "/*.ico", "/error");
registry.addInterceptor(new LoginCheckInterceptor())
.order(2)
.addPathPatterns("/**")
.excludePathPatterns("/", "/members/add", "/login", "/logout",
"/css/**", "/*.ico", "/error");
}
}
필터와 비교해보면 인터셉터는 addPathPatterns, excludePathPatterns로 매우 정밀하게 URL 패턴을 지정할 수 있다.
5. Oauth: Open Authorization
최근의 인터넷 서비스는 그 자체가 SaaS(Software as a Service)의 형태입니다. 서비스 중에서 사용자가 일부 필요한 것만 사용할 수 있게 한다는 것입니다. Facebook이나 트위터가 세상에 널리 퍼지게된 이유 중에 하나가 외부 서비스에서도 Facebook가 트위터의 일부 기능을 사용할 수 있게 한 것입니다. 외부 서비스와 연동되는 Facebook이나 트위터의 기능을 이용하기 위해 사용자가 반드시 Facebook이나 트위터에 로그인해야 하는 것이 아니라, 별도의 인증 절차를 거치면 다른 서비스에서 Facebook과 트위터의 기능을 이용할 수 있게 되는 것입니다.
OAuth는 인증을 위한 오픈 스탠더드 프로토콜로, 사용자가 Facebook이나 트위터 같은 인터넷 서비스의 기능을 다른 애플리케이션(데스크톱, 웹, 모바일 등)에서도 사용할 수 있게 한 것이다.
OAuth 2.0은 OAuth 1.0과 호환되지 않지만, 인증 절차가 간략하다는 장점이 있다.
😁OAuth는 프로토콜이다. (컴퓨터 또는 전자기기 간의 원할한 통신을 위해 지키기로 약속한 규약)
OAuth와 로그인
들어가기 전 참고
인증(Authentication): 본인이 누구인지 확인(로그인)
인가(Authorization): 권한부여 (ADMIN 권한처럼 특정 리소스에 접근할 수 있는 권한, 인증이 있어야 인가가 있음)
출처: 모든 개발자를 위한 HTTP 웹 기본 지식
OAuth와 로그인은 반드시 분리해서 이해해야 한다.
사원증을 이용해 출입할 수 있는 회사를 생각해 보자. 그런데 외부 손님이 그 회사에 방문할 일이 있다. 회사 사원이 건물에 출입하는 것이 로그인이라면 OAuth는 방문증을 수령한 후 회사에 출입하는 것에 비유할 수 있다.
다음과 같은 절차를 생각해 보자.
- 나방문씨(외부 손님)가 안내 데스크에서 업무적인 목적으로 김목적씨(회사 사원)를 만나러 왔다고 말한다.
- 안내 데스크에서는 김목적씨에게 나방문씨가 방문했다고 연락한다.
- 김목적씨가 안내 데스크로 찾아와 나방문씨의 신원을 확인해 준다.
- 김목적씨는 업무 목적과 인적 사항을 안내 데스크에서 기록한다.
- 안내 데스크에서 나방문 씨에게 방문증을 발급해 준다.
- 김목적씨와 나방문씨는 정해진 장소로 이동해 업무를 진행한다.
위 과정은 방문증 발급과 사용에 빗대어 OAuth 발급 과정과 권한을 이해할 수 있도록 한 것이다. 방문증이란 사전에 정해진 곳만 다닐 수 있도록 하는 것이니, '방문증'을 가진 사람이 출입할 수 있는 곳과 '사원증'을 가진 사람이 출입할 수 있는 곳은 다르다. 역시 직접 서비스에 로그인한 사용자와 OAuth를 이용해 권한을 인증받은 사용자는 할 수 있는 일이 다르다.
OAuth에서 ‘Auth’는 Authentication(인증)
뿐만 아니라 ‘Authorization’(허가, 인가)
또한 포함하고 있는 것이다.
그렇기 때문에 OAuth 인증을 진행할 때 해당 서비스 제공자는 ‘제 3자가 어떤 정보나 서비스에 사용자의 권한으로 접근하려 하는데 허용하겠느냐’ 라는 안내 메시지를 보여 주는 것이다.
OpenID와 OAuth
OpenID도 인증을 위한 표준 프로토콜이고 HTTP를 사용한다는 점에서는 OAuth와 같다. 그러나 OpenID와 OAuth의 목적은 다르다.
OpenId의 주요 목적은 인증(Authentication)
이지만 OAuth의 주요 목적은 허가(Authorization)
이다. 즉, OpenID를 사용한다는 것은 본질적으로 로그인하는 행동과 같다. OpenId는 OpenId Provider에서 사용자의 인증 과정을 처리한다. Open ID를 사용하는 여러 서비스(Relying Party)는 OpenID Proivder에게 인증을 위임하는 것이다.
물론 OAuth에서도 인증 과정이 있다. 가령 Facebook의 OAuth를 이용한다면 Facebook의 사용자인지 인증하는 절차를 Facebook(Service Provider) 처리한다. 하지만 OAuth의 근본 목적은 해당 사용자의 담벼락(wall)에 글을 쓸 수 있는 API를 호출할 수 있는 권한이나, 친구 목록을 가져오는 API를 호출할 수 있는 권한이 있는 사용자인지 확인하는 것이다.
OAuth를 사용자 인증을 위한 방법으로 쓸 수 있지만, OpenID와 OAuth의 근본 목적은 다르다는 것을 알아야 한다.
OAuth Dance, OAuth 1.0 인증 과정
OAuth를 이용하여 사용자 인증을 하는 과정을 OAuth Dance라고 한다.
OAuth의 대표 용어를 정리해 보았다. (네이버 로그인 예시)
용어 | 설명 |
---|---|
User | Service Provider(ex:네이버)에 계정을 가지고 있으면서, Consumer를 이용하려는 사용자 |
Service Provider | OAuth를 사용하는 Open API를 제공하는 서비스 |
Consumer | OAuth 인증을 사용해 Service Provider(ex: 네이버)의 기능을 사용하려는 애플리케이션이나 웹 서비스(ex: 내가 개발중인 프로젝트) |
Request Token | Consumer가 Service Provider에게 접근 권한을 인증받기 위해 사용하는 값. 인증이 완료된 후에는 Access Token으로 교환한다. |
Access Token | 인증 후 Consumer(ex: 내가 개발중인 프로젝트)가 Service Provider(네이버)의 자원에 접근하기 위한 키를 포함한 값 |
OAuth 인증 과정을 앞에서 설명한 회사 방문 과정과 연결하면 다음 표와 같다.
회사 방문 과정 | OAuth 인증 과정 | |
---|---|---|
1. | 나방문씨가 안내 데스크에서 업무적인 목적으로 김목적씨를 만나러 왔다고 말한다. | Request Token의 요청과 발급 |
2. | 안내 데스크에서는 김목적씨에게 나방문씨가 방문했다고 연락한다. | 사용자 인증 페이지 호출 |
3. | 김목적씨가 안내 데스크로 찾아와 나방문씨의 신원을 확인해 준다. | 사용자 로그인 완료 |
4. | 김목적씨는 업무 목적과 인적 사항을 안내 데스크에서 기록한다. | 사용자의 권한 요청 및 수락 |
5. | 안내 데스크에서 나방문 씨에게 방문증을 발급해 준다. | Access Token 발급 |
6. | 김목적씨와 나방문씨는 정해진 장소로 이동해 업무를 진행한다. | Access Token을 이용해 서비스 정보 요청 |
위의 표에 따르면, Access Token은 방문증이라고 이해할 수 있다. 이 방문증으로 사전에 허락된 공간에 출입할 수 있다. 마찬가지로 Access Token을 가지고 있는 Consumer는 사전에 호출이 허락된 Service Provider의 오픈 API를 호출할 수 있는 것이다.
그림으로 이해하면 다음과 같다. Consumer를 내가 개발중인 프로젝트, Service Provider를 네이버로 예를 들면 다음과 같다.
A. Consumer가 Service Provider(네이버)로 Request Token을 요청한다.
B. Service Provider(네이버)가 request token을 준다.
C. User(사용자)를 Service Provider(네이버) 로 연결한다. Service Provider에서는 사용자 권한을 얻는다.
D. Service Provider(네이버)가 User(사용자) 를 Consumer(내가 개발중인 프로젝트)에 연결한다.
E. Consumer가 Access Token을 요청한다.
F. Service Provider(네이버)가 Access Token을 준다.
G. Conumser가 보호된 자원에 접근한다.
Request Token
OAuth에서 Consumer가 Request Token 발급을 요청하고 Service Provider가 Request Token을 발급하는 과정은 "저 나방문입니다. 김목적씨를 만날 수 있을까요?"라고 말하는 절차에 비유할 수 있다.
Request Token을 요청하는 Request 전문을 살펴보자. 다음은 네이버의 OAuth API로 Request Token을 요청하는 예이다.
GET /naver.oauth?mode=req_req_token&oauth_callback=http://example.com/OAuthRequestToken.do&oauth_consumer_key=WEhGuJZWUasHg&oauth_nonce=zSs4RFI7lakpADpSsv&oauth_signature=wz9+ZO5OLUnTors7HlyaKat1Mo0=&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1330442419&oauth_version=1.0 HTTP/1.1
Accept-Encoding: gzip, deflate
Connection: Keep-Alive
Host: nid.naver.com
여러 개의 쿼리 스트링(쿼리 파라미터)가 존재한다.
쿼리 스트링 기준으로 정리하면 다음과 같다.
GET http://nid.naver.com/naver.oauth?mode=req_req_token&
oauth_callback=http://example.com/OAuthRequestToken.do&
oauth_consumer_key=WEhGuJZWUasHg&
oauth_nonce=zSs4RFI7lakpADpSsv&
oauth_signature=wz9+ZO5OLUnTors7HlyaKat1Mo0=&
oauth_signature_method=HMAC-SHA1&
oauth_timestamp=1330442419&
oauth_version=1.0 HTTP/1.1
중요한 매개변수는 다음 표와 같다.
매개변수 | 설명 |
---|---|
oauth_callback | Service Provider가 인증을 완료한 후 리다이렉트할 Consumer의 웹 주소. 만약 Consumer가 웹 애플리케이션이 아니라 리다이렉트할 주소가 없다면 소문자로 'oob'(Out Of Band라는 뜻)를 값으로 사용한다. |
oauth_consumer_key | Consumer를 구별하는 키 값. Service Provider는 이 키 값으로 Consumer를 구분한다. |
oauth_signature | OAuth 인증 정보를 암호화하고 인코딩하여 서명 값. OAuth 인증 정보는 매개변수 중에서 oauth_signature를 제외한 나머지 매개변수와 HTTP 요청 방식을 문자열로 조합한 값이다. 암화 방식은 oauth_signature_method에 정의된다. |
oauth_signature_method | oauth_signature를 암호화하는 방법. HMAC-SHA1, HMAC-MD5 등을 사용할 수 있다. |
oauth_signature 만들기
OAuth 1.0에서는 oauth_signature
를 생성하는 것이 가장 까다로운 단계이다. 당연히 Consumer와 Service
Provider가 같은 암호화(signing) 알고리즘을 이용하여 oauth_signature를 만들어야 한다.
oauth_signature
는 다음과 같이 네 단계를 거쳐 만든다.
1. 요청 매개변수를 모두 모은다.
oauth_signature를 제외하고 'oauth_'로 시작하는 OAuth 관련 매개변수를 모은다. POST body에서 매개변수를 사용하고 있다면 이 매개변수도 모아야 한다.
2. 매개변수를 정규화(Normalize)한다.
모든 매개변수를 사전순으로 정렬하고 각각의 키(key)와 값(value)에 URL 인코딩(rfc3986)을 적용한다. URL 인코딩을 실시한 결과를 = 형태로 나열하고 각 쌍 사이에는 &을 넣는다. 이렇게 나온 결과 전체에 또 URL 인코딩을 적용한다.
3. Signature Base String을 만든다.
HTTP method 명(GET 또는 POST), Consumer가 호출한 HTTP URL 주소(매개변수 제외), 정규화한 매개변수를 '&'를 사용해 결합한다. 즉 '[GET|POST] + & + [URL 문자열로 매개변수는 제외] + & + [정규화한 매개변수]' 형태가 된다.
4. 키 생성
3번 과정까지 거쳐 생성한 문자열을 암호화한다. 암호화할 때 Consumer Secret Key를 사용한다. Consumer Secret Key는 Consumer가 Service Provider에 사용 등록을 할 때 발급받은 값이다. HMAC-SHA1 등의 암호화 방법을 이용하여 최종적인 oauth_signature를 생성한다.
사용자 인증 페이지의 호출
OAuth에서 사용자 인증 페이지를 호출하는 단계는 '안내데스크에서 김목적씨에게 방문한 손님이 있으니 안내 데스크로와서 확인을 요청하는 것'에 비유할 수 있다.
Request Token을 요청하면, Service Provider는 Consumer에 Request Token으로 사용할 oauth_token과 oauth_token_secret을 전달한다. Access Token을 요청할 때는 Request Token의 요청에 대한 응답 값으로 받은 oauth_token_secret을 사용한다. Consumer가 웹 애플리케이션이라면 HTTP 세션이나 쿠키 또는 DBMS 등에 oauth_token_secret를 저장해 놓아야 한다.
oauth_token을 이용해 Service Provider가 정해 놓은 사용자 인증 페이지를 User에게 보여 주도록 한다. 네이버의 경우 OAuth용 사용자 인증 페이지의 주소는 다음과 같다.
https://nid.naver.com/naver.oauth?mode=auth_req_token
여기에 Request Token을 요청해서 반환받은 oauth_token을 매개 변수로 전달하면 된다. 예를 들면 다음과 같은 URL이 만들어 지게 되는 것이다.
이 URL은 사용자 인증 화면을 가리킨다.
https://nid.naver.com/naver.oauth?mode=auth_req_token&oauth_token=wpsCb0Mcpf9dDDC2
로그인 화면을 호출하는 단계까지가 '안내 데스크에서 김목적씨에게 전화하는 단계'라 보면 되겠다. 이제 김목적씨가 안내 데스크로 와서 나방문씨를 확인해야 한다. 정말 나방문씨가 맞는지 아닌지 확인하는 과정이 필요할 것이다. 이 과정이 OAuth에서는 Service Provider에서 User를 인증하는 과정이라고 볼 수 있다.
인증이 완료되면 앞에서 말한 바와 같이 어떤 권한을 요청하는 단계에 이르게 된다. "업무 약속이 있어 오셨으니 나방문씨가 출입할 수 있게 해 주세요"와 같은 것 말이다.
인증을 마치면 Consumer가 oauth_callback에 지정한 URL로 리다이렉트한다. 이때 Service Provider는 새로운 oauth_token과 oauth_verifier를 Consumer에 전달한다. 이 값들은 Access Token을 요청할 때 사용한다.
Access Token 요청하기
OAuth에서의 AccessToken은 나방문 씨에게 지급할 방문증과 같다.
Access Token을 요청하는 방법은 Request Token을 요청하는 방법과 거의 같다. 다만, 사용하는 매개변수의 종류가 약간 다르고 oauth_signature를 생성할 때 사용하는 키가 다르다. Access Token 을 요청할 때에는 매개변수 oauth_callback는 없고, oauth_token와 oauth_verifer가 있다.
Access Token 사용하기
드디어 방문증이 발급됐다. 이제 출입문을 통과하는 일만 남았다. 방문증을 가지고 출입문을 통과한다는 것은 User의 권한으로 Service Provider의 기능을 사용하는 것과 비슷하다. 다시 말해, 권한이 필요한 오픈 API를 호출할 수 있게 되는 것이다.
가령 네이버 카페에서 게시판 목록을 가져온다고 한다면 호출해야 하는 URL은 다음과 같다.
http://openapi.naver.com/cafe/getMenuList.xml
특정 User의 권한을 가지고 카페 게시판 목록 반환 URL을 요청해야 해당 User가 가입한 카페의 게시판 목록을 반환받을 수 있을 것이다. 이 URL을 호출할 때는 OAuth 매개변수를 함께 전달해야 한다.
다음은 Access Token을 사용해 오픈 API를 요청하는 예이다. HTTP 헤더에 Authorization 필드를 두었고, Authorization 필드의 값 부분에 OAuth 매개변수를 적는다.
POST /cafe/getMenuList.xml HTTP/1.1
Authorization: OAuth oauth_consumer_key="dpf43f3p2l4k3l03",oauth_token="nSDFh734d00sl2jdk"
,oauth_signature_method="HMACSHA1",oauth_timestamp="1379123202",oauth_nonce="chapoH",oauth_signature="MdpQcU8iPSUjWoN%2FUDMsK2sui9I%3D"
Accept-Encoding: gzip, deflate
Connection: Keep-Alive
Host: http://openapi.naver.com
OAuth 2.0
OAuth 2.0은 기존의 OAuth 1.0와 호환되지 않는 새로운 프로토콜이다.
기존의 OAuth 1.0은 데스크톱이나 휴대폰 애플리케이션에서 사용자가 원하는 서비스로 브라우저를 연 뒤 서비스를 인증하고, 서비스에서 애플리케이션으로 토큰을 복사하여 사용하는 방식이다.
브라우저를 왔다갔다하는 과정이 필수적이었어서 사용자 경험 측면에서 나쁘다는 비판을 받았다.
OAuth 2.0이 나타나며 이러한 인증 절차가 간소화 되었으며 몇가지 용어가 변경되었다.
OAuth 1.0 | OAuth 2.0 | |
---|---|---|
암호화 과정 | 필요 | 필요X.보안을 HTTPS에 위임 |
액세스 토큰 | 1년 이상 긴 access token | (짧은 기간의) access token+ refresh token |
6. JWT
JWT(JSON Web Token)란 인증에 필요한 정보들을 암호화시킨 토큰을 의미. JWT 기반 인증은 쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별한다.
JWT는 .을 구분자로 나뉘어지는 세 가지 문자열의 조합이다. 실제 디코딩된 JWT는 다음과 같은 구조를 지닌다.
JWT 구조
Header
Header의 alg와 typ은 각각 정보를 암호화할 해싱 알고리즘 및 토큰의 타입을 지정한다.
payload
Payload는 토큰에 담을 정보를 지니고 있다. 주로 클라이언트의 고유 ID값 및 유효 기간 등이 포함되는 영역이다. key-value 형식으로 이루어진 한 쌍의 정보를 Claim이라고 칭한다.
참고로 위의 Jwt 토큰에서 sub는 이 토큰의 제목을, iat는 이 데이터가 발행된 시간을 뜻한다.
Signature
Signature는 인코딩된 Header와 Payload를 더한 뒤 비밀키로 해싱하여 생성한다. Header와 Payload는 단순히 인코딩된 값이기 때문에 제 3자가 복호화 및 조작할 수 있지만, Signature는 서버 측에서 관리하는 비밀키가 유출되지 않는 이상 복호화할 수 없다. 따라서 Signautre는 토큰의 위변조 여부를 확인하는데 사용된다.
인증 과정
Authorization: <type> <access-token>
- 클라이언트 로그인 요청이 들어오면, 서버는 검증 후 클라이언트 고유 ID 등의 정보를 Payload에 담는다.
- 암호화할 비밀키를 사용해 Access Token(JWT)을 발급한다.
- 클라이언트는 전달받은 토큰을 저장해두고, 서버에 요청할 때 마다 토큰을 요청 헤더
Authorization
에 포함시켜 함께 전달한다. - 서버는 토큰의 Signature를 비밀키로 복호화한 다음, 위변조 여부 및 유효 기간 등을 확인한다.
- 유효한 토큰이라면 요청에 응답한다.
구체적으로 어떻게 헤더를 보낼까? Postman 예시를 살펴보자.
카카오 Access token을 이용해서 서버에서 JWT를 생성하는 예시이다.
type이 Bearer인 것을 확인할 수 있다.
type에는 여러가지가 있지만 Basic, Bearer에 대해 알아보자.
참고
처음에 인증 없이 서버에 접근하면 401 Unauthorized 상태코드와 함께 WWW-Authenticate: {type} realm=”인증에 대한 알림 메시지” 형태로 응답을 준다.
401 Unauthorized 상태 코드는 클라이언트가 해당 리소스에 대한 인증이 필요하다는 의미이다.
Basic
사용자 ID와 PW를 Base64로 인코딩한 값을 토큰으로 사용한다. (RFC 7617)
Basic 토큰 값이 노출이 되면 ID, PW가 노출되는 것이기 때문에 보안에 취약하다.
링크한 RFC 7617 문서를 보면, which transmits credentials as user-id/passwrod pairs, encoded using Base64 라고 나와있다.
은 기본적으로 Base64-Encoded(A:B)를 따른다.
A는 user-id, B는 password를 대응하여 Base64Encoded("jinny:lamp")
를 하게 되면 값은 amlubnk6bGFtcA==
가 나오게 된다.
Authorization: Basic amlubnk6bGFtcA==
참고
RFC(Request for Comments) 문서는 "의견을 요청하는 문서"라는 의미로, 국제 인터넷 표준화 기구(IETF; Internet Engineering Task Force)에서 관리하는 기술 표준.
Bearer
일반적으로 JWT(RFC 7519) 같은 OAuth 토큰을 사용한다.(RFC 6750)
- Basic 방식과는 달리 토큰에 ID, PW 값을 넣지 않는다.
- 로그인 시 토큰을 부여받고, 이후 요청할 때 요청 헤더에 토큰을 실어서 보낸다.
- 세션 저장소가 필요가 없고, 토큰 자체에 내장이 되어있다.
- STATELESS, 무결성, 보안성이 장점
RFC 6750에 따른 Bearer type의 Error Code는 다음과 같다.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="example"
Basic type과 마찬가지로 Authorization에 type과 access token을 공백으로 구분해서 보낸다.
Authorization: Bearer XDZ5jAfoofkcifhshoHH68HwWdYOgBfW9TzD
장점
- 인증 정보에 대한 별도의 저장소가 필요없다.
- 다수의 서버를 운영할 때 토큰을 만들 때 사용했던 시크릿 키만 공유 되어있으면 요청을 받은 서버에서 인증 처리 가능 (인증 서버에 트래픽이 몰리는 문제 방지 가능)
- 클라이언트 인증 정보를 저장하는 세션과 다르게, 서버는 무상태가 된다.
- Header와 Payload를 가지고 Signature를 생성하므로 데이터 위변조를 막을 수 있다.
- OAuth의 경우 Facebook, Google 등 소셜 계정을 이용하여 다른 웹서비스에서도 로그인을 할 수 있다.
- 모바일 어플리케이션 환경에서도 잘 동작합니다.
단점
- 쿠키/세션과 다르게 JWT는 토큰의 길이가 길어, 인증 요청이 많아질수록 네트워크 부하가 심해진다.
- Payload 자체는 암호화되지 않기 때문에 유저의 중요한 데이터는 Payload에 담으면 안된다.
- 토큰을 탈취당하면 대처하기 어렵다.
- 특정 사용자의 접속을 강제로 만료하기 어렵지만, 쿠키/세션 기반 인증은 서버 쪽에서 쉽게 세션을 삭제할 수 있다.
공격
- alg : none 공격 (HS256 씁시다)
- JWT는 디코딩(변환)이 매우 쉽기 때문에 최소한의 정보만 넣어야 한다.
- 시크릿 키 문제 → 대충 적으면 브루트포스 어택에 취약하다.
- JWT 탈취 → ..? 구조상 입장권을 회수하거나 사용 정지시키기 어렵다(stateless)
4번(JWT 탈취)에 대한 솔루션
- 훔치기 어렵게. ex) HttpOnly Cookie
- 서버에서 JWT 블랙리스트 만들어서 운영. → 세션 방식이랑 동일해져서 장점 없음
- JWT 유효기간을 매우 짧게 유지(ex: 5분). → 재발급을 위한 Refresh Token 필요. Refresh Token도 탈취당할 수 있기 때문에 Refresh token rotation이 필요하다.
보안 전략 - Refresh Token
클라이언트가 로그인 요청을 보내면 서버는 Access Token 및 그보다 긴 만료 기간을 가진 Refresh Token을 발급하는 전략.
클라이언트는 Acess Token이 만료되었을 때 Refresh Token을 사용하여 Access Token의 재발급을 요청. 서버는 DB에 저장된 Refresh Token과 비교하여 유효한 경우 새로운 Access Token을 발급하고, 만료된 경우 사용자에게 로그인을 요구.
해당 전략을 사용하면 Access Token의 만료 기한을 짧게 설정할 수 있으며, 사용자가 자주 로그인할 필요가 없다. 또한 서버가 강제로 Refresh Token을 만료시킬 수 있다.
그러나 검증을 위해 서버는 Refresh Token을 별도의 storage에 저장해야 한다. 이는 추가적인 I/O 작업이 발생함을 의미하기 때문에 JWT의 장점(I/O 작업이 필요 없는 빠른 인증 처리)을 완벽하게 누릴 수 없다. 클라이언트도 탈취 방지를 위해 Refresh Token을 보안이 유지되는 공간에 저장해야 한다.
access token은 JS private instance에 보관하고,
refresh token은 HttpOnly, SameSite strict로 설정된 cookie에 보관하면 xss, csrf 공격에 대해 보안이 가능해진다. 여기서 cookie secure을 true로 설정하면 스니핑 공격까지 막을 수 있다.
7. Oauth 2.0 소셜 로그인 과정
다음은 카카오 로그인 예시이다.
프론트 개발자
Authorization Code를 이용해 카카오 access token 발급
백엔드 개발자
프론트에서 넘겨준 access token으로 JWT 반환.
이후에 클라이언트에서 요청 헤더에 다음과 같이 추가해서 보냄.
Authorization: Bearer <access token>
8. CORS
개요
SOP (Same Origin Policy) : 어떤 출처에서 다른 출처의 리소스를 사용하는 것을 제한하는 보안 방식
Origin Example : http://localhost:8080
(EX) http://localhost:8082에서 http://localhost:8080/api/health 호출 시 다른 Origin 이므로 브라우저에서 에러 발생
CORS (Cross- Origin Resource Sharing) : 다른 출처에 리소스를 공유하는 것
참고
Cross-site 는 두 개 이상의 웹사이트 간에 발생하는 상호작용이나 정보 교환을 의미합니다. 즉, 하나의 웹사이트에서 다른 웹사이트로 이동하는 것을 의미합니다.
HTTP 요청은 기본적으로 Cross-site HTTP request가 가능하다.
다시 말하면, <img>
태그로 다른 도메인의 이미지 파일을 가져오거나, <link>
태그로 다른 도메인의 CSS 를 가져오거나, <script>
태크로 다른 도메인의 javascript 라이브러리를 가져오는 것이 모두 가능하다.
하지만
<script></script>
로 둘러싸여 있는 스크립트에서 생성된 Cross-Site HTTP Requests는 Same Old Policy를 적용 받기 때문에 Cross-Site HTTP Requests가 불가능하다.
AJAX가 널리 사용되면서 <script></script>
로 둘러싸여 있는 스크립트에서 생성되는 XMLHttpRequest
에 대해서도 Cross-Site Requests가 가능해야 한다는 요구가 늘어나자 W3C에서 CORS라는 이름의 권고안이 나오게 되었다.
CORS 요청의 종류
CORS 요청은 Simple/Preflight, Credential/Non-Credential의 조합으로 4가지가 존재한다.
브라우저가 요청 내용을 분석하여 4가지 방식 중 해당하는 방식으로 서버에 요청을 날리므로, 프로그래머가 목적에 맞는 방식을 선택하고 그 조건에 맞게 코딩해야 한다.
Simple Request
아래의 3가지 조건을 모두 만족하면 Simple Request
- GET, HEAD, POST 중의 한 가지 방식을 사용해야 한다.
- POST 방식일 경우 Content-Type이 아래 셋 중의 하나여야 한다.
- application/x-www-form-urlencoded
- multipart/form-data
- text
- 커스텀 헤더를 전송하지 말아야 한다.
Simple Request는 서버에 1번 요청하고, 서버도 1번 회신하는 것으로 처리가 종료된다.
참고
HTTP Origin 헤더는 웹 브라우저가 서버에게 현재 웹 페이지를 로드하는 도메인이나 프로토콜을 알리는 헤더입니다. 이 헤더는 크로스-사이트 요청 위조(CSRF) 공격을 방지하는 데 사용됩니다.
예를 들어, 웹 페이지에서 사용자가 로그인 폼을 제출할 때, 브라우저는 Origin 헤더를 포함하여 서버에게 요청을 전송합니다. 이 헤더는 현재 웹 페이지가 로드된 도메인을 식별하며, 서버는 이 정보를 사용하여 요청이 유효한지 확인합니다.
서버가 요청이 유효하지 않다고 판단하면, 보통은 요청을 거부하거나 추가적인 인증 단계를 요구합니다. 이를 통해 웹 애플리케이션은 불법적인 CSRF 공격으로부터 보호됩니다.
HTTP Origin 헤더는 보안 상의 이유로 웹 브라우저에 의해 자동으로 설정되며, 개발자나 사용자가 수정할 수 없습니다.
Access-Control-Allow-Origin 헤더의 값으로 지정된 도메인으로부터의 요청만 서버의 리소스에 접근할 수 있게 합니다.
Preflight Request
Simple Request 조건에 해당하지 않으면 브라우저는 Preflight Request 방식으로 요청한다.
따라서, Preflight Request는
- GET, HEAD, POST 외의 다른 방식으로도 요청을 보낼 수 있고,
- application/xml 처럼 다른 Content-Type으로 요청을 보낼 수 있으며,
- 커스텀 헤더도 사용할 수 있다.
이름에서 짐작할 수 있듯, Preflight Request는 예비 요청과 본 요청으로 나뉘어 전송된다.
먼저 서버에 예비 요청(Preflight Request)를 보내고 서버는 예비 요청에 대해 응답하고, 그 다음에 본 요청(Actual Request)을 서버에 보내고, 서버도 본 요청에 응답한다.
😆중요해요
하지만, 예비 요청과 본 요청에 대한 서버단의 응답을 프로그래머가 프로그램 내에서 구분하여 처리하는 것은 아니다.
프로그래머가 Access-Control- 계열의 Response Header만 적절히 정해주면, OPTIONS 요청으로 오는 예비 요청과, GET, POST, HEAD, PUT, DELETE 등으로 오는 본 요청의 처리는 서버가 알아서 처리한다.
다시 강조하지만, 프로그래머가 OPTIONS 요청의 처리 로직과 POST 요청의 처리 로직을 구분하여 구현하는 것이 아니다.
참고
Preflight 요청은 HTTP Options 메서드를 사용한다.
프로그래머는 Access-Control- 계열의 Response Header만 적절히 정해주면 된다.
관련 Header는 아래에서 설명한다.
Java로 구현한 예제 코드는 다음과 같다.
참고
모든 도메인을 허용하는 와일드카드(*)의 사용은 아무리 귀찮더라도 서비스가 전체적으로 공개될 것이 아니라면 사용을 자제하여야 합니다.
✔️정리
- 웹브라우저는 기본적으로 cross origin에 대해서 HTTP 요청 전에 서버 측에서 해당 요청을 보낼 수 있는지 확인하는 Preflight Request(사전 요청)을 보냄
- Preflight Request는 HTTP OPTIONS 메서드를 사용
- Preflight Request를 사용하는 이유는
CORS 오류는 웹브라우저에서 발생
하기 때문에 서버에서는 정상적으로 요청을 처리했는데 클라이언트에서는 오류가 난 것처럼 보일 수 있기 때문에 Preflight Request를 이용하여 사전에 확인함 - 모든 요청이 Preflight를 발생시키는 것은 아니고 위에 설명한 Simple Request의 조건을 만족한다면 Preflight가 발생하지 않음
✔️ (참고)인터셉터와 함께 사용한다면 주의해야 한다.
Authorization Header, 토큰, 토큰 타입을 검증하는 로직이 있는 인터셉터가 있습니다.
바로 여기서 CORS 에러가 발생했다.
클라이언트에서 서버로 요청을 보낼 때 브라우저는 접근 권한이 있는지 Preflight Request를 보내 확인한다. 그런데 Preflight Request에는 Authorization 헤더와 토큰이 없다.
따라서 다음 로직을 추가한다.
package com.cafein.backend.global.interceptor;
import java.util.Objects;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import com.cafein.backend.global.error.ErrorCode;
import com.cafein.backend.global.error.exception.AuthenticationException;
import com.cafein.backend.global.jwt.constant.TokenType;
import com.cafein.backend.global.jwt.service.TokenManager;
import com.cafein.backend.global.util.AuthorizationHeaderUtils;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {
private final TokenManager tokenManager;
@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response,
final Object handler) {
if (isPreflightRequest(request)) {
return true;
}
// Authorization Header 검증
String authorizationHeader = request.getHeader("Authorization");
AuthorizationHeaderUtils.validateAuthorization(authorizationHeader);
// 토큰 검증
String token = authorizationHeader.split(" ")[1];
tokenManager.validateToken(token);
// 토큰 타입 검증
Claims tokenClaims = tokenManager.getTokenClaims(token);
String tokenType = tokenClaims.getSubject();
if (!TokenType.isAccessToken(tokenType)) {
throw new AuthenticationException(ErrorCode.NOT_ACCESS_TOKEN_TYPE);
}
return true;
}
private boolean isPreflightRequest(final HttpServletRequest request) {
return isOptions(request) && hasHeaders(request) && hasMethod(request) && hasOrigin(request);
}
private boolean isOptions(HttpServletRequest request) {
return request.getMethod().equalsIgnoreCase(HttpMethod.OPTIONS.toString());
}
private boolean hasHeaders(HttpServletRequest request) {
return Objects.nonNull(request.getHeader("Access-Control-Request-Headers"));
}
private boolean hasMethod(HttpServletRequest request) {
return Objects.nonNull(request.getHeader("Access-Control-Request-Method"));
}
private boolean hasOrigin(HttpServletRequest request) {
return Objects.nonNull(request.getHeader("Origin"));
}
}
Request with Credential
HTTP Cookie
와 HTTP Authentication
정보를 인식할 수 있게 해주는 요청
🤔무슨 말일까?
클라이언트에서 서버에게 자격 인증 정보(Credential)
를 실어 요청할때 사용되는 요청이다.
여기서 말하는 자격 인증 정보란 쿠키(Cookie) 혹은 Authorization 헤더에 설정하는 토큰 값 등을 일컫는다.
즉, 클라이언트에서 일반적인 JSON 데이터 외에도 쿠키 같은 인증 정보를 포함해서 다른 출처의 서버로 전달할 때 CORS 요청 중 하나인 인증된 요청으로 동작한다는 말이며, 기존의 Simple Request나 Preflight Request과는 살짝 다른 인증 형태로 통신하게 된다.
기본적으로 브라우저가 제공하는 요청 API 들은 별도의 옵션 없이 브라우저의 쿠키와 같은 인증과 관련된 데이터를 함부로 요청 데이터에 담지 않도록 되어있다.
이때 요청에 인증과 관련된 정보를 담을 수 있게 해주는 옵션이 바로 credentials
옵션이다. 이 옵션에는 3가지의 값을 사용할 수 있으며, 각 값들이 가지는 의미는 아래와 같다.
옵션 값 | 설명 |
---|---|
same-origin(기본값) | 같은 출처 간 요청에만 인증 정보를 담을 수 있다. |
include | 모든 요청에 인증 정보를 담을 수 있다. |
omit | 모든 요청에 인증 정보를 담지 않는다. |
만일 이러한 별도의 설정을 해주지 않으면 쿠키 등의 인증 정보는 절대로 자동으로 서버에게 전송되지 않는다.
그렇다면 서버에서는..?
- 응답 헤더의
Access-Control-Allow-Credentials
항목을 true로 설정 - 응답 헤더의
Access-Control-Allow-Origin
의 값에 와일드카드 문자(”*”) 사용 불가 - 응답 헤더의
Access-Control-Allow-Methods
의 값에 와일드카드 문자(”*”) 사용 불가 - 응답 헤더의
Access-Control-Allow-Headers
의 값에 와일드카드 문자(”*”) 사용 불가
서버는 Response Header에 반드시 Access-Control-Allow-Credentials: true
를 포함해야 하고, Access-Control-Allow-Origin
헤더의 값에는 *
가 오면 안되고 구체적인 도메인이 와야 한다.
Request without Credential
CORS 요청은 기본적으로 Non-Credential 요청이므로, xhr.withCredentials = true
를 지정하지 않으면 Non-Credential 요청이다.
CORS 관련 HTTP Response Headers
서버에서 CORS 요청을 처리할 때 지정하는 헤더
Access-Control-Allow-Origin
Access-Control-Allow-Origin 헤더의 값으로 지정된 도메인으로부터의 요청만 서버의 리소스에 접근할 수 있게 한다.
<origin>
에는 요청 도메인의 URI를 지정한다.
모든 도메인으로부터 서버 리소스 접근을 허용하려면 *
를 지정한다. Request With Credential의 경우에는 *
를 사용할 수 없다.
Access-Control-Expose-Headers
기본적으로 브라우저에게 노출이 되지 않지만, 브라우저 측에서 접근할 수 있게 허용해주는 헤더를 지정한다.
기본적으로 브라우저에게 노출이 되는 HTTP Response Header는 아래의 6가지 밖에 없다.
- Cache-Control
- Content-Language
- Content-Type
- Expires
- Last-Modified
- Pragma
다음과 같이 Access-Control-Expose-Headers 를 Response Header에 지정하여 회신하면 브라우저 측에서 커스텀 헤더를 포함하여, 기본적으로는 접근할 수 없었던 Content-Length 헤더 정보도 알 수 있게 된다.
Access-Control-Max-Age
Preflight Request의 결과가 캐시에 얼마나 오래 남아있는지를 나타낸다.
Access-Control-Allow-Credentials
Request With Credential 방식이 사용될 수 있는지를 지정한다.
예비 요청에 대한 응답에 Access-Control-Allow-Credentials: false를 포함하면, 본 요청은 Request with Credential을 보낼 수 없다.
Simple Request에 withCredentials = true가 지정되어 있는데, Response Header에 Access-Control-Allow-Credentials: true
가 명시되어 있지 않다면, 그 Response는 브라우저에 의해 무시된다.
Access-Control-Allow-Methods
예비 요청에 대한 Response Header에 사용되며, 서버의 리소스에 접근할 수 있는 HTTP Method 방식을 지정한다.
Access-Control-Allow-Headers
예비 요청에 대한 Response Header에 사용되며, 본 요청에서 사용할 수 있는 HTTP Header를 지정한다.
설정 예시는 다음과 같다.
# 헤더에 작성된 출처만 브라우저가 리소스를 접근할 수 있도록 허용함.
# * 이면 모든 곳에 공개되어 있음을 의미한다.
Access-Control-Allow-Origin : https://naver.com
# 리소스 접근을 허용하는 HTTP 메서드를 지정해 주는 헤더
Access-Control-Request-Methods : GET, POST, PUT, DELETE
# 요청을 허용하는 해더.
Access-Control-Allow-Headers : Origin,Accept,X-Requested-With,Content-Type,Access-Control-Request-Method,Access-Control-Request-Headers,Authorization
# 클라이언트에서 preflight 의 요청 결과를 저장할 기간을 지정
# 60초 동안 preflight 요청을 캐시하는 설정으로, 첫 요청 이후 60초 동안은 OPTIONS 메소드를 사용하는 예비 요청을 보내지 않는다.
Access-Control-Max-Age : 60
# 클라이언트 요청이 쿠키를 통해서 자격 증명을 해야 하는 경우에 true.
# 자바스크립트 요청에서 credentials가 include일 때 요청에 대한 응답을 할 수 있는지를 나타낸다.
Access-Control-Allow-Credentials : true
# 기본적으로 브라우저에게 노출이 되지 않지만, 브라우저 측에서 접근할 수 있게 허용해주는 헤더를 지정
Access-Control-Expose-Headers : Content-Length
CORS 관련 HTTP Request Headers
클라이언트가 서버에 CORS 요청을 보낼 때 사용하는 헤더로, 브라우저가 자동으로 지정하며, XMLHttpRequest를 사용하는 프로그래머가 직접 지정해 줄 필요 없다.
Origin
Cross-site 요청을 날리는 요청 도메인 URI을 나타내며, acess control이 적용되는 모든 요청에 Origin
헤더는 반드시 포함된다.
Origin
은 서버 이름(포트 포함)만 포함되며 경로 정보는 포함되지 않는다.
Origin
은 공백일 수도 있는데, 소스가 data URL일 경우에 유용하다.
Origin : Protocal + Host + Port
Access-Control-Request-Method
예비 요청을 보낼 때 포함되어, 본 요청에서 어떤 HTTP Method를 사용할 지 서버에게 알려준다.
Access-Control-Request-Headers
예비 요청을 보낼 때 포함되어, 본 요청에서 어떤 HTTP Header를 사용할 지 서버에게 알려준다.
CORS 보안 취약점 예방 가이드
- Access-Control-Allow-Origin 헤더의 와일드카드(*) 출처 사용 금지
- Origin 요청 헤더의 값을 그대로 사용 금지
/* 이렇게 하지 말자 */
@Component
public class SimpleCorsFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
// ...
}
// ...
}
- Null 출처 허용 금지
- 정규식으로 처리할 것이라면 조심할 것. 완벽한 검증이 요구된다.
- 화이트 리스트 사용
- 결국 어딘가의 배열이나 리스트에 허용할 출처들을 저장해놓고 관리하는 것이 가장 베스트이다.
- 따라서 요청을 전송한 출처가 화이트 리스트에 있는 도메인 목록에 있는 경우에만 Access-Control-Allow-Origin 헤더에 해당 출처를 지정하는 식으로 백엔드 개발자는 하드 코딩을 조금 해야한다.
클라이언트에서 미리 origin 헤더값을 변조하면?
→ 브라우저에서 이를 감지하여 차단하기 때문에 불가능하다.
유용한 팁
서버에서 CORS 테스트하는법
참고로 http:// 도 추가해주어야 한다.