안녕하세요 코이킹입니다.
이번 포스트는 쿠키와 인터셉터를 활용한 자동 로그인 구현에 대한 내용이 되겠습니다.
1. 목표
- 스프링 부트를 사용하여 복잡한 프로세스가 포함된 기능을 갖춘 웹 어플리케이션을 구현할 수 있다.
- 쿠키와 인터셉터를 활용한 자동 로그인 기능을 구현할 수 있다.
- 스프링 부트를 사용하여 쿠키에 값을 넣고, 가져와서 사용하는 등 쿠키를 다룰 수 있다.
2. 어떻게 구현할지에 대한 설명
지난 포스트에서 세션을 활용하여 로그인 기능을 구혔했습니다. 세션을 활용한 로그인에서는 브라우져가 닫히면 인증정보가 사라져 로그인 상태가 해제되게 됩니다.
매번 로그인을 하는 행위는 의외로 귀찮은 작업이므로, 네이버와 같은 포털사이트를 보면 브라우져가 닫혔다가 다시 열리더라도 로그인 상태가 유지되는 설정이 있는 것을 알 수 있습니다.
이것의 정확한 명칭은 저도 잘 모르겠습니다만 일단 이 포스트내에선 '자동 로그인'이라고 부르도록 하겠습니다.
자동 로그인의 핵심을 정의해보면 '유저의 로그인 상태가 브라우저가 닫혔다가 다시 열리더라도 유지되는 것'입니다.
이제 핵심을 바탕으로 해야할 일을 정의해보자면 다음과 같습니다.
질문하기 | 답변추려내기 | |
데이터 다루기 | 브라우져가 닫혔을 때 인증정보를 보관을 어떻게 할지? | 쿠키에 인증정보를 저장 |
프로세스 | 자동로그인 기능 활성/비활성 컨트롤 | 로그인 페이지에서 '자동로그인'을 사용할 지 여부를 체크하여, 서버에 로그인 처리 요청시 '자동로그인'을 사용할 지 여부를 파라미터로 같이 보낸다. |
자동로그인의 처리가 필요한 지의 여부 판단 | 스프링의 인터셉터 기능을 사용 |
쿠키는 서버가 아닌 클라이언트에 값을 저장하므로, 브라우저가 닫히더라도 값을 보존할 수 있습니다.
쿠키에 인증정보를 담았다면, 쿠키에 담긴 인증정보를 확인하는 프로세스를 추가해야 자동 로그인 기능이 완성되는데 쿠키에 담긴 인증정보를 확인하는 프로세스는 스프링의 인터셉터를 활용하려 합니다.
인터셉터는 컨트롤터에서 요청을 받기 전에 요청을 가로 채서 전처리를 가능하게 하는 기술입니다.
3. 프로세스 흐름
4. 데이터 흐름
① 브라우저에서 서버로 로그인 페이지 표시 요청을 보내고, 서버에서 브라우저로 로그인 페이지를 리턴합니다.
② 로그인 요청을 보낼때 자동 로그인 체크박스의 체크 유무의 값을 같이 보냅니다.
③ 브라우저가 닫히고 다시 어플리케이션에 요청을 보내는 것을 상정했습니다.
③-1. ② 로그인 요청시 자동 로그인 사용을 체크한 경우입니다.
인터셉터에서 쿠키의 인증정보를 확인 한 후, 인증정보가 존재한다면 인증정보인 세션 ID를 키로 DB에서 유저 정보를 가져옵니다. DB의 유저 정보에 저장된 세션 만료 시간인 아직 남아있는 경우에는 세션에 인증정보를 담아 로그인 상태를 유지시킨 후 컨트롤러로 요청을 넘겨서 해당하는 처리를 실시하여 결과를 브라우저로 리턴합니다.
③-2. ② 로그인 요청시 자동 로그인 사용을 체크하지 않은 경우입니다.
인터셉터에서 쿠키의 정보가 확인되지 않았으므로 컨트롤러로 요청을 넘겨서 해당하는 처리를 실시하여 결과를 브라우저로 리턴합니다.
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>
<div class="form-check">
<label class="form-check-label"> Auto Login </label>
<input class="form-check-input" type="checkbox" name="useAutoLogin">
</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>
- 30~33행 : 자동로그인 여부를 확인하는 체크박스를 추가합니다.
2) 회원 기능 컨트롤러 : /template-springboot/src/main/java/com/sb/template/controller/MembershipController.java
package com.sb.template.controller;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import javax.servlet.http.Cookie;
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 org.springframework.web.util.WebUtils;
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();
Cookie authCookie = WebUtils.getCookie(req, "authCookie");
if (authCookie != null) {
authCookie.setPath("/");
authCookie.setMaxAge(0);
res.addCookie(authCookie);
Optional<Member> entity = memberService.getMemberInfoByMemberId(((Member)obj).getMemberId());
Member member = entity.get();
member.setSessionKey("unused");
member.setSessionLimitTime(new Date(System.currentTimeMillis()));
memberService.updateMember(member);
}
}
model.addAttribute("message", "Logout Success!!");
model.addAttribute("redirectUrl", "/");
return "/common/message";
}
}
132~144행 : 쿠키에 저장된 인증정보를 로그아웃 시에 삭제하고, DB에 세션 ID와 세션 만료기간을 고정값으로 갱신합니다.
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.Cookie;
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.beans.factory.annotation.Value;
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;
@Value("${spring.app.login-session-limit-millisecond}")
private Long sessionLimitMillisecond;
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.setAttribute("member", memberOp.get());
// If it checked UseAutoLogin, save SessionId in Cookie and Database. for Auto Login.
if (form.isUseAutoLogin()) {
log.info("set UseAutoLogin Cookie");
Cookie authCookie = new Cookie("authCookie", session.getId());
authCookie.setPath("/");
authCookie.setMaxAge(3000);
res.addCookie(authCookie);
memberOp.get().setSessionKey(session.getId());
Date sessionLimitTime = new Date(System.currentTimeMillis() + (long)((sessionLimitMillisecond == null) ? 600000L : sessionLimitMillisecond));
memberOp.get().setSessionLimitTime(sessionLimitTime);
} else {
memberOp.get().setSessionKey("unused");
memberOp.get().setSessionLimitTime(new Date(System.currentTimeMillis()));
}
// 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", "/");
}
public Member getMemberInfoBySessionKey(String sessionKey) {
log.info("getMemberInfoBySessionKey Start");
Optional<Member> result = memberRepository.findBySessionKey(sessionKey);
if (result.isEmpty()) {
log.info("sessionKey is inValid : {} ", sessionKey);
return null;
}
Date now = new Date(System.currentTimeMillis());
if (result.get().getSessionLimitTime().before(now)) {
log.info("sessionKey is expiration Limit Time : {} , Now : {}", result.get().getSessionLimitTime(), now);
return null;
}
log.info("Result Member Info {}", result.get().toString());
return result.get();
}
public void updateMember(Member member) {
memberRepository.save(member);
}
}
- 34~35행 : 인증정보의 기간 만료 기간을 설정 파일로부터 가져옵니다.
- 88~105행 : 자동로그인 사용 여부를 확인합니다.
자동로그인 사용의 경우 쿠키에 인증정보를 넣고, DB에 세션 ID와 세션 만료기간을 저장합니다.
자동로그인 미사용의 경우 쿠키에 값을 저장하지 않고, DB에 고정값의 세션 ID와 현재시간으로 세션 만료기간을 저장합니다.
4) 인터셉터 사용을 위한 설정: /template-springboot/src/main/java/com/sb/template/conf/WebConfig.java
package com.sb.template.conf;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import com.sb.template.interceptor.RememberMeInterceptor;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private RememberMeInterceptor rememberMeInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rememberMeInterceptor)
.order(1)
.addPathPatterns("/board/**")
.excludePathPatterns("/auth/logout", "/auth/join", "/common/**")
;
}
}
- 20행 : 사용할 인터셉터를 설정합니다.
- 22행 : 인터셉터를 적용할 URL패턴을 설정합니다.
- 23행 : 인터셉터를 적용하지 않을 URL패턴을 설정합니다.
5) 인터셉터 사용을 위한 설정: /template-springboot/src/main/java/com/sb/template/conf/WebConfig.java
package com.sb.template.interceptor;
import javax.servlet.http.Cookie;
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.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.WebUtils;
import com.sb.template.entity.Member;
import com.sb.template.service.MemberService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class RememberMeInterceptor implements HandlerInterceptor {
@Autowired
private MemberService memberService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
log.info("RememberMeInterceptor Start");
String requestedUri = request.getRequestURI().isBlank() ? "/" : request.getRequestURI();
HttpSession session = request.getSession(false);
log.info("requestedUri : {} ", requestedUri);
if (session == null || session.getAttribute("member") == null) {
log.info("SessionKey is Null");
Cookie authCookie = WebUtils.getCookie(request, "authCookie");
if (authCookie != null) {
String sessionKey = authCookie.getValue();
log.info("SessionKey from Cookie : {}", sessionKey);
Member member = memberService.getMemberInfoBySessionKey(sessionKey);
if (member == null) {
log.info("inValid Cookie");
authCookie.setPath("/");
authCookie.setMaxAge(0);
response.addCookie(authCookie);
response.sendRedirect(requestedUri);
return true;
}
session = request.getSession();
session.setAttribute("member", member);
log.info("Pre-SessionKey : {} , New-SessionKey {}", sessionKey, session.getId());
response.sendRedirect(requestedUri);
return true;
}
log.info("Cookie is Null");
}
return true;
}
}
- 44행 : 세션의 값을 확인합니다. 세션의 값이 Null이거나 저장된 인증정보가 없다면 쿠키 정보를 확인해서 자동 로그인 대상인지 확인을 하며, 세션에 저장된 인증정보가 있다면 로그인 중이므로 자동 로그인 인터셉터의 프로세스를 스킵합니다.
- 48~70행 : 쿠키의 값을 확인하여 쿠키에 저장된 인증정보가 DB의 정보가 일치한다면 세션에 인증정보를 넣어 로그인 상태로 만듭니다.
6. 동작확인
7. 전체 소스코드
https://github.com/leeyoungseung/template-springboot/tree/feature/11_membership_board_autologin
이것으로 쿠키를 활용한 자동 로그인 구현에 대한 포스트는 마치겠습니다.
다음 포스트는 "폼 태그'와 '드래그 앤 드롭'방식을 통한 파일 업로드에 대한 내용이 되겠습니다.
'프로그래밍 > Springboot-토이프로젝트' 카테고리의 다른 글
【게시판-12】프로필사진 등록('폼 태그'와 '드래그 앤 드롭'방식으로 파일 업로드) (0) | 2022.09.29 |
---|---|
【게시판-10】로그인 / 로그아웃 (1) | 2022.09.21 |
【게시판-09】회원가입 (1) | 2022.09.20 |
【게시판-08】타임리프 템플릿 결합과 부트스트랩 적용 (0) | 2022.09.17 |
【게시판-07】페이징 처리 (1) | 2022.09.16 |
댓글