WEB/Spring MVC 2

Spring MVC 2편 로그인 처리1 쿠키 세션

Tony Lim 2021. 10. 10. 15:18

 새로운 패키지 구조를 살펴보면 

향후 web 을 다른 기술을 바꾸어도 도메인은 그대로 유지할 수 있어야한다.

domain 은 web을 의존하지 않지만 web은 도메인을 의존해도 된다.

 

Member Domain 과 Member Repository를 만들어준다.


@Data
public class Member
{
    private Long id;

    @NotEmpty
    private String loginId; // 로그인 ID
    @NotEmpty
    private String name;
    @NotEmpty
    private String password;
}

@Slf4j
@Repository
public class MemberRepository
{
    private static Map<Long,Member> store = new HashMap<>(); //static 사용
    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)
    {
        Optional<Member> first = findAll().stream().filter(m -> m.getLoginId().equals(loginId)).findFirst();
        return first;
    }

    public List<Member> findAll()
    {
        return new ArrayList<>(store.values());
    }
        public void clearStore()
    {
        store.clear();
    }

}

 

MemberController


import javax.validation.Valid;

@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 bindingResult)
    {
        if (bindingResult.hasErrors())
        {
            return "members/addMemberForm";
        }

        memberRepository.save(member);
        return "redirect:/";
    }
}

앞 장에서 배웠던 Valid를 그대로 응용할 수 있다. @ModelAttribute 에서 굳이 인자로 member를 넣어준이유는 이렇게 하지 않으면 타임리프에서 ide에서 잘 인식을 못하기 때문이다.

 

로그인form 과 로그인 서비스를 만들어야한다.

@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";
        }

        return "redirect:/";
    }
}

bindResult.reject 하면 글로벌오류를 표현할 수 있다. rejectValue 는 FiledError 를 위한것이고 Object error(글로벌 오류) 를 넣을 려면 reject를 써야한다.
글로벌오류는 loginForm의 여러 필드들을 조합하거나 다른 비즈니스적인 의미를 나타낸다.

rejectValue는 이미 @Valid 에서 스프링에서 등록한 Validator로 검증할때 쓰일 것이다.

@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);
    }
}

현재 로그인을 처리해주는 로직이다.

 


Cookie 를 이용해서 로그인 처리를 해보자

영속 쿠키 = 만료 날짜를 입력하면 해당 날짜까지 유지

세션쿠키 = 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

        //쿠키에 시간 정보를 주지 않으면 세션 쿠키(브라우저 종료시 모두 종료)
        Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getLoginId()));
        response.addCookie(idCookie);

Controller(서버 쪽에서)단에서 로그인 성공시 쿠키를 하나 추가해준다.
memeberId=1  로 크롬  개발자 툴에서 Application, Network layer에서 확인할 수 있다.

    @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);
    }

로그아웃 로직을 추가하여 쿠키를 만료시킨후 홈페이지로 되돌아오게 하였다.

 

하지만 이 방식은 보안상의 큰 문제가 있다.

1. 쿠키의 값은 임의로 변경할 수 있다. 개발자 툴에서 그냥 변경 가능

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

3. 해커가 쿠키를 한번 훔쳐가면 평생 사용이 가능하다.

클라이언트단에서 보관되는 정보들은 다 문제가 생길 수 있다.

결국 중요한 것들은 서버에 저장해야하고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.

 

쿠키를 똑같이 만들어서 전달하되 이번에는 서버에서 생성한 랜덤 uuid를 value로 전달해준다. 

회원과 관련된 정보는 전혀 클라이언트에게 전달하지 않는다는 것과 추정이 불가능한 Session ID만 전달한다.

털려도 문제없고 , 해킹 당했다 싶으면 그냥 세션 저장소에서 지워버리면 된다.

 

 

@Component
public class SessionManager
{
    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String,Object> sessionStore = new ConcurrentHashMap<>();

    /**
     * 세션을 생성
     * sessionId 생성 (임의의 추정 불가능한 랜덤 값)
     * 세션 저장소에 sessionId와 보관할 값 저장
     * sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
     */
    public void createSession(Object value, HttpServletResponse response)
    {
        //create session id and store it in sessionStore
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId,value);

        // make cookie
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);

    }

    // get session
    public Object getSession(HttpServletRequest request)
    {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if(sessionCookie == null)
        {
            return null;
        }
        return sessionStore.get(sessionCookie.getValue());

    }

    /**
     * 세션 만료
     * @return
     */
    public void expire(HttpServletRequest request)
    {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if(sessionCookie != null)
        {
            sessionStore.remove(sessionCookie.getValue());
        }
    }



    public Cookie findCookie(HttpServletRequest request, String cookieName)
    {
        if (request.getCookies() == null)
        {
            return null;
        }

        return Arrays.stream(request.getCookies())
                .filter(c -> c.getName().equals(cookieName))
                .findAny()
                .orElse(null);

    }


}

 

 @Test
    @DisplayName("세션매니저 기능 확인")
    void sessionTest()
    {
        //create session
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member,response);

        //check whether request has response cookie, assume this is web browser's request
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies()); // mySessionId=skfjlskjldjs-1223sdlkjlf

        //lookup session
        Object result = sessionManager.getSession(request);
        assertThat(result).isEqualTo(member);

        //session expire
        sessionManager.expire(request);
        Object expired = sessionManager.getSession(request);
        assertThat(expired).isNull();
    }

Session manager의 동작기능을 체크하는 테스트 코드이다.

이것을 기존의 homecontroller , login controller에 주입시켜서 쿠키를 직접만들지 않고 세션매니저를 통해서 만들고 저장하자

 


HttpSession

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

쿠키 이름은 JSESSIONID 이고 추정 불가능한 랜덤 값을 준다.

        //if session exist just return existing session , else return create new session
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);

        //create session with session manager, add memeber data
//        sessionManager.createSession(loginMember,response);

getSession(true)가 default이고 false로 하게되면 기존 세션이 있으면 반환하고, 없으면 새로 만들지 않고 null을 반환한다.

처음 login request를 날리면 세션이 하나 생성되고 이 세션에 맞는 JSESSIONID를 생성하여 쿠키에 넣어주어 클라이언트에게 전달해준다. 
또한 서버에서 login 정보를 통해 생성한 member 객체와 기존에 가지고 있던 key를 통하여 생성된 세션객체의 저장소(MAP)에 저장을 한다.

request가 다시 날라오면 cookie에서 JSESSIONID를 통해 일치하는 세션을 찾고 서버에서 가지고 있는 key를 통해 해당 member 객체를 찾는다.

 

 

    @GetMapping("/")
    public String homeLoginV3Spring(@SessionAttribute(name=SessionConst.LOGIN_MEMBER,required = false) Member loginMember, Model model)
    {
        if (loginMember == null)
        {
            return "home";
        }

        model.addAttribute("member",loginMember);
        return "loginHome";
    }

Spring annotation 을 통해 기존의 세션가져오고 체크 하는 로직을 생략시킬 수 있다.

이 기능은 세션을 생성하지 않는다.  가져오는 로직을 생략할 뿐이다.

처음 로그인을 시도하면 url 에 jsessionid를 써준다. 브라우저가 쿠키를 지원안할 수 도 있으니 매번 링크url에 이 jsessionid를 붙여줘야한다.

application.properties에 server.servlet.session.tracking-mode=cooike 를 넣어주면 url에 나타니지 않는다.

 

세션 타임아웃 설정

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate() 가 호출 되는 경우에 삭제 한다. 하지만 대부분 사용자는 로그아웃 안하고 웹 브라우저를 그냥 꺼버린다.

하지만 HTTP 는 ConnectionLess 이므로 서버는 웹 브라우저가 닫혔는지 알 수 없다.

사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도 유지해준다. session 에는 여러 필드가 존재하여 이러한 값들을 조회할 수 있다.

sever.servlet.session.timeout=1800 으로 글로벌하게 설정이 가능하다. session.setMaxInactiveInterval 로 설정할 수 있다