[TIL] 211203

52983 단어 node.jsmongodbTILTIL

📝 오늘 한 것

  1. github 로그인 - 로그인 규칙 설정

  2. 로그아웃

  3. join / login / github login 처음부터 다시 구현해보기


📚 배운 것

user authentication

1. github 로그인 구현하기

1) 로그인 규칙 설정

user가 ① password를 가지거나 ② github의 email이 primary & verified 된 것이라면 로그인할 수 있도록 하려고 한다.

①은 앞에서 구현을 완료했고, ②를 구현해야 한다.

(1) github email이 DB에 있다면 login 시킨다

현재 github로부터 받은 user의 email들 가운데 primary & verified 된 email을 찾아놓은 상태이다.
이제 이 email과 같은 email을 가진 user가 DB에 있다면, 그 user가 전에 github로 로그인했든 password로 계정을 생성했든 상관없이 로그인시켜줄 것이다.

export const finishGithubLogin = (req, res) = {
  // finalUrl을 만듦
  // finalUrl로부터 데이터를 가져옴 (finalUrl에 POST request를 보냄)
  // 그 데이터를 json 형식으로 바꿈
  
  // access token을 이용해 github API에 접근해 user에 대한 정보를 가져옴
  if ("access_token" in tokenRequest) {
    const { access_token } = tokenRequest;
    const apiUrl = "https://api.github.com";
    // public 데이터
    const userData = await (
      await fetch(`${apiUrl}/user`, {
        headers: {
          Authorization: `token ${access_token}`,
        },
      })
    ).json();
    // private 데이터 (中 email)
    const emailData = await (
      await fetch(`${apiUrl}/user/emails`, {
        headers: {
          Authorization: `token ${access_token}`,
        },
      })
    ).json();
    // primary & verified email 가져오기
    const emailObj = emailData.find(email => email.primary === true && email.verified === true);
    if (!emailObj) {
      return res.redirect("/login");
    }
    // 찾은 email과 같은 email을 가진 user가 DB에 있다면, 그 user를 login 시킨다
    const existingUser = await User.findOne({ email: emailObj.email });
    if (existingUser) {
      req.session.loggedIn = true;
      req.session.user = existingUser;
      return res.redirect("/");
    } else {
      // 🔥 찾은 email과 같은 email을 가진 user가 DB에 없다면, 그 user의 계정을 생성한다 (join)
    }
  } else {
    return res.redirect("/login");
  }
};

이제 (현재 연결한 github 계정의 email과 같은 email로 Join 하여 그 email이 DB에 저장되어 있어야 함) login 페이지에서 'Continue with Github →'를 클릭하면, 로그인이 되어 home으로 redirect 되면서 Log out과 profile 메뉴가 뜬다.

(2) github email이 DB에 없다면 그 user의 계정을 생성한다 (join)

위 코드에서 🔥 부분을 구현하려고 한다.
일단 Users DB에서 Join 되어 있는 user 정보를 삭제한다.

db.users.remove({})

User.js 파일에서 userSchema에 socialOnly 값을 추가한다.
github 로그인을 통한 join인지 아닌지 알기 위해 사용된다.

또한, password에 부여해준 required: true를 삭제한다.
github 로그인을 통해 join 하면 password는 채울 수 없기 때문이다.
(단, join.pug 파일에서 password input은 그대로 required여야 한다. userSchema와 비교할 것.)

// User.js
const userSchema = new mongoose.Schema({
  // 생략
  password: String,
  socialOnly: { type: Boolean, default: false },
});

finishGithubLogin 컨트롤러를 수정한다.
github로부터 가져온 email이 DB에 없을 때 앞에서 가져온 userData와 eamilObj를 이용해 계정을 생성(Join)하도록 한다.

export const finishGithubLogin = (req, res) = {
  // finalUrl을 만듦
  // finalUrl로부터 데이터를 가져옴 (finalUrl에 POST request를 보냄)
  // 그 데이터를 json 형식으로 바꿈
  
  // access token을 이용해 github API에 접근해 user에 대한 정보를 가져옴
    // public 데이터
    // private 데이터 (中 email)
    // primary & verified email 가져오기

    // 찾은 email과 같은 email을 가진 user가 DB에 있다면, 그 user를 login 시킨다
    const existingUser = await User.findOne({ email: emailObj.email });
    if (existingUser) {
      req.session.loggedIn = true;
      req.session.user = existingUser;
      return res.redirect("/");
    } else {
      // 🔥 찾은 email과 같은 email을 가진 user가 DB에 없다면, 그 user의 계정을 생성한다 (join)
      const user = await User.create({
        name: userData.name,
        email: emailObj.email,
        username: userData.login,
        password: "", // github에서 가져온 데이터로부터 password 값을 채울 수 없다
        socialOnly: true, // 대신 github를 통한 계정 생성이란 것을 알리기 위해 socialOnly 값을 true로 바꾼다
        location: userData.location,
      });
    }
  } else {
    return res.redirect("/login");
  }
};

이제 (현재 연결한 github 계정의 email과 같은 email이 DB에 저장되어 있지 않더라도) login 페이지에서 'Continue with Github →'를 클릭하면, 로그인이 되어 home으로 redirect 되면서 Log out과 profile 메뉴가 뜬다.

한편, sicialOnly 값이 true인 user는 login form을 통해 로그인할 수 없도록 postLogin 컨트롤러를 수정해야 한다.

export const postLogin = async (req, res) => {
  const { username, password } = req.body;
  const user = await User.findOne({ username, socialOnly: false }); // 수정 ❗
// 중략
};

2) 최종 finishGithubLogin 컨트롤러 ❗❗❗

  • existingUser 부분에서 중복된 코드를 수정했다. (existingUser → user)

  • userSchema에 avatarUrl을 추가한 후 finishGithubLogin 컨트롤러에서 github email을 이용해 계정(user)을 새롭게 생성하는 부분의 코드를 수정했다.
    (password로 계정을 생성한 경우 avatar를 가지지 않는다. 다만, 나중에 프로필에서 추가 가능하다.)

export const finishGithubLogin = async (req, res) => {
  // 1. finalUrl을 만듦
  const baseUrl = "https://github.com/login/oauth/access_token";
  const config = {
    client_id: process.env.GH_CLIENT,
    client_secret: process.env.GH_SECRET,
    code: req.query.code,
  };
  const params = new URLSearchParams(config).toString();
  const finalUrl = `${baseUrl}?${params}`;
  // 2. finalUrl로부터 데이터를 가져옴 (finalUrl에 POST request를 보냄) (code → access token)
  // 그리고 그 데이터를 json 형식으로 바꿈
  const tokenRequest = await (
    await fetch(finalUrl, {
      method: "POST",
      headers: {
        Accept: "application/json",
      },
    })
  ).json();
  // 3-1. access token을 이용해 github API에 접근해 user에 대한 정보를 가져옴
  if ("access_token" in tokenRequest) {
    const { access_token } = tokenRequest;
    const apiUrl = "https://api.github.com";
    // 3-1-1. public 데이터
    const userData = await (
      await fetch(`${apiUrl}/user`, {
        headers: {
          Authorization: `token ${access_token}`,
        },
      })
    ).json();
    // 3-1-2. private 데이터 (中 email)
    const emailData = await (
      await fetch(`${apiUrl}/user/emails`, {
        headers: {
          Authorization: `token ${access_token}`,
        },
      })
    ).json();
    // (1) primary & verified email 가져오기
    const emailObj = emailData.find(
      (email) => email.primary === true && email.verified === true
    );
    if (!emailObj) {
      return res.redirect("/login");
    }
    // (2) 찾은 email과 같은 email을 가진 user가 DB에 있다면, 그 user를 login 시킨다
    // (3) 찾은 email과 같은 email을 가진 user가 DB에 없다면, 그 user의 계정을 생성한다 (join)
    let user = await User.findOne({ email: emailObj.email });
    if (!user) {
      user = await User.create({
        avatarUrl: userData.avatar_url,
        name: userData.name,
        email: emailObj.email,
        username: userData.login,
        password: "", // github에서 가져온 데이터로부터 password는 채울 수 없다
        socialOnly: true, // 대신, socialOnly 값을 true로 바꿔줬다
        location: userData.location,
      });
    }
    req.session.loggedIn = true;
    req.session.user = user;
    return res.redirect("/");
  } else {
    // 3-2. access_token이 tokenRequest 안에 없다면 user를 login 페이지로 보낸다.
    return res.redirect("/login");
  }
};

❗❗❗ 이제, github email과 같은 email을 가진 user가 DB에 있다면, 그 user가 전에 github로 로그인했든 password로 계정을 생성했든 상관없이, 로그인시켜줄 것이다.

github로 로그인
이미 wetube에서 github를 통해 계정을 만든 상태에서 cookie를 지워 로그아웃 한다.

'Continue with Github →'를 클릭하면 wetube에 로그인이 되는 것을 확인할 수 있다.
이때 socialOnly 값은 true 이다.

password로 계정 생성
이번에는 cookie를 지워 로그아웃 하고, Users DB에서 github를 통해 생성한 계정(user)을 삭제한 후, Join 페이지에서 password와 함께 앞서 wetube를 승인한 github email과 동일한 email을 입력해 계정을 생성한다. (join)

(wetube를 승인한 계정으로 gtihub 자체에 로그인이 되어 있는 상태에서) 'Continue with Github →'를 클릭하면 (앞서 User DB에서는 github를 통해 생성한 계정을 삭제했을지라도, join 페이지에서 계정을 만들 때 입력한 email이 현재 로그인 되어 있는 github email과 일치하므로) wetube에 로그인이 되는 것을 확인할 수 있다. 🔥
∵ const user = await User.findOne({ email: emailObj.email });
이때 socialOnly 값은 false이다.

❗❗❗ 한편, github email과 같은 email을 가진 user가 DB에 없다면, 앞에서 가져온 userData와 eamilObj를 이용해 계정을 생성(Join)한 후, 로그인시켜줄 것이다.

※ 소셜 로그인 구현 시 트위터, 카카오톡, 인스타그램 등은 client_id가 app_id 등으로 바뀌어 표현될 수는 있지만 전체적으로 github와 거의 비슷한 과정을 거친다.
다만, 구글이나 페이스북 등은 좀 더 절차가 복잡할 수 있다.


2. 로그아웃

1) req.session.destroy()

// userController.js
export const logout = (req, res) => {
  req.session.destroy();
  return res.redirect("/");
};

Log out 메뉴를 클릭하면, 로그아웃된다.
메뉴가 Log out & Profile에서 Join & Login으로 바뀐다.


3. join / login / github login 복습

※ 특정 버전의 커밋 가져오기

$ git clone [레파지토리 url]
$ git reset --hard [원하는 버전 커밋의 해시코드]

git clone을 이용해 user authentication 파트 처음부터 다시 구현해보기
헷갈렸거나 다시 보고 싶은 부분들만 정리함

1) 패키지 정리

(1) bcrypt

(join) 비밀번호 해싱
(login) 입력 비밀번호와 DB 비밀번호를 비교

(2) express-session

express에서 session을 사용할 수 있도록 한다.

브라우저가 서버에 요청을 보내면, 서버는 브라우저에게 session id를 주고, session store에 세션 id와 함께 세션 object를 저장해준다.

브라우저는 다음부터 서버에 request를 보낼 때 그 session id를 함께 보낸다.

같은 웹 사이트의 같은 user라도 서로 다른 브라우저에는 서로 다른 session id가 부여되므로 서버는 이를 통해 브라우저(user)를 구분할 수 있고, session store에 user에 대한 정보를 업데이트할 수 있다.

(3) connect-mongo

session 데이터를 mongoDB에 저장하도록 함

// server.js
import MongoStore from "connect-mongo";

app.use(session({
    secret: process.env.COOKIE_SECRET,
    resave: false,
    saveUninitialized: false,
    store: MongoStore.create({ mongoUrl: process.env.DB_URL }),
  })
);

(4) dotenv

process.env를 통해 .env 파일에 정의해놓은 것들에 접근하고 사용할 수 있도록 함

(5) node-fetch

node.js에서 fetch API를 사용하기 위해 설치해야 한다.
버전 3부터는 에러가 뜰 수 있다.
해결 방법은 있지만 공식 문서에선 CSM(CommonJS)를 쓴다면 버전 2를 권장하고 있다. (뒤에 정리)

2) email 중복 검사 / email 일치 유무

postJoin 컨트롤러에서 user가 입력한 email이 DB에 있는 email과 중복되면, 에러 메시지를 띄우도록 함

postLogin 컨트롤러에서 github email과 동일한 email이 DB에 있으면, 그 email을 비롯해 github로부터 가져온 user 정보를 바탕으로 계정을 생성하도록 함

→ 두 경우가 잠깐 헷갈렸다. 배치되지 않는다!

3) model.find() VS model.exists()

db.users.remove({}) 한 후에 join 하는데 자꾸 '해당 username/email은 이미 사용 중입니다.'라고 나왔다.
에러가 발생한 원인은 User.exists()를 User.find()라고 잘못 적었기 때문이었다.

model.find()는 조건을 만족하는 데이터를 담은 배열을 return 한다.
즉, 조건을 만족하는 데이터가 없다면 그 값은 [ ](빈 배열)이 된다.
그런데, 빈 배열은 true이다! ( 빈 문자열은 false이지만, 빈 배열 & 빈 객체는 true이다. )

한편, model.exists()는 조건을 만족하는 데이터의 유무를 Boolean 값으로 return 한다.
즉, 조건을 만족하는 데이터가 없다면 그 값은 false가 된다.

에러 코드

export const postJoin = async (req, res) => {
  const { name, email, username, password, password2, location } = req.body;
  const exists = await User.find({ $or: [{ username }, { email }] });
  console.log(exists); // [] → true
  if (exists) {
    return res.status(400).render("join", {
      pageTitle: "Join",
      errorMessage: "해당 username 또는 email은 이미 사용 중입니다.",
    });
  }
  // 생략
}

수정 및 해결

export const postJoin = async (req, res) => {
  const { name, email, username, password, password2, location } = req.body;
  const exists = await User.exists({ $or: [{ username }, { email }] });
  console.log(exists); // false
  if (exists) {
    return res.status(400).render("join", {
      pageTitle: "Join",
      errorMessage: "해당 username 또는 email은 이미 사용 중입니다.",
    });
  }
  // 생략
}

4) unique: true

userSchema를 정의할 때 username과 email에 'unique: true'를 적어줘야 한다.

5) try ~ catch

model.create() 사용 시 try ~ catch 구문을 사용해 에러가 발생할 때를 대비해야 한다.

6) localsMiddleware 실행 순서

코드 상으로는 postLogin 컨트롤러에서 req.session에 값을 준 후에 middlewares.js 파일 안의 localMiddleware가 실행되어야 하고, 실제로도 그렇게 실행되어 에러가 발생하지 않고 있다.

그런데 원래 next()가 있는 middleware는 controller보다 전에 실행되어야 하는 걸로 알고 있는데 이게 어떻게 된 건지 모르겠다.

// userController.js
export const postLogin = async (req, res) => {
  const { username, password } = req.body;
  const user = await User.findOne({ username });
  if (!user) {
    return res.status(400).render("login", {
      pageTitle: "Login",
      errorMessage: "해당 username을 가진 계정이 없습니다.",
    });
  }
  const ok = <await bcrypt.compare(password, user.password);
  if (!ok) {
    return res.status(400).render("login", {
      pageTitle: "Login",
      errorMessage: "비밀번호가 틀립니다.",
    });
  }
  req.session.loggedIn = true;
  req.session.user = user;
  return res.redirect("/");
};
// middlewares.js
export const localsMiddleware = (req, res, next) => {
  res.locals.loggedIn = req.session.loggedIn;
  res.locals.loggedInUser = req.session.user;
  res.locals.siteName = "Wetube";
  next();
};
// server.js
app.use(localsMiddleware);
app.use("/", rootRouter);

이유를 자세히 설명하자면, 실행 순서는 아래와 같다.
login 버튼을 누르면, express가 server.js 파일에서 home route를 찾아(∵ /login) postLogin 컨트롤러가 실행되기 전에, 그 위에 적힌 localsMiddleware가 먼저 실행된다.
다음으로 postLogin 컨트롤러가 쭉 실행되다가 마지막 부분에서 res.session에 값을 준 후 res.redirect("/")에 의해 express는 다시 home route를 찾는다.(∵ /)
이번에도 home 컨트롤러가 실행되기 전에, 그 위에 적힌 localsMiddleware가 먼저 실행된다.

따라서, 결과적으로 login 버튼을 누르면 localsMiddleware → postLogin 컨트롤러 → localsMiddleware → home 컨트롤러 순서대로 실행이 됨을 알 수 있다.

7) require() of ES Module not supported 에러

node-fetch를 import 하자 발생한 에러이다.
node-fetch가 버전 3부터는 ESM-only Module이라고 한다.
공식 문서에서는 CSM(CommonJS)를 쓴다면 버전 2로 쓸 것을 권장하고 있다.

node uninstall node-fetch
npm install [email protected]

8) 변수 사용 정리

#{변수} / =변수
pug 파일에서 자바스크립트 변수를 쓸 수 있음

env
.env 파일에 정의해놓은 값들을 process.env를 이용해 사용 가능

res.locals
pug와 express가 res.locals 값을 공유
전역 변수처럼 모든 pug 파일에서 사용 가능


✨ 내일 할 것

  1. user profile

좋은 웹페이지 즐겨찾기