본문 바로가기
Back-End/🌱 Spring Boot (java)

[Spring] HTTP 세션의 개념과 로그인 구현

by 코딩하는 동현😎 2023. 5. 6.

세션이라는것은 쿠키 기반이기 때문에 쿠키에 대한 이해가 필요하므로 이전 포스트를 보고 오시면 좋습니다.

 

자바 스프링에서 HttpSession을 이미 지원하기 때문에, 이것을 이용해 간단한 세션 로그인을 구현해보겠습니다.

세션을 직접적으로 구현하는 것은 만약에 요청이 들어오면 나중에 포스트하겠습니다!

HttpSession이 알아서 세션을 구현해줄테지만, 그래도 내부 원리를 알고 써야되기 때문에 (redis등을 적용하려면) 원리를 설명하고 적용하는 것을 보여드리겠습니다.


이전 포스트에서 오로지 쿠키만을 이용했을때 생기는 보안문제에 대해서 보고, 그의 대안책으로 세션이 나온것이라고 배웠습니다.

  1. 쿠키 값은 임의로 변경할 수 있고, 보관된 정보는 훔쳐갈 수 있습니다.
  2. 만약 쿠키에 개인정보나, 신용카드 정보가 있다면, 쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있습니다.
  3. 게다가 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있어서 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있습니다.

여기서 세션을 이용하면, 쿠키에 중요한 값말고 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 서버에서 토큰과 사용자 id를 매핑해서 인식하면 됩니다.
그리고 서버에서 토큰을 관리하고 발급하고, 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 됩니다

다.
 

세션이란?

세션이라는 것은 백엔드 쪽에서 세션 저장소를 만들어서 session id와 값을 매핑해서 저장해서, session id값만 클라이언트와 통신하고, 실제 정보가 담긴 값은 세션저장소에서만 조회를 해서 운용하는 것입니다.

 

동작 방식은 아래와 같습니다.

 

 

 

1. 클라이언트에서 id가 A인 사용자가 로그인하면, 로그인 처리를 하면서 DB에서 회원정보를 가져오게 됩니다.

 

 

 

2. 세션 저장소에서 랜덤한 토큰값을 sessionId에 저장하고, 회원정보를 매핑합니다. 여기서 회원정보는 숨겨야되기 때문에 백엔드에서만 매핑해서 조회하고, 클라이언트에는 의미없는 토큰 값인 session Id만 노출 할 것입니다.

 

 

 

3. 의미없는 랜덤 토큰 값인 sessionId를 쿠키를 통해 클라이언트로 보내고, 앞으로 로그인할때 sessionId가 담긴 쿠키를 이용해서 로그인 하도록 합니다.

 

 

 

4. 이후로 로그인한 상태로 콘텐츠를 접근하면 sessionId가 담긴 쿠키를 보내게 되고, 서버는 그 토큰을 이용해서 세션저장소에서 실제 회원 정보를 조회해서 서비스를 구현합니다.

 

세션 저장소등은 서버의 redis등 메시징 DB를 이용하는 경우도 있고 자바에서 직접 구현해볼수도 있지만, 자바 스프링에 이미 탑재된 HttpSession을 이용해서 구현을 해보겠습니다.


Spring Boot(java)로 로그인 실습

jsessionId가 뜨지 않도록 하고, 쿠키만 이용하도록 설정하기

application.properties

server.servlet.session.tracking-modes=cookie

 

라이브러리 예시 (build.gradle)

페이지는 thyemeleaf로 렌더링했습니다.

lombok을 이용해 getter, setter등을 관리하고 로그를 띄우도록 했습니다.

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	// implementation 'org.springframework.boot:spring-boot-devtools'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

회원 엔티티와 로그인DTO

Member.java

package jpabook.jpastore.domain;
import javax.validation.constraints.NotEmpty;

import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Member {
    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @NotEmpty
    private String name;
    @NotEmpty
    private String loginId;
    @NotEmpty
    private String password;
}

 
LoginForm.java

package jpabook.jpastore.controller;

import javax.validation.constraints.NotEmpty;

import lombok.Data;

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

Template 페이지 (resources/templates에 저장)

홈 화면

home.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">

<head>
    <meta charset="utf-8">git
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
</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>

로그인 했을때 나오는 홈 화면

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>

로그인 화면

login/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="formcontrol" 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>

LoginService

사용자의 id와 password를 받았을때 실질적인 검증 기능을 작성합니다

package jpabook.jpastore.service;

import org.springframework.stereotype.Service;

import jpabook.jpastore.domain.Member;
import jpabook.jpastore.repository.MemberRepository;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class LoginService {
    private final MemberRepository memberRepository;

    public Member login(String loginId, String password){
        return memberRepository.findByLoginId(loginId).stream().filter(m -> m.getPassword().equals(password)).findAny().orElse(null);
    }
}

HttpSession 구현

 

LoginController

로그인 요청을 받았을때 세션을 생성해서 클라이언트로 넘겨주고, 로그아웃 처리할때 세션 저장소에 매핑된 값을 삭제합니다.

 

세션을 생성하고 정보를 저장하는 로직

HttpSession session = httpServletRequest.getSession();
session.setAttribute("memberId", member.getId());

 

package jpabook.jpastore.controller;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;

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 org.springframework.web.bind.annotation.RequestParam;

import jpabook.jpastore.domain.Member;
import jpabook.jpastore.service.LoginService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Controller
@RequiredArgsConstructor
@Slf4j
public class LoginController {
    private final LoginService loginService;
    private final SessionManager sessionManager;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") LoginForm loginForm) {
        return "login/loginForm";
    }

    @PostMapping("/login")
    public String loginV3(
            @Valid @ModelAttribute("loginForm") LoginForm loginForm,
            BindingResult bindingResult,
            HttpServletRequest request,
            HttpServletResponse response)
            {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        } // 폼 부족하게 입력

        Member member = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
        log.info("login? {}", member);

        if (member == null) {
            bindingResult.reject("loginFail", "id or password incorrect");
            return "login/loginForm";
        }

        // 로그인 성공 처리

        // 여기 부터 변경됩니다.
        /////////////////////////////////////////////////////
        HttpSession session = request.getSession();
        session.setAttribute("memberId", member.getId());
        return "redirect:/";
    }
    
    @PostMapping("/logout")
    public String logoutV3(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
        session.invalidate();
        }
        return "redirect:/";
    }
}

 

 

 

HomeController

루트 경로로 즉 홈페이지 접근 요청을 받는 파일입니다.
사용자 로그인 여부에 따라서 처음보는 사용자는 home.html로 보내고, 로그인해서 세션이 있는 사용자는 loginHome.html로 보내서 세션저장소에서 조회한 회원 이름을 조회하도록 했습니다.

package jpabook.jpastore.controller;

import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.SessionAttribute;

import jpabook.jpastore.domain.Member;
import jpabook.jpastore.service.MemberService;

@Controller
public class HomeController {
    @Autowired private MemberService memberService;
    @Autowired private SessionManager sessionManager;
    @GetMapping("/")
    public String homeLoginV3(@SessionAttribute(name = "memberId", required = false) Long memberId,
                                Model model)
        {

        if (memberId == null) {
        return "home";
        }

        Member member = memberService.findOne(memberId);
        if (member == null) {
            return "home";
        }

        // 로그인 완료
        model.addAttribute("member" , member);
        return "loginHome";
    }
}
반응형

댓글