Node JS #10

Cookie and Session

우리가 사용하는 HTTP 프로토콜의 특성은 connectionless, stateles한 특성을 가지기 때문에 연결을 지속할 수 없다.

다시 말해 클라이언트가 서버와 통신하기 위해서는 매 요청마다 내가 누구누구임을 밝혀야 한다.

이를 위해 쿠키와 세션을 사용한다.

쿠키는 클라이언트(브라우저) 로켈에 존재하는 키와 값으로 이루어진 작은 데이터 파일이다.

이 쿠키는 브라우저가 동일 도메인을 방문할때마다 자동으로 Request Header에 넣어 서버에 전송한다.

image

개발자 도구에서 쿠기를 확인할 수 잇다.

Session

세션은 쿠키를 기반으로 하지만, 사용자 정보를 서버측에서 관리한다.

서버는 클라이언트를 구분하기 위해 session ID를 발급하고, 클라이언트는 이 session ID를 자신의 쿠키 스토리지에 저장한다.

이후 클라이언트가 같은 도메인 내의 사이트를 방문할 때마다 자신이 가진 쿠키인 session ID를 서버에게 보내므로, 서버는 사용자를 식별할 수 있다.

추천 블로그 글


Authorization

Setup

인증은 세션을 통해서 구현할 것이다.

이때 express내에서 session을 관리해주는 미들웨어를 사용할 것이다.

이를 위해 express-session 패키지를 설치해주자.

$ npm install express-session

npm : express-session

import express from "express";
import morgan from "morgan";
import session from "express-session";
import rootRouter from "./routers/rootRouter";
import userRouter from "./routers/userRouter";
import videoRouter from "./routers/videoRouter";
import { localsMiddleware } from "./middlewares";

const app = express();

const logger = morgan("dev");

app.set("view engine", "pug");
app.set("views", process.cwd() + "/src/views");
app.use(logger);
app.use(express.urlencoded({ extended: true }));
app.use(session({ secret: "Hello", resave: true, saveUninitialized: true }));

//session middleware must be called before
app.use(localsMiddleware);

app.use("/", rootRouter);
app.use("/videos", videoRouter);
app.use("/users", userRouter);

export default app;

아래의 코드를 살펴보자.

app.use(session({ secret: "Hello", resave: true, saveUninitialized: true }));

세부 설명은 공식문서의 설명으로 대체한다.

Required option

This is the secret used to sign the session ID cookie. The secret can be any type of value that is supported by Node.js crypto.createHmac (like a string or a Buffer). This can be either a single secret, or an array of multiple secrets. If an array of secrets is provided, only the first element will be used to sign the session ID cookie, while all the elements will be considered when verifying the signature in requests. The secret itself should be not easily parsed by a human and would best be a random set of characters. A best practice may include:

  • The use of environment variables to store the secret, ensuring the secret itself does not exist in your repository.
  • Periodic updates of the secret, while ensuring the previous secret is in the array.

Using a secret that cannot be guessed will reduce the ability to hijack a session to only guessing the session ID (as determined by the genid option).

Changing the secret value will invalidate all existing sessions. In order to rotate the secret without invalidating sessions, provide an array of secrets, with the new secret as first element of the array, and including previous secrets as the later elements.

resave

Forces the session to be saved back to the session store, even if the session was never modified during the request. Depending on your store this may be necessary, but it can also create race conditions where a client makes two parallel requests to your server and changes made to the session in one request may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you’re using).

The default value is true, but using the default has been deprecated, as the default will change in the future. Please research into this setting and choose what is appropriate to your use-case. Typically, you’ll want false.

How do I know if this is necessary for my store? The best way to know is to check with your store if it implements the touch method. If it does, then you can safely set resave: false. If it does not implement the touch method and your store sets an expiration date on stored sessions, then you likely need resave: true.

saveUninitialized

Forces a session that is “uninitialized” to be saved to the store. A session is uninitialized when it is new but not modified. Choosing false is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie. Choosing false will also help with race conditions where a client makes multiple parallel requests without a session.

The default value is true, but using the default has been deprecated, as the default will change in the future. Please research into this setting and choose what is appropriate to your use-case.

그렇다면 이 세션이 올바르게 동작하는지 테스트해보자.

app.get("/add-one", (req, res, next) => {
    return res.send(`${req.session.id}`);
});

임의의 클라이언트가 /add-one URL에 접근하면, 서버는 sessionID를 제공할 것이다.

image

실제로 서버는 session ID를 주는 모습을 확인할 수 있는데, 쿠키와 세션에서의 설명에서처럼 이 seession ID는 브라우저의 쿠키 저장소에 위치해 있을 것이다.

image

실제로 개발자 도구에서 확인해 보면 위에서 확인한 session ID가 정말로 쿠키 저장소에 보관되어져 있음을 알 수 있다.

그리고 이 sessionID는 크롬, 엣지 등 다른 브라우저에서 접속하면 다른 클라인언트이므로 session ID가 다르다.

이를 확인해 보자

// 모든 세션을 출력하는 미들웨어 등록
app.use((req, res, next) => {
    req.sessionStore.all((error, sessions) => {
        console.log(sessions);
        next();
    });
});
// 세션의 count 값을 증가시키는 로직
app.get("/add-one", (req, res, next) => {
    req.session.count += 1;
    return res.send(`${req.session.id}`);
});

나는 크롬과 사파리에서 localhost를 접속했는데, 아래와 같이 우리의 서버는 2개의 별도의 클라이언트로 인식하여 count값이 독립적으로 증가하는 것을 확인할 수 있다.

image

image

클라이언트와 서버가 세션을 구축하는 과정을 정리하자

  1. 자동으로 express는 세션 ID를 생성하여 브라우저에게 발급
  2. 세션 ID는 브라우저별로 존재하는 쿠키 저장소에 둠
  3. 서버는 세션 ID를 DB에 저장
  4. 이후 브라우저는 같은 도메인 주소를 접속할때 자신의 쿠키 저장소에 있던 세션 ID와 함께 서버에 요청을 보냄
  5. 서버는 이 세션 ID와 자신의 DB에 있는 정보와 매치하여 브라우저, 즉 클라이언트를 식별함

Implement

세션의 구현은 req.session 객체를 이용하면 된다.

서버의 session DB에 저장된 req.session 객체를 조작하면 된다.

export const postLogin = async (req, res) => {
    const { username, password } = req.body;
    const pageTitle = "Login";
    //check if account exists
    const user = await User.findOne({ username });
    if (!user) {
        return res.status(404).render("login", {
            pageTitle,
            errorMessage: "An account with this username does not exists.",
        });
    }

    //check if password correct
    const checkPassword = await bcrypt.compare(password, user.password);
    if (!checkPassword) {
        return res.status(404).render("login", {
            pageTitle,
            errorMessage: "Wrong password",
        });
    }
    // 서버의 session DB에서 loggedIn, user 정보를 추가한다.
    req.session.loggedIn = true;
    req.session.user = user;

    return res.redirect("/");
};

그리고 이렇게 저장된 req.session 객체 정보를 res.locals 정보로 넘겨주는 미들웨어를 등록하자.

이 res.locals에 저장된 정보는 템플릿에서 그대로 가져다 쓸 수 있다.

이는 한 request 생명주기 동안 생존하는 obkect라 생각하면 된다.

pug에서 session의 데이터를 참조하지 못하기 때문에 res.locals통해 session의 데이터를 pug로 보내준다고 생각하자.

res.locals

Use this property to set variables accessible in templates rendered with res.render. The variables set on res.locals are available within a single request-response cycle, and will not be shared between requests.

In order to keep local variables for use in template rendering between requests, use app.locals instead.

This property is useful for exposing request-level information such as the request path name, authenticated user, user settings, and so on to templates rendered within the application.

app.use((req, res, next) => {
    // Make `user` and `authenticated` available in templates
    res.locals.user = req.user;
    res.locals.authenticated = !req.user.anonymous;
    next();
});
export const localsMiddleware = (req, res, next) => {
    res.locals.loggedIn = Boolean(req.session.loggedIn);
    res.locals.siteName = "Wetube";
    res.locals.loggedInUser = req.session.user;

    next();
};

base.pug 템플릿을 조작하여 로그인 유무에 따라 로그인, 로그아웃 버튼이 보이도록 수정하자.

doctype html
html(lang= "ko")
    head
        meta(charset="UTF-8")
        meta(name="viewport", content="width=device-width, initial-scale=1.0")
        title #{pageTitle} | #{siteName}
        link(rel="stylesheet" href="https://unpkg.com/mvp.css")
    body
        header
            h1=pageTitle
            nav
                ul
                    li
                        a(href="/") Home
                    if loggedIn
                        li
                            a(href="/logout") Log out
                        li
                            a(href="/my-profile") #{loggedInUser.name}의 Profile
                    else
                        li
                            a(href="/join") Join
                        li
                            a(href="/login") Login
                    li
                        a(href="/search") Search
                    li
                        a(href="/videos/upload") Upload Video
        main
            block content
    include partials/footer.pug

이 res.locals 객체를 이용하는 테스트를 해보자

app.use((req, res, next) => {
    res.locals.sexy = "YOU";
});

이런 미들웨어를 등록하면, 무슨 템플릿에서든 sexy라는 이름으로 이 변수를 접근할 수 있을 것이다.

doctype html
html(lang= "ko")
    head
        meta(charset="UTF-8")
        meta(name="viewport", content="width=device-width, initial-scale=1.0")
        title #{pageTitle} | #{siteName}
        link(rel="stylesheet" href="https://unpkg.com/mvp.css")
    body
        header
            h1=pageTitle
            h3 who is sexy? #{sexy}
        main
            block content
    include partials/footer.pug

image

실제로도 그러한 것을 확인할 수 있다.


이제 우리는 로그인 기능을 구현하였고, 이 로그인 정보를 쿠키와 세션을 이용하여 유지하는 기능 또한 구현하였다.

그러나 이 session ID는 아직 실제 서버 DB에 저장된 게 아니기 때문에, 서버 재시작시 모든 session ID가 소멸한다는 문제점이 있다.

다음에는 session ID를 실제 서버 DB에 저장하여 서버가 재시동되어도 그 정보가 살아있도록 해보자.

Categories:

Updated:

Leave a comment