SNS 만들기 동작별 흐름 이해하기 -2(with Node, MySQL, Nunjucks) ★

백지연·2022년 2월 10일
1

NodeJS

목록 보기
20/26
post-thumbnail

이번에 다룰 내용은 로그인(로컬, 카카오)에 관한 내용인데, 개인적으로 sns를 만들면서 가장 코드를 이해하기 힘들었다. 따라서 이번 포스팅에서는 로그인만을 다루며 코드를 이해해보겠다!
사실 이 부분은 이전 포스팅에서도 한 번 다뤘었는데, 어떤 흐름으로 흘러가는지 정확히 하기 위해 이번 포스팅을 작성했다.

Github: https://github.com/delay-100/study-node/tree/main/ch9/sns5

이전 포스팅에서 파헤쳐 본 것

  1. 기본 module 세팅
  2. 전체 app.js 세팅
  3. 메인 페이지 이해하기 +layout.html 설명
  4. 회원가입 기능 이해하기

이번 포스팅에서 파헤쳐 볼 것

  1. 로컬 로그인 기능 이해하기
  2. kakao 로그인 기능 이해하기

Model은 SNS 만들기 -2에서 다뤘으므로 흐름 정리 포스팅에서는 다루지 않겠다. (model git 주소)


5. 로컬 로그인 기능 이해하기

  • 메인페이지 기본 실행 화면 (로컬 로그인 예시 작성 상태)

  • 메인페이지 로컬 로그인 후 실행 화면

해당 위치

layout.html 설명 中 로컬 로그인 버튼 내용

2. 로컬 로그인 폼
-> 로그인 버튼 클릭 시 type="submit"에 의해 form 태그의 action="/auth/login" 실행 +method="post"
-> routes/auth.js의 post("/login") 실행
-> http://127.0.0.1:8001/auth/login

Git [routes/auth.js] 中 /login

const express = require('express');
const passport = require('passport');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
const router = express.Router();

// 로컬 로그인 라우터, /auth/login
router.post('/login', isNotLoggedIn, (req, res, next) =>{
    passport.authenticate('local', (authError, user, info) => { // passport.authenticate('local') 미들웨어가 로컬로그인 전략(passport/localStrategy.js) 수행
                                                                // 미들웨어인데 라우터 미들웨어 안에 들어있음 - 미들웨어에 사용자 정의 기능을 추가하고 싶은 경우
        if(authError){ // 로그인 전략(동작)이 실패한 경우 - authError 에 값이 존재
            console.error(authError);
            return next(authError);
        }
        if(!user){  // 2번째 매개변수 값(user)이 존재하지 않는 경우 - db에 계정이 X
            return res.redirect(`/?loginError=${info.message}`);
        }
        // 2번째 매개변수 값(user)이 존재하는 경우 - passport가 req 객체에 login, logout 메서드를 추가함
        return req.login(user, (loginError) => { // req.login은 passport.serializeUser를 호출 - req.login에 제공하는 user 객체가 serializeUser로 넘어가게 됨
            if(loginError) {
                console.error(loginError);
                return next(loginError);
            }
            return res.redirect('/');
        });
    })(req, res, next); // 미들웨어 내의 미들웨어에는 (req, res, next)를 붙힘
});

=5-1> 1. isNotLoggedIn을 routes/middlewares에서 불러옴, 2. passport.authenticate('local', 미들웨어가 로컬 로그인 전략인 passport/localStrategy.js 실행, 3. 5-2 실행 결과(authError, user, info)의 인자로 대입 후 실행

5-1.1. isNotLoggedIn을 routes/middlewares에서 불러옴, 설명 생략 => 4-1.1. 참고

5-1.2. passport.authenticate('local', 미들웨어가 로컬 로그인 전략인 passport/localStrategy.js 실행

Git [passport/localStrategy.js]

// 로그인 전략
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy; // passport-local 모듈의 전략 생성자를 가져옴
const bcrypt = require('bcrypt');

const User = require('../models/user');

module.exports = () => {
    passport.use(new LocalStrategy({ // LocalStrategy 생성자의 첫 번째 인수(객체): 전략에 관한 설정
        usernameField: 'email', // body-parser에 의해 req.body 내의 속성명(req.body.email)
        passwordField: 'password',
    }, async (email, password, done) => { // LocalStrategy 생성자의 두 번째 인수(함수): 실제 전략 수행
                                         // 첫 번째 인수에서 넣어주었던 email, password가 여기의 매개변수가 됨
                                        // done은 routes/auth.js의 passport.authenticate의 콜백 함수
        try {
            const exUser = await User.findOne({ where: { email }}); // db에 일치하는 Email이 있는지 확인
            if(exUser){ // db에 일치하는 User가 있는 경우+
                const result = await bcrypt.compare(password, exUser.password); // bcrypt: 암호화 모듈, password: req(입력)의 매개변수, exUser.password: db에 저장되어 있는 password 
                if(result){ // 비밀번호까지 일치하는 경우
                    done(null, exUser); // routes/auth.js의 passport.authenticate('local', ~~) 에서 ~~부분에 들어가는 값이 done으로 반환됨
                } else { // 비밀번호 불일치
                    done(null, false, { message: '비밀번호가 일치하지 않습니다.'});
                }
            } else{ // 
                done(null, false, { message: '가입되지 않은 회원입니다.'});
            }
        } catch(error){
            console.error(error);
            done(error);
        }
    }));
};

=5-2> 5-2.1. db에 일치하는 User 존재, 비밀번호 일치(로그인 가능), 5-2.2. db에 일치하는 User가 존재, 비밀번호 불일치(로그인 불가/에러), 5-2.3. db에 일치하는 User가 미존재(로그인 불가/에러), 5-2.4. 에러가 난 경우

5-2.1. db에 일치하는 User 존재, 비밀번호 일치(로그인 가능)
-> done(null, exUser);

5-2.2. db에 일치하는 User가 존재, 비밀번호 불일치(로그인 불가/에러)
-> done(null, false, { message: '비밀번호가 일치하지 않습니다.'});

5-2.3. db에 일치하는 User가 미존재(로그인 불가/에러)
-> done(null, false, { message: '가입되지 않은 회원입니다.'});

5-2.4. 에러가 난 경우
-> done(error);

5-1.3. 5-2 실행 결과(authError, user, info)의 인자로 대입 후 실행

5-1.3.1. 5-2.4.가 인자로 온 경우
-> authError = error
-> console.error(authError); (+console.error(error) 설명)
-> return next(authError);
-> app.js의 아래쪽에 선언되어 있는 err 매개변수가 있는 곳으로 이동

Git [app.js] 中 에러 관련 함수

// 에러 관련 함수
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');
});

5-1.3.2. 5-2.2., 5-2.3.가 인자로 온 경우
-> authError = null, user = false, info = { message: ~~ }
-> db에 User가 없음
-> return res.redirect(`/?loginError=${info.message}`);
-> redirect 내의 주소가 /이므로 routes/page.js의 get('/') 부분이 실행된 후, views/main.html이 실행됨 (3-1 참고)
-> /?loginError=${info.message}도 주소에 들어감

Git [views/main.html] 中 script 부분

<script>
window.onload = () =>{
                if (new URL(location.href).searchParams.get('loginError')){ 
                    alert(new URL(location.href).searchParams.get('loginError')); 
                }
            };
</script>

=5-3> URL에서 loginError이 있는지 확인 후 있으면 경고창(alert) 표시

5-1.3.3. 5-2.1.가 인자로 온 경우
-> authError = null, user = exUser
-> req.login(user, (loginError) => {~~} 부분 실행
+req.login(user,은 passport가 req에 login을 추가해서 실행 가능한 것!
-> req.login은 passport/index.jsserializeUser 실행시킴
-> 5-4.1. 부분이 실행되고 return res.redirect('/');부분이 실행됨
-> res.redirect('/')에 의해 routes/page.js의 get /로 이동
-> http://127.0.0.1:8001 로 이동됨

Git [passport/index.js]

const passport = require('passport');
const local = require('./localStrategy');
const kakao = require('./kakaoStrategy');
const User = require('../models/user');

module.exports = () => {

    // 세션에 불필요한 데이터를 담아두지 않기 위한 과정들(serializeUser, deserializeUser)

    // serializeUser: 사용자 정보 객체를 세션에 아이디로 저장
    passport.serializeUser((user,done) => { // serializeUser: 로그인 시 실행됨, req.session(세션) 객체에 어떤 데이터를 저장할지 정하는 메서드
        done(null, user.id); // done 첫 번째 인수: 에러 발생 시 사용, done 두 번째 인수: 저장하고 싶은 데이터를 넣음
                             // user.id만 저장한 이유: 세션에 user의 모든 정보를 저장하면 서버의 용량이 낭비되기 때문
    });
    
    // deserializeUser: 세션에 저장한 아이디를 통해 사용자 정보 객체를 불러옴
    // passport.session 미들웨어가 이 메소드를 호출
    // 라우터가 실행되기 전 먼저 실행됨! -> 모든 요청이 들어올 때 매번 사용자의 정보를 조회함(db에 큰 부담 -> 메모리에 캐싱 또는 레디스 같은 db 사용)
    passport.deserializeUser((id, done) => { // deserializeUser: 매 요청 시 실행, id: serializerUser의 done으로 id 인자를 받음
        User.findOne({
            where:{id}, // db에 해당 id가 있는지 확인
            include: [{
                model: User,
                attributes: ['id', 'nick'], // 속성을 id와 nick으로 지정함으로서, 실수로 비밀번호를 조회하는 것을 방지
                as: 'Followers',
            }, {
                model: User,
                attributes: ['id', 'nick'],
                as: 'Followings',
            }],
        }) 
        .then(user => done(null, user)) // req(요청).user에 저장 -> 앞으로 req.user을 통해 로그인한 사용자의 정보를 가져올 수 있음
        .catch(err => done(err));
    });     

    local();
    kakao();
};

=5-4> 1. done(null, user.id); 실행 후 반환 2. deserializeUser 실행, 3. passport/index.jslocal();로 인해 5-1.2. 실행해 전략 확인

+passport 관련 app.js

const passport = require('passport');

const passportConfig = require('./passport'); // require('./passport/index.js')와 같음
passportConfig(); // 패스포트 설정, 한 번 실행해두면 ()에 있는 deserializeUser 계속 실행 - passport/index.js

// passport 사용 - req.session 객체는 express-session에서 생성하므로 express-session 뒤에 작성해야함
app.use(passport.initialize()); // 요청(req 객체)에 passport 설정을 심음
app.use(passport.session()); // req.session 객체에 passport 정보를 저장(요청으로 들어온 세션 값을 서버에 저장한 후, passport 모듈과 연결)

6. kakao 로그인 기능 이해하기

  • 카카오 로그인 화면 (메인 페이지에서 카카오톡 클릭 시)

  • 메인페이지 카카오 로그인 후 실행 화면

해당 위치

layout.html 설명 中 카카오 로그인 버튼 내용

3. 카카오톡 버튼
-> 카카오톡 버튼 클릭 시 href="/auth/kakao"에 의해 http://127.0.0.1:8001/auth/kakao 에 get 요청
-> routes/auth.js의 get("/kakao") 실행

Git [routes/auth.js] 中 /auth/kakao

// 카카오 로그인 라우터, /auth/kakao
router.get('/kakao', passport.authenticate('kakao')); // 카카오 api가 get으로 되어있어서 무조건 get으로 받아옴
                                                      // passport가 알아서 kakao 로그인 창으로 redirect 함

=6-1> passport가 kakao에 로그인 인증을 요청함 (카카오 로그인 화면으로 이동)

Git [routes/auth.js] 中 /auth/kakao/callback

// 카카오 로그인 후 성공 여부 결과를 받음                                                      
router.get('/kakao/callback', passport.authenticate('kakao', { // 카카오 로그인 전략을 다시 수행함
                                                              // 로컬 로그인과 다른 점: passport.authenticate 메서드에 콜백 함수를 제공하지 않음
                                                              // 로그인 성공 시 내부적으로 req.login을 호출함 (내가 직접 호출할 필요X)
    failureRedirect: '/', // failureRedirect 속성: 콜백 함수 대신 로그인에 실패했을 때 어디로 이동할지를 적음
}), (req, res) => { // 성공 시 어디로 이동할지 적는 미들웨어
    res.redirect('/'); 
});

=6-2> kakao에서 받은 인증이 유효하거나 유효하지 않아도 get /(routes/page.js) - http://127.0.0.1:8001/ 로 이동

+로그인 성공 시 내부적으로 req.login(5-1.3.3, 5-4 참고)을 실행시킨다.
여기서는 5-4.3.에서 local();이 아닌 kakao(); 에 영향을 받는다. 따라서 passport/kakaoStrategy.js가 실행된다.

Git [passport/kakaoStrategy.js]

// 카카오 로그인 전략
const passport = require('passport');
const KakaoStrategy = require('passport-kakao').Strategy; // passport-kakao 모듈로부터 Strategy 생성자를 불러와 전략 구현

const User = require('../models/user');

module.exports = () => {
    passport.use(new KakaoStrategy({  
        clientID: process.env.KAKAO_ID, // clientID: 전략 구현을 위해 카카오에서 발급해주는 id, 노출 방지로 .env 파일에서 관리함
        callbackURL: '/auth/kakao/callback', // callbackURL: 카카오로부터 인증 결과를 받을 라우터 주소
    }, async (accessToken, refreshToken, profile, done) => {
        console.log('kakao profile', profile);
        try { 
            const exUser = await User.findOne({
                where: {snsId: profile.id, provider: 'kakao'}, //  snsId: 카카오 아이디와 같은 지?, provider: 카카오에서 로그인했는지?
            }); // 기존에 카카오를 통해 회원가입한 사용자가 있는지 조회
            if(exUser){ // 이미 User로 존재하는 경우(회원가입이 이미 되어있는 경우)
                done(null, exUser); // 사용자 정보와 함께 done함수 호출
            } else { // user가 존재하지 않는 경우 - 회원가입 진행
                const newUser = await User.create({
                    email: profile._json && profile._json.kakao_account_email,
                    nick: profile.displayName,
                    snsId: profile.id,
                    provider: 'kakao',
                });
                done(null, newUser); // 새로운 유저와 함께 done 실행
            }
        } catch (error) {
            console.error(error);
            done(error);
        }
    }));
};

=6-3> 1. 회원가입이 이미 되어있는 경우, 2. 새로운 User로 생성 가능한 경우, 3. 에러가 난 경우

6-3.1. 회원가입이 이미 되어있는 경우
-> done(null, exUser);
-> 해당 유저를 반환

6-3.2. 새로운 User로 생성 가능한 경우
-> done(null, newUser);
-> db에 새로운 User를 생성 후 새로운 유저와 함께 반환

6-3.3. 에러가 난 경우
-> done(error);
-> console.error(error) 실행
+console.error(error) 설명

+ clientID: process.env.KAKAO_ID
kakao에 client ID 발급 받는 방법은 이 포스팅에 설명되어 있다.


전체 파일 구조


다음 포스팅에서는 7. 글쓰기/이미지 업로드 이해하기, 8. 팔로우-팔로잉 기능 이해하기, 9. 해시태그 검색 기능 이해하기를 다뤄보겠다.

잘못된 정보 수정 및 피드백 환영합니다!!

profile
TISTORY로 이사중! https://delay100.tistory.com

0개의 댓글