ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] 로그인 처리 with Spring Security
    Spring Boot 2022. 6. 28. 14:25

    [Spring Boot/게시판 만들기] - 스프링 부트 프로젝트 생성하기

    위의 과정을 통해 진행되는 프로젝트입니다.

     

    📌 개발환경

    IntelliJ Community, SpringBoot, Java 1.8, Gradle, Jar, Thymeleaf, JPA, MariaDB

     

    스프링 시큐리티(Spring Security)를 이용한 로그인, 로그아웃, 중복 로그인, 로그인 유지, 예외처리 등을 적용합니다.

     

    테이블 생성

    📁 SQL Download

    member.sql
    0.00MB

     

    Dependency 추가

    build.gradle

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

     

    Member.java

    Entity 정의와 요청, 응답 멤버 클래스를 작성합니다.
    사용자 로그인 처리를 위한 UserDetails도 상속받습니다.

    @NoArgsConstructor
    @Getter
    @Entity(name = "member")
    public class Member extends BaseTimeEntity implements UserDetails {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private String email;
        private String password;
        private String gender;
        private String dropYn;
        private LocalDateTime lastLoginTime;
    
        @Builder
        public Member(Long id, String email, String password, String gender, String dropYn) {
            this.id = id;
            this.email = email;
            this.password = password;
            this.gender = gender;
            this.dropYn = dropYn;
        }
    
        @Getter
        @Setter
        public static class RequestDto {
            private String email;
            private String password;
            private String gender;
            private String dropYn;
    
            public Member toEntity() {
                return Member.builder()
                        .email(email)
                        .password(password)
                        .gender(gender)
                        .dropYn(dropYn)
                        .build();
            }
        }
    
        @Getter
        public static class ResponseDto {
            private Long id;
            private String email;
            private String password;
            private String gender;
            private String dropYn;
            private String lastLoginTime;
            private String registerTime;
            private String modifyTime;
    
            public ResponseDto(Member member) {
                this.id = member.id;
                this.email = member.email;
                this.password = member.password;
                this.gender = member.gender;
                this.dropYn = member.dropYn;
                this.lastLoginTime = member.toStringDateTime(member.getLastLoginTime());
                this.registerTime = member.toStringDateTime(member.getRegisterTime());
                this.modifyTime = member.toStringDateTime(member.getModifyTime());
            }
        }
    
    
        @Override
        public String getPassword() {
            return this.password;
        }
    
        @Override
        public String getUsername() {
            return this.email;
        }
    
        //계정이 갖고있는 권한 목록은 리턴
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
    
            Collection <GrantedAuthority> collectors = new ArrayList<>();
            collectors.add(() -> {
                return "계정별 등록할 권한";
            });
    
            //collectors.add(new SimpleGrantedAuthority("Role"));
    
            return collectors;
        }
    
        //계정이 만료되지 않았는지 리턴 (true: 만료 안됨)
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        //계정이 잠겨있는지 않았는지 리턴. (true: 잠기지 않음)
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        //비밀번호가 만료되지 않았는지 리턴한다. (true: 만료 안됨)
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        //계정이 활성화(사용가능)인지 리턴 (true: 활성화)
        @Override
        public boolean isEnabled() {
            return true;
        }
    }

     

    MemberRepository.java

    가입 및 로그인 시 사용할 로그인 시간 업데이트, 이메일로 정보 찾기 등의 메서드를 생성합니다.

    public interface MemberRepository extends JpaRepository<Member, Long> {
        String updateMemberLastLoginTime = "update member set last_login_time = NOW() where email = :email";
    
        @Transactional
        @Modifying
        @Query(value = updateMemberLastLoginTime, nativeQuery = true)
        public int updateMemberLastLogin(@Param("email") String email);
        public Optional<Member> findByEmail(String email);
        public int countByEmailAndDropYn(String email, String dropYn);
    }

     

    MemberService.java

    save() 메서드에서 비밀번호 암호화 및 탈퇴여부(N)을 입력합니다.
    UserDetailsService를 상속 받아 로그인에 사용 될 loadUserByUsername 메서드를 Override 합니다.

    @RequiredArgsConstructor
    @Service
    public class MemberService implements UserDetailsService {
    
        private final MemberRepository memberRepository;
        private final BCryptPasswordEncoder passwordEncoder;
        
        public Long save(Member.RequestDto requestDto) {
            requestDto.setPassword(passwordEncoder.encode(requestDto.getPassword()));
            requestDto.setDropYn("N");
            return memberRepository.save(requestDto.toEntity()).getId();
        }
    
        public Member findByEmail(String email) {
            return memberRepository.findByEmail(email)
                    .orElseThrow(() -> new UsernameNotFoundException("Could not found user" + email));
        }
    
        public int countByEmailAndDropYn(String email, String dropYn) {
            return memberRepository.countByEmailAndDropYn(email, dropYn);
        }
    
        @Override
        public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
            return memberRepository.findByEmail(email)
                    .orElseThrow(() -> new UsernameNotFoundException("Could not found user" + email));
        }
    }

     

    MemberController.java

    가입, 로그인 페이지 및 액션에 대한 메서드를 생성합니다.

    @RequiredArgsConstructor
    @Controller
    public class MemberController {
    
        private final MemberService memberService;
    
        @GetMapping("/member/join")
        public String getJoinPage() {
            return "/member/join";
        }
    
        @GetMapping("/member/login")
        public String getLoginPage(Model model,
                                   @RequestParam(value = "error", required = false) String error,
                                   @RequestParam(value = "exception", required = false) String exception) {
            model.addAttribute("error", error);
            model.addAttribute("exception", exception);
            return "/member/login";
        }
    
        @PostMapping("/member/save")
        public String save(Member.RequestDto requestDto) {
            String url = "/error/blank";
    
            if (memberService.save(requestDto) > 0) {
                url = "redirect:/member/login";
            }
            return url;
        }
    
        @PostMapping("/member/count-email")
        public String countByEmailAndDropYn(Model model, Member.RequestDto requestDto) {
            model.addAttribute("count", memberService.countByEmailAndDropYn(requestDto.getEmail(), requestDto.getDropYn()));
            return "jsonView";
        }
    }

     

    AuthSuccessHandler.java

    로그인 성공 시 처리를 담당할 Handler를 생성합니다.
    onAuthenticationSuccess 메서드에서 성공 시 마지막 로그인 시간을 업데이트하고 리턴 URL을 지난번에 만들었던 게시판 목록 화면으로 지정합니다.

    @RequiredArgsConstructor
    @Component
    public class AuthSucessHandler extends SimpleUrlAuthenticationSuccessHandler {
    
        private final MemberRepository memberRepository;
    
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            memberRepository.updateMemberLastLogin(authentication.getName());
            setDefaultTargetUrl("/board");
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }

     

    AuthFailureHandler.java

    로그인 실패 시 처리를 담당할 Handler를 생성합니다.
    onAuthenticationFailure 메서드에서 실패 시 리턴 URL도 지정합니다.

    @Component
    public class AuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
    
            String msg = "Invalid Email or Password";
    
            // exception 관련 메세지 처리
            if (exception instanceof DisabledException) {
                msg = "DisabledException account";
            } else if (exception instanceof CredentialsExpiredException) {
                msg = "CredentialsExpiredException account";
            } else if (exception instanceof BadCredentialsException) {
                msg = "BadCredentialsException account";
            }
    
            setDefaultFailureUrl("/member/login?error=true&exception=" + msg);
    
            super.onAuthenticationFailure(request, response, exception);
        }
    }

     

    SecurityConfiguration.java

    WebSecurityConfigurerAdapter를 상속 받아 configure에서 처리하는 방식은 Spring Security 5.7.0-M2 we deprecated 되었습니다. 따라서 아래와 같이 SecurityFilterChain을 @Bean으로 등록하여 처리합니다.

    BCryptPasswordEncoder
    ↪   패스워드를 암호화 해주는 메서드

    filterChain
    ↪  로그인 처리를 담당하고 그 외에 HttpSecurity에 대한 부분도 담당

    mvcMatchers에 설정된 패턴만 로그인 인증 패스가 가능하며 그 외 요청은 인증허가를 받습니다. 

    @RequiredArgsConstructor
    @Configuration
    public class SecurityConfiguration {
    
        private final AuthSucessHandler authSucessHandler;
        private final AuthFailureHandler authFailureHandler;
    
        @Bean
        public BCryptPasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
            http.csrf().disable();
            http.authorizeRequests()
                    .mvcMatchers("/", "/member/**", "/error/**", "/js/**", "/css/**", "/image/**").permitAll() // 해당 경로들은 접근을 허용
                    .anyRequest().authenticated(); // 그 외 요청은 인증요구
            http.formLogin()
                    .loginPage("/member/login") // 로그인 페이지 URL 지정
                    .loginProcessingUrl("/login/action") // 로그인 처리 URL 지정
                    .successHandler(authSucessHandler) // 성공 handler
                    .failureHandler(authFailureHandler); // 실패 handler
            http.logout()
                    .logoutRequestMatcher(new AntPathRequestMatcher("/member/logout")) // 로그아웃 URL
                    .logoutSuccessUrl("/member/login") // 성공 리턴 URL
                    .invalidateHttpSession(true) // 인증정보를 지우하고 세션을 무효화
                    .deleteCookies("JSESSIONID", "remember-me"); // JSESSIONID, remember-me 쿠키 삭제
            http.sessionManagement()
                    .maximumSessions(1) // 세션 최대 허용 수 1, -1인 경우 무제한 세션 허용
                    .maxSessionsPreventsLogin(false) // true면 중복 로그인을 막고, false면 이전 로그인의 세션을 해제
                    .expiredUrl("/login?error=true&exception=Have been attempted to login from a new place. or session expired"); // 세션이 만료된 경우 이동 할 페이지를 지정
            http.rememberMe() // 로그인 유지
                    .key("0467EC591838570F48CC386CEE6ED9FBA53B4593A283BAFD5A94347AD3428408") // 토큰 생성시 키 값
                    .alwaysRemember(false) // 항상 기억할 것인지 여부
                    .tokenValiditySeconds(43200) // in seconds, 12시간 유지
                    .rememberMeParameter("remember-me"); //rememberMe 파라미터 이름 지정
    
            return http.build();
        }
    }

     

    WebConfiguration.java

    전반적인 웹 설정에 대한 부분을 담당합니다.
    지금은 jsonView 사용을 위해 MappingJackson2JsonView을 @Bean으로 등록합니다.

    @Configuration
    public class WebConfiguration implements WebMvcConfigurer {
        @Bean
        MappingJackson2JsonView jsonView() {
            return new MappingJackson2JsonView();
        }
    }

     

    HTML - /member/join.html

    가입을 담당 할 입력 폼입니다.
    email 인풋의 포커스 아웃 시 ajax 로 이메일 중복체크를 합니다.

    <!DOCTYPE html>
    <html lang="en" xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
        <title>Join</title>
        <link rel="stylesheet" href="/css/bootstrap.min.css">
        <script src="/js/jquery-3.6.0.min.js"></script>
        <script src="/js/bootstrap.min.js"></script>
    </head>
    <body>
    <div class="container">
        <div class="container">
            <div class="row justify-content-center">
                <div class="col-lg-5">
                    <div class="card shadow-lg border-0 rounded-lg mt-5">
                        <div class="card-header">
                            <h3 class="text-center fw-bold my-4">Join</h3>
                        </div>
                        <div class="card-body">
                            <form id="frmJoin" action="/member/save" method="post">
                                <div class="mb-3">
                                    <label class="form-label fw-bold">Email.</label>
                                    <input type="email" class="form-control" name="email" maxlength="100" required>
                                    <p class="alert alert-danger" id="emailChk" style="display:none;"></p>
                                </div>
                                <div class="mb-3">
                                    <label class="form-label fw-bold">Password.</label>
                                    <input type="password" class="form-control" name="password" minlength="8" maxlength="200" required>
                                </div>
                                <div class="mb-3">
                                    <label class="form-label fw-bold">Gender.</label>
                                    <div class="form-check">
                                        <input class="form-check-input" type="radio" name="gender" id="flexRadioDefault1" value="M" checked>
                                        <label class="form-check-label" for="flexRadioDefault1">Men</label>
                                    </div>
                                    <div class="form-check">
                                        <input class="form-check-input" type="radio" name="gender" id="flexRadioDefault2" value="W">
                                        <label class="form-check-label" for="flexRadioDefault2">Woman</label>
                                    </div>
                                </div>
                                <div class="d-flex align-items-center justify-content-between mt-4 mb-0">
                                    <a type="button" class="small" onclick="javascript:location.href='/member/login'">Sign in</a>
                                    <button type="submit" class="btn btn-primary">Submit</button>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    </body>
    <script th:inline="javascript">
        let frm = $("#frmJoin");
        let emailChk = false;
    
        $("input[name='email']").on("focusout",function(){
            $.ajax({
                url: "/member/count-email",
                method: "post",
                dataType: "json",
                data: { email: $("input[name='email']").val(), dropYn: "N" },
                success: function(r) {
                    if (r.count > 0) {
                        emailChk = false;
                        $("#emailChk").show();
                        $("#emailChk").text("This email is in use.");
                    } else {
                        emailChk = true;
                        $("#emailChk").hide();
                    }
                },
                error: function(jqXHR, textStatus, errorThrown) {
                    alert("An error has occurred.");
                }
            });
        });
    
        frm.submit(function() {
            let flag = false;
            let msg = "Are you sure you want to Join?";
    
            if (!emailChk) return false;
    
            if (confirm(msg)) {
                flag = true;
            }
    
            return flag;
        });
    </script>
    </html>

     

    HTML - /member/login.html

    로그인 처리 화면입니다.
    Remember-me를 체크하고 로그인하면 세션 시간이 만료되어도 remember-me 토큰 만료시간(tokenValiditySeconds)에 따라 로그인이 유지됩니다.

    <!DOCTYPE html>
    <html lang="en" xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
        <title>Login</title>
        <link rel="stylesheet" href="/css/bootstrap.min.css">
        <script src="/js/jquery-3.6.0.min.js"></script>
        <script src="/js/bootstrap.min.js"></script>
    </head>
    <body>
        <div class="container">
            <div class="row justify-content-center">
                <div class="col-lg-5">
                    <div class="card shadow-lg border-0 rounded-lg mt-5">
                        <div class="card-header">
                            <h3 class="text-center fw-bold my-4">Login</h3>
                        </div>
                        <div class="card-body">
                            <form id="frmLogin" name="frmLogin" action="/login/action" method="post">
                                <div th:if="${param.error}">
                                    <p th:text="${exception}" class="alert alert-danger"></p>
                                </div>
                                <div class="form-floating mb-3">
                                    <input class="form-control" id="username" name="username" type="email" required>
                                </div>
                                <div class="form-floating mb-3">
                                    <input class="form-control" id="password" name="password" type="password" required>
                                </div>
                                <div class="form-check mb-3">
                                    <input class="form-check-input" id="remember-me" name="remember-me" type="checkbox">
                                    <label class="form-check-label" for="remember-me">Remember-me</label>
                                </div>
                                <div class="d-flex align-items-center justify-content-between mt-4 mb-0">
                                    <!--<a class="small" href="javascript:">Forgot Password?</a>-->
                                    <a type="button" class="small" onclick="javascript:location.href='/member/join'">Sign up</a>
                                    <button type="submit" class="btn btn-primary">Login</button>
                                </div>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </body>
    </html>

     

    테스트


    📁 Project Download

    toy.zip
    0.61MB

    GitHub

    Reference

     

     

    댓글

Designed by Tistory.