[Node.js] express로 회원가입/로그인 간단 구현 2

김씨·2023년 1월 20일
1

Node.js

목록 보기
15/17
post-thumbnail

이전 장에 작성하던 회원가입 시스템을 좀 더 개선해보도록 하겠다.

1. 해시 함수로 비밀번호 암호화 하기

다음과 같이 4명의 회원이 우리 서비스에 가입하여 users.json에 정보가 저장된 상태라고 생각해보자.

// users.json
[
  {"username":"a","name":"xx","password":"1"}, 
  {"username":"b","name":"aa","password":"x"},
  {"username":"c","name":"dd","password":"aa"},
  {"username":"d","name":"x","password":"dd"}
]

만약 사용자 정보를 담은 이 파일이 해커에 의해 탈취된다면?

일반적으로 많은 사용자들이 여러 서비스에 동일한 아이디, 비밀번호를 사용하는 경향이 있다.

카카오톡, 구글, 네이버 등 다양한 계정의 비밀번호를 전부 다르게 설정하면 기억하기 매우 어렵기 때문이다.

그래서 우리 서비스에 사용자의 비밀번호를 아무런 암호화도 하지 않고 그대로 저장하게 되면, 이 정보가 털렸을 때 다른 서비스에 가입된 계정까지도 연쇄적으로 털릴 가능성이 높다.

그래서 보통은 해시 함수를 이용해서 사용자의 비밀번호를 암호화하고, 암호화 된 비밀번호를 데이터베이스에 저장하는 방식이 대부분이다.

해시 알고리즘은 어떤 길이의 값을 입력하더라도 고정된 길이의 결괏값을 반환하는 알고리즘이다.
게다가 입력값이 같다면 결괏값은 항상 일치한다.

예를 들어 SHA256라는 해시 알고리즘으로 1을 변환하면 아래와 같은 문자열이 된다.

6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b

매우 길고 복잡하다.
1을 넣으면 항상 저 문자열이 결과로 도출된다.

11을 넣으면 전혀 다른 문자열이 나온다.

4fc82b26aecb47d2868c4efbe3581732a3e7cbcc6c2efb32062c08170a05eeb8

하지만 길이는 같다는 것을 알 수 있다.

이렇게 입력값이 달라지면 길이는 고정되지만 전혀 다른 결괏값을 반환하는 알고리즘을 해시 알고리즘,
이를 기반으로 동작하는 함수를 해시 함수라고 부른다.

전혀 다른 입력값을 주더라도 같은 결과가 나오는 경우가 있지만, 상관 없다.
그렇기 때문에 역추적이 불가능해지기 때문이다.

예를 들어 오직 1을 넣었을 때만 123이라는 값으로 암호화된다면, 123을 보고 1이라는 원래값을 역추적 할 수 있다.
하지만 1, 77777, 99999999, 31313131 등의 값이 모두 123이 된다면 역추적 결과를 100% 확신할 수 없을 것이다.

실제 암호는 특수문자와 영문자 대소문자 등을 숫자와 섞고, 비밀번호 최소 자리수도 제한하는 경우가 많기 때문에 경우의 수가 저보다 훨씬 더 많을 것이다.

A -> B로 암호화는 가능하지만 B -> A로 복호화는 불가능하기에 단방향 함수라고도 불린다.

대부분의 비밀번호 암호화에 사용되는 해시 함수에 대해서 대략적으로 알아 보았다.

JS에서는 암호화를 할 때, bcrypt라는 모듈을 주로 사용한다.
npm i bcrypt로 해당 모듈을 다운로드 하자.

const bcrypt = require('bcrypt');

async function createUser(newUser) {
    const hashedPassword = await bcrypt.hash(newUser.password, 10);
    const users = await fetchAllUsers();
    users.push({
        ...newUser,
        password: hashedPassword,
    });
    await fs.writeFile(USERS_JSON_FILENAME, JSON.stringify(users));
}

모듈을 불러오고 사용자를 만들 때, 입력된 비밀번호를 암호화해서 users.json에 저장한다.
암호화는 bcrypt.hash()라는 함수에 사용자가 입력한 비밀번호를 인자로 전달하여 진행되는데, 두번째 인자는 salt라고 부르는 값으로, 암호화 속도가 너무 빠르면 해커들이 무차별 대입을 통해 임의로 비밀번호를 대입해보며 해킹을 시도할 수 있기 때문에 salt 값이 클수록 여러 번 해시 함수를 돌려서 암호화 속도를 늦춰준다고 생각하면 된다.

일부러 연산 속도를 늦추기 때문에 async 함수로 만들어서 백그라운드에서 돌리게 설계되었다.
만약 bcrypt.hash 연산이 연속적으로 일어나서 서버가 블로킹되어 다른 사용자들이 응답을 받지 못하고 기다려야 할 수 있기 때문이다.

// users.json
[]

users.json을 빈 배열로 만들고 서버를 재실행하여 회원가입을 진행해보자.

늘 만들던 그대로다.

// users.json
[
	{
		"username": "myID",
		"name": "abc",
		"password": "$2b$10$Pk7nO7WTNk8CWpGyMbDnCeeMvcwTQ65TXe6YujfJaBb.b79KjwAku"
	}
]

쿠키에는 비밀번호가 그대로 1로 저장되었지만, 우리의 데이터베이스, users.json에는 $2b$10$Pk7nO7WTNk8CWpGyMbDnCeeMvcwTQ65TXe6YujfJaBb.b79KjwAku라는 해시 함수로 암호화 된 비밀번호가 저장되었다.

이제 로그인을 하면 우리는 비밀번호로 1을 입력하겠지만, 1과 암호화 된 값을 비교하면 일치하지 않기 때문에 로그인에 실패할 것이다.

로그인 로직도 알맞게 바꿔주자.

app.post('/login', async (req, res) => {
    const { username, password } = req.body;
    const user = await fetchUser(username);

    // 가입 안 된 username인 경우
    if (!user) {
        res.status(400).send(`not registered username: ${username}`);
        return;
    }
    // 비밀번호가 틀렸을 경우
    const matchPassword = await bcrypt.compare(password, user.password);
    if (!matchPassword) {
        res.status(400).send('incorrect password');
        return;
    }

    // db에 저장된 user 객체를 문자열 형태로 변환하여 쿠키에 저장
    res.cookie(USER_COOKIE_KEY, JSON.stringify(user));
    // 로그인(쿠키 발급) 후, 루트 페이지로 이동
    res.redirect('/');
});

bcrypt.compare() 함수로 사용자가 입력한 비밀번호와 users.json에 저장된 비밀번호를 비교하는 함수다.
1을 입력하면 자동으로 해싱, 비교해서 일치하면 true를 반환한다.

서버를 재시작하고 로그아웃 해보자.

아이디와 비밀번호를 입력하고 다시 로그인한다.

회원가입 할 때는 form에 입력한 비밀번호를 그대로 쿠키에 저장했기 때문에 users.json에는 해싱된 비밀번호가 저장되어도 화면에는 1이 출력되었는데, 로그인 할 때는 users.json에 저장된 비밀번호를 가져와서 쿠키에 저장하기 때문에 암호화 된 비밀번호가 출력되었다.

async function removeUser(username, password) {
    const user = await fetchUser(username);
    const matchPassword = await bcrypt.compare(password, user.password);
    if (matchPassword) {
        const users = await fetchAllUsers();
        const index = users.findIndex(u => u.username === username);
        users.splice(index, 1);
        await fs.writeFile(USERS_JSON_FILENAME, JSON.stringify(users));
    }
}

계정을 삭제할 때도 비밀번호를 비교하는 로직이 있기 때문에 bcrypt.compare()를 사용하도록 수정하자.
방금 작업한 것과 거의 똑같다.

이제 users.json이 털리더라도 비밀번호는 $2b$10$Pk7nO7WTNk8CWpGyMbDnCeeMvcwTQ65TXe6YujfJaBb.b79KjwAku라는 임의의 해시값이 들어있기 때문에 해커가 이 정보를 다른 서비스에 입력해도 아무런 가치가 없을 것이다.

다른 서비스에 저 값을 비밀번호로 넣으면 다시 한 번 해싱되어 또 다른 알 수 없는 해시값이 될 뿐이다.

허술했던 보안이 약간은 발전되었다.


2. JWT 인증 구현하기

users.json가 털려도 원래 비밀번호를 알 수 없긴 하겠지만,
현재는 쿠키에 사용자가 모든 정보가 들어가기 때문에 굳이 users.json에 접근하지 않더라도 클라이언트의 컴퓨터만 털리면 모든 정보가 털리게 된다.

이를 수정해보자.

사용자 정보 대신, 쿠키에 JWT라는 것을 저장해서 사용자를 구분하도록 서버를 리팩토링 할 계획이다.

JWT는 Json Web Token의 약자로, Header, Payload, Signature 세 부분으로 이루어져 있다.

위 사진의 우측을 보면 Header에는 알고리즘, 토큰 타입이 들어간다.
Payload는 Subject(인증된 사용자를 가리키는 ID 등의 고유 식별자), Name(사용자 이름), Issued At(발급된 시간 Epoch Time 형태)으로 구성되어 있으며,
SignatureHeader, Payload를 Base64로 인코딩하고 Header에 기재된 해시 알고리즘에 따라 해싱을 한 뒤, 나중에 우리가 입력할 Secret을 통해 Sign한다.

말이 어렵지만 결국 SignatureHeaderPayload, 그리고 차후 넣어줄 Secret에 의해 변화하고 암호화 된다.

주로 데이터를 입력할 곳은 Payload 부분인데, 간단하게 사용자의 ID 정도만 입력해서 탈취당하더라도 큰 보안 문제가 발생하지 않도록 만들 것이다.

정리하면 알고리즘, 토큰 종류가 담긴 Header + 사용자 정보를 간단하게 입력할 Payload를 합치고 암호화해서 사진 왼쪽에 보이는 기다란 임의의 문자열이 생성된다.

마치 해시 함수로 비밀번호를 암호화 하는 것과 비슷한 모양이다.

저 임의의 문자열을 쿠키에 저장할 것이다.

사람이 봐도 내용을 알 수 없기 때문에 정보를 그대로 저장하는 것보다 훨씬 안전하다.

사용자가 서버에 요청을 보낼 때마다 쿠키도 함께 전달되는데, 서버에서 쿠키를 파싱해서 저 문자열을 받아온 다음 Payload를 추출해서 요청을 보낸 사용자가 누구인지 알 수 있다. Payload에 username을 저장할 것이기 때문이다.

여기까지 대략적인 그림을 그려보았다.

이제 코드로 작성해보도록 하자.

npm i jsonwebtoken

그 전에 jsonwebtoken 모듈을 다운로드 받자.

const jwt = require('jsonwebtoken');

// 위에서 본 Secret
const JWT_SECRET = 'secret';

// 토큰 발급
function generateToken(username) {
    const token = jwt.sign({
      	// 사용자 아이디 Payload에 저장
        username,
      	// Milliseconds(1000분의 1초 단위)
      	// 60초 뒤에 토큰 만료
        exp: Date.now() + 1000 * 60,
    }, JWT_SECRET);

    return token;
}

function verifyToken(token) {
  	// 임의의 문자열로 구성된 토큰을 Payload로 되돌림
    const decoded = jwt.verify(token, JWT_SECRET);
    
    // exp가 현재 시간 이전인 경우(만료된 토큰)
    if (decoded.exp < Date.now()) {
        return null;
    }
    // 토큰이 유효한 경우 사용자 ID 반환
  	// 이 ID로 users.json에서 사용자 찾아서 정보 렌더링 가능
    return decoded.username;
}

원래 Secret은 보안상 외부로 유출되면 안되기 때문에 절대로 소스 코드에 포함시키면 안된다.
보통은 .env 등의 파일에 저장해서 실행 시 주입시키는 방식으로 사용하지만, 그 부분은 생략하고 진행할 것이다.

보안상 실제로 이렇게 사용하면 안 된다는 사실만 알아두자.

위 코드에서 토큰을 발급하는 함수와 토큰을 검증해서 유효한 토큰이면 사용자 username을 반환하는 함수 2가지를 작성했다.

jwt.sign()의 첫번째 인자로 주어지는 객체가 위에서 본 Payload 부분인데, 사용자 정보는 username 하나만 입력된다.
expexpiresIn의 약자로, 언제 토큰이 만료될 것인지에 대한 정보다.
1000 * 60 Milliseconds, 즉 60초 뒤에 토큰이 만료되도록 설정했다.

토큰이 유효하더라도 현재 시간과 비교해서 만료 시간이 이미 지나버렸다면 사용자의 username이 아닌 null을 반환할 것이다.

주석에 설명을 다 해두었기 때문에 코드를 읽어보면 쉽게 이해할 수 있을 것이다.

// Auth Middleware
app.use((req, res, next) => {
  	// 쿠키를 추출(이제 USER_COOKIE_KEY 쿠키의 VALUE로 token을 저장할 것임)
    const token = req.cookies[USER_COOKIE_KEY];
  	// 쿠키가 존재하면 유효한 토큰인지, 만료 기한인 1분이 안지났는지 검증
    if (token) {
        const username = verifyToken(token);
      	// 완벽히 유효한 토큰인 경우 req.username에 반환 받은 username 저장
        if (username !== null) {
            req.username = username;
        }
    }
    next();
});

그리고 미들웨어를 하나 작성했다.
이전에는 사용자 정보가 필요할 때마다 파싱된 쿠키 VALUE를 가져오고, 이는 문자열이기 때문에 다시 JS 객체로 변환하고, 거기서 username을 추출해서 fetchUser()로 사용자 정보를 users.json에서 가져왔다.

나중에 사용자 정보가 필요한 라우터가 수십, 수백 개가 된다면 쓸데없이 반복적인 작업을 하는 것이 되기 때문에 미들웨어를 만들어서 이 작업을 대신 해주도록 하겠다.

여기서 새로운 개념이 등장한다.
req.username = username; 부분이다.

[A, B, C] 순서로 미들웨어, 라우터가 실행될 때,
미들웨어 A에서 req.username = username;을 실행한다면, 다음 미들웨어를 실행할 때도 req.username에 접근해서 이전 미들웨어에서 저장한 값을 읽어올 수 있다.

그래서 토큰이 유효하면 사용자의 usernamereq 객체 안에 존재하는 상태가 될 것이다.
이후 어떤 미들웨어, 라우터라도 req.username만 검사하면 매우 간단하게 로그인 유무를 확인할 수 있게 된 것이다.

app.get('/', async (req, res) => {
    // 로그인이 되어 req.username이 존재한다면
    if (req.username) {
        // users.json에서 사용자 정보를 가져온다
        const user = await fetchUser(req.username);
        // 사용자가 존재한다면
        if (user) {
            // JS 객체로 변환된 user 데이터에서 username, name, password를 추출하여 클라이언트에 렌더링
            res.status(200).send(`
                <a href="/logout">Log Out</a>
                <a href="/withdraw">Withdraw</a>
                <h1>id: ${user.username}, name: ${user.name}, password: ${user.password}</h1>
            `);
            return;
        }
    }

    // 쿠키가 존재하지 않는 경우, 로그인 되지 않은 것으로 간주
    res.status(200).send(`
        <a href="/login.html">Log In</a>
        <a href="/signup.html">Sign Up</a>
        <h1>Not Logged In</h1>
    `);
});

이제 GET / 라우터는 req.username만 검사하면 된다.

app.post('/signup', async (req, res) => {
    const { username, name, password } = req.body;
    const user = await fetchUser(username);

    // 이미 존재하는 username일 경우 회원 가입 실패
    if (user) {
        res.status(400).send(`duplicate username: ${username}`);
        return;
    }

    // 아직 가입되지 않은 username인 경우 db에 저장
    // KEY = username, VALUE = { name, password }
    const newUser = {
        username,
        name,
        password,
    };
    await createUser({
        username,
        name,
        password,
    });

    // username을 Payload로 갖는 토큰 생성
    const token = generateToken(newUser.username);
    // 토큰을 쿠키에 저장
    res.cookie(USER_COOKIE_KEY, token);
    // 가입 완료 후, 루트 페이지로 이동
    res.redirect('/');
});

POST /signup은 회원가입을 위해 여전히 form으로부터 입력받은 값을 req.body에서 읽어와야 한다.

그러나 이제 회원가입 완료 후 자동 로그인을 위한 쿠키를 발급할 때, generateToken()으로 토큰을 생성하여 USER_COOKIE_KEY 쿠키의 VALUE로 토큰을 넣어주도록 변경되었다.

app.post('/login', async (req, res) => {
    const { username, password } = req.body;
    const user = await fetchUser(username);
    // 가입 안 된 username인 경우
    if (!user) {
        res.status(400).send(`not registered username: ${username}`);
        return;
    }
    // 비밀번호가 틀렸을 경우
    const matchPassword = await bcrypt.compare(password, user.password);
    if (!matchPassword) {
        res.status(400).send('incorrect password');
        return;
    }

    // username을 Payload로 갖는 토큰 생성
    const token = generateToken(user.username);
    // 토큰을 쿠키에 저장
    res.cookie(USER_COOKIE_KEY, token);
    // 로그인 후, 루트 페이지로 이동
    res.redirect('/');
});

POST /login도 회원가입 부분과 거의 비슷하게 아랫쪽 코드에서 쿠키의 VALUE로 토큰을 주도록 변경되었다.

async function removeUser(username) {
    const users = await fetchAllUsers();   
    const index = users.findIndex(u => u.username === username);
    users.splice(index, 1);
    await fs.writeFile(USERS_JSON_FILENAME, JSON.stringify(users));
}

app.get('/withdraw', async (req, res) => {
    // 로그인이 되어 있는 경우만 회원 탈퇴 가능
    if (req.username) {
        await removeUser(req.username);
        res.clearCookie(USER_COOKIE_KEY);
        res.redirect('/');
    }
});

로그아웃을 위한 GET /withdraw 라우터에서는 req.username으로 로그인 여부를 확인하도록 변경했다.
로그인이 되어 있는 경우에만 회원 탈퇴가 가능하기 때문.

로그인이 되어 있다면 req.usernameremoveUser() 인자로 호출하여 users.json에서 사용자를 제거한다.

원래 removeUser()는 아이디, 비밀번호를 모두 받아서 비밀번호를 대조하는 로직이 들어가 있었는데, 아이디만 받아서 제거하는 방식으로 함수를 수정하였다.



회원가입, 로그아웃, 로그인, 회원 탈퇴 기능이 모두 정상적으로 동작하는 것을 확인했다.
1분 뒤 토큰이 만료되도록 설정했기 때문에, 로그인 1분 뒤 새로고침하면 자동 로그아웃이 된 것을 확인할 수 있을 것이다.

전체 코드는 아래에 첨부했다.

const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');

const app = express();

const USER_COOKIE_KEY = 'USER';
const USERS_JSON_FILENAME = 'users.json';
const JWT_SECRET = 'secret';

// DATABASE
async function fetchAllUsers() {
    const data = await fs.readFile(USERS_JSON_FILENAME);
    const users = JSON.parse(data.toString());
    return users;
}

async function fetchUser(username) {
    const users = await fetchAllUsers();
    const user = users.find((user) => user.username === username);
    return user;
}

async function createUser(newUser) {
    const hashedPassword = await bcrypt.hash(newUser.password, 10);
    const users = await fetchAllUsers();
    users.push({
        ...newUser,
        password: hashedPassword,
    });
    await fs.writeFile(USERS_JSON_FILENAME, JSON.stringify(users));
}

async function removeUser(username) {
    const users = await fetchAllUsers();   
    const index = users.findIndex(u => u.username === username);
    users.splice(index, 1);
    await fs.writeFile(USERS_JSON_FILENAME, JSON.stringify(users));
}

// TOKEN
function generateToken(username) {
    const token = jwt.sign({
        username,
        exp: Date.now() + 1000 * 60,
    }, JWT_SECRET);

    return token;
}

function verifyToken(token) {
    const decoded = jwt.verify(token, JWT_SECRET);
    
    // exp가 현재 시간 이전인 경우(만료된 토큰)
    if (decoded.exp < Date.now()) {
        return null;
    }
    return decoded.username;
}

// MIDDLEWARES
app.use(express.static(path.join(__dirname, 'public')));
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));

// Auth Middleware
app.use((req, res, next) => {    
    const token = req.cookies[USER_COOKIE_KEY];
    if (token) {
        const username = verifyToken(token);
        if (username !== null) {
            req.username = username;
        }
    }
    next();
});

// ROUTERS
app.get('/', async (req, res) => {
    // 로그인이 되어 req.username이 존재한다면
    if (req.username) {
        // users.json에서 사용자 정보를 가져온다
        const user = await fetchUser(req.username);
        // 사용자가 존재한다면
        if (user) {
            // JS 객체로 변환된 user 데이터에서 username, name, password를 추출하여 클라이언트에 렌더링
            res.status(200).send(`
                <a href="/logout">Log Out</a>
                <a href="/withdraw">Withdraw</a>
                <h1>id: ${user.username}, name: ${user.name}, password: ${user.password}</h1>
            `);
            return;
        }
    }

    // 쿠키가 존재하지 않는 경우, 로그인 되지 않은 것으로 간주
    res.status(200).send(`
        <a href="/login.html">Log In</a>
        <a href="/signup.html">Sign Up</a>
        <h1>Not Logged In</h1>
    `);
});

app.post('/signup', async (req, res) => {
    const { username, name, password } = req.body;
    const user = await fetchUser(username);

    // 이미 존재하는 username일 경우 회원 가입 실패
    if (user) {
        res.status(400).send(`duplicate username: ${username}`);
        return;
    }

    // 아직 가입되지 않은 username인 경우 db에 저장
    // KEY = username, VALUE = { name, password }
    const newUser = {
        username,
        name,
        password,
    };
    await createUser({
        username,
        name,
        password,
    });

    // username을 Payload로 갖는 토큰 생성
    const token = generateToken(newUser.username);
    // 토큰을 쿠키에 저장
    res.cookie(USER_COOKIE_KEY, token);
    // 가입 완료 후, 루트 페이지로 이동
    res.redirect('/');
});



app.post('/login', async (req, res) => {
    const { username, password } = req.body;
    const user = await fetchUser(username);
    // 가입 안 된 username인 경우
    if (!user) {
        res.status(400).send(`not registered username: ${username}`);
        return;
    }
    // 비밀번호가 틀렸을 경우
    const matchPassword = await bcrypt.compare(password, user.password);
    if (!matchPassword) {
        res.status(400).send('incorrect password');
        return;
    }

    // username을 Payload로 갖는 토큰 생성
    const token = generateToken(user.username);
    // 토큰을 쿠키에 저장
    res.cookie(USER_COOKIE_KEY, token);
    // 로그인 후, 루트 페이지로 이동
    res.redirect('/');
});

app.get('/withdraw', async (req, res) => {
    // 로그인이 되어 있는 경우만 회원 탈퇴 가능
    if (req.username) {
        const user = await fetchUser(req.username);
        await removeUser(user.username, user.password);
        res.clearCookie(USER_COOKIE_KEY);
        res.redirect('/');
    }
});

app.get('/logout', (req, res) => {
    // 쿠키 삭제 후 루트 페이지로 이동
    res.clearCookie(USER_COOKIE_KEY);
    res.redirect('/');
});

app.listen(3000, () => {
    console.log('server is running at 3000');
});

0개의 댓글