Spring/Spring Security

[Spring Security] 스프링 시큐리티를 이용하여 로그인, 회원가입 구현하기

빅콜팝 2022. 10. 30. 19:44
728x90
반응형

Config

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final AuthenticationSuccess authenticationSuccess;
    private final AuthenticationFailure authenticationFailure;
    private final LogoutExecute logoutExecute;
    private final LogoutSuccess logoutSuccess;
    private final UserDetailsService userDetailsService;
    private final AuthenticationEntryException authenticationEntryException;
    private final AccessDeniedHandlerException accessDeniedHandlerException;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .authorizeRequests()
                .anyRequest().authenticated()
        ;

        http.formLogin() // 로그인
                .loginPage("/member/login")
                .defaultSuccessUrl("/")
                .failureUrl("/member/login?error=true")
                .usernameParameter("userId")
                .passwordParameter("password")
                .loginProcessingUrl("/member/login")
                .successHandler(authenticationSuccess)
                .failureHandler(authenticationFailure)
                .permitAll()
        ;

        http.logout() // 로그아웃
                .logoutUrl("/member/logout") // default post
                .logoutSuccessUrl("/")
                .invalidateHttpSession(true) // 세션 무효화
                .deleteCookies("JSESSIONID")
                .addLogoutHandler(logoutExecute)
                .logoutSuccessHandler(logoutSuccess)
        ;

        http.rememberMe() // 사용자 저장
                .rememberMeParameter("idMaintain") // default 파라미터는 remember-me
                .tokenValiditySeconds(604800) // 7일로 설정(default 14일)
                .alwaysRemember(false)
                .userDetailsService(userDetailsService)
        ;

        http.sessionManagement()
                .maximumSessions(1) // -1 무제한
                .maxSessionsPreventsLogin(false) // true:로그인 제한 false(default):기존 세션 만료
                .expiredUrl("/member/login") // 세션 만료
        ;

        http.sessionManagement()
                .sessionFixation().changeSessionId() // default 세션 공격 보호
        ;

        http.exceptionHandling() // Exception 처리
                .authenticationEntryPoint(authenticationEntryException) // 인증 예외
                .accessDeniedHandler(accessDeniedHandlerException) // 인가 예외
        ;

        return http.build();

    }


    /**
     * 정적 자원 및 루트 페이지 ignore
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations())
                .antMatchers("/", "/img/**", "/lib/**", "/member/**");
    }

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


}


Controller

@Slf4j
@RequiredArgsConstructor
@RequestMapping("/member")
@Controller
public class MemberController {

    private final MemberService memberService;
    
    @GetMapping("/login")
    public String loginForm(@RequestParam(required = false) String error, Model model){

        model.addAttribute("user", new LoginForm());
        return "member/login";
    }

    @PostMapping("/login")
    public String login(@ModelAttribute("login") @Valid LoginForm loginForm, BindingResult bindingResult,
                        @RequestParam(defaultValue = "/") String redirectURL,
                        HttpServletRequest request,
                        Model model){

        if(bindingResult.hasErrors()) return "member/login";

        Member loginMember = memberService.login(loginForm.getUserId(), loginForm.getPassword());

        if (loginMember == null) {
            bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
            return "member/login";
        }

        model.addAttribute("user",loginForm);

        return "redirect:" + redirectURL;
    }

    @GetMapping("/signup")
    public String signupForm(Model model){
        model.addAttribute("user", new MemberJoinForm());
        return "member/signup";
    }

    @PostMapping("/signup")
    public String signup(@Valid @ModelAttribute("user") MemberJoinForm memberJoinForm, BindingResult bindingResult){

        log.info("user = {}",memberJoinForm.getUserId());

        if(!memberJoinForm.getUserId().equals(memberJoinForm.getConfirmId())){ // 중복 확인 아이디와 입력 아이디가 다름
            bindingResult.reject("notConfirm");
        }
        if(!memberJoinForm.getPassword().equals(memberJoinForm.getRePassword())){ // 비밀번호와 비밀번호 확인 값이 다름
            bindingResult.reject("notPassword");
        }

        if(bindingResult.hasErrors()) {
            log.info("bindingResult = {} " , bindingResult);
            return "member/signup";
        }

        // 회원가입
        memberService.memberJoin(memberJoinForm);
        return "redirect:/member/login";
    }
    
    /**
     * 아이디 중복 체크
     */
    @ResponseBody
    @GetMapping("/api/{userChkId}/exists")
    public ResponseEntity<Boolean> checkUserIdDuplicate(@PathVariable String userChkId){
        log.info("userChkId == {}", userChkId);
        return ResponseEntity.ok(memberService.checkUserIdDuplicate(userChkId));
    }
}


Service

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    /**
     * 회원가입
     * @param member
     * @return
     */
    public String memberJoin(MemberJoinForm member) {

        Member memberEntity = new Member();
        memberEntity.memberJoin(member.getUserId(), member.getUserName(), passwordEncoder.encode(member.getPassword()), member.getEmail(), Role.USER);

        memberRepository.save(memberEntity);

        return memberEntity.getUserId();
    }
    
    /**
     * 아이디 중복 체크
     */
    public boolean checkUserIdDuplicate(String userChkId) {
        return memberRepository.existsByUserId(userChkId);
    }
    
    /**
     * @return null이면 로그인 실패
     */
    public Member login(String userId, String password) {

        return memberRepository.findByUserId(userId)
                .filter(m -> passwordEncoder.matches(m.getPassword(),password))
                .orElse(null);
    }

}


signup.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">
<head>
    <meta charset="utf-8">
    <title>회원가입</title>
    <link href="/css/signup.css" rel="stylesheet">
</head>

<div layout:fragment="content">

    <script th:inline="javascript">
        const log = console.log;

        /**
         * 아이디 중복 체크
         */
        function idOverlapChked() {
            let userId = document.getElementById('userId').value;
            let url = `/member/api/${userId}/exists`;

            if(userId === null || userId === ""){
                document.getElementById("idOverlapMessage").innerHTML = '아이디를 입력해주세요.';
                return;
            }

            ajax("GET", url, userId);
        }

        function ajax(method, url, data) {
            let xhr = new XMLHttpRequest();
            xhr.onreadystatechange = function() {
                if (xhr.readyState === 4) { // 완료
                    if (xhr.status === 200) { // success

                      if(method === "GET"){ // 아이디 중복 확인
                        let template;
                        if(xhr.response === "true"){
                          template = '사용 불가능한 아이디입니다.';
                        }else{
                          template = '사용 가능한 아이디입니다.';
                          // document.getElementById("confirmId").innerText = userId;
                          document.getElementById("confirmId").value = data;
                        }
                        document.getElementById("idOverlapMessage").innerHTML = template;

                      }else{ // 회원가입 완료
                        alert("회원가입이 완료 되었습니다.");
                        location.href = "/member/login";
                      }
                    }
                }
            };

            if(method === "GET"){
              xhr.open("GET", url, false);
              xhr.send();
            }else{
              xhr.open('POST', url, true);
              xhr.responseType = "json";
              xhr.setRequestHeader('Content-Type', 'application/json');
              xhr.send(JSON.stringify(data));
            }

        }
    </script>

    <section class="bg-light">
      <div class="container py-4">
        <div class="row align-items-center justify-content-between">
          <a class="navbar-brand h1 text-center" href="/">
            <span class="text-dark h4">dyShop</span> <span class="text-primary h4">회원가입</span>
          </a>
        </div>

        <form th:action="@{/member/signup}" th:object="${user}" method="post" id="memberForm">

          <div th:if="${#fields.hasGlobalErrors()}">
            <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
          </div>
          <div class="form-group">
            <label for="userId" class="form-label mt-4" >아이디</label><br>
            <input type="text" class="form-control" id="userId" th:field="*{userId}" th:errorclass="is-invalid">
            <button class="btn btn-primary" type="button" id="idOverlapChk" th:onclick="|idOverlapChked()|">중복확인</button>
            <p th:if="${#fields.hasErrors('userId')}" class="is-invalid" th:errors="*{userId}">아이디 오류 삐뽀삐뽀</p>
            <label class="is-valid" id="idOverlapMessage"></label>
            <input type="hidden" id="confirmId" th:field="*{confirmId}"/>
            <p th:if="${#fields.hasErrors('confirmId')}" class="is-invalid" th:errors="*{confirmId}">아이디 중복확인 오류 삐뽀삐뽀</p>
          </div>

          <div class="form-group has-success">
            <label class="form-label mt-4" for="password">비밀번호</label>
            <input type="password" class="form-control is-valid" id="password" th:field="*{password}" th:errorclass="is-invalid">
              <p th:if="${#fields.hasErrors('password')}" class="is-invalid" th:errors="*{password}">비밀번호 오류 삐뽀삐뽀</p>
          </div>
          <div class="form-group has-danger">
            <label class="form-label mt-4" for="inputInvalid">비밀번호 재확인</label>
            <input type="password" class="form-control" id="inputInvalid" th:field="*{rePassword}" th:errorclass="is-invalid">
            <p th:if="${#fields.hasErrors('rePassword')}" class="is-invalid" th:errors="*{rePassword}">비밀번호 오류 삐뽀삐뽀</p>
          </div>

          <div class="form-group">
            <label for="exampleInputEmail4" class="form-label mt-4">이름</label>
            <input type="text" class="form-control" id="exampleInputEmail4" th:field="*{userName}" th:errorclass="is-invalid">
            <p th:if="${#fields.hasErrors('userName')}" class="is-invalid" th:errors="*{userName}">이름 오류 삐뽀삐뽀</p>
          </div>

          <div class="form-group">
            <label for="sex" class="form-label mt-4">성별</label><br>
            <select class="form-select" id="sex" th:field="*{sex}" th:errorclass="is-invalid">
              <option value="">선택</option>
              <option value="남자">남자</option>
              <option value="여자">여자</option>
            </select>
          </div>
          <p th:if="${#fields.hasErrors('sex')}" class="is-invalid" th:errors="*{sex}">성별 오류 삐뽀삐뽀</p>
          <div class="form-group">
            <label for="email" class="form-label mt-4">본인 확인 이메일</label>
            <input type="email" class="form-control" id="email" aria-describedby="emailHelp" placeholder="선택입력" th:field="*{email}" th:errorclass="is-invalid">
          </div>
          <p th:if="${#fields.hasErrors('email')}" class="is-invalid" th:errors="*{email}">email 오류 삐뽀삐뽀</p>
            <br>

          <div class="d-grid gap-2">
<!--            <button class="btn btn-primary btn-lg" type="button" th:onclick="|joinMember()|">가입하기</button>-->
            <button class="btn btn-primary btn-lg" type="submit">가입하기</button>
          </div>
        </form>
      </div>
    </section>
</div>
</html>


login.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout}">

<head>
    <meta charset="utf-8">
    <title>로그인</title>
    <link href="/css/login.css" rel="stylesheet">
</head>

<section class="text-center text-lg-start">
    <div layout:fragment="content">
        <div class="card mb-3 login-1">
            <div class="row g-0 d-flex align-items-center">
                <div class="col-lg-4 d-none d-lg-flex">
                    <img src="https://mdbootstrap.com/img/new/ecommerce/vertical/004.jpg" alt="Trendy Pants and Shoes"
                         class="w-100 rounded-t-5 rounded-tr-lg-0 rounded-bl-lg-5" />
                </div>
                <div class="col-lg-8">
                    <div class="card-body py-5 px-md-5">

                        <form style="width:50%;" th:action="@{/member/login}" th:object="${user}" method="post">

                            <div th:if="${#fields.hasGlobalErrors()}">
                                <p th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
                            </div>

                            <!-- userId input -->
                            <div class="form-outline mb-4">
                                <input type="text" id="userId" class="form-control" th:field="*{userId}" />
                                <label class="form-label" for="userId">아이디 입력</label>
                                <p th:if="${#fields.hasErrors('userId')}" class="is-invalid" th:errors="*{userId}">아이디 오류 삐뽀삐뽀</p>
                            </div>

                            <!-- Password input -->
                            <div class="form-outline mb-4">
                                <input type="password" id="password" class="form-control" th:field="*{password}" />
                                <label class="form-label" for="password">비밀번호 입력</label>
                                <p th:if="${#fields.hasErrors('password')}" class="is-invalid" th:errors="*{password}">비밀번호 오류 삐뽀삐뽀</p>
                            </div>

                            <div class="row mb-4">
                                <div class="col d-flex justify-content-center">
                                    <div class="form-check">
                                        <input class="form-check-input" type="checkbox" th:field="*{idMaintain}" id="idMaintain" />
<!--                                        <input type="hidden" value="on" id="_idMaintain"/> -->
                                        <label class="form-check-label" for="idMaintain"> 로그인 상태 유지 </label>
                                    </div>
                                </div>

                                <div class="col">
                                    <a href="#!">아이디/비밀번호 찾기</a>
                                </div>
                            </div>

                            <!-- Submit button -->
                            <button th:type="submit" class="btn btn-primary btn-block mb-4">로그인</button>

                            <!-- Submit button -->
                            <button type="button" onclick="location.href='/member/signup'" class="btn btn-primary btn-block mb-4">회원가입</button>

                        </form>

                    </div>
                </div>
            </div>
        </div>
    </div>
</section>
<!-- Section: Design Block -->

</html>
728x90
반응형