본문 바로가기

ComputerScience/NodeJs

node - 10.1 웹 API 서버 만들기 (프로젝트 구조, jwt토큰)

728x90

더북(TheBook): Node.js 교과서 개정 2판

thebook.io

* 위 내용을 정리하였음

1 API 서버

- 만약 노드를 모바일 서버로 활용하고 싶다면 서버를 REST API 구조로 구성하면 된다.
- API : Application Programming Interface, 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점
- API를 통해 클라이언트가 서버를 통해 데이터 베이스로부터 필요한 기능들을 제공받을 수 있다.
- 물론 열어둔 API는 인증된 사용자만 일정 횟수 내로 접근하도록 할수도 있다.

- 이전까지는 웹서버에서 html을 응답으로 보내줬지만 모바일은 JSON으로 데이터만 받으면 될 것이다.
- 서버에 API를 올려서 URL을 통해 접근할 수 있도록 웹 API 서버를 만든다.

2 프로젝트 구조

- 다른 서비스에서 (ex. localhost:4000) 우리의 메인 서버인 (localhost:8002)로부터 데이터를 받아올 수 있도록 해보자.

// package.json { "name": "rest-api-server", "version": "0.0.1", "description": "simple rest api server with express", "main": "app.js", "scripts": { "start": "nodemon app" }, "author": "jsdysw", "license": "ISC", "dependencies": { "bcrypt": "^5.0.0", "cookie-parser": "^1.4.5", "cors": "^2.8.5", "dotenv": "^8.2.0", "express": "^4.17.1", "express-rate-limit": "^5.1.3", "express-session": "^1.17.1", "jsonwebtoken": "^8.5.1", "morgan": "^1.10.0", "mysql2": "^2.1.0", "nunjucks": "^3.2.1", "passport": "^0.4.1", "passport-kakao": "^1.0.0", "passport-local": "^1.0.0", "sequelize": "^5.21.13", "uuid": "^8.1.0" }, "devDependencies": { "nodemon": "2.0.15" } }

- 이전 프로젝트에서 config. models, passport 폴더를 복사해서 넣는다.
- routes폴더에서는 auth와 middlewares만 그대로 사용한다.

// views/error.html <h1>{{message}}</h1> <h2>{{error.status}}</h2> <pre>{{error.stack}}</pre>
// app.js const express = require('express'); const path = require('path'); const cookieParser = require('cookie-parser'); const passport = require('passport'); const morgan = require('morgan'); const session = require('express-session'); const nunjucks = require('nunjucks'); const dotenv = require('dotenv'); dotenv.config(); const authRouter = require('./routes/auth'); const indexRouter = require('./routes'); const { sequelize } = require('./models'); const passportConfig = require('./passport'); const app = express(); passportConfig(); app.set('port', process.env.PORT || 8002); app.set('view engine', 'html'); nunjucks.configure('views', { express: app, watch: true, }); sequelize.sync({ force: false }) .then(() => { console.log('데이터베이스 연결 성공'); }) .catch((err) => { console.error(err); }); app.use(morgan('dev')); app.use(express.static(path.join(__dirname, 'public'))); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser(process.env.COOKIE_SECRET)); app.use(session({ resave: false, saveUninitialized: false, secret: process.env.COOKIE_SECRET, cookie: { httpOnly: true, secure: false, }, })); app.use(passport.initialize()); app.use(passport.session()); app.use('/auth', authRouter); app.use('/', indexRouter); app.use((req, res, next) => { const error = new Error(`${req.method} ${req.url} 라우터가 없습니다.`); error.status = 404; next(error); }); app.use((err, req, res, next) => { res.locals.message = err.message; res.locals.error = process.env.NODE_ENV !== 'production' ? err : {}; res.status(err.status || 500); res.render('error'); }); app.listen(app.get('port'), () => { console.log(app.get('port'), '번 포트에서 대기중'); });

- rest api 서버의 포트는 8002번으로 했다. 클라이언트인 새로운 서비스는 4000번에 열어놓고 rest api 서버에서 데이터를 가져올 것이다.
- 도메인 모델이 새로 등장한다. 인터넷 주소, 도메인 종류, 클라이언트 비밀키 가 들어있다.

// models/domain.js const Sequelize = require('sequelize'); module.exports = class Domain extends Sequelize.Model { static init(sequelize) { return super.init({ host: { type: Sequelize.STRING(80), allowNull: false, }, type: { type: Sequelize.ENUM('free', 'premium'), allowNull: false, }, clientSecret: { type: Sequelize.STRING(36), allowNull: false, }, }, { sequelize, timestamps: true, paranoid: true, modelName: 'Domain', tableName: 'domains', }); } static associate(db) { db.Domain.belongsTo(db.User); } };

- ENUM속성은 free, premium 둘중 한 값만 들어갈 수 있다.
- 클라이언트 비밀키는 다른 개발자들이 api를 사용할때 필요한 값이다. 유출되어서는 안된다.
- api를 호출할때 도메인과 비밀키가 동일할때만 호출을 허용해줄 것이다.
- 비밀키는 uuid로 충돌 가능성이 매우 적은 랜덤한 문자열이다.
- 사용자, 도메인은 일대다 관계를 맺는다.

// models/user.js const Sequelize = require('sequelize'); module.exports = class User extends Sequelize.Model { static init(sequelize) { return super.init({ email: { type: Sequelize.STRING(40), allowNull: true, unique: true, }, nick: { type: Sequelize.STRING(15), allowNull: false, }, password: { type: Sequelize.STRING(100), allowNull: true, }, provider: { type: Sequelize.STRING(10), allowNull: false, defaultValue: 'local', }, snsId: { type: Sequelize.STRING(30), allowNull: true, }, }, { sequelize, timestamps: true, underscored: false, modelName: 'User', tableName: 'users', paranoid: true, charset: 'utf8', collate: 'utf8_general_ci', }); } static associate(db) { db.User.hasMany(db.Post); db.User.belongsToMany(db.User, { foreignKey: 'followingId', as: 'Followers', through: 'Follow', }); db.User.belongsToMany(db.User, { foreignKey: 'followerId', as: 'Followings', through: 'Follow', }); db.User.hasMany(db.Domain); } };


- 이제 model/index.js에서 시퀄라이즈와 도메인 모델을 연결해준다.

// models/index.js const Sequelize = require('sequelize'); const env = process.env.NODE_ENV || 'development'; const config = require('../config/config')[env]; const User = require('./user'); const Post = require('./post'); const Hashtag = require('./hashtag'); const Domain = require('./domain'); const db = {}; const sequelize = new Sequelize( config.database, config.username, config.password, config, ); db.sequelize = sequelize; db.User = User; db.Post = Post; db.Hashtag = Hashtag; db.Domain = Domain; User.init(sequelize); Post.init(sequelize); Hashtag.init(sequelize); Domain.init(sequelize); User.associate(db); Post.associate(db); Hashtag.associate(db); Domain.associate(db); module.exports = db;
// views/login.html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>API 서버 로그인</title> <style> .input-group label { width: 200px; display: inline-block; } </style> </head> <body> {% if user and user.id %} <span class="user-name">안녕하세요! {{user.nick}}님</span> <a href="/auth/logout"> <button>로그아웃</button> </a> <fieldset> <legend>도메인 등록</legend> <form action="/domain" method="post"> <div> <label for="type-free">무료</label> <input type="radio" id="type-free" name="type" value="free"> <label for="type-premium">프리미엄</label> <input type="radio" id="type-premium" name="type" value="premium"> </div> <div> <label for="host">도메인</label> <input type="text" id="host" name="host" placeholder="ex) zerocho.com"> </div> <button>저장</button> </form> </fieldset> <table> <tr> <th>도메인 주소</th> <th>타입</th> <th>클라이언트 비밀키</th> </tr> {% for domain in domains %} <tr> <td>{{domain.host}}</td> <td>{{domain.type}}</td> <td>{{domain.clientSecret}}</td> </tr> {% endfor %} </table> {% else %} <form action="/auth/login" id="login-form" method="post"> <h2>NodeBird 계정으로 로그인하세요.</h2> <div class="input-group"> <label for="email">이메일</label> <input id="email" type="email" name="email" required autofocus> </div> <div class="input-group"> <label for="password">비밀번호</label> <input id="password" type="password" name="password" required> </div> <div>회원가입은 localhost:8001에서 하세요.</div> <button id="login" type="submit">로그인</button> </form> <script> window.onload = () => { if (new URL(location.href).searchParams.get('loginError')) { alert(new URL(location.href).searchParams.get('loginError')); } }; </script> {% endif %} </body> </html>

- 사용자는 첫 화면에서 도메인과 아이디, 비밀번호를 쳐야 한다.

// routes/index.js const express = require('express'); const { v4: uuidv4 } = require('uuid'); const { User, Domain } = require('../models'); const { isLoggedIn } = require('./middlewares'); const router = express.Router(); router.get('/', async (req, res, next) => { try { const user = await User.findOne({ where: { id: req.user && req.user.id || null }, include: { model: Domain }, }); res.render('login', { user, domains: user && user.Domains, }); } catch (err) { console.error(err); next(err); } }); router.post('/domain', isLoggedIn, async (req, res, next) => { try { await Domain.create({ UserId: req.user.id, host: req.body.host, type: req.body.type, clientSecret: uuidv4(), }); res.redirect('/'); } catch (err) { console.error(err); next(err); } }); module.exports = router;

- 허용된 도메인을 등록하는 것이다. 예를들면 앱을 설치한 기기에서만 api를 호출할 수 있도록 하는 것이다.
- uuid 패키지를 통해 랜덤 36자를 저장한다. (uuid 버전4사용)

const { v4: uuidv4 } = require('uuid');

- 이 부분은 v4라는 패키지를 uuidv4라는 이름으로 가져와서 쓰겠다는 것이다.

- 이제 localhost:8002로 결과를 확인해보자.

- 회원 로그인을 하면 도메인을 등록하도록 해야한다.
- 허용시켜준 도메인만 api를 호출할 수 있도록 해야 하기 때문이다.
- api로 요청을 하는 주소와 응답을 받는 주소가 반드시 동일하도록 해서 인증된 사용자와 인증된 곳에서만 서버에 접근할 수 있도록 한다.
- 카카오 로그인 구현을 위해 우리 서비스의 주소를 등록한 것도 같은 이유이다.
- 무료와 프리미엄은 나중에 api 호출 사용량을 제한하기 위한 구분 값이다.

- 도메인을 등록하고 발급받은 클라이언트 비밀키는 사용자가 꼭 가지고 있어야 한다.
- 카카오 로그인을 구현하기 위해서 카카오로부터 비밀키를 받은 것 처럼 개발자도 우리 서버가 제공하는 비밀키를 통해서 api를 호출할 수 있도록 해준다.

3 jwt 토큰으로 인증하기

- api를 제공하는 입장에서는 클라이언트의 별도 인증과정이 필요하다.
- 특히 jwt 토큰은 모바일 앱과 노드 서버 간에 사용자 인증을 구현할 때 자주 사용된다.
- JSON Web Token은 JSON형식의 데이터를 저장하는 토큰이다.

  • 헤더 : 토큰 종류, 해시 알고리즘 정보
  • 페이로드 : 토큰의 내용물이 인코딩 되어있다.
  • 시그니처 : 시그니처를 통해 토큰 변조 여부 확인 가능

- 시그니처는 JWT 비밀 키로 만들어진다. 시그니처 자체는 숨기지 않아도 되지만 비밀키는 반드시 숨겨야 한다.
- JWT에는 주인이 누구인지 권한은 무엇인지 내용이 담겨있다. 문자열을 해석해서 내용을 볼수도 있다.
- 비밀 키가 없으면 JWT는 변조가 불가능하기 때문에 믿고 신뢰할 수 있다.
- JWT의 시그니처를 보고 나만 아는 비밀키로 위조 여부를 판단한다.

- JWT는 용량이 크기 때문에 매 요청시 토큰이 오고 가면 비용이 많이 들 수 있다.
- 하지만 JWT가 아니라 그냥 랜덤 문자열로 사용자를 인증하려면 그 랜덤 문자열과 일치하는 사용자 아이디와 권한을 매번 탐색해야 할 것 이다.
- 두 비용중 저렴한 것을 사용하면 된다.

npm i jsonwebtoken

- 일단 jwt 모듈을 설치한다.
- 이제 본격적으로 API를 만들어 본다.
- 사용자가 API를 쓰려면 JWT 토큰을 발급 받고 인증받아야 한다.

// .env COOKIE_SECRET=nodebirdsecret JWT_SECRET= jwtSecret
// routes/middlewares.js const jwt = require('jsonwebtoken'); exports.isLoggedIn = (req, res, next) => { if (req.isAuthenticated()) { next(); } else { res.status(403).send('로그인 필요'); } }; exports.isNotLoggedIn = (req, res, next) => { if (!req.isAuthenticated()) { next(); } else { res.redirect('/'); } }; exports.verifyToken = (req, res, next) => { try { req.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET); return next(); } catch (error) { if (error.name === 'TokenExpiredError') { // 유효기간 초과 return res.status(419).json({ code: 419, message: '토큰이 만료되었습니다', }); } return res.status(401).json({ code: 401, message: '유효하지 않은 토큰입니다', }); } };

- 사용자가 쿠키처럼 요청 헤더에 토큰을 넣어 보낸다.
- 요청 헤더에 저장된 토큰을 비밀키로 검증하고 유효기간도 통과하면 토큰 내용을 decoded에 저장한다. next()를 호출

// routes/v1.js const express = require('express'); const jwt = require('jsonwebtoken'); const { verifyToken } = require('./middlewares'); const { Domain, User } = require('../models'); const router = express.Router(); router.post('/token', async (req, res) => { const { clientSecret } = req.body; try { const domain = await Domain.findOne({ where: { clientSecret }, include: { model: User, attribute: ['nick', 'id'], }, }); if (!domain) { return res.status(401).json({ code: 401, message: '등록되지 않은 도메인입니다. 먼저 도메인을 등록하세요', }); } const token = jwt.sign({ id: domain.User.id, nick: domain.User.nick, }, process.env.JWT_SECRET, { expiresIn: '1m', // 1분 issuer: 'nodebird', }); return res.json({ code: 200, message: '토큰이 발급되었습니다', token, }); } catch (error) { console.error(error); return res.status(500).json({ code: 500, message: '서버 에러', }); } }); router.get('/test', verifyToken, (req, res) => { res.json(req.decoded); }); module.exports = router;


- 토큰을 등록하는 라우터와 테스트 해볼 수 있는 라우터이다.
- 라우터 이름에 버전을 이름에 적었다. 새로운 버전이 등장하면 새로 만들고 이전 사용자들에게 공지해주어야 한다. 이런 용도로 버전별로 라우터를 엄격히 구분한다.

const token = jwt.sign({ id: domain.User.id, nick: domain.User.nick, }, process.env.JWT_SECRET, { expiresIn: '1m', // 1분 issuer: 'nodebird', });

- 이부분만 살펴보자. sign으로 토큰을 만든다. 첫번째 인수는 사용자 정보, 두번째 인수는 비밀키, 세번째 인수는 유효시간과 발급자 정보이다.
- 이제 이 라우터를 서버에 연결해보자.

// app.js const v1 = require('./routes/v1'); ...... app.use(passport.session()); app.use('/v1', v1); app.use('/auth', authRouter); ......

- 요즘에는 jwt토큰으로 로그인을 구현하는 방법도 많이 사용된다.
- 세션 쿠키를 대신 jwt 토큰을 발급한다. 즉 세션을 사용하지 않는다.
- 모든 라우터에서 verifyToken 미들웨어로 토큰을 검증만 하면 된다.

728x90
반응형