-
[Spring Boot] 로그인 처리 with Spring SecuritySpring Boot 2022. 6. 28. 14:25
[Spring Boot/게시판 만들기] - 스프링 부트 프로젝트 생성하기
위의 과정을 통해 진행되는 프로젝트입니다.
📌 개발환경
IntelliJ Community, SpringBoot, Java 1.8, Gradle, Jar, Thymeleaf, JPA, MariaDB
스프링 시큐리티(Spring Security)를 이용한 로그인, 로그아웃, 중복 로그인, 로그인 유지, 예외처리 등을 적용합니다.
테이블 생성
📁 SQL Download
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
GitHub
Reference
'Spring Boot' 카테고리의 다른 글
[Java] jar 배포 시 File java.nio.file.NoSuchFileException (0) 2022.11.01 [Spring Boot] AOP(Aspect Oriented Programming) 적용하기 (0) 2022.10.24 [JAVA] 파일 첨부, 파일 업로드 구현하기 (0) 2022.07.11 [Spring Boot] 콘솔 쿼리 로그 출력 설정 (0) 2022.07.06 [Spring Boot] Thymeleaf 반복되는 헤더, 푸터 레이아웃 적용하기 (1) 2022.07.06