Spring Security란 Java로 만들어진 스프링 프레임워크 기반의 시큐리티 프레임워크이다. Spring Security는 다양한 표준 인증 프로토콜 및 접근제어를 제공하기 때문에 손쉽게 높은 레벨의 보안 기능을 사용할 수 있다. 실제 사용은 생각보다 쉽지만 적용하기가 쉽지 않다. 이는 어느 하나만 적용해서 사용하는게 아니라 여기저기 적용할 부분이 많고, 설정이 까다롭고 에러가 발생해도 원인을 파악하기 쉽지 않기 때문에 사용하기 어렵다고 생각이든다. 또한 새로운 보안 기능을 추가할 경우 더욱더 갈피를 잡지 못해서 어려움을 격는다. 그렇기 때문에 직접 보안기능을 구현해서 사용하는 경우도 많다.
이 글에서는 Spring boot을 기반으로 Spring Security를 사용한 HTML 폼 사용자 인증에 대해 다룰려고 한다.
작성자: http://ospace.tistory.com/ (ospace114@empal.com)
Spring Security란?Spring Security는 Spring에서 보안관련 기능을 제공하는 프레임워크이다. 보안 기능은 Spring의 필터를 확장하여 구현되고 FilterChainProxy에 의해서 필터들을 관리한다. 이런 필터들의 조합으로 Spring Security의 보안 기능들을 제공한다.
Fig 1. Spring Seuciry 구조
Spring Security는 크게 2가지 기능을 제공하고 있다. 하나는 인증이고, 다른 하나는 허가이다. 인증은 사용자가 해당 사용자인지 확인하는 기능이며, 허가는 확인된 사용자를 허용할지 거부할지 판단하는 기능이다.
Spring Security에서 인증은 AuthenticationManager가 담당하며 AuthenticationManager에서 여러 개의 AuthenticationProvider에 의해서 적절한 인증을 수행한다. 허가는 AccessDecisionManager가 담당하며 SecurityContext에 있는 인증 정보를 사용하여 허가여부를 판단한다.
일단 Maven 기반 Spring Boot 프로젝트를 가정해서 시작하겠다.
기본 설정Dependency 추가먼저 Spring boot인 메이븐 프로젝트를 생성한다. 그리고 Spring Security를 사용하기 위해서 기본적인 설정이 필요하다. 먼저 프로젝트 환경 설정에 Spring Security 라이브러리를 추가해야한다. 먼저 Spring Security에 사용하기 위해 depdendency를 pom.xml에 추가한다.
org.springframework.bootspring-boot-starter-security또한, web을 사용하기 위한 dependency를 추가해야 html 페이지 처리할 수 있다.
org.springframework.bootspring-boot-starter-webSecurityConfig 클래스Spring Security를 @EnableWebSecurity 어노테이션으로 설정을 할 수 있다. 먼저 SecuirtyConfig 클래스를 생성하고 @EnableWebSecurity를 지정한다. 이전에는 WebSecurityConfigurer 인터페이스나 WebSecurityConfigurerAdapter를 상속해서 사용했지만 Spring Security 5.7.0-M2 이후에는 deprecated되었다. Spring Security 5.4.0 부터는 SecurityFilterChain, WebSecurityCustomize 빈객체를 생성한다.
@EnableWebSecuritypublic class SecurityConfig {@Beanpublic PasswordEncoder encoder() {return new BCryptPasswordEncoder();}@Beanpublic WebSecurityCustomizer webSecurityCustomize() {return (web)->web.ignoring().requestMatchers("/image/**");}@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf(AbstractHttpConfigurer::disable);http.authorizeHttpRequests(authz->authz.anyRequest().permitAll());return http.build();}}클래스명인 SecurityConfig을 사용할 필요는 없고 원하는 이름을 사용하면 된다. 패스워드 인코더는 BCryptPasswordEncoder를 사용하도록 빈객체를 생성한다. WebSecurityCustomizer은 웹 관련 설정을 한다. 웹 기본 설정으로 위의 예제는 requestMatchers()를 사용해서 "/image"에 경로는 Spring Security에 적용을 받지 않는다. 다음으로 SecurityFilterChain 에서 HttpSecurity는 HTTP 관련 보안 설정을 한다. 이는 가장 직접적인 HTTP 요청에 대해 설정하는 부분으로 빈번히 사용된다. csrf를 disable을 해줘야 한다. 이는 CSRF(Cross-site request forgery)로 다른 사이트로 요청 위조라는 의미로 정당하지 않은 행위를 사용자 의지와 무관하게 다른 사이트를 공격하는 행위를 의미한다. disable한다고 해서 공격을 허용하는 것으로 받아들일 수 있지만, 여기서는 spring security의 기본 기능을 파악하기 위해서 이므로 csrf을 사용할 경우 추가 작업이 필요해서 테스트에 어려움이 많다. csrf 사용은 추후에 다룰려고 한다. 만약 실무에서 사용한다면 브라우저를 사용하는 일반 사용자인 경우 csrf를 사용하고 그렇지 않은 경우는 csrf을 사용하지 않는다. 혹시나 해서 REST Api을 사용할 경우도 csrf를 비활성화해야 한다.
가장 기본적인 설정까지 진행했다. 앞으로 진행을 하면서 필요한 설정을 중간에 계속 추가하면서 처리하도록 하겠다.
사용자 등록먼저 기본적인 사용자 등록을 만들어보자. 이 부분은 별도 기능으로 직접 구현해서 처리하는 부분이다. 사용자 등록, 사용자 인증 순으로 다룰려고 한다.
클래스User 클래스먼저 사용자 정보를 저장할 클래스를 정의해보자.
public class User {private String id;private String pwd;public String getId() {return id;}public void setId(String id) {this.id = id;}public String getPwd() {return pwd;}public void setPwd(String pwd) {this.pwd = pwd;}}최대한 단순하게 만들었다. 필요한 필수 정보만 포함하였다.
UserRepository 클래스사용자 정보를 관리하는 클래스로 구현 단순화를 위해 외부 연동 없이 내부 메모리에 단순하게 추가 및 조회 기능을 제공한다.
@Componentpublic class UserRepository {private final Map users = new HashMap();public void add(User user) {users.put(user.getId(), user);}public User get(String id) {return users.get(id);}}HTML 페이지사용자 등록 페이지(register.html)사용자 정보를 입력할 HTML 폼 페이지를 작성한다. 사용되는 사용자 정보가 아이디와 패스워드로 단순하기 때문에 폼 페이지도 단순하다. 폼페이지는 POST 형식으로 "/register"으로 호출하고 있다. 사용자 등록 페이지 작성을 위해 /resource/static/ 디렉토리에 register.html 파일을 생성해서 아래 내용을 입력한다.
Register아래는 등록 페이지 모습이다.
Fig 2. 등록 페이지
기본 페이지(index.html)홈페이지에 접속시 표시될 기본 페이지를 작성하다. 기본 페이지는 /resource/static 디렉토리에 index.html파일을 생성하면 된다. 단순 링크만 있는 네비게이터 페이지이다. 페이지 내용 중에 사용할 수 없는 링크도 있다. 앞으로 나올 예제로 인해 미리 추가 해놓은 것이다.
TitleHomeRegisterLoginLogoutUser아래는 기본 페이지 모습이다.
Fig 3. 기본 페이지
사용자 페이지(/user/index.html)사용자 페이지는 로그인된 사용자가 보이는 페이지 화면이다. 사용자 페이지는 /resource/static/user에 index.html 파일을 생성한다.
TitleHomeHomeLogout아래는 사용자 페이지 모습이다.
Fig 4. 사용자 페이지
사용자 등록처리페이지 작업이 끝났다면 HTML 폼에서 입력받아 처리를 해보자.
@Controllerpublic class DemoController {@Autowiredprivate UserService userService;@PostMapping("/register")public String register(User user) {userService.add(user);return "redirect:/";}}사용자 등록 페이지에서 “/register” POST 요청을 하고 DemoController에서 사용자 정보를 입력받고 패스워드는 암호화해서 저장한다. 이때 암호화는 BCryptPasswordEncoder를 사용한다. 그리고 UserRole은 "BASIC"으로 기본 처리를 한다. 사용자 정보는 UserRepository에 저장된다. 사용자 추가가 완료되면 "/" 페이지로 리다이렉트된다.
클래스UserService 클래스사용자 정보를 등록할 UserService 클래스도 추가한다.
@Servicepublic class UserService {@Autowiredprivate UserRepository userRepo;@Autowiredprivate PasswordEncoder passwordEncoder;public void register(User user) {user.setPwd(passwordEncoder.encode(user.getPwd()));userRepo.add(user);}}여기까지 해서 기본적인 사용자 등록작업을 마쳤다.
등록테스트기본 페이지로 접속한다Register 링크로 가면 등록 페이지가 보인다.UserID와 Password를 입력하고 "가입하기" 버튼을 입력하면 사용자 등록이 된다.등록이 완료되면 기본 페이지로 이동된다.생각보다 간단하게 작업이 끝났다. UserRepository는 추후에 DB로 연동되는 DAO로 대처할 수 있다. 여기서는 간단하게 처리하기 위해 단순히 메모리에서 처리하는 방식으로 작성하였다.
사용자 로그인사용자 로그인을 처리해보자. 사용자 id와 passsword를 입력받아서 등록된 사용자를 조회하여 인증처리한다. Spring Security에서 내부적으로 UserDetails를 사용해서 사용자 인증과 권한를 처리하고 있다. 실제로는 Spring Security의 User 클래스를 상속받아서 처리한다.
클래스SecurityUser 클래스여기서 만든 User 클래스는 Spring Security의 User 클래스로 변환해서 넘겨줄 클래스가 필요하다. SecurityUser 클래스는 여기서 만든 User 클래스를 받아서 변환한다.
public class SecurityUser extends org.springframework.security.core.userdetails.User {public SecurityUser(User user) {super(user.getId(), user.getPwd(), new ArrayList());}}UserService 클래스UserService 클래스는 UserRepository와 Spring Security 간에 사용자 정보를 넘겨줄 매개체 역할을 한다. UserDetailService을 상속받아서 loadUserByUsername()를 구현해서 이를 통해서 사용자 정보를 UserRepository에서 검색해서 Spring Security의 사용자 정보로 변환해서 넘겨준다. UserDetailService를 상속받아서 Service를 구현하기 하면 Spring Security에서 자동으로 인식하여 등록한다. 즉, AuthenticationProvider 역활을 한다. Spring Security에서 사용자 정보를 조회할때 사용된다. 사용자 정보를 찾을 수 없다면 UsernameNotFoundException 예외를 발생한다.
@Servicepublic class UserService implements UserDetailsService {@Autowiredprivate UserRepository userRepo;private PasswordEncoder passwordEncoder;public void setPasswordEncoder(PasswordEncoder passwordEncoder) {this.passwordEncoder = passwordEncoder;}@Overridepublic UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {User user = userRepo.get(id);if(null == user) throw new UsernameNotFoundException(id);return new SecurityUser(user);}public void register(User user) {userRepo.add(user);}}인코더를 Spring Security 초기화할 때 주입할 수 있도록 수정했다.
HTML 페이지로그인 페이지(/login.html)사용자 로그인 페이지를 만들어보 등록 페이지와 거의 동일하다. 다른 부분은 action이 별도로 없다. 이는 Spring Security에서 알아서 로그인 처리를 해준다. 즉, Spring Security에서 로그인 페이지를 인지하고 해당 액션이 호출되었을 경우 login 요청 처리를 한다. 주의할 것은 id 입력 컨트롤의 이름은 "username"으로 지정하고 password는 "password"로 사용해야 한다. 그렇게 해야 Spring Security에서 해당 값을 인지하여 로그인 처리할 수 있다.
LoginLogin아래는 로그인 페이지 모습이다.
Fig 5. 로그인 페이지
로그아웃 페이지(/thankyou.html)단순히 로그아웃이 성공되었을 때에 호출되는 페이지이다.
Thank You Thank You Home아래는 로그아웃 페이지 모습이다.
Fig 6. 로그아웃 페이지
Spring Security 설정앞의 내용을 하나로 연결해보자. 지금까지 Spring Security설정은 모두 허용된 상태라서 아무 페이지나 들어갈 수 있다. 이제 User 페이지는 인증된 사용자만 접근하도록 제한할려고 한다. 그리고 로그인과 로그아웃 요청에 대한 처리도 추가한다. Spring Security가 사용자 정보를 조회할 UserService관련 설정하고, 로그인 요청을 처리할 페이지를 지정하고 로그아웃 성공시 표시할 페이지를 설정해보자.
@EnableWebSecuritypublic class SecurityConfig {@Autowiredprivate UserService userService;@BeanPasswordEncoder encoder() {return new BCryptPasswordEncoder();}@BeanDaoAuthenticationProvider authProvider() {DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();PasswordEncoder encoder = encoder();userService.setPasswordEncoder(encoder);authProvider.setUserDetailsService(userService);authProvider.setPasswordEncoder(encoder);return authProvider;}@Beanpublic WebSecurityCustomizer webSecurityCustomize() {return (web)->web.ignoring().requestMatchers("/image/**");}@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf(AbstractHttpConfigurer::disable);http.authorizeHttpRequests(req->req.requestMatchers("/", "/index.html", "/register.html", "/register", "/login.html", "/thankyou.html").permitAll().anyRequest().authenticated()).formLogin(login->login.loginPage("/login.html").failureUrl("/login.html")).logout(logout->logout.logoutSuccessUrl("/thankyou.html"));return http.build();}}AuthenticationProvider을 제공하기 위해 DaoAuthenticationProvider에서 UserServic와 사용할 패스워드 인코더를 지정해서 리턴한다.
그리고 SecurityFilterChain에서 formLogin()에 의해서 로그인관련 설정을 할 수 있다. requestMatchers()에 의해서 인증없이 바로 호출할 수 있는 페이지를 지정하여 permitAll()한다. 그리고 나머지 경우에 대해서는 인증하도록 설정한다. loginPage()로 로그인에 사용할 URL 주소을 지정한다. failureUrl()에서 인증 실패할 경우 호출할 URL 주소를 지정한다. 여기서는 "/login.html"로 지정하여 로그인이 실패하거나 인증실패할 경우 로그인을 하도록 유도한다. 다음으로 logoutSuccessUrl()은 로그아웃 성공시 호출할 URL 주소를 지정한다.
인증 테스트마지막으로 제대로 동작하는지 확인한다.
먼저 사용자 등록을 마친다.그리고 Login 링크를 클릭하면 사용자 id와 password를 입력한다.정상적으로 처리가 되면 이전에 접속했던 페이지로 이동한다.그리고 다시 Logout를 선택하고 성공하면 로그아웃 페이지가 표시된다.만약 인증없이 User 페이지로 이동한다면 인증 실패로 인해 로그인 페이지로 자동 이동한다.
GitHub프로젝트 경로: https://github.com/ospace/spring-works/tree/main/spring-security-01
결론Spring Security 버전 5.4.0에서 많은 기능이 추가되었고 5.7.0-M2 이후로는 이전 보안 기능이 사라졌다. 그렇기에 5.7.0-M2 버전부터 사용하는데 있어서도 많은 변경사항이 발생했다. 이런 변경사항은 참조[1]을 확인하시기 바란다. 또한 버전 6.0 부터도 반경된 부분이 많이서 사용하는 버전에 따라서 확인이 필요하다. 이렇게 해서 간단하게 입력 폼을 사용하고 UserDetailsService를 사용한 사용자 인증 방식을 살펴보았다. 물론 이외에 다양한 형태로 구조화할 수 있지만 차후 확장성이나 변경에 있어서 이런 구조를 선택하게되었다. 부족한 내용이지만 여러분에게 도움이 되었으면 하네요. 모두 즐거운 코딩생활되세요. ^^ ospace.
참조[1] Spring Security without the WebSecurityConfigureAdapter, https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter
[2] Spring Boot 기반 Spring Security 회원가입 / 로그인 구현하기, https://xmfpes.github.io/spring/spring-security/
[3] Cross Site Request Forgery, https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html