MyStory/Deployment and management

안전한 인증 시스템 구현하기: JWT, CSRF 보호 및 보안 구현

LupyLaon 2025. 4. 11. 14:27

안전한 인증 시스템 구현하기: JWT, CSRF 보호 및 보안 구현

안전한 웹 애플리케이션을 개발하기 위해서는 견고한 인증 시스템이 필수입니다. 이 글에선 Node.js와 Vue.js를 사용하여 회원가입, 로그인, 로그아웃 기능을 구현하면서 적용한 다양한 보안 기술과 모범 사례를 공유합니다.

목차

  1. 인증 시스템 개요
  2. 회원가입 구현
  3. 로그인 구현
  4. 로그아웃 구현
  5. 토큰 갱신 메커니즘
  6. 적용된 보안 기술
  7. 프론트엔드 연동
  8. 마치며

인증 시스템 개요

인증 시스템은 JWT(JSON Web Token)를 기반으로 합니다. 엑세스 토큰과 리프레시 토큰을 함께 사용하는 방식을 채택했으며, CSRF 보호, 계정 잠금, 토큰 순환 등 다양한 보안 기술을 적용했습니다.

구현한 인증 시스템의 주요 특징은 다음과 같습니다:

  • 이중 토큰 시스템: 짧은 수명의 엑세스 토큰 + 긴 수명의 리프레시 토큰
  • 보안 강화 인증: 비밀번호 해싱, CSRF 보호, IP 및 User-Agent 검증
  • 계정 보호: 로그인 시도 제한, 계정 잠금 기능
  • 상세한 로깅: 보안 이벤트 전용 로그 및 오류 추적

기술 스택

Node.js Vue.js MongoDB JWT Express bcrypt

회원가입 구현

회원가입 프로세스는 사용자 정보의 유효성 검사, 비밀번호 암호화, 데이터베이스 저장의 세 단계로 구성됩니다.

1. 백엔드 회원가입 로직

// authController.js의 회원가입 로직
exports.register = async (req, res) => {
    try {
        logger.info('회원가입 요청', {
            email: req.body.email,
            ip: req.ip,
            userAgent: req.headers['user-agent']
        });

        const { name, email, password, phone, termsAgree, notificationAgree } = req.body;

        // 이메일 중복 확인
        const userExists = await User.findOne({ email });
        if (userExists) {
            logger.security('이메일 중복으로 회원가입 실패', {
                email,
                ip: req.ip
            });
            return res.status(400).json({ message: '이미 사용 중인 이메일입니다.' });
        }

        // 사용자 생성
        logger.debug('사용자 생성 시도', { email });
        const user = await User.create({
            name,
            email,
            password, // 저장 전 bcrypt로 해싱됩니다
            phone,
            termsAgree,
            notificationAgree
        });

        logger.security('사용자 계정 생성 성공', {
            userId: user._id.toString(),
            email,
            ip: req.ip,
            userAgent: req.headers['user-agent']
        });

        // 생성 후 토큰 발급하여 응답
        if (user) {
            res.status(201).json({
                _id: user._id,
                name: user.name,
                email: user.email,
                token: generateAccessToken(user._id) // 액세스 토큰 발급
            });
        }
    } catch (error) {
        logger.logError('회원가입 오류', error, {
            email: req.body.email,
            ip: req.ip
        });
        res.status(500).json({ message: '서버 오류가 발생했습니다: ' + error.message });
    }
};

2. 비밀번호 해싱 처리

보안 강화 포인트: 비밀번호는 데이터베이스에 저장되기 전에 반드시 해시 처리되어야 합니다. MongoDB 스키마의 pre-save 미들웨어를 사용하여 이 과정을 자동화했습니다.
// User.js에서 비밀번호 해싱 미들웨어
UserSchema.pre('save', async function(next) {
    // 비밀번호가 수정되었을 때만 해시 처리
    if (!this.isModified('password') || !this.password) {
        return next();
    }

    try {
        const salt = await bcrypt.genSalt(10);
        this.password = await bcrypt.hash(this.password, salt);
        next();
    } catch (error) {
        next(error);
    }
});

3. 회원가입 유효성 검사

사용자 입력의 유효성을 검사하는 것이 보안의 첫 번째 방어선입니다. express-validator를 사용하여 강력한 유효성 검사 체인을 구현했습니다.

// validators.js의 회원가입 유효성 검사
const registerValidator = [
    body('name')
        .notEmpty().withMessage('이름은 필수 입력사항입니다.')
        .isLength({ min: 2, max: 50}).withMessage('이름은 2~50자 사이여야 합니다.')
        .trim()
        .escape()
        .custom(forbiddenWordsCheck),

    body('email')
        .notEmpty().withMessage('이메일은 필수 입력사항입니다.')
        .isEmail().withMessage('유효한 이메일 형식이 아닙니다.')
        .normalizeEmail(),

    body('password')
        .notEmpty().withMessage('비밀번호는 필수 입력사항입니다.')
        .isLength({ min: 8 }).withMessage('비밀번호는 최소 8자 이상이어야 합니다.')
        .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/)
        .withMessage('비밀번호는 대문자, 소문자, 숫자, 특수문자를 각각 하나 이상 포함해야 합니다.')
        .custom(passwordStrengthCheck),

    // 추가 유효성 검사 로직...

    validate // 유효성 검사 결과 확인
];
💡 비밀번호 정책: 안전한 비밀번호는 최소 8자 이상이며, 대문자, 소문자, 숫자, 특수문자를 각각 하나 이상 포함해야 합니다. 또한 일반적인 패턴(예: '123456', 'password')은 거부됩니다.

로그인 구현

로그인 프로세스는 사용자 검증, 계정 보호, 토큰 발급의 세 단계로 구성됩니다.

1. 로그인 로직

// authController.js의 로그인 로직
exports.login = async (req, res) => {
    try {
        const { email, password } = req.body;
        logger.info('로그인 시도', {
            email,
            ip: req.ip,
            userAgent: req.headers['user-agent']
        });

        const user = await User.findOne({ email });

        // 사용자가 존재하지 않거나 계정이 잠겨있는 경우
        if (!user || user.lockUtils > Date.now()) {
            logger.security(user ? '잠긴 계정으로 로그인 시도' : '존재하지 않는 계정으로 로그인 시도', {
                email,
                ip: req.ip,
                userAgent: req.headers['user-agent'],
                accoutLocked: user ? true : false
            });

            return res.status(401).json({
                message: user?.lockUtils > Date.now()
                ? '계정이 일시적으로 잠겼습니다. 나중에 다시 시도하세요.'
                : '이메일 또는 비밀번호가 일치하지 않습니다.'
            });
        }

        // 비밀번호 확인
        const isMatch = await user.matchPassword(password);

        if (isMatch) {
            // 로그인 성공 처리...
            user.loginAttempts = 0;
            user.lockUntil = 0;
            await user.save();

            // 액세스 토큰 및 리프레시 토큰 발급
            const accessToken = generateAccessToken(user._id);
            const refreshToken = generateRefreshToken(user._id);

            // 리프레시 토큰 저장 및 응답...
        } else {
            // 로그인 실패 시 시도 횟수 증가
            user.loginAttempts += 1;

            // 5회 이상 실패 시 계정 잠금 (30분)
            if (user.loginAttempts >= 5) {
                user.lockUntil = Date.now() + 30 * 60 * 1000;
                // 계정 잠금 로깅...
            }

            await user.save();
            // 로그인 실패 로깅 및 응답...
        }
    } catch (error) {
        // 오류 로깅 및 처리...
    }
};

2. 이중 토큰 시스템

보안 강화 포인트: 짧은 수명의 액세스 토큰과 긴 수명의 리프레시 토큰을 함께 사용하여 보안성과 사용자 경험의 균형을 맞췄습니다.
// jwtUtils.js의 토큰 생성 함수들
// JWT 엑세스 토큰 생성 (1시간)
const generateAccessToken = (userId) => {
    return jwt.sign(
        { id: userId },
        process.env.JWT_SECRET,
        {
            algorithm: 'HS256',
            expiresIn: '1h',
            issuer: 'guaridan-app',
            audience: 'guardian-users',
            subject: userId.toString(),
            jwtid: crypto.randomBytes(16).toString('hex')   // 고유한 토큰 ID
        }
    );
};

// 리프레시 토큰 생성 (7일)
const generateRefreshToken = (userId) => {
    const refreshToken = jwt.sign(
        { id: userId },
        process.env.REFRESH_TOKEN_SECRET,
        {
            algorithm: 'HS256',
            expiresIn: '7d',
            issuer: 'guardian-app',
            audience: 'guardian-users',
            subject: userId.toString(),
            jwtid: crypto.randomBytes(16).toString('hex')
        }
    );

    return refreshToken;
};

3. 계정 보호 장치

🛡️ 보안 강화 포인트: 다수의 로그인 실패 시도를 감지하여 계정을 자동으로 일시 잠금하는 메커니즘을 구현했습니다. 이는 무차별 대입 공격(Brute Force Attack)으로부터 사용자 계정을 보호합니다.

또한 Express 미들웨어를 사용한 속도 제한(Rate Limiting)을 구현하여 IP 기반 공격을 방어합니다:

// authRoutes.js의 로그인 속도 제한
const loginLimiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15분
    max: 5, // 최대 시도 횟수
    message: { message: '너무 많은 로그인 시도. 15분 후에 다시 시도하세요. '}
});

// 로그인 라우트에 적용
router.post('/login', loginValidator, loginLimiter, authController.login);

로그아웃 구현

안전한 로그아웃 프로세스는 단순히 클라이언트 측 토큰을 삭제하는 것뿐만 아니라, 서버 측에서도 토큰을 무효화하는 작업을 포함합니다.

// authController.js의 로그아웃 로직
exports.logout = async (req, res) => {
    try {
        const refreshToken = req.cookies.refreshToken;

        if (refreshToken) {
            const storedToken = await RefreshToken.findOneAndUpdate(
                { token: refreshToken },
                { isRevoked: true }
            );

            if (storedToken) {
                logger.security('로그아웃 성공', {
                    userId: storedToken.user.toString(),
                    ip: req.ip,
                    userAgent: req.headers['user-agent']
                });
            }
        }

        // 쿠키 삭제
        res.clearCookie('refreshToken');
        res.json({ message: '로그아웃 성공' });
    } catch (error) {
        // 오류 처리...
    }
};
💡 보안 팁: 로그아웃 시 리프레시 토큰을 서버에서 취소(revoke)하는 것은 토큰 탈취에 대한 위험을 크게 줄입니다. 우리 시스템에서는 토큰에 isRevoked 플래그를 설정하여 이 작업을 처리합니다.

토큰 갱신 메커니즘

엑세스 토큰이 만료되더라도 사용자가 다시 로그인할 필요 없이 서비스를 이용할 수 있도록 토큰 갱신 메커니즘을 구현했습니다.

// authController.js의 토큰 갱신 로직
exports.refreshToken = async (req, res) => {
    try {
        // 쿠키에서 리프레시 토큰 가져오기
        const refreshToken = req.cookies.refreshToken;

        if (!refreshToken) {
            // 토큰 없음 처리...
        }

        // 데이터베이스에서 토큰 찾기
        const storedToken = await RefreshToken.findOne({ token: refreshToken });

        if (!storedToken || !storedToken.$isValid()) {
            // 유효하지 않은 토큰 처리...
        }

        // 토큰 검증...

        // 보안 강화: 요청 IP와 User-Agent 확인
        if (storedToken.ip !== req.ip || storedToken.userAgent !== req.headers['user-agent']) {
            logger.security('의심스러운 리프레시 토큰 사용 시도', {
                // 로깅 정보...
            });

            // 의심스러운 활동 - 토큰 취소
            storedToken.isRevoked = true;
            await storedToken.save();

            return res.status(401).json({
                message: '의심스러운 활동이 감지되었습니다. 다시 로그인해주세요.'
            });
        }

        // 새 엑세스 토큰 발급
        const newAccessToken = generateAccessToken(user._id);

        // 리프레시 토큰 교체 (토큰 순환)
        const newRefreshToken = generateRefreshToken(user._id);

        // 토큰 교체 및 응답...
    } catch (error) {
        // 오류 처리...
    }
};
🔄 토큰 순환(Token Rotation): 리프레시 토큰을 사용할 때마다 새로운 토큰을 발급하고 이전 토큰을 무효화하는 방식입니다. 이는 토큰 탈취 시 공격자가 토큰을 장기간 활용할 수 없게 만듭니다.

적용된 보안 기술

guardians 인증 시스템에는 다양한 보안 기술이 적용되었습니다.

1. CSRF 보호

교차 사이트 요청 위조(CSRF) 공격을 방지하기 위해 csurf 미들웨어를 사용했습니다.

// index.js의 CSRF 보호 설정
const csrfProtection = csrf({
    cookie: {
        key: 'XSRF-TOKEN',      // 쿠키 이름
        httpOnly: false,         // JavaScript에서 접근 가능
        sameSite: 'lax',     // 크로스 사이트 요청 허용
        secure: process.env.NODE_ENV === 'production'   // HTTPS에서만 전송 (프로덕션)
    }
});

// CSRF 보호가 필요한 라우트에만 적용
app.use('/api/auth', csrfProtection, authRoutes);

// CSRF 토큰 제공 엔드포인트
app.get('/api/csrf-token', csrfProtection, (req, res) => {
    res.json({ csrfToken: req.csrfToken() });
});

2. HTTP 보안 헤더

Helmet 미들웨어를 사용하여 다양한 HTTP 보안 헤더를 설정했습니다.

// index.js에서 Helmet 적용
app.use(helmet());

3. 로깅 시스템

보안 이벤트 추적과 문제 해결을 위한 상세한 로깅 시스템을 구현했습니다.

// logger.js의 로깅 설정
const logger = winston.createLogger({
    // 로거 설정...
    transports: [
        // 콘솔 로그
        new transports.Console({ /* 설정... */ }),

        // 정보 레벨 로그 파일
        new transports.DailyRotateFile({ /* 설정... */ }),

        // 오류 레벨 로그 파일
        new transports.DailyRotateFile({ /* 설정... */ }),

        // 보안 이벤트 전용 로그 파일
        new transports.DailyRotateFile({
            filename: path.join(logDir, 'security-%DATE%.log'),
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '90d',
            level: 'info',
            // 보안 로그만 필터링
            format: format.combine(
                /* 설정... */
            )
        })
    ]
});

// 보안 이벤트 로깅 전용 메서드
logger.security = (message, meta = {}) => {
    logger.info(message, { security: true, ...meta });
};
📊 로깅의 중요성: 상세한 보안 로그는 침해 시도를 감지하고, 문제를 디버깅하며, 보안 감사를 수행하는 데 필수적입니다. 우리 시스템은 보안 이벤트를 별도의 로그 파일에 기록하여 90일간 보관합니다.

프론트엔드 연동

Vue.js 프론트엔드는 백엔드 인증 시스템과 원활하게 통합됩니다.

1. 인증 서비스

// auth.js - 프론트엔드 인증 서비스
export const authService = {
    // 엑세스 토큰 저장
    setToken(token) {
        localStorage.setItem(ACCESS_TOKEN_KEY, token);
    },

    // 엑세스 토큰 가져오기
    getToken() {
        return localStorage.getItem(ACCESS_TOKEN_KEY);
    },

    // 사용자 정보 저장
    setUserInfo(user) {
        localStorage.setItem(USER_INFO_KEY, JSON.stringify(user));
        authEvents.emit('user-changed', user);
    },

    // 토큰 갱신 처리
    async refreshToken() {
        try {
            const response = await fetch('http://localhost:5000/api/auth/refresh-token', {
                method: 'POST',
                credentials: 'include', // 쿠키 포함
                headers: {
                    'Content-Type' : 'application/json'
                }
            });

            // 응답 처리...
        } catch (error) {
            // 오류 처리...
        }
    },

    // 추가 메서드...
};

2. API 서비스

// api.js - 백엔드 API 통신 서비스
const api = axios.create({
  baseURL: API_URL,
  headers: {
    'Content-Type': 'application/json'
  },
  withCredentials: true     // 쿠키를 자동으로 포함
});

// 요청 인터셉터 - 토큰 및 CSRF 토큰 추가
api.interceptors.request.use(
    async (config) => {
        // JWT 토큰 추가
        const token = authService.getToken();
        if (token) {
            config.headers.Authorization = `Bearer ${token}`;
        }

        // POST, PUT, DELETE 요청에만 CSRF 토큰 추가
        if (['post', 'put', 'delete'].includes(config.method)) {
            try {
                const csrfToken = await fetchCsrfToken();
                if (csrfToken) {
                    config.headers['X-CSRF-TOKEN'] = csrfToken;
                }
            } catch (error) {
                console.error('CSRF 토큰 설정 오류 : ', error);
            }
        }
        return config;
    },
    (error) => Promise.reject(error)
);

3. 회원가입 및 로그인 폼

안전한 인증을 위해 프론트엔드에서도 비밀번호 정책 등의 유효성 검사를 구현했습니다.

// 회원가입 폼의 유효성 검사
handleJoin() {
    // 초기화
    this.nameError = '';
    this.emailError = '';
    this.passwordError = '';
    this.confirmPasswordError = '';
    this.phoneError = '';
    this.termsError = '';

    let isValid = true;

    // 이름 유효성 검사
    if (!this.name || this.name.trim().length < 2) {
      this.nameError = '이름을 2자 이상 입력해주세요.';
      isValid = false;
    }

    // 이메일 유효성 검사
    if (!this.validateEmail(this.email)) {
      this.emailError = '유효한 이메일 주소를 입력해주세요.';
      isValid = false;
    }

    // 비밀번호 유효성 검사
    const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
    if (!passwordRegex.test(this.password)) {
      this.passwordError = '비밀번호는 최소 8자 이상이며, 대문자, 소문자, 숫자, 특수문자를 각각 하나 이상 포함해야 합니다.';
      isValid = false;
    }

    // 추가 검사...

    if (!isValid) return;

    // API 호출 및 처리...
}

마치며

이 글에서는 Node.js와 Vue.js를 사용하여 안전한 인증 시스템을 구현하는 방법을 살펴보았습니다. JWT 기반 인증, 이중 토큰 시스템, CSRF 보호, 계정 잠금 등 다양한 보안 기술을 적용하여 사용자 계정을 보호하는 구현을 해보았습니다..

향후 개선 방향

  • 2FA(Two-Factor Authentication): 이중 인증 도입으로 보안 강화
  • OAuth 소셜 로그인 확장: 다양한 소셜 로그인 옵션 제공
  • 로그인 활동 모니터링: 사용자별 로그인 활동 기록 및 알림 기능
  • 자동화된 보안 테스트: 지속적인 보안 취약점 검사 도입

이 글이 여러분의 안전한 인증 시스템 구현에 도움이 되었기를 바랍니다. 질문이나 의견이 있으시면 댓글로 남겨주세요.