이전 포스팅에서 HTTP Basic 방식으로 인증을 구현해보았다.
HTTP Basic 방식은 구현이 간편하지만 헤더의 정보가 탈취되었을 때 인증의 위험이 크다. 요청마다 헤더에 민감한 정보가 담겨서 오가는 것은 좋지 않다. 세션을 사용하면 이 단점을 보완할 수 있다.
🤔 Session Authentication 이란?
첫 로그인 시에만 인증 정보를 서버에게 보내주고 서버는 이를 통해 세션 저장소에 유저의 정보를 저장한다. 클라이언트는 이 세션 정보를 통해서만 서버와 통신할 수 있게 된다.
자세한 과정을 알아보자.
1. 클라이언트가 최초 1회 아이디와 비밀번호를 서버에게 보낸다.
2. 서버는 해당 인증 정보를 승인하고 세션 저장소에 저장한다.
3. 세션 식별자 (ID) 를 쿠키에 담아 클라이언트에게 응답을 전송한다.
4. 앞으로 클라이언트가 요청을 보낼 때마다 세션 ID를 포함한다.
5. 서버는 세션 ID를 확인해 사용자를 식별한 뒤 자원을 응답한다.
세션 저장소는 일반적으로 외부 서버나 데이터베이스에 위치한다고 한다. 나의 경우 메모리 상에서 세션 저장소를 만들어서 세션 인증을 구현해보겠다.
👩💻 Java로 구현해보기
로그인에 성공한 경우
클라이언트가 최초 1회 아이디와 비밀번호를 서버에 보냈을 때 이를 인증하고 쿠키로 세션 아이디를 받아야 한다. 테스트 코드로 이를 나타내면 다음과 같다.
@DisplayName("로그인 성공 시 세션 아이디를 쿠키로 전달 받는다.")
@Test
void sessionLogin() {
saveMemberAsAnna();
String credentials = "anna@email.com" + ":" + "1234";
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes());
String sessionId = RestAssured.given()
.header("Authorization", "Basic " + encoded)
.when().post("/login/session")
.then().statusCode(200)
.extract().cookie("sessionId");
assertThat(sessionId).isNotNull();
}
아이디와 비밀번호를 전송하는 방식은 Base64로 인코딩하는 방식을 택했다. 서버는 이 인코딩 정보를 통해 세션 저장소에 정보를 저장하고 식별 값을 발급할 것이다.
@PostMapping("/login/session")
public ResponseEntity<Void> loginSession(
@RequestHeader("Authorization") String encodedCredentials,
HttpServletRequest request,
HttpServletResponse response
) {
String encoded = encodedCredentials.split(" ")[1];
String credentials = new String(Base64.getDecoder().decode(encoded));
String[] emailAndPassword = credentials.split(":");
Member member = authService.findByEmailAndPassword(new LoginRequest(
emailAndPassword[0], emailAndPassword[1]
));
HttpSession session = request.getSession(true);
session.setAttribute("email", member.getEmail());
session.setMaxInactiveInterval(3600);
String sessionId = session.getId();
sessionStorage.addSession(sessionId, session);
response.addCookie(new Cookie("sessionId", sessionId));
return ResponseEntity.ok().build();
}
세션에 사용자의 이메일을 저장하고 세션 아이디와 세션 값을 세션 저장소에 넣었다.
그리고 클라이언트에게 세션 아이디를 쿠키에 담아 응답을 보내었다.
세션 저장소의 경우 아래와 같이 간단하게 메모리에 저장하는 방식으로 구현하였다.
@Component
public class SessionStorage {
private final Map<String, HttpSession> sessions = new HashMap<>();
public void addSession(String sessionId, HttpSession session) {
sessions.put(sessionId, session);
}
}
세션 아이디를 통해 자원을 요청하는 경우
이제 요청 시 마다 세션 아이디를 통해 사용자를 확인하는 과정이 필요하다. 아래와 같이 자원을 요청할 때 발급 받은 세션 아이디를 쿠키에 담아 보내줄 수 있다.
@DisplayName("세션 아이디를 쿠키에 담아 데이터를 요청하면 응답을 받는다.")
@Test
void getMemberBySessionId() {
saveMemberAsAnna();
String credentials = "anna@email.com" + ":" + "1234";
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes());
String sessionId = RestAssured.given()
.header("Authorization", "Basic " + encoded)
.when().post("/login/session")
.then().statusCode(200)
.extract().cookie("sessionId");
RestAssured.given()
.cookie("sessionId", sessionId)
.when().get("/login/member/session")
.then().statusCode(200);
}
마찬가지로 인터셉터에서 요청을 가로채 쿠키 값을 토대로 세션 정보가 있다면 검증에 성공하는 로직을 작성하였다.
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
private final SessionStorage sessionStorage;
private final MemberService memberService;
public LoginCheckInterceptor(SessionStorage sessionStorage, MemberService memberService) {
this.sessionStorage = sessionStorage;
this.memberService = memberService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// 세션 아이디가 입력되지 않은 경우 예외 처리
String sessionId = Arrays.stream(request.getCookies())
.filter(cookie -> cookie.equals("sessionId"))
.map(Cookie::getValue)
.findAny()
.orElseThrow(() -> new SecurityException("세션 정보를 입력해주세요."));
return true;
}
}
@Component
public class SessionStorage {
private final Map<String, HttpSession> sessions = new HashMap<>();
public void addSession(String sessionId, HttpSession session) {
sessions.put(sessionId, session);
}
public String getEmailBySessionId(String sessionId) {
return (String) sessions.get(sessionId).getAttribute("email");
}
public boolean doesNotContains(String sessionId) {
return sessions.containsKey(sessionId);
}
}
잘못된 세션 아이디를 통해 자원을 요청하는 경우
@DisplayName("잘못된 세션 아이디를 쿠키에 담아 요청 시 401 Unauthorized 응답을 받는다.")
@Test
void failGetMemberBySessionIdWhenIncorrectSessionId() {
saveMemberAsAnna();
RestAssured.given().log().all()
.cookie("sessionId", "wrongSessionId")
.when().log().all().get("/login/member/session")
.then().statusCode(401);
}
임의로 세션 아이디를 지정해 요청을 보내는 경우 오류를 반환해야 한다.
인터셉터에서 세션 저장소로부터 세션 아이디를 조회 후 없는 아이디라면 예외를 반환하도록 하였다.
비즈니스 로직에 따라 세션 저장소를 활용해 다양한 예외 처리가 가능하다.
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
private final SessionStorage sessionStorage;
private final MemberService memberService;
public LoginCheckInterceptor(SessionStorage sessionStorage, MemberService memberService) {
this.sessionStorage = sessionStorage;
this.memberService = memberService;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// 세션 아이디가 입력되지 않은 경우 예외 처리
String sessionId = Arrays.stream(request.getCookies())
.filter(cookie -> cookie.getName().equals("sessionId"))
.map(Cookie::getValue)
.findAny()
.orElseThrow(() -> new SecurityException("세션 정보를 입력해주세요."));
// 세션 아이디가 잘못된 경우 예외 처리
if (sessionStorage.doesNotContains(sessionId)) {
throw new SecurityException("존재하지 않은 세션 정보입니다.");
}
// 세션 아이디에 해당하는 이메일을 가진 회원이 없는 경우 예외 처리
String email = sessionStorage.getEmailBySessionId(sessionId);
memberService.findByEmail(email);
return true;
}
}
참고
https://medium.com/@884m884/understanding-session-based-authentication-from-scratch-64110bcfc00f
'우아한테크코스 > 레벨2' 카테고리의 다른 글
[Spring] Authentication 와 Authorization 을 Spring Security 없이 구현해보기 (1) - HTTP 편 (0) | 2024.06.29 |
---|---|
[회고] 우아한테크코스 6기 백엔드 레벨 2를 보내주며 .. 😥 (8) | 2024.06.19 |
[회고] 우아한테크코스 6기 백엔드 레벨2 9주차 회고 (라이트닝 토크, 방학식, 레벨2 돌아보기) (0) | 2024.06.18 |
[회고] 우아한테크코스 6기 백엔드 레벨2 8주차 회고 (포비와의 면담, 회복탄력성, 풋살) (0) | 2024.06.14 |
[회고] 우아한테크코스 6기 백엔드 레벨2 7주차 회고 (2) | 2024.06.03 |