[Spring Security] Custom Form Login 구현(3)

식빵·2022년 9월 11일
0

이 시리즈에 나오는 모든 내용은 인프런 인터넷 강의 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 에서 기반된 것입니다. 그리고 여기서 인용되는 PPT 이미지 또한 모두 해당 강의에서 가져왔음을 알립니다. 추가적으로 여기에 작성된 코드들 또한 해당 강의의 github 에 올라와 있는 코드를 참고해서 만든 겁니다.




1. 인증객체에 추가 정보 담기


로그인 이후에 단순히 사용자 정보, 권한 정보만 담지 말고,
요청에 어떤 파라미터가 있냐에 따라서 추가 정보를 담을지 말지 등의
작업도 하고 싶다면 WebAuthenticationDetails, AuthenticationDetailsSource 를 사용하면 된다.


1-1. 처리 흐름 (그림)

위에서 말한 두 클래스의 사용, 처리 방식은 아래 그림과 같다.




1-2. 처리 흐름 (코드)

이번에는 코드로 가볍게 해당 내용들이 실제 어떤 방식으로 처리되는지 구경해보자.


UsernamePasswordAuthenticationFilter.java

먼저 인증 필터(UsernamePasswordAuthenticationFilter)에서 실제 인증을 시작하는 메소드인 attemptAuthentication 메소드에서 setDetails 메소드에서 추가 정보를 담는 작업을 수행한다.


UsernamePasswordAuthenticationFilter.java

위에서 본 setDetails 메소드의 내용을 보면 위와 같다.
authenticationDetailsSource.buildDetails 메소드에 의해서 작업을 수행하는 것을 확인할 수 있다.


WebAuthenticationDetailsSource.java

buildDetails 메소드의 내용. 설명 생략


WebAuthenticationDetails.java

기본적으로 IP 와 sessionId 를 저장하는 것을 확인할 수 있다.
보면 알겠지만, request.getParameter() 메소드를 통해서 추가적인 정보를 저장할 수 있다는 것을 짐작할 수 있다.


일단 추가적인 정보를 인증객체에 담는 방식을 알았으니,
Custom 하게 추가정보를 담을 수 있도록 AuthenticationDetails, WebAuthenticationDetailsSource 를 상속해서 새로운 클래스를 작성해보겠다.




1-3. Custom 클래스 작성

package me.dailycode.security.common;

import org.springframework.security.web.authentication.WebAuthenticationDetails;

import javax.servlet.http.HttpServletRequest;

public class FormWebAuthenticationDetails extends WebAuthenticationDetails {
	
	private final String secretKey;
	
	/**
	 * Records the remote address and will also set the session Id if a session already
	 * exists (it won't create one).
	 *
	 * @param request that the authentication request was received from
	 */
	public FormWebAuthenticationDetails(HttpServletRequest request) {
		super(request);
		secretKey = request.getParameter("secret_key");
	}
	
	public String getSecretKey() {
		return secretKey;
	}
	
}

package me.dailycode.security.common;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

@Component
@Qualifier("formAuthenticationDetailsSource")
public class FormAuthenticationDetailsSource implements 
	AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> {
	
	@Override
	public WebAuthenticationDetails buildDetails(HttpServletRequest context) {
		return new FormWebAuthenticationDetails(context);
	}
	
}




1-4. Spring Security 설정에 적용


@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
	@Autowired @Qualifier("formAuthenticationDetailsSource")
	private AuthenticationDetailsSource authenticationDetailsSource;
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
			.antMatchers("/", "/users").permitAll()
			.antMatchers("/mypage").hasRole("USER")
			.antMatchers("/messages").hasRole("MANAGER")
			.antMatchers("/config").hasRole("ADMIN")
			.anyRequest().authenticated()
			.and()
			.formLogin()
			.loginPage("/login")
			.loginProcessingUrl("/login_proc")
			.authenticationDetailsSource(authenticationDetailsSource) // 추가!
			.defaultSuccessUrl("/")
			.permitAll();
	}
    
    // ... 생략 ...
}

TIP: 혹시 Autowired, Qualifier 가 안 쓴지 오래되서 기억 안나면...
https://www.baeldung.com/spring-qualifier-annotation 참고




1-5. 로그인 화면에 input 추가

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout/header::userHead"></head>
<body>
<div th:replace="layout/top::header"></div>
  <div class="container text-center">
    <div class="login-form d-flex justify-content-center">
      <div class="col-sm-5" style="margin-top: 30px;">
        <div class="panel">
          <p>아이디와 비밀번호를 입력해주세요</p>
        </div>
        <form th:action="@{/login_proc}" class="form-signin" method="post">
          <input type="hidden" th:value="secret" th:name="secret_key">
          <div class="form-group">
            <input type="text" class="form-control" name="username" placeholder="아이디" required="required" autofocus="autofocus" />
          </div>
          <div class="form-group">
            <input type="password" class="form-control" name="password" placeholder="비밀번호" required="required" />
          </div>
          <button type="submit" class="btn btn-lg btn-primary btn-block">
            로그인
          </button>
        </form>
      </div>
    </div>
  </div>
</body>
</html>
  • <input type="hidden" th:value="secret" th:name="secret_key">




1-6. CustomAuthenticationProvider 에 적용

우리가 이전 글에서 작성했던 CustomAuthenticationProvider 에서 해당 추가 정보에 대한 정보의 유무에 따라 인증 처리 방식이 달라지도록 코드를 수정해보자.


public class CustomAuthenticationProvider implements AuthenticationProvider {
	
	@Autowired
	private UserDetailsService userDetailsService;
	
	@Autowired
	private PasswordEncoder passwordEncoder;
	
	@Override
	public Authentication authenticate(Authentication authentication)
    	throws AuthenticationException {
		
		String username = authentication.getName();
		String password = (String)authentication.getCredentials();
		
		AccountContext accountContext 
        	= (AccountContext) userDetailsService.loadUserByUsername(username);
		
		if (!passwordEncoder.matches(password, accountContext.getPassword())) {
			throw new BadCredentialsException("BadCredentialsException'");
		}
		
		
		// 추가 정보를 통한 인증처리 START!!!!
		FormWebAuthenticationDetails formWebAuthenticationDetails
			= (FormWebAuthenticationDetails) authentication.getDetails();
		
		String secretKey = formWebAuthenticationDetails.getSecretKey();
		
		if (!StringUtils.hasText(secretKey)) {
			throw new InsufficientAuthenticationException("InsufficientAuthenticationException");
		}
		// 추가 정보를 통한 인증처리 END!!!!
		
		return new UsernamePasswordAuthenticationToken(
				accountContext,
				null,
				accountContext.getAuthorities()
			);
	}
	
	@Override
	public boolean supports(Class<?> authentication) {
		return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
	}
}






2. 인증 성공 핸들러 작성


로그인 성공 후에, 어떤 부가적인 작업들을 더 해야되는 경우에는 스프링 시큐리티의
기본 인증 성공 핸들러만 사용할 수 없다.

그러므로 커스텀 인증 성공 핸들러를 작성/적용하는 법을 알아보자.


2-1. 코드 작성

package me.dailycode.security.handler;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;

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

@Component("customAuthenticationSuccessHandler")
public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
	
	private RequestCache requestCache = new HttpSessionRequestCache();
	
	private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
	
	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
		// 요청 캐시를 통해서, 로그인 성공후 사용자가 가려던 페이지로 가도록 구현한다.
		
		setDefaultTargetUrl("/");
		
		SavedRequest savedRequest = requestCache.getRequest(request, response);
		if (savedRequest != null) {
			String targetUrl = savedRequest.getRedirectUrl();
			redirectStrategy.sendRedirect(request, response, targetUrl);
		} else {
			redirectStrategy.sendRedirect(request, response, getDefaultTargetUrl());
		}
		
	}
}
  • 여기서 savedRequest == null 인 경우는 어떤 보호자원에 접근하다가 튕겨서
    로그인 페이지에 온게 아니라, 그냥 로그인 페이지로 바로 들어와서 로그인을 시도할 때이다.

  • 보호자원에 접근하다가 튕겼을 때, savedRequest 가 세션에 저장되는 것이라고 이해하면 된다.



2-2. Spring Security 설정 적용

@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Autowired
	private AuthenticationSuccessHandler customAuthenticationSuccessHandler;
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
			.antMatchers("/", "/users").permitAll()
			.antMatchers("/mypage").hasRole("USER")
			.antMatchers("/messages").hasRole("MANAGER")
			.antMatchers("/config").hasRole("ADMIN")
			.anyRequest().authenticated()
			.and()
			.formLogin()
			.loginPage("/login")
			.loginProcessingUrl("/login_proc")
			.authenticationDetailsSource(authenticationDetailsSource) // 추가!
			// .defaultSuccessUrl("/") // 주석처리해주자. 
			.successHandler(customAuthenticationSuccessHandler)
			.permitAll();
	}
    
    // ... 생략 ...
}






3. 인증 실패 핸들러 작성


커스텀 인증 실패 핸들러를 작성/적용하는 법을 알아보자.


3-1. 코드 작성

package me.dailycode.security.handler;

import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;

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

@Component("customAuthenticationFailureHandler")
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
	
	@Override
	public void onAuthenticationFailure(HttpServletRequest request,
    									HttpServletResponse response,
    									AuthenticationException exception) 
    	throws IOException, ServletException {
		
		String errorMessage = "Invalid Username or Password";
		
		if (exception instanceof BadCredentialsException e) {
			errorMessage = e.getMessage();
		} else if (exception instanceof InsufficientAuthenticationException e) {
			errorMessage = e.getMessage();
		}
		
		setDefaultFailureUrl("/login?error=true&exception=" + errorMessage);
		
		super.onAuthenticationFailure(request, response, exception);
	
	}
}



3-2. Controller 소스, 로그인 HTML 수정

@Controller
public class LoginController {
	
	@GetMapping("/login")
	public String login(@RequestParam(value = "error", required = false) String error,
	                    @RequestParam(value="exception", required = false) String exception,
	                    Model model) {
		
		model.addAttribute("error", error);
		model.addAttribute("exception", exception);
		
		return "user/login/login";
	}
	
	// ... 생략 ...
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout/header::userHead"></head>
<body>
<div th:replace="layout/top::header"></div>
  <div class="container text-center">
    <div class="login-form d-flex justify-content-center">
      <div class="col-sm-5" style="margin-top: 30px;">
        <div class="panel">
          <p>아이디와 비밀번호를 입력해주세요</p>
        </div>
        <div th:if="${param.error}" class="form-group">
          <span th:text="${exception}" class="alert alert-danger">에러 메세지</span>
        </div>
        <form th:action="@{/login_proc}" class="form-signin" method="post">
			<!-- ...  생략 ... -->
        </form>
      </div>
    </div>
  </div>
</body>
</html>



3-3. Spring Security 설정 적용

@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Autowired
	private AuthenticationFailureHandler customAuthenticationFailureHandler;
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
			.antMatchers("/", "/users", "/login*").permitAll() // "/login" 추가
			.antMatchers("/mypage").hasRole("USER")
			.antMatchers("/messages").hasRole("MANAGER")
			.antMatchers("/config").hasRole("ADMIN")
			.anyRequest().authenticated()
			.and()
			.formLogin()
			.loginPage("/login")
			.loginProcessingUrl("/login_proc")
			.authenticationDetailsSource(authenticationDetailsSource)
			// .defaultSuccessUrl("/")
			.successHandler(customAuthenticationSuccessHandler)
			.failureHandler(customAuthenticationFailureHandler) // 추가!
			.permitAll();
	}
	
    // ... 생략 ...
}
  • antMatchers 에 "/login*" 을 추가하는 이유는 antMatchers 를 사용하면
    login?~~~ 했을 때 ?~~ 자체에 대해서도 permitAll() 처리하기 위함이다.

  • 사실 이런 경우에는 mvcMatcher 를 사용하는 게 더 좋다.






4. Access Denied


그런데 인증을 성공했지만, 자원에 접근할 수 있는 권한이 없거나, 모자란 경우에는
인가 예외가 FilterSecurityInterceptor 필터에서 발생한다.
그리고 해당 예외는 ExceptionTranslationFilter 에서 받아서 처리한다.

여기서 말하는 건 인가예외에 대한 예외처리다.
인증예외는 당연히 인증을 담단하는 인증 필터에서 처리한다.
애초에 인증예외를 던지는 곳이 AuthenticationFilter 가 인증 작업을 위임하는
AuthenticationManager, Provider, UsersDetailsService 라는 것을 기억하길 바란다. 헷갈릴까봐 작성한다.

그러면 인가예외는 정확히 어느 위치에서 어느 코드에서 처리되는지 더 자세히 관찰하자.

ExceptiontranslationFilter 클래스에서 this.accessDeniedHandler.handle(~) 메소드에 의해서 최종적인 처리가 진행되는 걸 확인할 수 있다.

이제 저 accessDeniedHandler 를 직접 구현해서 ExceptiontranslationFilter 가 호출하도록 하자.




4-1. 코드 작성

accessDeniedHandler 클래스를 구현해보자.

package me.dailycode.security.handler;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

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

public class CustomAccessDeniedHandler implements AccessDeniedHandler {
	private String errorPage;
	
	@Override
	public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
		String deniedUrl = errorPage + "?exception=" + accessDeniedException.getMessage();
		response.sendRedirect(deniedUrl);
	}
	
	public void setErrorPage(String errorPage) {
		this.errorPage = errorPage;
	}
}



4-2. Controller : "/denied" 엔드포인트 추가

@Controller
public class LoginController {
	
    // ... 생략 ...
    
	@GetMapping("/denied")
	public String accessDenied(@RequestParam(value = "exception", required = false) String exception,
	                           Model model) {
		
		
		Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
		AccountContext account 
        	= (AccountContext) authentication.getPrincipal();
		model.addAttribute("username", account.getUsername());
		model.addAttribute("exception", exception);
		
		return "user/login/denied";
	}
}



4-3. Spring Security 설정 적용

@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
			.antMatchers("/", "/users", "/login*").permitAll() // "/login" 추가
			.antMatchers("/mypage").hasRole("USER")
			.antMatchers("/messages").hasRole("MANAGER")
			.antMatchers("/config").hasRole("ADMIN")
			.anyRequest().authenticated()
			.and()
			.formLogin()
			.loginPage("/login")
			.loginProcessingUrl("/login_proc")
			.authenticationDetailsSource(authenticationDetailsSource)
			// .defaultSuccessUrl("/")
			.successHandler(customAuthenticationSuccessHandler)
			.failureHandler(customAuthenticationFailureHandler)
			.permitAll()
			.and()
			.exceptionHandling() // 추가!
			.accessDeniedHandler(accessDeniedHandler()); // 추가!
	}
	
	@Bean
	public AccessDeniedHandler accessDeniedHandler() {
		CustomAccessDeniedHandler accessDeniedHandler = new CustomAccessDeniedHandler();
		accessDeniedHandler.setErrorPage("/denied");
		return accessDeniedHandler;
	}
	
    // ... 생략...
}



4-4. denied HTML 작성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout/header::userHead"></head>
<body>
<div th:replace="layout/top::header"></div>
<div class="container">
  <div class="row align-items-start">
    <nav class="col-md-2 d-none d-md-block bg-light sidebar">
      <div class="sidebar-sticky">
        <ul class="nav flex-column">
          <li class="nav-item">
            <div style="padding-top:10px;" class="nav flex-column nav-pills" aria-orientation="vertical">
              <a th:href="@{/}" style="margin:5px;" class="nav-link  text-primary">대시보드</a>
              <a th:href="@{/mypage}" style="margin:5px;" class="nav-link text-primary">마이페이지</a>
              <a th:href="@{/messages}" style="margin:5px;" class="nav-link text-primary">메시지</a>
              <a th:href="@{/config}" style="margin:5px;" class="nav-link text-primary">환경설정</a>
            </div>
          </li>
        </ul>
      </div>
    </nav>
    <div style="padding-top:50px;"  class="col">
      <div class="container text-center">
        <h1><span th:text="${username}" class="alert alert-danger" />님은 접근 권한이 없습니다.</h1>
        <br />
        <h3 th:text="${exception}"></h3>
      </div>
    </div>
  </div>
</div>
<div th:replace="layout/footer::footer"></div>
</body>
</html>




5. Form Login 구현 끝!

끝!

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글