diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..dca2646 --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,125 @@ +name: Deploy Workinkorea Development Server +on: + push: + branches: [dev] + +env: + BASE_URL: byeong98.xyz + DOCKER_IMAGE_NAME: workinkorea-server + PORT: 8000 + +jobs: + development-build-and-deploy: + runs-on: ubuntu-latest + environment: development + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + run: | + docker build -t ${{ env.DOCKER_IMAGE_NAME }} . + docker save ${{ env.DOCKER_IMAGE_NAME }} | gzip > image.tar.gz + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + + - name: Add SSH known hosts + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts + + - name: Copy image to remote server + run: | + scp -P ${{ secrets.SSH_PORT }} image.tar.gz ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}:~/ + + - name: Deploy on remote server + run: | + ssh -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} bash << 'ENDSSH' + set -e + + IMAGE_NAME="${{ env.DOCKER_IMAGE_NAME }}" + BASE_URL="${{ env.BASE_URL }}" + PORT="${{ env.PORT }}" + + echo "Loading Docker image..." + docker load < ~/image.tar.gz + + echo "Stopping existing container..." + docker stop ${IMAGE_NAME} 2>/dev/null || true + docker rm ${IMAGE_NAME} 2>/dev/null || true + + echo "Starting new container..." + docker run -d \ + --name ${IMAGE_NAME} \ + --network core_network \ + --restart unless-stopped \ + --label "traefik.enable=true" \ + --label "traefik.http.routers.${IMAGE_NAME}.rule=Host(\`arw.${BASE_URL}\`)" \ + --label "traefik.http.routers.${IMAGE_NAME}.entrypoints=websecure" \ + --label "traefik.http.routers.${IMAGE_NAME}.tls.certresolver=le" \ + --label "traefik.http.services.${IMAGE_NAME}.loadbalancer.server.port=${PORT}" \ + -e COOKIE_DOMAIN="${{ secrets.COOKIE_DOMAIN }}" \ + -e CLIENT_URL="${{ secrets.CLIENT_URL }}" \ + -e ORIGINS_URLS="${{ secrets.ORIGINS_URLS }}" \ + -e DATABASE_SYNC_URL="${{ secrets.DATABASE_SYNC_URL }}" \ + -e DATABASE_ASYNC_URL="${{ secrets.DATABASE_ASYNC_URL }}" \ + -e REDIS_HOST="${{ secrets.REDIS_HOST }}" \ + -e REDIS_PORT="${{ secrets.REDIS_PORT }}" \ + -e REDIS_DB="${{ secrets.REDIS_DB }}" \ + -e GOOGLE_CLIENT_ID="${{ secrets.GOOGLE_CLIENT_ID }}" \ + -e GOOGLE_CLIENT_SECRET="${{ secrets.GOOGLE_CLIENT_SECRET }}" \ + -e GOOGLE_REDIRECT_URI="${{ secrets.GOOGLE_REDIRECT_URI }}" \ + -e GOOGLE_AUTHORIZATION_URL="${{ secrets.GOOGLE_AUTHORIZATION_URL }}" \ + -e GOOGLE_TOKEN_URL="${{ secrets.GOOGLE_TOKEN_URL }}" \ + -e GOOGLE_USER_INFO_URL="${{ secrets.GOOGLE_USER_INFO_URL }}" \ + -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ + -e JWT_ALGORITHM="${{ secrets.JWT_ALGORITHM }}" \ + -e ACCESS_TOKEN_EXPIRE_MINUTES="${{ secrets.ACCESS_TOKEN_EXPIRE_MINUTES }}" \ + -e REFRESH_TOKEN_EXPIRE_MINUTES="${{ secrets.REFRESH_TOKEN_EXPIRE_MINUTES }}" \ + -e MAIL_USERNAME="${{ secrets.MAIL_USERNAME }}" \ + -e MAIL_PASSWORD="${{ secrets.MAIL_PASSWORD }}" \ + -e MAIL_FROM_NAME="${{ secrets.MAIL_FROM_NAME }}" \ + -e MAIL_FROM="${{ secrets.MAIL_FROM }}" \ + -e MAIL_PORT="${{ secrets.MAIL_PORT }}" \ + -e MAIL_SERVER="${{ secrets.MAIL_SERVER }}" \ + -e MINIO_ENDPOINT="${{ secrets.MINIO_ENDPOINT }}" \ + -e MINIO_ACCESS_KEY="${{ secrets.MINIO_ACCESS_KEY }}" \ + -e MINIO_SECRET_KEY="${{ secrets.MINIO_SECRET_KEY }}" \ + -e MINIO_BUCKET_NAME="${{ secrets.MINIO_BUCKET_NAME }}" \ + -e ADMIN_JWT_SECRET="${{ secrets.ADMIN_JWT_SECRET }}" \ + -e ADMIN_JWT_ALGORITHM="${{ secrets.ADMIN_JWT_ALGORITHM }}" \ + -e ADMIN_ACCESS_TOKEN_EXPIRE_MINUTES="${{ secrets.ADMIN_ACCESS_TOKEN_EXPIRE_MINUTES }}" \ + -e ADMIN_REFRESH_TOKEN_EXPIRE_MINUTES="${{ secrets.ADMIN_REFRESH_TOKEN_EXPIRE_MINUTES }}" \ + -e ADMIN_EMAILS="${{ secrets.ADMIN_EMAILS }}" \ + ${IMAGE_NAME} + + echo "Cleaning up old images..." + docker image prune -f + + echo "Removing temporary files..." + rm -f ~/image.tar.gz + + echo "Deployment completed successfully!" + ENDSSH + + - name: Send Discord Deployment Notification + if: always() + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL_DEV }} + status: ${{ job.status }} + title: "Workinkorea-Server / Development" + description: | + **Deployment ${{ job.status == 'success' && 'Successful' || 'Failed' }}** + + • **Branch**: `${{ github.ref_name }}` + • **Commit**: `${{ github.sha }}` + • **Message**: ${{ github.event.head_commit.message }} + • **Environment**: Development + • **URL**: https://arw.${{ env.BASE_URL }} \ No newline at end of file diff --git a/.github/workflows/deploy-pro.yml b/.github/workflows/deploy-pro.yml index 56664a2..29d697d 100644 --- a/.github/workflows/deploy-pro.yml +++ b/.github/workflows/deploy-pro.yml @@ -21,23 +21,6 @@ jobs: uses: 'google-github-actions/auth@v2' with: credentials_json: '${{ secrets.GCP_SA_KEY }}' - - - name: database migration - run: | - pip install --upgrade pip - pip install alembic asyncpg psycopg2-binary sqlalchemy python-dotenv pydantic-settings pydantic redis - wget https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.8.0/cloud-sql-proxy.linux.amd64 -O cloud-sql-proxy - chmod +x cloud-sql-proxy - ./cloud-sql-proxy workinkorea-main:asia-northeast3:workinkorea-postgresql & - sleep 10 - export DATABASE_SYNC_URL="${{ secrets.DATABASE_SYNC_URL }}" - export DATABASE_ASYNC_URL="${{ secrets.DATABASE_ASYNC_URL }}" - export REDIS_HOST="${{ secrets.REDIS_HOST }}" - export REDIS_PORT="${{ secrets.REDIS_PORT }}" - export REDIS_DB="${{ secrets.REDIS_DB }}" - alembic upgrade head - pkill cloud-sql-proxy - - name: Build and Push Container run: | @@ -97,22 +80,18 @@ jobs: --env-vars-file env.yaml # discord notification - - name: send success message - if: success() - uses: sarisia/actions-status-discord@v1 - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} - status: ${{ job.status }} - title: "Workinkorea-Server-Production" - description: | - **Image: ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO_NAME }}/${{ env.IMAGE_NAME }}:${{ github.sha }}** - - - name: send failure message - if: failure() + - name: Send Discord Deployment Notification + if: always() uses: sarisia/actions-status-discord@v1 with: webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} status: ${{ job.status }} - title: "Workinkorea-Server-Production" + title: "Workinkorea-Server / Production" description: | - **Image: ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPO_NAME }}/${{ env.IMAGE_NAME }}:${{ github.sha }}** \ No newline at end of file + **Deployment ${{ job.status == 'success' && 'Successful' || 'Failed' }}** + + • **Branch**: `${{ github.ref_name }}` + • **Commit**: `${{ github.sha }}` + • **Message**: ${{ github.event.head_commit.message }} + • **Environment**: Production + • **URL**: https://workinkorea.com \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index cd82c01..f3a135f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,16 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim WORKDIR /app +# Redis 설치 +RUN apt-get update && \ + apt-get install -y redis-server && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Redis 설정 (localhost만 접근 가능하도록) +RUN sed -i 's/bind 127.0.0.1 ::1/bind 127.0.0.1/' /etc/redis/redis.conf && \ + sed -i 's/protected-mode yes/protected-mode no/' /etc/redis/redis.conf + # 의존성 파일 복사 COPY pyproject.toml uv.lock ./ diff --git a/Jenkinsfile b/Jenkinsfile index 2f5e518..6ce3b62 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -61,14 +61,13 @@ pipeline { steps { echo "Determining colors.." script { - def blueRunning = sh( - script: "docker ps -aq -f 'name=workinkorea-server-blue'", + script: "docker ps -q -f 'name=workinkorea-server-blue'", returnStdout: true ).trim() def greenRunning = sh( - script: "docker ps -aq -f 'name=workinkorea-server-green'", + script: "docker ps -q -f 'name=workinkorea-server-green'", returnStdout: true ).trim() @@ -157,7 +156,7 @@ pipeline { echo "Health checking.." script { def healthCheck = sh( - script: "docker inspect -f '{{.State.Running}}' ${env.DOCKER_IMAGE_NAME}-${env.NEW_COLOR}", + script: "docker inspect -f '{{.State.Running}}' ${env.DOCKER_IMAGE_NAME}-${env.NEW_COLOR} 2>/dev/null || echo 'false'", returnStdout: true ).trim() @@ -285,8 +284,8 @@ pipeline { script { sh """ docker stop ${env.DOCKER_IMAGE_NAME}-${env.COLOR} || true - docker container prune -f || true - docker image prune -f || true + docker rm ${env.DOCKER_IMAGE_NAME}-${env.COLOR} || true + docker rmi ${env.DOCKER_IMAGE_NAME}-${env.COLOR} || true """ } discordSend description: "${env.DOCKER_IMAGE_NAME}-${env.NEW_COLOR} deployed successfully", @@ -301,8 +300,8 @@ pipeline { try{ sh """ docker stop ${env.DOCKER_IMAGE_NAME}-${env.NEW_COLOR} || true - docker container prune -f || true - docker image prune -f || true + docker rm ${env.DOCKER_IMAGE_NAME}-${env.NEW_COLOR} || true + docker rmi ${env.DOCKER_IMAGE_NAME}-${env.NEW_COLOR} || true """ if (env.COLOR != "none") { echo "Rolling back to ${env.COLOR} container..." diff --git a/app/admin/dependencies.py b/app/admin/dependencies.py index 464e1bd..a011780 100644 --- a/app/admin/dependencies.py +++ b/app/admin/dependencies.py @@ -1,6 +1,6 @@ import jwt from app.auth.models import User -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request from app.core.settings import SETTINGS from app.database import get_async_session from sqlalchemy.ext.asyncio import AsyncSession @@ -22,7 +22,8 @@ def get_auth_repository( async def get_admin_user( - credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)), auth_repository: AuthRepository = Depends(get_auth_repository) ) -> User: """ @@ -36,7 +37,13 @@ async def get_admin_user( raises: HTTPException """ - access_token = credentials.credentials + access_token = None + if credentials: + access_token = credentials.credentials + + if not access_token: + access_token = request.cookies.get("access_token") + env_admin_emails: str = SETTINGS.ADMIN_EMAILS if not access_token: @@ -51,7 +58,6 @@ async def get_admin_user( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Admin JWT configuration not found" ) - try: payload = jwt.decode( access_token, @@ -66,7 +72,6 @@ async def get_admin_user( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) - # 어드민 토큰 타입 체크 if token_type != "admin_access": raise HTTPException( @@ -83,33 +88,28 @@ async def get_admin_user( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) - user = await auth_repository.get_user_by_email(email) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) - # user_gubun이 'admin'인지 체크 if user.user_gubun != "admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required - invalid user type" ) - # 어드민 이메일 리스트에 있는지 체크 (이중 체크) if not SETTINGS.ADMIN_EMAILS: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Admin emails not configured" ) - admin_emails = [email.strip() for email in env_admin_emails.split(",")] if user.email not in admin_emails: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required - email not authorized" ) - return user diff --git a/app/auth/dependencies.py b/app/auth/dependencies.py index a8f79d1..cd122c0 100644 --- a/app/auth/dependencies.py +++ b/app/auth/dependencies.py @@ -26,7 +26,8 @@ def get_company_repository( async def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False)), auth_repository: AuthRepository = Depends(get_auth_repository) ) -> User: """ @@ -34,44 +35,57 @@ async def get_current_user( args: credentials: HTTPAuthorizationCredentials """ - access_token = credentials.credentials - + access_token = None + if credentials: + access_token = credentials.credentials + if not access_token: + access_token = request.cookies.get("access_token") if not access_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated" ) - try: payload = jwt.decode( access_token, SETTINGS.JWT_SECRET, algorithms=[SETTINGS.JWT_ALGORITHM] ) - email: str = payload.get("sub") - if not email: + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Access token expired" + ) + except jwt.InvalidSignatureError: # 어드민 토큰으로 일반 유저 api 쓰려고 할 때 발생할 수도 있음 + try: # 어드민 시크릿으로 재검증 + payload = jwt.decode( + access_token, + SETTINGS.ADMIN_JWT_SECRET, + algorithms=[SETTINGS.ADMIN_JWT_ALGORITHM] + ) + except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid token" + detail="Invalid token signature" ) - except jwt.ExpiredSignatureError: + except Exception as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Access token expired" + # detail=f"Invalid token: {e}" -> 프로덕션 환경에서는 예외 상세메세지를 숨기는 편이 좋음 + detail=f"Invalid token" ) - except Exception: + email: str = payload.get("sub") + if not email: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token" ) - user = await auth_repository.get_user_by_email(email) if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found" ) - return user diff --git a/app/auth/router.py b/app/auth/router.py index 2d3aa33..55a4139 100644 --- a/app/auth/router.py +++ b/app/auth/router.py @@ -148,6 +148,15 @@ async def login_google_callback( # jwt token 쿠키에 저장 success_url = f"{SETTINGS.CLIENT_URL}/auth/callback?{urlencode(status_massage_dict)}" response = RedirectResponse(url=success_url) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=False, # 개발 환경에서는 secure=False + max_age=SETTINGS.ACCESS_TOKEN_EXPIRE_MINUTES, + samesite="lax", + domain=SETTINGS.COOKIE_DOMAIN + ) response.set_cookie( key="refresh_token", value=refresh_token, @@ -236,6 +245,10 @@ async def logout(request: Request, auth_redis_service: AuthRedisService = Depend key="refresh_token", domain=SETTINGS.COOKIE_DOMAIN ) + response.delete_cookie( + key="access_token", + domain=SETTINGS.COOKIE_DOMAIN + ) return response except Exception as e: return {"error": str(e)} @@ -305,7 +318,19 @@ async def refresh(request: Request, if not access_token: return JSONResponse(content={"message": "Failed to create access token"}, status_code=500) - return JSONResponse(content={"access_token": access_token, "token_type": token_type}) + # return JSONResponse(content={"access_token": access_token, "token_type": token_type}) + response = JSONResponse(content={"success": True}, status_code=200) + response.set_cookie( + key="access_token", + value=access_token, + httponly=True, + secure=False, + max_age=SETTINGS.ACCESS_TOKEN_EXPIRE_MINUTES, + samesite="lax", + domain=SETTINGS.COOKIE_DOMAIN + ) + return response + except Exception as e: return JSONResponse(content={"error": str(e)}, status_code=500) @@ -435,11 +460,20 @@ async def company_login(form_data: OAuth2PasswordRequestForm = Depends(), status_massage_dict = { "user_id": company_user.id, "company_id": company_user.company_id, - "token": access_company_token, + # "token": access_company_token, -> 이제 쿠키로 감 } url = f"{SETTINGS.CLIENT_URL}/company?{urlencode(status_massage_dict)}" response = JSONResponse(content={"url": url}) + response.set_cookie( + key="access_token", + value=access_company_token, + httponly=True, + secure=False, # 개발 환경에서는 secure=False + max_age=SETTINGS.ACCESS_TOKEN_EXPIRE_MINUTES, + samesite="lax", + domain=SETTINGS.COOKIE_DOMAIN + ) response.set_cookie( key="refresh_token", value=refresh_company_token, diff --git a/dockerfile b/dockerfile index cd82c01..f3a135f 100644 --- a/dockerfile +++ b/dockerfile @@ -2,6 +2,16 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim WORKDIR /app +# Redis 설치 +RUN apt-get update && \ + apt-get install -y redis-server && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Redis 설정 (localhost만 접근 가능하도록) +RUN sed -i 's/bind 127.0.0.1 ::1/bind 127.0.0.1/' /etc/redis/redis.conf && \ + sed -i 's/protected-mode yes/protected-mode no/' /etc/redis/redis.conf + # 의존성 파일 복사 COPY pyproject.toml uv.lock ./ diff --git a/entrypoint.sh b/entrypoint.sh index 7b9361b..eeb954e 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,8 +1,11 @@ #!/bin/sh set -e +# start redis +redis-server /etc/redis/redis.conf --daemonize yes + # database migration -uv run alembic upgrade head +# uv run alembic upgrade head # start server -exec uv run uvicorn app.main:app --host 0.0.0.0 --port "$PORT" \ No newline at end of file +uv run uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" \ No newline at end of file