[Spring Boot] 12. 홈페이지 만들기 ③ 로그인, 로그아웃, 비밀번호 찾기

shr·2022년 2월 23일
0

Spring

목록 보기
11/23
post-thumbnail
  1. 설계

    주소method방식리턴
    /member/loginGETMVCvoid
    /member/find/idGETRESTResponseEntity<String>
    /member/change_passwordGETMVCvoid
    POSTMVCString

    Spring Security를 이용해 로그인과 로그아웃 처리를 해 주려고 한다. Controller를 기준으로 추가해 주어야 할 부분은 위와 같다.


  1. src/main/java - com.example.demo - SecurityConfig 수정

    package com.example.demo;
    
    import org.springframework.context.annotation.*;
    import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
    import org.springframework.security.config.annotation.method.configuration.*;
    import org.springframework.security.config.annotation.web.builders.*;
    import org.springframework.security.config.annotation.web.configuration.*;
    import org.springframework.security.crypto.password.*;
    
    import com.example.demo.service.LoginFailureHandler;
    import com.example.demo.service.LoginService;
    import com.example.demo.service.LoginSuccessHandler;
    
    import lombok.AllArgsConstructor;
    
    @AllArgsConstructor
    @Configuration
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        private PasswordEncoder passwordEncoder;
        private LoginService loginService;
        private LoginSuccessHandler successHandler;
        private LoginFailureHandler failureHandler;
    
        // 인증 프로바이더
        @Bean
        public DaoAuthenticationProvider daoAuthenticationProvider() {
            // DaoAuthenticationProvider : id와 password로 인증할 수 있도록 하는 구현체
            DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
            provider.setPasswordEncoder(passwordEncoder);
            provider.setUserDetailsService(loginService);
            return provider;
        }
    
        // 폼 로그인 활성화
        @Override
        protected void configure(HttpSecurity http) throws Exception {
        	// 로그인
            http.formLogin().loginPage("/member/login").loginProcessingUrl("/member/login")
                .usernameParameter("username").passwordParameter("password")
                .successHandler(successHandler).failureHandler(failureHandler)
                // 로그아웃
                .and().logout().logoutUrl("/member/logout").logoutSuccessUrl("/")
                
                // 403이 발생하면 이동할 경로를 지정 : /로 지정하면 /로 이동 후 / 페이지로 forward된다. 
    			// ▶ 루트 페이지에서 에러 메시지를 출력하기는 애매하다.
    			// http.exceptionHandling().accessDeniedPage("/");
    		
    			// 에러 페이지를 지정해 놓고 컨트롤러를 만들지 않으면?
    			// 403 발생 -> /system/e403으로 이동 시도 -> 없음 -> 404 발생
    			http.exceptionHandling().accessDeniedPage("/system/e403");
        }
    }

    💡폼 로그인

    브라우저를 이용해 아이디와 비밀번호를 입력하면 로그인 정보를 서버 측 세션에 저장하는 방식의 로그인이다. 세션은 쿠키를 기반으로, 사용자가 최초 접근하면 서버가 세션을 생성한 다음 쿠키로 사용자 컴퓨터에 저장한다. 세션은 세션이 없는 사용자가 접근했을 때 만들어지는 것이다.

    로그인과 로그아웃을 관리하기 위해 LoginSuccessHandlerLoginFailureHandler를 만들어 주려고 한다.

    📝 403 에러 페이지를 지정해 놓았는데 404 에러 페이지가 뜬다면 내가 지정해 놓은 페이지의 컨트롤러를 만들었는지 확인해 보아야 한다.


  1. src/main/java - com.example.demo.service - LoginFailureHandler 생성

    package com.example.demo.service;
    
    import java.io.IOException;
    
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    
    import org.springframework.security.authentication.BadCredentialsException;
    import org.springframework.security.authentication.DisabledException;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.web.DefaultRedirectStrategy;
    import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
    import org.springframework.stereotype.Component;
    
    import com.example.demo.dao.MemberDao;
    import com.example.demo.entity.Member;
    
    import lombok.AllArgsConstructor;
    
    @AllArgsConstructor
    @Component
    public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
        private MemberDao dao;
    
        // 오류 메시지를 띄워 주기 위해 HttpSession을 사용한다.
    
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                AuthenticationException exception) throws IOException, ServletException {
            String username = request.getParameter("username");
    
            Member member = dao.findById(username);
    
            HttpSession session = request.getSession();
            // 아이디가 틀렸다면
            if (member == null) {
                session.setAttribute("msg", "사용자를 찾을 수 없습니다.");
                // 아이디가 틀린 게 아니라면 어떤 예외인가?
            } else if (exception instanceof BadCredentialsException) {
                // 1. 비밀번호가 틀렸다.
                Integer loginFailCnt = member.getLoginFailCnt();
                    loginFailCnt++;
                if (loginFailCnt < 5) {
                    session.setAttribute("msg", "비밀번호가" + loginFailCnt + "회 틀렸습니다. 5회 틀리면 계정이 잠깁니다.");
                    dao.update(Member.builder().username(username).loginFailCnt(loginFailCnt).build());
                } else {
                    session.setAttribute("msg", "비밀번호가 5회 틀렸습니다. 계정이 잠겼습니다.");
                    dao.update(Member.builder().username(username).loginFailCnt(loginFailCnt).enabled(false).build());
                }
            } else if (exception instanceof DisabledException) {
                // 2. 계정이 잠겼다
                session.setAttribute("msg", "잠긴 계정입니다. 관리자에게 연락해 주세요.");
            }
    
            // 이동
            new DefaultRedirectStrategy().sendRedirect(request, response, "/member/login");
        }
    }

    로그인에 실패했을 때, 로그인 횟수를 세어 실패 횟수가 5회 이상이면 계정을 비활성화한다. 에러 메시지는 아이디가 틀렸을 때, 비밀번호가 틀렸을 때, 계정이 잠겼을 때 모두 띄운다.

    📝 Controller에서는 RedirectAttributes를 이용해 일회성 에러 메시지를 띄울 수 있었지만, RedirectAttributes는 Controller에서만 사용 가능하다. 따라서 LoginFailureHandler에서는 HttpSession을 이용한다.


  1. src/main/java - com.example.demo.service - LoginSuccessHandler 생성

    package com.example.demo.service;
    
    import java.io.IOException;
    
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpSession;
    
    import org.springframework.security.core.Authentication;
    import org.springframework.security.web.DefaultRedirectStrategy;
    import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
    import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
    import org.springframework.security.web.savedrequest.SavedRequest;
    import org.springframework.stereotype.Component;
    
    import com.example.demo.dao.MemberDao;
    import com.example.demo.entity.Member;
    
    import lombok.AllArgsConstructor;
    
    @AllArgsConstructor
    @Component
    public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
        private MemberDao dao;
    
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                Authentication authentication) throws IOException, ServletException {
            String username = authentication.getName();	
    
            // 로그인 실패 횟수 리셋
            Member member = dao.findById(username);
            dao.update(Member.builder().username(username).loginFailCnt(0).build());
    
            // 이동
            SavedRequest savedRequest = new HttpSessionRequestCache().getRequest(request, response);
    
            // 비밀번호 꺼내기
            String password = request.getParameter("password");
    
            // 임시 비밀번호를 통한 로그인
            if (password.length() >= 20) {
                HttpSession session = request.getSession();
                session.setAttribute("msg", "임시 비밀번호로 로그인하셨습니다. 비밀번호를 반드시 변경해 주세요.");
                new DefaultRedirectStrategy().sendRedirect(request, response, "/member/change_password");
            } else {
                // 일반 로그인
                // 1. 가려던 곳이 있는 경우
                if (savedRequest != null)
                    new DefaultRedirectStrategy().sendRedirect(request, response, savedRequest.getRedirectUrl());
                // 2. 가려던 곳이 없는 경우 (루트 페이지로 이동)
                else 
                    new DefaultRedirectStrategy().sendRedirect(request, response, "/");
            }
        }
    }

    로그인에 성공했을 때, 로그인 실패 횟수를 리셋하고 이동시킨다. 사용자가 가려던 곳이 있다면 그곳으로 이동시키고, 없다면 루트 페이지로 이동시킨다.

    💡 간단 개념 정리

    Principal
    스프링 시큐리티에서 인증한 사용자 객체를 읽어 올 수 있는 객체 타입. 최상위 인터페이스이기 때문에 이 타입으로 받으면 사용할 수 있는 메소드가 getName() 정도밖에 없다. ID 정보만을 필요로 할 때 사용한다.

    Authentication
    세션 정보를 보관하는 객체에서 필요한 정보를 뽑아내는 메소드를 가지고 있다. 따라서 실제로 인증 정보를 사용하기 위해 사용되는 객체 타입이다.

    출처
    스프링 Security_로그인_Principal 객체


  1. src/main/resources - mapper - memberMapper.xml 수정
        <update id="update">
            update member 
            <trim suffixOverrides="," prefix="set"> <!-- 뒤에 쉼표 지워 주고 앞에 set 넣어 준다! -->
                <if test="password!=null">password=#{password},</if>
                <if test="email!=null">email=#{email},</if>
                <if test="enabled!=null">enabled=#{enabled},</if>
                <if test="authority!=null">authority=#{authority},</if>
                <if test="checkcode!=null">checkcode=null,</if>
                <if test="count!=null">count=#{count},</if>
                <if test="levels!=null">levels=#{levels},</if>
                <!--★ 수정 부분! -->
                <if test="loginFailCnt!=null">loginFailCnt=#{loginFailCnt},</if>
            </trim>
            where username=#{username}
        </update>

  1. DB의 member 테이블 재생성

    drop table member;
    
    create table member (
        username varchar2(10 char),
        password varchar2(60 char),
        irum varchar2(10 char),
        email varchar2(50 char),
        birthday date,
        joinday date default sysdate,
        enabled number(1) default 0,
        authority varchar2(10 char) default 'ROLE_USER',
        checkcode varchar2(20 char),
        count number(7) default 0,
        levels varchar2(15),
        -- ★ 수정 부분!
        loginFailCnt number(1) default 0,
        constraint member_pk_username primary key(username)
    );

  1. src/main/java - com.example.demo.controller - MemberController 수정

    @PreAuthorize("isAnonymous()")
    @GetMapping("/member/login")
    public void login(HttpSession session, HttpServletRequest request) {
        // 로그인에 실패한 경우
        if (session.getAttribute("msg") != null) {
            request.setAttribute("msg", session.getAttribute("msg"));
            session.removeAttribute("msg");
        }
    }
        @ResponseBody
        @GetMapping("/member/find/id")
        public ResponseEntity<String> findId(String email, HttpServletRequest request) {
            if (request.isUserInRole("ROLE_USER") == false)
                return ResponseEntity.status(HttpStatus.FORBIDDEN).body("잘못된 작업입니다.");
            String username = service.findId(email);
            if (username == null)
                return ResponseEntity.status(HttpStatus.CONFLICT).body("아이디를 찾지 못했습니다.");
            return ResponseEntity.ok(username);
        }
        @PreAuthorize("isAuthenticated()")
        @GetMapping("/member/change_password")
        public void changePassword(HttpSession session, HttpServletRequest request) {
            if (session.getAttribute("msg") != null) {
                request.setAttribute("msg", session.getAttribute("msg"));
                session.removeAttribute("msg");
            }
        }
    
        @PreAuthorize("isAuthenticated()")
        @PostMapping("/member/change_password")
        public String changePassword(String password, Principal principal) {
            service.changePassword(password, principal.getName());
            return "redirect:/";
        }

  1. src/main/resources - templates - member - change_password.html 생성
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="UTF-8" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
    <title>비밀번호 변경</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
    <link rel="stylesheet" href="/css/main.css">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script>
    <script th:inline="javascript">
    $(document).ready(function() {	
        let msg = /*[[${msg}]]*/
        if (msg != null)
            $('#msg_div').css("display","block");
    })
    </script>
    </head>
    <body>
    <div id="page">
        <header id="header" th:replace="/fragments/header">
        </header>
        <nav id="nav" th:replace="/fragments/nav">
        </nav>
        <div id="main">
            <aside id="aside" th:replace="/fragments/aside">
            </aside>
            <div class="alert alert-danger" id="msg_div" style="display:none;">
                <strong>주의! </strong><span th:text="${msg}"></span>
            </div>
            <section id="section">
                <form id="change_password_form" method="post" action="/member/change_password">
                    <input type="hidden" name="_csrf" th:value="${_csrf.token}">
                    <div class="form-group">
                        <label for="password">비밀번호</label>
                        <input id="password" type="password" name="password" class="form-control">
                        <span class="helper-text" id="password_msg"></span>
                    </div>
                    <div class="form-group">
                        <label for="password2">비밀번호 확인</label>
                        <input id="password2" type="password" class="form-control">
                        <span class="helper-text" id="password2_msg"></span>
                    </div>
                    <button class="btn btn-success" id="change_password">비밀번호 변경</button>
                </form>
            </section>
        </div>
        <footer id="footer" th:replace="/fragments/footer">
        </footer>
    </div>
    </body>
    </html>

  1. src/main/resources - templates - member - login.html 수정

    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="UTF-8" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
    <title>로그인</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
    <link rel="stylesheet" href="/css/main.css">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script>
    <script th:inline="javascript">
    $(document).ready(function() {
        // ★ 수정 부분!
        let msg = /*[[${msg}]]*/
        if (msg != null)
            $('#msg_div').css("display","block");
    
        $('#login').click(function(e) {
            e.preventDefault();
            const $username = $('#username').val().toUpperCase();
            $('#username').val($username);
            $('#login_form').submit();
        })
    })
    </script>
    </head>
    <body>
    <div id="page">
        <header id="header" th:replace="/fragments/header">
        </header>
        <nav id="nav" th:replace="/fragments/nav">
        </nav>
        <div id="main">
            <aside id="aside" th:replace="/fragments/aside">
            </aside>
            <div class="alert alert-danger" id="msg_div" style="display:none;">
                <strong>서버 메시지 </strong><span th:text="${msg}"></span>
            </div>
            <section id="section">
                <form id="login_form" method="post" action="/member/login">
                    <input type="hidden" name="_csrf" th:value="${_csrf.token}">
                    <div class="form-group">
                        <label for="username">아이디</label>
                        <input id="username" type="text" name="username" class="form-control">
                        <span class="helper-text" id="username_msg"></span>
                    </div>
                    <div class="form-group">
                        <label for="password">비밀번호</label>
                        <input id="password" type="password" name="password" class="form-control">
                        <span class="helper-text" id="password_msg"></span>
                    </div>
                    <button class="btn btn-success" id="login">로그인</button>
                    <a class="btn btn-warning" href="/member/find_id">아이디 찾기</a>
                    <a class="btn btn-info" href="/member/find_password">비밀번호 찾기</a>
                </form>
            </section>
        </div>
        <footer id="footer" th:replace="/fragments/footer">
        </footer>
    </div>
    </body>
    </html>

profile
못하다 보면 잘하게 되는 거야 ・ᴗ・̥̥̥

0개의 댓글