Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[후_Hwi] 웹서버 4단계 - 쿠키를 이용한 로그인 구현 #75

Open
wants to merge 14 commits into
base: hooi
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,57 @@ URL 전체 문법
- query : key=value 형태. ?로 시작, &로 추가 가능
- fragment : html 내부 북마크 등에 사용하며 서버에 전송되는 정보는 아님

<br>

쿠키

- Set-Cookie : 서버에서 클라이언트로 쿠키 전달(응답)
- Cookie : 클라이언트가 서버에서 받은 쿠키를 저장하고, HTTP 요청시 서버로 전달

---

- Stateless한 HTTP의 특징의 대안
- (쿠키 미사용) 모든 요청에 사용자 정보 포함
- 모든 요청과 링크에 사용자 정보를 포함하라구? 😱
- 모든 요청에 사용자 정보가 포함되도록 개발 해야함
- 브라우저를 완전히 종료하고 다시 열면?
- 쿠키의 등장
- 웹 브라우저 내부의 쿠키 저장소에 서버가 응답에 보낸 Set-Cookie의 내용을 저장
- 앞으로 요청을 보낼때마다 쿠키 저장소를 뒤져서 Cookie에 담아서 요청
- 모든 요청에 쿠키 정보 자동 포함
- 사용처
- 사용자 로그인 세션 관리
- 광고 정보 트래킹
- 쿠키 정보는 항상 서버에 전송됨
- 네트워크 트래픽 추가 유발
- 최소한의 정보만 사용(세션 id, 인증 토큰)
- 서버에 전송하지 않고, 웹 브라우저 내부에 데이터를 저장하고 싶으면 웹 스토리지(localStorage, sessionStorage) 참고
- 보안에 민감한 데이터는 저장하면 안됨
- 생명주기
- expires : 만료일이 되면 쿠키 삭제
- max-age : 0이나 음수를 지정하면 쿠키 삭제 (초단위)
- 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시까지만 유지
- 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
- 도메인 domain
- 명시 : 명시한 문서 기준 도메인 + 서브 도메인 포함
- domain=example.org를 지정해서 쿠키를 생성하면, example.org는 물론이고 dev.example.org도 쿠키 접근
- 생략 : 현재 문서 기준 도메인만 적용
- example.org에서 쿠키를 생성하고 domain 지정을 생략하면 example.org에서만 쿠키 접근
- 경로 path
- 이 경로를 포함한 하위 경로 페이지만 쿠키 접근
- 일반적으로 path=/ 루트로 지정
- 보안
- Secure
- 쿠키는 http, https를 구분하지 않고 전송하는데, secure를 적용하면 https인 경우에만 전송
- HttpOnly
- XSS 공격 방지
- 자바스크립트에서 접근 불가
- HTTP 전송에만 사용
- SameSite
- XSRF 공격 방지
- 요청 도메인과 쿠키에 설정된 도메인이 같은 경우만 쿠키 전송
- 브라우저에서 지원하는지 확인 하고 사용

### 삽질을 통해 배운 점

겪은 문제 : 서버에 "POST" 요청을 했을 때 바로 response가 오지 않고, 다음 요청이 왔을 때 전에 "POST"의 response가 비어있는 상태로 software caused connection
Expand Down Expand Up @@ -191,8 +242,18 @@ TODO 리스트
- [x] Run를 여러 메소드로 쪼갠다.

## 리뷰 받은 내용

- [x] 마크다운 문법에 맞게 README를 작성하기
- [x] HttpRequestUtils에서 getQueryString() 메소드 수정
- [x] 인코딩 형식들 README에 작성하기
- [x] URL에 대한 내용 찾아보기

## 4단계 - 쿠키를 이용한 로그인 구현
- [x] 회원가입한 사용자로 로그인을 할 수 있어야 한다.
- [x] “로그인” 메뉴를 클릭하면 http://localhost:8080/user/login.html 으로 이동해 로그인할 수 있다.
- [x] 로그인이 성공하면 index.html로 이동하고, 로그인이 실패하면 /user/login_failed.html로 이동해야 한다.
- [x] 정상적으로 로그인 되었는지 확인하려면 앞 단계에서 회원가입한 데이터를 유지해야 한다.
- [x] 앞 단계에서 회원가입할 때 생성한 User 객체를 DataBase.addUser() 메서드를 활용해 메모리에 저장한다.
- [x] 필요에 따라 Database 클래스의 메소드나 멤버변수를 수정해서 사용한다.
- [x] 아이디와 비밀번호가 같은지를 확인해서 로그인이 성공하면 응답 header의 Set-Cookie 값을 sessionId=적당한값으로 설정한다.
- [x] Set-Cookie 설정시 모든 요청에 대해 Cookie 처리가 가능하도록 Path 설정 값을 /(Path=/)로 설정한다.
- [x] 응답 header에 Set-Cookie값을 설정한 후 요청 header에 Cookie이 전달되는지 확인한다.
10 changes: 10 additions & 0 deletions src/main/java/controller/Controller.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package controller;

import http.Request;
import http.Response;

import java.io.IOException;

public interface Controller {
public Response run(Request request) throws IOException;
}
40 changes: 40 additions & 0 deletions src/main/java/controller/DefaultController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package controller;

import http.Request;
import http.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;

public class DefaultController implements Controller {

private static final Logger log = LoggerFactory.getLogger(DefaultController.class);
private static final DefaultController instance = new DefaultController();

public static DefaultController getInstance() {
return instance;
}

private DefaultController() {

}

@Override
public Response run(Request request) throws IOException {
String url = request.methodUrl().getValue(); // /index.html
String contentType = url.split("\\.")[1];
byte[] body = Files.readAllBytes(new File("./webapp" + url).toPath());
Map<String, String> responseHeader = new HashMap<>();
// responseHeader.put("Content-Type", "text/" + contentType + ";charset=utf-8");
responseHeader.put("Content-Length", String.valueOf(body.length));
Response response = new Response("HTTP/1.1", "200", "OK", responseHeader, body);
log.debug("[response] : {}", response.responseMessage());
return response;
}
}
48 changes: 48 additions & 0 deletions src/main/java/controller/LogInController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package controller;

import db.DataBase;
import http.Request;
import http.Response;
import model.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class LogInController implements Controller {
private static final Logger log = LoggerFactory.getLogger(LogInController.class);
private static final LogInController instance = new LogInController();

public static LogInController getInstance() {
return instance;
}

private LogInController() {

}

@Override
public Response run(Request request) {
String userId = request.findBodyByFieldName("userId");
String password = request.findBodyByFieldName("password");
Optional<User> user = DataBase.findUserById(userId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래쪽 로직을 보니 .get() 을 호출하고 있던데 옵셔널 객체를 강제 언래핑하는 것은 피해 주시고요.
User 가 이 로직 안에서 Optional 상태로 존재할 이유가 있나요? 저라면 여기서 .orElseThrow() 와 같은 편의 메소드를 사용할 것 같아요.


Map<String, String> responseHeader = new HashMap<>();
responseHeader.put("Content-Type", "text/html;charset=utf-8");
responseHeader.put("Content-Length", String.valueOf(0));
if (user.isEmpty() || !user.get().isCorrectPassword(password)) {
log.debug("로그인을 실패하였습니다!");
responseHeader.put("Location", "/user/login_failed.html");

} else if (user.get().isCorrectPassword(password)) {
log.debug("로그인을 성공했습니다!");
responseHeader.put("Location", "/index.html");
responseHeader.put("Set-Cookie", "logined=true; Path=/");
}
Response response = new Response("HTTP/1.1", "302", "Found", responseHeader, "".getBytes());
log.debug("[response] : {}", response.responseMessage());
return response;
}
}
40 changes: 40 additions & 0 deletions src/main/java/controller/LogOutController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package controller;

import http.Request;
import http.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;

public class LogOutController implements Controller {

private static final Logger log = LoggerFactory.getLogger(LogOutController.class);
private static final LogOutController instance = new LogOutController();

public static LogOutController getInstance() {
return instance;
}

private LogOutController() {

}

@Override
public Response run(Request request) throws IOException {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

뭔가 로그아웃을 위한 로직은 없는 것 같습니다?

byte[] body;
body = Files.readAllBytes(new File("./webapp" + "/index.html").toPath());
Map<String, String> responseHeader = new HashMap<>();
responseHeader.put("Content-Type", "text/html;charset=utf-8");
responseHeader.put("Content-Length", String.valueOf(body.length));
responseHeader.put("Location", "/index.html");
responseHeader.put("Set-Cookie", "logined=true; Max-Age=0; Path=/");
Response response = new Response("HTTP/1.1", "302", "Found", responseHeader, body);
log.debug("[response] : {}", response.responseMessage());
return response;
}
}
67 changes: 67 additions & 0 deletions src/main/java/controller/SignUpController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package controller;

import db.DataBase;
import http.Request;
import http.Response;
import model.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;

public class SignUpController implements Controller {

private static final Logger log = LoggerFactory.getLogger(SignUpController.class);
private static final SignUpController instance = new SignUpController();

public static SignUpController getInstance() {
return instance;
}

private SignUpController() {

}

@Override
public Response run(Request request) {
byte[] body = "".getBytes();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어떤 의미인지요?

Map<String, String> responseHeader = new HashMap<>();
responseHeader.put("Content-Type", "text/html;charset=utf-8");
responseHeader.put("Content-Length", String.valueOf(body.length));

DataBase.findUserById(request.findBodyByFieldName("userId"))
.ifPresentOrElse(
redirectSignUpFail(responseHeader),
redirectSignUpSuccess(request, responseHeader)
);
Response response = new Response("HTTP/1.1", "302", "Found", responseHeader, body);
log.debug("[response] : {}", response.responseMessage());
return response;
}

private Runnable redirectSignUpSuccess(Request request, Map<String, String> responseHeader) {
return () -> {
createUser(request);
responseHeader.put("Location", "/index.html");
};
}
Comment on lines +44 to +49
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runnable 은 여기서 꼭 필요한 걸까요?


private void createUser(Request request) {
User user = new User(
request.findBodyByFieldName("userId"),
request.findBodyByFieldName("password"),
request.findBodyByFieldName("name"),
request.findBodyByFieldName("email"));
log.debug("User : {}", user);
DataBase.addUser(user);
}

private Consumer<User> redirectSignUpFail(Map<String, String> responseHeader) {
return user -> {
responseHeader.put("Location", "/user/form.html");
log.debug("중복된 아이디가 존재합니다.");
};
}
}
82 changes: 82 additions & 0 deletions src/main/java/http/Request.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package http;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import util.HttpRequestUtils;
import util.IOUtils;
import util.Pair;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

public class Request {
private static final Logger log = LoggerFactory.getLogger(Request.class);

private final int METHOD = 0;
private final int URL = 1;
private final int HTTP_VERSION = 2;
private final int KEY = 0;
private final int VALUE = 1;

private String httpMethod;
private String requestUrl;
private String httpVersion;
private Map<String, String> requestHeaderField = new HashMap<>();
private Map<String, String> requestBody = new HashMap<>();

public Request(InputStream in) throws IOException {
BufferedReader input = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
setRequestLine(input);
setRequestHeader(input);
setRequestBody(input);
}

private void setRequestLine(BufferedReader input) throws IOException {
String requestLine = URLDecoder.decode(input.readLine(), StandardCharsets.UTF_8);
log.debug("<<<<<request start>>>>>");
log.debug("[request line] : {}", requestLine);

String[] requestInfo = HttpRequestUtils.getRequestInfo(requestLine);
httpMethod = requestInfo[METHOD];
requestUrl = requestInfo[URL];
httpVersion = requestInfo[HTTP_VERSION];
Comment on lines +46 to +48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굉장히 굉장히 사이드 이펙이 발생하기 쉬운 구조로 보이고요...
HttpRequestInfo 라는 클래스가 설계되어서, 메서드 간에는 객체의 형태로 넘나들어야 하지 않나 생각합니다.

그러니까 HttpRequestUtils.getRequestInfo() 의 리턴 타입으로 쓸만한 클래스 한 개,
적합한 파라메터를 넘겨받는 다른 private 메서드 n개가 설계돼야 할 것 같군요.

}

private void setRequestHeader(BufferedReader input) throws IOException {
String line;
while (!"".equals(line = URLDecoder.decode(input.readLine(), StandardCharsets.UTF_8))) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.isEmpty(), .isBlank() 와 같은 메서드들이 이미 String 에 있습니다.

if (line == null) {
return;
}
Pair pair = HttpRequestUtils.parseHeader(line);
requestHeaderField.put(pair.getKey(), pair.getValue());
}

requestHeaderField.entrySet().forEach(e -> {
log.debug("{} : {}", e.getKey(), e.getValue());
});
log.debug("<<<<<request end>>>>>");
}

private void setRequestBody(BufferedReader input) throws IOException {
int contentLength = (requestHeaderField.get("Content-Length") == null) ?
0 : Integer.parseInt(requestHeaderField.get("Content-Length"));
String data = IOUtils.readData(input, contentLength);
String decodedData = URLDecoder.decode(data, StandardCharsets.UTF_8);
requestBody = HttpRequestUtils.parseRequestBody(decodedData);
}

public Pair methodUrl() {
return new Pair(httpMethod, requestUrl);
}

public String findBodyByFieldName(String fieldName) {
return requestBody.get(fieldName);
}
}
Loading