[Spring Boot] 인증 구현1(로그인/로그아웃) - 쿠키
# 서론
HTTP는 무상태 프로토콜이다. 즉, 요청을 한번 처리하고 서버와 클라이언트는 연결이 끊긴다.
따라서 다음 요청을 보낼 때, 서버는 클라이언트가 누구인지 매번 확인해야 한다.
서버가 클라이언트가 누구인지, 어떻게 계속 확인할까? 이는 로그인의 기본 개념이다.
아이디어1 쿼리로 계속 사용자 정보를 넘겨준다.
이는 매우 비효율적일뿐더러, 보안이 취약하다.
(개발자가 모든 요청 로직에 사용자 정보를 URL에 담도록 개발을 해야 한다.)
아이디어2 쿠키 활용
한 번 로그인하면 서버에서 쿠키를 만들어서 클라이언트에게 내려준다.
쿠키 특성상 매 요청 시 HTTP 헤더에 포함돼서 보내지기 때문에, 서버 측에선 쿠키의 여부에 따라 사용자를 식별할 수 있을 것이다.
일단 아이디어1의 비효율적인 문제를 해결할 것 같으니, 구현해본다.
로그인 구현에 사용될 도메인과 리포지토리는 다음과 같다.
@Data
public class Member {
private Long id;
@NotEmpty
private String loginId;
@NotEmpty
private String name;
@NotEmpty
private String password;
}
@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(m -> m.getLoginId().equals(loginId))
.findFirst();
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
}
# 로그인 구현
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);
}
}
리포지토리에서 아이디를 가져와 비밀번호를 대조한다.
이제, 컨트롤러를 만든다.
@GetMapping("/login")
public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
return "login/loginForm";
}
먼저, 그냥 로그인 페이지를 조회했을 때는 로그인 폼을 보여준다.
@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
if (bindingResult.hasErrors()) {
return "login/loginForm";
}
Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
if (loginMember == null) {
bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
return "login/loginForm";
}
// 로그인 성공 처리
// 쿠키에 시간 정보를 주지 않으면 세션 쿠기(브라우저 종료시 모두 종료)
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
response.addCookie(idCookie);
return "redirect:/";
}
HTML FORM에 아이디와 비밀번호를 쳐서 로그인을 시도하면 해당 컨트롤러가 호출되게끔 만든다.
만약 아이디와 비밀번호를 잘못 입력한 경우 처리하고
서비스를 호출하여 해당 아이디와 비밀번호를 가진 멤버가 있는지 확인한다.
로그인에 성공하면 쿠키에 ID(사용자 ID 말고 private Long id)를 담아서 클라이언트에 전달한다.
# 로그아웃 구현
@PostMapping("/logout")
public String logout(HttpServletResponse response) {
expireCookie(response, "memberId");
return "redirect:/";
}
private void expireCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
response.addCookie(cookie);
}
로그아웃 선택 시 쿠키의 시간을 최대로 설정해서 날려버린다.
# 아무 문제 없는가?
상당히 효율적인 것 같지만, 여전히 보안이 취약하다.
쿠키 값을 예측 가능하게 설정했으므로 해커는 이를 활용하여 해당 사용자인 마냥 로그인을 시도할 수 있다.
(회원 시퀀스에 따라 id가 증가하는걸 발견하고는 특정 회원 ex.3번째 회원 으로 위장하여 로그인 할 수 있을 것이다.)
# 어떻게 해결하면 좋을까?
쿠키를 활용하므로써 효율성 문제는 해결했으니, 보안 문제를 해결할 방법을 모색한다.
쿠키 값을 예측 가능하기 때문에, 이를 개선해야 한다. -> 회원과 전혀 연관성 없는 임의의 값을 사용한다.
(Java에서 기본으로 제공하는 UUID를 활용한다.)
이제 쿠키에는 완전 임의의 랜덤 값이 들어간다.
서버는 랜덤 값이 어떤 사용자인지 알아낼 추가 로직이 필요하게 된다. 따라서 서버 측에선 랜덤 값 : 사용자로 1:1 매핑된 별도의 저장소를 운용한다. 그리고 이를 통해 검증한다. (이게 세션의 기본 개념이다.)
다음 글은 해당 부분들을 세션을 활용해서 보완해본다.