[Spring Boot] 11. 홈페이지 만들기 ② 로그인, 아이디 찾기

shr·2022년 2월 22일
0

Spring

목록 보기
10/23
post-thumbnail

지난 시간에 이어 회원 가입을 마저 완료하고 로그인, 아이디 찾기까지 진행한다.

  1. 설계

    주소method방식리턴
    /member/joinGETMVCvoid
    POSTMVC체크코드 확인 페이지
    /member/id_checkGETRESTBoolean (ResponseEntity 사용)
    /member/check_joinGETMVCvoid
    POSTMVC로그인 페이지
    /member/loginGETMVCvoid
    POSTMVC루트 페이지 (스프링 시큐리티가 이동시켜 준다.)
    /member/check_joinGETMVCvoid
    POSTMVCString (RedirectAttributes 사용)
    /member/find_idGETMVCvoid
    /member/find/idGETRESTString (ResponseEntity 사용)

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

    package com.example.demo.controller;
    
    import java.time.LocalDate;
    
    import javax.validation.constraints.*;
    
    import org.springframework.beans.factory.annotation.*;
    import org.springframework.http.*;
    import org.springframework.security.access.prepost.*;
    import org.springframework.stereotype.*;
    import org.springframework.web.bind.*;
    import org.springframework.web.bind.annotation.*;
    import org.springframework.web.servlet.mvc.support.*;
    
    import com.example.demo.controller.editor.*;
    import com.example.demo.entity.*;
    import com.example.demo.service.*;
    
    @Controller
    public class MemberController {
        @InitBinder
        public void init(WebDataBinder wdb) {	
            wdb.registerCustomEditor(LocalDate.class, new DatePropertyEditor());
        }
    
        @Autowired
        private MemberService service;
    
        // 1. 화면 보여 주기만 하는 (void) 메소드들 먼저 만들기
        @PreAuthorize("isAnonymous")
        @GetMapping("/member/join")
        public void join() {
        }
    
        @PreAuthorize("isAnonymous")
        @GetMapping("/member/login")
        public void login() {
        }
    
        // 2. id_check 만들기, ResponseEntity 쓰기
        @PreAuthorize("isAnonymous")
        @GetMapping("/member/id_check")
        public ResponseEntity<Boolean> idCheck(@NotNull String username) {
            // return ResponseEntity.ok(true); // 200이 나온다.
            if (service.idCheck(username)==false)
                return ResponseEntity.status(HttpStatus.CONFLICT).body(false);
            return ResponseEntity.status(HttpStatus.OK).body(true);
        }
    
        @PreAuthorize("isAnonymous")
        @PostMapping("/member/join")
        public String join(Member member, RedirectAttributes ra) {
            service.join(member);
            ra.addFlashAttribute("msg", "가입 확인 코드를 이메일로 보냈습니다. 이메일을 확인하세요");
            return "redirect:/member/check_join";
        }
    
        @PreAuthorize("isAnonymous")
        @GetMapping("/member/check_join")
        public void checkJoin() {
            // RedirectAttributes에 담은 값을 화면에 출력할 때는 추가 작업이 불필요하다.
            // 다만 이 컨트롤러에서 꺼내서 다른 곳에서 사용하려고 할 때는 추가 코딩이 필요하다.
        }
    
        @PreAuthorize("isAnonymous")
        @PostMapping("/member/check_join")
        public String checkJoin(String checkcode, RedirectAttributes ra) {
            Boolean result = service.checkcode(checkcode);
            if (result==true) 
                return "redirect:/member/login";
            else {
                ra.addFlashAttribute("msg", "체크코드를 확인하세요");
                return "redirect:/member/check_join";
            }
        }
    
        @PreAuthorize("isAnonymous")
        @GetMapping("/member/find_id")
        public void findId() {
        }
    
        // REST 방식에서 값을 읽어내는 동작은 GET이다. ★ 매핑 주소 find_id 아닌 find/id으로 주는 것 주의!
        @ResponseBody
        @PreAuthorize("isAnonymous")
        @GetMapping("/member/find/id")
        public ResponseEntity<String> findId(String email) {
            String username = service.findId(email);
            if(username == null)
                return ResponseEntity.status(HttpStatus.CONFLICT).body("아이디를 찾지 못했습니다.");
            return ResponseEntity.ok(username);
        }
    
        @PreAuthorize("isAnonymous")
        @GetMapping("/member/reset_password")
        public void resetPassword() {
        }
    
        @PreAuthorize("isAnonymous")
        @PostMapping("/member/reset_password")
        public String resetPassword(String username, String email, RedirectAttributes ra) {
            // 비밀번호를 찾으면 로그인 창으로 이동해 "임시 비밀번호를 이메일로 전송했습니다."라고 출력하려고 한다.
            // 비밀번호를 못 찾으면 GET /member/reset_password로 이동해서 "비밀번호를 찾지 못했습니다"라고 출력하려고 한다.
            Boolean result = service.resetPassword(username, email);
            if(result==true) {
                ra.addFlashAttribute("msg", "임시비밀번호를 이메일로 전송했습니다");
                return "redirect:/member/login";
            } else {
                ra.addFlashAttribute("msg", "비밀번호를 찾지 못했습니다");
                return "redirect:/member/reset_password";
            }
        }
    } 

    💡 RedirectAttributes

    checkJoin()를 보자. 체크코드를 옳게 입력했을 때는 login창으로, 틀리게 입력했을 때는 "체크코드를 확인하세요."라는 메시지를 출력하려고 한다. 체크코드가 옳은지 아닌지에 대한 데이터는 POST 방식의 check_join에 담기는데, 오류 메시지를 출력할 수 있는 것은 GET 방식의 check_join이다. 이럴 때 쓰는 것이 RedirectAttributes이다. addFlashAttribute()는 redirect 전에 플래시에 데이터를 저장하고, redirect 후에는 소멸한다. 간단히 말해 오류가 발생한 곳과 오류 메시지 출력하는 곳이 따로일 때, 일회성 메시지 저장용으로 쓸 수 있다.


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

    package com.example.demo.service;
    
    import java.time.LocalDate;
    
    import javax.mail.MessagingException;
    import javax.mail.internet.MimeMessage;
    
    import org.apache.commons.lang3.RandomStringUtils;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.mail.javamail.*;
    import org.springframework.security.crypto.password.*;
    import org.springframework.stereotype.Service;
    
    import com.example.demo.dao.MemberDao;
    import com.example.demo.entity.Level;
    import com.example.demo.entity.Member;
    
    @Service
    public class MemberService {
        @Autowired
        private MemberDao dao;
        @Autowired
        private PasswordEncoder passwordEncoder;
        @Autowired
        private JavaMailSender javaMailSender;
    
        private void sendMail(String from, String to, String subject, String text) {
            MimeMessage message = javaMailSender.createMimeMessage();
            MimeMessageHelper helper;
            try {
                helper = new MimeMessageHelper(message, false, "utf-8");
                helper.setFrom(from);
                helper.setTo(to);
                helper.setSubject(subject);
                helper.setText(text, true);
                javaMailSender.send(message);
            } catch (MessagingException e) {
                e.printStackTrace();
            }	
        }
    
        public boolean idCheck(String username) {
            return !dao.existsById(username);
        }
    
        public void join(Member member) {
            String checkcode = RandomStringUtils.randomAlphanumeric(20);
            String encodedPassword = passwordEncoder.encode(member.getPassword());
            member.setJoinday(LocalDate.now()).setAuthority("ROLE_USER").setCheckcode(checkcode)
                .setEnabled(false).setLevels(Level.BRONZE).setPassword(encodedPassword).setCount(0);
            dao.save(member);
            String text = new StringBuilder("<h1>가입 확인 코드</h1>")
                    .append("<p>가입을 마무리하려면 아래 코드를 화면에 입력해 주세요 (*˙˘˙)♡</p>")
                    .append(checkcode).toString();
            sendMail("admin@icia.com", member.getEmail(), "가입 확인 메일 (。・ө・。)", text);
        }
    
        public Boolean checkcode(String checkcode) {
            Member member = dao.findByCheckcode(checkcode);
            if(member == null)
                return false;
            return dao.update(Member.builder().username(member.getUsername()).checkcode("0").enabled(true).build())==1;
        }
    
        public String findId(String email) {
            Member member = dao.findByEmail(email);
            if (member == null)
                return null;
            return member.getUsername();
        }
    
        public Boolean resetPassword(String username, String email) {
            Member member = dao.findById(username);
            // 아이디 검색 실패
            if (member == null)
                return false;
            // 이메일 검색 실패
            if (member.getEmail().equals(email) == false)
                return false;
            // 임시 비밀번호를 만들어 암호화한 다음 DB에 업데이트 후 이메일로 임시 비밀번호 발송
            String newPassword = RandomStringUtils.randomAlphanumeric(20);
            String newEncodedPassword = passwordEncoder.encode(newPassword);
            dao.update(Member.builder().username(username).password(newEncodedPassword).build());
            String text = new StringBuilder("<h1>임시 비밀번호</h1>")
                    .append("<p>아래 임시 비밀번호로 로그인해 주세요. 로그인 후 비밀번호를 변경해 주세요. (*˙˘˙)♡</p>")
                    .append(newPassword).toString();
            sendMail("admin@icia.com", member.getEmail(), "임시 비밀번호 안내 (。・ө・。)", text);
            return true;
        }
    }

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

    package com.example.demo.service;
    
    import java.util.ArrayList;
    import java.util.Collection;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.InternalAuthenticationServiceException;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.stereotype.Service;
    
    import com.example.demo.dao.MemberDao;
    import com.example.demo.dto.Account;
    import com.example.demo.entity.Member;
    
    import lombok.AllArgsConstructor;
    
    // UserDetails : 스프링 시큐리티에서 인증 정보를 담고 있는 클래스 -> Account 클래스 작성
    
    @AllArgsConstructor
    @Service
    public class LoginService implements UserDetailsService {
        @Autowired
        private MemberDao dao;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            Member member = dao.findById(username);
            // 1. 사용자가 없는 경우 -> InternalAuthenticationServiceException
            if (member == null)
                throw new InternalAuthenticationServiceException("사용자를 찾을 수 없습니다.");
    
            // 2. Account 객체 생성
            Account account = Account.builder().username(username).password(member.getPassword()).enabled(member.getEnabled()).build();
    
            // 3. 권한을 넣어 준 다음 return 한다.
            String authority = member.getAuthority();
            Collection<GrantedAuthority> authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority(authority));
            account.setAuthorities(authorities);
            return account;
        }
    }

    💡 UserDetails

    스프링 시큐리티에서 사용자의 정보를 담는 인터페이스이다. 우리가 이 인터페이스를 구현하게 되면 Spring Security에서 구현한 클래스를 사용자 정보로 인식하고 인증 작업을 한다. 쉽게 말하면 UserDetails 인터페이스는 VO 역할을 한다고 보면 된다.

    출처
    Spring Security - 인증 절차 인터페이스 구현 (1) UserDetailsService, UserDetails

    📝 스프링 시큐리티에서 권한은 단순 문자열이다. 즉 "ROLE_ADMIN"은 "ROLE_USER"와 완전히 별개이다. 그래서 관리자가 유저의 작업까지 하려면, ROLE_ADMIN과 ROLE_USER를 모두 가져야만 하므로 1:N 관계가 된다. 그렇기 때문에 Member 테이블과 권한 테이블을 분리해야 한다. 여기서 프로그래머마다 정책을 달리 할 수 있는데 우리는 관리자는 관리자 아이디 따로, 일반 사용자는 일반 사용자 아이디 따로 사용하도록 사용자와 권한을 1:1 관계로 코드를 짠다.


  1. src/main/java - com.example.demo.dto - Account 생성

    package com.example.demo.dto;
    
    import java.util.Collection;
    
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    
    import lombok.AllArgsConstructor;
    import lombok.Builder;
    import lombok.NoArgsConstructor;
    import lombok.Setter;
    
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public class Account implements UserDetails {
        private String username;
        private String password;
        private Boolean enabled;
        private Collection<GrantedAuthority> authorities;
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorities;
        }
    
        @Override
        public String getPassword() {
            return password;
        }
    
        @Override
        public String getUsername() {
            return username;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return enabled;
        }
    }

  1. src/main/resources - mapper - memberMapper.xml 수정

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.example.demo.dao.MemberDao">
        <select id="existsById" resultType="boolean">
            select count(*) from member where username=#{username} and rownum=1
        </select>
    
        <insert id="save">
            insert into member(username, password, irum, email, birthday, levels, checkcode) 
            values(#{username}, #{password}, #{irum}, #{email}, #{birthday}, #{levels}, #{checkcode})
        </insert>
    
        <select id="findByCheckcode" resultType="member">
            select * from member where checkcode=#{checkcode} and rownum=1
        </select>
    
        <select id="findByEmail" resultType="member">
            select * from member where email=#{email} and rownum=1
        </select>
    
        <select id="findById" resultType="member">
            select * from member where username=#{username} and rownum=1
        </select>
    
        <update id="update">
            update member 
            <trim suffixOverrides="," prefix="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>
            </trim>
            where username=#{username}
        </update>
    </mapper>

  1. src/main/src - templates - member - check_join.html, login.html, find_id.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>Insert title here</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() {
        // 서버에서 ${msg} 값이 전달된 경우 $('#msg_div')를 보이게 하자.
        // 자바스크립트에서 타임리프 변수에 접근하기
        const msg = /*[[${msg}]]*/;
    
        // 위 코드에서 msg 값이 없으면 null이 된다.
        // js에서 undefined와 null의 차이 : undefined는 값을 안 준 것, null은 값이 없는 것
        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>
            <section id="section">
                <!--  bootstrap alert  -->
                <div class="alert alert-warning" id="msg_div" style="display:none;">
                    <strong>확인!</strong><span th:text="${msg}" ></span>
                </div>
                <form id="check_join_form" method="post" action="/member/check_join">
                    <input type="hidden" name="_csrf" th:value="${_csrf.token}">
                    <div>
                        <h1>체크코드 확인</h1>
                        <div class="form-group">
                            <label for="checkcode">체크코드</label>
                            <input id="checkcode" type="text" name="checkcode" class="form-control">
                            <span class="helper-text" id="checkcode_msg"></span>
                        </div>
                        <button class="btn btn-primary">가입 완료</button>
                    </div>
                </form>
            </section>
        </div>
        <footer id="footer" th:replace="/fragments/footer">
        </footer>
    </div>
    </body>
    </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>Insert title here</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>
    $(document).ready(function() {
        // 로그인에 실패했을 때 : /member/login?error
        const queryString = location.search.substr(1);
        if (queryString == "error")
            alert("로그인에 실패했습니다.");
    
        // 임시 비밀번호가 발급됐을 때 : RedirectAttributes
        const msg = /*[[${msg}]]*/
        console.log("")
        if (msg!=null)
            $('#msg_div').css("display","block");
    
        $('#login').click(function(e) {
            // #login 버튼이 폼 내부에 있으면 submit으로 동작한다. 그걸 막기 위해 preventDefualt를 쓴다.
            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>
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="UTF-8" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
    <title>Insert title here</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>
    $(document).ready(function() {
        $('#findId').click(function() {
            $.ajax("/member/find/id?email=" + $('#find_id_email').val())
                .done((result) => { Swal.fire("성공", "아이디 : " + result, "success") })
                .fail((response) => {
                    // 서버에서 보낸 오류 메시지
                    console.log(response.responseText);
                    // 서버에서 보낸 상태 코드
                    console.log(response.status);
                    Swal.fire("검색 실패", response.responseText, "error");
                }).always(()=>{
                    // 성공과 실패에 상관없이 항상 실행되는 코드
                    $('#find_id_email').val("")
                });
        });
    });
    </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>
            <section id="section">
                <div id="find_id">
                    <h1>아이디 찾기</h1>
                    <div class="form-group">
                        <label for="find_id_email">이메일</label>
                        <input id="find_id_email" type="text" class="form-control">
                        <span class="helper-text" id="find_id_email_msg"></span>
                    </div>
                    <button class="btn btn-primary" id="findId">아이디 찾기</button>
                </div>	
            </section>
        </div>
        <footer id="footer" th:replace="/fragments/footer">
        </footer>
    </div>
    </body>
    </html>

    📝 타임리프에서 자바스크립트를 사용하기 위한 th:inline="javascript" 속성 까먹지 않기!


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

0개의 댓글