안녕하세요 코이킹입니다.
이번 포스트는 세션을 활용한 로그인과 로그아웃 구현에 대한 내용이 되겠습니다.
1. 목표
- 스프링 부트를 사용하여 복잡한 프로세스가 포함된 기능을 갖춘 웹 어플리케이션을 구현할 수 있다.
- 세션을 활용한 로그인 / 로그아웃 기능을 구현할 수 있다.
- 스코프(페이지, 리퀘스트, 세션, 어플리케이션) 라는 키워드에 대해 알기
2. 어떻게 구현할지에 대한 설명
로그인 기능의 핵심을 정의해보면 '유저로 부터 입력받은 인증정보가 일치하면, 권한을 부여하여 회원만이 사용 가능한 기능을 사용할 수 있게 하는 것과 로그아웃을 하기 전이나 권한 부여 기간이 만료되기 전까지는 페이지 이동이 있더라도 권한이 유지되야하는 것'이며 로그아웃은 '로그인으로 인해 부여된 권한을 제거하는 것'입니다.
이제 핵심을 바탕으로 해야할 일을 정의해보자면 다음과 같습니다.
스스로 질문하기 | 답변추려내기 | |
데이터 다루기 | 로그인시의 데이터 입력방법 | 로그인 페이지를 추가하여, 데이터 입력 폼에서 로그인에 필요한 정보를 입력 받는다. |
로그인 페이지의 이동경로 | 페이지의 헤더부분의 링크에서 이동 | |
로그인 처리요청시 데이터 전송방법 | POST방식으로 데이터 송신하기 | |
로그인 페이지 요청, 로그인 처리 요청 수신방법 | 회원기능 컨트롤러에서 로그인 페이지 요청, 로그인 처리 요청을 매칭하는 메서드를 추가하기. | |
로그인 처리완료후 인증정보는 어떻게 저장할것 인지? | 세션계층에 데이터 저장하기 | |
프로세스 | 로그인 유지 기간은? | 개발중에는 3 또는 5분 배포시에는 30분으로 설정 |
로그인 하지 않은 유저와 로그인을 완료한 유저의 차이는? | 미 로그인 시 : - 상단의 '로그인'과 '회원가입' 링크버튼이 표시 - 글 수정 / 글 삭제 기능 사용 불가 로그인 완료 시 : - 상단에 '로그인'과 '회원가입' 링크버튼을 비표시하고, '로그아웃 '버튼표시 - 글 수정 / 글 삭제 기능 사용가능 |
해야할 일을 추려내면서 세션이라는 단어가 몇 번이고 등장하고 있는 것을 확인할 수 있으실 겁니다.
이 포스트를 통해 구현과정을 설명하려는 로그인 / 로그아웃 기능에서 인증정보를 담을 곳으로 세션을 선택한 이유를 알기 위해서는 서블릿의 스코프에 대해서 알아야 합니다.
영역 | 값이 유지되는 주기 | 사용처 |
리퀘스트 | 요청를 보내고 서버가 응답할 때까지 | - CRUD 게시판 구현시 데이터를 담아온 영역 - forward시에 데이터 유지에 사용 |
세션 | 유효기간 만료 또는 브라우저 종료시 | - 각각의 유저가 자신의 브라우저에서 페이지를 이동해도 값이 유지되야하는 기능을 구현할 때 ex) 인증정보 저장, 장바구니등.. |
어플리케이션 | 웹 어플리케이션이 종료될때 까지 | - 어플리케이션을 이용하는 사용자 전원에게 전역적으로 사용되어야하는 값을 설정할 때 ex) 어플리케이션의 URL |
위의 스코프에 대한 설명 대로 페이지를 이동해도 인증정보를 유지하기 위해서 로그인 기능을 구현할 때 세션을 사용할 겁니다.
3. 프로세스 흐름
4. 데이터 흐름
① 브라우저에서 서버로 로그인 페이지 표시 요청을 보내고, 서버에서 브라우저로 로그인 페이지를 리턴합니다.
② 로그인 요청을 보냅니다. 로그인 처리 요청의 일련의 처리를 행한 후 결과를 리턴하는데,
리턴시 세션에 인증 정보를 담아 게시판의 글목록으로 리턴합니다.
다른 페이지로 이동하더라도 인증정보는 남아있습니다.
③ 브라우저에서 서버로 로그아웃 요청을 보냅니다. 서버에선 세션에 등록된 인증정보를 제거하고, 세션을 만료하여 로그아웃 처리를 실시한 후 게시판의 글목록으로 리턴합니다.
5. 소스코드와 해석
1) 로그인 페이지 템플릿 : /template-springboot/src/main/resources/templates/auth/login.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{common/layout}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, inital-scale=1.0"/>
</head>
<body>
<div layout:fragment="contents">
<form method="post" th:action=@{/auth/login}>
<h1 class="h3 mb-3 fw-normal text-center">Please Login</h1>
<div class="mb-3 col-8">
<label class="form-label">ID (E-mail)</label>
<input type="text" name="memberId" class="form-control" aria-describedby="emailHelp">
<div id="emailHelp" class="form-text">We'll never share your ID with anyone else.</div>
</div>
<div class="mb-3 col-8">
<label class="form-label"> Password </label>
<input class="form-control" type="password" name="password">
</div>
<div class="nav justify-content-center col-8">
<input class="btn btn-primary" type="submit" value="Login Complete">
</div>
</form>
<hr/>
<div>
<button class="btn btn-secondary py-2 my-2" type="button" onclick="location.href='javascript:history.back();'">Return to Previous Page</button>
<button class="btn btn-info py-2 my-2" type="button" onclick="location.href='/board/list'">Back to List</button>
</div>
</div>
</body>
</html>
※ 구현된 대부분의 코드는 게시판 기능 구현 시에 사용된 것과 동일하므로 자세한 설명은 생략합니다.
- 폼 태그를 사용하여 로그인 처리요청을 서버로 보내는 템플릿입니다.
2) 회원 기능 컨트롤러 : /template-springboot/src/main/java/com/sb/template/controller/MembershipController.java
package com.sb.template.controller;
import java.util.List;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.sb.template.dto.ResponseDto;
import com.sb.template.entity.Member;
import com.sb.template.enums.ResponseInfo;
import com.sb.template.forms.AuthForm;
import com.sb.template.service.MemberService;
import lombok.extern.slf4j.Slf4j;
@Controller
@RequestMapping("auth")
@Slf4j
public class MembershipController {
@Autowired
private MemberService memberService;
@RequestMapping(method = RequestMethod.GET, path = "/join")
public String joinMember(@ModelAttribute AuthForm form) {
return "auth/join";
}
@RequestMapping(method = RequestMethod.POST, path = "/join")
public String joinMember(@ModelAttribute @Validated AuthForm form, BindingResult bindingResult, Model model) {
if (bindingResult.hasErrors()) {
List<String> errors = bindingResult.getAllErrors().stream().map(e -> e.getDefaultMessage()).collect(Collectors.toList());
model.addAttribute("message", String.join(", ", errors));
model.addAttribute("redirectUrl", "/auth/join");
return "/common/message";
}
Member member = memberService.createMember(form);
if (member == null) {
model.addAttribute("message", form.getMemberId()+" is already exist!!");
model.addAttribute("redirectUrl", "/auth/join");
return "/common/message";
}
model.addAttribute("message", member.getMemberId()+"has been joined!!");
model.addAttribute("redirectUrl", "/auth/login");
return "/common/message";
}
@PostMapping(path = "/checkId")
@ResponseBody
public ResponseEntity<?> checkId(
@RequestParam(required = true,
name = "memberId")
@Email
@NotBlank
String memberId
) {
log.info("Input memberId : {}", memberId);
return ResponseEntity.ok(ResponseDto.builder()
.resultCode(ResponseInfo.SUCCESS.getResultCode())
.message(ResponseInfo.SUCCESS.getMessage())
.data(memberService.existMemberId(memberId))
.build()
);
}
@RequestMapping(method = RequestMethod.GET, path = "/login")
public String loginForm() {
return "auth/login";
}
@RequestMapping(method = RequestMethod.POST, path = "/login")
public String doLogin(@ModelAttribute @Validated AuthForm form, BindingResult bindingResult, HttpServletRequest req, HttpServletResponse res, Model model) {
if (bindingResult.hasErrors()) {
List<String> errors = bindingResult.getAllErrors().stream().map(e -> e.getDefaultMessage()).collect(Collectors.toList());
model.addAttribute("message", String.join(", ", errors));
model.addAttribute("redirectUrl", "/auth/join");
return "/common/message";
}
log.info("LoginForm Data : {} ", form.toString());
memberService.loginProcess(form, req, res, model);
return "/common/message";
}
@RequestMapping(method = RequestMethod.GET, path = "/logout")
public String logout(HttpServletRequest req, HttpServletResponse res, Model model) {
HttpSession session = req.getSession();
Object obj = session.getAttribute("member");
if (obj != null) {
session.removeAttribute("member");
session.invalidate();
}
model.addAttribute("message", "Logout Success!!");
model.addAttribute("redirectUrl", "/");
return "/common/message";
}
}
- 102행 : 로그인 처리 메서드를 추가했습니다.
112행에서 서비스를 호출하여 로그인 처리를 위임합니다. 세션은 스프링에서 제공해주는 기능이 아닌 서블릿의 기능인 HttpServletRequest를 사용하여 다루고 있습니다.
- 119행 : 로그아웃 처리 메서드를 추가했습니다.
인증 정보를 세션에서 제거하고, 세션을 만료시킴으로써 로그아웃 처리를 완료합니다.
3) 회원기능 서비스 : /template-springboot/src/main/java/com/sb/template/service/MemberService.java
package com.sb.template.service;
import java.util.Date;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;
import com.sb.template.entity.Member;
import com.sb.template.enums.MemberRole;
import com.sb.template.forms.AuthForm;
import com.sb.template.repo.MemberRepository;
import com.sb.template.utils.EncUtil;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
public class MemberService {
@Autowired
private MemberRepository memberRepository;
@Autowired
private EncUtil enc;
public Member createMember(AuthForm form) {
Optional<Member> memberOp = memberRepository.findByMemberId(form.getMemberId());
if (!memberOp.isEmpty()) {
log.warn("{} is already exist!!", form.getMemberId());
return null;
}
Member member = form.toEntity();
member.setPassword(enc.generateSHA512(member.getPassword()));
member.setRole(MemberRole.COMMON.value);
member = memberRepository.save(member);
return member;
}
public boolean existMemberId(String memberId) {
Optional<Member> result = memberRepository.findByMemberId(memberId);
return result.isEmpty() ? false : true;
}
public Optional<Member> getMemberInfoByMemberId(String memberId) {
return memberRepository.findByMemberId(memberId);
}
public void loginProcess(AuthForm form,
HttpServletRequest req, HttpServletResponse res, Model model) {
// exist and data
Optional<Member> memberOp = memberRepository.findByMemberId(form.getMemberId());
if (memberOp.isEmpty()) {
model.addAttribute("message", form.getMemberId()+" is not exist!!");
model.addAttribute("redirectUrl", "/auth/join");
return;
}
// Password OK?
if (!memberOp.get().getPassword().equals(enc.generateSHA512(form.getPassword()))) {
model.addAttribute("message", "Unmatch Password!!");
model.addAttribute("redirectUrl", "/auth/login");
return;
}
// Set User Data in Session
HttpSession session = req.getSession();
session.setMaxInactiveInterval(300000);
session.setAttribute("member", memberOp.get());
// Update latest login time.
memberOp.get().setUpdatedTime(new Date());
memberRepository.save(memberOp.get());
log.info("Save login user info {} : ", memberOp.get().toString());
model.addAttribute("message", "Login Success!!");
model.addAttribute("redirectUrl", "/");
}
}
- 60행 : 로그인 처리 메서드입니다.
-> 65행 : ID존재 여부를 확인하여, 없다면 회원가입 화면으로 리다이렉트 하도록 설정합니다.
-> 72행 : 패스워드가 일치하는 지 확인합니다. 단방향 암호화가 된 상태로 패스워드가 저장되어 있으므로, 패스워드를 확인할 때도 입력된 패스워드를 암호화를 한 후 DB의 패스워드 값과 비교합니다. 패스워드 불일치라면 로그인 화면으로 리다이렉트 하도록 설정합니다.
- 80행 : 세션의 만료 시간을 5분으로 설정합니다.
- 81행 : 세션에 인증정보를 등록합니다.
4) 로그인 이후 조건부 렌더링이 추가된 템플릿
(1) 글 상세보기 : /template-springboot/src/main/resources/templates/board/read.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{common/layout}">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, inital-scale=1.0"/>
</head>
<body>
...
...
<div>
<button class="btn btn-info" type="button" onclick="location.href='/board/list'">Back to List</button>
<button class="btn btn-warning" th:if="${not #strings.isEmpty(session?.member?.memberId)} and ${session.member.memberId}==${board.memberId}" type="button" th:onclick="'location.href=\'' + @{/board/update/{boardNo}(boardNo=${board.boardNo})} +'\';'">Update</button>
<button class="btn btn-danger" th:if="${not #strings.isEmpty(session?.member?.memberId)} and ${session.member.memberId}==${board.memberId}" type="button" th:onclick="'location.href=\'' + @{/board/delete/{boardNo}(boardNo=${board.boardNo})} +'\';'">Delete</button>
</div>
</div>
</body>
</html>
-16~17행 : 세션에 member객체(인증정보)가 있는지 확인하여, 글 수정과 삭제 버튼을 조건부 렌더링합니다.
session?. member?. memberId와 같이? 표시가 들어있는 이유는 값이 null일 경우 타입 리프 템플릿을 브라우저에 표시할 때 에러가 발생하는데?를 넣어주면 null이어도 에러가 발생하지 않습니다.
${not #strings.isEmpty(session?.member?.memberId)} : 세션에 저장되어 있는 member객체의 memberId가 비어있지 않다면 true
and ${session.member.memberId}==${board.memberId} : 세션에 저장되어 있는 member객체의 memberId가 리퀘스트에 저장된 memberId가 일치한다면 true
두 개의 조건식이 모두 true의 경우 버튼이 렌더링 됩니다.
※ 두번째 조건식의 세션 값을 가져올 때?를 사용하지 않은 건 and를 사용 시 왼쪽의 식부터 실행되는 데 왼쪽의 식이 true가 아니라면 두 번째 조건식에 애초에 실행되지 않기 때문에 굳이 사용하지 않았습니다.
(2) 상단의 공통부분 : /template-springboot/src/main/resources/templates/common/common_header.html
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<body>
<div th:fragment="commonHeader">
<header class="bg-dark text-white d-flex flex-wrap align-items-center justify-content-center justify-content-md-between border-bottom">
<h3>
<a href="/" class="d-flex align-items-center col-md-3 text-white text-decoration-none px-1 py-1 mx-1 my-1">
Template-SpringBoot
</a>
</h3>
<div class="nav col-md-3 px-1">
<div th:if="${not #strings.isEmpty(session?.member?.memberId)}">
<p th:text="'Welcome ['+ ${session.member.memberId}+']'"></p>
</div>
<div class="nav-item px-1 py-1" th:if="${#strings.isEmpty(session?.member?.memberId)} and ${#httpServletRequest.requestURI != '/auth/login'}">
<button class="btn btn-primary" id="write-board" type="button" onclick="location.href='/auth/login'">Login Member</button>
</div>
<div class="nav-item px-1 py-1" th:if="${#strings.isEmpty(session?.member?.memberId)} and ${#httpServletRequest.requestURI != '/auth/join'}">
<button class="btn btn-success me-2" id="write-board" type="button" onclick="location.href='/auth/join'">Join Member</button>
</div>
<div class="nav-item px-1 py-1 row" th:if="${not #strings.isEmpty(session?.member?.memberId)}">
<div class="col-6 mx-auto my-auto">
<button class="btn btn-warning" id="write-board" type="button" onclick="location.href='/auth/logout'">Logout</button>
</div>
</div>
</div>
</header>
</div>
</body>
</html>
- 20행 : 로그인 버튼을 조건부 렌더링합니다.
세션에 저장된 인증정보가 없고, 현재 위치한 페이지가 로그인 페이지가 아닌 경우 표시됩니다.
- 23행 : 회원가입 버튼을 조건부 렌더링합니다.
세션에 저장된 인증정보가 없고, 현재 위치한 페이지가 회원가입 페이지가 아닌 경우 표시됩니다.
- 26행 : 로그아웃 버튼을 조건부 렌더링합니다. 세션에 저장된 인증정보가 있다면 표시됩니다.
6. 동작확인
7. 전체 소스코드
https://github.com/leeyoungseung/template-springboot/tree/feature/10_membership_board_login
이것으로 로그인과 로그아웃 구현에 대한 포스트는 마치겠습니다.
다음 포스트는 쿠키를 활용한 자동 로그인에 대한 내용이 되겠습니다.
'프로그래밍 > Springboot-토이프로젝트' 카테고리의 다른 글
【게시판-12】프로필사진 등록('폼 태그'와 '드래그 앤 드롭'방식으로 파일 업로드) (0) | 2022.09.29 |
---|---|
【게시판-11】쿠키와 인터셉터를 활용한 자동 로그인 (1) | 2022.09.21 |
【게시판-09】회원가입 (1) | 2022.09.20 |
【게시판-08】타임리프 템플릿 결합과 부트스트랩 적용 (0) | 2022.09.17 |
【게시판-07】페이징 처리 (1) | 2022.09.16 |
댓글