반응형

Spring Security 란?

  • Spring에서 제공하는 애플리케이션의 보안 (권한, 인증, 인가 등)을 담당하는 프레임워크
    • Spring Security는 인증과 권한에 대한 부분을 Filter의 흐름에 따라 처리
  • [Spring Boot] Session을 사용한 로그인 구현에서 세션을 통해 로그인 하는 방법을 정리했었음
    • 로그인 성공 시 직접 세션에 "userId"라는 attributes에 userId를 담아 로그인을 진행했었음
  • Security에는 Security Session이 존재하고, 로그인 성공 시 여기에 Authentication을 넣어줘야 함
  • Authentication에는 UserDetails라는 유저 정보를 또 넣어 줘야함
  • Security Session(Authentication(UserDetails)) 이런 식의 포맷을 직접 맞춰줘야 함
  • 대부분의 사이트를 개발할 때 로그인 기능은 거의 필수적인데 이를 체계적으로 만들 수 있도록 도와준다고 보면 될듯함

Spring Security를 사용한 로그인 구현 (Form Login)

  • Spring Security를 사용하여 Form Login을 진행해보는 예제

라이브러리 추가

  • Spring Security를 사용하기 위해 라이브러리 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

BCRyptPasswordEncoder 생성

  • Spring Security에서는 비밀번호를 암호화 해주는 BCRyptPasswordEncoder가 존재
  • 이를 Spring에 등록해놓고 비밀번호 암호화, 비밀번호 체크할 때 사용하면 됨
@Configuration
public class BCryptConfig {
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

SecurityConfig 생성

  • Spring Security에 대한 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/security-login/info").authenticated()
                .antMatchers("/security-login/admin/**").hasAuthority(UserRole.ADMIN.name())
                .anyRequest().permitAll()
                .and()
                .formLogin()
                .usernameParameter("loginId")
                .passwordParameter("password")
                .loginPage("/security-login/login")
                .defaultSuccessUrl("/security-login")
                .failureUrl("/security-login/login")
                .and()
                .logout()
                .logoutUrl("/security-login/logout")
                .invalidateHttpSession(true).deleteCookies("JSESSIONID");
    }
}
  • authorizeRequest() : 인증, 인가가 필요한 URL 지정
  • antMatchers(URL)
    • authenticated() : 해당 URL에 진입하기 위해서 Authentication(인증, 로그인)이 필요함
    • hasAuthority() : 해당 URL에 진입하기 위해서 Authorization(인가, ex)권한이 ADMIN인 유저만 진입 가능)이 필요함
    • URL에 ** 사용 : ** 위치에 어떤 값이 들어와도 적용시킴
    • antMatchers(HttpMethod.POST, URL) : 이런 식으로 특정 HttpMethod만 검사 할 수도 있음
  • anyRequest() : 그 외의 모든 URL
    • permitAll() : Authentication, Authorization 필요 없이 통과
  • formLogin() : Form Login 방식 적용
    • usernameParameter() : 로그인할 때 사용되는 ID를 적어줌
      • 이 예제에서는 loginId로 로그인하기 때문에 따로 적어줘야 함
      • 만약 userName으로 로그인을 한다면 적어주지 않아도 됨
    • passwordParameter() : 로그인할 때 사용되는 password를 적어줌
      • 이 예제에서는 password로 로그인하기 때문에 따로 적어주지 않아도 됨
    • loginPage() : 로그인 페이지 URL
    • defaultSuccessURL() : 로그인 성공 시 이동할 URL
    • failureURL() : 로그인 실패 시 이동할 URL
  • logout() : 로그아웃에 대한 정보

PrincipalDetails 생성

  • 우리가 직접 로그인 처리를 안해도 되는 대신 지정해줘야 할 정보들
    • POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨
  • 로그인에 성공하면 Security Session을 생성해 줌 (Key값 : Security ContextHolder)
  • Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 PrincipalDetails에서 UserDetails를 설정해준다고 보면 됨
public class PrincipalDetails implements UserDetails {

    private User user;

    public PrincipalDetails(User user) {
        this.user = user;
    }

    // 권한 관련 작업을 하기 위한 role return
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collections = new ArrayList<>();
        collections.add(() -> {
            return user.getRole().name();
        });

        return collections;
    }

    // get Password 메서드
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    // get Username 메서드 (생성한 User은 loginId 사용)
    @Override
    public String getUsername() {
        return user.getLoginId();
    }

    // 계정이 만료 되었는지 (true: 만료X)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정이 잠겼는지 (true: 잠기지 않음)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    // 비밀번호가 만료되었는지 (true: 만료X)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 계정이 활성화(사용가능)인지 (true: 활성화)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

PrincipalDetailsService 생성

  • 이전까지는 UserService에서 findUserByLoginId 이런 식으로 User을 불러왔었음
  • Security는 User 객체가 아닌 PrincipalDetails가 필요하기 때문에 따로 설정이 필요 => 이 서비스에서는 Security에서 사용할 PrincipalDetails를 return
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByLoginId(username)
                .orElseThrow(() -> {
                    return new UsernameNotFoundException("해당 유저를 찾을 수 없습니다.");
                });
        return new PrincipalDetails(user);
    }
}

Controller 구현

  • Entity, DTO, Repository, Service, 화면은 미리 만들어 놓음
    • [Spring Boot] 로그인 구현 방법 정리 참고
    • 공통 화면 사용을 위해 모든 요청에 아래 코드 추가
      • model.addAttribute("loginType", "security-login");
      • model.addAttribute("pageName", "Security 로그인");
@Controller
@RequiredArgsConstructor
@RequestMapping("/security-login")
public class SecurityLoginController {

    private final UserService userService;

    @GetMapping(value = {"", "/"})
    public String home(Model model, Authentication auth) {
        model.addAttribute("loginType", "security-login");
        model.addAttribute("pageName", "Security 로그인");

        if(auth != null) {
            User loginUser = userService.getLoginUserByLoginId(auth.getName());
            if (loginUser != null) {
                model.addAttribute("nickname", loginUser.getNickname());
            }
        }

        return "home";
    }

    @GetMapping("/join")
    public String joinPage(Model model) {
        model.addAttribute("loginType", "security-login");
        model.addAttribute("pageName", "Security 로그인");

        model.addAttribute("joinRequest", new JoinRequest());
        return "join";
    }

    @PostMapping("/join")
    public String join(@Valid @ModelAttribute JoinRequest joinRequest, BindingResult bindingResult, Model model) {
        model.addAttribute("loginType", "security-login");
        model.addAttribute("pageName", "Security 로그인");

        // loginId 중복 체크
        if(userService.checkLoginIdDuplicate(joinRequest.getLoginId())) {
            bindingResult.addError(new FieldError("joinRequest", "loginId", "로그인 아이디가 중복됩니다."));
        }
        // 닉네임 중복 체크
        if(userService.checkNicknameDuplicate(joinRequest.getNickname())) {
            bindingResult.addError(new FieldError("joinRequest", "nickname", "닉네임이 중복됩니다."));
        }
        // password와 passwordCheck가 같은지 체크
        if(!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
            bindingResult.addError(new FieldError("joinRequest", "passwordCheck", "바밀번호가 일치하지 않습니다."));
        }

        if(bindingResult.hasErrors()) {
            return "join";
        }

        userService.join2(joinRequest);
        return "redirect:/security-login";
    }

    @GetMapping("/login")
    public String loginPage(Model model) {
        model.addAttribute("loginType", "security-login");
        model.addAttribute("pageName", "Security 로그인");

        model.addAttribute("loginRequest", new LoginRequest());
        return "login";
    }

    @GetMapping("/info")
    public String userInfo(Model model, Authentication auth) {
        model.addAttribute("loginType", "security-login");
        model.addAttribute("pageName", "Security 로그인");

        User loginUser = userService.getLoginUserByLoginId(auth.getName());

        if(loginUser == null) {
            return "redirect:/security-login/login";
        }

        model.addAttribute("user", loginUser);
        return "info";
    }

    @GetMapping("/admin")
    public String adminPage( Model model) {
        model.addAttribute("loginType", "security-login");
        model.addAttribute("pageName", "Security 로그인");

        return "admin";
    }
}
  • 전에 다뤘던 쿠키 로그인, 세션 로그인 과는 달리 @PostMapping("/login"), @GetMapping("/logout"), @GetMapping("/info")에서의 인증(로그인 체크), @GetMapping("/admin")에서의 인가(권한 체크)하는 부분이 없음
    • 이는 Spring Security가 로그인, 로그아웃, 인증, 인가를 모두 진행해주기 때문

결과

  • 메인 페이지 (로그인 하지 않은 상황)

  • 메인 페이지 (로그인 한 상황)

  • 유저 정보 페이지

  • 관리자 페이지 (인가 실패, 쿠키나 세션과는 달리 Spring Security에서 에러를 발생 시켜줌 => Http Error 403(Forbidden)이 발생해야 함)

  • 관리자 페이지 (ADMIN 계정으로 로그인 한 경우)

추가 (인가를 할 수 있는 다른 방법)

  • 위의 예제에서는 SecurityConfig에 URL을 지정해줌으로써 인증, 인가를 진행
  • 다른 방법으로 인증, 인가를 진행할 수도 있음
  1. SecurityConfig에 아래의 어노테이션 추가
@EnableGlobalMethodSecurity(prePostEnabled = true)
  1. 인가를 진행하고자 하는 URL에 매핑되어있는 메소드에 아래와 같은 어노테이션을 적절히 추가
@PreAuthorize("hasAuthority('ADMIN')")
@PreAuthorize("hasAnyAuthority('ADMIN', 'USER')")
반응형

↓ 클릭시 이동

복사했습니다!