스프링 시큐리티의 내부 구조는 상당히 복잡하지만 실제 사용은 약간의 설정만으로도 처리가 가능하다
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 화면을 처리하는 부분에서 다룬다.)
설정을 변경하고 WAS를 실행한 후 '/sample/member'를 접근해 보면 '/sample/all'과는 달리 '/sample/member'는 로그인 페이지로 강제 이동하는 것을 볼 수 있다.
신기한 점은 '/login'에 해당하는 컨트롤러나 웹페이지를 제작한 적이 없다는 사실이다..!
이것은 스프링 시큐리티가 기본으로 제공하는 페이지인데, 현실적으로는 별도의 로그인 페이지를 제작해야만 하므로 테스트하는 과정에서만 사용할만하다..
2. 단순 로그인 처리
로그인 화면이 보여지기는 하지만 로그인을 할 수 없는 상황이므로, '/sample/member'에 접근할 수 있는 방법은 아무것도 없는 상황이다. 추가적인 설정을 통해서 지정된 아이디와 패스워드로 로그인이 가능하도록 설정을 추가해 본다.
스프링 시큐리티에서 명심해야 하는 사항 중 하나는 username이나 User라는 용어의 의미가 일반적인 시스템에서의 의미와 차이가 있다는 점이다. 일반 시스템에서 userid는 스프링 시큐리티에서는 username에 해당한다. 일반적으로 사용자의 이름을 username이라고 처리하는 것과 혼동하면 안된다.
User라는 용어 역시 혼란의 여지가 있다. 스프링 시큐리티의 User는 인증 정보와 권한을 가진 객체이므로 일반적인 경우에 사용하는 사용자 정보와는 다른 의미이다. 예제에서는 이를 구분하기 위해서 시스템상의 회원 정보는 MemberVO라는 클래스를 이용할 것이다. 단순히 로그인이 처리되는 것을 확인하기 위해서 메모리상에 문자열을 지정하고 이를 기준으로 동작하도록 설정해 본다.
인증과 권한에 대한 실제 처리는 UserDetailsService라는 것을 이용해서 처리하는데, XML에서는 다음과 같이 지정할 수 있다.
추가된 설정의 핵심은 'member'라는 계정 정보를 가진 사용자가 로그인을 할 수 있도록 하는 것이다. 위의 설정을 추가한 후에 WAS를 통해서 '/sample/member'로 접근해서 로그인하면 예상과 달리 에러가 발생한다.
실행 결과에서 에러는 'PasswordEncoder'라는 존재가 없기 때문에 발생한다.
스프링 시큐리티 5버전부터 반드시 PasswordEncoder라는 존재를 이용하도록 변경되었다.
임시 방편으로 스프링 시큐리티 5버전에는 포맷팅 처리를 지정해서 패스워드 인코딩 방식을 지정할 수 있다. (http://spring.io/blog/2017/11/01/spring-security-5-0-0-rc1-released#password-storage-format) 만일 패스워드의 인코딩 처리없이 사용하고 싶다면 패스워드 앞에 '{noop}' 문자열을 추가한다.
- security-context.xml
다시 WAS를 통해서 '/sample/member'에 접근하고 'member/member'로 로그인하면 아래와 같이 동작하는 것을 확인할 수 있다.
2-1. 로그아웃 확인
스프링 시큐리티를 학습하다 보면 매번 로그아웃하고 새롭게 로그인을 해야 하는 상황이 자주 발생한다. 이에 대해서 가장 확실한 방법은 브라우저에서 유지하고 있는 세션과 관련된 정보를 삭제하는 것이다. 개발자 도구에서 Application 탭을 확인해 보면 'Cookies' 항목에 'JSESSIONID'와 같이 세션을 유지하는데 사용되는 세션 쿠키의 존재를 확인할 수 있다. (JSESSIONID는 Tomcat에서 발생하는 쿠키의 이름이다. WAS마다 다른 이름을 사용한다.)
로그아웃은 JSESSIONID 쿠키를 강제로 삭제해서 처리한다.
쿠키를 삭제한 후에 로그인이 필요한 URI를 다시 호출해 보면 로그인이 필요한 것을 확인할 수 있다.
예제에서 웹화면을 이용하는 로그아웃은 조금뒤에 처리하도록 하겠다~~~!
2-2. 여러 권한을 가지는 사용자 설정
정상적으로 로그인이 처리되는 것을 확인했다면 '/sample/admin'을 처리하도록 한다. '/sample/admin'은 'ROLE_ADMIN'이라는 권한을 가진 사용자가 접근할 수 있도록 지정하는데 사용자는 'ROLE_ADMIN'과 'ROLE_MEMBER'라는 2개의 권한을 가지도록 지정한다.
- security-context.xml
새롭게 추가된 <security:intercept-url>은 '/sample/admin'에 대한 접근을 설정한다. <security:user>에 추가된 admin 사용자는 'ROLE_MEMBER'와 'ROLE_ADMIN'이라는 2개의 권한을 가지도록 설정한다. admin 계정을 가진 사용자는 'sample/member'와 '/sample/admin' 모두에 접근할 수 있다.
2-3. 접근 제한 메시지의 처리
특정한 사용자가 로그인은 했지만 URI를 접근할 수 있는 권한이 없는 상황이 발생할 수도 있다. 이 경우에는 접근 제한 에러 메세지를 보게 된다. 예제의 경우 member라는 권한을 가진 사용자는 '/sample/admin'에 접근할 수 없다.
스프링 시큐리티에서는 접근 제한에 대해서 AcessDeniedHandler를 직접 구현하거나 특정한 URI를 지정할 수 있다.
- security-context.xml
<security:access-denied-handler>는 org.springframework.security.web.access.AccessDeniedHandler 인터페이스의 구현체를 지정하거나 error-page를 지정할 수 있다. 위의 경우 '/accessError' 라는 URI로 접근 제한 시 보이는 화면을 처리한다.
org.zerock.controller에 CommonController 클래스를 생성해서 '/accessError'를 처리하도록 지정한다.
- CommonController.java
package org.zerock.controller;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import lombok.extern.log4j.Log4j;
@Controller
@Log4j
public class CommonController {
@GetMapping("/accessError")
public void accessDenied(Authentication auth, Model model) {
log.info("access Denied : " + auth);
model.addAttribute("msg", "Access Denied");
}
}
CommonController에서는 간단히 사용자가 알아볼 수 있는 에러 메시지만을 Model에 추가한다. '/accessError'는 Authentication 타입의 파라미터를 받도록 설계해서 필요한 경우에 사용자의 정보를 확인할 수 있도록 한다. views 폴더에는 'accessError.jsp' 파일을 생성한다.
- accessError.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib uri = "http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ taglib uri = "http://www.springframework.org/security/tags" prefix = "sec" %>
<%@ page import = "java.util.*" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv = "Content-Type" content = "text/html; charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1>Access Denied Page</h1>
<h2><c:out value = "${SPRING_SECURITY_403_EXCEPTION.getMessage() }"/></h2>
<h2><c:out value = "${msg }"/></h2>
</body>
</html>
Acess Denied의 경우는 403 에러 메시지가 발생한다. JSP에서는 HttpServlet Request 안에 'SPRING_SECURITY_403_EXCEPTION' 이라는 이름으로 AcessDeniedException 객체가 전달된다. 브라우저에서 '/sample/admin' URI를 member/member 정보로 로그인한 사용자가 접근하는 경우 이전과 달리 에러 메시지 대신에 accessError.jsp의 내용이 보이게 된다.
2-4. AcessDeniedHandler 인터페이스를 구현하는 경우
<security:access-denied-handler error-page="/accessError"/> 와 같이 error-page만을 제공하는 경우에는 사용자가 접근했던 URI 자체의 변화는 없다. 위의 그림에서도 URI 자체는 '/sample/admin'으로 되어 있고, 화면의 내용은 '/accessError'에 해당하는 URI의 결과이다.
접근 제한이 된 경우에 다양한 처리를 하고 싶다면 직접 AccessDeniedHandler 인터페이스를 구현하는 편이 좋다. 예를 들어 접근 제한이 되었을 때 쿠키나 세션에 특정한 작업을 하거나 HttpServletResponsep에 특정한 헤더 정보를 추가하는 등의 행위를 할 경우에는 직접 구현하는 방식이 더 권장된다.
예제를 위해 org.zerock.security 패키지를 생성하고 CustomAccessDeniedHandler 클래스를 추가한다.
- CustomAccessDeniedHandler.java
package org.zerock.security;
import java.io.IOException;
import java.nio.file.AccessDeniedException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.web.access.AccessDeniedHandler;
import lombok.extern.log4j.Log4j;
@Log4j
public class CustomAccessDeniedHandler implements AccessDeniedHandler{
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
org.springframework.security.access.AccessDeniedException accessDeniedException)
throws IOException, ServletException {
log.error("Access Denied Handler");
log.error("Redirect.....");
response.sendRedirect("/accessError");
}
}
CustomAccessDeniedHandler 클래스는 AccessDeniedHandler 인터페이스를 직접 구현한다. 인터페이스의 메서드는 handle() 뿐이기 때문이고 HttpServletRequest, HttpServletResponse를 파라미터로 사용하기 때문에 직접적으로 서블릿 API를 이용하는 처리가 가능하다.
위의 코드에서는 접근 제한에 걸리는 경우 리다이렉트 하는 방식으로 동작하도록 지정되었다. security-context.xml 에서는 error-page 속성 대신에 CustomAccessDeniedHandler를 빈으로 등록해서 사용한다.
<security:access-denied-handler>는 error-page 속성과 ref 속성 둘 중 하나만을 사용한다. 위와 동일한 방식으로 '/sample/admin'에 member/member 계정으로 로그인하면 이전과 달리 '/accessError'로 리다이렉트 되는 것을 확인할 수 있다.
3. 커스텀 로그인 페이지
앞서 언급했듯이 스프링 시큐리티에서 기본적으로 로그인 페이지를 제공하기는 하지만, 현실적으로는 화면 디자인 등의 문제로 사용하기 불편하다. 때문에 거의 대부분 경우 별도의 URI를 이용해서 로그인 페이지를 다시 제작해서 사용한다. 이를 이용하는 방식은 접근 제한 페이지와 유사하게 직접 특정한 URI를 지정할 수 있다.
- security-context.xml
login-page 속성의 URI는 반드시 GET 방식으로 접근하는 URI를 지정한다.
org.zerock.controller 패키지의 CommonController에 '/customLogin'에 해당하는 메서드를 추가한다.
- CommonController.java
@GetMapping("/customLogin")
public void loginInput(String error, String logout, Model model) {
log.info("error : " + error);
log.info("logout : " + logout);
if(error != null) {
model.addAttribute("error", "Login Error Check Your Account");
}
if(logout != null) {
model.addAttribute("logout", "Logout!!");
}
}
logInput()은 GET 방식으로 접근하고, 에러 메시지와 로그아웃 메시지를 파라미터로 사용할 수 있다.
views 폴더에는 customLogin.jsp를 추가한다.
- customLogin.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib uri = "http://java.sun.com/jsp/jstl/core" prefix = "c" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv = "Content-Type" content = "text/html; charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1>Custom Login Page</h1>
<h2><c:out value = "${error }"/></h2>
<h2><c:out value = "${logout}"/></h2>
<form method = 'post' action = "/login">
<div>
<input type = 'text' name = 'username' value = 'admin'>
</div>
<div>
<input type = password' name = 'password' value = 'admin'>
</div>
<div>
<input type = 'submit'>
</div>
<input type = "hidden" name = "${_csrf.parameterName }" value = "${_csrf.token }"/>
</form>
</body>
</html>
코드를 저장하고 브라우저에서 로그인 정보를 삭제한 후 '/sample/admin'과 같이 접근 제한이 필요한 URI에 접근하면 작성된 customLogin.jsp 페이지의 내용을 볼 수 있다.
customLogin.jsp를 보면 몇 가지 특이한 점들이 있다. 우선 <form> 태그의 action 속성값이 '/login'으로 지정되어 있다는 점이다. 실제로 로그인의 처리 작업은 '/login'을 통해서 이루어지는데 반드시 POST 방식으로 데이터를 전송해야만 한다. <input> 태그의 name 속성은 기본적으로는 username과 password 속성을 이용한다.
마지막의 <input type = 'hidden'> 태그는 특이하게도 ${_csrf.parameterName}으로 처리한다. 이 EL의 값은 실제 브라우저에서는 '_csrf'라는 이름으로 처리된다. 브라우저에서 '페이지 소스 보기'를 해보면 아래와 같은 태그와 값이 생성된 것을 볼 수 있다. (value는 임의의 값으로 지정됨)
만일 사용자가 패스워드 등을 잘못 입력하는 경우에는 자동으로 다시 로그인 페이지로 이동하게 된다.
4. CSRF (Cross-site request forgery) 공격과 토큰
스프링 시큐리티에서 POST 방식을 이용하는 경우 기본적으로 CSRF 토큰이라는 것을 이용하게 된다. 별도의 설정이 없다면 스프링 시큐리티가 적용된 사이트의 모든 POST 방식에는 CSRF 토큰이 사용되는데 '사이트간 위조 방지'를 목적으로 특정한 값의 토큰을 사용하는 방식이다.
CSRF 공격을 막기 위해서는 여러 방식이 존재할 수 있다. CSRF 공격 자체가 사용자의 요청에 대한 출처를 검사하지 않아서 생기는 허점이기 때문에 사용자에 요청에 대한 출처를 의미하는 referer 헤더를 체크하거나 일반적인 경우에 잘 사용되지 않고 REST 방식에서 사용되는 PUT, DELETE와 같은 방식을 이용하는 등의 방법을 고려해 볼 수 있다.
4-1. CSRF 토큰
CSRF 토큰은 사용자가 임의로 변하는 특정한 토큰값을 서버에서 체크하는 방식이다. 서버에는 브라우저에 데이터를 전송할 때 CSRF 토큰을 같이 전송한다. 사용자가 POST 방식 등으로 특정한 작업을 할 때는 브라우저에서 전송된 CSRF 토큰의 값과 서버가 보관하고 있는 토큰 값을 비교한다. 만일 CSRF 토큰의 값이 다르다면 작업을 처리하지 않는 방식이다.
서버에서 생성하는 토큰은 일반적으로 난수를 생성해서 공격자가 패턴을 찾을 수 없도록 한다. 사용자가 '/customLogin'을 처음 호출했을 때와 강제로 세션 쿠키를 삭제한 후에 다시 호출했을때의 csrf 토큰의 값이 변경된다.
공격자의 입장에서는 CSRF 공격을 하려면 변경되는 CSRF 토큰의 값을 알아야만 하기 때문에 고정된 내용의 <form> 태그나 <img> 태그 등을 이용할 수 없게 된다.
4-2. 스프링 시큐리티의 CSRF 설정
일반적으로 CSRF 토큰은 세션을 통해서 보관하고, 브라우저에서 전송된 CSRF 토큰값을 검사하는 방식으로 처리한다. 스프링 시큐리티에서는 CSRF 토큰 생성을 비활성화 하거나 CSRF 토큰을 쿠키를 이용해서 처리하는 등의 설정을 지원한다.
<security:csrf disabled="true"/>
5. 로그인 성공과 AuthenticationSuccessHandler
로그인을 처리하다 보면 로그인 성공 이후에 특정한 동작을 하도록 제어하고 싶은 경우가 있다. 예를 들어 만일 로그인 할 때 'admin 계정/admin 패스워드'로 로그인 했다면 사용자가 어떤 경로로 로그인 페이지로 들어오면 무조건 '/sample/admin'으로 이동하게 하거나, 별도의 쿠키 등을 생성해서 처리하고 싶은 경우를 생각해볼 수 있다.
이런 경우를 위해서 스프링 시큐리티에서는 AuthenticationSuccessHandler 라는 인터페이스를 구현해서 설정할 수 있다. org.zerock.security 패키지에 CustomLoginSuccessHandler 클래스를 추가한다.
- CustomLoginSuccessHandler.java
package org.zerock.controller;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import lombok.extern.log4j.Log4j;
@Log4j
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler{
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication auth) throws IOException, ServletException {
log.warn("Login Success");
List<String> roleNames = new ArrayList<>();
auth.getAuthorities().forEach(authority -> {
roleNames.add(authority.getAuthority());
});
log.warn("ROLE NAMES : " + roleNames);
if(roleNames.contains("ROLE_ADMIN")) {
response.sendRedirect("/sample/admin");
return;
}
if(roleNames.contains("ROLE_MEMBER")) {
response.sendRedirect("/sample/member");
return;
}
response.sendRedirect("/");
}
}
CustomLoginSuccessHandler는 로그인 한 사용자에 부여된 권한 Authentication 객체를 이용해서 사용자가 가진 모든 권한을 문자열로 체크한다. 만일 사용자가 'ROLE_ADMIN' 권한을 가졌다면 로그인 후에 바로 '/sample/admin'으로 이동하게 하는 방식이다.
security-context.xml에서는 작성된 CustomLoginSuccessHandler를 빈으로 등록하고 로그인 성공 후 처리를 담당하는 핸들러로 지정한다.
- security-context.xml
브라우저에서 기존과 달리 '/customLogin'의 호출부터 시작해서 로그인하면 사용자의 권한에 따라 다른 페이지를 호출하는 것을 확인할 수 있다.
6. 로그아웃의 처리와 LogoutSuccessHandler
로그인과 마찬가지로 특정한 URI를 지정하고, 로그아웃 처리 후 직접 로직을 처리할 수 있는 핸들러를 등록할 수 있다.
- security-context.xml
<security:logout logout-url = "/customLogout" invalidate-session = "true"/>
로그아웃 시 세션을 무효화 시키는 설정이나 특정한 쿠키를 지우는 작업을 지정할 수 있다. org.zerock.controller 패키지의 CommonController에는 GET 방식으로 로그아웃을 결정하는 페이지에 대한 메서드를 처리한다.
- CommonController.java
@GetMapping("/customLogout")
public void logoutGET() {
log.info("custom logout");
}
views 폴더에는 customLogout.jsp를 추가한다.
- customLogout.jsp
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv = "Content-Type" content = "text/html; charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h1>Logout Page</h1>
<form action = "/customLogout" method = 'post'>
<input type = "hidden" name = "${_csrf.parameterName }" value = "${_csrf.token }"/>
<button>로그아웃</button>
</form>
</body>
</html>
로그아웃 역시 로그인과 동일하게 실제 작업은 '/customLogout'으로 처리하고 POST 방식으로 이루어 진다. POST 방식으로 처리되기 때문에 CSRF 토큰값을 같이 지정한다.
POST 방식으로 처리되는 로그아웃은 스프링 시큐리티의 내부에서 동작한다. 만일 로그아웃 시 추가적인 작업을 해야 한다면 logoutSuccessHandler를 정의해서 처리한다.
로그아웃의 처리는 실제로는 어떤 결과를 이용해도 관계 없다.
로그아웃을 테스트하기 위해서 '/sample/admin.jsp' 페이지에 로그아웃으로 이동하는 링크를 추가한다.
- admin.jsp
로그인이 되지 않은 사용자는 브라우저에서 '/sample/admin'을 호출한다. 사용자가 로그인 한 적이 없거나 적당한 권한이 없다면 로그인하는 화면으로 이동하게 된다. 여기서 로그인이 정상적으로 실행되면 '/sample/admin' 화면을 보게된다. 화면에서 'logout'을 선택하면 GET 방식으로 Logout Page를 보게된다. '/customLogout'에서 POST 방식으로 로그아웃을 하게 되면 내부적으로는 자동으로 로그인 페이지를 호출하게 된다. 이 부분은 스프링 시큐리티의 기본 설정이므로 필요하다면 logout-success-url 속성 등을 이용해서 변경할 수 있다.
로그아웃이 정상적으로 처리되었는지 확인하는 방법은 로그인이 필요한 '/sample/admin'을 다시 호출해보는 방식으로 확인할 수 있다.
'Spring' 카테고리의 다른 글
[39] spring web security를 이용한 로그인 처리 - spring web security 란? (1) | 2020.01.28 |
---|---|
[38] 파일 업로드 처리 - 잘못 업로드된 파일 삭제 (Quartz 라이브러리) (0) | 2020.01.20 |
[37] 파일 업로드 처리 - 게시물의 수정과 첨부파일 (0) | 2020.01.16 |
org.springframework.jdbc.UncategorizedSQLException: ### Error updating database. Cause: java.sql.SQLException: 해당 위치에 지원되지 않는 SQL92 토큰: 31 (0) | 2020.01.10 |
[35] 파일 업로드 처리 - 게시물의 조회와 첨부파일 (0) | 2020.01.10 |