[서버 week_5] Authentication, jwt, 로그인, 회원가입 로직

SH·2022년 5월 20일

서버 세미나

목록 보기
10/14

Middleware

요청과 응답의 중간(middle)에서 쓰이는 것


Authentication

무상태 프로토콜 - 모든 요청이 상호 독립적, req&res 사이에 데이터 보존 안함, 다른 서버에서 요청이 들어와도 문제 없음
-> 로그인을 했더라도 해당 로그인을 했던 것을 서버가 기억하지 못함.

인증(Authentication): 사용자가 자신이 주장하는 바로 사용자가 맞는지 확인하는 절차

인가(Authorizaion): 사용자가 특정 자원에 대해 접근 권한이 있는지 확인하는 절차

단순한 key-value 쌍으로 클라 로컬에 저장됨
요청과 응답의 header에 저장됨
로그인함 -> 서버가 쿠키 보냄 -> 웹브라우저가 쿠키를 저장해두었다가 또 요청을 보낼 때 자동으로 같이 쿠키를 보냄 -> 서버가 아 이사람이구나! 알아차림

ex) 사용자 A가 로그인한 뒤 해당 홈페이지에 접속할 때마다 계속 로그인 유지

→ 쿠키로 가능. 아이디와 비밀번호를 클라이언트가 서버에게 보내면 서버는 회원 저장소에서 회원인지 조회하고 회원이 맞으면 쿠키를 같이 응답으로 보냄. 이후에 클라이언트가 해당 사이트에 접속할 때마다 브라우저의 쿠키 저장소에서 해당 쿠키를 요청과 함께 보냄. 클라이언트는 그 쿠키의 값으로 회원을 식별


Session

일정 시간 동안 같은 브라우저로부터 들어오는 일련의 요구를 한 상태로 보고 그걸 유지하는 기술
쿠키의 경우 보안상의 문제가 발생할 수 있음. 중요한 정보는 모두 서버에 저장하고 클라이언트와 서버는 임의의 식별자(추정 불가능함)로 연결해야 함. → 세션으로 해결

ex)
세션 동작 방식
사용자가 로그인 아이디와 비밀번호를 서버에 보냄
→ 서버에서 회원 저장소를 통해 회원인 걸 식별함
→ 세션 저장소에 세션 아이디를 토큰키로, 값을 멤버 객체로 연결
(이 때 세션 아이디는 zz0101xx-bab9-4b92-9b32-dadb280f4b61처럼 UUID를 사용하여 추정 불가능한 값으로 넣음)
→ 쿠키 값을 세션 아이디로 해서 서버가 클라이언트에게 보냄

이후 클라이언트가 해당 서버에 접속할 때마다 브라우저의 쿠키 저장소에서 세션 아이디를 조회, 쿠키값으로 보냄
→ 서버는 세션 아이디로 세션 저장소를 뒤져서 회원인 걸 찾음
→ 이걸 활용해서 클라이언트에게 응답


JWT

Json Web Token

두 개체 사이에서 JSON 객체를 사용해 정보를 안전하게 전달

  • Header: JWT 토큰 유형 / 해시 알고리즘 (토큰에 대한 기본정보)
  • Payload: 클라이언트 정보 (로그인을 한다면 유저 정보)
  • Signature: 서명 정보 (토큰이 검증됐다는 것을 증명)

토큰은 헤더의 Authorization 필드에 담긴다

Authorization: <type> <credentials> // Type에는 인증 타입이 들어간다 여러 유형이 있는데 Bearer를 쓸거임(JWT)

설치방법

yarn add jsonwebtoken
yarn add -D @types/jsonwebtoken

API 명세서

구성 요소

  • API 이름
  • HTTP Method
  • Content-Type
  • Request Header, Body, Params, Query
  • Response Body(Success, Fail 두 경우 모두 써줘야 함)

예시) https://github.com/TeamDooRiBon/DooRi-Server/wiki
https://skitter-sloth-be4.notion.site/API-b7425add8a044c68b5aa86eaef17c571

jwt를 사용하기 전 준비

일단 jwt를 사용하기 위해
1. jwtalgo와 jwtsecret(jwt키)를 config에 정의해주고
2. jwt의 payload 형식인 인터페이스 JwtPayloadInfo와
3. jwtHandler.ts 파일
4. 마지막으로 미들웨어 함수로서 중간에서 토큰을 디코딩해 body에 넣어주는 auth.ts 파일을 만든다

  1. 일단 .env 파일에 jwt의 알고리즘 종류와 키를 명시해준다
JWT_SECRET=키
JWT_ALGO='RS256'

  1. interfaces/common/JwtPayloadInfo.ts를 만들어서 암호화 시 필요한 payload 데이터의 형식을 정해준다
import mongoose from "mongoose";

// jwt 구성 요소 중 하나인 payload의 인터페이스
// payload - 클라이언트 정보 (로그인을 한다면 유저 정보)
// JWT token 객체 안에 담아서 보내 줄 정보

export interface JwtPayloadInfo {
    member: {
        id: mongoose.Schema.Types.ObjectId
    }
}

  1. modules/jwtHandler.ts
import mongoose from "mongoose";
import { JwtPayloadInfo } from "../interfaces/common/JwtPayloadInfo";
import jwt from "jsonwebtoken";
import config from "../config";

const getToken = (userId: mongoose.Schema.Types.ObjectId): string => {
    const payload: JwtPayloadInfo = {
        member: {
            id: userId
        },
    };

    const accessToken: string = jwt.sign(
        payload,
        config.jwtSecret,
        { expiresIn: '2h'},
    );

    return accessToken;

};

export default getToken;

jwtHandler에서 getToken 함수를 만들어준다
이 getToken 함수는 userId를 파라미터로 받아서 payload 인터페이스 형태를 가진 객체에 저장한다. 또 accessToken이라는 함수에서는 jwt를 통해 암호화를 한다. 암호화 시 payload와 jwtSecret, expiresIn(토큰 만료 시간) 세 가지 데이터를 넣어준다. 암호화 된 토큰인 accesstoken이 마지막에 리턴된다.


  1. middleware/auth.ts
import { Request, Response,NextFunction } from "express";
import jwt from "jsonwebtoken";
import message from "../modules/responseMessage";
import statusCode from "../modules/statusCode";
import util from "../modules/util";
import config from "../config";

export default (req: Request, res: Response, next: NextFunction) => {

    // request-header에서 토큰 받아오기
    const token = req.headers["authorization"]?.split(' ').reverse()[0];

    // 토큰이 있는지 없는지 확인
    if (!token) {
        return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, message.NULL_VALUE));
    }

    // 토큰 검증
    try{

        // 토큰 디코딩
        const decoded = jwt.verify(token, config.jwtSecret);

        req.body.user = (decoded as any).user;

        next(); // 다음 함수 실행
    } catch (error: any){
        console.log(error);
        if (error.name === 'TokenExpiredError'){
            return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, message.INVALID_TOKEN));
        }
    
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));

    }

};

jwt를 활용한 회원가입

bycrpt 설치

yarn add bcryptjs
yarn add -D @types/bcryptjs

전체 로직

createMemberDto.ts 로 회원가입 시 필요한 정보를 받아온다
-> 비번을 bycrpt로 암호화해서 db에 저장한다
-> jwt로 토큰을 만들어서 response에 넣어서 클라에게 넘겨준다


MemberSignupDto.ts

export interface MemberSignupDto {
    name: string;
    login_id: string;
    password: string;
}

MemberController.ts

const signupMember = async (req: Request, res: Response) => {
    
    // validation error
    const error = validationResult(req)
    if (!error.isEmpty()) {
        return res.status(400).json({ errors: error.array() });
    }

    const memberSignupDto: MemberSignupDto = req.body;

    try {
        
        // id가 중복되면 에러 발생시킴
        const result = await MemberService.signupMember(memberSignupDto);
        if (!result) return errorGenerator({ statusCode: 409 });

        // 아니라면 토큰 얻어와서 data에 같이 리턴
        const accessToken = getToken(result._id);

        const data = {
            _id: result._id,
            accessToken
        };

        res.status(statusCode.CREATED).send(util.success(statusCode.CREATED, message.CREATE_USER_SUCCESS, data));


    } catch(error){
        console.log(error);
        // 서버 내부에서 오류 발생
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));
    }

    
}

controller단에서는 크게 많은 로직이 들어가진 않는다. id가 중복되지 않는다면 전에 만들었던 getToken에서 토큰을 얻어와서 같이 data에 넣어 리턴한다.


MemberService.ts

const signupMember = async (memberSignupDto: MemberSignupDto) => {

    try{

        // 해당 login_id의 멤버가 있으면 null 리턴
        const existMember = await Member.findOne({
            login_id: memberSignupDto.login_id
        });
        if (existMember) return null;

        // 새로운 멤버 객체 만들기
        const member = new Member(memberSignupDto);

        // bycrpt로 salt(아주 작은 임의의 랜덤한 텍스트) 생성 
        const salt = await bcrypt.genSalt(10); 

        // bcyrpt.hash(string, salt) : Plain Text + Salt Hashing Hashed Text
        member.password = await bcrypt.hash(memberSignupDto.password, salt); 

        await member.save();

        const data = {
            _id: member.id
        };
        
        return data;

    } catch(e){
        console.log(e);
        throw e;
    }
}

findOne으로 해당 login_id를 가진 회원이 이미 존재하면 null을 반환한다. 여기서 null을 반환할 경우 controller단에서 error를 발생시킨다.
-> 아니라면 새로운 맴버 객체를 만든다.
-> bcrypt로 비밀번호 암호화
-> member 객체 저장
-> 저장된 객체의 id값 리턴


MemberRouter.ts

router.post("/", [
    body("name").notEmpty(),
    body("login_id").notEmpty(),
    body("password").notEmpty().isLength({ min: 6 }),
],
MemberController.signupMember);

jwt를 활용한 로그인

전체로직
UserSigninDto를 통해 email과 password를 받음
-> 전달받은 email로 user를 찾고 해당 유저의 password와 전달받은 password가 일치하는지 확인(로그인 로직)
-> 일치하면 getToken으로 토큰을 받아서 data에 id와 함께 같이 반환


UserSigninDto.ts

export interface UserSignInDto {
    email: string,
    password: string
}

로그인 시 email과 password를 받는다


const signInUser =async (req: Request, res: Response) => {

    const error = validationResult(req);

    if (!error.isEmpty()){
        console.log(error);
        return res.status(statusCode.BAD_REQUEST).send(util.fail(statusCode.BAD_REQUEST, message.BAD_REQUEST, res.json({ errors: error.array() })));
    }

    const userSignInDto: UserSignInDto = req.body;

    try{

        const result = await UserService.signInUser(userSignInDto);
        if (!result) return res.status(statusCode.UNAUTHORIZED).send(util.fail(statusCode.UNAUTHORIZED, message.INVAILED_PASSWORD));

        const accessToken = getToken((result as PostBaseResponseDto)._id); // result가 다른 타입이 들어올 수 있는 여지가 있기 때문에 타입 단언을 해주어야 함

        const data = {
            _id: (result as PostBaseResponseDto)._id,
            accessToken
        }

        res.status(statusCode.OK).send(util.success(statusCode.OK, message.SIGNIN_USER_SUCCESS));

    } catch(error){

        console.log(error)
        res.status(statusCode.INTERNAL_SERVER_ERROR).send(util.fail(statusCode.INTERNAL_SERVER_ERROR, message.INTERNAL_SERVER_ERROR));

    }
    
}

UserSigninDto에서 받은 이메일과 비번을 가진 회원이 존재하면(UserService에서 확인) getToken으로 토큰을 발급하고 data에 토큰과 id를 같이 넣어 반환한다.


UserService.ts

const signInUser = async(userSignInDto: UserSignInDto) : Promise<PostBaseResponseDto | null | number> => {
    try {
        const user = await User.findOne({
            email: userSignInDto.email
        });

        if (!user) return null;

        const isMatch = await bcrypt.compare(userSignInDto.password, user.password);
        if (!isMatch) return 401;

        const data = {
            _id: user._id
        };
        return data;


    } catch (error) {

        console.log(error);
        throw error;
        
    }
}

Dto로 받은 email로 User 객체를 찾는다. 못찾으면 null 반환
-> bcrypt로 입력받은 비밀번호와 찾은 user 객체 비밀번호 비교. 다르면 에러 반환하고 같으면 id 반환


UserRouter.ts

router.post('/signin', [
    body('email').notEmpty(),
    body('email').isEmail(),
    body('password').isLength({ min: 6 }),
    body('password').notEmpty()
], UserController.signInUser);

ERROR 정리

Error: secretOrPrivateKey must have a value 에러

https://velog.io/@daep93/Nestjs-secretOrPrivateKey-must-have-a-value
https://dev.to/emmanuelthecoder/how-to-solve-secretorprivatekey-must-have-a-value-in-nodejs-4mpg


comment 에러 https://stackoverflow.com/questions/18083389/ignore-typescript-errors-property-does-not-exist-on-value-of-type https://bobbyhadz.com/blog/typescript-ignore-property-does-not-exist-on-type
profile
블로그 정리안하는 J개발자

0개의 댓글