Security 예외처리를 설정 해보자.

지난 포스트 에서 시큐리티에 대한 기본설정을 해보았다.

이번엔, 좀 더 나아가 예외상황을 직접 커스터마이징 해보자.

Access Denied (접근 거부) 핸들링

먼저 ROLE_ADMIN 권한 만 접근 할 수 있는 페이지와, 접근이 거부 되었을때 나오는 페이지를 만들어 주자.

templates/admin.html

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
    />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    admin 만 들어 올 수 있는 페이지
  </body>
</html>

templates/access-denied.html

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
    />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    접근이 거부되었습니다.
  </body>
</html>

그리고 admin 페이지는 /admin 으로, 접근 거부는 /access-denied 경로로 랜더링 할 수 있는 컨트롤러를 설정하자.

SecurityController.java

@Controller
public class SecurityController {

    //...

    @GetMapping(value = "/admin")
    public String adminPage() {
        return "admin";
    }

    @GetMapping(value = "/access_denied")
    public String adminPage() {
        return "access_denied";
    }


    //...
}

이제 WebSecurityConfig 에서 다음과 같이 변경 해보자.

public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    // ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/private/**").hasAnyRole("USER")
                .antMatchers("/admin/**").hasAnyRole("ADMIN") // admin 경로 추가
                .anyRequest().permitAll()
        .and()
            .formLogin()
            .loginPage("/login")
            .usernameParameter("username")
            .passwordParameter("password")
            .failureUrl("/login?error=true")
        .and()
            .logout()
            .deleteCookies("JSESSIONID")
            .clearAuthentication(true)
            .invalidateHttpSession(true)
        .and()
            .exceptionHandling()
                .accessDeniedPage("/access_denied")// access denied page
        ;
    }

    // ...
}

GET /access-denied 는 Security 가 이미 Bean 으로 잡고있어서 못쓴다. 주의하자

이제 서버를 부팅 후, /admin 페이지로 들어가보자. 로그인 창이 뜨고, user / 1234 로 로그인 하게되면, user 는 ADMIN 권한을 가지고 있지 않기 때문에, 접근 거부 페이지가 뜰 것이다.

원활한 개발을 위해 간단하게 네비게이션 메뉴를 추가 해 보자. thymeleaf fragment 를 추가 할껀데, 보여주기만 할거니 자세하게는 몰라도 된다.

pom.xml

<dependency>
    <groupId>nz.net.ultraq.thymeleaf</groupId>
    <artifactId>thymeleaf-layout-dialect</artifactId>
    <version>2.4.1</version>
</dependency>

templates/layout/layout.html

<!DOCTYPE html>
<html
  lang="ko"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
>
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
    />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css"
    />
    <link
      rel="stylesheet"
      href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap-theme.min.css"
    />
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
    <title>Document</title>
  </head>
  <body>
    <div class="container-fluid">
      <th:block th:replace="layout/navigation" />
      <th:block layout:fragment="contents" />
    </div>
  </body>
</html>

templates/layout/navigation.html

<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
  <th:block layout:fragment="navigation">
    <nav class="navbar navbar-default">
      <ul class="nav navbar-nav">
        <li><a th:href="@{/}"></a></li>
        <li><a th:href="@{/admin}">관리자 페이지</a></li>
        <li><a th:href="@{/private}">개인 페이지</a></li>
        <li>
          <a href="javascript: void(0);" onclick="document.getElementById('logout-form').submit()">
            로그아웃
          </a>
        </li>
      </ul>
    </nav>
    <form id="logout-form" th:action="@{/logout}" method="post"></form>
  </th:block>
</html>

2019년 5월 16일 노트. security 의 logout 기본설정은 GET /logout 이 아니고 POST /logout 이다.

access_denied.html

<html
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout/layout"
>
  <th:block layout:fragment="contents">
    <h1>접근이 거부되었습니다.</h1>
  </th:block>
</html>

admin.html

<html
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout/layout"
>
  <th:block layout:fragment="contents">
    <h1>접근이 거부되었습니다.</h1>
  </th:block>
</html>

index.html

<html
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout/layout"
>
  <th:block layout:fragment="contents">
    <h1>안녕하세요 시큐리티 홈 입니다..</h1>
  </th:block>
</html>

private.html

<html
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout/layout"
>
  <th:block layout:fragment="contents">
    <h1>비공개 페이지</h1>
  </th:block>
</html>

public.html

<html
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layout/layout"
>
  <th:block layout:fragment="contents">
    <h1>public 페이지</h1>
  </th:block>
</html>

이제 ADMIN 유저를 추가 해보자. import.sql 에 추가하면 된다.

import.sql

insert into tbl_users (idx, username, password) values (1, 'user', '{bcrypt}$2a$10$Wyc.IrbO.bqraF58565Yde6J6heWdARvbDUKfaQYr9v/IoHcQ1RlK');
insert into tbl_users (idx, username, password) values (2, 'admin', '{bcrypt}$2a$10$Wyc.IrbO.bqraF58565Yde6J6heWdARvbDUKfaQYr9v/IoHcQ1RlK');

그리고 admin 유저가 ADMIN 역할을 소유 할 수 있도록 UserDetailsServiceImpl 에 설정 해주자.

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @PersistenceContext
    private EntityManager em; // JPA

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

        // ...

        if (s.equals("admin")) {
            GrantedAuthority adminRole = new SimpleGrantedAuthority("ROLE_USER");
            authorities.add(adminRole);
        }

        return new User(userEntity.getUsername(), userEntity.getPassword(), authorities);
    }
}

이제 각각 user / 1234 와 admin / 1234 로 로그인 - 로그아웃을 반복하면서, admin 페이지에 접근 해 보면 admin 만 접근 할 수있는것을 확인 할 수 있을것이다.

이제 해당 페이지에 접근 하려고 하였을때, 로그를 기록하자

먼저 CustomAccessDeniedHandler class 를 만들고 AccessDeniedHandlerImpl 를 상속받아서 구현하자.

package ziponia.spring.security;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@Configuration
public class CustomAccessDeniedHandler extends AccessDeniedHandlerImpl {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        this.setErrorPage("/access_denied");
        log.info("Access Denied Request: {}, {}", request.getRemoteHost(), request.getRemoteUser());
        super.handle(request, response, accessDeniedException);
    }
}

그 다음 WebSecurityConfig 파일로 가서 HttpSecurity 를 교체 해 주면 된다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/private/**").hasAnyRole("USER")
                .antMatchers("/admin/**").hasAnyRole("ADMIN")
                .anyRequest().permitAll()
        .and()
            .formLogin()
            .loginPage("/login")
            .usernameParameter("username")
            .passwordParameter("password")
            .failureUrl("/login?error=true")
        .and()
            .exceptionHandling()
                // .accessDeniedPage("/access_denied")
                .accessDeniedHandler(customAccessDeniedHandler)

        ;
    }
}

이제 user 로 로그인 해서 관리자 페이지에 들어가면, 위 로그가 뜨는 것을 확인 할 수 있다.