🔒 보안

Authentication

인증

사용자나 시스템의 신원을 확인하는 보안 프로세스. 비밀번호, MFA, 생체인식, OAuth 등 다양한 방식으로 "당신이 누구인지" 검증합니다. Authorization(인가)과 구분됩니다.

📖 상세 설명

Authentication(인증)은 "당신이 누구인지" 확인하는 프로세스입니다. 보안의 첫 번째 관문으로, 시스템에 접근하려는 주체(사용자, 서비스, 디바이스)가 자신이 주장하는 신원과 일치하는지 검증합니다. 인증은 세 가지 요소로 분류됩니다: 지식 기반(비밀번호, PIN), 소유 기반(휴대폰, 보안키), 생체 기반(지문, 얼굴). 이 중 두 가지 이상을 조합하면 MFA(다단계 인증)가 됩니다.

현대 인증 시스템은 토큰 기반 인증이 표준입니다. 사용자가 로그인하면 서버는 JWT(JSON Web Token)나 세션 토큰을 발급하고, 이후 요청마다 이 토큰으로 신원을 확인합니다. OAuth 2.0과 OpenID Connect(OIDC)는 제3자 인증을 위한 표준 프로토콜로, "Google로 로그인" 같은 소셜 로그인을 가능하게 합니다. SAML은 기업 환경에서 SSO(Single Sign-On)를 구현하는 데 사용됩니다.

비밀번호 기반 인증은 가장 취약한 방식입니다. 피싱, 크리덴셜 스터핑, 무차별 대입 공격에 노출됩니다. 이를 보완하기 위해 FIDO2/WebAuthn 표준이 등장했습니다. 물리적 보안키(YubiKey)나 플랫폼 인증자(Windows Hello, Touch ID)를 사용하여 비밀번호 없이 안전하게 인증합니다. Passkey는 FIDO2 기반의 차세대 인증 방식으로, Apple, Google, Microsoft가 공동 지원합니다.

AI/ML 시스템에서 인증은 API 접근 제어의 핵심입니다. ML 모델 API는 API 키, OAuth 토큰, mTLS(상호 TLS)로 클라이언트를 인증합니다. MLOps 파이프라인에서 서비스 간 인증은 Workload Identity나 SPIFFE/SPIRE로 처리합니다. 연합학습에서는 참여 노드의 신원을 암호학적으로 검증하여 악의적 노드의 참여를 방지합니다.

💻 코드 예제

# JWT 기반 인증 - Python FastAPI
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
from pydantic import BaseModel
import os

app = FastAPI()

# 설정
SECRET_KEY = os.environ.get("JWT_SECRET", "your-secret-key")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# 비밀번호 해싱
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

# 모델
class User(BaseModel):
    username: str
    email: str | None = None
    disabled: bool | None = None

class UserInDB(User):
    hashed_password: str

class Token(BaseModel):
    access_token: str
    token_type: str

# 가상 데이터베이스
fake_users_db = {
    "alice": {
        "username": "alice",
        "email": "alice@example.com",
        "hashed_password": pwd_context.hash("secret123"),
        "disabled": False,
    }
}

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """비밀번호 검증"""
    return pwd_context.verify(plain_password, hashed_password)

def get_user(db: dict, username: str) -> UserInDB | None:
    """사용자 조회"""
    if username in db:
        return UserInDB(**db[username])
    return None

def authenticate_user(db: dict, username: str, password: str) -> UserInDB | bool:
    """사용자 인증"""
    user = get_user(db, username)
    if not user or not verify_password(password, user.hashed_password):
        return False
    return user

def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    """JWT 액세스 토큰 생성"""
    to_encode = data.copy()
    expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    """토큰에서 현재 사용자 추출"""
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user = get_user(fake_users_db, username)
    if user is None:
        raise credentials_exception
    return user

# 엔드포인트
@app.post("/token", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
    """로그인 및 토큰 발급"""
    user = authenticate_user(fake_users_db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token = create_access_token(
        data={"sub": user.username},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    )
    return {"access_token": access_token, "token_type": "bearer"}

@app.get("/users/me", response_model=User)
async def read_users_me(current_user: User = Depends(get_current_user)):
    """현재 사용자 정보 (인증 필요)"""
    return current_user

@app.get("/ml/predict")
async def predict(current_user: User = Depends(get_current_user)):
    """ML 추론 API (인증 필요)"""
    return {"user": current_user.username, "prediction": 0.95}
// JWT 인증 미들웨어 - Node.js Express + Passport
const express = require('express');
const passport = require('passport');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

const app = express();
app.use(express.json());

// 설정
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = '30m';

// 가상 데이터베이스
const users = new Map();

// Passport JWT 전략 설정
const jwtOptions = {
    jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    secretOrKey: JWT_SECRET,
};

passport.use(new JwtStrategy(jwtOptions, async (payload, done) => {
    try {
        const user = users.get(payload.sub);
        if (user) {
            return done(null, user);
        }
        return done(null, false);
    } catch (error) {
        return done(error, false);
    }
}));

app.use(passport.initialize());

// 인증 미들웨어
const authenticate = passport.authenticate('jwt', { session: false });

// 회원가입
app.post('/register', async (req, res) => {
    const { username, email, password } = req.body;

    if (users.has(username)) {
        return res.status(400).json({ error: 'User already exists' });
    }

    // 비밀번호 해싱 (bcrypt, cost factor 12)
    const hashedPassword = await bcrypt.hash(password, 12);

    const user = {
        id: Date.now().toString(),
        username,
        email,
        hashedPassword,
        createdAt: new Date(),
    };

    users.set(username, user);
    res.status(201).json({ message: 'User created', username });
});

// 로그인
app.post('/login', async (req, res) => {
    const { username, password } = req.body;

    const user = users.get(username);
    if (!user) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    // 비밀번호 검증
    const isValid = await bcrypt.compare(password, user.hashedPassword);
    if (!isValid) {
        return res.status(401).json({ error: 'Invalid credentials' });
    }

    // JWT 토큰 생성
    const token = jwt.sign(
        {
            sub: user.username,
            email: user.email,
            iat: Math.floor(Date.now() / 1000),
        },
        JWT_SECRET,
        { expiresIn: JWT_EXPIRES_IN }
    );

    res.json({
        access_token: token,
        token_type: 'Bearer',
        expires_in: JWT_EXPIRES_IN,
    });
});

// 보호된 라우트 - 사용자 정보
app.get('/me', authenticate, (req, res) => {
    const { hashedPassword, ...userInfo } = req.user;
    res.json(userInfo);
});

// 보호된 라우트 - ML 추론 API
app.post('/api/ml/predict', authenticate, (req, res) => {
    const { input } = req.body;

    // ML 추론 로직 (예시)
    const prediction = {
        user: req.user.username,
        input,
        result: Math.random(),
        confidence: 0.95,
        timestamp: new Date(),
    };

    res.json(prediction);
});

// 토큰 갱신
app.post('/refresh', authenticate, (req, res) => {
    const newToken = jwt.sign(
        { sub: req.user.username, email: req.user.email },
        JWT_SECRET,
        { expiresIn: JWT_EXPIRES_IN }
    );

    res.json({ access_token: newToken, token_type: 'Bearer' });
});

app.listen(3000, () => console.log('Server running on port 3000'));
# OAuth 2.0 Authorization Code Flow (PKCE)

# 1. 클라이언트: PKCE 코드 생성
# code_verifier: 43-128자 랜덤 문자열
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d /=+ | cut -c -43)
# code_challenge: code_verifier의 SHA256 해시 (Base64URL 인코딩)
CODE_CHALLENGE=$(echo -n $CODE_VERIFIER | openssl sha256 -binary | base64 | tr +/ -_ | tr -d =)

echo "Code Verifier: $CODE_VERIFIER"
echo "Code Challenge: $CODE_CHALLENGE"

# 2. 사용자를 인증 서버로 리다이렉트 (Authorization Request)
# 브라우저에서 이 URL로 이동
AUTH_URL="https://auth.example.com/authorize?\
response_type=code&\
client_id=my-ml-app&\
redirect_uri=http://localhost:3000/callback&\
scope=openid%20profile%20email%20ml.predict&\
state=random-state-string&\
code_challenge=$CODE_CHALLENGE&\
code_challenge_method=S256"

echo "Authorization URL: $AUTH_URL"

# 3. 사용자 인증 후 콜백으로 authorization_code 수신
# http://localhost:3000/callback?code=AUTH_CODE&state=random-state-string

# 4. Authorization Code를 Access Token으로 교환
AUTH_CODE="received-auth-code"

curl -X POST https://auth.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$AUTH_CODE" \
  -d "redirect_uri=http://localhost:3000/callback" \
  -d "client_id=my-ml-app" \
  -d "code_verifier=$CODE_VERIFIER"

# 응답 예시:
# {
#   "access_token": "eyJhbGciOiJSUzI1NiIs...",
#   "token_type": "Bearer",
#   "expires_in": 3600,
#   "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
#   "id_token": "eyJhbGciOiJSUzI1NiIs...",
#   "scope": "openid profile email ml.predict"
# }

# 5. Access Token으로 API 호출
ACCESS_TOKEN="eyJhbGciOiJSUzI1NiIs..."

curl -X POST https://api.example.com/ml/predict \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"input": [1.0, 2.0, 3.0]}'

# 6. Refresh Token으로 새 Access Token 발급
REFRESH_TOKEN="dGhpcyBpcyBhIHJlZnJlc2g..."

curl -X POST https://auth.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN" \
  -d "client_id=my-ml-app"

# 7. 토큰 검증 (Introspection)
curl -X POST https://auth.example.com/oauth/introspect \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "my-ml-app:client-secret" \
  -d "token=$ACCESS_TOKEN"

🗣️ 실무에서 이렇게 말하세요

💬 보안 요구사항 논의에서
"인증(Authentication)은 '당신이 누구인지' 확인하는 거고, 인가(Authorization)는 '무엇을 할 수 있는지' 확인하는 거예요. 두 개를 혼동하면 안 됩니다. 우리 API는 JWT로 인증하고, RBAC으로 인가하는 구조입니다."
💬 MFA 도입 회의에서
"비밀번호만으로는 피싱 공격에 취약합니다. TOTP 앱이나 FIDO2 보안키로 MFA를 적용하면 계정 탈취를 99% 이상 방지할 수 있어요. 관리자 계정은 필수이고, 일반 사용자도 점진적으로 적용합시다."
💬 마이크로서비스 인증 설계에서
"서비스 간 인증은 mTLS로 처리하고, 외부 클라이언트는 OAuth 2.0 PKCE 플로우를 사용합시다. API Gateway에서 토큰 검증하고, 내부 서비스로는 검증된 사용자 컨텍스트를 헤더로 전달하면 됩니다."

⚠️ 주의사항 & 베스트 프랙티스

평문 비밀번호 저장

비밀번호를 평문이나 단순 해시(MD5, SHA1)로 저장하면 데이터베이스 유출 시 즉시 노출됩니다. bcrypt, Argon2id 같은 느린 해시 함수를 사용하고, 솔트를 적용하세요.

JWT 시크릿을 코드에 하드코딩

JWT 서명 키가 노출되면 공격자가 임의의 토큰을 생성할 수 있습니다. 환경변수나 시크릿 매니저에서 키를 로드하고, 주기적으로 로테이션하세요. RS256 같은 비대칭 알고리즘 사용을 권장합니다.

토큰 만료 시간을 너무 길게 설정

액세스 토큰이 오래 유효하면 토큰 탈취 시 피해가 커집니다. 액세스 토큰은 15-30분, 리프레시 토큰은 7-30일로 설정하세요. 민감한 작업에는 재인증을 요구하세요.

인증 베스트 프랙티스

MFA를 기본으로 활성화하고, Passkey/FIDO2 도입을 검토하세요. 로그인 시도 제한(Rate Limiting)과 계정 잠금을 구현하세요. 모든 인증 이벤트를 로깅하고, 의심스러운 패턴을 모니터링하세요.

🔗 관련 용어

📚 더 배우기