반응형

로그인 previous

※ 웹/앱 서비스에서 로그인 기능은 필수 기능

로그인 기능에는 고려할 부분이 많음

 - 아이디와 비밀번호가 맞는지 인증 기능

 - 서버에서 로그인을 처리하는 로직의 위치 파악

 - 로그인 후 로그인 페이지 리다이렉트 이전 페이지로 다시 복귀

 - 한 번 로그인 한 뒤에는 로그인 상태가 유지

 

※로그인 상태를 유지하는 방법
로그인 상태는 쿠키 혹은 세션으로 관리를 하며 여기에 해당 키의 유효시간 관리를 통해 일정시간만 유지되도록 할 수 있음

 

※ 스프링에서는 스프링 시큐리티라는 프레임워크로 로그인, 계층화, 리멤버미까지 다양한 기능을 제공하지만, 결국 이러한 스프링 시큐리티도 쿠키, 세션을 통해 관리하는 것이고 여러 리졸버를 이용함.
쿠키와 세션을 통해 로그인을 처리하는 과정과 이를 처리하기 위해 필터와 인터셉터를 학습. 

 

쿠키를 사용한 로그인 처리

1. 쿠키 동작 방식

• 서버에서 로그인 성공 시 사용자 정보를 쿠키에 담아 브라우저로 전달하면 브라우저는 해당 쿠키를 저장해둠  
• 해당 사이트에 접속할 때마다 지속해서 해당하는 쿠키를 사용

※ request의 헤더에 cookie가 담겨있어 모든 request에 쿠키 정보가 자동으로 포함됨

 

2. 쿠키의 종류

※ 사용자는 상황에따라 쿠키의 생명주기를 설정해 사용할 수 있음
 - 영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지
 - 세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

 

3. 서버에서 쿠키 생성하기 - version 1

※ java.servlet.http에는 Cookie라는 클래스를 제공해주는데 이 클래스를 이용해 클라이언트에 응답 할 쿠키정보를 쉽게 핸들링할 수 있음

3.1 로그인 수행 controller 쿠키 생성 하기 예제

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

• new Cookie("memberId", String.valueOf(loginMember.getId()));
 - Cookie 라는 클래스 생성자로 key / value를 인자로 넘겨주어 생성
• response.addCookie(idCookie); 
 - 생성된 쿠키(idCookie)를 서버 응답 객체(HttpServletResponse)에 addCookie를 이용해 쿠키에 담아줌

 - 웹 브라우저에서는 Set-Cookie 프로퍼티에 쿠키정보가 담겨져 반환됨

 

 

3.2 루트 페이지 controller 쿠키 조회 하기 예제

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

• @CookieValue(name = "memberId", required = false) Long memberId
 - 쿠키를 편하게 조회할 수 있도록 도와주는 애노테이션

 - 전송된 쿠키정보중 key가 memberId인 쿠키값을 찾아 memberId 변수에 할당해줌
 - required가 false이기에 쿠키정보가 없는 비회원도 해당페이지에 접근 가능

 

 

4. 서버에서 쿠키 삭제하기(로그 아웃)

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
    expiredCookie(response, "memberId");
    return "redirect:/";
}

private void expiredCookie(HttpServletResponse response, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    response.addCookie(cookie);
}

• 로그아웃 기능은 쿠키를 삭제하는게 아니라 종료 날짜를 0으로 줘서 바로 만료시킴으로써 삭제할 수 있음

 - 응답 쿠키의 정보를 보면 Max-Age=0으로 되어있어 해당 쿠키는 즉시 종료된다.

 

 

5. version 1의 보안 문제

• 쿠키 값을 임의대로 변경할 수 있음 

 - 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 됨
 - 실제 웹브라우저 개발자모드 → Application → Cookie: memberId=1을 memberId=2로 변경(다른 사용자의 이름이 보임)
• 쿠키에 보관된 정보(memberId) 를 타인이 훔쳐갈 수 있음
• 한 번 도용된 쿠키정보는 계속 악용될 수 있음

 

6. 대안

• 쿠키에 중요한 값을 바로 노출하지 않고 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)으로 대체하고, 서버에서
토큰과 사용자 id를 매핑해서 보관

ex) 브라우저에서 cookiename : randomkey, 서버에서 randomkey : 사용자 id 

• 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예측 불가능하게 생성

• 해커가 토큰을  시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분)유지

• 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 됨.

 

 

세션을 사용한 로그인 처리

1. 세션 동작 방식

• 중요 정보는 서버의 세션 저장소에 key/value로 저장한 뒤 브라우저에서는 key값만 쿠키로 저장 해둠.

쿠키에 저장하고 있는 sessionId를 전달하면 서버의 세션저장소에서는 해당sessionId를 key로 가지고 있는 value값을 조회해서 로그인 여부와 중요 정보를 확인함

• 개선된 점

 - 회원과 관련된 정보는 클라이언트에서 가지고 있지 않음
 - 추정 불가능한 세션 아이디만 쿠키를 통해 주고받기에 보안에서 많이 안전해짐

 추가 개선할점

 - 세션아이디가 저장된 쿠키의 만료시간을 짧게 유지한다면, 해커가 해당 키를 도용한다 하더라도 금새 갱신되며 사용하지 못하게 되어 보안적으로 좀 더 안전해질 수 있음

 

2. 세션 관리 기능

※ 직접 세션을 만들기 위해서는 다음과 같이 크게 3가지 기능을 제공해야 함
 세션 생성
 - 세션 키는 중복이 안되며 추정 불가능한 랜덤 값이어야 함
 - 세션 키에 매칭될 값(사용자 정보)가 있어야 함
 - 이렇게 생성된 세션 키를 응답 쿠키에 저장해 클라이언트에 전달
 세션 조회
 - 클라이언트가 요청한 세션아이디 쿠키 값으로 세션 저장소에 저장된 값을 조회할 수 있어야 함
 세션 만료
 - 클라이언트가 요청한 세션아이디 쿠키 값으로 세션 저장소에 보관한 세션 엔트리를 제거해야 함

 

 

3. 직접 세션 구현

3.1 세션 관리 클래스 소스

package hello.login.web.session;

import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Component;

@Component
public class SessionManager {
	public static final String SESSION_COOKIE_NAME = "mySessionId";
	// 동시에 여러 스레드 접근 시 ConcurrentHashMap을 사용
	private Map<String, Object> sessionStore = new ConcurrentHashMap<>();
	
	/* 세션생성
	 * sessionId생성 (임의의 추정 불가능한 랜덤값)
	 * 세션 저장소에 sessionId와 보관할 값 저장
	 * sessionId로 응답 쿠키를 생성해서 클래이언트에 전달
	 */
	public void createSession(Object value , HttpServletResponse response) {
		// sessionId생성하고, 값을 세션에 저장
		String sessionId = UUID.randomUUID().toString();
		
		sessionStore.put(sessionId, value);
		
		Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
		response.addCookie(mySessionCookie);
	}

	// 세션 조회
	public Object getSession(HttpServletRequest request) {
		Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
		if(sessionCookie == null) {
			return null;
		}
		
		return sessionStore.get(sessionCookie.getValue());
	}
	
	// 세션 만료
	public void expire(HttpServletRequest request) {
		Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
		if(sessionCookie != null) {
			sessionStore.remove(sessionCookie.getValue());
		}
		
	}
	
	// request의 쿠키를 가져와서 session과 관련된 "mySessionId"이라는 이름의 쿠키를 조회(session의 key가 들어있음)
	public Cookie findCookie(HttpServletRequest request, String cookieName) {
		/*
		Cookie[] cookies = request.getCookies();
		
		if(cookies == null) {
			return null;
		}
		
		for (Cookie cookie : cookies) {
			if(cookie.getName().equals(SESSION_COOKIE_NAME)) {
				return sessionStore.get(cookie.getValue());
			}
		}
		
		return null;
		*/
		Cookie[] cookies = request.getCookies();
		if(cookies == null) {
			return null;
		}
		// cookies 배열을 스트림으로 바꿔주고
		// 필터적용(loop로 cookies를 cookie 변수에 담아서 cookie.getName().equals(cookieName)로직을 하나씩 돌려줌)
		// findFirst() : 첫번째로 나온 cookies의 cookie객체
		// findAny() : 순서와 상관없이 빨리 나온 cookies의 cookie 객체(병렬처리에서 순서와 상관없이 찾을때)
		// 없으면 null
		return Arrays.stream(cookies)
				.filter(cookie -> cookie.getName().equals(cookieName))
				.findAny()
				.orElse(null);
	}
}

 @Component : 스프링 빈으로 자동 등록
 ConcurrentHashMap : HashMap은 동시 요청에 안전하지 않음 동시 요청에 안전한 ConcurrentHashMap를 사용

 

 

4. 직접 만든 세션 적용

4.1 로그인 컨트롤러 (세션 생성)

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
	private final LoginService loginService;
	private final SessionManager sessionManager;
    
	@PostMapping("/login")
	public String loginV2(@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";
		}
		
		// 로그인 성공처리
		// 세션 관리자를 통해 세션을 생성하고 회원 데이터 보관
		sessionManager.createSession(loginMember, response);
		
		return "redirect:/";
	}
}

 로그인 성공시 직접 만든 sessionManager객체의 createSession 메서드 호출

 - sessionId(임의의 렌덤값) 생성

 - 세션 저장소에 sessionId와 보관할 값 저장

 - sessionId로 응답 쿠키를 생성해서 클라이언트에 전달

 

 

4.2 로그아웃 컨트롤러 (세션 만료)

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
	private final LoginService loginService;
	private final SessionManager sessionManager;	
    
	@PostMapping("/logout")
	public String logoutV2(HttpServletResponse response,HttpServletRequest request) {
		sessionManager.expire(request);
		return "redirect:/";
	}
}

 로그아웃 시 직접 만든 sessionManager객체의 expire메서드 호출

 - 요청 헤더의 쿠키를 찾아 sessionId로 세션 저장소에서 삭제

 

 

4.3 홈 컨트롤러 (세션 조회)

@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {
	private final MemberRepository memberRepository;
	private final SessionManager sessionManager;

	@GetMapping("/")
	public String homeLoginV2(HttpServletRequest request, Model model){
		// 세션 관리자에 저장된 회원 정보 조회
		Member member = (Member) sessionManager.getSession(request);
		
		// 로그인 성공
		if(member == null) {
			return "home";
		}
		
		model.addAttribute("member", member);
		
		return "loginHome";
	}
}

 

 

※ 서블릿에서는 세션매니저 역할(HttpSession)객체를 제공하고 있음

 - 우리가 만들었던 SessionManager의 역할을 하는 객체를 서블릿에서는 HttpServlet 클래스를 통해 제공하고 있음

 - HttpSession을 이용하면 우리는 세션 생성, 조회, 삭제를 편하게 사용할 수 있고 추적 불가능한 키를 가진 쿠키를 생성할 수 있음

 - 이때 쿠키 이름은 JSESSIONID이며 HttpOnly이기에 클라이언트에서 조작할 수 없음

 

 

5. 서블릿 HttpSession을 이용한 로그인 처리

5.1 세션 조회용 상수 클래스 생성

public interface SessionConst {
	String LOGIN_MEMBER = "loginMember";
}

 

5.2 로그인 컨트롤러 (세션 생성)

@PostMapping("/login")
public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,HttpServletRequest request) {
	if(bindingResult.hasErrors()) {
		return "login/loginForm";
	}
	
	Member loginMember= loginService.login(form.getLoginId(), form.getPassword());
	
	if(loginMember ==null) {
		bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
		return "login/loginForm";
	}
	
	// 로그인 성공 처리
	// 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
	// 서블릿을 통해 HttpSession을 생성하면 쿠키 이름을 JSESSIONID으로, 값은 추정 불가능한 랜덤 값으로 저장한다.
	HttpSession session = request.getSession();
	
	// 세션에 로그인 회원 정보 보관
	// 세션에 "loginMember"이름으로 loginMember 객체가 저장됨
	session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
	
	return "redirect:/";
}

 request.getSession()
 - getSession 메서드는 세션을 생성 혹은 조회하는 메서드


 public HttpSession getSession(boolean create); //default true
  - create : true일 경우
    : 세션이 있으면 기존 세션을 반환
    : 세션이 없으면 새로운 세션을 생성해 반환
  - create :false일 경우
   : 세션이 있으면 기존 세션을 반환
   : 세션이 없으면 새로운 세션을 생성하지 않고 null을 반환
※ 추가적으로 인수를 전달하지 않을 경우 기본 값으로 true

 

 

 

5.3 로그아웃 컨트롤러 (세션 만료)

@PostMapping("/logout")
public String logoutV3(HttpServletRequest request) {
	// 세션이 있으면 세션 반환, 세션이 없으면 새로운 세션을 생성하지 않고 null 반환 default가 true
	HttpSession session = request.getSession(false);
	
	if(session != null) {
		// session 삭제
		session.invalidate();
	}
	
	return "redirect:/";
}

 session.invalidate();
  - 세션을 제거하는 메서드다. 

 

 

5.4 홈 컨트롤러 (세션 조회)

@GetMapping("/")
public String homeLoginV3(HttpServletRequest request, Model model){
	// 세션 관리자에 저장된 회원 정보 조회
	HttpSession session = request.getSession(false);
	if(session == null) {
		return "home";
	}
	
	Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
	
	// 세션에 회원 데이터가 없으면 home으로 이동
	if(loginMember == null) {
		return "home";
	}
	
	// 세션이 유지되면 로그인으로 이동
	model.addAttribute("member", loginMember);
	
	return "loginHome";
}

 HttpSession session = request.getSession(false);

 - 기존에 있는 세션 조회

 

 Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);

- "loginMember"의 key로 저장된 세션 정보 조회하여 회원 정보 저장

 

※ @SessionAttribute 어노테이션으로 위의 로직을 간편화하여 사용할 수 있음

 

 

5.5 홈 컨트롤러 (세션 조회) - @SessionAttribute 어노테이션 활용

@GetMapping("/")
public String homeLoginV3Spring(
			// @SessionAttribute어노테이션은 세션을 생성하지 않음 찾을때만 씀
			@SessionAttribute(name=SessionConst.LOGIN_MEMBER, required = false) Member loginMember, Model model){

	// 세션에 회원 데이터가 없으면 home으로 이동
	if(loginMember == null) {
		return "home";
	}
	
	// 세션이 유지되면 로그인으로 이동
	model.addAttribute("member", loginMember);
		
	return "loginHome";
}

@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false)
 - 이전에 사용한 @CookieValue와 비슷

 - 클라이언트로부터 전달받은 내용의 세션중에서 key가 일치하는게 있는지 찾음.

 - required가 false이니 만약 못찾으면 null이 할당

※ 소스가 간결해짐

 

 

5.6 HttpSession객체에서 제공하는 정보

package hello.login.web.session;

import java.util.Date;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
public class SessionInfoController {
	@GetMapping("/session-info")
	public String sessionInfo(HttpServletRequest request) {
		HttpSession session = request.getSession(false);
		if(session ==null) {
			return "세션이 없습니다.";
		}
		
		// 세션에 있는 attribute를 꺼내서 확인
		session.getAttributeNames().asIterator()
		.forEachRemaining(name -> log.info("session name={}, value= {}", name, session.getAttribute(name)));
		
		log.info("sessionId={}", session.getId());
		log.info("getMaxInactiveInterval={}", session.getMaxInactiveInterval());
		log.info("getCreationTime={}",new Date(session.getCreationTime()));
		log.info("getLastAccessedTime={}", new Date(session.getLastAccessedTime()));
		log.info("isNew={}", session.isNew());
		
		return "세션 출력";
	}
}

 sessionId : 세션 아이디(JSESSIONID)의 값(ex:754BE5D4DD969894D958AC278370D06E)
 maxInactiveInterval : 세션의 유효 시간(ex: 1800초, (30분))
 creationTime: 세션 생성일시
 lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접속한 시간.
(클라이언트에서 서버로 sessionId(JSESSIONID)를 요청한 경우 갱신됨.)
 isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId(JSESSIONID)를 요청해서 조회된 세션인지 여부

 

 

5.7 세션 타임아웃 설정

 대부분의 사용자는 직접 명시적으로 로그아웃 버튼을 누르지 않음

HTTP는 비연결성(ConnectionLess)이기에 서버에선 클라이언트가 웹 브라우저를 종료했는지를 알 수 없음

※ 세션을 언제 삭제해야할지 판단하기 어려움  

 

• 세션의 타임아웃을 설정 되어야하는 이유

 - 세션을 무한정 유지되도록 한다면, 여러가지 문제가 발생할 수 있음

 - JSESSIONID를 탈취당한 경우 시간이 흘러도 해당 쿠키로 악용될 수 있음

 - 세션은 기본적으로 메모리에 생성되는데 메모리의 크기가 무한하지 않아 사용하지 않는 세션이 관리되지 않으면 성능 저하 발생 및 OutOfMemoryException이 발생 할 수있음

 

 세션의 종료 시점
 - 타임아웃이 너무 빠르면 로그인 유지가 무관하게 계속 로그인을 해야함.

 - 기본적으로는 세션 생성 시점으로부터 30분 정도

 - 사용자가 가장 최근 요청한 시간을 기준으로 30분 정도를 유지
※ HttpSession은 기본적으로 이 방식을 사용

 

세션의 타임아웃 설정 방법

※ 스프링 부트에서는 application.properties에 글로벌 설정을 해 줄 수 있음

session.setMaxInactiveInterval(1800);//1800초

- 1800초(30분)으로 설정을 해두면 LastAccessTime 이후 timeout 시간이 지나면 WAS 내부에서 해당 세션을 삭제

 

※ TrackingModes

 최초 로그인시 브라우저 URL 입력창이 다음과 같은 형식이됨.
http://localhost:8080/;jsessionid=F5511518B921DF6209l.......
 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해 세션을 유지하는 방법임.

 이를 없애기 위해서는 스프링 설정 파일(application.properties)에 다음과 같은 설정을 추가해주면 됨.

server.servlet.session.tracking-modes=cookie
반응형

+ Recent posts