지난번까지 Spring을 MySQL과 연동하여 회원가입을 구현하였다. 이번에는 웹 사이트에서 가장 중요한 로그인 기능을 구현해보도록 하겠습니다.
로그인 기능을 사용하는 가장 기본적인 방법은 쿠키와 세션 2가지의 방법이 있습니다. 먼저 쿠키를 활용하여 로그인 하는 방법에 대해서 알아보고, 쿠키를 활용한 로그인 방식의 취약점도 알아보겠습니다.
1. 쿠키 Cookie
- 서버가 '나' 를 기억해서 로그인하려면?
기본적으로 HTTP 는 무상태(Stateless) 프로토콜이다. 이때 클라이언트와 서버가 요청과 응답을 주고 받으면 연결이 끊어지며 클라이언트가 다시 요청하면 서버는 이전 요청을 기억하지 못하고, 서로 상태를 유지하지도 않는다.
그렇다면 기본적인 http 만을 사용해서 서버가 'TerianP' 라는 클라이언트를 알고 응답할 수 있도록 하는 방법은 무엇일까? 바로 GET 방식으로 모든 요청과 링크에 사용자 정보를 포함해서 주는 것이다.
만약 쿠키와 세션이 없는 google.com 에 로그인하면서 'TerianP' 라는 것을 알리려면, 다음처럼 로그인 시 GET 방식으로 서버에 요청을 보내게 된다.
https://www.google.com/?userid=TerianP&&passwd=boanzoro
잠깐만 훑어봐도 엄청나게 보안상 문제가 많다는 것을 알 수 있다. 보안을 조금이라도 공부한 사람이라면 바로 발작을 일으킬 정도로 무서운 표현이다. 클라이언트의 로그인 정보가 URL에 그대로 들어나기 때문이다. 그 외에도 기타등등 엄청난 이슈들이 많다.
1) What is Cookie?
쿠키는 앞서 설명한 GET 방식으로 보내고 받는 방식에서 나오는 여러 문제를 해결하기 위해서 태어났다.
로그인 시 서버가 클라이언트의 정보를 기억하도록 하는 장치로써 쿠키를 사용하는 방식의 로그인 시 Set-Cookie 기능이 작동하여 서버에서 클라이언트한테 고유의 Cookie 값을 전달하고, 클라이언트는 서버에서 받은 쿠키를 저장하고 추후 HTTP 요청시 매번 서버로 이 값을 자동으로 던져준다. 이후 서버는 Cookie 값을 확인하여 클라이언트가 TerianP 임을 확인한다.
https://www.google.com/Cookie:user=TerianP<쿠키값>
2) 쿠키에 포함되는 내용
set-cookie: sessionID=abcd1234; expires=Thu, 13-Jan-2022 00:00:00 GMT; path=/; domain=.google.com; Secure
- 쿠키 생명주기(expires, max-age) : 대표적으로 세션 쿠키와 영속 쿠키가 있다. 세션 쿠키는 브라우저 종료시 까지만 유지되는 쿠키, 영속 쿠키는 지정된 날짜까지 유지되는 쿠키
- 쿠키 도메인(Domain) : 명시한 문서기준 도메인 + 서브 도메인 포함하며 전송됨. 만약 도메인 지정을 생략하며 딱 해당 도메인에서만 접근이 가능하며 서브 도메인에서는 접근 불가능하다.
- 쿠키 경로(Path) : 해당 경로를 포함한 하위 경로 페이만 쿠키 접근 가능하다. 일반적으로 path=/ , 즉 루트 페이지로 지정
- 쿠키 보안(Secure, HttpOnly, SameStie) : 쿠키는 http, https 쿠분하지 않고 전송한다. 또한 HttpOnly 를 걸어두면 XSS 공격을 방지할 수 있으며, 자바 스크립트에서 접근이 불가능하다. 단 HttpOnly는 HTTP 전송에서만 사용가능하다. 마지막으로 SameSite 설정의 경우 XSRF 공격을 방지할 수 있으며, 요청 도메인과 쿠키에 설정된 도메인이 일치하는 경우에만 쿠키를 전송하는 설정이다.
3) 쿠키의 사용처와 주의사항
- 사용처
- 사용자 로그인 세션 관리
- 광고 정보 트래킹
- 주의사항
- 쿠키 정보는 항상 서버에 전송된다. 즉 쿠키가 많아질수록 네트워크 트래픽이 추가로 발생한다.
- 쿠키에는 최소한의 정보만 사용해야한다. 절대 보안에 민감한 데이터 - 주민번호, 신용카드 번호 - 등은 포함하면 안된다.
- 서버에 전송하지 않고, 내부에서만 데이터를 저장하고 싶으면 웹 스토리지(localStorage, sessionStorage) 기능을 참고하면 좋다.
- 쿠키는 보안에 매우 취약하다. 대표적으로 XSS 공격을 통해 다른 클라이언트의 쿠키값을 가져와서 변조 후 내가 해당 유저가 되서 공격이 가능하다. 이는 admin 의 쿠키값을 가져올 수 있다면 내가 해당 사이트의 admin 이 되서 공격이 가능하다는 의미.
2. 쿠키를 사용해서 로그인 기능 구현하기
- 메인 페이지에서 로그인 버튼을 눌러서 로그인 하면 일반 회원의 경우 한 사람의 이름과 회원 수정, 로그아웃이 있는 페이지로 보냅니다.
- 만약 관리자가 로그인 한 경우 관리자 전용 페이지는 회원 관리 페이지와 로그아웃이 있는 페이지를 열어줍니다.
- 쿠키값은 Controller 를 통해서 로그인 시 생성되는데 쿠키값 안에는 DB 회원의 고유 번호(memberCode)를 저장해둔다. 즉 로그인 후 쿠키를 확인해보면 로그인 한 사람의 memberCode 를 알 수 있게 되는것이다.
- 추가, 수정된 클래스 내용들은 각 주석을 참고 부탁드립니다.
1) 실제 코드 확인
- build.gradle : lombok 사용을 위한 내용 추가
plugins {
id 'org.springframework.boot' version '2.6.1'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'HJproject'
version = '0.0.7-SNAPSHOT'
// JDK 버전 설정
sourceCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// lombok 추가
implementation 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok:1.18.22'
// H2 DB
// runtimeOnly 'com.h2database:h2'
// // MySQL DB
runtimeOnly 'mysql:mysql-connector-java'
// // JDBC
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
// // JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// // AOP
implementation 'org.springframework.boot:spring-boot-starter-aop'
// testImplementation('org.springframwork.boot:spring-boot-starter-test'){
// exclude group : 'org.junit.vintage', module: 'junit-vintage-engine'
// }
}
test {
useJUnitPlatform()
}
- loginForm : 로그인 기능 수행을 위한 도메인 역할을 하는 loginForm 작성
package HJproject.Hellospring.domain.login;
import com.sun.istack.NotNull;
import lombok.Data;
import lombok.RequiredArgsConstructor;
@Data
@RequiredArgsConstructor
public class LoginForm {
// notNull 어노테이션은 해당 값이 꼭 있어야함함
@NotNull
private String loginid;
@NotNull
private String loginpw;
}
- loginService : 로그인 기능을 수행할 Service 파일 생성
@RequiredArgsConstructor : 이 어노테이션이 붙어있으면 초기화되지 않은 final 필드나 @nonNull 이 붙은 필드에 대해서 생성자를 생성해줌 => 즉 알아서 DI를 해줌, 따로 Springconfig 에 Bean으로 등록할 필요도 없는듯?
package HJproject.Hellospring.service;
import HJproject.Hellospring.domain.member.Member;
import HJproject.Hellospring.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
public Member login(String loginid, String loginpw) {
// login Controller 을 통해서 넘어오는 loginid loginpw
System.out.println("내가 넣은 패스워드 :" + loginpw);
System.out.println("내가 넣은 아이디 :" + loginid);
// 여기서 findbyid 메서드를 사용하여 Optional 로 감싸진 member 객체에 해당 아이디 기준의 DB 정보를 저장함 => code, passwd , sex , email 등등
Optional<Member> findMemberOptional = memberRepository.findById(loginid);
Member member = findMemberOptional.get(); // Optional 로 감싸졌을때는 get 으로 꺼내올 수 있음
if (member.getPasswd().equals(loginpw)) { // 꺼내온 passwd 가 내가 입력한 패스워드 loginpw 와 같다면
// System.out.println("내가 가져온 패스워드 :" + member.getPasswd());
// System.out.println("내가 넣은 패스워드 :" + userpw);
// System.out.println(member.getCode());
// System.out.println(member.getRData());
return member; // member 객체 리턴
}else{
return null; // 아니면 null 리턴
}
}
}
- LoginController : 웹에서 로그인을 위해서 get 요청받고 post 요청 보낼 시 사용되는 Controller 클래스
package HJproject.Hellospring.Controller;
import HJproject.Hellospring.domain.member.Member;
import HJproject.Hellospring.domain.login.LoginForm;
import HJproject.Hellospring.service.LoginService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
@Slf4j
@Controller
@RequiredArgsConstructor // 클래스의 final 필드에 대한 생성자를 자동으로 생성
public class LoginController {
private final LoginService loginService;
@GetMapping("/login")
public String LoginForm(@ModelAttribute("LoginForm")LoginForm form){
return "members/login";
}
@PostMapping("/login")
/* HttpServletResponse : 아래의 쿠키값을 생성 후 클라이언트에게 보낼때 response 에 넣어서 보내야함 */
public String login(@ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse httpServletResponse) {
if (bindingResult.hasErrors()) {
return "members/login";
}
// loginForm 을 통해 값들을 가져옴
Member LoginMember = loginService.login(form.getLoginid(), form.getLoginpw());
if (LoginMember == null) { // login 메서드에서 던져주는 값이 null 이면 로그인 실패
// bindingResult.reject("login Fail", "아이디 또는 비밀번호가 맞지 않습니다");
System.out.println("로그인 실패");
return "members/login";
}
// null 이외의 값 즉 member 객체라면 로그인 성공 처리
// 쿠키에 시간 정보를 주지 않았기 때문에 세션 쿠키로 인식됨 -> 브라우저 종료시 모두 종료
Cookie CookieCode = new Cookie("memberCode", String.valueOf(LoginMember.getCode()));
httpServletResponse.addCookie(CookieCode);
System.out.println("쿠키 정보 전달 완료 : "+ CookieCode);
return "redirect:/";
}
}
- HomeController : 홈 화면 즉, 메인 화면에서 로그인 성공 여부, 로그인 후 쿠키 획득 여부에 따라서 페이지가 다르게 나타날 수 있도록 controll 하기위한 내용 추가
package HJproject.Hellospring.Controller;
import HJproject.Hellospring.domain.member.Member;
import HJproject.Hellospring.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;
@Controller
@RequiredArgsConstructor
public class HomeController {
private final MemberRepository memberRepository;
// @GetMapping("/") // 가장 기본 페이지 의미, 즉 루트 페이지 위치
// public String home(){
// return "newspringhome"; // springhome.html 을 찾아서 연다.
// }
@GetMapping("/")
// @CookieValue 를 통해서 쿠키값을 가져올 수 있다.
// required = true 의 경우 쿠키값이 없으면 접속 불가능, false 는 없어도 접근 가능
public String LoginHome(@CookieValue(name = "memberCode", required = false) Long memberCode, Model model) {
// 로그인 안했을 시
if(memberCode == null){
System.out.println("memberCode : "+memberCode);
return "newspringhome";
}
/* 로그인 시도 시 */
// loginMember 가 Optional 로 감싸져있기 때문에 .get() 으로 한번 가져와야 안에있는 값을 가져올 수 있음
Optional<Member> loginMember = memberRepository.findByCode(memberCode);
// 로그인 할 때 가져오는 Code 값이 null 인 경우
if(loginMember.get().getCode() == null){
System.out.println("getCode : "+ loginMember.get().getCode());
return "newspringhome";
}else if(loginMember.get().getCode() == 0){
model.addAttribute("member", loginMember.get());
System.out.println("관리자 로그인 성공");
return "newspringhome_admin";
}else {
model.addAttribute("member", loginMember.get());
System.out.println("로그인 성공");
return "newspringhome_login";
}
}
@PostMapping("/logout")
// 쿠키를 삭제하려면 쉽게 그냥 쿠키 생명주기, 시간을 0으로 만들어 버리면 됨
public String logout(HttpServletResponse httpServletResponse){
Cookie cookie = new Cookie("memberCode", null);
cookie.setMaxAge(0);
httpServletResponse.addCookie(cookie);
return "redirect:/";
}
}
- index.html : 총 3가지로 로그인 이전 페이지(로그인 버튼, 회원가입 버튼이 나오도록), 일반 회원용 로그인 페이지, 관리자(admin)용 로그인 페이지
- 해당 코드들은 포함하기에는 너무 커서 생략했습니다. 블로그에 있는 제 git 페이지에 들어가면 확인 하실 수 있습니다.
3. 실제 로그인 성공 확인 및 쿠키 확인하기
1) 기본 페이지
2) cookieTEST 로 회원가입하고 로그인 해보자
- 쿠키값 확인 방법 :
=> F12 를 눌러서 어플리케이션 - 쿠키 를 통해서 확인 할 수 있다.
=> F12 를 눌러서 네트워크 - 자신의 페이지를 눌러보면 아래 사진처럼 요청 헤더 / 응답 헤더를 확인 할 수 있따.
3) 관리자 전용 페이지
4) 쿠키값 변조 : 심각한 보안 문제
우리가 웹 상에서 쿠키 값을 확인할 수 있다. 그렇다면 쿠키값을 임의로 변경하면 어떻게 될까?
먼저 1 이라는 회원으로 로그인 후 쿠키값을 확인해봤다. 그리고 이제 이 쿠키값을 임의로 변경해보겠다.
아래 사진처럼 쿠키값을 임의로 수정하면 로그인한 아이디가 바뀌는 것을 볼 수 있다. 이는 엄청난 보안 취약점으로 작용한다. 즉 앞서 로그인해 본 admin 의 Code를 알 수 있다면 임의로 admin 으로 수정 후 admin 로그인해서 서버에 여러 공격이 가능하다는 것이다.
물론 이에 대한 대안도 존재한다. 예를 들어서 지금처럼 한번에 바로 보여지는 쿠키값을 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하도록해놓고 이 토큰을 서버에서 사용자 Code 와 매핑하여 인식하도록 하는 것이다. 이때 토큰은 서버에서 관리하도록 하며, 토큰에 만료 시간을 넣어서 쉽게 사용하지 못하도록 한다.
물론, 그냥 서버 세션을 적용해서 로그인하도록하면 이렇게 복잡하게 할 필요가 없다. 한번에 끝...!!
이 부분은 다음 글에 정리하도록 하겠다.
- 참고 자료
@RequiredArgsConstructor 어노테이션에 대한 설명
'Java - Spring &&n SpringBoot' 카테고리의 다른 글
spring - 로그인 기능 구현하기 (3) HTTP서블릿 세션 활용하기 (0) | 2022.01.29 |
---|---|
spring - 로그인 기능 구현하기 (2) 세션으로 로그인 하기 (0) | 2022.01.18 |
Spring - AOP 개념 잡기 (0) | 2021.12.27 |
Spring - DB 연동(2) : JPA, Spring Data JPA (0) | 2021.12.25 |
Spring - DB 연동(1) : H2 DB, 순수 JDBC, JdbcTemplate (0) | 2021.12.21 |
댓글