Part 7) Spring Web Security를 이용한 로그인 처리 (Ch 31)
*이 카테고리의 글은 <코드로 배우는 스프링 웹 프로젝트> 에서 각 파트별로 진행하는 프로젝트의 흐름을 보기 위해 각 장의 내용을 간단히 요약한 것이다.
* 출처: 코드로 배우는 스프링 웹 프로젝트 개정판, 구멍가게 코딩단, 남가람북스
Ch31 로그인과 로그아웃처리
- 스프링 시큐리티의 내부 구조는 상당히 복잡하지만 실제 사용은 약간의 설정만으로도 처리가 가능하다.
- 앞에서 설계한 몇 개의 URI에 대한 접근 제한을 통해서 간단히 스프링 시큐리티 적용 방법을 살펴보겠다.
31.1 접근 제한 설정
- security-context.xml에 아래와 같이 접근 제한을 설정한다.
- 특정한 URI에 접근할 때 인터셉터를 이용해서 접근을 제한하는 설정은 <security: intercept-url>을 이용한다.
- <security:intercept-url>은 pattern이라는 속성과 access라는 속성을 지정해야만 한다.
- pattern 속성은 말 그대로 URI의 패턴을 의미하고, access의 경우는 권한을 체크한다. 위의 경우 '/sample/member'라는 URI는 'ROLE_MEMBER'라는 권한이 있는 사용자만이 접근할 수 있다.
- access의 속성값으로 사용되는 문자열은 1) 표현식과 2) 권한명을 의미하는 문자열을 이용할 수 있다.
- <security:http>는 기본 설정이 표현식을 이용하는 것이다. 만일 단순한 문자열만을 이용하고 싶은 경우에는 use-expressions="false"를 지정한다.
- 아래 화면은 표현식을 사용하지 않은 경우에 권한을 지정하는 방식이다. (표현식을 사용하는 방식이 권장되므로 예제에서는 사용하지 않는다. 표현식에 대한 내용은 JSP 화면을 처리하는 부분에서 다룬다.) 표현식을 사용하지 않는다면 아래와 같은 형태로 작성할 수 있다. (예제에서는 사용하지 않는다.)
<security:http auto-config="true" use-expressions="false">
<security:intercept-url pattern="/sample/userPage" access="ROLE_MEMBER" />
<security:intercept-url pattern="/sample/puserPage" access="ROLE_MANAGER" />
<security:intercept-url pattern="/sample/adminPage" access="ROLE_ADMIN" />
- 설정을 변경하고 WAS를 실행한 후 '/sample/member'를 접근해보면 '/sample/all'과는 달리 '/sample/member'는 로그인 페이지로 강제로 이동하는 것을 볼 수 있다.
- 신기한 점은 '/login'에 해당하는 컨트롤러나 웹페이지를 제작한 적이 없다는 사실이다. 이것은 스프링 시큐리티가 기본적으로 제공하는 페이지인데, 현실적으로는 별도의 로그인 페이지를 제작해야만 하므로 테스트하는 과정에서만 사용할 만하다.
31.2 단순 로그인 처리
- 로그인 화면이 보여지기는 하지만 로그인을 할 수 없는 상황이므로, '/sample/member'에 접근할 수 있는 방법은 아무것도 없는 상황이다. 추가적인 설정을 통해서 지정된 아이디와 패스워드로 로그인이 가능하도록 설정을 추가해 본다.
- 스프링 시큐리티에서 명심해야 하는 사항 중 하나는 username이나 User라는 용어의 의미가 일반적인 시스템에서의 의미와 차이가 있다는 점이다.
- 일반 시스템에서 userid는 스프링 시큐리티에서는 username에 해당한다. 일반적으로 사용자의 이름을 username이라고 처리하는 것과 혼동하면 안된다.
- User라는 용어 역시 혼란의 여지가 있다. 스프링 시큐리티의 User는 인증 정보와 권한을 가진 객체이므로 일반적인 경우에 사용하는 사용자 정보와는 다른 의미이다.
- 예제에서는 이를 구분하기 위하여 시스템상의 회원 정보는 MemberVO 라는 클래스를 이용할 것이다. 단순히 로그인이 처리되는 것을 확인하기 위해서 메모리상에 문자열을 지정하고 이를 기준으로 동작하도록 설정해 본다.
- 인증과 권한에 대한 실제 처리는 UserDetailsService라는 것을 이용해서 처리하는데, XML에서는 다음과 같이 지정할 수 있다.
- 추가된 설정의 핵심은 'member'라는 계정 정보를 가진 사용자가 로그인을 할 수 있도록 하는 것이다. 위의 설정을 추가한 후에 WAS를 통해서 '/sample/member'로 접근해서 로그인하면 예상과 달리 에러가 발생한다.
- 실행 결과 에러는 'PasswordEncoder'라는 존재가 없기 때문에 발생한다.
- 스프링 시큐리티 5버전부터 반드시 PasswordEncoder라는 존재를 이용하도록 변경되었다. 스프링 시큐리티 4버전까지는 PasswordEncoder의 지정이 없어도 동작했지만, 5버전부터는 PasswordEncoder의 지정이 반드시 필요하다.
- 임시 방편으로 스프링 시큐리티 5버전에는 포맷팅 처리를 지정해서 패스워드 인코딩 방식을 지정할 수 있다. 만일 패스워드의 인코딩 처리 없이 사용하고 싶다면 패스워드 앞에 '{noop}' 문자열을 추가한다.
<security:user name="member" password="{noop}member" authorities="ROLE_MEMBER" />
- 다시 WAS를 실행해서 '/sample/member'에 접근하고 'member/member'로 로그인하면 아래와 같이 동작하는 것을 확인할 수 있다.
31.2.1 로그아웃 확인
- 스프링 시큐리티를 학습하다 보면 매번 로그아웃하고 새롭게 로그인을 해야 하는 상황이 자주 발생한다. 이에 대해서 가장 확실한 방법은 브라우저에서 유지하고 있는 세션과 관련된 정보를 삭제하는 것이다.
- 개발자 도구에서 Application 탭을 확인해 보면 'Cookies' 항목에 'JSESSIONID'와 같이 세션을 유지하는데 사용되는 세션 쿠키의 존재를 확인할 수 있다.
(JSESSIONID는 Tomcat에서 발행하는 쿠키의 이름이다. WAS마다 다른 이름을 사용한다.)
- 로그아웃은 JSESSIONID 쿠키를 강제로 삭제해서 처리한다.
- 쿠키를 삭제한 후에는 로그인이 필요한 URI를 다시 호출해 보면 로그인이 필요한 것을 확인할 수 있다.
- 예제에서 웹 화면을 이용하는 로그아웃은 조금 뒤에 처리하도록 한다.
31.2.2 여러 권한을 가지는 사용자 설정
- 정상적으로 로그인이 처리되는 것을 확인했다면 '/sample/admin'을 처리하도록 한다.
- '/sample/admin'은 'ROLE_ADMIN'이라는 권한을 가진 사용자가 접근할 수 있도록 지정하는데 사용자는 'ROLE_ADMIN'과 'ROLE_MEMBER'라는 2개의 권한을 가지도록 지정한다.
- 새롭게 추가된 <security:intercept-url>은 '/sample/admin'에 대한 접근을 설정한다.
- <security:user>에 추가된 admin 사용자는 'ROLE_MEMBER'와 'ROLE_ADMIN'이라는 2개의 권한을 가지도록 설정한다.
- Admin 계정을 가진 사용자는 '/sample/member'와 '/sample/admin' 모두에 접근할 수 있다.
31.2.3 접근 제한 메시지의 처리
- 특정한 사용자가 로그인은 했지만, URI를 접근할 수 있는 권한이 없는 상황이 발생할 수도 있다. 이 경우에는 접근 제한 에러 메세지를 보게 된다. - 예제의 경우 member라는 권한을 가진 사용자는 '/sample/member'에는 접근할 수 있지만, '/sample/admin'은 접근할 수 없다. 이 경우에는 아래와 같은 메세지를 보게 된다.
-org.zerock.controller에 CommonController 클래스를 생성해서 '/accessError'를 처리하도록 지정한다.
- Access Denied인 경우는 403 에러 메세지가 발생한다. JSP에서는 HttpServletRequest 안에 'SPRING_SECURITY_403_EXCEPTION' 이라는 이름으로 AccessDeniedException 객체가 전달된다.
- 브라우저에서 '/sample/admin' URI를 member/member 정보로 로그인한 사용자가 접근하는 경우 이전과 달리 에러 메세지 대신 accessError.jsp의 내용이 보이게 된다.
31.2.4 AccessDeniedHandler 인터페이스를 구현하는 경우
- <security:access-denied-handler error-page="/accessError" /> 와 같이 error-page만을 제공하는 경우에는 사용자가 접근했던 URI 자체의 변화는 없다. 위의 그림에서도 URI 자체는 '/sample/admin'으로 되어 있고, 화면의 내용은 '/accessError'에 해당하는 URI의 결과이다.
- 접근 제한이 된 경우에 다양한 처리를 하고 싶다면 직접 AccessDeniedHandler 인터페이스를 구현하는 편이 좋다.
- 예를 들어 접근 제한이 되었을 때 쿠키나 세션에 특정한 작업을 하거나 HttpServletResponse에 특정한 헤더 정보를 추가하는 등의 행위를 할 경우에는 직접 구현하는 방식이 더 권장된다.
- 예제를 위해 org.zerock.security 패지리르 생성하고 CustomAccessDeniedHandler 클래스를 추가한다.
- CustomAccessDeniedHandler 클래스는 AccessDeniedHandler 인터페이스를 직접 구현한다. 인터페이스의 메서드는 handle() 뿐이고 HttpServletRequest, HttpServletReponse를 파라미터로 사용하기 때문에 직접적으로 서블릿 API를 이용하는 처리가 가능하다.
- 위의 코드에서는 접근 제한에 걸리는 경우 리다이렉트 하는 방식으로 동작하도록 지정되었다.
- security-context.xml에서는 error-page 속성 대신 CustomAccessDeniedHandler를 빈으로 등록해서 사용한다.
- <security:access-denied-handler>는 erorr-page 속성과 ref 속성 둘 중 하나만을 사용한다.
- 위와 동일한 방식으로 '/sample/admin'에 member/member 계정으로 로그인하면 이전과 달리 '/accessError' 로 리다이렉트 되는 것을 확인할 수 있다.
31.3 커스텀 로그인 페이지
- 앞서 언급했듯이 스프링 시큐리티에서 기본적으로 로그인 페이지를 제공하기는 하지만, 현실적으로는 화면 디자인 등의 문제로 사용하기 불편하다. 때문에 거의 대부분의 경우 별도의 URI를 이용해서 로그인 페이지를 다시 제작해서 사용한다.
- 이를 이용하는 방식은 접근 제한 페이지와 유사하게 직접 특정 URI를 지정할 수 있다.
- login-page 속성의 URI는 반드시 GET 방식으로 접근하는 URI를 지정한다.
- org.zerock.controller 패키지의 CommonController에 '/customLogin'에 해당하는 메서드를 추가한다.
- loginInput()은 GET 방식으로 접근하고, 에러 메시지와 로그아웃 메시지를 파라미터로 사용할 수 있다.
- views 폴더에는 customLogin.jsp를 추가한다.
- customLogin.jsp를 보면 몇 가지 특이한 점들이 있다. 우선 <form> 태그의 action 속성값이 '/login'으로 지정되어 있다는 점이다.
- 실제로 로그인의 처리 작업은 '/login'을 통해서 이루어지는데 반드시 POST 방식으로 데이터를 전송해야만 한다.
- <input> 태그의 name 속성은 기본적으로는 username과 password 속성을 이용한다.
- 마지막의 <input type='hidden'> 태그는 조금 특이하게도 ${_csrf.parameterName}으로 처리한다. 이 EL값은 실제 브라우저에서는 '_csrf'라는 이름으로 처리된다. 브라우저에서 '페이지 소스 보기'를 해보면 아래와 같은 태그와 값이 생성된 것을 볼 수 있다. (이때의 value 값은 임의의 값이 지정된다.)
- 만일 사용자가 패스워드 등을 잘못 입력하는 경우에는 자동으로 다시 로그인 페이지로 이동하게 된다.
31.4 CSRF(Cross-site request forgery) 공격과 토큰
- 스프링 시큐리티에서 POST 방식을 이용하는 경우 기본적으로 CSRF 토큰이라는 것을 이용하게 된다.
- 별도의 설정이 없다면 스프링 시큐리티가 적용된 사이트의 모든 POST 방식에는 CSRF 토큰이 사용되는데 '사이트간 위조 방지'를 목적으로 특정한 값의 토큰을 사용하는 방식이다.
- CSRF 공격은 '사이트간 요청 위조'라고 번역될 수 있다. 서버에서 받아들이는 정보가 특별히 사전 조건을 검증하지 않는다는 단점을 이용하는 공격 방식이다. 실제로 2008년에 국내 인터넷 A 쇼핑몰이 이 기법으로 관리자 계정을 탈취해서 개인정보들이 유출되었다. CSRF를 이용해서 단순히 게시물의 조회수를 늘리는 등의 조작부터 피해자의 계정을 이용하는 다양한 공격이 가능하다.
- 예를 들어 인터넷에 A라는 사이트가 존재한다고 가정해보자. A 사이트에는 특정 사용자의 등급을 변경하는 URI가 존재하는 것을 공격자(attacker)가 알았고, 해당 URI에는 약간의 파라미터가 필요하다는 것을 알았다고 가정하자.
예) www.aaa.xxx/update? grade=admin&account=123
- 공격자는 A 사이트의 관리자(피해자)가 자주 방문하는 B 사이트에 <img> 태그나 <form> 태그를 이용해서 위의 URI를 추가한 게시물을 작성한다. (2008년의 경우 이메일의 링크를 통해서 A 사이트에 대한 공격이 이루어졌다.예) <form action='www.aaa.xxx/update? grade=admin&account=123'> <input type='submit' value='축 이벤트 당첨'> </form> 또는 <img src='www.aaa.xxx/update?grade=admin&account=123'>
- A 사이트의 관리자(피해자)는 자신이 평상시에 방문하던 B 사이트를 방문하게 되고 공격자가 작성한 게시물을 보게 된다. 이때 <img> 태그 등에 사용된 URI가 호출되고 서버에서는 로그인한 관리자의 요청에 의해서 공격자는 admin 등급의 사용자로 변경된다.
- A 사이트의 관리자(피해자)는 자신이 관리하던 A사이트에 로그인이 되어 있는 상태라면 A 사이트의 서버 입장에서는 로그인한 사용자의 정상적인 요청으로 해석된다.
- CSRF 공격은 서버에서 받아들이는 요청을 해석하고 처리할 때 어떤 출처에서 호출이 진행되었는지 따지지 않기 때문에 생기는 허점을 노리는 공격 방식이다.
- '사이트간 요청 위조'라고 하지만 현실적으로는 하나의 사이트 내에서도 가능하다. CSRF는 <img> 태그 등의 URI 등을 이용할 수 있기 때문에 손쉽게 공격할 수 있는 방법이 된다.
- CSRF 공격을 막기 위해서는 여러 방식이 존재할 수 있다. CSRF 공격자체가 사용자의 요청에 대한 출처를 검사하지 않아서 생기는 허점이기 때문에 사용자의 요청에 대한 출처를 의미하는 referer 헤더를 체크하거나, 일반적인 경우에 잘 사용되지 않고 REST 방식에서 사용되는 PUT, DELETE와 같은 방식을 이용하는 등의 방식을 고려해 볼 수 있다.
31.4.1 CSRF 토큰
- CSRF 토큰은 사용자가 임의로 변하는 특정한 토큰값을 서버에서 체크하는 방식이다.
- 서버에는 브라우저에 데이터를 전송할 때 CSRF 토큰을 같이 전송한다. 사용자가 POST 방식 등으로 특정한 작업을 할 때는 브라우저에서 전송된 CSRF 토큰의 값과 서버가 보관하고 있는 토큰의 값을 비교한다. 만일 CSRF 토큰의 값이 다르다면 작업을 처리하지 않는 방식이다.
- 서버에서 생성하는 토큰은 일반적으로 난수를 생성해서 공격자가 패턴을 찾을 수 없도록 한다.
- 공격자의 입장에서는 CSRF 공격을 하려면 변경되는 CSRF 토큰의 값을 알아야만 하기 때문에 고정된 내용의 <form> 태그나 <img> 태그 등을 이용할 수 없게 된다.
31.4.2 스프링 시큐리티 CSRF 설정
- 일반적으로 CSRF 토큰은 세션을 통해서 보관하고, 브라우저에서 전송된 CSRF 토큰값을 검사하는 방식으로 처리한다.
- 스프링 시큐리티에서는 CSRF 토큰 생성을 비활성화 하거나 CSRF 토큰을 쿠키를 이용해서 처리하는 등의 설정을 지원한다.
<security:csrf disabled="true"/> <!--csrf 토큰 생성 비활성화 -->
31.5 로그인성공과 AuthenticationSuccessHandler
- 로그인을 처리하다 보면 로그인 성공 이후에 특정한 동작을 하도록 제어하고 싶은 경우가 있다.
- 예를 들어 만일 로그인할 때 'admin 계정 / admin 패스워드'로 로그인 했다면 사용자가 어떤 경로로 로그인 페이지로 들어오건 무조건 '/sample/admin'으로 이동하게 하거나, 별도의 쿠키 등을 생성해서 처리하고 싶은 경우를 생각해볼 수 있다.
- 이런 경우를 위해서 스프링 시큐리티에서는 AuthenticationSuccessHandler라는 인터페이스를 구현해서 설정할 수 있다.
- org.zerock.security 패키지에 Custom-LoginSuccessHandler 클래스를 추가한다.
- CustomLoginSuccessHandler는 로그인 한 사용자에 부여된 권한 Authentication 객체를 이용해서 사용자가 가진 모든 권한을 문자열로 체크한다.
- 만일 사용자가 'ROLE_ADMIN' 권한을 가졌다면 로그인 후에 바로 '/sample/admin'으로 이동하게 하는 방식이다.
- security-context.xml에서는 작성된 CustomLoginSuccessHandler를 빈으로 등록하고 로그인 성공 후 처리를 담당하는 핸들러로 지정한다.
- 브라우저에서 기존과 달리 '/customLogin'의 호출부터 시작해서 로그인하면 사용자의 권한에 따라 다른 페이지를 호출하는 것을 확인할 수 있다.
31.6 로그아웃의 처리와 LogoutSuccessHandler
- 로그인과 마찬가지로 특정한 URI를 지정하고, 로그아웃 처리 후 직접 로직을 처리할 수 있는 핸들러를 등록할 수 있다.
<security:logout logout-url="/customLogout" invalidate-session="true"/>
- 로그아웃 시 세션을 무효화 시키는 설정이나 특정한 쿠키를 지우는 작업을 지정할 수 있다.
- org.zerock.controller 패키지의 CommonController에는 GET 방식으로 로그아웃을 결정하는 페이지에 대한 메서드를 처리한다.
- views 폴더에는 customLogout.jsp를 추가한다.
- 로그아웃 역시 로그인과 동일하게 실제 작업은 '/customLogout'으로 처리하고 POST 방식으로 이루어진다. POST 방식으로 처리되기 때문에 CSRF 토큰값을 같이 지정한다.
- POST 방식으로 처리되는 로그아웃은 스프링 시큐리티의 내부에서 동작한다. 만일 로그아웃 시 추가적인 작업을 해야 한다면 logoutSuccessHandler를 정의해서 처리한다.
- 로그아웃의 처리는 실제로는 어떤 결과를 이용해도 관계 없다.
- 로그아웃을 테스트 하기 위해서 '/sample/admin.jsp' 페이지에 로그아웃으로 이동하는 링크를 추가한다.