ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [김영한 스프링] 33. 로그인 처리1 쿠키, 세션 - 로그인 기능 & 쿠키 사용 & 보안 문제
    Spring/스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 2023. 9. 12. 05:03

    로그인 기능

     

     

     

    LoginService

    package hello.login.domain.login;
    
    import hello.login.domain.member.Member;
    import hello.login.domain.member.MemberRepository;
    import lombok.RequiredArgsConstructor;
    import org.springframework.stereotype.Service;
    
    import java.util.Optional;
    
    @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);
            
    //        Optional<Member> findMemberOptional = memberRepository.findByLoginId(loginId);
    //        Member member = findMemberOptional.get();
    //
    //        if (member.getPassword().equals(password)) {
    //            return member;
    //        } else {
    //            return null;
    //        }
        }
    }

    main/java/hello/login/domain/login/LoginService 생성

     

    로그인의 핵심 비즈니스 로직은 회원을 조회한 다음에 파라미터로 넘어온 password와 비교해서 같으면 회원을 반환하고, 만약 password가 다르면 null을 반환한다.

     

     

    LoginForm

    package hello.login.web.login;
    
    import lombok.Data;
    
    import javax.validation.constraints.NotEmpty;
    
    @Data
    public class LoginForm {
    
        @NotEmpty
        private String loginId;
    
        @NotEmpty
        private String password;
    }

    main/java/hello/login/web/login/LoginForm 생성

     

     

    LoginController

    main/java/hello/login/web/login/LoginController 생성

     

     

    package hello.login.web.login;
    
    import hello.login.domain.login.LoginService;
    import hello.login.domain.member.Member;
    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.validation.Valid;
    
    @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:/";
        }
    }

    로그인 컨트롤러는 로그인 서비스를 호출해서 로그인에 성공하면 홈 화면으로 이동하고, 로그인에 실패하면 bindingResult.reject()를 사용해서 글로벌 오류(ObjectError)를 생성한다. 그리고 정보를 다시 입력하도록 로그인 폼을 뷰 템플릿으로 사용한다.

     

     

    로그인 폼 뷰 템플릿 - loginForm.html

    <!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>

    main/resources/templates/login/loginForm.html 생성

     

    로그인 폼 뷰 템플릿에는 특별한 코드는 없다. loginId, password가 틀리면 글로벌 오류가 나타난다.

     

     

    실행

     

     

    로그인 처리하기 - 쿠키 사용

     

    로그인 상태 유지하기

    로그인의 상태를 어떻게 유지할 수 있을까?

    HTTP 강의에서 일부 설명했지만, 쿼리 파라미터를 계속 유지하면서 보내는 것은 매우 어렵고 번거로운 작업이다. 쿠키를 사용해 보자.

     

    쿠키

    서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하자. 그러면 브라우저는 앞으로 해당 쿠키를 지속해서 보내준다.

     

     

    쿠키 생성

     

     

    클라이언트 쿠키 전달1

     

     

    클라이언트 쿠키 전달2

     

     

    쿠키에는 영속 쿠키와 세션 쿠키가 있다.

    • 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
    • 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료 시 까지만 유지

     

    브라우저 종료 시 로그아웃이 되길 기대하므로, 우리에게 필요한 것은 세션 쿠키이다

     

     

    LoginController - login()

    package hello.login.web.login;
    
    import hello.login.domain.login.LoginService;
    import hello.login.domain.member.Member;
    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;
    import javax.validation.Valid;
    
    @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, HttpServletResponse httpServletResponse) {
            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()));
            httpServletResponse.addCookie(idCookie);
            return "redirect:/";
        }
    }

    쿠키 생성 로직

    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    response.addCookie(idCookie);

    로그인에 성공하면 쿠키를 생성하고 HttpServletResponse에 담는다. 쿠키 이름은 memberId이고, 값은 회원의 id를 담아둔다. 웹 브라우저는 종료 전까지 회원의 id를 서버에 계속 보내줄 것이다.

     

     

    실행

     

     

    홈 - 로그인 처리 - HomeController

    package hello.login.web;
    
    import hello.login.domain.member.Member;
    import hello.login.domain.member.MemberRepository;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.CookieValue;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Slf4j
    @Controller
    @RequiredArgsConstructor
    public class HomeController {
    
        private final MemberRepository memberRepository;
    
    //    @GetMapping("/")
        public String home() {
            return "home";
        }
    
        @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";
        }
    }

    • 기존 home()에 있는 @GetMapping("/")은 주석 처리하자.
    • @CookieValue를 사용하면 편리하게 쿠키를 조회할 수 있다.
    • 로그인하지 않은 사용자도 홈에 접근할 수 있기 때문에 required = false를 사용한다.

     

    로직 분석

    • 로그인 쿠키(memberId)가 없는 사용자는 기존 home으로 보낸다. 추가로 로그인 쿠키가 있어도 회원이 없으면 home으로 보낸다.
    • 로그인 쿠키(memberId)가 있는 사용자는 로그인 사용자 전용 홈 화면인 loginHome으로 보낸다. 추가로 홈 화면에 화원 관련 정보도 출력해야 해서 member 데이터도 모델에 담아서 전달한다.

     

     

    홈 - 로그인 사용자 전용 - /loginHome.html

    <!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" type="submit">
                    로그아웃
                </button>
                </form>
            </div>
        </div>
    
        <hr class="my-4">
    
    </div> <!-- /container -->
    
    </body>
    </html>

    main/resources/templates/loginHome.html 생성

     

    • th:text="|로그인: ${member.name}|" : 로그인에 성공한 사용자 이름을 출력한다.
    • 상품 관리, 로그아웃 버튼을 노출한다.

     

     

    실행

     

     

    로그아웃 기능

    • 세션 쿠키이므로 웹 브라우저 종료 시
    • 서버에서 해당 쿠키의 종료 날짜를 0으로 지정

     

     

    LoginController - logout 기능 추가

    package hello.login.web.login;
    
    import hello.login.domain.login.LoginService;
    import hello.login.domain.member.Member;
    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;
    import javax.validation.Valid;
    
    @Slf4j
    @Controller
    @RequiredArgsConstructor
    public class LoginController {
    
        private final LoginService loginService;
    
        @GetMapping("/login")
        public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
            return "login/loginForm";
        }
    
        @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);
        }
    
        @PostMapping("/login")
        public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse httpServletResponse) {
            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()));
            httpServletResponse.addCookie(idCookie);
            return "redirect:/";
        }
    }

     

     

    실행

     

     

    쿠키와 보안 문제

     

    쿠키를 사용해서 로그인Id를 전달해서 로그인을 유지할 수 있었다. 그런데 여기에는 심각한 보안 문제가 있다.

     

     

    test1, test2 회원가입

     

     

    • test1으로 로그인
    • 쿠키 2 -> 3으로 변경
    • 새로고침하면 test2로 변경됨

     

     

    보안 문제

    • 쿠키 값은 임의로 변경할 수 있다.
      • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.
      • 실제 웹브라우저 개발자모드 -> Application -> Cookie 변경으로 확인
      • Cookie: memberId=1 -> Cookie: memberId=2 (다른 사용자의 이름이 보임)
    • 쿠키에 보관된 정보는 훔쳐갈 수 있다.
      • 만약 쿠키에 개인정보나, 신용카드 정보가 있다면?
      • 이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
      • 쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다.
    • 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
      • 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.

     

    대안

    • 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.
    • 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다.
    • 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 
      유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다.

     

     

    출처 : https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2

     

    스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

    웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

    www.inflearn.com

Designed by Tistory.