본문 바로가기

Spring Security

Spring Security를 활용한 Form Login 구현

이번 포스팅에서는 Spring Security를 활용하여 가장 기본이 되는 Form Login 방식을 구현해보려고 합니다.

 

1. Spring Security 기본 세팅

1 - 1. Spring Security 라이브러리 추가

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

 

1 - 2. Spring Security 코드 세팅

1 - 2 - 1. BCryptPasswordEncoder 설정

BCryptPasswordEncoder의 encode()를 이용하여 비밀번호를 암호화 할때 사용합니다.

@Configuration
public class BCryptConfig {
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

1 - 2 - 2. SecurityConfig 설정

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests() // 인증, 인가가 필요한 URL 지정
                .antMatchers("/security-login/info").authenticated() // 해당 URL은 인증 필요
                .antMatchers("/security-login/admin").hasAuthority(UserRole.ADMIN.name()) // 해당 URL은 인증뿐만 아니라 ADMIN이라는 권한 필요
                .anyRequest().permitAll() // 나머지 URL은 모두 허용
                .and()
                .formLogin() // 폼 로그인 방식 적용
                .usernameParameter("loginId") // 로그인 할때 사용되는 Id
                .passwordParameter("password") // 로그인 할때 사용되는 password
                .loginPage("/security-login/login") // 로그인 페이지 URL
                .loginProcessingUrl("/security-login/login") // form action시 시큐리티가 인터셉터하여 로그인 진행
                .defaultSuccessUrl("/security-login") // 로그인 성공시 이동할 URL
                .failureUrl("/security-login/login") // 로그인 실패시 이동할 URL
                .and()
                .logout()
                .logoutUrl("/security-login/logout") // 로그아웃시 이동할 URL
                .invalidateHttpSession(true).deleteCookies("JSESSIONID");
        http
                .exceptionHandling() // 인증, 인가 실패시 처리할 로직
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                // 인증 실패시 이곳에서 로직 처리
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                        response.sendRedirect("/security-login/authentication-fail");
                    }
                })
                .accessDeniedHandler(new AccessDeniedHandler() {
                // 인가 실패시 이곳에서 로직 처리
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                        response.sendRedirect("/security-login/authorization-fail");
                    }
                });
	// security 설정 후 H2 데이터베이스 이용시 이상한 화면 노출되지 않도록 설정
        http.headers().frameOptions().disable();
    }
}

 

1 - 2 - 3. PrincipalDetails 설정

스프링 시큐리티가 사용자의 정보를 알 수 있도록 사용자에 대한 정보를 저장하는 인터페이스입니다.

public class PrincipalDetails implements UserDetails {

    private Users users;

    public PrincipalDetails(Users users) {
        this.users = users;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collections = new ArrayList<>();
        collections.add(() -> {
            return users.getRole().name();
        });

        return collections;
    }

    @Override
    public String getPassword() {
        return users.getPassword();
    }

    @Override
    public String getUsername() {
        return users.getLoginId();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

1 - 2 - 4. PrincipalDetailsService 설정

  • PrincipalDetailsService에서는 직접 구현한 loadUserByUsername이라는 메소드를 사용하여 인증 처리를 하게 됩니다.
  • 인증에 성공하게 되면, SecuritySession이 생성되고 SecuritySession은 SecuritySession(Authentication(UserDetails)) 구조로 되어있기 때문에 PrincipalDetailsService에서 PrincipalDetails를 리턴해주게 됩니다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

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

        return new PrincipalDetails(user);
    }
}

 

2. 코드 구현

2 - 1. Controller 구현

@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) {
            Users loginUser = userService.getLoginUserByLoginId(auth.getName());
            if (loginUser != null) {
                model.addAttribute("nickname", loginUser.getNickname());
            }
        }

        return "user/home";
    }

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

        model.addAttribute("joinRequest", new JoinRequest());
        return "user/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 "user/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 "user/login";
    }

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

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

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

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

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

        return "user/admin";
    }

    @GetMapping("/authentication-fail")
    public String authenticationFail() {
        return "user/auth/authenticationFail";
    }

    @GetMapping("/authorization-fail")
    public String authorizationFail() {
        return "user/auth/authorizationFail";
    }
}

 

2 - 2. Service 구현

@Service
@Transactional
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // Spring Security를 사용한 로그인 구현 시 사용
    private final BCryptPasswordEncoder encoder;

    /**
     * loginId 중복 체크
     * 회원가입 기능 구현 시 사용
     * 중복되면 true return
     */
    public boolean checkLoginIdDuplicate(String loginId) {
        return userRepository.existsByLoginId(loginId);
    }

    /**
     * nickname 중복 체크
     * 회원가입 기능 구현 시 사용
     * 중복되면 true return
     */
    public boolean checkNicknameDuplicate(String nickname) {
        return userRepository.existsByNickname(nickname);
    }

    /**
     * 회원가입 기능 1
     * 화면에서 JoinRequest(loginId, password, nickname)을 입력받아 User로 변환 후 저장
     * loginId, nickname 중복 체크는 Controller에서 진행 => 에러 메세지 출력을 위해
     */
    public void join(JoinRequest req) {
        userRepository.save(req.toEntity());
    }

    /**
     * 회원가입 기능 2
     * 화면에서 JoinRequest(loginId, password, nickname)을 입력받아 User로 변환 후 저장
     * 회원가입 1과는 달리 비밀번호를 암호화해서 저장
     * loginId, nickname 중복 체크는 Controller에서 진행 => 에러 메세지 출력을 위해
     */
    public void join2(JoinRequest req) {
        userRepository.save(req.toEntity(encoder.encode(req.getPassword())));
    }

    /**
     *  로그인 기능
     *  화면에서 LoginRequest(loginId, password)을 입력받아 loginId와 password가 일치하면 User return
     *  loginId가 존재하지 않거나 password가 일치하지 않으면 null return
     */
    public Users login(LoginRequest req) {
        Optional<Users> optionalUser = userRepository.findByLoginId(req.getLoginId());

        // loginId와 일치하는 User가 없으면 null return
        if(optionalUser.isEmpty()) {
            return null;
        }

        Users user = optionalUser.get();

        // 찾아온 User의 password와 입력된 password가 다르면 null return
        if(!user.getPassword().equals(req.getPassword())) {
            return null;
        }

        return user;
    }

    /**
     * userId(Long)를 입력받아 User을 return 해주는 기능
     * 인증, 인가 시 사용
     * userId가 null이거나(로그인 X) userId로 찾아온 User가 없으면 null return
     * userId로 찾아온 User가 존재하면 User return
     */
    public Users getLoginUserById(Long userId) {
        if(userId == null) return null;

        Optional<Users> optionalUser = userRepository.findById(userId);
        if(optionalUser.isEmpty()) return null;

        return optionalUser.get();
    }

    /**
     * loginId(String)를 입력받아 User을 return 해주는 기능
     * 인증, 인가 시 사용
     * loginId가 null이거나(로그인 X) userId로 찾아온 User가 없으면 null return
     * loginId로 찾아온 User가 존재하면 User return
     */
    public Users getLoginUserByLoginId(String loginId) {
        if(loginId == null) return null;

        Optional<Users> optionalUser = userRepository.findByLoginId(loginId);
        if(optionalUser.isEmpty()) return null;

        return optionalUser.get();
    }
}

 

3. 결과

  • 메인 페이지 - 로그인 안한 상태

  • 메인 페이지 - 로그인 한 상태

  • 유저정보 클릭

  • 관리자 페이지 클릭 - 관리자 권한이 없을 시, accessDeniedHandler에 설정한 URL로 이동