개인적인 공부 용으로 정리를 해놓겠다.
원래 블로그에 글을 비공개로 올리곤 했었는데, 생각해보니 부족한 글이라도... 혹시 누군가에겐 도움이 되지 않을까 하여,
공개하는 일기 같은 느낌으로... 쓰겠다.
일단 백엔드에서 JWT Token 을 발급하도록 개발을 해놨다.
스프링 부트로 개발돼있다.
1. JWT 토큰에 대하여..
JWT 토큰에 대한 건 이 블로그에 잘 정리돼있다.
초 간단 정리
JWT 는 Json Web Token 의 약자로, 웹표준으로서 두 개체에서 JSON 객체를 사용하여 가볍고 자가수용적인 방법으로 정보를 안정성 있게 전달해준다.
웹 표준이고 유명하기 때문에 당연히 여러 프로그래밍 언어에서 지원이 된다.
자가 수용적이란 말은 토큰 자체가 갖고 있는 정보와 그 정보가 인증돼있다는 사실을 갖고 있다는 말이다.
모든 JWT 토큰은 자신이 어떤 토큰인지, 그리고 어떤 정보를 갖고 있는지를 토큰에 정보로 담고 있으며, 이것들을 서명한 값 또한 갖고 있다.
보통은 회원 인증 혹은 정보교류에 사용된다. (이건 JWT 토큰만 그런건 아니고)
헤더.내용.서명
이런 구조로 돼있다.
1. 헤더를 base64 로 인코딩 한다. (A) (헤더에는 어떤 방식으로 해싱했는지도 들어간다 (3번에서)
2. 내용을 base64 로 인코딩 한다. (B)
3. A + "." + B 를 "서버 비밀키" 로 해싱하고 base 64 로 인코딩한다. (C)
5. "A.B.C" 를 만들어준다.
이제 A.B.C 가 있을때, "A.B" 를 서버가 자신이 가진 비밀키로 해싱 해보면, C 가 나온다는 걸 알기만 하면 된다. (C의 원래 값)
이러면 서버는 뭘 알 수가 있는가?
아. A.B.C 의 정보가 바뀌지 않았구나! 이걸 알 수 있다.
이걸 알면 뭐가 된다? 그럼 이 토큰은 "조작" 된 건 아니란 걸 알 수 있다.
이 토큰은 조작되지 않았고, 이 토큰은 내가 클라이언트에게 준 토큰이니까, 이 토큰을 요청에 함께 보내 놈은 그 클라이언트겠구나. 하는 걸 인증할 수 있는 것이다.
참고로.. A 와 B 내용 모두 그냥 base64 인코딩이기 때문에... 이 토큰은 말 그대로, 해당 토큰의 대상이 누군지만 명시해야지, 엄청 중요한 정보를 담아 놓으면 안된다.
예를 들면 유저의 개인정보 같은 것들을 넣어놓으면, 사실 A,B 를 base64 -> 원래 문자열 로만 바꿔도 그냥 B 본문이 보이기 때문에..
핵심은. JWT 토큰은, 서버에서 이 토큰의 내용이 바뀌지 않았구나! 라는 걸 알 수 있을 뿐이다.
그리고 이 토큰의 A.B 는 누구나 인코딩만 풀면 볼 수 있기 때문에 너무 중요한 데이터를 넣어 놓으면 안된다.
JWT 토큰에 조작이 안됐네? 토큰의 B를 보니, 이거 userId 가 "aaa1234" 라고 돼있네? 아 지금 이 토큰을 담은 http 요청은 aaa1234 유저의 요청이구나! ->이게 다다.
2. 서버 부분은 어떻게 돼있는가?
너무 깊게 정리하지는 않아야 한다.
1. 토큰 프로바이더를 만든다. (토큰을 생성하는 놈을 만든다.)
2. 로그인 요청이 오면 낚아 채서, AuthenticationManager 로 인증하고, 해당 유저가 제대로 된 유저다? 싶으면 토큰 프로바이더로 토큰을 만들어 반환한다.
WebSecurityConfigurerAdapter 의 메소드를 오버라이드한 configure 를 보면..
@Autowired
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder);
}
요렇게 돼있다.
내가 만든 userDetailService 는
@Service
@Qualifier("custom_user_detail")
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
AccountRepository accountRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return accountRepository.findByUsername(username)
.map(t -> new UserDetailsImpl(t))
.orElseThrow(() -> new UsernameNotFoundException(username));
}
}
요런 식으로 돼있다. UserDetailsService 인터페이스를 상속한 클래스다.
하여간 저렇게 AuthenticationManager 가 내가 만든 UserDetailsService 를 사용하게 하고, passwordEncoding 설정도 해서 넣어준다.
3. JwtAuthenticationFilter 를 만들고 Spring Seucurity 의 Security Filter Chain 의 일원이 되게 해준다.
이 JwtAuthenticationFilter는 내가 만든 JwtTokenProvider 를 사용한다. 요청을 중간에 낚아채서 토큰이 들어있는지 확인하고, 유효한 토큰이면 오케이! 이놈 인증 됐으니까 들여보내!.. 요렇게 된다.
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private CustomUserDetailsService customUserDetailsService;
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(httpServletRequest);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Long userId = tokenProvider.getUserIdFromJWT(jwt);
UserDetailsImpl userDetails = customUserDetailsService.loadUserByUserId(userId);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer "))
return bearerToken.substring(7);
return null;
}
}
3. React.js 토큰 관리 부분 정리.
다른 로그인, 회원가입 부분은 사실상 "서버" 쪽의 역할이 크다. 서버쪽 역할은 뒤로 미뤄두고, 토큰을 어떻게 관리하는지만 정리한다.
일단 상수로 쓰일 변수만 따로 위와 같이 정리해 놓는다.
options 부분을 살펴보자.
options = Object.assign({}, defaults, options);
header 설정을 Authorization, Bearer ~~ 로 해준다.
그리고 이 defaults 헤더와 추가로 주어진 옵션을 합쳐서 fetch library 를 사용해 통신한다.
fetch library 는 react 에서 기본적으로 제공되는 라이브러리이다. 이 부분은 리액트 프론트 부분을 다시 정리하면서, axios 로 바꿀 것이다.
localStorage를 사용한다.
localStorage에 대한 정리는 이쪽 글에 잘 정리돼 있다.
https://www.zerocho.com/category/HTML&DOM/post/5918515b1ed39f00182d3048
로컬 스토리지(localStorage)와 세션 스토리지(sessionStorage)에 대해 알아보겠습니다. 이름만 봐도 각각의 기능이 뭔지 대충 알겠죠? 영어에 약하신 분들을 위해 간단히 설명드리자면, 로컬 스토리지와 세션 스토리지는 HTML5에서 추가된 저장소입니다. 간단한 키와 값을 저장할 수 있습니다. 키-밸류 스토리지의 형태입니다.
로컬 스토리지와 세션 스토리지의 차이점은 데이터의 영구성입니다. 로컬 스토리지의 데이터는 사용자가 지우지 않는 이상 계속 브라우저에 남아 있습니다. 하지만 세션 스토리지의 데이터는 윈도우나 브라우저 탭을 닫을 경우 제거됩니다. 지속적으로 필요한 데이터(자동 로그인 등)는 로컬 스토리지에 저장하고, 잠깐 동안 필요한 정보(일회성 로그인 정보라든가)는 세션 스토리지에 저장하면 되겠습니다. 하지만 비밀번호같은 중요한 정보는 절대 저장하지 마세요! 클라이언트에 저장하는 것이기 때문에 언제든지 털릴 수 있습니다.
두 스토리지는 모두 window 객체 안에 들어 있습니다. Storage 객체를 상속받기 때문에 메소드가 공통적으로 존재합니다. 도메인 별 용량 제한도 있습니다.(프로토콜, 호스트, 포트가 같으면 같은 스토리지를 공유합니다) 브라우저별로, 기기별로 다르긴 하지만 모바일은 2.5mb, 데스크탑은 5mb~10mb라고 생각하시면 됩니다.
메소드를 간단히 설명하자면, localStorage.setItem(키, 값)으로 로컬스토리지에 저장한 후, localStorage.getItem(키)로 조회하면 됩니다. localStorage.removeItem(키)하면 해당 키가 지워지고, localStorage.clear()하면 스토리지 전체가 비워집니다.
객체는 제대로 저장되지 않고 toString 메소드가 호출된 형태로 저장됩니다. [object 생성자]형으로 저장되는 거죠. 객체를 저장하려면 두 가지 방법이 있습니다. 그냥 키-값 형식으로 풀어서 여러 개를 저장할 수도 있습니다. 한 번에 한 객체를 통째로 저장하려면 JSON.stringify를 해야됩니다. 객체 형식 그대로 문자열로 변환하는 거죠. 받을 때는 JSON.parse하면 됩니다.
세션 스토리지는 window.sessionStorage에 위치합니다. clear, getItem, setItem, removeItem, key 등 모든 메소드가 같습니다. 단지 로컬스토리지와는 다르게 데이터가 영구적으로 보관되지는 않을 뿐입니다.
------
매 서버 요청마다 서버로 쿠키가 같이 전송됩니다. 왜 서버에 쿠키가 전송될까요?
그것을 알기 위해서는 HTTP 요청에 대해 먼저 간단히 알아야 합니다. HTTP 요청은 상태를 가지고 있지 않습니다. 무슨 말인가 하면, 브라우저에서 서버로 나에 대한 정보를 가져오라는 GET /me라는 요청을 보낼 때, 서버는 요청 자체만으로는 그 요청이 누구에게서 오는 지 알 수 없습니다. 그래서 응답을 보낼 수가 없죠. 이 때 쿠키에 나에 대한 정보를 담아서 서버로 보내면 서버는 쿠키를 읽어서 내가 누군지 파악합니다. 쿠키는 처음부터 서버와 클라이언트 간의 지속적인 데이터 교환을 위해 만들어졌기 때문에 서버로 계속 전송되는 겁니다.
----
앱의 가장 상단. App.js 에서 토큰이 있으면 로그인 돼있는지 확인하고, 토큰이 없으면 로그인 되지 않았다고 판단한다.
export function getCurrentUser() {
if (!localStorage.getItem(ACCESS_TOKEN)) {
return Promise.reject("No access token set.");
}
return request({
url: API_BASE_URL + "/user/me",
method: "GET"
});
}
위는 해당 행위를 하는 함수다.
fetch 는 기본적으로 Promise 기반이다. 위와 같이, 토큰이 없을경우 바로 Promise.reject(); 해준다.
토큰이 있으면 유저 정보를 확인한다.
loadCurrentUser() {
this.setState({
isLoading: true
});
getCurrentUser()
.then(response => {
this.setState({
currentUser: response,
isAuthenticated: true,
isLoading: false
});
console.log(this.state);
})
.catch(error => {
console.log(error);
this.setState({
isLoading: false
});
});
}
componentDidMount() {
this.loadCurrentUser();
}
getCurrentUser() 에서 반환하는 Promise 에 대하여 위와 같이 작성을 해준다.
componentDidMount() 생성주기 콜백을 보면, mount 되면 바로 loadCurrentUser를 실행하는 것을 볼 수 있다.
결국 간단히 정리하자면
1. 앱의 상단에서 토큰이 있는지 확인하고, 있다면 유효한 토큰인지 확인해서 유저 정보를 가져오고, 토큰이 없으면 그냥 로그인 안된 것으로 간주한다.
2. 로그인 버튼을 클릭해서, 로그인 화면으로 넘어가고... 아이디 비밀번호를 입력해서 로그인 하면, 토큰을 받고. 토큰을 받으면 그걸 localStorage에 저장해주고... 로그인 됐다는 것을 처리 해준다.
handleLogin() {
console.log("login Success");
this.loadCurrentUser();
this.props.history.push("/");
}
로그인이 성공했을 때 실행되는 함수는 위와 같이 해놨다. 일단 localStorag에 토큰을 저장해놓고, handleLogin() 함수를 실행하면, loadCurrentUser를 재실행 하면서, localStorage에 있는 토큰으로 다시 한번 토큰이 유효한지 확인 후 유저 정보를 가져온다.
'개발' 카테고리의 다른 글
스프링 서버 구축 순서 간단 정리 (0) | 2020.01.30 |
---|---|
데이터 구조 간단 정리 (0) | 2020.01.19 |
스프링 기본 정리(4) AOP (0) | 2020.01.19 |
스프링 기본 정리(3) 간단한 트랜잭션 정리 (1) | 2020.01.19 |
스프링 기본 정리(2) 스프링의 IoC, 애플리케이션 컨텍스트에 대한 정리 (0) | 2020.01.18 |