쉽게 쉽게

[Spring] Spring Security 구현 (2) - 커스텀 본문

개발공부/Spring

[Spring] Spring Security 구현 (2) - 커스텀

곱마2 2024. 11. 5. 15:11
반응형

▤ 목차

    1. Spring Security 커스텀 화면

    Spring Security에서 기본으로 제공하는 로그인 화면과 설정이 있지만 이는 기본적인 수준이라 커스텀이 반드시 필요하다.

    커스텀 구현 과정에서 화면 생성, 커스텀용 클래스 및 빈 생성 등을 진행할 것이다. 

    Spring Security는 AuthenticationManager(ProviderManager)가 가지고 있는 provider 목록을 순회하면서 provider가 실행 가능한 경우에 provider의 authenticate 메서드를 호출하여 인증 절차를 수행한다.

    따라서 4~8에 해당하는 과정을 커스텀하여 만들어줄 것이다.

    전체적인 과정은 아래와 같다.

    login 화면 구현 -> Spring Security 설정 수정 -> custcomUser 생성 (vo) -> AuthenticationProvider 생성 (권한 처리)

    생성할 클래스 목록을 살펴보면 아래와 같다.

    1. customAuthenticationProvider에서 사용하는 custcomUser (로그인 vo)를 생성 
    2. customAuthenticationProvider 로그인 인증을 처리하는 클래스를 생성
    3. customAuthenticationProvider에서 사용되는 customUserDetailsService 생성
    4. 로그인 성공/실패를 관리하는 loginSuccessHandler / loginFailureHandler 핸들러 생성

     1. login 화면 구현

    <%@ page language="java" contentType="text/html; charset=UTF-8"
        pageEncoding="UTF-8"%>
    <%@taglib prefix="tiles" uri="http://tiles.apache.org/tags-tiles"%>
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="UTF-8">
    <title>로그인</title>
    </head>
    <style>
    .login-wrapper{
        width: 400px;
        height: 350px;
        padding: 40px;
        box-sizing: border-box;
    }
    
    .login-wrapper > h2{
        font-size: 24px;
        color: #6A24FE;
        margin-bottom: 20px;
    }
    #login-form > input{
        width: 100%;
        height: 48px;
        padding: 0 10px;
        box-sizing: border-box;
        margin-bottom: 16px;
        border-radius: 6px;
        background-color: #F8F8F8;
    }
    #login-form > input::placeholder{
        color: #D2D2D2;
    }
    #login-form > input[type="submit"]{
        color: #fff;
        font-size: 16px;
        background-color: #6A24FE;
        margin-top: 20px;
    }
    #login-form > input[type="checkbox"]{
        display: none;
    }
    </style>
    <body>
        <div class="login-wrapper">
            <h2>Login</h2>
            <form action="/common/loginProcess.do" method="post" id="login-form">
            	<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" /> <!-- CSRF 토큰 -->
                <input type="text" name="user_id" placeholder="Id">
                <input type="password" name="user_password" placeholder="Password">
                <input type="submit" value="Login">
            </form>
        </div>
    </body>
    </html>

    Spring Security에서 csrf 설정을 비활성화하지 않으면 login 화면에 csrf 토큰을 넣어줘야 한다.

    만약 csrf 토큰을 넣지않으면 403 에러가 발생할 것이다. 

     2. 로그인 성공화면 구현

    <%@ page language="java" contentType="text/html; charset=UTF-8"
        pageEncoding="UTF-8"%>
    <%@taglib prefix="tiles" uri="http://tiles.apache.org/tags-tiles"%>
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="UTF-8">
    </head>
    <body>
    <h2>로그인 성공</h2>
    </body>
    </html>

    2. Spring Security 커스텀 설정

    <?xml version="1.0" encoding="UTF-8"?>
    <beans:beans xmlns="http://www.springframework.org/schema/security"
        xmlns:beans="http://www.springframework.org/schema/beans"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.springframework.org/schema/security
            https://www.springframework.org/schema/security/spring-security-5.8.xsd
            http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans.xsd">
    	
        <!-- HTTP 보안 설정 -->
        <http auto-config="false" use-expressions="true">
        	<!-- csrf 비활성화  -->
        	<!-- <csrf disabled="true" /> -->
            <intercept-url pattern="/resources/**" 	access="permitAll" />
           	<intercept-url pattern="/common/**" access="permitAll" />
        	<intercept-url pattern="/**" access="permitAll" />
            <!-- 로그인 설정 -->
            <!--
    			login-page : form login 폼 페이지 지정
    			default-target-url : 로그인 성공시 이동할 페이지 지정
    			authentication-failure-handler-ref : 로그인 실패시 핸들러
    			authentication-success-handler-ref : 로그인 성공시 핸들러
    			username-parameter : 로그인 id 값
    			password-parameter : 로그인 비밀번호 값
    		-->
            <form-login 
                login-page="/common/login.do"  		
                default-target-url="/common/main.do"
                login-processing-url="/common/loginProcess.do"
                authentication-failure-handler-ref="loginFailureHandler" 
                authentication-success-handler-ref="loginSuccessHandler" 
                username-parameter="user_id"
                password-parameter="user_password" />
    
            <!-- 로그아웃 설정 -->
            <logout logout-url="/common/logout" logout-success-url="/common/login.do" invalidate-session="true" delete-cookies="JSESSIONID"/>
        </http>
    
        <!-- PasswordEncoder 설정 (Spring Security 5.8.1 권장사항) -->
        <beans:bean id="passwordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder"/>
    
    	<!-- 로그인 성공/실패 핸들러 -->
    	  <beans:bean id="loginSuccessHandler"
            class="iancms.global.auth.loginSuccessHandler" />
    
           <beans:bean id="loginFailureHandler"
            class="iancms.global.auth.loginFailureHandler" />
    	
        <!-- CustomAuthenticationProvider 등록 -->
        <authentication-manager>
            <authentication-provider ref="customAuthenticationProvider" />
        </authentication-manager>
        
        <!-- 로그인 권한 과정을 처리할 클래스 -->
        <beans:bean id="customAuthenticationProvider" class="test.global.auth.customAuthenticationProvider" />
    	
    </beans:beans>

    2024.10.31 - [개발공부/Spring] - [Spring] Spring Security 구현 (1) - 설정

     

    [Spring] Spring Security 구현 (1) - 설정

    ▤ 목차1. Spring Security 설정 방법Spring Security 설정에 2가지 방법을 사용할 수 있다는 것을 알았다.java config 방식과 xml 방식인데 이 둘은 설정방법에는 차이가 있다. 이 둘의 차이를 먼저 알아보고

    minsu092274.tistory.com

    이전에 Spring Security 정리했던 설정보다 새롭게 추가한 설정에 대해 설명하고자 한다. 

     1. Spring Security 세부 설정

    CSRF 설정

    • CSRF 설정을 활성화하면 login 화면에서 csrf 토큰값을 넘겨줘야 한다.
    • CSRF 토큰을 비활성화하려면 <csrf disabled="true" />를 추가해 주면 된다.

    form-login (로그인 세부 설정)

    • Spring Security의 로그인 인증을 하는 역할
    • login-processing-url  : 로그인 요청을 처리할 URL을 지정
    • username-parameter : 로그인 폼에서 사용자 ID를 입력받을 input 태그의 name 속성 값을 지정
    • password-parameter : 로그인 폼에서 비밀번호를 받을 input 태그의 name 속성 값을 지정
    • default-target-url : 로그인이 성공하면 이동할 URL을 지정
    • authentication-success-handler-ref : 로그인이 성공하면 호출될 핸들러를 지정
    • authentication-failure-handler-ref : 로그인이 실패하면 호출될 핸들러를 지정
    • logout : 로그아웃 요청을 처리할 URL을 지정

    로그인이 성공/실패시 핸들러를 설정 

    • 로그인 과정 성공/실패시 추가적인 처리를 진행할 수 있는 핸들러를 호출할 수 있다.
    • 없어도 default-target-url로 성공처리를 할 수 있지만 추가적인 기능을 원한다면 구현이 필요하다.(로그인 로그 등)
    • Spring Security의 관련 클래스를 상속받아 구현되며 loginSuccessHandler / loginFailureHandler라고 명명해서 구현할 것이다.

    authentication-manager 설정 

    • authentication-manager는 인증 처리하는 filter로부터 인증처리를 지시받는 첫 번째 클래스
    • Spring Security는 ID와 Password를 Authentication 인증 객체에 저장하고 이 객체를 AuthenticationManager에게 전달
    • authentication-manager는 AuthenticationProvider 목록 중에서 인증 처리 요건에 맞는 AuthenticationProvider를 찾아 인증 처리를 위임
    • Spring Security의 AuthenticationProvider를 상속받는 customAuthenticationProvider라는 클래스를 구현해서 로그인 인증을 처리할 것이다.

    3. custcomUser 구현

    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.List;
    
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import lombok.Setter;
    
    @Getter
    @Setter
    @NoArgsConstructor
    @AllArgsConstructor
    public class customUser implements UserDetails {
    	
    	private static final long serialVersionUID = 1L;
    
    	/** 아이디 */
    	private String user_id;
    
    	/** 비밀번호 */
    	private String user_password;
    
    	/** 이름 */
    	private String user_name;
    	
    	/** 생년월일 */
    	private String user_birth;
    	
    	/** 권한 */
    	/** ROLE_ADMIN, ROLE_USER */
    	private String user_role;
    
    	@Override
    	public Collection<? extends GrantedAuthority> getAuthorities() {
    		List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
    		authorities.add(new SimpleGrantedAuthority(user_role));
            return authorities;
    	}
    	
    	@Override
    	public String getPassword() {
    		// TODO Auto-generated method stub
    		return user_password;
    	}
    
    	@Override
    	public String getUsername() {
    		// TODO Auto-generated method stub
    		return user_id;
    	}
    	
    	@Override
    	public boolean isAccountNonExpired() {
    		// TODO Auto-generated method stub
    		return false;
    	}
    
    	@Override
    	public boolean isAccountNonLocked() {
    		// TODO Auto-generated method stub
    		return false;
    	}
    
    	@Override
    	public boolean isCredentialsNonExpired() {
    		// TODO Auto-generated method stub
    		return false;
    	}
    
    	@Override
    	public boolean isEnabled() {
    		// TODO Auto-generated method stub
    		return false;
    	}
    
    
    }

    lombok을 활용하여 UserDetails를 구현하는 custcomUser를 생성했다.

     1. UserDetails이란?

    UserDetails는 Spring Security가 사용자의 인증을 처리하는 데 필요한 사용자 정보를 제공하는, 사용자의 정보
    (사용자 이름, 비밀번호, 권한, 계정 만료, 비밀번호 만료, 계정 잠금 등)를 담고 있는 인터페이스이다.

    해당 인터페이스에서 구현을 요구하는 메서드는 다음과 같다.

    • Collection<? extends GrantedAuthority> getAuthorities(): 사용자에게 부여된 권한(롤)을 GrantedAuthority 객체의 컬렉션으로 반환
    • String getPassword(): 사용자의 암호화된 비밀번호를 반환
    • String getUsername(): 사용자의 이름을 반환
    • boolean isAccountNonExpired(): 사용자 계정이 만료되지 않았는지 여부를 반환
    • boolean isAccountNonLocked(): 사용자 계정이 잠기지 않았는지 여부를 반환
    • boolean isCredentialsNonExpired(): 사용자의 비밀번호가 만료되지 않았는지 여부를 반환
    • boolean isEnabled(): 사용자 계정이 활성화되었는지 여부를 반환

    custcomUser 구현 목적

    Spring Security의 AuthenticationProvider를 상속받는 customAuthenticationProvider라는 클래스를 구현하는 게 최종 목적인데 AuthenticationProvider에서 UserDetailsService를 통해 사용자 정보를 조회를 진행하며, 그 결과를 UserDetails로 받기 때문이다.

    또한 UserDetails에서 다루는 필수값은 아이디, 비밀번호, 권한이며 내가 원하는 값들을 추가하기 위해서도 custcomUser를 구현할 필요가 있다.

    4. customAuthenticationProvider 생성

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.AuthenticationProvider;
    import org.springframework.security.authentication.BadCredentialsException;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Component;
    
    @Component
    public class customAuthenticationProvider implements AuthenticationProvider {
    
        @Autowired
        private customUserDetailsService userDetailsService;  // UserDetailsService를 주입받아 사용
    
        @Autowired
        private PasswordEncoder passwordEncoder;  // 비밀번호를 인코딩/검증하기 위한 PasswordEncoder
    
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            // 사용자가 입력한 사용자명과 비밀번호를 가져옴
            String username = authentication.getName();
            String password = authentication.getCredentials().toString();
            // UserDetailsService를 통해 사용자 정보를 조회
            UserDetails user = userDetailsService.loadUserByUsername(username);
    		 // 비밀번호가 일치하는지 확인 
    		  if (!passwordEncoder.matches(password, user.getPassword())){ 
    			  throw new BadCredentialsException("Invalid username or password"); 
    			  }
    		 
    		//authentication 객체로 리턴
    		UsernamePasswordAuthenticationToken authenticatedUser = null;
    		authenticatedUser = new UsernamePasswordAuthenticationToken(user, password, user.getAuthorities()); 
            // 인증 성공 시, 인증된 Authentication 객체 반환
            return authenticatedUser;
        }
    
        @Override
        public boolean supports(Class<?> authentication) {
            // UsernamePasswordAuthenticationToken 타입의 인증을 지원
            return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
        }
    }

    customAuthenticationProvider는 로그인 요청한 유저와 DB의 유저 정보를 비교하여 인증을 담당하는 부분이다.

    UsernamePasswordAuthenticationToken으로 반환할 때, 권한은 UserDetails의 getAuthorities를 활용해서 반환한다.

     1. customUserDetailsService 구현

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.stereotype.Service;
    import iancms.domain.user.common.service.commonService;
    import iancms.domain.user.common.vo.customUser;
    
    @Service
    public class customUserDetailsService implements UserDetailsService {
    
        @Autowired
        private commonService commonService;  // 데이터베이스에서 사용자 정보를 가져올 service
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    		try {
    			// 데이터베이스에서 사용자 정보를 조회
    			customUser user = commonService.loginCheck(username);
    			return user;
    			
    		} catch (Exception e) {
    			throw new UsernameNotFoundException("User not found");
    		}
    		
    		
    
        }
    }

    userDetailsService는 Spring Security에서 유저의 정보를 가져오는 인터페이스이다.

    user에 user_id, password, 권한 값은 필수며 나머지 값은 원하는 대로 추가하면 된다.

    필자는 uesr에 모든 값을 넘겨줬다.

    아래는 DB에서 로그인 정보를 불러올 때, 쿼리이다.

    <select id="loginCheck" parameterType="String" resultType="customUser">
    		SELECT
    		*
    		FROM
    		user_mn
    		where
    		user_id = #{user_id}
    	</select>

    5. loginSuccessHandler / loginFailureHandler 생성

     1. loginSuccessHandler 구현 

    import java.io.IOException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    
    import org.springframework.security.core.Authentication;
    import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
    import org.springframework.stereotype.Component;
    import iancms.domain.user.common.vo.customUser;
    
    @Component
    public class loginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler{
    	
    	@Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
    
    		customUser user = (customUser) authentication.getPrincipal();
    		String sAuth = user.getUser_role();
    		String targetUrl = "/";
    		
    		// 권한에 따라 접속 메뉴 구분
    		if (sAuth.equals("ROLE_USER")) {
    			targetUrl = "/common/main.do";
    			
    		} else if (sAuth.equals("ROLE_ADMIN")) {
    			targetUrl = "/admin/main.do";
    		} 
    
    		//페이지 이동
    		response.sendRedirect(targetUrl);
        }
    	
    }

    로그인 성공 시 권한에 따라 페이지 이동을 다르게 할 수 있다.

    추가적으로 로그인 시 로그가 쌓이는 로직을 여기에 구현할수도 있다. 

     2. loginFailureHandler 구현 

    import java.io.IOException;
    import java.net.URLEncoder;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
    import org.springframework.security.authentication.BadCredentialsException;
    import org.springframework.security.authentication.InternalAuthenticationServiceException;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
    import org.springframework.stereotype.Component;
    
    @Component
    public class loginFailureHandler extends SimpleUrlAuthenticationFailureHandler{
    	
    	private static final Logger logger = LoggerFactory.getLogger(loginFailureHandler.class);
    	
    	@Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                            AuthenticationException exception) throws IOException, ServletException {
    
            String errorMessage;
            String inputUserId = (String) request.getParameter("userId");
            if (exception instanceof BadCredentialsException) {
                errorMessage = "아이디 또는 비밀번호가 맞지 않습니다." + "<br>" + "다시 확인해 주세요.";
            } else if (exception instanceof InternalAuthenticationServiceException) {
                errorMessage = "내부적으로 발생한 시스템 문제로 인해 요청을 처리할 수 없습니다." + "<br>" + "관리자에게 문의하세요.";
            } else if (exception instanceof UsernameNotFoundException) {
                errorMessage = "계정이 존재하지 않습니다." + "<br>" + "회원가입 진행 후 로그인 해주세요.";
            } else if (exception instanceof AuthenticationCredentialsNotFoundException) {
                errorMessage = "인증 요청이 거부되었습니다." + "<br>" + "관리자에게 문의하세요.";
            } else {
                errorMessage = "알 수 없는 이유로 로그인에 실패하였습니다." + "<br>" + "관리자에게 문의하세요.";
            }
            
            logger.info("[FAIL] 로그인 실패 : ID={}, MSG={}", inputUserId, exception.getMessage());
            request.getSession().setAttribute("errorMessage", errorMessage);
            
            setDefaultFailureUrl("/common/login.do");
    
            super.onAuthenticationFailure(request, response, exception);
        }		
    	
    }

    로그인 실패 시 에러 메서지를 세션에 담아 로그인 화면에 띄울 수 있다.

    // 로그인 화면	
    	@RequestMapping(value = "/login.do", method = RequestMethod.GET)
    	public String login(HttpSession session , commonDto dto, Model model) throws Exception {
    		
    		// 세션에서 에러 메시지 가져오기
          	String errorMessage = (String) session.getAttribute("errorMessage");
            System.out.print(errorMessage);
    	model.addAttribute("msg", errorMessage);
    	return "common/login.main";
    	}

    컨트롤러에서 세션에 담긴 메세지 값을 받아 화면에서 모달로 띄워준다.

    <!-- 실패 메세지를 출력(modal) -->
        <div id="modalOverlay" class="modal-overlay" ></div>
        <!-- 모달 창 -->
        <div id="myModal" class="modal" style="display:none;">
            <div class="modal-header">
                Notification
                <span id="closeModal" class="modal-close">&times;</span>
            </div>
            <div class="modal-body">
                <p id="modalMessage">${msg}</p>
            </div>
            <div class="modal-button">
                <button id="closeModalButton">Close</button>
            </div>
        </div>
    .modal-overlay {
                display: none;
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background-color: rgba(0, 0, 0, 0.5);
                z-index: 1000;
            }
    
            /* 모달 창 스타일 */
    .modal {
                position: fixed;
                top: 10%;
                left: 50%;
                transform: translate(-50%, -50%);
                background-color: white;
                padding: 20px;
                border-radius: 8px;
                box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
                z-index: 1001;
                width: 300px;
            }
    
            /* 모달 헤더 스타일 */
    .modal-header {
                font-size: 18px;
                margin-bottom: 10px;
            }
    
            /* 모달 닫기 버튼 스타일 */
    .modal-close {
                float: right;
                font-size: 20px;
                cursor: pointer;
            }
    
     /* 모달 본문 스타일 */
    .modal-body {
                margin-bottom: 10px;
            }
    
    /* 모달 버튼 */
    .modal-button {
                display: flex;
                justify-content: flex-end;
            }
    
    .modal-button button {
                padding: 5px 10px;
                cursor: pointer;
            }

    이로써 설정은 마무리했으며, 원하는 권한별 메뉴 경로를 잘 설정하여 마무리하면 될 것 같다.

    https://backend-jaamong.tistory.com/83

     

    [Spring Security] UserDetails, UserDetailsService

    UserDetailsService UserSecurityService는 스프링 시큐리티 설정에 등록할 클래스이다. 이 클래스는 스프링 시큐리티 로그인 처리의 핵심 부분이다. @RequiredArgsConstructor @Service public class UserSecurityService implem

    backend-jaamong.tistory.com

    https://bin-repository.tistory.com/129

     

    [40] spring web security를 이용한 로그인 처리 - 로그인과 로그아웃 처리

    스프링 시큐리티의 내부 구조는 상당히 복잡하지만 실제 사용은 약간의 설정만으로도 처리가 가능하다 1. 접근 제한 설정 security-context.xml에 아래와 같이 접근 제한을 설정한다. 특정한 URI에 접

    bin-repository.tistory.com

    https://gregor77.github.io/2021/05/18/spring-security-03/

     

    Spring Security - 3. 인증 절차를 정의하는 AuthenticationProvider

    Spring Security에서 어떻게 인증이 시작될까?Spring security는 내부에 인증 절차가 이미 구현되어 있다. spring security의 인증 절차를 이해하고 난다면, 구현체와 설정을 통해서 새로운 인증 절차를 추가

    gregor77.github.io

    https://to-dy.tistory.com/70

     

    Spring Security - 기본 설정 (완전 기초)

    1. 라이브러리 추가Spring Security는 Spring 버전에 의존도가 있기 때문에, 의존성(dependency) 관련 버전을 반드시 확인하고 사용해야 한다. 무조건 내가 쓴 버전을 사용해야 한다는 것이 아니다. 의존성

    to-dy.tistory.com

    잘못된 내용이 있다면 지적부탁드립니다. 방문해주셔서 감사합니다.
    반응형