[서버개발캠프] Spring boot + Spring security + Refresh JWT + Redis + JPA 4편: 로그인 유지와 로그아웃 처리

Sieun Sim·2020년 1월 21일
2

서버개발캠프

목록 보기
8/21

Post request에 header 붙이기

axios를 사용한 post request에서 header가 자꾸 request body로 들어가는 문제점이 있었다. 형식에 딱 맞춰야 하는 것 같은데 풀어서 쓰니까 계속 안됐다. 그냥 headers 따로, data 따로 변수로 선언해준 다음에 넣어주면 잘된다.

    const headers = {
    	'Content-Type': 'application/json',
    	'Authorization' : "Bearer "+ cookie.load('access-token')
    };
    const data = {
    	accessToken: cookie.load('access-token')
    };
    axios.post("http://localhost:8080/newuser/out", data, {
    	headers: headers
    })

    HttpServletRequest request ~
    if (request.getMethod().equals("POST")) {...}

등등 필터에서 파라미터로 받은 HttpServletRequest 객체에서 정보를 꺼내 이것저것 처리할 수 있다.

로그인 유지 처리

"안녕하세요, 000님" 헤더를 처리하기 위한 방법을 생각해봤다.

  • filter에서 header의 토큰을 보고 response header에 username 알려주기

    구현하기는 간단한데

      response.setHeader("username", username);

    보안상 괜찮은건지를 잘 모르겠다

  • 쿠키의 토큰을 직접 파싱해서 쓰기

    이것도 쉽긴 한데 만료된 토큰일 수도 있고 서버가 컨펌한적도 없는데 계속 로컬 토큰에서 정보를 가져오는게 찝찝하다.

일단은 프론트에서 페이지가 렌더될 때 미리 POST Body로 토큰을 보내서 응답으로 username을 받아오는 식으로 처리했다. 이 때 access token은 만료됐지만 refresh token이 살아있는 경우에 대해서도 클라이언트에서 알아서 요청하도록 처리했다. refresh token 쓰니까 예외처리가 두배 ^^!

  • 컨트롤러

    @PostMapping(path="/newuser/check")
        public Map<String, Object> checker(@RequestBody Map<String, String> m) {
            String username = null;
            Map<String, Object> map = new HashMap<>();
            try {
                username = jwtTokenUtil.getUsernameFromToken(m.get("accessToken"));
            } catch (IllegalArgumentException e) {
                logger.warn("Unable to get JWT Token");
            } catch (ExpiredJwtException e) {}
            if (username != null) {
                map.put("success", true);
                map.put("username", username);
            } else {
                map.put("success", false);
            }
            return map;
        }
  • 프론트의 componentDidMount()

    componentDidMount(){
            axios.post("http://localhost:8080/newuser/check", data, {
                headers: headers
               }).then(res => {
                console.log(res);
                if (res.data.success) {
                    this.setState({
                        isNormal:true,
                        username : res.data.username
                    });
                }  else {
                    this.setState({
                        isNormal:false
                    });
                    console.log("cannot validate access token. trying to get new..");
                    this.requestAccessToken();
                }
            }).catch(e => {
                this.setState({
                    isNormal:false
                });
                console.log(e);
            })
        }
  • 프론트의 Access token 재요청 함수
    requestAccessToken = () => {
            axios.post("http://localhost:8080/newuser/refresh", {
                accessToken: this.state.accessToken,
                refreshToken: cookie.load('refresh-token')
            }).then(res => {
                if (res.data.success) {
                    //console.log("success to refresh token to: " + res.data.accessToken);
                    cookie.save('access-token', res.data.accessToken, { path: '/' })
                    this.setState({
                        isNormal: true
                    })
                } else {
                    alert("로그인연장 실패로 로그인이 필요합니다.");
                    console.log("failed to refresh access token. You need re-login.");
                    this.setState({
                        isNormal: false
                    })
                }
            }
            ).catch(e => {
                console.log(e);
            })
        }

로그아웃 처리

한번 손을 떠난 토큰에 대해서는 바꿀 수 없다는게 토큰계의 국룰인 것 같다. 그래서 아직 만료되지 않았지만 유저의 로그아웃으로 인해 더이상 로그인되지 않아야 할 때를(쿠키 삭제와는 또 다르다.) 위해 로그아웃 요청한 토큰을 Blacklist로 캐싱해두기로 했다. 매 요청마다 로그아웃한 토큰인지 확인과정이 추가되는게 걱정되기는 하지만 redis가 엄청나게 빠르다니까 괜찮을 것 같다.

로그아웃한다는건 Refresh token과 access token을 모두 다시 받아야 한다는 소리이므로 access token은 블랙리스트로, refresh token은 redis에 저장해두었던 것을 삭제만 하면 된다.

Redis의 사용

Redis를 쓸 일은

  1. User의 Refresh token 저장
  2. 로그아웃한 access token Blacklist 생성

이 둘인데, 생각해보면 1번은 key를 username으로 두면 되고 2번은 토큰의 존재 여부만 알면 된다(사실 key도 필요없는 것 같다). 그래서 key의 형태가 절대 겹치지 않는다. redis의 database는 인덱스로 접근할 수 있다고 한다. Redis를 RDBMS에서 쓰듯이 Blacklist table, refresh token table처럼 쓰고 싶었지만 select 를 사용해 데이터베이스를 아예 따로 두거나, key 자체를 blacklist와 token으로 두고 hashmap으로 blacklist key 하나, token key 하나로 거대한 object value로 쓸 수 밖에 없는 것 같아서 굳이 나누지 않기로 했다. 0~12개의 인덱스 중에서 0번이 기본 database라고 하며 다른 데이터베이스를 쓰려면 LettuceConnectionFactory.setDatabase(index) 를 사용해 설정해주어야 한다. 근데 또 여러개를 함께 쓰려면 redis 설정 처음부터 나누어야 해서 골이 아프더라.. 나는 그냥 0번 데이터베이스에 key,value 형태로만 사용했다. redisTemplate.opsForValue() 로 set이나 get을 통해 쉽게 접근할 수 있다.

Refresh token 제거하기

redisTemplate을 사용해 간단하게 제거했다.

try {
            if (redisTemplate.opsForValue().get(username) != null) {
                //delete refresh token
                redisTemplate.delete(username);
            }
        } catch (IllegalArgumentException e) {
            logger.warn("user does not exist");
        }

Access token Blacklist에 올리기

일단 내 access token 만료기간과 같은 10분으로 설정했다. 사실 남은 시간을 계산해서 넣는게 정확하다.

//cache logout token for 10 minutes!
redisTemplate.opsForValue().set(accessToken, true);
redisTemplate.expire(accessToken, 10*6*1000, TimeUnit.MILLISECONDS);

Filter에 로그아웃 했는지 확인하는 부분 추가

모든 조건을 충족해야 Authentication을 부여할것이기 때문에 그냥 조건 하나로 뺐다.

if (username == null) {
            logger.info("token maybe expired: username is null.");
        } else if (redisTemplate.opsForValue().get(jwtToken) != null) {
            logger.warn("this token already logout!");
        } else {
            //DB access 대신에 파싱한 정보로 유저 만들기!
            Authentication authen =  getAuthentication(jwtToken);
            //만든 authentication 객체로 매번 인증받기
            SecurityContextHolder.getContext().setAuthentication(authen);
            response.setHeader("username", username);
        }

Spring Security의 기본 logout 기능?

여기서 또 기본 설정을 고쳐서 쓸 생각을 하니까 막막.. 어차피 handler를 따로 작성해 적용시킨다고 해도 post mapping으로 따로 빼두는 것과 하는일은 큰 차이가 없을 것 같다. 그래서 그냥 기본 설정을 안써보기로 했다.

  • 컨트롤러 full code
    @PostMapping(path="/newuser/out")
        public ResponseEntity<?> logout(@RequestBody Map<String, String> m) {
            String username = null;
            String accessToken = m.get("accessToken");
            try {
                username = jwtTokenUtil.getUsernameFromToken(accessToken);
            } catch (IllegalArgumentException e) {} catch (ExpiredJwtException e) { //expire됐을 때
                username = e.getClaims().getSubject();
                logger.info("username from expired access token: " + username);
            }
    
            try {
                if (redisTemplate.opsForValue().get(username) != null) {
                    //delete refresh token
                    redisTemplate.delete(username);
                }
            } catch (IllegalArgumentException e) {
                logger.warn("user does not exist");
            }
    
            //cache logout token for 10 minutes!
            logger.info(" logout ing : " + accessToken);
            redisTemplate.opsForValue().set(accessToken, true);
            redisTemplate.expire(accessToken, 10*6*1000, TimeUnit.MILLISECONDS);
    
            return new ResponseEntity(HttpStatus.OK);
        }

0개의 댓글