본문 바로가기

Spring/Spring Security

[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/people-driver development by creating an account on GitHub.

github.com

 

PART1 Security로 로그인, 로그아웃, 자동로그인 구현 부터 보시고 오시면 이해하기 편합니다.

https://gyumingomin.tistory.com/8

 

[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.tistory.com

 

PART2 들어가기에 앞서

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

기본적인 Security 구조 (Security 설명은 PART1에 있습니다.)

3. 실전 적용

- 요청 경로 정리와 수정

시큐리티는 요청 권한을 포매팅 형식으로 지정해 간단하게 코드를 짤 수 있다.

예를 들면 .antMatcher(/user/**).permitAll()

따라서 우선 권한 처리에 따른 경로를 다시 새롭게 정리해 보았다.

[/user/register] [/user/findpass] [/developer/readViewCount] [/developer/readOtherPage] [/developer/search] [/developer/seacrhFirst] [/business/search] [/business/project] 경로로 요청이 들어오면, permitAll (전체 허용)

[/user/logout] [/user/editProfile] [/developer/Info] 경로로 요청이 들어오면, authentication (인증된 회원만)

[/developer/register] [/developer/profile] [/developer/readPage] 경로로 요청이 들어오면, DEVELOPER (권한을 가진 회원만)

[/business/register] 경로로 요청이 들어오면, BUSINESS (권한을 가진 회원만)

우선 정리를 해보니 권한에 따른 규칙성을 찾아보기가 힘들다.

하지만 크게 살펴보면, permitAll은 나머지에 비해 상대적으로 가장 큰 범위를 차지하고 있고,

인증된 회원, 권한에 따른 접속 경로가 설정하기가 편해 보인다.

따라서 그것에 맞춰 Spring Security로 권한을 설정해 보면 아래와 같다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
            .antMatchers("/resources/**").permitAll()
            .antMatchers("/user/logout", "/user/editProfile", "developer/Info").authenticated()
            .antMatchers("/developer/register", "/developer/profile", "/developer/readPage").hasRole("DEVELOPER")
            .antMatchers("/business/register").hasRole("BUSINESS")
            .anyRequest().permitAll()
            .and()
            // 나머지 로직
}
더보기

나의 프로젝트는 요청 경로가 아직 엄청 많은 프로젝트가 아니라 하나씩 지정해줄 수 있었다.

하지만 나의 경우는 bad case이다. (매핑이 많아 지면 많아 질 수록 하나씩 일일이 작성해야 한다.)

그래서 나름 괜찮은 경우는 인증된 회원일 경우 요청 경로 중앙에 /user/auth/logout 처럼 중앙에 auth를 추가해주거나 권한에 따른 dev, bus를 요청 경로 중앙에 추가해서 /*/auth/**, /*/dev/**, /*/bus/** 처럼 분리 시켜주는 거다.

 

사실 컨트롤러를 짤 때, 패스 경로를 권한에 따라 잘 구분해주고 의존성을 최대한 작게 잘 구성할 수 있으면 (MSA) ,  더욱 간단하게 /user/**, /developer/**, /business/** 처럼 권한과 인증된 회원에 따라 쉽게 구별할 수 있다.

기본적으로 security로 지정할 수 있는 권한은 인증과 인가에 대한 권한 처리이다.

따라서 인증된 회원인지, 접속할 권한이 있는지를 URI경로에 따라 권한을 지정할 수 있는데 요청 파라미터에 대해서는 지원을 따로 해주진 않는다.

따라서 기존 인터셉터로 설정했던 권한과 비교해서 추가적으로 해줄 권한 처리는 새로운 인터셉터를 통해 설정하도록 하겠다.

 

- 기존 인터셉터 권한 처리

 

기존에는 인터셉터를 권한에 따라 (로그인된 회원 - AuthMemberInterceptor, DEVELOPER - AuthDeveloperInterceptor, BUSINESS - AuthBusinessInterceptor) 이렇게 3가지로 나눠서 따로 작업을 해주었다.

 

1. 인증된 회원 관련된 인터셉터

public class AuthMemberInterceptor implements HandlerInterceptor {

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		
		// 전체 URL 요청 경로
		String requestURI = request.getRequestURI();
		// 프로젝트 경로
		String contextPath = request.getContextPath();
		// session에 저장된 로그인된 사용자 정보
		Object obj = request.getSession().getAttribute("loginMember");
		
		if (obj == null) {
			// 미로그인된 사용자
			if (request.getMethod().equalsIgnoreCase("get")) {
				response.setContentType("text/html;charset=utf-8");
				PrintWriter out = response.getWriter();
				
				out.println("<script>");
				out.println("alert('Login Please');");
				out.println("location.href='"+contextPath+"/user/login'");
				out.println("</script>");
				return false;
			}
			return true;
		} else {
			// 로그인된 사용자
			return true;
		}
	}
}
인증된 회원 같은 경우는 /user와 관련된 요청 매핑으로 설정하였고, 로그인이 되지 않았을 경우 로그인을 해달라는 요청을 보내는 방식으로 작동하였고

 

2. DEVELOPER 권한 관련된 인터셉터

public class AuthDeveloperInterceptor implements HandlerInterceptor {

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		
		// 전체 URL 요청 경로
		String requestURI = request.getRequestURI();
		// 프로젝트 경로
		String contextPath = request.getContextPath();
		// session에 저장된 로그인된 사용자 정보
		Object obj = request.getSession().getAttribute("loginMember");
		
		String regex = "[-+]?\\d*\\.?\\d+"; // 숫자일때만 확인(게시글 번호가 숫자이므로)

		response.setContentType("text/html;charset=utf-8");
		PrintWriter out = null;
		
		// 미로그인된 사용자
		if (obj == null) {
			if (request.getMethod().equalsIgnoreCase("get")) {
				// get 매핑일 때만
				out = response.getWriter();
				
				out.println("<script>");
				out.println("alert('Login Please');");
				out.println("location.href='"+contextPath+"/user/login'");
				out.println("</script>");
				return false;
			}
			return true;
		// 로그인된 사용자
		} else {
			if (request.getMethod().equalsIgnoreCase("get")) {
				// 로그인 된 사용자가 BUSINESS일 때 접속 불가하게
				Member m = (Member)obj; // 로그인된 사용자 정보
				if (m.getRole() == Role.BUSINESS) {
					if (requestURI.equals("/developer/Info")) {
						return true;
					}
					out = response.getWriter();
					
					out.println("<script>");
					out.println("alert('Only Developer Access Possible');");
					out.println("history.go(-1)");
					out.println("</script>");
					return false;
				} else {
					// 로그인된 사용자가 DEVELOPER
					String uno = request.getParameter("id");
					// 쿼리스트링 id가 숫자일때만
					if (uno != null && !uno.trim().equals("") && uno.matches(regex)) {
						int id = Integer.parseInt(uno); // uno
						Member mem = (Member)obj;
						// 로그인한 회원만 프로필에 등록할 수 있도록 요청id와 로그인된 회원이 같지 않으면
						if (!requestURI.equals("/developer/Info")) {
							if (mem.getId() != id) {
								out = response.getWriter();
								
								out.println("<script>");
								out.println("alert('You Don't Access His/Her Profile');");
								out.println("history.go(-1)");
								out.println("</script>");
								return false;
							} else return true; // 로그인한 회원이랑 요청 쿼리스트링 id가 같으면 요청 허용
						} else return true; // /developer/info 경로로 요청한 것은 id에 상관없이 접근 가능 
						
					} else return true; // 쿼리스트링 id가 숫자가 아닐때 (404가 뜰거임)
				}
			} else return true; // 요청이 POST일 때
		}
		
	}
	
}
여기서도 따로 매핑을 지정해서 로그인 하지 않은 경우와 로그인 후를 나눠
권한에 따라 BUSINESS일 경우 접속하지 못하게 설정하고, 요청 파라미터의 값을 세션에 저장되어 있는 회원 정보와 비교해 다른 회원 정보에는 접속하지 못하게 권한을 설정해두었다.

3. BUSINESS 권한 관련된 인터셉터

public class AuthBusinessInterceptor implements HandlerInterceptor {

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		
		// 전체 URL 요청 경로
		String requestURI = request.getRequestURI();
		// 프로젝트 경로
		String contextPath = request.getContextPath();
		// session에 저장된 로그인된 사용자 정보
		Object obj = request.getSession().getAttribute("loginMember");
		
		String regex = "[-+]?\\d*\\.?\\d+"; // 숫자일때만 확인(게시글 번호가 숫자이므로)
	
		response.setContentType("text/html;charset=utf-8");
		PrintWriter out = null;
		
		// 미로그인된 사용자
		if (obj == null) {
			if (request.getMethod().equalsIgnoreCase("get")) {
				// get 매핑일 때만
				out = response.getWriter();
				
				out.println("<script>");
				out.println("alert('Login Please');");
				out.println("location.href='"+contextPath+"/user/login'");
				out.println("</script>");
				return false;
			}
			return true;
		// 로그인된 사용자
		} else {
			if (request.getMethod().equalsIgnoreCase("get")) {
				// 로그인 된 사용자가 DEVELOPER일 때 접속 불가하게
				Member m = (Member)obj; // 로그인된 사용자 정보
				if (m.getRole() == Role.DEVELOPER) {
					out = response.getWriter();
					
					out.println("<script>");
					out.println("alert('Only Business Access Possible');");
					out.println("history.go(-1)");
					out.println("</script>");
					
					return false;
				} else {
					// 로그인된 사용자가 BUSINESS
					String uno = request.getParameter("id");
					// 쿼리스트링 id가 숫자일때만
					if (uno != null && !uno.trim().equals("") && uno.matches(regex)) {
						int id = Integer.parseInt(uno);
						Member mem = (Member)obj;
						// 로그인한 회원만 등록할 수 있도록 요청 id와 로그인한 회원이 같지 않으면
						if (!requestURI.equals("/business/register")) {
							if (mem.getId() != id) {
								out = response.getWriter();
								
								out.println("<script>");
								out.println("alert('You Don't Access His/Her Profile');");
								out.println("history.go(-1)");
								out.println("</script>");
								return false;
							} else return true; // 같을때,
						} else return true; // business/register가 아닐때
					} else return true; // 숫자가 아닐때 404가 뜰거임
				}
			} else return true; // post 방식일 때
		}
	}

	
}
BUSINESS 또한 로그인과 로그인하지 않은 회원을 나누고, DEVELOPER일 경우 접속 불가와 요청 파라미터와 세션에 등록되어 있는 회원 정보를 비교해 다른 회원 정보를 이용할 수 없게 나누었다.

 

4. 비교

이렇게 하니 반복되는 코드도 많이 발생하고, 요청 경로에 따른 처리도 하나하나 일일이 수정해줘야 하는 과정도 생기는 경우가 있다. 그리고 script 태그를 사용해 직접 보내줘 처리를 하고 있어 XSS 공격 문제도 발생할 수 있었다.
가장 중요한 문제는 요청 경로는 다르지만, 권한이 같게 컨트롤러를 지정해버렸을 경우 하나하나 직접 찾아서 적용하는데 시간과 에너지가 많이 소요된다.

 

그러나 시큐리티를 사용하게 되며 인증된 회원 처리와, 권한 부분을 시큐리티에서 자동적으로 처리하는 부분들이 생기게 되었다.

 

- 시큐리티로 적용

그렇게 시큐리티에서 지원해주지 않는 요청 파라미터에 대한 처리만 따로 해주면 되므로

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

	@Autowired
	private CustomInterceptor customInterceptor;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {

        registry.addInterceptor(customInterceptor)
        		.addPathPatterns("/developer/**")
        		.addPathPatterns("/business/**")
        		.excludePathPatterns("/developer/search")
        		.excludePathPatterns("/developer/searchFirst")
        		.excludePathPatterns("/developer/readOtherPage")
        		.excludePathPatterns("/developer/readViewCount")
        		.excludePathPatterns("/developer/Info")
        		.excludePathPatterns("/business/search")
        		.excludePathPatterns("/business/project");

	}
}

 

위와 같이 새로운 인터셉터 하나만을 추가해서

@Component
public class CustomInterceptor implements HandlerInterceptor {

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
			throws Exception {
		
		Object obj = request.getSession().getAttribute("loginMember");
		
		String regex = "[-+]?\\d*\\.?\\d+"; // 숫자일때만 확인(게시글 번호가 숫자이므로)
		
		Member member = (Member) obj;
		
		// 일단 시큐리티 필터 체인에서 걸러지고
		// 들어올 때는 로그인 된 회원이며, Business 또는 Developer일 것임
		if (member.getRole() == Role.DEVELOPER) {
			// 권한이 Developer일 때 다른 사람 페이지로 접근 못하게 설정
			String uno = request.getParameter("id");
			// 쿼리스트링 id가 숫자일때만
			if (uno != null && !uno.trim().equals("") && uno.matches(regex)) {
				int id = Integer.parseInt(uno);
				if (member.getId() != id) {
					// 멤버의 Id가 일치하지 않으면, 처리 하지 않기로
					response.sendRedirect("/errors?message=wrongMember");
					return false;
				}
			}
			// 나머지는 문제 없으니
			return true;
		} else {
			// 권한이 BUSINESS일 때 다른 사람 페이지로 접근 못하게 설정
			String uno = request.getParameter("id");
			// 쿼리스트링 id가 숫자일때만
			if (uno != null && !uno.trim().equals("") && uno.matches(regex)) {
				int id = Integer.parseInt(uno);
				if (member.getId() != id) {
					// 멤버의 Id가 일치하지 않으면, 처리 하지 않기로
					response.sendRedirect("/errors?message=wrongMember");
					return false;
				}
			}
			// 나머지는 문제 없으니
			return true;
		}	
	}
}

권한에 따른 요청 파라미터와 세션 비교 코드만 넣어주게 되었다.

이렇게 수정하고 나니 권한에 따른 처리와 보안을 적용하기가 한결 더 매끄러워졌고 쉽게 권한에 따른 요청 처리를 나눌 수 있게 되었다.

4. 구현 시연

로그인 되지 않은 상태에서 회원 정보 수정 페이지 이동
DEVELOPER 권한을 가진 회원이 business 권한 페이지 요청
BUSINESS 권한을 가진 회원이 developer 권한 페이지 요청
회원 Id가 1인 회원이 다른 DEVELOPER 페이지 요청

더보기

- 추가적인 설명
현재 에러페이지 관련 처리는 PART1에서 간단히 설명하고 있습니다.
시연 영상에서는 잘 작동하고 있구나 만 확인해주시면 감사하겠습니다!

5. 정리

여기까지가 spring security로 구현한 로그인, 로그아웃, 자동 로그인, 권한 처리 이다.
Session 기반의 인증을 사용하며, 인터셉터로 세션에서 회원 정보를 가져와 등록하는 과정은 생각보다 어렵지 않았지만 부가적인 처리인 에러 페이지 설정과 사용자 편의에 맞춘 UI/UX가 너무 어려웠다.

시큐리티 적용하는 것만 해도 기존 기능과 비슷하게 만들려 하니 많은 코드를 수정하게 되었다. 다음에는 세션처리 방식을 jwt를 활용한 인증, 인가로 바꿀 예정인데 어라.. 눈에 먼지가...

- 추가적인 설명 (JWT와 Session을 사용하기 적합한 환경 및 생각)

 

기본적으로 SPA(Single Page Application)에서는 서버와 프론트엔드 클라이언트가 독립적으로 동작한다.

이 경우 백엔드 서버는 REST API를 통해 데이터를 제공하고, 프론트엔드는 JWT 토큰을 사용해 인증을 처리하는데

이렇게 함으로써 프론트엔드와 백엔드 간의 독립성을 유지하고 확장성을 높일 수 있다.

 

하지만 기존 JSP를 사용하는 고전적인 웹 애플리케이션 서버 같은 경우는 서버와 프론트가 하나로 동작하기 때문에
JWT 토큰 보다는 Session을 사용해 보안을 높이는 경우가 가장 어울린다.

 

후에 JSP를 React로 변환시키는 경우 React 컴포넌트로 제작하고 서버를 RESTful 화 시켜야 하는데 이 과정이 진짜 많이 번거로울 수 있다. 하지만 한 번 경험 해보는 거는 큰 도움이 된다고 생각하니 경험 해보도록 하자


 

다음은 Session -> JWT로 찾아뵙도록 하겠습니다.

읽어주셔서 감사합니다!

그리고 많은 피드백 부탁드립니다.