학습메모 1을 참고해 로그인 버튼에 필요한 깃헙 로고 SVG 파일을 받아왔다.
HTML/CSS를 편집해 예쁜 Github으로 로그인하기 버튼을 만들어줬다!
학습메모 2를 참고해 OAuth application을 등록했다.
client_id와 client_secret 값을 얻을 수 있다!
이제 학습메모 3을 참고해 어떤 url로 Github으로 로그인을 요청해야 하는지를 확인해보자.
import * as queryString from "query-string";
const params = queryString.stringify({
client_id: process.env.APP_ID_GOES_HERE,
redirect_uri: "https://www.example.com/authenticate/github",
scope: ["read:user", "user:email"].join(" "), // space seperated string
allow_signup: true,
});
const githubLoginUrl = `https://github.com/login/oauth/authorize?${params}`;
https://github.com/login/oauth/authorize?client_id=[...]&redirect_uri=http://localhost:3000/login/github&scope=read:user%20user:email&allow_signup=true
위의 client_id에 방금 등록해서 얻은 client_id를 넣어주면 완성.
이 URL로 브라우저에서 요청을 보내보자. 우리가 원하는 Github Authorize
페이지를 볼 수 있다. 성공!
물론 callback url에 대한 처리를 안해줬으므로 Authorize qkrwogk
버튼을 눌러도 404 Not Found가 나온다. request요청을 로그로 남겨 확인 먼저 해봤다.
http://localhost:3000/login/github?code=1315cae6eea5e0bc9303
위와 같은 code 값이 url 파라미터로 넘어온다.
학습메모 3을 계속 참고해보면, 이 code를 이용해서 다시 요청을 보내야 한다.
import axios from "axios";
import * as queryString from "query-string";
async function getAccessTokenFromCode(code) {
const { data } = await axios({
url: "https://github.com/login/oauth/access_token",
method: "get",
params: {
client_id: process.env.APP_ID_GOES_HERE,
client_secret: process.env.APP_SECRET_GOES_HERE,
redirect_uri: "https://www.example.com/authenticate/github",
code,
},
});
/**
* GitHub returns data as a string we must parse.
*/
const parsedData = queryString.parse(data);
console.log(parsedData); // { token_type, access_token, error, error_description }
if (parsedData.error) throw new Error(parsedData.error_description);
return parsedData.access_token;
}
https://github.com/login/oauth/access_token?client_id=[...]&client_secret=[...]&redirect_uri=http://localhost:3000/login/github&code=[...]
앞서 획득한 code, 그리고 앱 등록시 획득한 client_id, client_secret을
넣어서 테스트해보자!
위와 같이 access_token이라는 파일이 전달된다.
error=bad_verification_code
&error_description=The+code+passed+is+incorrect+or+expired.
&error_uri=https%3A%2F%2Fdocs.github.com%2Fapps%2Fmanaging-oauth-apps%2Ftroubleshooting-oauth-app-access-token-request-errors%2F%23bad-verification-code
에러 발생 시(code가 잘못되면) 위와 같은 에러가 뜨고
access_token=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
&scope=read%3Auser%2Cuser%3Aemail
&token_type=bearer
성공하면 위와 같이 access_token이 반환된다.
이제 학습메모3을 참고하여 마지막으로 access_token을
import axios from "axios";
async function getGitHubUserData(access_token) {
const { data } = await axios({
url: "https://api.github.com/user",
method: "get",
headers: {
Authorization: `token ${access_token}`,
},
});
console.log(data); // { id, email, name, login, avatar_url }
return data;
}
Header를 넣어야 하므로 이번엔 Postman 앱에서 요청을 보내보겠다.
잘 나온다 나온다!!! 여기서 id, email, name, login, avatar_url
등
필요한 정보를 추출해서 사용하면 된다. 전체 데이터 구조를 살펴보자.
{
"login": "qkrwogk",
"id": ...,
"node_id": "...",
"avatar_url": "https://avatars.githubusercontent.com/u/138586629?v=4",
"gravatar_id": "",
"url": "https://api.github.com/users/qkrwogk",
"html_url": "https://github.com/qkrwogk",
"followers_url": "https://api.github.com/users/qkrwogk/followers",
"following_url": "https://api.github.com/users/qkrwogk/following{/other_user}",
"gists_url": "https://api.github.com/users/qkrwogk/gists{/gist_id}",
"starred_url": "https://api.github.com/users/qkrwogk/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/qkrwogk/subscriptions",
"organizations_url": "https://api.github.com/users/qkrwogk/orgs",
"repos_url": "https://api.github.com/users/qkrwogk/repos",
"events_url": "https://api.github.com/users/qkrwogk/events{/privacy}",
"received_events_url": "https://api.github.com/users/qkrwogk/received_events",
"type": "User",
"site_admin": false,
"name": "박재하",
"company": null,
"blog": "",
"location": null,
"email": null,
"hireable": null,
"bio": null,
"twitter_username": null,
"public_repos": 7,
"public_gists": 0,
"followers": 38,
"following": 44,
"created_at": "2023-07-04T11:41:36Z",
"updated_at": "2023-10-07T02:27:29Z",
"private_gists": 15,
"total_private_repos": 5,
"owned_private_repos": 3,
"disk_usage": 8997,
"collaborators": 0,
"two_factor_authentication": ...,
"plan": {
"name": "pro",
"space": 976562499,
"collaborators": 0,
"private_repos": 9999
}
}
우선 id
, name
, email
정도만 받아와서 로그인되게 해보자!
FE에서 만들어둔 github 로그인 버튼 클릭 시 킬릭 이벤트로 reqGitHubLogin 함수를 등록해줬다.
해당 함수는 학습메모 5를 참고하여 URL 파라미터에 앞서 테스트해본 BE로 코드 받아오기 기능
을 구현한다.
// fetch.js
const reqGitHubLogin = async () => {
const url = new URL("https://github.com/login/oauth/authorize");
const searchParams = new URLSearchParams();
searchParams.append("client_id", "47cc85d150a21a06a19a");
searchParams.append("redirect_uri", "http://localhost:3000/login/github");
searchParams.append("scope", "read:user user:email");
searchParams.append("allow_signup", "true");
url.search = searchParams.toString();
window.open(url.toString());
return;
};
export { ..., reqGitHubLogin };
// user/login.js
const setEvtsOnLogin = () => {
document
.querySelector("button.github")
.addEventListener("click", reqGitHubLogin);
};
const onWindowLoad = () => {
setActsOnLogin();
setEvtsOnLogin();
};
완성된 기능은 login.js에서 버튼에 콜백함수로 등록해준다.
이제 GitHub 로그인 버튼을 클릭하면 코드가 파라미터로 담겨 잘 리다이렉트되는데,
서버에서 code 값을 읽어와 다시 액세스 토큰을 받아오는 로직을 구현하지 않아 404인 상태.
구현해주자.
// loginRouter.js
...
loginRouter.get("/login/github", handleGitHubLogin );
...
우선 로그인 라우터에 GET /login/github
을 등록해주고
// loginController.js
const handleGitHubLogin = async (req, res) => {
try {
const { code } = req.queryObj;
console.log("[OAuth] code: ", code);
const accessToken = await getAccessTokenFromCode(code);
if (!accessToken) throw new Error("access_token을 받지 못함");
console.log("[OAuth] accessToken: ", accessToken);
const userData = await getGitHubUserData(accessToken);
if (!userData) throw new Error("userData를 받지 못함");
res.json({ email: userData.login, nickname: userData.name });
return;
} catch (e) {
console.log(e);
res.error(500, e.toString());
}
};
export { isLoggedIn, doLogin, handleGitHubLogin };
이런 느낌으로 구현해줬다. getAccessTokenFromCode()는 다음과 같다.
const getAccessTokenFromCode = async (code) => {
const url = new URL("https://github.com/login/oauth/access_token");
const searchParams = new URLSearchParams();
searchParams.append("client_id", github_oauth_config.client_id);
searchParams.append("client_secret", github_oauth_config.client_secret);
searchParams.append("redirect_uri", "http://localhost:3000/login/github");
searchParams.append("code", code);
url.search = searchParams.toString();
const res = await fetch(url.toString());
const resParams = new URLSearchParams(await res.text());
const accessToken = resParams.get("access_token");
return accessToken;
};
code를 전달하고 accessToken을 받아온다.
const getGitHubUserData = async (accessToken) => {
const url = new URL("https://api.github.com/user");
const res = await fetch(url.toString(), {
headers: {
Authorization: `token ${accessToken}`,
},
});
const userData = await res.json();
return userData;
};
accessToken을 전달하고 userData를 받아온다. 완성!
이제 GitHub로그인 버튼을 클릭해보자.
잘 받아온다. 이제 이 정보로 세션이 아닌 JWT로 계정 관리를 해보자!
npm i jsonwebtoken
JWT(Json Web Token) node.js 모듈을 설치해준다.
토큰을 만들고 반환하는 로직은 간단하다. (chatGPT 참고)
// 로그인 라우트
app.post("/login", (req, res) => {
const { username, password } = req.body;
// 사용자 인증 로직
if (username === "exampleuser" && password === "password") {
// Access Token 생성
const accessToken = jwt.sign({ username }, secretKey, { expiresIn: "15m" });
// Refresh Token 생성
const refreshToken = jwt.sign({ username }, refreshSecretKey, {
expiresIn: "7d",
});
res.json({ accessToken, refreshToken });
} else {
res.status(401).json({ message: "로그인 실패" });
}
});
유효한 username, password가 오면, username값을 저장한 채 토큰 생성하는 것인데,
우리는 이미 검증된 OAuth를 이용했으므로 email과 nickname을 받아 넘겨주면 되시겠다.
또한 JWT 토큰을 브라우저에서 저장하는 방식은 3가지다. 학습메모 7 참고
우리는 이미 어느정도 구현했고, 보안성도 가장 높다고 평가되는 쿠키에 저장해주기로 한다.
다음으로는 로그인 검증 로직이다. (chatGPT 참고)
// 미들웨어 함수로 JWT 검증
function verifyToken(req, res, next) {
const token = req.headers["authorization"];
if (!token) {
return res.status(403).json({ message: "토큰이 제공되지 않았습니다." });
}
jwt.verify(token, secretKey, (err, decoded) => {
if (err) {
return res.status(401).json({ message: "인증에 실패했습니다." });
}
// 사용자 정보를 요청 객체에 저장
req.user = decoded;
next();
});
}
해당 로직은 express의 경우 middleware로 들어가야 하지만, 미들웨어 역할을 하도록
각 컨트롤러 앞단에 검사하는 조건문을 추가해주면 되겠다.
마찬가지로 chatGPT 참고.
// Refresh Token을 사용하여 Access Token 재발급
app.post("/refresh", (req, res) => {
const refreshToken = req.body.refreshToken;
jwt.verify(refreshToken, refreshSecretKey, (err, decoded) => {
if (err) {
return res
.status(401)
.json({ message: "Refresh Token이 유효하지 않습니다." });
}
// Refresh Token이 유효하면 새로운 Access Token 생성
const accessToken = jwt.sign({ username: decoded.username }, secretKey, {
expiresIn: "15m",
});
res.json({ accessToken });
});
});
POST /login/refresh
요청이 들어오면 refreshToken을 재발급해주면 되시겠다.
// loginController.js
const generateToken = (userData) => {
const accessToken = jwt.sign(userData, jwt_config.secretKey, {
expiresIn: "15m",
});
const refreshToken = jwt.sign(userData, jwt_config.refreshSecretKey, {
expiresIn: "7d",
});
return { accessToken, refreshToken };
};
위에서 분석한 예시코드와 유사하게 코드를 구성해준다. secretKey들은 따로 파일로 관리해서
git repository에 push되지 않도록 해준다.
// loginController.js
const handleGitHubLogin = async (req, res) => {
try {
...
const tokens = generateToken({
email: userData.login,
nickname: userData.name,
});
res.cookie = tokens;
...
} ...
};
res.cookie에 넣어줘서 Set-Cookie 등록을 해주는데, 2개의 쿠키를 등록해줘야 해서
기존의 HttpResponse 메소드로는 동작을 안하더라.
HTTP/1.1 200 OK ...
...
Set-Cookie: cookie1=value1; HttpOnly; Path=/
Set-Cookie: cookie2=value2; HttpOnly; Path=/
...
요약하자면 이런식으로 헤더를 두 번 넣어서 쿠키를 등록해줘야 한다. 이걸 구현해주자.
// HttpResponse.js
set cookie(cookieObj) {
this.#cookieObj = cookieObj;
}
#pushSetCookieToHeaderList() {
if (this.#cookieObj) {
// 쿠키 등록이 필요하면
Object.keys(this.#cookieObj).forEach((key) => {
this.#headerList.push(
`Set-Cookie: ${key}=${this.#cookieObj[key]}; HttpOnly; Path=/`
);
});
}
}
...
redirect(url) {
...
Object.keys(this.#headerObj).forEach((key) => {
this.#headerList.push(`${key}: ${this.#headerObj[key]}`);
});
this.#pushSetCookieToHeaderList();
...
}
자 완성. 이제 JWT 토큰이 쿠키에 잘 들어가나 보자.
너무 잘되어버린다.
학습메모 8을 참고하여 등록된 JWT 토큰을 확인해보면 데이터가 잘 들어가 있는 것을 확인할 수 있다.
참고로 이전 url로 돌아가기 위해서는 redirect_uri callback 시 OAuth 표준에서도 제공하는
"state"라는 url parameter를 이용하면 되시겠다.
학습메모 2를 보면 이런 파라미터가 있다.
const reqGitHubLogin = async () => {
const url = new URL("https://github.com/login/oauth/authorize");
const searchParams = new URLSearchParams();
...
// state는 원래 임의의 문자열을 생성해야 하나 여기서는 이전 페이지로 돌아가기 위해 사용
const current_url = new URL(document.URL);
const from = current_url.searchParams.get("from");
searchParams.append("state", from ? from : "/");
url.search = searchParams.toString();
window.location.href = url.toString();
return;
};
프론트 단에서 state 파라미터도 함께 전달해 GitHub 로그인을 요청해주고
const handleGitHubLogin = async (req, res) => {
try {
const { code, state } = req.queryObj;
...
// 로그인 전 페이지로 이동 구현
const url = state ? state : "/";
console.log("[OAuth] redirect to: ", url);
res.redirect(url);
return;
} ...
};
위와 같이 이전 페이지로의 이동을 구현할 수 있다.
이제 등록한 토큰을 검증해보자.
GET /login/status
에 대한 처리GET /login/status
에 대한 컨트롤러인 isLoggedIn 함수를 개선해보자.
const isLoggedIn = async (req, res) => {
let loginStatus = req.session?.email ? true : false;
let nickname = undefined;
if (loginStatus) {
try {
const sessionDTO = new SessionDTO();
sessionDTO.id = req.session?.id;
sessionDTO.email = req.session?.email;
const resultFindSession = await findSessionById(sessionDTO);
if (resultFindSession.length === 0) {
return new Error("email에 해당하는 세션을 찾지 못했습니다.");
}
const userDTO = new UserDTO();
userDTO.email = req.session?.email;
const resultFindUser = await findUserNicknameByEmail(userDTO);
if (resultFindUser.length === 0) {
return new Error("email에 해당하는 닉네임을 찾지 못했습니다.");
}
nickname = resultFindUser[0].nickname;
res.json({ loginStatus, nickname: encodeURIComponent(nickname) });
return;
} catch (e) {
res.json({ loginStatus: false, error: e.toString() });
return;
}
} else if (req.cookie?.accessToken) {
const accessToken = req.cookie.accessToken;
try {
const decoded = await jwt.verify(accessToken, jwt_config.secretKey);
console.log("[LoginStatus] jwt decoded:", decoded);
loginStatus = true;
nickname = decoded.nickname;
res.json({ loginStatus, nickname: encodeURIComponent(nickname) });
return;
} catch (e) {
res.json({ loginStatus: false, error: e.toString() });
return;
}
}
res.json({ loginStatus: false, error: "no login" });
};
위쪽은 일반 로그인의 경우, 아래쪽은 일반 로그인이 수행되지 않은 경우 JWT 토큰을 확인하는 로직이다.
jwt.verify()
결과 성공하면 nickname과 성공하였음을 반환하며, 실패하면 false와 에러 메시지를
반환한다.
GET /user/list
, GET /boards/write
, GET /boards/:id
, POST /boards
, POST /comments
우선 검증을 위한 verifyToken() 함수를 만들어준다.
// utils/verifyToken.js
import jwt from "jsonwebtoken";
import { jwt_config } from "../config/jwt_config.js";
const verifyToken = (accessToken) => {
try {
const decoded = jwt.verify(accessToken, jwt_config.secretKey);
return { verified: true, decoded };
} catch (e) {
return { verified: false, decoded: undefined };
}
};
export { verifyToken };
이제 기존의 로그인 검증 로직에 JWT 토큰 검증 로직을 조건문으로 추가해준다.
const { isLoggedIn } = await getLoginStatusAndEmail(req);
if (!isLoggedIn) {
const from = req.path;
res.redirect("/user/login.html" + `?from=${from}`);
return;
}
이전에 이런 형태였다면
const { isLoggedIn } = await getLoginStatusAndEmail(req);
const accessToken = req.cookie?.accessToken;
const resultVerifyToken = verifyToken(accessToken);
if (!isLoggedIn && !resultVerifyToken.verified) {
const from = req.path;
res.redirect("/user/login.html" + `?from=${from}`);
return;
}
이런 식으로 모두 바꿔줬다.
nickname을 가져오는 로직도 따로 만들어줬었기 때문에 Token방식에서랑 구분이 필요했는데,
// 닉네임 가져오기
let nickname = undefined;
if (isLoggedIn) {
const userDTO = new UserDTO();
userDTO.email = email;
const resultFindUser = await findUserNicknameByEmail(userDTO);
if (resultFindUser.length === 0) {
return new Error("email에 해당하는 닉네임을 찾지 못했습니다.");
}
nickname = resultFindUser[0].nickname;
} else {
nickname = resultVerifyToken.decoded.nickname;
}
이런식으로 (두 방식 모두로 로그인하는 경우는 없으므로) 구분해서 nickname에서 값을 받아줬다.
FE에서 loginStatus를 검사할 때(모든 페이지에서 검사함) accessToken이 유효하지 않으면
refreshToken으로 accessToken을 발급받는 로직을 만들어본다.
위 그림은 15분이 지나 accessToken이 만료될 때의 GET /login/status
결과 화면이다.
이 에러메시지를 활용하여 /refresh를 시도해보자.
const getLoginStatus = async () => {
const url = "/login/status";
const res = await fetch(url);
const json = await res.json();
const { loginStatus, nickname } = json;
if (!loginStatus && json.error.includes("jwt expired")) {
console.log("jwt token refresh 시도");
// refresh 시도 후에도 로그인이 안 되어 있으면 false로 간주
const url = "/login/refresh";
const res = await fetch(url);
const json = await res.json();
return { loginStatus: json.loginStatus, nickname: json.nickname };
}
return { loginStatus, nickname: decodeURIComponent(nickname) };
};
재시도 로직 추가. 이제 이 GET /login/refresh
요청에 응답하며 쿠키에 accessToken을
갱신하는 컨트롤러 함수를 만들어 주자.
// loginController.js
const handleTokenRefresh = (req, res) => {
const refreshToken = req.cookie?.refreshToken;
if (!refreshToken) {
res.error(401, "Refresh Token이 없음");
}
let decoded = undefined;
try {
decoded = jwt.verify(refreshToken, jwt_config.refreshSecretKey);
console.log("[Token Refresh] decoded: ", decoded);
} catch (e) {
res.error(401, "Refresh Token이 유효하지 않음");
}
const userData = {
email: decoded.email,
nickname: decoded.nickname,
};
try {
// Refresh Token이 유효하면 새로운 Access Token 생성
const accessToken = jwt.sign(userData, jwt_config.secretKey, {
expiresIn: "1m",
});
res.cookie = { accessToken };
res.json({ loginStatus: true, nickname: decoded.nickname });
console.log("[Token Refresh] new accessToken: ", accessToken);
return;
} catch (e) {
res.error(500, e.toString());
}
};
완성! Token Refresh 될 때 확인을 해보자!
시도했고
보냈고
받았고 완벽해 (동일한 값임을 확인)
set cookie도 확인.
글쓰기, 댓글쓰기, 글 조회, 유저리스트 조회 등 JWT 토큰으로 로그인한 유저도 모든 기능 잘 처리된다.