지난 글에서는 Spring Security의 흐름을 이해하기 위해 각 인터페이스를 알아보았다. 이번 글에서는 코드를 기능 단위(회원 가입/로그인/권한 처리)로 정리해보았다.
회원 가입
회원가입은 단순 저장이기에, 로그인 인증처럼 시큐리티 필터를 사용하지 않는다. 단, 도메인단만 시큐리티에서 사용하는 UserDetails를 상속받아 구현하였다. 이 프로젝트에서는 User, Admin 두 가지 권한 중 하나를 선택해서 가입할 수 있다. 비밀번호는 BCryptPasswordEncoder을 통해 암호화되어 저장된다.
도메인단 : User
, UserRole
도메인단은 UserDetails를 상속받아 구현하였다.
Copy // 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;
}
회원가입 로직은 단순 저장하는 역할이다. 즉, 서비스단에서 시큐리티를 사용할 필요가 없다. 따로 UserDetailsService
를 상속받아 구현하지 않았다.
Copy 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;
}
}
회원가입 로직에서는 레포지토리단 또한 단순 저장하는 역할이다. 이 또한 시큐리티를 따로 사용하지 않았다.
Copy @ Repository
public interface UserRepository extends JpaRepository < User , Integer > {
public User findByName ( String name);
}
/join은 컨트롤러에만 있고 따로 SpringSecurityConfig에 등록하지 않았다.
Copy @ 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 파일을 통해서 해당 어플리케이션의 모든 요청을 낚아채서 먼저 로그인하게 만들었다. 실패 시 / 성공 시 각자 계정 권한마다 화면이 다르다.
필터 체인(SpringSecurityFilterChain)에 등록, 페이지 별 접근 권한 설정, 로그인 / 로그아웃 url 매핑 등의 설정을 해주었다. Manager에 Provider 등록해주었다.
Copy @ 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()) ;
}
}
Copy @ 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의 아이디/비번이 같은지 검증한다.
Copy @ 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을 가져온다,
Copy 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;
}
}
도메인단은 회원가입에 정리해놓았으므로 생략하였다.
전체 스프링 시큐리티 로그인 구현 코드는 다음 깃허브 브랜치에 있다❕