본문으로 바로가기

JWT의 이해


JWT는 JSON Web Token의 약자로 데이터가 JSON으로 이루어져 있는 토큰을 의미한다

두 개체가 서로 안전하게 정보를 주고 받을 수 있도록 웹 표준으로 정의된 기술

세션 기반 인증


서버쪽에서 사용자가 로그인 중임을 기억하는 방법

사용자가 로그인→ 서버에 있는 세션 저장소에 사용자의 정보를 조회 → 세션 id 발급 → 사용자가 요청을 보낼 때마다 서버는 세션 저장소에서 세션이 유효한지 확인 후 요청에 응답

토큰 기반 인증


사용자가 로그인 → 서버에서 사용자의 정보가 담긴 토큰을 지급 → 사용자가 요청을 토큰과 함께 보냄 → 서버는 해당 토큰이 유효한지만 검사하고 응답함

User 스키마 / 모델 만들기


Post 스키마와 모델을 만든 것과 동일하게 만들어 주면된다

import mongoose,{Schema} from 'mongoose';

const UserSchema = new Schema({
    username : String,
    hashedPassword : String,
});

const User = mongoose.model('User',UserSchema);

모델 메서드 만들기


모델 메서드는 말 그대로 모델에서 사용할 수 있는 함수를 말하며 두 가지 종류가 있다

  • 인스턴스 메서드 : 모델로 생성한 인스턴스에서 사용할 수 있는 메서드를 말한다

          const user = new User({ username : 'as', });
          user.setPassword('123');
  • 스태틱 메서드 : 인스턴스를 생성해주는 모델 그 자체에서 사용할 수 있는 메서드를 말한다

          const user = User.findByUsername('as');

인스턴스 메서드 만들기


비밀번호를 사용자 입력으로 받아서 텍스트로 저장하게되면 보안상 매우 위험해진다

그렇기 때문에 사용자의 입력을 받아서 bcrypt를 사용해서 비밀번호를 가공해주고 해당 모델에 가공한 비밀번호를 설정해주는 setPassword 메서드

비밀번호를 입력했을 때 위에서 설정한 비밀번호와 일치하는 지 확인하는 checkPassword 메서드

두가지를 만들어 보자

import mongoose,{Schema} from 'mongoose';
import * as bcrypt from "bcrypt";

const UserSchema = new Schema({
    username : String,
    hashedPassword : String,
});

UserSchema.methods.setPassword = async function(password){
    const hash = await bcrypt.hash(password,10);
    this.hashedPassword = hash;
}
UserSchema.methods.checkPassword = async function(password){
    const result = await bcrypt.compare(password,this.hashedPassword);
    return result;
}

const User = mongoose.model('User',UserSchema);

export default User;

인스턴스 메서드를 만들 때 this는 생성된 객체의 인스턴스를 가르켜야 하기 때문에 화살표 함수를 사용하면 제대로된 값을 얻지 못한다

스태틱 메서드 만들기


유저 이름으로 데이터를 찾을 수 있게 해주는 스태틱 메서드

...
UserSchema.methods.checkPassword = async function(password){
    const result = await bcrypt.compare(password,this.hashedPassword);
    return result;
};
UserSchema.statics.findByUsername = function (username){
    return this.findOne({username});
};

const User = mongoose.model('User',UserSchema);
...

회원 인증 API 만들기


회원 가입


export const register = async ctx=>{
    const schema = Joi.object().keys({
        username : Joi.string()
            .alphanum()
            .min(3)
            .max(20)
            .required(),
        password : Joi.string().required(),
    });
    const result = schema.validate(ctx.request.body);
    if(result.error){
        ctx.status = 400;
        ctx.body = result.error;
        return;
    }
    const {username, password} = ctx.request.body;
    try{
        const exists = await User.findByUsername(username);
        if(exists) {
            ctx.status = 409;
            return;
        }

        const user = new User({username});
        await user.setPassword(password);
        await user.save();

        ctx.body = user.serialize();
    }catch (e){
        ctx.throw(500,e);
    }
}

Post api를 구현할 때 다들 한 번씩 썻던 내용들이다

다른게 있다면 요청에 응답해 줄 때 비밀번호가 노출되지 않게 인스턴스 메서드 serialize를 호출했다는 점

UserSchema.methods.serialize = function (){
    const data = this.toJSON();
    delete data.hashedPassword;
    return data;
}

로그인


export const login = async ctx=>{
    const {username, password} = ctx.request.body;
    if(!username || !password) {
        ctx.status = 401;
        return;
    }
    try {
        const user = await User.findByUsername(username);
        if(!user) {
            ctx.status = 401;
            return;
        }
        const valid = await user.checkPassword(password);
        if(!valid) {
            ctx.status = 401;
            return;
        }
        ctx.body = user.serialize();
    } catch (e){
        ctx.throw(500,e);
    }
}

아이디와 비밀번호가 유효하지 않으면 에러

중복된 아이디가 있다면 에러

비밀번호가 유효하지 않다면 에러

토큰 발급 및 검증하기


토큰 발급을 위해서는 jsonwevtoken이라는 라이브러리를 이용한다

JWT에서 토큰 서명을 만들 때 비밀키가 필요한데 그 비밀키는 외부로 노출되어선 안되니 환경변수로 설정해서 변수를 하나 저장하자

// src/.env
PORT=4000
MONGO_URI=mongodb://localhost:27017/blog
JWT_SECRET= /*문자열*/

토큰 발급


UserSchema.methods.generateToken = function (){
    const token = jwt.sign(
        {
        _id : this.id,
        username : this.username,
        },
        process.env.JWT_SECRET,
        {
            expiresIn: '7d',
        },
    );
    return token;
}

유저 모델의 인스턴스 메서드로 만들어 주면 되는데

jsonwebtoken 라이브러리의 jwt를 이용해서 만든다

첫 번째 파라미터로는 넣을 데이터를,

두 번째 파라미터로는 방금 환경 변수로 설정한 JWT_SECRET인 비밀키를,

세 번째 파라미터로는 유효기간을 설정해줬는데 객체로 들어간 것을 보면 아마 다른 옵션들이 더 존재하는 것 같다

토큰 발급 함수를 만들었으니 이제 사용자에게 발급해주어야하는데

register와 login 요청이 왔을 때 사용자의 쿠키에 토큰을 담아서 응답하게 두 함수를 수정한다

// auth.ctrl.js - register
...
                const user = new User({username});
        await user.setPassword(password);
        await user.save();

        ctx.body =user.serialize();

        **const token = user.generateToken();
        ctx.cookies.set('access_token',token,{
            maxAge : 1000*60*60*24*7,
            httpOnly : true,
        });**

    }catch (e){
        ctx.throw(500,e);
    }
}
// auth.ctrl.js - join
...

                ctx.body = user.serialize();

        **const token = user.generateToken();
        ctx.cookies.set('access_token',token,{
            maxAge : 1000*60*60*24*7,
            httpOnly : true,
        });**

    } catch (e){
        ctx.throw(500,e);
    }
}

방금 만들어 준 generateToken메서드를 사용해서 토큰을 발급받고

토큰과 유효기간 그리고 옵션을 설정해서 쿠키에 access_token이라는 이름으로 보내주었다

토큰 검증하기


사용자가 토큰을 가지고 있는지 (로그인 상태인지) 확인하는 작업을 구현해야하는데

미들웨어를 통해서 구현할 것

// in src/lib/jwtMiddleware.js
import * as jwt from "jsonwebtoken";

const jwtMiddleware = (ctx,next)=>{
    const token = ctx.cookies.get('access_token');
    if(!token) {
        return next();
    }
    try{
        const decoded = jwt.verify(token,process.env.JWT_SECRET);
        ctx.state.user = {
            _id : decoded._id,
            username : decoded.username
        }
        console.log(decoded);
        return next();
    }catch (e){
        return next();
    }
}
export default jwtMiddleware;

쿠키에서 access_toke의 값을 받아와서 값이 존재한다면 state.user에 받아온 값을 디코딩해서 객체로 저장하고

그렇지 않다면 다음 미들웨어를 실행시키는 미들웨어다

...
app.use(bodyParser());
app.use(jwtMiddleware);
app.use(router.routes()).use(router.allowedMethods());
...

미들웨어를 적용할 땐 순서를 조심해야하는데 토큰 검증을 먼저한 후에 router 작업을 해야하기 때문에 router의 상단에 위치할 수 있도록 하자

그리고 check api를 구현한다

export const check = async ctx=>{
    const {user} = ctx.state;
    if(!user) {
        ctx.status = 401;
        return;
    }
    ctx.body = user;
}

ctx.state에서 user를 가져오는데 값이 있다면 응답 그렇지 않다면 에러를 발생시킨다

토큰 재발급


토큰은 유효기간이 있기 때문에 기간이 다 되어가면 재발급을 해주어야한다

jwtmiddleware의 토큰을 검증하는 과정에서 기간이 3.5일 미만이면 재발급 해주는 기능을 구현하자

const decoded = jwt.verify(token,process.env.JWT_SECRET);
        ctx.state.user = {
            _id : decoded._id,
            username : decoded.username
        }
        const now = Math.floor(Date.now()/1000);
        if(decoded.exp - now < 60 * 60 * 24 * 3.5){
            const user = await User.findById(decoded._id);
            const token = user.generateToken();
            ctx.cookies.set('access_token', token,{
                maxAge : 1000 * 60 * 60 * 24 * 7,
                httpOnly : true,
            });
        }
        return next();
    }catch (e){
        return next();
    }
}

로그아웃 기능 구현


그냥 쿠키에 발급했던 토큰을 지워주면 된다

export const logout = async ctx=>{
    ctx.cookies.set('access_token');
    ctx.status= 204;
}

posts API 회원 인증 시스템 도입


posts API에 회원 인증 단계를 구현하려면 우선 post Model들이 회원 정보를 가지고 있어야한다

모델에 유저 정보가 들어가기 위해서 우선 post 스키마에 유저 데이터를 넣어주자

const PostSchema = new Schema({
    title : String,
    body : String,
    tags : [String],
    publishedDate :{
        type : Date,
        default : Date.now,
    },
    **user : {
        _id : mongoose.Types.ObjectId,
        username : String
    }**
});

포스팅을 하거나 삭제 업데이트를 하기 위해선 로그인을 해야 하는게 일반적이다

그러한 api를 사용하기 전에 로그인을 했는지 검증하기 위해 미들웨어를 작성하고 적용시켜보자

const checkLoggedIn = (ctx,next) =>{
    if(!ctx.state.user) {
        ctx.status = 401;
        return ;
    }
    return next();
}

export default checkLoggedIn;

간단한데 ctx.state에 유저 정보가 있으면 다음 미들웨어를 실행시키고 그렇지 않으면 에러를 발생시킨다

posts.get('/',postsCtrl.list);
posts.post('/', checkLoggedIn,postsCtrl.write);
posts.get('/:id', postsCtrl.read);
posts.delete('/:id', checkLoggedIn, postsCtrl.remove);
posts.patch('/:id', checkLoggedIn, postsCtrl.update);

로그인이 필요한 api에만 checkLoggedIn 미들웨어를 붙여주자

그리고 스키마에 유저 정보를 추가해주었으니 post 모델을 작성할 때 들어가는 데이터도 업데이트 해주어야한다

const post  = new Post({
        title,
        body,
        tags,
        **user : ctx.state.user,**
    });
    try{
        await post.save();
        ctx.body = post;

포스트 수정, 삭제를 할 때 로그인만 되어있으면 만사 OK일까?

아니다 접근하려는 포스트가 내가 작성한 포스트인지 즉, 유효한 권한을 가지고 있는지 확인해야하는데

이를 구현하기 전에 필요한 미들웨어를 하나 구현하자

export const getPostById = async (ctx,next)=>{
    const {id} = ctx.params;
    if(!ObjectId.isValid(id)){
        ctx.status = 400;
        return ;
    }
    try{
        const post = await Post.findById(id);
        if(!post){
            ctx.status = 404;
            return;
        }
        ctx.state.post = post;
        return next();
    } catch (e){
        ctx.throw(500,e);
    }
};

url에서 id값을 받아온 뒤 id값이 유효한지 확인하고 post를 state에 저장해두고 다음 미들웨어를 실행시킨다

posts.get('/',postsCtrl.list);
posts.post('/', checkLoggedIn,postsCtrl.write);
posts.get('/:id', getPostById, postsCtrl.read);
posts.delete('/:id', checkLoggedIn, getPostById, postsCtrl.remove);
posts.patch('/:id', checkLoggedIn, getPostById, postsCtrl.update);

해당 id값을 가지고 url에 요청할 때 미들웨어에서 id값으로 포스트를 조회하고 ctx.state에 저장시켜둔다

이렇게 되면 기존에 있던 read api에서 id값으로 포스트를 조회하는 역할이 중복되기 때문에 필요가 없어진다

export const read = async ctx=>{
    ctx.body = ctx.state.post;
};

값들의 유효성 검사는 미들웨어에서 이미 다 통과한 후 state에 저장되어 있기 때문에 불러오기만 하면 된다

이제 권한 확인 미들웨어를 작성할 차례다

jwtMiddleware와 getPostById 미들웨어를 통해 정상적인 진행이 되었다면 ctx.state에는 현재 로그인한 user의 정보와 id로 조회한 post의 정보가 담겨있을 것이다

그리고 조회한 post에는 쓴 사용자의 id가 담겨있기 때문에 이 둘을 비교하는 것으로 권한 검증이 가능하다

export const checkOwnPost = (ctx,next) =>{
    const {user, post} = ctx.state;
    if(post.user._id.toString() !== user._id){
        ctx.status = 403;
        return ;
    }
    return next();
}