본문 바로가기
Spring/스프링 MVC 활용

#6 로그인 처리 - 쿠키, 세션

by 히포파타마스 2021. 7. 30.

로그인 처리 - 쿠키, 세션

 

기존의 상품 관리 서비스에 로그인 기능을 추가한다.

 

· 홈 화면 - 로그인 전

- 회원 가입

- 로그인

 

[홈 화면 - 로그인 전]

 

· 홈 화면 - 로그인 후

- 본인 이름(~~님 환영합니다.)

- 상품 관리

- 로그 아웃

 

[홈 화면 - 로그인 후]

 

· 보안 요구사항

- 로그인 사용자만 상품에 접근하고, 관리할 수 있음

- 로그인 하지 않은 사용자가 상품관리에 접근하면 로그인 화면으로 이동

 

· 회원 가입

[회원 가입]

 

· 로그인

[로그인]

 

 

 

 

1. 로그인 폼, 기능 추가

■ 홈

홈 화면을 개발한다.

 

[HomeController]

@GetMapping("/") 
public String home() {
	return "home"; 
}

 

[홈 뷰 템플릿 - home]

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"> 
<head>
	<meta charset="utf-8">
	<link th:href="@{/css/bootstrap.min.css}" href="../css/bootstrap.min.css" 
		rel="stylesheet">
</head> 
<body>
	<div class="container" style="max-width: 600px">
		<div class="py-5 text-center">
			<h2>홈 화면</h2> 
		</div>

	<div class="row">
		<div class="col">
			<button class="w-100 btn btn-secondary btn-lg" type="button"
				th:onclick="|location.href='@{/members/add}'|"> 
			회원 가입
			</button> 
		</div>
		<div class="col">
			<button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'"
				th:onclick="|location.href='@{/login}'|" type="button"> 
			로그인
			</button> 
        </div>
	</div>

	<hr class="my-4">

</div> <!-- /container --> 

</body>
</html>

 

 

 

■ 회원 가입

[Member]

@Data
public class Member { 
	private Long id; 
    
	@NotEmpty
	private String loginId; //로그인 ID 
    
	@NotEmpty
	private String name; //사용자 이름 

	@NotEmpty
	private String password; 
}

회원 가입 폼 전용 객체를 만들지 않기 때문에 Member에 직접 검증 로직을 적용하였다.

 

[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(@Valid @ModelAttribute Member member, BindingResult result) {
		
		if (result.hasErrors()) {
			return "members/addMemberForm"; 
		}
        
        memberRepository.save(member);
		
        return "redirect:/"; 
	}
}

 

[회원 가입 뷰 템플릿 - addMemberForm]

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"> 
<head>
	<meta charset="utf-8">
	<link th:href="@{/css/bootstrap.min.css}" href="../css/bootstrap.min.css" 
		rel="stylesheet"> 
	<style>
	.container {
		max-width: 560px; 
	}
	.field-error { 
		border-color: #dc3545; 
		color: #dc3545;
	}
	</style> 
</head>
<body>

<div class="container">

	<div class="py-5 text-center">
		<h2>회원 가입</h2> 
	</div>
    
	<h4 class="mb-3">회원 정보 입력</h4>
    
	<form action="" th:action th:object="${member}" method="post">

		<div th:if="${#fields.hasGlobalErrors()}">
			<p class="field-error" th:each="err : ${#fields.globalErrors()}"
				th:text="${err}">전체 오류 메시지</p>
		</div> 
    
		<div>
			<label for="loginId">로그인 ID</label>
			<input type="text" id="loginId" th:field="*{loginId}" class="form-control"
				th:errorclass="field-error">
			<div class="field-error" th:errors="*{loginId}" />
		</div> 
	
 	   <div>
			<label for="password">비밀번호</label>
			<input type="password" id="password" th:field="*{password}" class="form-control"
				th:errorclass="field-error">
			<div class="field-error" th:errors="*{password}" /> 
		</div>
	
		<div>
			<label for="name">이름</label>
			<input type="text" id="name" th:field="*{name}" class="form-control"
				th:errorclass="field-error">
			<div class="field-error" th:errors="*{name}" /> 
		</div>

		<hr class="my-4">

		<div class="row">
			<div class="col">
				<button class="w-100 btn btn-primary btn-lg" type="submit">회원가입</button>
			</div>
			<div class="col">
				<button class="w-100 btn btn-secondary btn-lg" onclick="location.href=
                'items.html'" th:onclick="|location.href='@{/}'|" type="button">취소</button>
			</div> 
		</div>
	</form>
    
</div> <!-- /container --> 
</body>
</html>

 

 

 

■ 로그인

로그인 로직을 담당하는 LoginService 객체를 만든다.

 

[LoginService]

@Service
@RequiredArgsConstructor public class LoginService {

	private final MemberRepository memberRepository; 
	
    /**	
	* @return null이면 로그인 실패      
	*/
    
	public Member login(String loginId, String password) {
		return memberRepository.findByLoginId(loginId)
			.filter(m -> m.getPassword().equals(password)) 
			.orElse(null);
	} 
}

login 메서드는 아이디와 비밀번호를 받아 실제 회원인지를 판단해준다.

 

[LoginForm]

@Data
public class LoginForm {
	
	@NotEmpty
	private String loginId; 
    
	@NotEmpty
	private String password; 
}

로그인 폼에 사용될 객체를 만든다.

 

[LoginController]

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

	private final LoginService loginService; 
    
	@GetMapping("/login")
	public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
		return "login/loginForm"; 
	}

	@PostMapping("/login")
	public String login(@Valid @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:/";
	} 
}

로그인 컨트롤러는 LoginService를 호출해서 로그인에 성공하면 홈 화면으로 이동하고, 로그인에 실패하면 글로벌 오류를 생성한다.

오류가 발생하면 정보를 다시 입력하도록 로그인 폼으로 이동시킨다.

 

[로그인 뷰 템플릿 - loginForm]

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"> 
<head>
	<meta charset="utf-8">
	<link th:href="@{/css/bootstrap.min.css}" href="../css/bootstrap.min.css" rel="stylesheet"> 
	<style>
		.container {
			max-width: 560px; 
		}
		.field-error { 
		border-color: #dc3545; 
		color: #dc3545;
		}
	</style> 
</head>
<body>
<div class="container">

	<div class="py-5 text-center">
		<h2>로그인</h2>
	</div>

	<form action="item.html" th:action th:object="${loginForm}" method="post">
	
		<div th:if="${#fields.hasGlobalErrors()}">
			<p class="field-error" th:each="err : ${#fields.globalErrors()}"
				th:text="${err}">전체 오류 메시지</p>
		</div> 
    
		<div>
			<label for="loginId">로그인 ID</label>
			<input type="text" id="loginId" th:field="*{loginId}" class="form-control"
				th:errorclass="field-error">
		<div class="field-error" th:errors="*{loginId}" /> 
		</div>

		<div>
			<label for="password">비밀번호</label>
			<input type="password" id="password" th:field="*{password}" class="form-control"
				th:errorclass="field-error">
		<div class="field-error" th:errors="*{password}" /> 
		</div>

		<hr class="my-4">

		<div class="row">
			<div class="col">
				<button class="w-100 btn btn-primary btn-lg" type="submit">
				로그인
				</button>
			</div>
			<div class="col">
				<button class="w-100 btn btn-secondary btn-lg" onclick="location.href=
     	  		 	'items.html'" th:onclick="|location.href='@{/}'|" type="button">취소</button>
			</div> 
		</div>
        
	</form>
    
</div> <!-- /container --> 
</body>
</html>

아이디, 비밀번호가 일치하지 않으면 글로벌 오류가 나타난다.

 

 

 

 

2. 로그인 처리 - 쿠키, 세션 사용

로그인 상태일 때 홈 화면에 고객의 이름이 나타나야 한다.

이를 해결하기 위해서는 로그인 상태를 유지해야 할 필요가 있다.

즉, 사용자가 로그인을 했다면 그 이후부터 서버는 사용자가 로그인한 상태라는 것을 인지해야 한다.

 

로그인 상태를 유지하기 위해 사용자 측에서 모든 요청에 쿼리 파라미터를 계속 유지하면서 보내는 것은 매우 번거로운 작업이다.

 

 

 

■ 쿠키 사용 방식

따라서 쿼리 파라미터를 보내기보다는 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하는 방법이 있다.

이러면 브라우저는 앞으로 해당 쿠키를 지속해서 보내주기 때문에 서버는 로그인 상태를 편하게 유지할 수 있다.

 

[쿠키 생성]

멤버를 식별할 수 있는 데이터를 쿠키로 브라우저에 전달한다.

 

[클라이언트 쿠키 전달]

이후 모든 요청에 쿠키 정보가 자동으로 포함된다.

 

그러나 로그인 상태를 유지하기 위해서 단순히 쿠키만 사용하는 것은 다음과 같은 심각한 보안 문제가 있다.

 

· 쿠키 값은 임의로 변경할 수 있다.

- 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.

- 쿠키에 담긴 데이터가 규칙성이 있으면 쉽게 변경될 수 있다.

ex) Cookie: memberId = 1 → Cookie: memberId=2 로 변경해서 요청하면 다른 사용자의 정보를 볼 수 있다.

 

· 쿠키에 보관된 정보는 훔쳐갈 수 있다.

- 만약에 쿠키에 개인정보나, 신용카드 정보 같은 중요한 정보가 있다면 보안상 심각한 문제가 될 것이다.

- 쿠키는 로컬 pc에 저장되고 네트워크 요청마다 서버로 전달되기 때문에 로컬 pc에서, 네트워크 전송 구간에서 훔쳐질 수 있다.

 

· 다른 사람이 쿠키 정보를 알고 있으면 계속해서 사용 가능하다.

- 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.

 

 

 

■ 세션 사용 방식

앞의 쿠키 사용으로 발생하는 보안 문제에 대한 대안으로 다음과 같은 해결방법이 있다.

 

· 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다.

그리고 서버에서 토큰을 관리한다.

 

·  토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능해야 한다.

 

·  해커가 토큰을 훔쳐도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지한다. 또는 해킹이 의심되는 경우 서버에서 토큰을 강제로 제거한다.

 

이러한 해결방법을 사용하는 것이 세션이다.

 

세션 동작 방식은 다음과 같다.

 

[로그인 - 세션 사용]

사용자가 loginid, password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.

 

[세션 생성]

추정 불가능한 세션 ID를 생성한다.

생성된 세션 ID와 매칭 할 값(memberA)을 서버의 세션 저장소에 보관한다.

 

[쿠키 전달 - 세션 사용]

서버는 클라이언트에 쿠키 이름(mySessionId)과 세션 ID를 담아 전달한다.

세션 ID는 회원과 관련된 정보가 아닌 추정 불가능한 값이기 때문에 클라이언트는 회원 정보와 관련된 정보를 알 수 없다.

 

[로그인 이후 접근 - 세션]

클라이언트는 요청 시 항상 서버에서 전달받은 쿠키 이름(mySessionId)와 함께 쿠키를 전달한다.

서버에서는 클라이언트가 전달한 쿠키 정보로 세션 저장소를 조회해서 로그인 시 보관한 세션 정보를 사용한다.

 

 

 

■ 서블릿 HTTP 세션

서블릿은 세션을 위해 HttpSession 이라는 기능을 제공한다.

 

서블릿을 통해 HttpSession을 생성하면 WAS는 다음과 같은 쿠키를 생성한다.

 

Cookie: JSESSIONID=5B78E23B513F50164D6FDD8C97B0AD05

 

쿠키 이름은 JSESSIONID 이고 추정 불가능한 값이 생성된다.

 

HttpSession을 사용해 로그인 서비스를 개발해본다.

 

[SessionConst]

public class SessionConst {
	public static final String LOGIN_MEMBER = "loginMember"; 
}

HttpSession에 데이터를 보관하고 조회할 때, 같은 이름이 중복되어 사용되므로, 상수를 정의하였다.

 

[LoginController_login - HttpSession 사용]

@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
	HttpServletRequest request) {

	if (bindingResult.hasErrors()) {
		return "login/loginForm"; 
	}
    
	Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
	
    if (loginMember == null) {
		bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
		return "login/loginForm"; 
	}

	//로그인 성공 처리
	//세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성     
	HttpSession session = request.getSession();
	
    //세션에 로그인 회원 정보 보관
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
    return "redirect:/";
}

세션을 생성하려면 getSession(boolean create)를 사용하면 된다.

create가 true일 때 세션이 있으면 기존 세션을 반환하고 세션이 없으면 새로운 세션을 생성해서 반환한다.

create가 false일 때 세션이 있으면 기존 세션을 반환하지만 세션이 없으면 null을 반환한다.

 

세션에 회원 정보를 보관하려면 setAttribute()를 사용하면 된다.

세션의 이름과 회원 정보를 key & value 형식으로 보관한다.

하나의 세션에 여러 값을 저장할 수 있다.

 

[LoginController_logout - HttpSession 사용]

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {

	//세션을 삭제한다.
	HttpSession session = request.getSession(false);
    
	if (session != null) {         
		session.invalidate();
	}

	return "redirect:/"; 
}

invalidate() 메서드를 사용하면 세션을 제거할 수 있다.

 

[HomeController - HttpSession]

@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model) {	

	//세션이 없으면 home
	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"; 
}

getAttribute 메서드를 사용해서 세션 이름으로 회원 정보를 조회할 수 있다.

세션이 없으면 기존의 홈 화면으로, 세션이 있으면 로그인 전용 뷰로 이동한다.

 

[홈 템플릿 뷰 - loginHome]

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"> 
<head>
	<meta charset="utf-8">
	<link th:href="@{/css/bootstrap.min.css}" href="../css/bootstrap.min.css" rel="stylesheet">
</head> 
<body>

<div class="container" style="max-width: 600px">
	<div class="py-5 text-center">
		<h2>홈 화면</h2> 
	</div>

	<h4 class="mb-3" th:text="|로그인: ${member.name}|">로그인 사용자 이름</h4> 
	
    <hr class="my-4">
	
    <div class="row">
		<div class="col">
			<button class="w-100 btn btn-secondary btn-lg" type="button"
				th:onclick="|location.href='@{/items}'|"> 
			상품 관리
			</button> 
		</div>
		<div class="col">
		<form th:action="@{/logout}" method="post"> 
			<button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'"
				type="submit">
			로그아웃 
			</button> 
		</form>
		</div> 
	</div>
    
	<hr class="my-4">
</div> <!-- /container -->

</body>
</html>

로그인에 성공했을 때 보여주는 화면이다.

로그인 사용자 이름을 보여주며, 상품 관리, 로그아웃을 선택할 수 있다.

 

HttpSession으로 세션을 생성하면 WAS는 JSESSIONID 이라는 쿠키 이름과 추정 불가능한 세션 ID를 생성해서 쿠키로 사용한다.

이후 요청에 세션이 있을 경우 getSession은 JSESSIONID라는 쿠키를 읽어 세션 ID와 매칭 되는 세션을 반환한다.

서버는 반환된 세션에서 정해진 세션 이름(LOGIN_MEMBER)으로 회원 정보를 찾고 용도에 맞게 사용한다.

 

 

 

■ @SessionAttribute

스프링은 세션을 더 편리하게 사용할 수 있도록 @SessionAttribute를 지원한다.

 

이미 로그인된 사용자를 찾을 때는 다음과 같이 @SessionAttribute를 사용하면 된다. 이 기능은 세션을 생성하지 않는다.

 

@SessionAttribute(name = "loginMember", required = false) Member loginMember

 

[HomeController]

@GetMapping("/")
public String homeLoginV3Spring(
@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember,
	Model model) {

	//세션에 회원 데이터가 없으면 home 
	if (loginMember == null) {
		return "home"; 
	}

	//세션이 유지되면 로그인으로 이동
	model.addAttribute("member", loginMember);
	
	return "loginHome"; 
}

세션을 찾고, 세션에 들어있는 데이터를 찾는 번거로운 과정을 스프링이 한 번에 처리한다.

 

 

 

 

3. 세션 정보 & 설정

■ TrackingModes

로그인 처음 시도하면 URL이 다음과 같이 jsessionid를 포함하는 것을 확인할 수 있다.

 

http://localhost:8080/;jsessionid=F59911518B921DF62D09F0DF8F83F872

 

이는 웹브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하기 위함이다.

타임리프 같은 템플릿은 엔진을 통해서 링크를 걸면 jsessionid를 URL에 자동으로 포함해준다.

 

URL 전달 방식을 끄고 항상 쿠키를 통해서만 세션을 유지하고 싶으면 application.properties에 다음 옵션을 넣어주면 된다.

 

[세션 쿠키 설정]

server.servlet.session.tracking-modes=cookie

 

■ 세션 정보

HttpSession은 메서드를 통해 다양한 정보를 제공한다.

· sessionId : 세션 ID, JSESSIONID의 값

· maxInactiveInterval : 세션의 유효시간

· creationTime : 세션 생성 일시

· lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 세션 ID를 요청한 경우에 갱신.

· isNew : 새로 생성된 세션인지 판단

 

■ 세션 타임아웃 설정

HttpSession은 사용자가 서버에 최근에 요청한 시간을 기준으로 세션 만료 시간을 갱신한다.

 

타임아웃은 application.properties에서 설정할 수 있다.

[세션 타임아웃 설정]

 

server.servlet.session.timeout=60

글로벌 설정은 분단위로 설정해야 한다.

 

[특정 세션 단위로 시간 설정]

session.setMaxInactiveInterval(1800); //1800초

 

'Spring > 스프링 MVC 활용' 카테고리의 다른 글

#8 예외 처리와 오류 페이지  (0) 2021.08.02
#7 로그인 처리 - 필터, 인터셉터  (0) 2021.08.01
#5 Bean Validation  (0) 2021.07.28
#4 검증 - Validation  (0) 2021.07.28
#3 메시지, 국제화  (0) 2021.07.27

댓글