지난 시간에 이어 회원 가입을 마저 완료하고 로그인, 아이디 찾기까지 진행한다.
설계
주소 | method | 방식 | 리턴 |
---|---|---|---|
/member/join | GET | MVC | void |
POST | MVC | 체크코드 확인 페이지 | |
/member/id_check | GET | REST | Boolean (ResponseEntity 사용) |
/member/check_join | GET | MVC | void |
POST | MVC | 로그인 페이지 | |
/member/login | GET | MVC | void |
POST | MVC | 루트 페이지 (스프링 시큐리티가 이동시켜 준다.) | |
/member/check_join | GET | MVC | void |
POST | MVC | String (RedirectAttributes 사용) | |
/member/find_id | GET | MVC | void |
/member/find/id | GET | REST | String (ResponseEntity 사용) |
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 후에는 소멸한다. 간단히 말해 오류가 발생한 곳과 오류 메시지 출력하는 곳이 따로일 때, 일회성 메시지 저장용으로 쓸 수 있다.
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;
}
}
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 관계로 코드를 짠다.
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;
}
}
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>
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"
속성 까먹지 않기!