MyStory/SafeLink Project

Node.js ์ธ์ฆ ์‹œ์Šคํ…œ ๊ตฌ์ถ•๊ธฐ (2ํŽธ) - ๋ณด์•ˆ ๊ฐ•ํ™”์™€ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ

LupyLaon 2025. 6. 9. 16:40

๐Ÿ›ก๏ธ ๋‹ค์ธต ๋ณด์•ˆ ์‹œ์Šคํ…œ

1ํŽธ์—์„œ ๋‹ค๋ฃฌ JWT ํ† ํฐ ์‹œ์Šคํ…œ์„ ๊ธฐ๋ฐ˜์œผ๋กœ, ์ด๋ฒˆ ํŽธ์—์„œ๋Š” ์‹ค์ „์—์„œ ๊ผญ ํ•„์š”ํ•œ ๋ณด์•ˆ ๊ธฐ๋Šฅ๋“ค๊ณผ ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ๋“ค์„ ์‚ดํŽด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ํ˜„๋Œ€์ ์ธ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ ๋‹จ์ˆœํ•œ ์ธ์ฆ์„ ๋„˜์–ด ๋‹ค์–‘ํ•œ ๋ณด์•ˆ ์œ„ํ˜‘์— ๋Œ€ํ•œ ์ข…ํ•ฉ์ ์ธ ๋Œ€์‘์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿšซ Rate Limiting - API ๋‚จ์šฉ ๋ฐฉ์ง€

์ „์—ญ Rate Limiting

// index.js์—์„œ ์ „์—ญ Rate Limiting ์„ค์ •
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
    windowMs: 15 * 60 * 1000,    // 15๋ถ„ ์‹œ๊ฐ„ ์ฐฝ
    max: 100,                    // IP๋‹น ์ตœ๋Œ€ 100ํšŒ ์š”์ฒญ
    message: {
        success: false,
        message: '๋„ˆ๋ฌด ๋งŽ์€ ์š”์ฒญ์ž…๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'
    },
    standardHeaders: true,        // `RateLimit-*` ํ—ค๋” ์ถ”๊ฐ€
    legacyHeaders: false,        // `X-RateLimit-*` ํ—ค๋” ๋น„ํ™œ์„ฑํ™”
});

app.use(limiter);

๋กœ๊ทธ์ธ ์ „์šฉ Rate Limiting

๋กœ๊ทธ์ธ ์—”๋“œํฌ์ธํŠธ์—๋Š” ๋”์šฑ ์—„๊ฒฉํ•œ ์ œํ•œ์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค:

// authRoutes.js์—์„œ ๋กœ๊ทธ์ธ ์ „์šฉ ๋ฆฌ๋ฏธํ„ฐ
const loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000,    // 15๋ถ„
    max: 5,                      // ์ตœ๋Œ€ 5ํšŒ ์‹œ๋„
    message: { 
        success: false,
        message: '๋„ˆ๋ฌด ๋งŽ์€ ๋กœ๊ทธ์ธ ์‹œ๋„. 15๋ถ„ ํ›„์— ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.'
    },
    skipSuccessfulRequests: true,  // ์„ฑ๊ณตํ•œ ์š”์ฒญ์€ ์นด์šดํŠธ์—์„œ ์ œ์™ธ
});

// ๋กœ๊ทธ์ธ ๋ผ์šฐํŠธ์— ์ ์šฉ
router.post('/login', loginValidator, loginLimiter, authController.login);

๊ณ„์ •๋ณ„ ๋ธŒ๋ฃจํŠธํฌ์Šค ๋ฐฉ์ง€

IP ๊ธฐ๋ฐ˜ ์ œํ•œ๊ณผ ํ•จ๊ป˜ ๊ณ„์ •๋ณ„ ์ž ๊ธˆ ์‹œ์Šคํ…œ๋„ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค:

// User ๋ชจ๋ธ์— ๋ธŒ๋ฃจํŠธํฌ์Šค ๋ฐฉ์ง€ ํ•„๋“œ
const UserSchema = new mongoose.Schema({
    loginAttempts: { type: Number, default: 0 },
    lockUntil: { type: Number, default: 0 },
    // ... ๊ธฐํƒ€ ํ•„๋“œ
});

// ๋กœ๊ทธ์ธ ์‹คํŒจ ์ฒ˜๋ฆฌ ๋กœ์ง
if (!isMatch) {
    user.loginAttempts += 1;
    
    // ๐Ÿ”’ 5ํšŒ ์‹คํŒจ ์‹œ 30๋ถ„ ์ž ๊ธˆ
    if (user.loginAttempts >= 5) {
        user.lockUntil = Date.now() + 30 * 60 * 1000;
        user.loginAttempts = 0;
        
        logger.security('๊ณ„์ • ์ž ๊ธˆ ๋ฐœ์ƒ', {
            userId: user._id,
            email: user.email,
            ip: req.ip,
            attempts: user.loginAttempts
        });
    }
    
    await user.save();
    return res.status(401).json({
        success: false,
        message: '์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'
    });
}

// โœ… ๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ์ž ๊ธˆ ํ•ด์ œ
user.loginAttempts = 0;
user.lockUntil = undefined;
await user.save();

๐Ÿ” CSRF ๋ณดํ˜ธ - ํฌ๋กœ์Šค์‚ฌ์ดํŠธ ์š”์ฒญ ์œ„์กฐ ๋ฐฉ์ง€

์„ ํƒ์  CSRF ๋ณดํ˜ธ

๋ชจ๋“  ์—”๋“œํฌ์ธํŠธ์— CSRF ๋ณดํ˜ธ๋ฅผ ์ ์šฉํ•˜๋ฉด ์‚ฌ์šฉ์„ฑ์ด ๋–จ์–ด์งˆ ์ˆ˜ ์žˆ์–ด, ์„ ํƒ์  ๋ณดํ˜ธ ์ „๋žต์„ ์ฑ„ํƒํ–ˆ์Šต๋‹ˆ๋‹ค:

// index.js์—์„œ CSRF ์„ค์ •
const csrf = require('csurf');

const csrfProtection = csrf({
    cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict'
    }
});

// ๐ŸŽซ CSRF ํ† ํฐ ์ œ๊ณต ์—”๋“œํฌ์ธํŠธ
app.get('/api/csrf-token', csrfProtection, (req, res) => {
    res.json({ csrfToken: req.csrfToken() });
});

// ๐Ÿ›ก๏ธ ๋ฏผ๊ฐํ•œ ์ž‘์—…์—๋งŒ CSRF ๋ณดํ˜ธ ์ ์šฉ
// router.post('/register', csrfProtection, registerValidator, authController.register);
// router.post('/login', csrfProtection, loginValidator, loginLimiter, authController.login);

CSRF ์—๋Ÿฌ ์ฒ˜๋ฆฌ

// ์ „์—ญ ์—๋Ÿฌ ํ•ธ๋“ค๋Ÿฌ์—์„œ CSRF ์—๋Ÿฌ ์ฒ˜๋ฆฌ
app.use((err, req, res, next) => {
    if (err.code === 'EBADCSRFTOKEN') {
        return res.status(403).json({ 
            message: 'CSRF ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.',
            hint: 'CSRF ํ† ํฐ์„ /api/csrf-token์—์„œ ๋ฐ›์•„์„œ X-CSRF-TOKEN ํ—ค๋”์— ํฌํ•จํ•˜์„ธ์š”.'
        });
    }
    // ๊ธฐํƒ€ ์—๋Ÿฌ ์ฒ˜๋ฆฌ...
});

๐ŸŒ CORS ์„ค์ • - ์•ˆ์ „ํ•œ ํฌ๋กœ์Šค ์˜ค๋ฆฌ์ง„ ์š”์ฒญ

๋™์  Origin ๊ฒ€์ฆ

// index.js์—์„œ CORS ์„ค์ •
const allowedOrigins = [
    'http://localhost:3000',
    'http://localhost:8080',
    'http://localhost:8081',
    process.env.CLIENT_URL
].filter(Boolean);

app.use(cors({
    origin: function (origin, callback) {
        // ๐Ÿ”ง ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” origin์ด undefined์ผ ์ˆ˜ ์žˆ์Œ
        if (!origin && process.env.NODE_ENV === 'development') {
            return callback(null, true);
        }

        if (allowedOrigins.includes(origin)) {
            callback(null, true);
        } else {
            console.log('CORS ์ฐจ๋‹จ๋œ ๋„๋ฉ”์ธ:', origin);
            callback(new Error('CORS ์ •์ฑ…์— ์˜ํ•ด ์ฐจ๋‹จ๋จ'));
        }
    },
    credentials: true,                              // ์ฟ ํ‚ค ํฌํ•จ ์š”์ฒญ ํ—ˆ์šฉ
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-TOKEN']
}));

๐Ÿ‘ค ์†Œ์…œ ๋กœ๊ทธ์ธ ๊ตฌํ˜„ - Passport.js ์ „๋žต

Passport ์ „๋žต ์„ค์ •

// config/passport.js
const passport = require('passport');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const KakaoStrategy = require('passport-kakao').Strategy;
const NaverStrategy = require('passport-naver').Strategy;

// ๐Ÿ”‘ JWT ์ „๋žต
const jwtOptions = {
    jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    secretOrKey: process.env.JWT_SECRET
};

passport.use(new JwtStrategy(jwtOptions, async (payload, done) => {
    try {
        const user = await User.findById(payload.id);
        return user ? done(null, user) : done(null, false);
    } catch (error) {
        return done(error, false);
    }
}));

// ๐ŸŸก ์นด์นด์˜ค ๋กœ๊ทธ์ธ ์ „๋žต
passport.use(new KakaoStrategy({
    clientID: process.env.KAKAO_CLIENT_ID,
    callbackURL: process.env.KAKAO_CALLBACK_URL
}, async (accessToken, refreshToken, profile, done) => {
    try {
        // ๊ธฐ์กด ์‚ฌ์šฉ์ž ์ฐพ๊ธฐ
        let user = await User.findOne({ kakaoId: profile.id });

        if (!user) {
            // ๐Ÿ†• ์ƒˆ ์‚ฌ์šฉ์ž ์ƒ์„ฑ
            user = await User.create({
                name: profile.displayName || profile.username,
                email: profile._json.kakao_account.email,
                kakaoId: profile.id,
                termsAgree: true    // ์†Œ์…œ ๋กœ๊ทธ์ธ ์‹œ ์•ฝ๊ด€ ๋™์˜ ์ฒ˜๋ฆฌ
            });
        }

        return done(null, user);
    } catch (error) {
        return done(error, false);
    }
}));

// ๐ŸŸข ๋„ค์ด๋ฒ„ ๋กœ๊ทธ์ธ ์ „๋žต
passport.use(new NaverStrategy({
    clientID: process.env.NAVER_CLIENT_ID,
    clientSecret: process.env.NAVER_CLIENT_SECRET,
    callbackURL: process.env.NAVER_CALLBACK_URL
}, async (accessToken, refreshToken, profile, done) => {
    try {
        let user = await User.findOne({ naverId: profile.id });

        if (!user) {
            user = await User.create({
                name: profile.displayName,
                email: profile.emails[0].value,
                naverId: profile.id,
                termsAgree: true
            });
        }

        return done(null, user);
    } catch (error) {
        return done(error, false);
    }
}));

์†Œ์…œ ๋กœ๊ทธ์ธ ์ฝœ๋ฐฑ ์ฒ˜๋ฆฌ

// authController.js์—์„œ ์นด์นด์˜ค ์ฝœ๋ฐฑ ์ฒ˜๋ฆฌ
exports.kakaoCallback = async (req, res) => {
    try {
        const user = req.user;

        // ๐ŸŽซ ํ† ํฐ ์ƒ์„ฑ
        const accessToken = generateAccessToken(user._id);
        const refreshToken = generateRefreshToken(user._id);

        // ๐Ÿ’พ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ DB ์ €์žฅ
        await RefreshToken.create({
            user: user._id,
            token: refreshToken,
            ip: req.ip,
            userAgent: req.get('User-Agent') || 'Unknown',
            expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
        });

        // ๐Ÿช HTTP-only ์ฟ ํ‚ค ์„ค์ •
        res.cookie('refreshToken', refreshToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000
        });

        // ๐Ÿ”„ ํ”„๋ก ํŠธ์—”๋“œ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (ํ† ํฐ๊ณผ ํ•จ๊ป˜)
        res.redirect(`${process.env.CLIENT_URL}/?token=${accessToken}&social=kakao`);
        
    } catch (error) {
        logger.logError('์นด์นด์˜ค ๋กœ๊ทธ์ธ ์ฝœ๋ฐฑ ์˜ค๋ฅ˜', error, {
            ip: req.ip,
            userAgent: req.headers['user-agent']
        });
        res.redirect(`${process.env.CLIENT_URL}/login?error=kakao_login_failed`);
    }
};

โœ… ํฌ๊ด„์ ์ธ ์œ ํšจ์„ฑ ๊ฒ€์ฆ

๊ฐ•๋ ฅํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ •์ฑ…

// middleware/validators.js
const passwordStrengthCheck = (value) => {
    // ์ตœ์†Œ 8์ž, ๋Œ€์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž ๊ฐ 1๊ฐœ ์ด์ƒ
    const strongPasswordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
    
    if (!strongPasswordRegex.test(value)) {
        throw new Error('๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ตœ์†Œ 8์ž ์ด์ƒ์ด๋ฉฐ, ๋Œ€๋ฌธ์ž, ์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋ฅผ ๊ฐ๊ฐ ํ•˜๋‚˜ ์ด์ƒ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.');
    }
    
    // ๐Ÿšซ ์ผ๋ฐ˜์ ์ธ ํŒจํ„ด ์ฐจ๋‹จ
    const commonPatterns = ['123456', 'password', 'qwerty', 'admin', '111111'];
    if (commonPatterns.some(pattern => value.toLowerCase().includes(pattern))) {
        throw new Error('ํ”ํžˆ ์‚ฌ์šฉ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ ํŒจํ„ด์ด ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.');
    }
    
    return true;
};

// ๐Ÿšซ ๊ธˆ์ง€์–ด ๊ฒ€์‚ฌ
const forbiddenWordsCheck = (value) => {
    const forbiddenWords = ['๋น„์†์–ด', '์š•์„ค', 'badword'];
    
    if (forbiddenWords.some(word => value.toLowerCase().includes(word))) {
        throw new Error('๋ถ€์ ์ ˆํ•œ ๋‹จ์–ด๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.');
    }
    
    return true;
};

ํšŒ์›๊ฐ€์ž… ์œ ํšจ์„ฑ ๊ฒ€์ฆ

const registerValidator = [
    body('name')
        .notEmpty().withMessage('์ด๋ฆ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.')
        .isLength({ min: 2, max: 50}).withMessage('์ด๋ฆ„์€ 2~50์ž ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.')
        .trim()
        .escape()                           // HTML ์ด์Šค์ผ€์ดํ”„
        .custom(forbiddenWordsCheck),

    body('email')
        .notEmpty().withMessage('์ด๋ฉ”์ผ์€ ํ•„์ˆ˜ ์ž…๋ ฅ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.')
        .isEmail().withMessage('์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.')
        .normalizeEmail(),                  // ์ด๋ฉ”์ผ ์ •๊ทœํ™”

    body('password')
        .notEmpty().withMessage('๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.')
        .isLength({ min: 8 }).withMessage('๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ตœ์†Œ 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.')
        .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
        .withMessage('๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ๋Œ€๋ฌธ์ž, ์†Œ๋ฌธ์ž, ์ˆซ์ž, ํŠน์ˆ˜๋ฌธ์ž๋ฅผ ๊ฐ๊ฐ ํ•˜๋‚˜ ์ด์ƒ ํฌํ•จํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.')
        .custom(passwordStrengthCheck),

    body('confirmPassword')
        .custom((value, { req }) => {
            if (value !== req.body.password) {
                throw new Error('๋น„๋ฐ€๋ฒˆํ˜ธ์™€ ๋น„๋ฐ€๋ฒˆํ˜ธ ํ™•์ธ์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.');
            }
            return true;
        }),

    body('phone')
        .optional()
        .isMobilePhone('ko-KR').withMessage('์œ ํšจํ•œ ํ•œ๊ตญ ์ „ํ™”๋ฒˆํ˜ธ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.'),

    body('termsAgree')
        .isBoolean()
        .custom(value => {
            if (value !== true) {
                throw new Error('์„œ๋น„์Šค ์ด์šฉ์„ ์œ„ํ•ด ์•ฝ๊ด€์— ๋™์˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.');
            }
            return true;
        }),

    validate  // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๊ฒฐ๊ณผ ํ™•์ธ ๋ฏธ๋“ค์›จ์–ด
];

๋กœ๊ทธ์ธ ์ด๋ฉ”์ผ ์ •๊ทœํ™”

const loginValidator = [
    body('email')
        .notEmpty().withMessage('์ด๋ฉ”์ผ์€ ํ•„์ˆ˜ ์ž…๋ ฅ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.')
        .isEmail().withMessage('์œ ํšจํ•œ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค.')
        .normalizeEmail({
            gmail_remove_dots: false,           // Gmail์˜ ์  ์ œ๊ฑฐ ์•ˆํ•จ
            gmail_remove_subaddress: true,      // +๋ถ€๋ถ„ ์ œ๊ฑฐ
            all_lowercase: true,                // ์†Œ๋ฌธ์ž ๋ณ€ํ™˜
            gmail_convert_googlemaildotcom: true // googlemail.com → gmail.com
        }),

    body('password')
        .notEmpty().withMessage('๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.'),

    validate
];

๐Ÿ“Š Winston์„ ํ™œ์šฉํ•œ ๊ตฌ์กฐํ™”๋œ ๋กœ๊น…

๋กœ๊ทธ ๋ ˆ๋ฒจ๋ณ„ ํŒŒ์ผ ๋ถ„๋ฆฌ

// utils/logger.js
const winston = require('winston');
const { format, transports } = winston;
require('winston-daily-rotate-file');

// ๐Ÿ“ ๋กœ๊ทธ ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ
const logDir = 'logs';
if (!fs.existsSync(logDir)) {
    fs.mkdirSync(logDir);
}

// ๐ŸŽจ ์ปค์Šคํ…€ ๋กœ๊ทธ ํฌ๋งท
const logFormat = format.printf(({ level, message, timestamp, ...meta }) => {
    return `${timestamp} [${level.toUpperCase()}]: ${message} ${
        Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''
    }`;
});

const logger = winston.createLogger({
    level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
    format: format.combine(
        format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        format.errors({ stack: true }),
        logFormat
    ),
    defaultMeta: { service: 'guardian-auth' },
    transports: [
        // ๐Ÿ–ฅ๏ธ ์ฝ˜์†” ์ถœ๋ ฅ
        new transports.Console({
            format: format.combine(format.colorize(), logFormat)
        }),

        // ๐Ÿ“„ ์ผ๋ฐ˜ ๋กœ๊ทธ (์ผ๋ณ„ ์ˆœํ™˜)
        new transports.DailyRotateFile({
            filename: path.join(logDir, 'application-%DATE%.log'),
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '14d',
            level: 'info'
        }),

        // โŒ ์—๋Ÿฌ ๋กœ๊ทธ (์žฅ๊ธฐ ๋ณด๊ด€)
        new transports.DailyRotateFile({
            filename: path.join(logDir, 'error-%DATE%.log'),
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '30d',
            level: 'error'
        }),

        // ๐Ÿ”’ ๋ณด์•ˆ ์ด๋ฒคํŠธ ์ „์šฉ ๋กœ๊ทธ
        new transports.DailyRotateFile({
            filename: path.join(logDir, 'security-%DATE%.log'),
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '90d',
            level: 'info',
            format: format.combine(
                format.timestamp(),
                format.json(),
                format((info) => info.security ? info : false)()
            )
        })
    ]
});

๋ณด์•ˆ ์ด๋ฒคํŠธ ๋กœ๊น…

// ์ „์šฉ ๋กœ๊น… ๋ฉ”์„œ๋“œ
logger.logError = (message, error, meta = {}) => {
    logger.error(message, {
        error: {
            message: error.message,
            stack: error.stack
        },
        ...meta
    });
};

logger.security = (message, meta = {}) => {
    logger.info(message, { security: true, ...meta });
};

// ์‚ฌ์šฉ ์˜ˆ์‹œ - ์˜์‹ฌ์Šค๋Ÿฌ์šด ํ† ํฐ ์‚ฌ์šฉ
logger.security('์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ ์‚ฌ์šฉ', {
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    tokenFound: !!storedToken,
    tokenRevoked: storedToken ? storedToken.isRevoked : false,
    timestamp: new Date().toISOString()
});

// ์‚ฌ์šฉ ์˜ˆ์‹œ - ๊ณ„์ • ์ƒ์„ฑ
logger.security('์‚ฌ์šฉ์ž ๊ณ„์ • ์ƒ์„ฑ ์„ฑ๊ณต', {
    userId: user._id.toString(),
    email,
    ip: req.ip,
    userAgent: req.headers['user-agent']
});

๐Ÿ”ง ๋ณด์•ˆ ํ—ค๋”์™€ ๋ฏธ๋“ค์›จ์–ด ์„ค์ •

Helmet์„ ํ†ตํ•œ ๋ณด์•ˆ ํ—ค๋”

// index.js์—์„œ Helmet ์„ค์ •
const helmet = require('helmet');

// ๐Ÿ›ก๏ธ ๋ณด์•ˆ ํ—ค๋” ์„ค์ • (๊ฐ€์žฅ ๋จผ์ € ์ ์šฉ)
app.use(helmet({
    contentSecurityPolicy: {
        directives: {
            defaultSrc: ["'self'"],
            styleSrc: ["'self'", "'unsafe-inline'"],
            scriptSrc: ["'self'"],
            imgSrc: ["'self'", "data:", "https:"]
        }
    },
    hsts: {
        maxAge: 31536000,
        includeSubDomains: true,
        preload: true
    }
}));

์š”์ฒญ ๋กœ๊น… ๋ฏธ๋“ค์›จ์–ด

// ์ƒ์„ธํ•œ ์š”์ฒญ ๋กœ๊น…
app.use((req, res, next) => {
    const startTime = Date.now();
    
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`);
    console.log('Origin:', req.headers.origin);
    console.log('User-Agent:', req.headers['user-agent']);
    
    res.on('finish', () => {
        const duration = Date.now() - startTime;
        logger.info('์š”์ฒญ ์™„๋ฃŒ', {
            method: req.method,
            path: req.path,
            statusCode: res.statusCode,
            duration: `${duration}ms`,
            ip: req.ip
        });
    });
    
    next();
});

๐Ÿš€ ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ์—๋Ÿฌ ์ฒ˜๋ฆฌ

์ค‘์•™ํ™”๋œ ์—๋Ÿฌ ํ•ธ๋“ค๋ง

// ์ „์—ญ ์—๋Ÿฌ ํ•ธ๋“ค๋Ÿฌ
app.use((err, req, res, next) => {
    console.error('์„œ๋ฒ„ ์—๋Ÿฌ:', err);

    // CSRF ํ† ํฐ ์—๋Ÿฌ
    if (err.code === 'EBADCSRFTOKEN') {
        return res.status(403).json({ 
            message: 'CSRF ํ† ํฐ์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.',
            hint: 'CSRF ํ† ํฐ์„ /api/csrf-token์—์„œ ๋ฐ›์•„์„œ X-CSRF-TOKEN ํ—ค๋”์— ํฌํ•จํ•˜์„ธ์š”.'
        });
    }

    // Mongoose ์œ ํšจ์„ฑ ๊ฒ€์ฆ ์—๋Ÿฌ
    if (err.name === 'ValidationError') {
        const errors = Object.values(err.errors).map(e => e.message);
        return res.status(400).json({
            success: false,
            message: '์ž…๋ ฅ ๋ฐ์ดํ„ฐ๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.',
            errors
        });
    }

    // MongoDB ์ค‘๋ณต ํ‚ค ์—๋Ÿฌ
    if (err.code === 11000) {
        return res.status(400).json({
            success: false,
            message: '์ด๋ฏธ ์‚ฌ์šฉ ์ค‘์ธ ๊ฐ’์ž…๋‹ˆ๋‹ค.'
        });
    }

    // ๊ธฐ๋ณธ ์—๋Ÿฌ ์‘๋‹ต
    res.status(500).json({
        success: false,
        message: '์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.',
        error: process.env.NODE_ENV === 'development' ? err.message : '๋‚ด๋ถ€ ์„œ๋ฒ„ ์˜ค๋ฅ˜'
    });
});

๐Ÿ“ˆ ์„ฑ๋Šฅ ์ตœ์ ํ™”์™€ ๋ชจ๋‹ˆํ„ฐ๋ง

๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ธ๋ฑ์Šค

// User ๋ชจ๋ธ์—์„œ ์„ฑ๋Šฅ ์ตœ์ ํ™”
UserSchema.index({ email: 1 });                    // ์ด๋ฉ”์ผ ๊ฒ€์ƒ‰ ์ตœ์ ํ™”
UserSchema.index({ kakaoId: 1 }, { sparse: true }); // ์†Œ์…œ ID ๊ฒ€์ƒ‰
UserSchema.index({ naverId: 1 }, { sparse: true });
UserSchema.index({ lastLocation: '2dsphere' });     // ์œ„์น˜ ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰
UserSchema.index({ createdAt: 1 });                 // ์‹œ๊ฐ„์ˆœ ์ •๋ ฌ

// RefreshToken ๋ชจ๋ธ
RefreshTokenSchema.index({ token: 1 });             // ํ† ํฐ ๊ฒ€์ƒ‰ ์ตœ์ ํ™”
RefreshTokenSchema.index({ user: 1 });              // ์‚ฌ์šฉ์ž๋ณ„ ํ† ํฐ ์กฐํšŒ
RefreshTokenSchema.index({ expiresAt: 1 });         // ๋งŒ๋ฃŒ ํ† ํฐ ์ •๋ฆฌ

๐ŸŽฏ ๊ฒฐ๋ก 

์ด๋ฒˆ ์ธ์ฆ ์‹œ์Šคํ…œ ๊ตฌ์ถ•์„ ํ†ตํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํ˜„๋Œ€์  ๋ณด์•ˆ ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ชจ๋‘ ๋งŒ์กฑํ•˜๋Š” ์‹œ์Šคํ…œ์„ ์™„์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค:

โœ… ๊ตฌํ˜„๋œ ๋ณด์•ˆ ๊ธฐ๋Šฅ

  • ๋‹ค์ธต ์ธ์ฆ ์‹œ์Šคํ…œ: JWT + Refresh Token
  • ๋ธŒ๋ฃจํŠธํฌ์Šค ๋ฐฉ์ง€: Rate Limiting + ๊ณ„์ • ์ž ๊ธˆ
  • CSRF ๋ณดํ˜ธ: ์„ ํƒ์  ํ† ํฐ ๊ฒ€์ฆ
  • ์†Œ์…œ ๋กœ๊ทธ์ธ: ์•ˆ์ „ํ•œ OAuth ๊ตฌํ˜„
  • ์ž…๋ ฅ ๊ฒ€์ฆ: ํฌ๊ด„์  ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ
  • ๋ณด์•ˆ ๋กœ๊น…: ๊ตฌ์กฐํ™”๋œ ์ด๋ฒคํŠธ ์ถ”์ 

๐Ÿš€ ํ™•์žฅ์„ฑ ๊ณ ๋ ค์‚ฌํ•ญ

  • ๋ชจ๋“ˆํ™”๋œ ๊ตฌ์กฐ: ๊ธฐ๋Šฅ๋ณ„ ํŒŒ์ผ ๋ถ„๋ฆฌ
  • ํ™˜๊ฒฝ๋ณ„ ์„ค์ •: ๊ฐœ๋ฐœ/์šด์˜ ํ™˜๊ฒฝ ๋Œ€์‘
  • ์„ฑ๋Šฅ ์ตœ์ ํ™”: ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ธ๋ฑ์‹ฑ
  • ๋ชจ๋‹ˆํ„ฐ๋ง: ์ƒ์„ธํ•œ ๋กœ๊น…๊ณผ ์—๋Ÿฌ ์ถ”์ 

๐Ÿ’ก ์‹ค์ „ ์ ์šฉ ํŒ

  1. ๋ณด์•ˆ ์ •์ฑ… ์ˆ˜๋ฆฝ: ๋น„๋ฐ€๋ฒˆํ˜ธ ๊ฐ•๋„, ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ๋“ฑ
  2. ๋ชจ๋‹ˆํ„ฐ๋ง ์ฒด๊ณ„: ๋ณด์•ˆ ์ด๋ฒคํŠธ ์•Œ๋ฆผ ์‹œ์Šคํ…œ ๊ตฌ์ถ•
  3. ์ •๊ธฐ ๋ณด์•ˆ ์ ๊ฒ€: ๋กœ๊ทธ ๋ถ„์„๊ณผ ์ทจ์•ฝ์  ์ ๊ฒ€
  4. ์‚ฌ์šฉ์ž ๊ต์œก: ์•ˆ์ „ํ•œ ๋น„๋ฐ€๋ฒˆํ˜ธ ์‚ฌ์šฉ ๊ฐ€์ด๋“œ

์ด๋Ÿฌํ•œ ์ฒด๊ณ„์ ์ธ ์ ‘๊ทผ์„ ํ†ตํ•ด ์•ˆ์ „ํ•˜๊ณ  ํ™•์žฅ ๊ฐ€๋Šฅํ•œ ์ธ์ฆ ์‹œ์Šคํ…œ์„ ๊ตฌ์ถ•ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ์‹ค์ œ ์„œ๋น„์Šค์—์„œ ์š”๊ตฌ๋˜๋Š” ๋‹ค์–‘ํ•œ ๋ณด์•ˆ ์š”๊ตฌ์‚ฌํ•ญ์„ ํšจ๊ณผ์ ์œผ๋กœ ๋Œ€์‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋ณด์•ˆ์€ ํ•œ ๋ฒˆ ๊ตฌ์ถ•ํ•˜๊ณ  ๋๋‚˜๋Š” ๊ฒƒ์ด ์•„๋‹ˆ๋ผ ์ง€์†์ ์œผ๋กœ ๊ฐœ์„ ํ•ด์•ผ ํ•˜๋Š” ์˜์—ญ์ž…๋‹ˆ๋‹ค. ์ƒˆ๋กœ์šด ์œ„ํ˜‘์— ๋Œ€์‘ํ•˜๊ณ  ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ๊ฐœ์„ ํ•˜๊ธฐ ์œ„ํ•ด ๊พธ์ค€ํ•œ ์—…๋ฐ์ดํŠธ์™€ ๋ชจ๋‹ˆํ„ฐ๋ง์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.