Vue.js 3 & NodeJS/사내교육 - 점심 메뉴

[9] Backend 개발 - 사용자 API 개발, 인증 처리, ID Token

carrotweb 2026. 8. 4. 16:05
728x90
반응형

ID Token

사용자가 로그인하면 서버에서 사용자를 확인하여 사용자 정보로 토큰을 생성한 후 응답 데이터로 클라이언트에게 토큰을 전달하는 방식입니다.

클라이언트는 요청을 보낼 때 Authorization HTTP Header에 토큰을 포함하여 전달하면 서버는 전달 받은 토큰을 검증하여 인증된 사용자인지 확인합니다.

 

ID 토큰은 JSON Web Token (JWT, JSON 웹 토큰)입니다.

ID 토큰에는 인증된 사용자에 대한 정보(claim(클레임))가 포함되어 있습니다.

 

장점

  • 클라이언트에서 토큰을 관리하기 때문에 서버에 부하가 낮아집니다.
  • 접근 권한을 적용할 수 있습니다.

단점

  • 토큰이 탈취 당할 수 있습니다. → 보안성이 취약함 (HTTPS로 보안할 수 있습니다. 단, 클라이언트 해킹은 차단할 수 없습니다.)
  • 모든 자원을 접근할 수 있습니다. 별도의 접근 권한 처리 필요

 

ID Token Process (ID 토큰 프로세스)

 

ID Token으로 JSON Web Token를 사용하겠습니다.

 

JWT(JSON Web Token - JSON 웹 토큰)
(https://datatracker.ietf.org/doc/html/rfc7519)

 

JWT(JSON Web Token - JSON 웹 토큰)
(https://jwt.io/)

JSON 웹 토큰을 디코딩, 검증 그리고 생성합니다. 두 집단 간에 안전하게 클레임(정보)을 표현하기 위한 개방형 산업 표준 RFC 7519 방식입니다.

사용자 보호를 위해 모든 JWT 디버깅과 유효성 검사는 브라우저에서 수행됩니다. JWT는 리소스에 대한 액세스 권한을 부여하는 자격 증명을 나타낼 수 있으므로 JWT를 붙여 넣거나 공유할 때 주의하세요.

 

JWT는 header(헤더).payload(페이로드, 전송되는 데이터).signature(시그니처, 서명)로 구성되어 있습니다.
도트(.)로 구분되어 있습니다. (Self-Contained Token)

 

JWT는 header.payload.signature로 구성되어 있습니다.

 

header(헤더)

algorithm(alg, 서명에 사용된 알고리즘)과 token type(typ, 토큰 타입) 정보를 가지고 있습니다.

 

payload(페이로드, 전송되는 데이터)

저장되는 정보를 claim(클레임)이라고 부르고 name : value로 구성됩니다. 여러 claim 세트로 구성됩니다. (payload는 암호화되지 않기 때문에 개인정보는 사용하지 않아야 합니다.)

 

signature(시그니처, 서명)

JWT의 무결성을 보장하기 위한 서명입니다.

sign(header + "." + payload, private_key)

 

각각의 JSON은 base64UrlEncode()로 인코딩합니다.

 

JWT Claims

Registered Claim Names (등록된 클레임 이름)

이름이 이미 정의된 클레임입니다.

 

iss (issuer, 이슈어, 발급자) : JWT 발급자 (JWT를 발급한 주체), 도메인을 사용합니다.

Ex) "iss" : "auth.example.com", "iss" : "https://auth.example.com"

 

sub (subject, 서브젝트, 제목) : JWT 제목

Ex) "sub" : "authcode"

 

aud (audience, 오디언스, 청취자) : JWT 대상자 (JWT를 받는 수신자), JWT를 요청한 클라이언트의 ID나 고유 식별 값을 사용합니다.

Ex) "aud" : "lunchmenuApp"

 

exp (expiration time, 엑스퍼레이션 타임, 만료 시간) : JWT 만료 시간, 시간 이후에는 JWT에 대한 처리가 수락되어서는 안됩니다.

Ex) "exp": 1629535586

 

nbf (not before, 낫 비포, 이전 아님) : 시간 이전에는 JWT에 대한 처리가 수락되어서는 안됩니다.

Ex) "nbf": 1629535596

 

iat (Issued at, 이슈드 엣, 발급 시간) : JWT 발급 시간

Ex) "iat": 1629449186

 

jti (JWT ID) : JWT에 대한 고유 식별자, 발급자가 생성한 값 간의 충돌을 방지하기 위해 사용됩니다.

Ex) "jti": "c56e9bf321b8406d9dac956652ec9aef"

 

Public Claim Names (공개 클레임 이름)

원하는 이름으로 클레임 이름을 정의할 수 있습니다. 새로운 클레임 이름은 충돌을 방지하기 위해 충돌 방지 이름(Collision-Resistant Name, 컬리전 리지스턴트 네임)이 포함되어 있어야 합니다. 그래서 네임스페이스나 URI 형식으로 정의하는 것이 좋습니다.

Ex) "cohttp://m.example.auth.api.v1.member.userid" : "test1"

Ex) "http://auth.example.com/api/v1/member/userid" : "test1"

 

Private Claim Names (비공개 클레임 이름)

JWT 생산자(JWT 발급자)와 소비자(JWT를 요청한 클라이언트)는 합의하에 비공개 클레임 이름을 정의하고 사용할 수 있습니다.

Ex) "name" : "kang byung joon", "given_name" : "byung joon“, "family_name" : "kang"

Ex) "nickname" : "carrotweb"

 

Libraries for Token Signing/Verification
(https://jwt.io/libraries)

 

jsonwebtoken
(https://www.npmjs.com/package/jsonwebtoken)

 

1. JSON 토큰을 사용하기 위해 jsonwebtoken 설치 (터미널에서 실행합니다.)

npm install jsonwebtoken --save

 

uuid

(https://www.npmjs.com/package/uuid)

 

2. UUID를 사용하기 위해 uuid 설치 (터미널에서 실행합니다.)

npm install uuid --save

 

 

JWT Sign

jwt.sign(payload, secret, options, [callback])

 

payload

JWT에 저장되는 정보로 key : value로 구성됩니다.

여기서는 로그인 사용자를 구분하기 위해 아이디(sub), JWT 발급 시간(iat), JWT 만료 시간(exp), JWT 대상자(aud), JWT 발급자(iss), JWT에 대한 고유 식별자(jti), 이름(name), 권한(roles)을 저장합니다.

 

secret

서명을 만들 때 사용되는 암호 문자열입니다.

여기서는 UUID를 암호 문자열로 사용했습니다.

 

options

JWT를 생성할 때 사용되는 옵션입니다.

algorithm은 암호화 알고리즘으로 HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512, PS256, PS384 등을 사용할 수 있습니다. 기본값으로 HS256를 사용합니다.

 

expiresIn(exp)는 JWT 만료 시간으로 초 단위 또는 시간 범위 문자열로 설정합니다.

년 단위 : "1y", 일 단위 : "1 days", "1d", 시간 단위 : "2.5 hrs", "2h", 분 단위 : "1m", 초 단위 : "5s“ (단위 문자가 포함되지 않으면 초 단위로 설정됩니다.)

 

notbefore(nbf)는 JWT를 처리하지 않는 시간으로 초 단위 또는 시간 범위 문자열로 설정합니다.

expiresIn와 동일한 표기법을 사용합니다.

 

audience(aud)는 JWT 대상자(JWT를 받는 수신자)입니다. JWT를 요청한 클라이언트의 ID나 고유 식별 값을 사용합니다.

 

issuer(iss)는 JWT 발급자(JWT를 발급한 주체)입니다. 도메인을 사용합니다.

 

subject(sub)는 JWT 제목(claim의 주제)입니다. 인증에서는 사용자 ID를 사용합니다.

 

jwtid는 JWT 식별자입니다.

 

[callback]

JWT가 생성된 후 호출되는 funcation입니다. 생성된 토큰(token)이나 에러(err)를 인자로 줍니다.

 

3. src\api\v1\member.js 파일 전체 수정

const express = require('express');
const router = express.Router();
const getDBConnection = require('../../db/mariadb/connection');
const mybatisMapper = require('mybatis-mapper');
const { v4: uuidv4 } = require('uuid');
const jwt = require('jsonwebtoken');

// XML 파일 경로는 상대 경로가 아닌 절대 경로(Root 경로)로 './' 또는 '/' 없이 입력해야 합니다.
mybatisMapper.createMapper(['./src/db/mappers/mariadb/lunchmenu/member-sql.xml']);

// SQL 언어 형식과 들여쓰기를 설정
const format = {language: 'mariadb', indent: '  '};

// 사용자 ID로 검색하여 사용자를 인증하고 리턴합니다.
router.post('/login', async function(req, res, next) {
    console.log(`REST API Post Method - Member Login.`);

    const result = {
        result: 'fail',
        code: 401,
        message: '',
        idtoken: ''
    };

    if (req.body.id == undefined || req.body.id == "") {
        result.code = 400;
        result.message = "id가 없습니다."
        res.status(result.code).json(result);
        return;
    } else if (req.body.password == undefined || req.body.password == "") {
        result.code = 400;
        result.message = "password가 없습니다."
        res.status(result.code).json(result);
        return;
    }

    const params = {
        id : req.body.id,
        password : req.body.password
    };

    //const query = "SELECT * FROM tb_member WHERE mbr_id = ? AND mbr_pw = ?";
    const query = mybatisMapper.getStatement('apiserver.db.mappers.mariadb.lunchmenu.member', 
        'selectMember', params, format);

    let conn;
    try {
        conn = await getDBConnection();
        //data = await conn.query(query, [req.body.id, req.body.password]);
        data = await conn.query(query);
        if (data.length == 0) {
            result.message = "사용자가 없습니다.";
        } else {
            if (data[0].mbr_pw != params.password) {
                result.message = "비밀번호가 동일하지 않습니다."
            } else {
                result.message = "사용자가 인증되었습니다.";
                const uuid = uuidv4().toString();
                const jwtid = uuid.replace(/-/gi, '');
                const secret = "005c9780fe7c11eb89b4e39719de58a5";
                try {
                    const issuedAt = new Date().toLocaleString();
                    const iat = Date.parse(issuedAt) / 1000;
                    const idToken = await new Promise((resolve, reject) => {
                        jwt.sign({
                                name : data[0].mbr_name,
                                roles : data[0].mbr_roles,
                                iat : iat
                            },
                            secret,
                            {
                                algorithm: "HS256",
                                issuer : "apiserver.example.com",
                                subject : data[0].mbr_id,
                                audience : "lunchmenuApp",
                                expiresIn : "1d",
                                jwtid : jwtid
                            },
                            (err, token) => {
                                if (err) {
                                    reject(err);
                                } else {
                                    resolve(token);
                                }
                            });
                    });
                    result.result = "success";
                    result.code = 200;
                    result.idtoken = idToken;
                } catch(err) {
                    console.log(err);
                    result.code = 500;
                    result.message = "ID 토큰 생성이 실패하였습니다.";
                }
            }
        }
    } catch (error) {
        result.code = 500;
        if (error.code == "ER_NO_SUCH_TABLE") {
            result.message = "테이블이 없습니다.";
        } else {
            result.message = "서버 오류가 발생하였습니다.";
        }
    } finally {
        if (conn) {
            conn.release();
        }
    }

    res.status(result.code).json(result);
});

module.exports = router;

 

 

JWT Verify

jwt.verify(token, secretOrPublicKey, [options, callback])

 

token

jwt.sign() 메서드로 생성된 JWT입니다.

 

secretOrPublicKey

서명을 만들 때 사용된 암호 문자열입니다.

 

[callback]

JWT가 검증(확인)된 후 호출되는 funcation입니다.

payload 값(decode)이나 에러(err)를 인자로 줍니다.

 

4. src\api\v1\authMiddleware.js 파일 전체 수정

const jwt = require('jsonwebtoken');

const authMiddleware = async (req, res, next) => {
    let authorization = req.header('Authorization');

    const result = {
        result: 'fail',
        code: 401,
        message: '인증이 실패하였습니다.',
        data: {}
    };

    if (authorization == undefined || authorization == "") {
        result.code = 400;
        res.status(result.code).json(result);
    } else {
        authorization = authorization.replace("Bearer ","");
        try {
            const secret = "005c9780fe7c11eb89b4e39719de58a5";
            const idToken = await new Promise((resolve, reject) => {
                jwt.verify(authorization, secret, 
                    (err, decoded) => {
                        if (err) {
                            reject(err);
                        } else {
                            resolve(decoded);
                        }
                    });
            });
            req.idToken = idToken;
            next();
        } catch(err) {
            res.status(result.code).json(result);
        }
    }
}

module.exports = authMiddleware;

 

5. index.js 파일 수정 (쿠키, 세션 주석 처리)

const express = require('express');
//const cookieParser = require('cookie-parser');
//const session = require('express-session');
const app = express();
const port = 9000;

app.get('/', (req, res) => {
    res.send('Hello World!');
});

:

// Cookie 설정
//app.use(cookieParser());

// 세션 설정
//app.use(session({
//    secret: "test", // 세션 ID 쿠키에 서명하는데 사용되는 비밀번호입니다.
//    resave: false, // 세션을 세션 저장소에 다시 저장하도록 강제합니다.
//    saveUninitialized: true, // "초기화되지 않은" 세션을 강제로 저장소에 저장합니다.
//    name: "sessionid" // 응답에 설정할 세션 ID 쿠키의 이름입니다.
//}));

// 인증 설정
app.use('/api/v1/lunchmenus', require('./src/api/v1/authMiddleware'));
:

 

사용자 API 테스트 (POSTMAN에서 확인)
1. 기존 쿠키 삭제

 

2. 전체 점심 메뉴 조회

 

3. 사용자 로그인

 

4. JWT 확인

 

5. 전체 점심 메뉴 조회

 

 

CORS 처리

Cross Origin Resource Sharing (교차 출처 리소스 공유)

1. index.js 파일 수정 (기존 CORS 처리에 Headers에 Authorization 추가, OPTIONS 처리 추가)

// 모든 리소스에 접근 허용 - CORS(Cross-origin resource sharing, 교차 출처 리소스 공유) 문제 해결
// GET, POST, PUT, DELETE 메서드 사용 허용
// Content-type, Authorization 요청 허용
app.use(function (req, res, next) {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    if (req.method === 'OPTIONS') {
        res.sendStatus(200);
    } else {
        next();
    }
});

Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

Preflight 요청에 대한 응답이 액세스 제어 검사를 통과하지 못했습니다. HTTP 확인 상태가 아닙니다.

OPTIONS으로 요청이 들어오면 상태 코드 200으로 응답하면 됩니다.

 

CORS(Cross Origin Resource Sharing, 교차 출처 리소스 공유)
(https://expressjs.com/en/resources/middleware/cors.html)

 

1. cors를 사용하기 위해 cors 설치 (터미널에서 실행합니다.)

npm install cors --save

 

2. index.js 파일 수정 (CORS 추가, 기존 CORS 주석 처리)

const express = require('express');
//const cookieParser = require('cookie-parser');
//const session = require('express-session');
const cors = require('cors');
const app = express();
const port = 9000;

app.use(cors());

:

// 모든 리소스에 접근 허용 - CORS(Cross-origin resource sharing, 교차 출처 리소스 공유) 문제 해결
// GET, POST, PUT, DELETE 메서드 사용 허용
// Content-type, Authorization 요청 허용
//app.use(function (req, res, next) {
//	res.header('Access-Control-Allow-Origin', '*');
//	res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
//	res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
//	if (req.method === 'OPTIONS') {
//		res.sendStatus(200);
//	} else {
//		next();
//	}
//});

:
728x90
반응형