[Spring Security] 로그인 기능 구현 (3) 로그인 기능 확장
본문 바로가기

Web 개발/게시판 만들기

[Spring Security] 로그인 기능 구현 (3) 로그인 기능 확장

728x90
반응형

💡 이전 내용

Spring Security의 개념을 알아보고,

스프링 시큐리티에서 제공하는 기본 로그인 기능을 사용하였습니다.

이번 글에는 로그인 화면을 생성하고, 로그인 기능을 구현하겠습니다.

로그인 기능은 UserDetails 인터페이스를 구현하며 오버라이딩한 메소드를 통해 구현하겠습니다.

💻 로그인 화면 구현

로그인 화면은 다음과 같이 부트스트랩에서 제공하는 로그인 화면을 사용하였습니다.

 

1. 부트스트랩 예제 다운로드

부트스트랩 홈페이지에 접속하여, 예제를 다운로드합니다.

https://getbootstrap.com/docs/5.2/examples/

 

다운로드한 파일의 압축을 해제하여

bootstrap-5.2.0\site\content\docs\5.2\examples\sign-in

위 경로에 있는 signin.css 파일과 index.html 파일을 프로젝트에 추가합니다.

 

<!DOCTYPE html>
<html lang="en"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    
    <!-- Bootstrap -->
    <th:block th:replace="board2/fragments/config :: configFragment"> </th:block>

    <!-- Style CSS -->
    <link rel="stylesheet" th:href="@{/css/signin.css}"/>
</head>
<body class="text-center d-flex">
    <main class="form-signin w-100 m-auto">
        <form method="post">
            <h1 class="h3 mb-3 fw-normal">Please sign in</h1>

            <div class="form-floating">
                <input id="floatingInput" type="text" class="form-control" name="userId">
                <label for="floatingInput">ID</label>
            </div>
            <div class="form-floating">
                <input id="floatingPassword" type="password" class="form-control" name="password" autocomplete="off">
                <label for="floatingPassword">Password</label>
            </div>

            <div class="checkbox mb-3">
                <input id="rememberId" type="checkbox"/>
                <label for="rememberId">아이디 저장</label>
            </div>
            <button type="submit" class="w-100 btn btn-lg btn-primary">로그인</button>
        </form>
        <div>
               <a th:href="@{/signup}">
                   <button type="button" class="w-100 mt-1 btn btn-lg btn-secondary">회원가입</button>
               </a>
            <p class="mt-5 mb-3 text-muted">&copy; 2022</p>
        </div>
    </main>

</body>
</html>

 

2. 화면 호출

Controller에 다음 코드를 추가하여

"/login" URL로 접근시 로그인 화면을 보여줍니다.

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

 

3. 구성 파일 변경

SecurityConfig의 configure(HttpSecurity http) 메소드에 다음 코드를 추가합니다.

// 폼 로그인
http.formLogin()
        .loginPage("/login")
        .loginProcessingUrl("/login")
        .failureHandler(failureHandler())
        .successHandler(successHandler())
        .usernameParameter("userId")
        .permitAll()
    .and()
    .csrf().disable();

 

formLogin() 메소드를 통해 form을 통한 로그인을 허용하고,

로그인 화면은 "/login" 으로, 로그인 과정은 form action="/login"을 통해 동작하도록 설정합니다.

 

💻 로그인 기능 구현

1. UserDetails, UserDetailsService 구현

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account implements UserDetails, Serializable {

    private static final long serialVersionUID = ;

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 20, nullable = false)
    private String userId;

    @Column(length = 100, nullable = false)
    private String password;

    private String korName;
    
    @Column(length = 256)
    private String email;

    @Enumerated(EnumType.STRING)
    private UserType userType = UserType.USER;

    @Column(name = "fail_cnt")
    private Integer loginFailCnt = 0;

    // 계정 잠김 여부
    private boolean accountNonLocked = true;

    // 사용 여부
    private boolean enabled = true;

    // 메일 수신 여부
    private boolean receiveEmail = false;

    // 회원 가입 일자
    private LocalDate joinDate = LocalDate.now();

    // 최근 로그인 일자
    private LocalDate lastLoginDate;

    // 1년 이상 로그인 하지 않을 시 계정 만료
    private LocalDate accountExpiredDate;

    // 3개월 마다 비밀번호 변경 필요
    private LocalDate credentialsExpiredDate;


    @Builder
    public Account(Long id, String userId, String password, String korName){
        this.id = id;
        this.userId = userId;
        this.password = password;
        this.korName = korName;
    }

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

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

    @Override
    public boolean isAccountNonExpired() {
        return isFutureDate(accountExpiredDate);
    }

    @Override
    public boolean isAccountNonLocked() {
        return this.accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return isFutureDate(credentialsExpiredDate);
    }

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

이 때, Account 엔티티는 반드시 직렬화(Serializable) 해야합니다.

직렬화 관련: https://devlog-wjdrbs96.tistory.com/268

 

@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private AccountService accountService;

    @Override
    public Account loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Account> optionalAccount = accountService.findByUserId(username);
        if (optionalAccount.isEmpty()) {
            throw new UsernameNotFoundException("Username [" + username + "] not found.");
        }

        return optionalAccount.get();
    }
}

 

2. AuthenticationProvider 구현

AuthenticationProvider를 활용하여 로그인 정보를 확인하겠습니다.

 

@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider, MessageSourceAware {

    private MessageSourceAccessor messages;

    @Autowired
    private AccountService accountService;

    private final UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker();

    @Override
    public void setMessageSource(MessageSource messageSource) {
        this.messages = new MessageSourceAccessor(messageSource);
    }

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

        // 비밀번호 확인
        Account userDetails = accountService.authenticate(username, password);

        // 예외 발생 여부 확인
        preAuthenticationChecks(userDetails);

        return new UsernamePasswordAuthenticationToken(username, password, authentication.getAuthorities());
    }

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

    private void preAuthenticationChecks(Account userDetails) {
        try {
            userDetailsChecker.check(userDetails);
        } catch (LockedException e) {
            log.info("계정 잠금");
            throw e;
        } catch (DisabledException e) {
            log.info("계정 유효 만료");
            throw e;
        } catch (AccountExpiredException e) {
            log.info("계정 유효기한 만료");
            throw e;
        } catch (CredentialsExpiredException e) {
            log.info("비밀번호 기한 만료");
        } catch (UsernameNotFoundException e) {
            log.info("계정 없음");
            throw e;
        }
    }
}

authentication 메소드를 오버라이딩하여 로그인 정보를 확인합니다.

아이디 존재 여부, 비밀번호 매칭 확인은 AccountService에서 구현하였습니다.

계정 잠금, 유효기간 만료, 비밀번호 기한 만료 등은 UserDetailsChecker에서 자동으로 확인합니다.

Exception이 발생하지 않는다면, UsernamePasswordAuthenticationToken을 통해 Spring Security Context 에 전달됩니다.

 

@Slf4j
@RequiredArgsConstructor
@Service
public class AccountService {

    private final AccountRepository accountRepository;
    private final PasswordEncoder passwordEncoder;

    public Optional<Account> findByUserId(String username) {
        return accountRepository.findByUserId(username);
    }

    @Transactional
    public Account saveOrUpdate(Account account) {
        return accountRepository.save(account);
    }


    public Account authenticate(String username, String password) {
        Optional<Account> optionalAccount = accountRepository.findByUserId(username);
        if(optionalAccount.isPresent()) {
            log.info("@Authenticate : PW 확인");
            Account account = optionalAccount.get();
            if(passwordMatches(account, password)){
                if(!account.isCredentialsNonExpired())
                    unlockedUser(account);
            } else{
                throw new BadCredentialsException("Wrong password");
            }
        } else{
            throw new UsernameNotFoundException("[" + username + "] Username Not Found.");
        }

        return optionalAccount.get();

    }

    public void updateLoginInfo(Account account) {
        log.info("@loginSuccess : 로그인 정보 업데이트");
        account.setLoginFailCnt(0);
        account.setLastLoginDate(LocalDate.now());
        account.setAccountExpiredDate(LocalDate.now().plusYears(1));
        accountRepository.save(account);
    }

    private boolean passwordMatches(Account account, String password) {
        if (!passwordEncoder.matches(password, account.getPassword())) {
            increaseFailureCount(account);
            int cnt = getFailureCount(account);
            if(cnt > 4) {
                lockedUser(account);
            }
            return false;
        }
        return true;
    }

    public int getFailureCount(Account account) {
        return account.getLoginFailCnt();
    }

    private void increaseFailureCount(Account account) {
        account.setLoginFailCnt(account.getLoginFailCnt()+1);
        accountRepository.save(account);
    }

    private void lockedUser(Account account) {
        account.setAccountNonLocked(false);
        accountRepository.save(account);
    }

    private void unlockedUser(Account account) {
        account.setAccountNonLocked(true);
        accountRepository.save(account);
    }


}

 

3. 구성 파일 변경

 

위에서 생성한 클래스들을 스프링 빈으로 등록하고, 설정하겠습니다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(customAuthenticationProvider()).userDetailsService(customUserDetailsService());
    }

    @Override
    public void configure(WebSecurity web) throws Exception{
        web.ignoring().requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 접근 권한
        http.authorizeRequests()
                .mvcMatchers("/", "/login", "/signup").permitAll()
                .mvcMatchers("/admin").hasRole("ADMIN")
                .anyRequest().authenticated();

        // 폼 로그인
        http.formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/login")
                .failureHandler(failureHandler())
                .successHandler(successHandler())
                .usernameParameter("userId")
                .permitAll()
            .and()
            .csrf().disable();

        // 로그아웃
        http.logout()
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessUrl("/login")
                .invalidateHttpSession(true)
                .clearAuthentication(true)
                .deleteCookies("SESSION", "JSESSIONID");

        // 중복 로그인
        http.sessionManagement()
                .invalidSessionUrl("/login?invalidSession")
                .sessionAuthenticationErrorUrl("/login?maximumSessions")
                .maximumSessions(1) // 최대 허용 세션 수
                .maxSessionsPreventsLogin(false) // 중복 로그인하면 기존 세션 만료
                .expiredUrl("/login?expiredSession");
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SimpleUrlAuthenticationSuccessHandler successHandler(){
        SimpleUrlAuthenticationSuccessHandler successHandler = new CustomLoginSuccessHandler();
        successHandler.setDefaultTargetUrl("/");
        return successHandler;
    }

    @Bean
    public AuthenticationFailureHandler failureHandler(){
        return new CustomLoginFailureHandler();
    }

    @Bean
    public CustomAuthenticationProvider customAuthenticationProvider() {
        CustomAuthenticationProvider customAuthenticationProvider = new CustomAuthenticationProvider();
        customAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return customAuthenticationProvider;
    }

    @Bean
    public CustomUserDetailsService customUserDetailsService() {
        return new CustomUserDetailsService();
    }
}

 

💻 로그인 성공

로그인이 성공하면 "/" URL로 이동하게 됩니다.

이 때, SecurityContextHolder에 사용자 정보가 담겨있기 때문에

SecurityContextHolder를 통해 사용자의 로그인 유무를 확인할 수 있습니다.

    @GetMapping("/")
    public String index(HttpServletRequest request) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return "index";
    }

 

 

 

728x90
반응형