(로그인 전) 홈 화면



(로그인 후) 홈 화면


보안 요구사항
패키지 구조

도메인이 가장 중요하다
HomeController 수정
@Slf4j
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "home";
}
}
home.html 생성 및 실행

정상적으로 실행되는 것을 확인할 수 있다.
Member 클래스 생성
@Data
public class Member {
private Long id; //데이터베이스에 저장되는 id
@NotEmpty
private String loginId; //회원가입에 입력하는 로그인 ID
@NotEmpty
private String name; //사용자 이름
@NotEmpty
private String password; //비밀번호
}
MemberRepository 저장소 생성
@Slf4j
@Repository
public class MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
public Member save(Member member){
member.setId(sequence++);
log.info("save : member={}", member);
store.put(member.getId(),member);
return member;
}
public Member findById(long id){
return store.get(id);
}
public Optional<Member> findByLoginId(String loginId){
return findAll().stream().filter(member -> member.getLoginId().equals(loginId)).findFirst();
}
public List<Member> findAll(){
return new ArrayList<>(store.values());
}
public void clearStore(){
store.clear();
}
}
MemberController 생성
@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/add")
public String addForm(@ModelAttribute("member")Member member){
return "members/addMemberForm";
}
@PostMapping("/add")
public String save(@Validated @ModelAttribute Member member, BindingResult bindingResult){
if(bindingResult.hasErrors()){
return "members/addMemberForm";
}
memberRepository.save(member);
return "redirect:/";
}
}
addMemberForm.html 생성
홈 화면에서 회원 가입 클릭시 정상적으로 회원 가입 페이지로 이동한다.

정상적으로 회원 가입을 했을 때

회원 정보를 메모리 기반으로 저장하고 있기 때문에, 서버를 재시작 하면, 데이터가 사라진다. 앞으로 편의상 테스트 용 회원 데이터를 추가해보자
회원용 테스트 데이터 추가 - TestDataInit 클래스에 추가
@PostMapping("/add")
public String save(@Validated @ModelAttribute Member member, BindingResult bindingResult){
if(bindingResult.hasErrors()){
return "members/addMemberForm";
}
memberRepository.save(member);
return "redirect:/";
}
로그인 기능을 개발해보자. 로그인 ID, 비밀번호를 입력하는 부분에 집중해보자
LoginService 생성 - 비지니스 로직
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
public Member login(String loginId, String password){
return memberRepository.findByLoginId(loginId)
.filter(member -> member.getPassword().equals(password))
.orElse(null);
}
}
LoginForm 생성
@Data
public class LoginForm {
@NotEmpty
private String loginId;
@NotEmpty
private String password;
}
LoginController 생성
@Controller
@RequiredArgsConstructor
@Slf4j
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form){
return "login/loginForm";
}
@PostMapping("/login")
public String login(@Validated @ModelAttribute LoginForm form, BindingResult bindingResult){
if(bindingResult.hasErrors()){
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if(loginMember == null){
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
//성공 로직 TODO
return "redirect:/";
}
}
bindingResult.reject()를 사용해서 글로벌 오류(ObjectError)를 생성한다.@Validated @ModelAttribute LoginForm form: 폼 데이터를 LoginForm 객체에 바인딩하고 @Validated 어노테이션을 사용하여 유효성 검사를 수행한다.BindingResult bindingResult: 유효성 검사 오류를 캡처하고 보유한다.loginService.login 메서드를 사용하여 로그인을 시도하고, 폼에서 얻은 로그인 ID 및 비밀번호를 전달합니다.bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");: loginFail은 오류 코드, 아이디 또는 비밀번호가 맞지 않습니다.는 오류 메시지이다.loginForm.html 생성
실행 결과



실행해보면 로그인이 성공하면 홈으로 이동하고, 로그인에 실패하면 "아이디 또는 비밀번호가 맞지 않습니다."라는 경고와 함께 로그인 폼이 나타난다.
로그인의 상태를 유지하면서, 로그인에 성공한 사용자는 홈 화면에 접근시 고객의 이름을 보여주려면 어떻게 해야할까?
쿠키를 사용해서 로그인, 로그아웃 기능을 구현해보자
로그인 상태 유지하기
서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하면, 브라우저는 앞으로 해당 쿠키를 지속해서 보내준다.
쿠키 생성

클라이언트 쿠키 전달

쿠키에는 영속 쿠키과 세션쿠키가 있다
브라우저 종료시 로그아웃이 되기를 기대하므로, 우리에게 필요한 것은 세선쿠키 이다.
LoginController - login() 쿠키 생성
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
HttpServletResponse에 담는다memberId이고, 값은 회원의 id를 담아둔다. 웹 브라우저는 종료 전까지 회원의 id를 서버에 계속 보내줄 것이다. 
HomeController 수정
@GetMapping("/")
public String homeLogin(@CookieValue (name = "memberId", required = false) Long memberId, Model model){
if(memberId == null){
return "home";
}
Member loginMember = memberRepository.findById(memberId);
if(loginMember == null){
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
required = false를 사용한다. 로직 분석
loginHome으로 보낸다. 추가로 홈 화면에 회원 정보도 출력해야 하기 때문에, member데이터도 모델에 담아서 전달한다.loginHome.html 생성
실행 결과

로그아웃 기능
LoginController - logout 기능 추가
@PostMapping("/logout")
public String logout(HttpServletResponse response){
Cookie cookie = new Cookie("memberId", null);
cookie.setMaxAge(0);
response.addCookie(cookie);
return "redirect:/";
}
로그아웃도 응답 쿠키를 생성하는데,Max-Age=0를 확인할 수 있다. 해당 쿠키는 즉시 종료된다.

로그아웃시 홈 화면으로 리다이렉트 되고, Response Headers를 보면 Set-Cookie에 Max-Age=0 으로 들어간 것을 확인할 수 있다. (Application 탭에서 보면 쿠키 정보가 없는 것을 확인할 수 있다.)
쿠키만으로 로그인과 로그아웃을 처리하는 방식은 보안상에 큰 문제가 있다.
쿠키를 사용해서 로그인 Id를 전달해서 로그인을 유지할 수 있었지만, 심각한 보안 문제가 있다.
보안문제
대안
중요한 정보를 모두 서버에 저장해야 한다.
그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.
세션 동작 방식
로그인
사용자가 loginId, password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
세션 생성
세션 ID를 생성하는데 추정 불가능 해야 한다
UUID는 추정이 불가능하다
생성된 세션 ID와 세션에 보과할 값 (memberA)을 서버의 세션 저장소에보관한다.
세선 id를 응답 쿠키로 전달
클라이언트와 서버는 결국 쿠키로 연결되어야 한다.
mySessionId라는 이름으로 세션ID만 쿠키에 담아서 전달한다.mySessionId 쿠키를 보관한다.중요
클라이언트의 세션 id쿠키 전달

mySessionId 쿠키를 서버로 전달한다.mySessionId 쿠키 정보로 세션 저장소를 조회해서 로그인시 보관한 세션 정보를 사용한다.정리
세션을 직접 개발해서 적용해보자
세션 생성
세션 조회
세션 만료
먼저 세션을 관리해주는 세션 관리자를 만들고 위 3가지 기능(세션 생성, 세션 조회, 세션 만료)를 적용해보자
SessionManager 생성
@Component
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "mySessionId";
private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
//세션 생성
public void createSession(Object value, HttpServletResponse response){
//세션 id를 생성하고, 값을 세션에 저장
String sessionId = UUID.randomUUID().toString();
sessionStore.put(sessionId, value);
//쿠키 생성
Cookie mySessioncookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
response.addCookie(mySessioncookie);
}
//세션 조회
public Object getSession(HttpServletRequest request){
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if(sessionCookie == null){
return null;
}
return sessionStore.get(sessionCookie.getValue());
}
//세션 만료
public void expire(HttpServletRequest request){
Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
if(sessionCookie != null){
sessionStore.remove(sessionCookie.getValue());
}
}
public Cookie findCookie(HttpServletRequest request, String cookieName){
Cookie[] cookies = request.getCookies();
if(cookies == null){
return null;
}
return Arrays.stream(cookies)
.filter(cookie -> cookie.getName().equals(cookieName))
.findAny().orElse(null);
}
}
SesstionManagerTest생성
public class SessionManagerTest {
SessionManager sessionManager = new SessionManager();
@Test
void sessionTest(){
MockHttpServletResponse response = new MockHttpServletResponse();
//세션 생성
Member member = new Member();
sessionManager.createSession(member,response);
//요청에 응답 쿠키 저장
MockHttpServletRequest request = new MockHttpServletRequest();
request.setCookies(response.getCookies());
//세션 조회
Object result = sessionManager.getSession(request);
assertThat(result).isEqualTo(member);
//세션 만료
sessionManager.expire(request);
Object expired = sessionManager.getSession(request);
assertThat(expired).isNull();
}
}
LoginController - loginV2()
//세션 관리자를 통해 세션을 생성하고, 회원 데이터를 보관
sessionManager.createSession(loginMember, response);
sessionManager.createSession(loginMember,response) : 로그인 성공시 세션을 등록한다. 세션에 loginMember를 저장해두고, 쿠키도 함께 발행한다.Logincontroller - logoutV2()
@PostMapping("/logout")
public String logoutV2(HttpServletRequest request){
sessionManager.expire(request);
return "redirect:/";
}
HomeController - homeLogin
@GetMapping("/")
public String homeLogin(HttpServletRequest request, Model model) {
//세션관리자에 저장된 회원 정보 조회
Member member = (Member)sessionManager.getSession(request);
if (member == null) {
return "home";
}
model.addAttribute("member", member);
return "loginHome";
}
}
실행 결과

서블릿이 제공하는 HttpSession을 사용해보자
SessionConst 생성
public class SessionConst {
public static final String LOGIN_MEMBER = "loginMember";
}
HttpSession에 데이터를 보관하고 조회할 때, 같은 이름이 중복되어 사용하므로, 실수를 하나 정의했다. LoginController - loginV3
//세션이 있으면, 세션 반환, 없으면 신규 세션을 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);
세션 생성과 조회
세션을 생성하려면 request.getSession(true)를 사용하면 된다.
public HttpSession getSesstion(boolean create):
request.getSession(true) : 세션이 있으면 기존 세션 반환, 없으면 새로운 세션 생성해서 반환
request.getSession(false) : 세션이 있으면 기존 세션 반환, 세션이 없으면 새로운 세션을 생성하지 않고 null반환
request.getSession기본 값이 true이다.
세션에 로그인 회원 정보 보관
session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
request.setAttribute(...) 와 비슷하다.LoginController - logoutV3()
@PostMapping("/logout")
public String logoutV3(HttpServletRequest request){
HttpSession session = request.getSession(false);
if(session != null){
session.invalidate();
}
return "redirect:/";
}
request.getSesstion(false): 세션 제거가 목적이기 때문에, 새로운 세션을 만들지 않고 기존의 세션만 불러온다session.invalidate() : 세션을 제거한다.HomeController - homeLoginV3()
@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {
HttpSession session = request.getSession(false);
if(session == null){
return "home";
}
Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);
//세션에 회원 데이터가 없으면 home
if (loginMember == null) {
return "home";
}
//세션이 유지되면 로그인으로 이동
model.addAttribute("member", loginMember);
return "loginHome";
}
Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);loginMember에 저장하기 위해 캐스팅을 한다. 실행결과

참고
request.getSession(false) : request.getSession()를 사용하면 기본값이 true이기 때문에, 로그인 하지 않을 사용자에게도 의미없는 세션이 만들어진다.session.getAttribute(SessionConst.LOGIN_MEMBER) : 로그인 시점에 세션에 보관한 회원 객체를 찾는다. 스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute를 지원한다.
@SessionAttribute(name = "loginMember", required = false) Member loginMemberHomeController - homeLoginV3Spring()
@GetMapping("/")
public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model){
if(loginMember == null){
return "home";
}
model.addAttribute("member", loginMember);
return "loginHome";
}
TrackingModes
로그인을 완전히 처음 시도하면, URL에 jsessionId를 포함하고 있는 것을 확인할 수 있다.
application.propertiesserver.servlet.session.tracking-modes=cookie세션이 제공하는 정보들을 확인해보자
SessionInfoController 생성
@Slf4j
@RestController
public class SessionInfoController {
@GetMapping("/session-info")
public String sessionInfo(HttpServletRequest request){
HttpSession session = request.getSession(false);
if(session ==null){
return "세션이 없습니다.";
}
//세션 데이터 출력
session.getAttributeNames().asIterator()
.forEachRemaining(name->log.info("session name = {}, value = {}",name, session.getAttribute(name)));
log.info("sessionId={}",session.getId());
log.info("getMaxInactiveInterval={}",session.getMaxInactiveInterval());
log.info("getCreationTime={}",new Date(session.getCreationTime()));
log.info("lastAccessedTime={}",new Date(session.getLastAccessedTime()));
log.info("isNew={}", session.isNew());
return "세션 출력";
}
}
세션 출력 결과 
JSESSIONID의 값이다. sessionId를 요청한 경우 갱신세션 타임아웃 설정
세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()가 호출되는 경우에 삭제된다.그런데, 대부분의 사용자는 로그아웃을 선택하지 그냥 웹 브라우저를 종료한다.
문제는 HTTP가 비연결성이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지를 인식할 수 없다. 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기 어렵다.
이 경우 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.
세션 종료 시점
사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것이다. 이렇게 하면 사용자가 서비스를 사용하고 있으면, 세션의 생존 시간이 30분으로 계속 늘어나게 된다.
세션 타임아웃 설정
server.servlet.session.timeout=1800session.setMaxInactioveInterval(1800);세션 타임아웃 발생
세션의 타임아웃 시간은 해당 세션과 관련된 JSESSIONID 를 전달하는 HTTP 요청이 있으면 현재 시간으로 다시 초기화 된다. 이렇게 초기화 되면 세션 타임아웃으로 설정한 시간동안 세션을 추가로 사용할 수 있다.
session.getLastAccessedTime() : 최근 세션 접근 시간
LastAccessedTime 이후로 timeout 시간이 지나면, WAS가 내부에서 해당 세션을 제거한다.