본문 바로가기

Spring/Spring Security

[Spring Boot] Spring Security로 권한 처리 및 로그인, 자동 로그인 적용 PART_1 (JSP, Session 기반 웹 애플리케이션 서버)

개인적으로 제작 중인 프로젝트에 적용시키면서 실전경험 쌓기 (2024.04.08)

https://github.com/GyuminGomin/people-driver

 

GitHub - GyuminGomin/people-driver: busan IT Academy Final Project

busan IT Academy Final Project. Contribute to GyuminGomin/people-driver development by creating an account on GitHub.

github.com

동기 : 기존 프레임워크에서 진행했던 작업에서는 자동 로그인, 권한 처리, 토큰 기반 csrf 방지 처리를 다 Interceptor로 구현하였다. 일일이 다 권한 처리 및 로그인 설정을 구현하려니 힘이 들었다. 서비스가 커지면 커질 수록 더욱 로직을 쉽게 알아볼 수 있게 작성할 필요가 있다 생각해 Spring Security로 변환하고자 하였다.

더보기

그 전 Interceptor와 SpringSecurity에 대해 기초적인 지식을 알아야 하기에 설명부터 할 것 입니다.

(만약 Interceptor와 SpringSecurity에 대해 알고 있다 하면 바로 3번 실전 적용을 참고하면 됩니다.)

(그리고 내용이 엄청 많아 파트를 2개로 나눠서 설명할 예정이므로 다음것도 봐주시길 부탁드립니다!)

1. Spring MVC의 전처리,  후처리

- AOP (Aspect Oriented Programming)

말 그대로 '관점 지향적 프로그래밍'으로 핵심 비즈니스 로직은 아니지만 시스템이 가지는 보안이나 로그, 트랜잭션 같은 반드시 처리가 필요한 부분을 횡단 관심사(cross-concern)이라고 하며 이러한 횡단 관심사를 분리해서 제작하는 것을 지원하는 것

 

  •  프록시 패턴(Proxy 패턴)

proxy : 직접호출 방식이 아닌 간접 호출을 의미

외부에서 특정 객체(target)을 호출하면, 실제 객체를 감싸고 있는 바깥쪽 객체(Proxy)를 통해 호출

Proxy 객체는 AOP 기능이 적용된 상태에서 호출을 받아 사용되고 실제 객체와 동일한 타입을 자동으로 생성할 수 있기 때문에 외부에서는 실제 객체와 동일한 타입으로 호출 할 수 있다.

  • AOP 관련 용어
용어 설명
Aspect 공통 관심사에 대한 추상적인 명칭 ex) 로깅, 보안, 트랜잭션과 같은 기능 자체에 대한 용어
Advice 실제로 기능을 구현한 객체
Join Points 공통 관심사를 적용할 수 있는 대상, Spring AOP에서는 각 객체의 메소드가 이에 해당
Pointcuts 여러 메소드 중 실제 Advice가 적용될 대상 메소드
Target 대상 메소드를 가지는 객체
Proxy Advice가 적용되었을 때 만들어지는 객체
Introduction Target에는 없는 새로운 메소드나 인스턴스 변수를 추가하는 기능
Weaving Advice와 target이 결합 되어 프록시 객체를 만드는 과정
  • Spring의 AOP 구현 클래스 관련 용어
용어 설명
Before Advice Target의 메소드 호출 전에 적용
After returning Targe의 메소드 호출 이후에 적용
After throwing Target의 예외 발생 후 적용
After Target의 메소드 호출 후 예외의 발생에 관계없이 적용
Around Target의 메소드 호출 이전과 이후 모두 적용 (가장 많이 사용)
  • 코드로 이해하기
@Aspect // AOP 클래스 임을 명시 (필수)
@Slf4j // logger 도구
@Component // 빈 생성 (필수)
@NoArgsConstructor // 기본 생성자 호출 (필수)
public class AOPAdvice {
	
	// target joinPoint(method)가 실행 되기 전 호출 
    // (execution({반환 타입} {지정한 메소드 위치}))
    // {모든 반환타입}, {(com.gyumin.service(트리) .*(모든클래스).*(모든메서드) (..)(모든매개변수)}
	@Before("execution(* com.gyumin.service.*.*(..))")
	public void startLog(JoinPoint jp) {
		log.info("--------------------------------------");
		log.info("--------------------------------------");
		log.info("------------- START LOG --------------");
		log.info("target : {}", jp.getTarget()); // 우리가 패턴으로 지정한 클래스(execution)
		log.info("type : {}", jp.getKind()); // 타겟의 종류 알려줌 (호출된 대상자가 method면 method-execution)
		log.info("parameters : {}", Arrays.toString(jp.getArgs())); // 매개변수는 여러개일 수 있으므로
		log.info("name : {}", jp.getSignature().getName()); // 타겟이 되는 joinpoint 메소드 이름
		log.info("----------- START LOG END ------------");
	}
	
	@After("execution(* com.gyumin.service.MessageServiceImpl.*(..))")
	public void endLog() {
		log.info("------------END AFTER LOG-------------");
		log.info("--------------------------------------");
	}
	
	@AfterThrowing(value = "execution(* com.gyumin.service.*.*(..))",
				   throwing = "exception")
	public void endThrowing(JoinPoint jp, Exception exception) {
		log.info("----------------------------------------");
		log.info("----------START @AfterThrowing----------");
		log.info("target : {}", jp.getTarget());
		log.info("name : {}", jp.getSignature().getName());
		log.warn("error : {}", exception.getMessage());
		log.info("---------END @AfterThrowing LOG---------");
		log.info("----------------------------------------");
	}
	
	// 타겟 메소드가 작업 수행 후 정상적으로 값을 반환 하고 난 뒤 수행
	@AfterReturning(
			pointcut="execution(!void com.gyumin.service.MessageServiceImpl.*(..))",
			returning = "returnValue"
			)
	public void successLog(JoinPoint jp, Object returnValue) {
		log.info("----------------------------------------");
		log.info("---------START @AfterReturning----------");
		log.info("target : {}", jp.getTarget());
		log.info("name : {}", jp.getSignature().getName());
		log.warn("return : {}", returnValue);
		log.info("--------END @AfterReturning LOG---------");
		log.info("----------------------------------------");
	}
	
    // @Before, @After를 둘 다 처리 (즉, 전 처리 후 처리 담당) ( object o = pjp.proceed(); )를 기반으로
	@Around("execution(* com.gyumin.service.*.*(..))")
	public Object serviceLog(ProceedingJoinPoint pjp) throws Throwable{
		log.info("----------------------------------------");
		log.info("------------ AROUND START --------------");
		log.info("target : {}", pjp.getTarget());
		log.info("name : {}", pjp.getSignature().getName());
		log.info("parameter : {}", Arrays.toString(pjp.getArgs()));
		
		// Before
		Object o = pjp.proceed();		// target 실체 객체의 pointcut method 호출
		// AFTER
		log.info("around AFTER : {}", o); // 실제 메서드가 반환한 결과
		log.info("-------------- AROUND END --------------");
		log.info("----------------------------------------");
		return o;
	}
}

- 인터셉터(Interceptor)와 필터(Filter)

  • Spring MVC의 인터셉터, 필터

웹 애플리케이션 내에서 특정한 URI 호출을 말 그대로 '가로채는' 역할

- Spring AOP와 인터셉터의 차이 : 전달받는 Parameter
Advice의 경우 JointPoint 또는 ProceedingJoinPoint 등을 활용해 호출대상이 되는 메소드의 parameter 등을 처리하는 방식
Interceptor의 경우 HttpServletRequest, HttpServletResponse를 parameter로 받는 구조

- Filter와 인터셉터의 차이 : 실행 시점에 속하는 영역(Context)이 다름
하지만 특정 URI에 접근할 때 제어하는 용도로 사용하므로 유사한 부분이 많음
  • Interceptor의 주요 메소드
용어 설명
preHandle(request,response,handler) 지정된 컨트롤러의 동작  이전에 가로채는 역할로 사용
postHandle(request,response,handler,modleAndView) 지정된 컨트롤러의 동작이후에 처리, Spring MVC의 Front Controller인 DispatcherServlet이 화면을 처리하기 전에 동작
afterCompletion(request,response,handler,exception) DispatcherServlet의 화면  처리가 완료된 상태에서 처리
  • 코드로 이해하기
@Slf4j // Logger
public class TestInterceptor implements HandlerInterceptor{
	
    /**
     * DispatcherServlet이 handlermapping을 처리하기 전 실행
     *
     */
	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		log.info("TestInterceptor preHandler START ========================");
		// 로직 구현
		log.info("TestInterceptor preHandler END ==========================");
		return true; // true면 컨트롤러에 메소드를 호출한다. false면 호출하지 않는다.
	}

	/**
	 * Controller의 mapping method가 호출 완료되고 난 후
	 * DispatcherServlet으로 반환값이(ModelAndView) 반환 되고 난 후 호출
	 */
	@Override
	public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
			ModelAndView modelAndView) throws Exception {
		log.info("TestInterceptor postHandle START ========================");
		// 로직 구현
		log.info("TestInterceptor postHandle END ==========================");
	}

	/**
	 * DispatcherServlet이 JSP 또는 response 객체를 통해 응답 출력을 하고 난 후 호출
	 */
	@Override
	public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
			throws Exception {
		log.info("TestInterceptor afterCompletion START ========================");
		// 로직 구현
		log.info("TestInterceptor afterCompletion END ==========================");
	}
	
	
}

 

@WebFilter(
	urlPatterns = "/*",
	initParams = {@WebInitParam(name="encoding", value="UTF-8")}
) // 필터 적용 패턴 지정
@Slf4j // Logger
public class PrintFilter implements Filter{ // Filter를 구현한 PrintFilter
	
	/**
	 * 필터 초기화 될 때 한 번 실행
	 */
	@Override
	public void init(FilterConfig filterConfig) throws ServletException {
		log.info("PrintFilter 초기화 시작 ===");
		encodingName = filterConfig.getInitParameter("encoding");
		log.info("PrintFilter 초기화 종료 ===");
	}

	/**
     * 여러개의 필터가 chain에 맞춰 순차적으로 다음 필터로 request, response 전달 후
     * 현재 필터 이후 등록된 다른 필터들이 실행되고 마지막으로 서블릿이나 JSP로 요청 전달
     * 지금은 인코딩 적용 필터 예시 (Spring에서는 인코딩을 지원해주는 CharacterEncodingFilter가 존재)
     */
	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		log.info("PrintFilter doFilter START ============================");
		request.setCharacterEncoding(encodingName);
		chain.doFilter(request, response);
		log.info("PrintFilter doFilter END ==============================");
	}
	
	/**
	 * 필터가 메모리에서 삭제되기 전 한 번 실행
	 */
	@Override
	public void destroy() {
		log.info("PrintFilter destroy() ===");
	}
}

/*
설정 파일에서 
(하지만 현재 Jakarta를 쓰면 자동적으로 필터링이 Utf-8로 설정되어 있으므로 할 필요 전혀 없기도 하고
springframework.web.CharacterEncodingFilter가 존재하므로 일부로 따로 만들 필요가 없음!)
<filter>
    <filter-name>printFilter</filter-name>
    <filter-class>com.gyumin.mvc.filter.PrintFilter</filter-class>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>UTF-8</param-value>
    </init-param>
</filter>

<filter-mapping>
    <filter-name>printFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

이렇게 필터의 인코딩을 지정해두어야 함

이건 xml파일 형식
*/

- Web Application Work-flow

전처리 후처리 순서 (Filter -> Interceptor -> AOP -> Controller -> AOP -> Interceptor -> Filter 순) (좌), Filter Flow(우)

 

Filter와 Interceptor의 Flow (좌), AOP Flow (우)

2. Spring Security란

로그인과 로그아웃, 자동로그인, 권한설정을 직접 구현해왔던 것과 달리 이 기능들의 작업을 구현해둔 보안 프레임워크

프로그램 외에 리소스(이미지 등)에 대한 접근도 제어할 수 있고, CSRF, XSS, 다중 접속 방지 등도 간단하게 구현 가능

스프링 시큐리티가 애플리케이션 보안을 구성하는 두 가지 영역 : 인증과 인가

- 주요 용어
접근 주체(Principal) : 보호된 대상에 접근하는 유저
인증(Authentication) : Principal가 누구인지 확인하는 것, 로그인하는 것을 의미
인가(Authorization) : Principal에 대한 권한을 확인, 허락하는 것 (접속 권한 설정)

- Spring Security 구조

출처 : 학원에서 받은 ppt

Spring Security를 Session-Cookie 방식으로 인증
1. 사용자가 로그인을 시도(Http Request)를 하면, AuthenticationFilter가 요청을 가로채고 AuthenticationManager로 요청을 위임
2. ProviderManager를 구현한 AuthenticationManager는 AuthenticationProvider를 조회하며 인증 요구
3. AuthenticationProvider는 UserDetailsService로 DB와 유저를 비교하고 일치한다면, UserDetilas 객체를 생성하고 결과 반환
4. 마지막으로 AuthenticationFilter에서 기존 애플리케이션의 세션과 다른 인메모리 Session 저장소인 SecurityContextHolder에 Authentication을 담은 객체를 생성하며 유저 정보를 저장
5. 이후 request(요청)에서 request cookie의 JSESSIONID를 검증 후 유효하면 Authentication(인증 사용자 정보)를 제공하는 절차로 3번부터 9번과정 생략
더보기

설명할 게 더 많지만, 여기서 부터는 실전 적용으로 하나씩 하나씩 설명해가려고 합니다. 만약 개념적으로 더 추가할 게 생긴다면 후에 추가하도록 하겠습니다.

3. 실전 적용

실전 적용하기에 앞서서 제 프로젝트 위주로 작성된 글이므로 개인적인 프로젝트에 적용하는 데 이해하기가 어려울 수도 아니면 엄청 쉬울 수도 있습니다.

- 의존성 추가 및 에러 페이지 설정

  • 부트 Spring Security 설정 (maven)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
    <version>${org.springframework-boot-version}</version>
</dependency>

시큐리티를 사용하기 위한 의존성 추가 (org.springframework-boot-version)은 현재 부트 버전 2.7.18 사용 중

 

  • 후에 있을 로그인 실패 로직 또는 권한 처리를 프론트로 알림 출력을 위한 error.jsp 페이지 생성 (contextPath:/error/error 경로)
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>error</title>
</head>
<body>
<script>
const msg = '${message}';
if (msg == '<사용자 설정>') {
	alert('<사용자 설정>');
	window.location.href="<사용자 설정>";
} else {
	alert('알 수 없는 오류가 발생하였습니다. 관리자에게 문의해 주세요.');
	window.history.go(-1);
}
</script>	
</body>
</html>

<!-- <사용자 설정>은 후에 하나씩 추가해서 보여드릴 예정입니다. -->
  • 에러 처리 컨트롤러
@Controller
public class WebErrorController implements ErrorController {
	
	// 에러 처리 - 현재 error.jsp를 오픈 하기 위한 설정이다. (message를 통해 알림을 반환하므로 이렇게 설정)
	@GetMapping("errors")
	public String error(@RequestParam("message") String message, Model model) {
		model.addAttribute("message", message);
		return "/error/error";
	}
	
	@GetMapping("/error")
	public String handleErrorGet(HttpServletRequest request) {
		Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
		
		if (status != null) {
			int statusCode = Integer.parseInt(status.toString());
			
			if (statusCode == HttpStatus.NOT_FOUND.value()) {
				return "error/error_404";
			} else {
				request.setAttribute("message", "wrong");
				return "error/error";
			}
		} else {
			request.setAttribute("message", "wrong");
			return "error/error";
		}
	}
	
	@GetMapping("/error/error_405")
	public void handleError_405 () {}
}

 

이제 여기서 앱을 실행을 하면

초기 페이지

 이런 로그인 페이지가 뜨게 될 것입니다. (현재는 아무것도 설정되어 있지 않아 Security가 자동으로 생성)

Spring Security가 자동으로 <username : user, password : 서버창> 으로 인증을 할 수 있게 됩니다.

하지만! 저희가 원하는 것은 사용자가 설정한 로그인 페이지로 로그인 처리를 하고 싶다!

- Security 설정

Security 설정 파일인 SecurityConfig 파일을 생성해주도록 하겠습니다.

@Configuration
public class SecurityConfig {

	@Autowired
	private LoginSuccessHandler loginSuccessHandler; // 로그인 인증 성공시 처리할 로직
	@Autowired
	private LoginFailureHandler loginFailureHandler; // 로그인 인증 실패시 처리할 로직
	@Autowired
	private AuthenticationDeniedHandler authenticationDeniedHandler; // 로그인 인증이 되지 않은 사용자가 인증 페이지 접속 시 처리할 로직
	@Autowired
	private AuthAccessDeniedHandler authAccessDeniedHandler; // 권한이 없는 사용자가 권한 페이지에 접속 시 처리할 로직
	@Autowired
	private UserDetailsService uds; // UserDetailsService (인증을 처리하는 서비스)
	
	
	/**
	 * 패스워드 인코더
	 * BCrypt (Blowfish 알고리즘 기반)
	 * - Salting(해싱된 비밀번호에 무작위 솔트를 추가하여 레인보우 테이블과 같은해킹 기법을 방어)
	 * - Iterations(해싱 알고리즘을 반복적으로 적용)
	 */
	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http
			.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // csrf 적용
			.and()
			.exceptionHandling() // 예외 처리
				.accessDeniedHandler(authAccessDeniedHandler) // 권한 처리
				.authenticationEntryPoint(authenticationDeniedHandler) // 인증 처리
				.and()
			.authorizeRequests()
				/*
				 * @Bean public WebSecurityCustomizer webSecurityCustomizer() { return (web) ->
				 * web.ignoring().antMatchers("/resources/**"); }
				 * 아래와 같은 것
				 */
				.antMatchers("/resources/**").permitAll() // resources는 현재 css나 이미지 파일이 담겨져 있음 (인증 사용을 하지 않겠다는 의미)
				.antMatchers("/user/logout", "/user/editProfile", "developer/Info").authenticated() // 인증된 사용자만 접속 가능
				.antMatchers("/developer/register", "/developer/profile", "/developer/readPage").hasRole("DEVELOPER") // ROLE_DEVELOPER 권한을 가진 사용자만 접속 가능
				.antMatchers("/business/register").hasRole("BUSINESS") // ROLE_BUSINESS 권한을 가진 사용자만 접속 가능
				.anyRequest().permitAll() // 그 외 나머지 요청은 인증 사용하지 않음
				.and()
			.formLogin() // 로그인 처리
				.loginPage("/user/login").permitAll() // 로그인 페이지 설정
				.usernameParameter("email") // 프론트에서 넘겨줄 username 키 값 설정
				.passwordParameter("password") // 프론트에서 넘겨줄 password 키 값 설정
				.loginProcessingUrl("/user/login") // 로그인 처리가 이루어지는 페이지
				.failureHandler(loginFailureHandler) // 로그인 실패 시 처리할 로직
				.successHandler(loginSuccessHandler) // 로그인 성공 시 처리할 로직
				.and()
			.rememberMe() // 자동 로그인 처리
				.rememberMeCookieName("Id") // 자동 로그인 쿠키에 저장할 키 값 설정
				.rememberMeParameter("checked") // 프론트에서 넘겨줄 rememberMe 키 값 설정
				.tokenValiditySeconds(60*60*24*15) // 쿠키의 지속 시간 (15일)
				.userDetailsService(uds) // 로그인 성공 시 처리할 로직을 사용하기 위한 서비스 객체
				.authenticationSuccessHandler(loginSuccessHandler) // 위 서비스를 설정해야 로그인 성공했을 시의 처리 로직을 사용 가능
				.and()
			.logout() // 로그아웃 처리
				.logoutUrl("/user/logout") // 로그아웃 처리를 위한 url
				.and()
			.headers() // xss 방지를 위해 설정
				.contentSecurityPolicy("script-src 'self' 'unsafe-inline' http://code.jquery.com http://d1p7wdleee1q2z.cloudfront.net http://dapi.kakao.com https://cdn.tiny.cloud http://t1.daumcdn.net" );
			
		return http.build(); // 설정 반환
	}
}
Security를 적용하게 되며 인증관련된 로직인 로그인과 로그아웃 처리를 다음과 같이 설정했다.
기존에는 /user/login 요청이 들어오면 컨트롤러 단에서 처리를 해줬는데
Security를 적용하게 되며 필터로 거쳐 Authentication 설정을 해주었다.

그리고 login.jsp 파일에서 보내는 username은 email 이며, password는 password이다. 그리고 rememberMe는 checked이다.

이렇게 처리를 해주게 되면, 초기 설정 시 생성되었던 로그인 페이지는 직접 설정한 login 페이지로 바뀌게 된다.

 

그리고 우리가 구현할 로그인 처리는

UserDetailsService에서 설정되어야 하므로 먼저 유저 정보를 담을 UserDetails를 구현한 객체를 생성해 주고

@Data
public class CustomUserDetails implements UserDetails {

	private Member member; // composition
	
	public CustomUserDetails(Member member) {
		this.member = member;
	}
	
	/**
	 * 권한을 member 클래스의 Role 속성에 해당하는 값으로 설정
	 * GrantedAuthority는 Spring Security에서 사용자의 권한을 관리하고 처리하기 위해 제공하는 인터페이스
	 * SimpleGrantedAuthority는 GrantedAuthority 인터페이스의 간단한 구현체
	 * 
	 * 다양한 권한을 담기 위해서는 Collection으로 설정
	 * 
     * ROLE_을 붙여준 이유는 Spring Security에서 권한은 무조건 ROLE_~ 로 인식한다.
	 */
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		Collection<GrantedAuthority> authorities = new ArrayList<>();
		authorities.add(new SimpleGrantedAuthority("ROLE_"+member.getRole().toString()));
		return authorities;
	}

	@Override
	public String getPassword() {
		return member.getPassword();
	}

	@Override
	public String getUsername() {
		return member.getEmail();
	}

	/**
	 * 사용자 계정의 만료 여부를 반환 (일반적으로 계정이 만료되지 않았으면 true를 반환
	 */
	@Override
	public boolean isAccountNonExpired() {
		return true;
	}
	
	/**
	 * 사용자 계정의 잠금 여부를 반환 (계정이 잠겨 있지 않으면 true를 반환)
	 */
	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	/**
	 * 사용자 자격 증명(암호)의 만료 여부를 반환 (일반적으로 자격 증명이 만료되지 않았으면 true를 반환)
	 */
	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	/**
	 * 사용자 계정의 활성화 여부를 반환, 활성화된 계정이면 true를 반환
	 */
	@Override
	public boolean isEnabled() {
		return true;
	}

}

 

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

	private final MemberRepository mr;
	
	/**
	 * orElseThrow는 Optional 클래스의 메서드 중 하나이다.
	 * 그리고 isPresent를 사용해 존재하는지 없는지 비교한다. null로 비교 하면 x
	 */
	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
		Member member = mr.findByEmail(email)
				.orElseThrow(() -> new UsernameNotFoundException("Not Exist"));
		return new CustomUserDetails(member);
		
	}
}

다음과 같이 JPA와 CustomUsetDetailsService를 연결시켜 회원정보가 존재하는지 확인하는 UserDetailsService를 만들어 주었다.

이미 SecurityConfig 설정 파일에서 PasswordEncoder를 설정해주었기 때문에, 회원정보가 존재하는지 확인할 때는 DB에 저장되어있는 인코딩을 자동 디코딩해서 비교해준다.

이렇게 설정하고 회원가입 로직을 먼저 구현해서 비밀번호를 설정한 passwordEncoder로 설정해 회원가입 처리를 하고 로그인을 하게 되면, 회원이 존재한다면 CustomUserDetails(member)를 통해 인증된 유저를 SecurityContextHolder 세션에 저장하고 LoginSuccessHandler를 실행하게 될 것이며 존재하지 않는다면, LoginFailedHandler를 실행하게 될 것이다.

- 인증(로그인) 실패와 성공

이제 로그인 실패와 성공 로직을 구현

@Configuration
public class LoginFailureHandler implements AuthenticationFailureHandler {

	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		
		if (exception instanceof BadCredentialsException || exception instanceof InternalAuthenticationServiceException){
			// 실패 상태 코드 설정
		    response.sendRedirect("/errors?message=NotExist");
		} else {
			response.sendRedirect("/errors?message=Error");
		}
	}

}

 

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>error</title>
</head>
<body>
<script>
const msg = '${message}';
if (msg == 'NotExist') {
    <!-- ------------------------------------- 로그인 실패 ------------------------------------------ -->
	alert('아이디 또는 비밀번호가 일치하지 않습니다.');
	window.location.href="/user/login";
} else if (msg == 'loginPlease') {
	<!-- ------------------------------------- 여기서 부터는 권한 처리 ------------------------------------------ -->
	alert('로그인 부터 해주세요.');
	window.location.href="/user/login";
} else if (msg == 'csrf') {
	alert('잘못된 접근 방식 입니다.');
	window.history.go(-1);
} else if (msg == 'business') {
	alert('비즈니스 회원만 접속 가능합니다.');
	window.history.go(-1);
} else if (msg == 'developer') {
	alert('개발자 회원만 접속 가능합니다.');
	window.history.go(-1);
} else if (msg == 'wrongMember') {
	alert('접근할 수 없는 회원 정보 입니다.');
	window.history.go(-1);
    <!-- ------------------------------------- 여기까지 권한 처리 ------------------------------------------ -->
} else if (msg == 'wrong') {
	<!-- ------------------------------------- 예상치 못한 에러 처리 ------------------------------------------ -->
	alert('에러가 어디에서 발생하는가? 정리하기');
	window.history.go(-1);
}

else {
	alert('알 수 없는 오류가 발생하였습니다. 관리자에게 문의해 주세요.');
	window.history.go(-1);
}
</script>	
</body>
</html>

 

로그인이 실패하게 되면, 인증이 실패하게 되는 것이므로, /errors?message=NotExist로 요청을 리다이렉트 시켜 로그인 실패 알림을 알려주었고

@Configuration
public class LoginSuccessHandler implements AuthenticationSuccessHandler{

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		
        /* -> 후에 혹시라도 사용하게 될 수 있을까봐 예시로 생성
        
        // Authentication에 인증된 회원이 저장되어 있으면 가져오기
		CustomUserDetails userDetail = (CustomUserDetails) authentication.getPrincipal();
		Member member = userDetail.getMember();
		
        */
        
		// 회원 정보 session에 저장
		HttpSession session = request.getSession();
		session.setAttribute("loginMember", member);
		response.sendRedirect("/success?message=loginSuccess");
	}
	
	
}

 

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>error</title>
</head>
<body>

	
<script>
const msg = '${message}';
if (msg == 'loginSuccess') {
	alert('로그인 성공');
	window.location.href="/";
} else {
	alert('잘못된 접근 방식 입니다.');
	window.location.href="/";
}
</script>
</body>
</html>

로그인이 성공하게 되면 /success?message=loginSuccess 경로로 리다이렉트 시켜 성공 알림을 띄어주었다.

( 만약 여기서 회원 정보를 담아서 '*** 회원 로그인 성공'을 띄우고 싶다면, message를 보낼 때, 회원정보를 추가해서 보내주면 된다. (만약 회원정보가 한글을 담고 있으면 response 헤더 인코딩을 utf-8로 설정해서 보내줘야함 )

- 로그아웃 처리

로그아웃 매핑 SecurityConfig 설정 파일에서 지정해주었다. /user/logout

따라서

// 로그아웃 처리
@GetMapping("/logout")
public String logout(HttpServletResponse response, @CookieValue(name = "Id", required = false) Cookie cookie,
        HttpSession session, RedirectAttributes rttrs) {
    session.removeAttribute("loginMember");

    if (cookie != null) {
        cookie.setPath("/");
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }

    return "redirect:/";
}

다음과 같이 만약 세션에 로그인된 정보가 존재한다면, 삭제하고, 자동로그인에 저장되어 있는 쿠키 값도 같이 지워주었다.

@CookieValue 어노테이션을 사용해 쿠키 name이 Id인 것의 정보를 삭제하고 홈으로 리다이렉트 시켜주었다.

- 자동로그인 처리

그리고 자동 로그인 또한 SecurityConfig 설정 파일에서 설정해 주었고

이러한 경우, 프론트에서 자동 로그인을 checked로 ture 혹은 false로 보내주게 된다면, Spring Security에서 알아서 자동 로그인을 위한 쿠키세션을 Id로 등록시켜준다.

  • 자동 로그인 추가 설명

일단 자동 로그인 이해를 위해 추가적인 설명을 덧 붙이자면,

기존 인터셉터로 자동 로그인을 구현하려면

public class CheckCookieInterceptor implements HandlerInterceptor {

	@Autowired
	private MemberRepository mr;
	@Autowired
    private PasswordEncoder passwordEncoder; // SecurityConfig 파일에 등록시켜둔 bean

	/**
	 * 자동 로그인 구현
	 */
	@Override
	public boolean preHandle(HttpServletRequest request,
			HttpServletResponse response,
			Object handler)
			throws Exception {

		HttpSession session = request.getSession();
		if (session.getAttribute("loginMember") != null) {
			return true;
		}

		Cookie cookie = WebUtils.getCookie(request, "Id");
		if (cookie != null) {
			String email = cookie.getValue();
			String decodeEmail = passwordEncoder.decode(email);
			Member member = mr.findByEmail(decodeEmail);
			session.setAttribute("loginMember", member);
		}

		return true;
	}

}
세션에 로그인 정보가 있는지 확인 후, 있으면 요청 처리를 보내주고, 없다면, 쿠키 값을 비교해 decode 시켜준 후 세션에 로그인 정보를 새롭게 저장시켜 로그인을 유지 시켜준다.

4. 구현 시연

비밀번호 틀릴 경우
로그인 성공시
로그인 상태 유지 시
로그아웃 시 Id 쿠키 값 사라짐

5. 정리

여기까지가 spring security로 구현한 로그인, 로그아웃, 자동 로그인이다.
현재 SecurityConfig 파일을 보면 알 수 있듯이 권한 처리도 설정해 두었다.

보면 알겠지만, Security는 Filter를 통한 전처리로 Interceptor와 비슷한 모습을 많이 보인다.
작동하는 원리도 Interceptor와 같다고 생각하면 편하다.

추가로
현재 서버는 고전적인 웹 애플리케이션 서버라 서버와 프론트가 독립적이지 않은 형태이다.
따라서 데이터를 전송하지 않고, response.redirect로 에러 페이지를 출력해주고 있는데

만약 프론트와 서버가 독립적인 RESTful한 API 서버라고 한다면, 서버에서 프론트로 보내줄 때, response.getWriter.write()로 response Body에 성공 메시지와 함께 Status를 함께 반환해주고 처리를 하고 프론트 단에서 Status에 따른 처리를 해주면 된다.

 

후에 있을 JWT 토큰을 사용하게 될 때, ajax로 데이터를 처리하는 과정을 통해 API로 보내는 형태도 사용해볼 예정인데 기대해주시면 감사하겠습니다. 다음은 Security와 Interceptor 권한 처리에 대한 Part2로 찾아뵙도록 하겠습니다.

읽어주셔서 감사합니다!
그리고 많은 피드백 부탁드립니다.

 

https://gyumingomin.tistory.com/9

 

[Spring Boot] Spring Security로 권한 처리 및 로그인, 자동 로그인 적용 PART_2 (JSP, Session 기반 웹 애플리

개인적으로 제작 중인 프로젝트에 적용시키면서 실전경험 쌓기 (2024.04.09) https://github.com/GyuminGomin/people-driver GitHub - GyuminGomin/people-driver: busan IT Academy Final Project busan IT Academy Final Project. Contribute to

gyumingomin.tistory.com