Framework/Spring

[Spring Boot]Spring Security - 3) 로그인 구현

  • -
반응형

지난 글에 이어 본 글에서는 스프링 시큐리티의 로그인 기능을 커스텀하는 방법을 소개합니다. 소스와 내용이 기존 글에서부터 이어지기 때문에 지난 글을 보지 않으셨다면 이어서 보시는게 좋습니다 :)


스프링 시큐리티의 로그인 절차는 아래와 같이 흘러간다. 여기서 AuthenticationProviderUserDetailsService를 구현함으로써 상황에 맞는 비즈니스 로직을 녹여낼 수 있다.

AuthenticationProvider는 스프링 시큐리티의 인증 절차를 담당하는 인터페이스로 부가적인 설명은 이 글에서 확인할 수 있다.

 

1. 사용자 정의 인터페이스 구현

UserDetails를 상속받는 UserEntity를 생성한다. 각각의 메소드들은 운영 환경에 맞게 수정해서 사용하면 된다. 여기서는 권한을 고정으로 넣었고 이외 계정에 관한 정보를 모두 true로 설정했다.

@Data
@Entity(name="USER_INFO")
@NoArgsConstructor
public class UserEntity implements UserDetails {
    @Id
    private String userId;
    private String userPw;
    private String userName;

    @Builder
    public UserEntity(
            String userId,
            String userPw,
            String userName) {
        this.userId = userId;
        this.userPw = userPw;
        this.userName = userName;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collectors = new ArrayList<>();
        collectors.add(() -> {
            return "ROLE_USER";
        });

        return collectors;
    }

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

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

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

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

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

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

 

2. 사용자 조회 메소드 추가

UserEntity 타입의 사용자 조회 메소드를  추가한다. 여기서 인자값으로 추가된 username에는 사용자 아이디를 입력받게 된다.

@Repository
public interface UserRepository extends JpaRepository<UserEntity, String> {

    public UserEntity findByUserId(String username);
}

 

3. UserDetailsService 구현

loadUserByUsername 메소드를 상속받아 구현한다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    
    private final UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity user = userRepository.findByUserId(username);

        if (user == null) throw new UsernameNotFoundException("User not exist");

        return user;
    }
}

 

4. AuthenticationProvider 구현

3.에서 추가한 CustomUserDetailsService를 주입받아 사용자 정보를 체크하고, PasswordEncoder를 이용해 화면에서 넘어온 패스워드와 사용자 패스워드를 비교한다.

1.에서 언급한 각각의 메소드들을 사용함에따라 분기문을 이용해 예외 처리를 하는식으로 입맛에 맞게 사용이 가능하다.

@Component
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {

    private final CustomUserDetailsService customUserDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = (String) authentication.getPrincipal();
        String password = (String) authentication.getCredentials();

        UserEntity user = (UserEntity) customUserDetailsService.loadUserByUsername(username);

        try {
            //패스워드 체크
            if (!passwordEncoder.matches(password, user.getUserPw())) {
                throw new BadCredentialsException("Password is invalid");
            }

            if (!user.isAccountNonExpired()) {
                //계정 만료 여부
                throw new CredentialExpiredException("Account is expired");
            } else if (!user.isAccountNonLocked()) {
                //계정 잠금 여부
                throw new AccountLockedException("Account is locked");
            } else if (!user.isEnabled()) {
                //계정 사용 여부
                throw new LockedException("Can't use account");
            } else if (!user.isCredentialsNonExpired()) {
                //계정 비밀번호 만료 여부
                throw new CredentialExpiredException("Credentials is expired");
            }
        } catch (CredentialExpiredException e) {
            e.printStackTrace();
        } catch (AccountLockedException e) {
            e.printStackTrace();
        }

        //인증이 완료 후 객체 리턴
        return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(UsernamePasswordAuthenticationToken.class);
    }

}

 

5. 로그인 성공 핸들러

로그인 성공시 실행되는 객체로 AuthenticationSuccessHandler를 상속받아 onAuthenticationSuccess 메소드안에서 로그인 성공 후 부가적인 로직을 작성해 사용하면 된다.

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.sendRedirect("/");
    }
}

 

6. 로그인 실패 핸들러

5.과 반대로 로그인 실패시 실행되는 객체로 onAuthenctaionFailure 메소드안에서 실패시 각각의 예외에 대해 쿼리파람을 붙여 로그인 페이지로 리다이렉트되도록 했다. 로그인 실패시 부가적인 작업이 필요한 경우 이 곳에 로직을 작성해 사용하면 된다.

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        if (exception instanceof BadCredentialsException) {
            //패스워드가 틀린 경우
            response.sendRedirect("/login?error=invalid");
        } else if (exception instanceof UsernameNotFoundException) {
            //계정이 존재하지 않는 경우
            response.sendRedirect("/login?error=account");
        } else if (exception instanceof LockedException) {
            response.sendRedirect("/login?error=lock");
        } else {
            response.sendRedirect("/login?error=invalid");
        }
    }

}

 

7. Security Config 수정

@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Override
    public void configure(WebSecurity web) throws Exception {
        //static 하위 파일은 인증 대상에서 제외
        web.ignoring().antMatchers("/css/**");
        web.ignoring().antMatchers("/favicon.ico");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        log.info("======================================");
        log.info(">> Run on Spring security");
        log.info("======================================");

        http.authorizeRequests()
                .antMatchers("/login").permitAll()
                .antMatchers("/join").permitAll()
                .antMatchers("/user/join").permitAll()
                .anyRequest().authenticated();

        //로그인 설정
        http.formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/authenticate")
                .successHandler(customAuthenticationSuccessHandler)
                .failureHandler(customAuthenticationFailureHandler)
                .permitAll();

        http.logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/login")
                .invalidateHttpSession(true);

        http.exceptionHandling()
                .accessDeniedPage("/denied");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

}

 

8. 로그인 페이지 생성

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>회원가입</title>
</head>
<body>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
<link th:href="@{/css/sign-in.css}" rel="stylesheet">
<style>
  #floatingInput {
    border-bottom-left-radius: 0;
    border-bottom-right-radius: 0;
  }
</style>
<main class="form-signin w-100 m-auto">
  <form method="post" th:action="@{/authenticate}">
    <h1 class="h3 mb-3 fw-normal text-center">로그인</h1>

    <div class="form-floating">
      <input type="text" class="form-control" id="floatingInput" name="username" placeholder="아이디">
      <label for="floatingInput">아이디</label>
    </div>
    <div class="form-floating">
      <input type="password" class="form-control" id="floatingPassword" name="password" placeholder="패스워드">
      <label for="floatingPassword">패스워드</label>
    </div>

    <button class="w-100 btn btn-lg btn-primary" type="submit">로그인</button>
    <p class="mt-5 mb-3 text-center"><a href="https://github.com/eeesnghyun/springSecurity">springSecurityExample</a></p>
  </form>
</main>
</body>
</html>

 

9. 컨트롤러 추가

기존 컨트롤러에 index를 조회할 수 있게 추가해준다.

@Controller
public class HomeController {

    @RequestMapping("/")
    public String index() { 
    	return "/index"; 
    }

    @RequestMapping("/join")
    public String join() {
        return "/join";
    }

    @RequestMapping("/login")
    public String login() {
        return "/login";
    }
}

 

로그인 실패시에는 아래와같이 쿼리파람이 추가되어 리다이렉트가 되기 때문에 화면에서 해당 파라미터를 추출해 문구를 보여주는 식으로 작업이 가능하다.


시큐리티에서 제공되는 가장 기본적인 기능만 추가해서 누구나 쉽게 재사용이 가능한 예제를 만들어봤습니다.

전체 소스는 깃허브에서 확인하실 수 있습니다 :)

반응형
Contents

포스팅 주소를 복사했습니다.

이 글이 도움이 되었다면 공감 부탁드립니다.