ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • spring security oauth2 로그인 구현 (개발 로그)
    project 2022. 3. 30. 18:56

    # Intro

    리액트 + 스프링 부트 + aws 로 구성된 app 에 소셜 로그인 기능을 구현하고 있다. 개인적으로 이번 프로젝트에서 가장 어려웠고 그 이유는 낯설어서 그런 것도 있고 참고할 문서를 잘 찾지 못해서 그런 점도 있다.

     

    우선 프로젝트 구성은 하나의 ec2 에 frontend, backend 가 다 있는 상황이고 NGINX 로 포워딩하는 상황. 아직 개발단계(3월초 기준)이고 백엔드 api 서버를 프론트엔드에서 localhost 로 접근하도록 하는 상황이다.

     

     

    # JWT 를 통한 로그인 구현

    처음엔 session 으로 구현했었다. 그러다가 jwt 를 알게 되었고, jwt 로 구현하기로 했다. 그렇게 한 이유는 사실 session 이 redirect 될 때 안담기길래 이것 때문인지 알고 바꾸었다. (사실 아니었지만) cloud 에서 ec2 를 여러개 띄우는 microservice architecture 에서 jwt 로 하면 좀더 편하다고 한다. Session 을 쓰면 서버사이드 스토리지에 저장해야 하는데, centralized redis 같은데에 저장하지 않는이상 불편한 점이 많다.(동기화 이슈 등) 인증 체크를 cpu 레벨에서(db, in-memory 거치지 않고) 처리할 수 있다는 것도 장점이다. Jwt 의 단점중에 와닿았던 점이, jwt 를 탈취당했을 때 인증된 유저인척 하는 공격을 서버사이드에서 제어할 수 없다는게 단점이었는데, 단점을 mitigate 하기 위해 토큰을 두가지 타입으로 발행하고, (access, refresh) 전자의 lifetime 을 짧게 하고 후자를 마치 session 처럼 서버에 담아두는 개념이다. 이렇게 하면 attacker 가 jwt 로 나쁜짓을 할 때 refresh token 을 서버에서 삭제하면 되기 때문에 ‘어느정도’ 제어가 가능하다. (일단 복잡하지 않도록 refresh token 은 구현하지 않았다.)

     

    jwt 로 구현하는 게 맞는진 모르겠다. -> jwt, session 둘 다 선택의 문제인 것 같다. 다만 공부가 되므로 jwt 로 만들어 보고, 리펙토링 할 때 세션으로 바꾸는 것이 좋을 듯 하다. 기본 작동은 session 이고 security context 에 authentication 을 넣을 때 저장되며 설정을 통해 저장되지 않게 할 수 있다. 이렇게 설정하고 jwt 로 인증 유지를 하도록 했다.

     

    OAuth2 의 과정을 거친 후 api server로 리다이렉트 해서 successhandler 에서 jwt 를 발행하고, 클라이언트에게(브라우저) 전달하기로 했다. 클라이언트가 토큰을 다시 서버에게 주면 토큰을 검사하고 authenticate 한다. authorization 은 따로 하지 않고 authenticate 를 통과하면 올바른 유저로 설정했다. 다시 말해, authorization type 가 1개이다. 간단한 application 에서 이런 방식을 선택할 수 있다. 

     

    토큰을 클라이언트에게 어떻게 전달할지가 고민이었는데, 1. redirection uri 의 query parameter 로, 2. cookie 로 전달할 수 있었다. 전자의 경우 백엔드가 편하지만 프론트엔드가 귀찮고, 탈취가 좀더 쉬울거 같으며, uri 가 이상해진다는 단점이 있다. 팀원들과의 이야기 후에 후자의 방법으로 하기로 했다.

     

    jwt 를 cookie 에 심는 것도 꽤 어려웠다. 크롬 정책에 의해 same-site 가 none 일 경우 https 에서만 가능하다고 한다. 일단 개발 단계고 api server 를 aws 에, 프론트엔드는 localhost:3000 에서 개발하고 있었다. 쿠키는 different domain 에서 심기가 불가능하다.(subdomain 은 가능) 개발 단계에서 localhost 는 explicit domain 에서 쿠키 심기가 가능하고, 배포 단계에서는 다른 port 를 쓸 거라서 different origin 이다. 따라서 different origin 에서의 cookie 심기가 가능해야 한다. 그러기 위해 backend api server 를 https 로 바꿀 필요가 있었다.

     

    # 서버 https 로 바꾸기

    서버를 dns 를 적용해서 https 로 바꾸면서 마주친 오류의 해결 과정이다.

    1. aws 서버에 배포하고 nginx proxy_pass 기능을 이용해서 https://www.nbbang.shop/api/* 로 들어오는 request 를 http://localhost:8094/* 로 포워딩함. 하나의 ec2 안에 nginx, was 가 다 있어서 이런 구성을 함. -> 원래 따로따로 하는게 맞는거 같다. 프론트 서버, was 여러개 이런 식으로 구성하면 좋았을 거 같지만 aws 프리티어는 1개만 사용 가능하다.
    2. location /api/ { add_header proxied nginx; proxy_set_header Host $http_host; proxy_pass http://localhost:8094/;}
    3. https://www.nbbang.shop/api/oauth2/authorization/google 로 request 를 할 경우 구글 로그인 페이지로 리다이렉트 될 것으로 예상함. 400 오류: redirect_uri_mismatch 를 마주쳤고, redirect uri 가 localhost:8094/... 로 가는것을 확인함.
    4. application.properties 에 다음을 추가함. localhost:8094 에서 localhost 로 바뀜
    server:
      forward-headers-strategy: FRAMEWORK
    (<https://stackoverflow.com/questions/60365017/redirect-url-for-spring-oauth2-app-on-azure-with-active-directory-invalid-redir>)
    
    1. application.properties 에 redirect_uri 를 추가함.
    2. nginx.conf 에 **proxy_set_header** Host $http_host; 를 추가함.

    https://blog.davidvassallo.me/2018/08/10/lessons-learnt-of-spring-boot-oauth2-redirect-uris/

    1. redirect 가 http://www.nbbang.shop/api/login/oauth2/code/google 로 되는것을 확인함. https 가 되어야 함. → 3번을 추가하니 해결되었음
    2. 이번엔 redirect 가 https://www.nbbang.shop/oauth2/authorization/google 로 들어옴. redirecturi 를 /api/... 로 하드코딩 해주었음. → 무한루프 발생함. auth 를 하기위해 들어간 uri 가 auth 가 필요하므로 auth 를 호출해서 무한루프가 발생한다.
    3. 잘 안되서 뭐때매 안되지?? 고민하고 고민... 생각해보니까 /api는 사실 NGINX 에서 localhost:8094/* 로 리다이렉트 되는거지 localhost:8094/api/* 로 리다이렉트 되는게 아니다. 그러나 구글에 갔다 온 redirect uri 가 올바른 주소로 찾아와야 하므로 이걸 잘 적어줘야 한다. application.properties 에 redirect uri 를 https://www.nbbang.shop/api/login... 으로 적어주고, SecurityConfig 는 localhost 에 대한 configuration 이므로 따로 적어주지 않았다. (기본값) 이렇게 했더니 리다이렉션이 됬다

    ... 아키텍처를 먼저 이해하고 uri 를 구성했어야 했다. 어떤 포트에 어떤 서버를 열었는지, NGINX 로 포워딩을 어떻게 했는지 이해하고 나니까 문제가 해결되었다.

     

    # 다시, cookie 심기

    이제 backend api server 가 https 로 바뀌었다. Cookie 를 심을 것이고 이를 테스팅하기 위해 vue 코드를 구성하여 localhost 에 띄우고, 잘 작동하는지 알아보았다.

    • 클라이언트에서 header 에 보냈는데 access-control-request-headers 로 들어온다. → preflight request 라고 하고 OPTIONS method 를 이용한다고 한다. 프론트엔드에서 withCredentials = true 로 설정하면 된다.

    Cookie 를 explicit domain 에서 다른 domain 에 심는 것은 불가능하고 localhost 는 가능하고 배포할때는 같은 domain 이라 문제 없을것이다. Explicit domain (여기서 https://www.nbbang.shop) 에서 localhost 에 cookie 를 심을 건데, set-cookie domain 을 “” 로 해줘야 한다. (localhost, 127.0.0.1 로 했을 땐 안됬다)

    여기까지 일단 1차적으로 로그인이 구현되었다. 로직 흐름을 살펴보면 다음과 같다.

     

    # Workflow

    OAuth2 인증 과정

    1. <a href='https://www.nbbang.shop/api/oauth2/authorization/google?redirect_uri=...'>url</a> 로 클릭하여 authorization 페이지로 이동.
    2. authorization 페이지 (https://www.nbbang.shop/api/oauth2/authorization/google) 에서 사용자가 로그인을 완료하고, redirect uri (https://www.nbbang.shop/api/login/oauth2/code/google) 로 authentication code 를 전달함.
    3. NGINX 를 거치고 /api/.. 가 localhost:8094 로 forwarding 되어 api 서버에 도착함. api 서버는 authentication code 를 받아서 google resource server 에 개인정보를 요청(OAuth2UserRequest)함.
    4. google resource server 는 요청의 authentication code (OAuth2AccessToken 객체) 을 확인하고, 개인정보를 담아서 준다. OAuth2UserService 에서 받아서 ProviderDetails 에 담긴 내용을 확인할 수 있다.
    5. 개인정보를 데이터베이스에 load or save 하고 (메소드 이름) 인증을 완료한다. (이 부분은 기본코드로 했다.) successhandler 에서 jwt 를 생성해서 클라이언트가 최초에 보내준 request uri 의 뒤쪽에 달린 redirect_uri 에 response 에 쿠키로 심고 redirect 한다. Session 을 사용했다면 authentication 에 담길 때 session 이 생성되었을 것이다.

    인증 후 api 서버 사용 과정

    • 클라이언트는 쿠키를 받고 with credentials = true 로 설정하여 request 를 보낸다. 
    • 서버는 쿠키에서 jwt 를 꺼내 custom 으로 구현한 authentication filter 에서 valid 한지 확인한다. Valid 할 경우 jwt 에 담긴 유저 정보로 Authentication 객체를 생성하고, Security Context 에 넣는다. -> jwt를 마치 session 처럼 사용하기에 그렇다. Username + password 인증일 경우 authentication 을 집어넣는 과정에 authentication manager, authentication provider, userservice 클래스를 구현하던지 하는 과정이 추가로 필요하다. 여기서 구현한 authentication 은 얕게 되었다.
    • jwt 가 valid 하지 않다면 response 로 401 unauthorized 를 보낸다. 프론트엔드에서 이를 받아 로그인 페이지로 리다이렉트해서 다시 로그인을 처리할 수 있다.
    • 이후 필터 이후의 처리 과정에서 security context 에서 인증 정보를 꺼내서 사용할 수 있다.

     

    * Cookie: 브라우저 개발자 탭의 application 에서 확인할 수 있다. 백엔드에서 심을 수 있고 (프론트에서도 가능한가?) 지울 수 있다. Servlet 의 addCookie 기능을 사용하거나 addHeader 기능으로 심을 수 있다. 전자의 경우 sameSite 옵션을 지정할 수 없다. 다른 도메인에는 심을 수 없다고 하며 다른 origin 에는 sameSite = none / secure = true 로 설정해야 한다. localhost 에 심고 싶을 경우 domain 옵션을 127.0.0.1 로 설정해야 한다. (왜지) httpOnly 옵션을 설정하면 xss 를 방지할 수 있다고 한다. 어떻게 해도 attacker 가 탈취하는 것을 방지하긴 어려우니까, 중요한 정보를 담지 않아야 한다.

     

    # 로그아웃 기능 구현

    로그아웃 기능을 구현하려고 찾아보니까 3가지 방법이 있는듯 하다.

    1. blacklist 방법
    2. httponly = true 로 하고 서버에서 삭제하는 방법 (되나?)
    3. 클라이언트에서 삭제하는 방법

    (2번이 된다면 best 이기에 시도해봐야 겠다.. 안된다..)

    1번으로 구현했고 로그아웃을 하면 blacklist 로 토큰을 추가하고, 모든 요청에 대해 blacklist 에 있는지 확인해서 있다면 로그인 실패로 처리하는 것이다. 이를 위해 적합한 스토리지는 redis 이고, hashmap 으로 쓰는게 맞는거 같다. Api 서버가 여러개라면 centralized redis (또는 aws elastic cache 같은 거) 를 쓰는 게 좋을텐데 과금 문제도 있고.. ec2 내부에 redis 를 설치해서 사용했다. 이렇게 하면 jwt 장점이 없어진 것 같다. (학습용이니까 뭐..)

     

    Redis 를 쓸 때 주의점이라고 생각되는데, redis 에 저장된 데이터의 lifecycle 이 다르다는 점이다. 잘못 사용하면 로그아웃 했는데 data 가 문제가 생겨 다시 로그인된 상태로 바뀌는 일이 있을 수 있다. 로그인 뿐만 아니라 redis 쓸 때의 전반적인 주의점이라고 생각이 들었고, 이 경우엔 access token 과 lifecycle 을 같게 맞춰놓았다. 

     

    (정답은 3번이 맞는거 같다..)

    # 추가로 정리한 내용들

    시큐리티 필터는 WebAsyncManagerIntegrationFilter 부터 시작한다고 한다. permitAll 로 열어두면 이 필터부터는 작동이 안되고, 예를 들어 이 필터 뒤에 임의의 필터를 집어넣으면 작동하지 않는다. 따라서 임의의 필터를 넣고 싶으면 이 앞에 위치해야 한다.

     

    인터셉터는 MVC, channel 등등 여러가지가 있다. MVC 로 config 된 인터셉터에 뭘 넣어줘도 websocket 받는 api 에서 받을수 없다. 프로토콜이 달라서 그런건데, 스프링에서는 websocket 을 받는 인터셉터를 구현해 놨다. (HandshakeInterceptor)

     

    query string 으로 보내도 https 로 encrypt 하기 때문에 노출되었을 때 문제는 없다. 

    http only 옵션은 xss attack 을 방지하기 위한 것

    csrf attack 을 방지하기 위해 http security 옵션 중 csrf() 가 있고 프로젝트에선 기본 값을 사용했고 override 해서 정책을 변경할 수도 있다.

    cors 는 헤더로 설정하는 것이며 filter 를 거치면 이를 해결하도록 관심사를 분리하고, config 에서 해결하는 기능을 적어두면 된다. 

     

     

    # 마치며...

    스프링을 활용한 첫 팀프로젝트였고, 아직 갈길이 멀다. Spring security 를 활용하면서 스프링에 대해 조금 더 알게된 것 같다. Default 클래스가 구현되어 있고 필요할 때 일부분만 override 해서 쓰는 구조라던지, 인증/인가에 대한 기능을 몽땅 처리해주는 것, security context 가 thread-local 이라는거, di 받아서 아무데서나 꺼내서 지금 유저가 누구인지 파악하는 것 등 몇달 전에 인강으로 스프링 수업 들을 때는 무슨 말인지 잘 몰랐는데 조금 알게 된 것 같다. 

     

    기술을 쓸 때 이해하고 써야 한다는 생각을 가지고 있지만, 아직 갈길이 멀다. 공부하고 코드를 돌아보면 잘 이해하지 못하고 적은 부분이 계속 나온다. 더 공부해야 겠다는 생각이 들었다.

     

    # 추가 리펙토링 생각 (4/26)

    코드로 구현한 상당 부분이 이미 라이브러리로 제공되고 있다. Spring security 5.1 부터 jwt resource server 가 제공되었는데, 이를 이용하는 방식으로 리펙토링 하는것이 좋을 것 같다.

Designed by Tistory.