4. 로그인 구현 with Spring Security

지난 글에서는 Spring Security의 흐름을 이해하기 위해 각 인터페이스를 알아보았다. 이번 글에서는 코드를 기능 단위(회원 가입/로그인/권한 처리)로 정리해보았다.

회원 가입

회원가입은 단순 저장이기에, 로그인 인증처럼 시큐리티 필터를 사용하지 않는다. 단, 도메인단만 시큐리티에서 사용하는 UserDetails를 상속받아 구현하였다. 이 프로젝트에서는 User, Admin 두 가지 권한 중 하나를 선택해서 가입할 수 있다. 비밀번호는 BCryptPasswordEncoder을 통해 암호화되어 저장된다.

도메인단 : User, UserRole

도메인단은 UserDetails를 상속받아 구현하였다.

// User : 유저엔티티
@Table(name = "user")
public class User implements UserDetails {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    private String name;

    private String password;

    @Column
    @Enumerated(EnumType.STRING)
    private UserRole userRole;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Set<GrantedAuthority> roles = new HashSet<>();
        roles.add(new SimpleGrantedAuthority(userRole.getValue()));
        return roles;
    }

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

    @Override
    public String getUsername() {
        return this.name;
    }

    @Override // 만료 계정인지
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override // 잠긴 계정인지
    public boolean isAccountNonLocked() {
        return true;
    }

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

    @Override // 계정 활성화 여부
    public boolean isEnabled() {
        return true;
    }
}

// UserRole : 계정 권한을 enum타입으로 설정
public enum UserRole {
    USER("ROLE_USER"),
    ADMIN("ROLE_ADMIN");

    private String value;
}
  • 서비스단 : UserService

회원가입 로직은 단순 저장하는 역할이다. 즉, 서비스단에서 시큐리티를 사용할 필요가 없다. 따로 UserDetailsService 를 상속받아 구현하지 않았다.

public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public User createUser(UserJoinRequest userJoinRequest) {
        User user = userRepository.save(
                User.builder()
                        .name(userJoinRequest.getName())
                        .userRole(userJoinRequest.getUserRole())
                        .password(bCryptPasswordEncoder.encode(userJoinRequest.getPassword())).build());
        return user;
    }
}
  • 레포지토리단 : UserRepository

회원가입 로직에서는 레포지토리단 또한 단순 저장하는 역할이다. 이 또한 시큐리티를 따로 사용하지 않았다.

@Repository
public interface UserRepository extends JpaRepository<User, Integer> {
    public User findByName(String name);
}
  • 컨트롤러단 : UserController

/join은 컨트롤러에만 있고 따로 SpringSecurityConfig에 등록하지 않았다.

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService usersService;

    @PostMapping("/join")
    public void createUser(UserJoinRequest userJoinRequest, HttpServletResponse response) throws IOException {
        usersService.createUser(userJoinRequest);
        response.sendRedirect("/login");
    }
}

로그인/로그아웃

스프링 시큐리티는 필터체인이라고 할 수 있다. Config 파일을 통해서 해당 어플리케이션의 모든 요청을 낚아채서 먼저 로그인하게 만들었다. 실패 시 / 성공 시 각자 계정 권한마다 화면이 다르다.

  • SpringSecurityConfig :

필터 체인(SpringSecurityFilterChain)에 등록, 페이지 별 접근 권한 설정, 로그인 / 로그아웃 url 매핑 등의 설정을 해주었다. Manager에 Provider 등록해주었다.

@EnableWebSecurity // SpringSecurityFilterChain 에 등록
@RequiredArgsConstructor
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserDetailsService userDetailsService;

    // 인증에서 제외
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**", "/script/**", "/image/**", "/fonts/**", "lib/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().authorizeRequests()
                .antMatchers("/login", "/join").permitAll()     // 모두 접근 가능
                .antMatchers("/admin").hasRole("ADMIN")         // ADMIN 만 접근 가능
                .antMatchers("/main").authenticated()           // 인증해야 접근 가능
                /* 로그인 폼 */
                .and().formLogin()
                .loginPage("/login")
                .usernameParameter("name")
                .passwordParameter("password")
                .defaultSuccessUrl("/main")
                .failureUrl("/fail")
                .permitAll()
                /* 로그아웃 */
                .and().logout()
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login")
                .deleteCookies("JSESSIONID")
                .invalidateHttpSession(true)
                .permitAll();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // Manager 에 Provider 등록
    @Override
    public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder.authenticationProvider(customAuthenticationProvider());
    }

    // Provider 생성
    @Bean
    public CustomAuthenticationProvider customAuthenticationProvider() {
        return new CustomAuthenticationProvider(userDetailsService, bCryptPasswordEncoder());
    }
}
  • HomeController

@Controller
@RequiredArgsConstructor
public class HomeController {

        private final UserRepository userRepository;

        @GetMapping(value = {"/", "/login"})
        String login() {
        return "login";
        }

        @GetMapping("/fail")
        String fail() {
            return "fail";
        }

        @GetMapping("/join")
        String join() {
            return "join";
        }

        @GetMapping("/admin")
        ModelAndView adminModel(Authentication authentication) {
            User user = Optional.ofNullable(userRepository.findByName(authentication.getName()))
                    .map(u -> User.builder().name(u.getName()).password(u.getPassword()).userRole(u.getUserRole()).build())
                    .orElseThrow(IllegalArgumentException::new);

            ModelAndView modelAndView = new ModelAndView("/admin");
            modelAndView.addObject("user", user);

            return modelAndView;
        }

        @GetMapping(value ="/main")
        ModelAndView mainModel(Authentication authentication) {
            User user = Optional.ofNullable(userRepository.findByName(authentication.getName()))
                    .map(u -> User.builder().name(u.getName()).password(u.getPassword()).userRole(u.getUserRole()).build())
                    .orElseThrow(IllegalArgumentException::new);

            ModelAndView modelAndView = new ModelAndView("/main");
            modelAndView.addObject("user", user);

            return modelAndView;
        }
    }
  • CustomAuthenticationProvider : AuthenticationProvider을 상속받아 구현

authenticate()을 통해 입력받은 Authentication와 DB의 User의 아이디/비번이 같은지 검증한다.

@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final UserDetailsService userDetailsService;

    private final BCryptPasswordEncoder passwordEncoder;

    // 인증 메소드
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String name = authentication.getName();
        String password = (String) authentication.getCredentials();

        User user = (User) userDetailsService.loadUserByUsername(name);

        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException(user.getUsername() + "비밀번호를 다시 입력해주세요.");
        }
        return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }
}
  • CustomUserDetailsService : UserDetailsService 을 상속받아 구현

loadUserByUsername()을 통해 DB의 User을 가져온다,

public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String name) {

        User user = userRepository.findByName(name);
        if (user == null) {
            throw new UsernameNotFoundException("Can't find user");
        }
        return user;
    }
}

도메인단은 회원가입에 정리해놓았으므로 생략하였다.

전체 스프링 시큐리티 로그인 구현 코드는 다음 깃허브 브랜치에 있다❕

Last updated