본문 바로가기
Development/Spring Boot

스프링부트 Spring Security 기본 세팅 (스프링 시큐리티)

by Nahwasa 2021. 10. 30.
[ 2023-02-10 추가 ]
   스프링부트 3.0 이상에 적용하실 경우 '스프링부트 3.0이상 Spring Security 기본 세팅 (스프링 시큐리티)' 글을 참고해주세요. 버전 상관없이 시큐리티 기본 세팅을 익히실 경우에도 위 링크가 더 좋습니다.

목차

     

      이하 내용은 스프링부트 2.6.2를 기준으로 작성되었으나, 스프링부트 2.7.8 까지는 이하 글에서 설명한대로 동작함을 확인했습니다. 다만, 2.7.X 대에서는 deprecated로 표시된게 있을 수 있습니다.

     

     

    [ 예시 프로젝트 ]
      이하 글에서는 import를 빼놓고 코드를 넣어두었고, 부분부분 따로 있는 코드들이 있어서 완전 초보라면 이해하기 힘들 것 같습니다. 또는 당장 급하게 복붙할 스프링 시큐리티 코드를 찾는 경우도 있을 수 있습니다 ㅋㅋ.

       이하 예시 코드는 스프링부트 + 스프링시큐리티 기본세팅 + jsp 정도만 적용시킨 코드이니 위의 경우라면 이걸 참고하시면 될 것 같습니다.

    - 예시 프로젝트 (스프링부트 2.6.2 기준) : github
    - 예시 프로젝트 (스프링부트 2.7.7 기준) : github

    - 예시 프로젝트 (스프링부트 3.0.2 기준) : github (3.0.2용 코드를 만드는 과정은 '이 글'에 작성되어 있습니다.)


     

    Spring Boot - Spring Security

     

      스프링부트로 된 프로젝트에 스프링 시큐리티를 적용하는 기본 세팅에 대해 다루는 글 입니다. 스프링 시큐리티는 프로젝트 짤 때 로그인한 유저에 대해 쉽게 관리할 수 있게 해줍니다. 세팅만 할 수 있다면 유저의 id와 pw에 따른 로그인 진행(인증과정), 권한(ROLE) 부여와 권한에 따른 접근 제어, 유저 정보 보관, 세션 관리 등을 간단하게 처리할 수 있습니다.

     

      하지만 스프링쪽은 세팅방식이 너무 다양해서 다양한 유저의 니즈(?)를 충족시켜주는건 좋으나, 초보자 입장에서는 프로젝트에 뭔가를 적용해보려고 찾아봐도 자신의 프로젝트 환경과 비슷한걸 찾아야 적용하기가 편해서 일반적으로 세팅이 그리 쉽진 않습니다.

     

      이 글에서는 스프링 시큐리티의 전체적인 구조와 같은 원론적인 내용보다는 기본 세팅에 대한 코드를 보여주고, 해당 코드들이 무슨 역할을 하는지를 위주로 설명합니다. 기본 세팅만 일단 성공적으로 끝나면 추가로 필요한것들을 찾아서 붙이는건 어렵지 않으므로, 개인 프로젝트 진행할 때나 스프링부트를 사용한 프로젝트 기본 세팅에 문제없이 사용할 수 있습니다. 다만 전 xml을 통한 세팅보다는 필요한 기능 선에서 최대한 깔끔한 코드로 세팅하는게 더 직관적이라 생각하여 좋아하므로 좀 더 복잡한 세팅이 필요하다면 구조를 변경해야 할 수도 있습니다.

     

      서술 방식은 아주 기본적인 코드에서 시작해서, 그 때 그 때 필요한 기능들을 추가하며 번호가 올라갈수록 최종적으로 제가 생각한 기본 세팅을 만들어 갈껍니다. 따라서 이해를 위해서라면 순서대로 보시면 되고, 당장 프로젝트 세팅하는데 복붙할 코드만 필요하다면 글 맨 위에 있는 '예시 프로젝트'를 보시면 됩니다. 이 글에서는 최종적으로 아래 3개의 .java 파일로 기본 세팅을 진행합니다. gradle 추가하는 것 외에 다른 뭐 xml이나 properties는 건드리지 않았습니다. SHA512PasswordEncoder는 선택사항입니다. 일반적으로는 세팅하지 않으셔도 되니, 사실상 2개의 파일이면 되겠네요.

     

     

    1. 스프링 시큐리티 시작!

    일단 Gradle에 스프링 시큐리티를 추가해줘야 합니다.

    implementation 'org.springframework.boot:spring-boot-starter-security'

     

    [ LoginIdPwValidator ]

    아직 필요 없음

     

    [ SHA512PasswordEncoder ]

    아직 필요 없음

     

    [ SpringSecurityConfig ]

    @Configuration
    @EnableWebSecurity
    public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests()
                        .anyRequest().authenticated()
                    .and()
                        .formLogin()
                        .defaultSuccessUrl("/view/dashboard", true)
                        .permitAll()
                    .and()
                        .logout();
        }
    }

      WebSecurityConfigurerAdapter를 상속해서 클래스를 하나 만드시면 됩니다. 클래스명은 뭘로하던지 상관 없습니다. 그리고 어노테이션 @Configuration과 @EnableWebSecurity를 추가하면 됩니다.

     

      각종 설정은 WebSecurityConfigurerAdapter에 오버로딩 되어 있는 다양한 configure 함수를 오버라이드 해서 설정할 수 있습니다. 우선 위와 같이 HttpSecurity만 인자로 가지는(메소드 시그니쳐에 따라 다양한 configure 가능) configure를 오버라이딩 해줍니다.

     

    A. '.anyRequest().authenticated()'에서 어떠한 URI로 접근하던지 인증이 필요함을 설정합니다.

    B. 'formLogin()'에서 폼방식 로그인을 사용할 것임을 알리고, logout도 필요하니 logout도 추가해줍니다.

    C. 'defaultSuccessUrl'로 로그인 성공 시 이동할 uri를 적어줍니다.

     

     

     

    2. 스프링 시큐리티 예외 처리

      서버를 클라우드에 올리는 경우, 일반적으로 로드밸런서를 사용중이라면 로드밸런서에 상태 체크 url을 작성해야 합니다. 그 외에도 로그인 하지 않아도 볼 수 있는 소개페이지 등은 로그인을 하지 않아도 볼 수 있어야 합니다. 이런 경우 스프링 시큐리티에서 인증을 진행하지 않아야만 정상적으로 처리가 가능합니다. 이처럼 로그인 없이 접근 가능해야하는 URI는 SpringSecurityConfig에 '.antMatchers("/chk").permitAll()'와 같이 예외를 설정할 수 있습니다. 그럼 .antMatchers("/chk").permitAll()에서 /chk는 인증 안할꺼고, .anyRequest().authenticated() 에서 그 외 다른건 전부 인증 필요해! 가 됩니다. (.antMatchers에 추가로 넣으려면 .antMatchers("/chk", "/intro") 와 같이 여러개를 넣으시면 됩니다. '*'을 사용해 패턴 설정도 가능합니다.) 

     

      또 백엔드, 프론트엔드가 분리되지 않은 프로젝트의 경우(스프링부트에서 jsp나 타임리프를 붙여서 하나의 프로젝트로 백엔드+프론트엔드 전부 처리하는 프로젝트) css나 이미지 파일 등의 경우 인증이 되지 않은 상태에서도 보여져야 하는 경우가 대부분입니다. 이 경우 별도로 WebSecurity 하나를 인자로 갖는 configure를 오버라이딩해서 예외 처리를 할 수 있습니다.

     

    [ LoginIdPwValidator ]

    아직 필요 없음

     

    [ SHA512PasswordEncoder ]

    아직 필요 없음

     

    [ SpringSecurityConfig ]

    @Configuration
    @EnableWebSecurity
    public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests()
                        .antMatchers("/chk").permitAll()    // LoadBalancer Chk
                        .anyRequest().authenticated()
                    .and()
                        .formLogin()
                        .defaultSuccessUrl("/view/dashboard", true)
                        .permitAll()
                    .and()
                        .logout();
        }
    
        @Override
        public void configure(WebSecurity web) throws Exception {
            web.ignoring().antMatchers("/static/js/**","/static/css/**","/static/img/**","/static/frontend/**");
        }
    }

      위에서 말한 내용들을 추가해줬습니다. 아래에서 설명하겠지만, 로그인 페이지를 커스텀 페이지로 변경해둔 경우, WebSecurity 예외처리를 하지 않을 경우 이미지 등도 인증이 필요해 지기 때문에 다음과 같이 화면이 깨지게 됩니다.

     

     

     

    3. 로그인 화면을 커스텀 페이지로 변경 (+ROLE에 따른 접근 처리, logout 커스텀)

      위의 '2'까지의 과정만 진행해도 로그인 화면이 생기긴합니다. 스프링 시큐리티에서 기본적으로 제공해주는 화면으로 위와 같이 생겼습니다. 하지만 일반적으로 개인 프로젝트라 하더라도 로그인 화면은 별도로 만드는 경우가 대부분 입니다. 따라서 로그인 화면을 별도로 만든 커스텀 페이지로 변경해 보겠습니다. 추가로 따로 번호 붙여 작성하긴 애매한 ROLE에 따른 접근 처리와 logout 주소 변경에 대해서도 다룹니다.

     

    [ LoginIdPwValidator ]

    아직 필요 없음

     

    [ SHA512PasswordEncoder ]

    아직 필요 없음

     

    [ SpringSecurityConfig ]

    @Configuration
    @EnableWebSecurity
    public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests()
                        .antMatchers("/chk").permitAll()    // LoadBalancer Chk
                        .antMatchers("/manage").hasAuthority("ROLE_ADMIN")
                        .anyRequest().authenticated()
                    .and()
                        .formLogin()
                        .loginPage("/view/login")
                        .loginProcessingUrl("/loginProc")
                        .usernameParameter("id")
                        .passwordParameter("pw")
                        .defaultSuccessUrl("/view/dashboard", true)
                        .permitAll()
                    .and()
                        .logout()
                        .logoutRequestMatcher(new AntPathRequestMatcher("/logoutProc"));
        }
    
        @Override
        public void configure(WebSecurity web) throws Exception {
            web.ignoring().antMatchers("/static/js/**","/static/css/**","/static/img/**","/static/frontend/**");
        }
    }

      위의 코드 중 formLogin 아래 추가된 부분이 커스텀 페이지로 변경하는 설정입니다. 

    A. '.loginPage("/view/login")' 에서 커스텀 페이지로 로그인 페이지를 변경합니다.

     

    B. '.loginProcessingUrl("/loginProc")' 은 별도로 Controller에 만들어야 하는게 아니고, formLogin 방식이므로 해당 주소를 어디로 처리할지 정해주는 겁니다. 그럼 저 '/view/login'에서 '<form method="post" action="/loginProc">'와 같이 form의 action을 정해주면 알아서 스프링 시큐리티쪽으로 id와 pw를 보내게 됩니다.

     

    C. '.usernameParameter("id")'는 유저 아이디에 해당하는 form의 name을 변경합니다. 이 부분은 없어도 되며, 그럼 default는 'username' 입니다. 위와 같이 변경했다면 input은 '<input type="text" name="id">' 처럼 되겠네요.

     

    D. '.passwordParameter("pw")'는 마찬가지로 유저 비밀번호 부분에 해당합니다. input은 '<input type="password" name="pw">' 처럼 되겠네요.

     

      다음으로 유저 ROLE에 따라서도 접근제어가 가능합니다.

     

    E. '.antMatchers("/manage").hasAuthority("ROLE_ADMIN")' 부분처럼 처리하면 됩니다. 그럼 해당 사용자가 ADMIN의 role을 가지고 있어야만 '/manage' 이하의 uri에 접근 가능하게 됩니다. ROLE은 DB에 넣어두면 되겠죠.

     

      또한 logout시 호출하는 uri도 변경할 수 있습니다.

     

    F. '.logoutRequestMatcher(new AntPathRequestMatcher("/logoutProc"))' 부분 처럼 처리하면 '/logoutProc'을 호출할 시 로그아웃이 되고, 그럼 인증된게 사라지니 다시 로그인 페이지로 자동으로 이동되게 되는 것입니다. 이 부분은 생략 가능해서 이렇게 서브로 넣었습니다. 생략 시 default로 '/logout' 호출 시 로그아웃이 가능합니다.

     

     

     

    4. id, pw가 맞는지 확인하자! (인증 처리)

      사용자가 로그인하려고 입력한 id와 pw는 일반적으로 id와 암호화된 pw를 회원가입 시 DB에 저장해두고, 로그인할 때 입력받은 id를 통해 DB에서 정보를 가져와 비교하는 방식으로 많이 개발됩니다. 이 과정을 처리하기 위해서는 우선 UserDetailsService를 구현(implements)하는 클래스를 하나 만들어서 거기서 처리할 수 있도록 해줍니다. 그리고 '1'과 '2'에서 설명하던 SpringSecurityConfig 파일에 해당 클래스로 id, pw 인증을 처리하겠다고 설정하면 됩니다.

     

    [ LoginIdPwValidator ]

    @Service
    public class LoginIdPwValidator implements UserDetailsService {
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
        
        @Autowired
        private UserMapper mapper;
    
        @Override
        public UserDetails loadUserByUsername(String insertedId) throws UsernameNotFoundException {
            UserInfo user = mapper.getUserInfo(insertedId);
            
            if (user == null) {
                return null;
            }
            
            String pw = user.getPw(); //"d404559f602eab6fd602ac7680dacbfaadd13630335e951f097af3900e9de176b6db28512f2e000b9d04fba5133e8b1c6e8df59db3a8ab9d60be4b97cc9e81db"
            String roles = user.getRoles(); //"USER"
    
            return User.builder()
                    .username(insertedId)
                    .password(pw)
                    .roles(roles)
                    .build();
        }
    }

      id, pw에 따른 인증을 처리하기 위한 UserDetailService 구현체 입니다. 로그인 진행 시 사용자가 입력한 id가 loadUserByUsername의 인자로 들어옵니다. 그럼 DB의 유저 정보로 SELECT 해주시면 됩니다. getUserInfo(); 에 매핑된 쿼리는 "SELECT * FROM USER_INFO WHERE USER_ID = 'dev'" 정도로 동작하겠네요.

     

     이 때 쿼리에 SELECT된게 없다면 그냥 return null; 정도로 처리하셔도 됩니다. 여기서 UsernameNotFoundException을 throw해서 명확히 입력한 id가 잘못되었다는걸 알려도 되겠지만, 요즘 대부분 id 혹은 pw가 잘못되었다고 메시지를 띄우니까요.

     

      @Bean 을 통해 PasswordEncoder를 정하는 저 부분도 중요합니다. 유저 pw의 암호화 방식을 정하는 부분 입니다. 일반적으로는 그냥 저 BCryptPasswordEncoder를 쓰시면 됩니다. 프로젝트에 따라 암호화 방식이 정해진 경우도 많으므로 4번에서는 이 부분도 커스텀 해보겠습니다. 참고로 이 부분은 프로젝트 내 어디에 있던 상관 없습니다. SpringSecurityConfig쪽에 넣어도 되고, 아예 다른 클래스에 넣어놔도 됩니다. 전 여기에 넣는게 제일 깔끔한 구성이라 생각해서 여기에 넣었을 뿐입니다.

     

      그리고 pw는 여기서 비교하지 않습니다. 유저의 pw와 역할(roles)은 그 이하의 return문에 넣어주시면 됩니다. 그럼 어떻게 비밀번호가 DB와 맞는지 체크해요?! 하시겠는데, 바로 위에서 @Bean으로 지정한 PasswordEncoder쪽에 스프링 시큐리티가 저희가 넣은 DB의 pw와 유저가 입력했던 pw를 넣어 비교합니다. 즉, 여기까지는 유저가 입력한 pw가 뭔지 개발자한테도 감춰두겠다는 얘기죠. (물론 커스텀한 Encoder를 쓰면 뭐 어떻게든 알 수 있긴합니다.)

     

      주의점은, 당연하게도 유저의 pw는 해당 프로젝트를 운영하는 입장의 사람도 몰라야 정상입니다. 따라서 DB에는 이미 암호화 된 pw가 들어있어야만 합니다. 만약 혹시라도 pw 그 자체가 DB에 들어있다면 애초에 잘못된 프로젝트라 판단되긴하지만, 그 경우 위 laodUserByUsername에서 pw를 직접 인코딩한 후에 '.password(pw)' 처럼 호출해줘야 합니다. 물론 회원가입 시에도 pw는 위 @Bean으로 설정해둔 Encoder를 통해 인코딩 후 저장을 해줘야 하겠죠.

     

    [ SHA512PasswordEncoder ]

    아직 필요 없음

     

    [ SpringSecurityConfig ]

    @Configuration
    @EnableWebSecurity
    public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
        @Autowired
        LoginIdPwValidator loginIdPwValidator;
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests()
                        .antMatchers("/chk").permitAll()    // LoadBalancer Chk
                        .anyRequest().authenticated()
                    .and()
                        .formLogin()
                        .loginPage("/view/login")
                        .loginProcessingUrl("/loginProc")
                        .usernameParameter("id")
                        .passwordParameter("pw")
                        .defaultSuccessUrl("/view/dashboard", true)
                        .permitAll()
                    .and()
                        .logout()
        }
    
        @Override
        public void configure(WebSecurity web) throws Exception {
            web.ignoring().antMatchers("/static/js/**","/static/css/**","/static/img/**","/static/frontend/**");
        }
        
        @Override
        public void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.userDetailsService(loginIdPwValidator);
        }
    }

      위에서 만든 LoginIdPwValidator를 Autowired를 통해 주입하고, 아래 AuthenticationManagerBuilder를 인자로 갖는 configure를 추가해줬습니다. 저렇게 설정하면 이제 유저가 id와 pw를 입력한 후 form이 발송되면 LoginIdPwValidator 쪽으로 id가 넘어가 비교할 수 있게 됩니다.

     

     

     

    5. 비밀번호 암호화 방식 커스텀

      개인 프로젝트라면 그냥 '3'까지의 내용으로 쓰시면 됩니다. 하지만 프로젝트에 따라 암호화 방식이 달라지는 경우가 있습니다. 이번엔 SHA-512 방식으로 암호화 해서 사용해보는 경우를 알아보겠습니다. 물론 다른 암호화 방식으로 써도 마찬가지이며, 아예 암호화 방식을 직접 만들어보는것도 재밌으니 한번 해보세요.

     

    [ LoginIdPwValidator ]

    ...
    
    @Bean
    public PasswordEncoder passwordEncoder() {
    	return new SHA512PasswordEncoder();
    }
    
    ...

      PasswordEncoder만 저렇게 새로 만든 Encoder로 변경해주시면 됩니다. 

     

     

    [ SHA512PasswordEncoder ]

    public class SHA512PasswordEncoder implements PasswordEncoder {
        private final Log logger = LogFactory.getLog(getClass());
    
        @Override
        public String encode(CharSequence rawPassword) {
            if (rawPassword == null) {
                throw new IllegalArgumentException("rawPassword cannot be null");
            }
            return this.getSHA512Pw(rawPassword);
        }
    
        @Override
        public boolean matches(CharSequence rawPassword, String encodedPassword) {
            if (rawPassword == null) {
                throw new IllegalArgumentException("rawPassword cannot be null");
            }
            if (encodedPassword == null || encodedPassword.length() == 0) {
                this.logger.warn("Empty encoded password");
                return false;
            }
    
            String encodedRawPw = this.getSHA512Pw(rawPassword);
            if (encodedPassword.length() != encodedRawPw.length()) {
                return false;
            }
            for (int i = 0; i < encodedPassword.length(); i++) {
                if (encodedPassword.charAt(i) != encodedRawPw.charAt(i))
                    return false;
            }
            return true;
        }
    
        private String getSHA512Pw(CharSequence rawPassword) {
            MessageDigest md = null;
            try {
                md = MessageDigest.getInstance("SHA-512");
                md.update(rawPassword.toString().getBytes());
            } catch (Exception e) {
                this.logger.warn(e.getMessage());
            }
    
            byte[] msgb = md.digest();
    
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < msgb.length; i++) {
                String tmp = Integer.toHexString(msgb[i] & 0xFF);
                while (tmp.length() < 2)
                    tmp = "0" + tmp;
                sb.append(tmp.substring(tmp.length() - 2));
            }
            return sb.toString();
        }
    }

      결국 PasswordEncoder를 구현(implements) 하시면 됩니다. 그럼 encode와 matches를 구현해야 합니다. encode는 문자를 인코딩하는 부분, matches는 사용자가 입력한 비밀번호(rawPassword쪽으로 들어옴)와, DB에 들어있던 이미 암호화되어있는 비밀번호(encodedPassword 쪽으로 들어옴)를 비교하는 부분입니다. rawPassword는 스프링 시큐리티에서 알아서 넣어주고, encodedPassword는 위 LoginPwValidator에서 User.builder() 부분에 .password(pw)를 통해 넣어준 문자가 넣어집니다.

     

      encode는 동일한 문자가 들어오면 동일한 return이 나가게만 짜시면 됩니다. matches도 마찬가지입니다. 위 코드는 제가 SHA-512 방식에 맞게 짜본 Encoder 소스 입니다. 그냥 각자 역량에 맞게 적절하게 짜시면 됩니다. 

     

    [ SpringSecurityConfig ]

    '3'에서 변경된 부분 없음

     

     

     

    최종 코드

    https://github.com/NaHwaSa/SpringBootSomething/tree/main/SpringSecurityBasicSetting

     

     

    [ ps. Login 페이지의 경우 ]

    ...
    <form method="post" action="/loginProc">
    	...
    	<input type="text" name="id">
    	...
    	<input type="password" name="pw" maxlength="32" >
    	...
    	<button type="submit">로그인</button>
    </form>
    ...

    html 부분에 form 태그로 그 사이사이의 내용이야 어찌됬든 위의 요소들만 잘 들어 있으면 됩니다. action, name 들은 모두 커스텀한 경우입니다.

     

     

    [ ps. Controller에서 유저 정보 얻기 ]

    ...
    @GetMapping("/view/template_add")
    public String dashboard(@AuthenticationPrincipal User userInfo) throws Exception {
    	
    	return "/template_add";
    }
    ...

      위와 같이 인자에 '@AuthenticationPrincipal User 변수명' 을 넣어두면 컨트롤러쪽에서 바로 유저 정보를 얻을 수 있습니다. 그러니까 뭐 게시판 만들 때 작성자 id를 DB에 넣는다고 웹쪽 세션스토리지에 로그인 id 담아두거나, hidden 처리된 input같은 곳에 넣어뒀다가 빼와서 ajax 태울 때 같이 보내거나 하지 않아도 됩니다.

     

    댓글