diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml new file mode 100644 index 0000000..a2e333e --- /dev/null +++ b/.github/workflows/publish-dev.yml @@ -0,0 +1,174 @@ +name: 개발 서비스 배포 + +on: + push: + branches: [ develop ] + workflow_dispatch: +env: + ENVIRONMENT: dev + TF_WORKSPACE: dev +jobs: + apply-terraform: + name: 'Terraform 리소스 적용' + runs-on: ubuntu-latest + outputs: + rds_endpoint: ${{ steps.generate_output.outputs.rdx_endpoint }} + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + - name: Terraform 설치 + uses: hashicorp/setup-terraform@v3 + with: + cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }} + - name: Terraform 초기화 + run: terraform init + - name: AWS 인증 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + - name: Terraform 적용 + run: | + terraform apply -auto-approve \ + -var 'environment=${{ env.ENVIRONMENT }}' \ + -var 'aws_region=ap-northeast-2' \ + -var 'database_user=${{ secrets.DEV_DATABASE_USER }}' \ + -var 'database_password=${{ secrets.DEV_DATABASE_PASSWORD }}' + - name: 출력 생성 + id: generate_output + run: echo "rdx_endpoint=$(terraform output -raw rds_endpoint)" >> "$GITHUB_OUTPUT" + build-server: + name: '서버 빌드' + runs-on: ubuntu-latest + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + - name: JDK 설치 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '17' + - name: 서버 빌드 + run: | + sudo chmod +x ./gradlew + ./gradlew clean build -x test + - name: 서버 실행 파일 아티펙트 업로드 + uses: actions/upload-artifact@v4 + with: + name: server + path: build/libs/*.jar + docker-build: + name: 'Docker 이미지 빌드' + needs: [ apply-terraform, build-server ] + runs-on: ubuntu-latest + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + - name: 빌드 폴더 생성 + run: mkdir -p build/libs + - name: 서버 실행 파일 다운로드 + uses: actions/download-artifact@v4 + with: + name: server + path: build/libs + - name: 도커 이미지 빌드 + run: | + docker buildx build \ + --build-arg SPRING_PROFILES_ACTIVE=${{ env.ENVIRONMENT }} \ + --build-arg DATABASE_ADDRESS=${{ needs.apply-terraform.outputs.rds_endpoint }} \ + --build-arg DATABASE_USERNAME=${{ secrets.DEV_DATABASE_USER }} \ + --build-arg DATABASE_PASSWORD=${{ secrets.DEV_DATABASE_PASSWORD }} \ + --build-arg JWT_SECRET=${{ secrets.JWT_SECRET }} \ + --build-arg JWT_TOKEN_VALIDITY_TIME=${{ secrets.JWT_TOKEN_VALIDITY_TIME }} \ + --build-arg KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }} \ + -t gooiman-api:${{ github.sha }} . + + - name: 도커 이미지 저장 + run: docker save gooiman-api:${{ github.sha }} > image.tar + + - name: 도커 이미지 아티펙트 업로드 + uses: actions/upload-artifact@v4 + with: + name: docker-image + path: image.tar + + ecr-push: + name: 'ECR 푸시' + needs: [ apply-terraform, docker-build ] + runs-on: ubuntu-latest + outputs: + ecr_registry: ${{ steps.login-ecr.outputs.registry }} + ecr_repository: gooiman_${{ env.ENVIRONMENT }} + image_tag: ${{ github.sha }} + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + + - name: 도커 이미지 아티펙트 다운로드 + uses: actions/download-artifact@v4 + with: + name: docker-image + + - name: 도커 이미지 로드 + run: docker load < image.tar + + - name: AWS 인증 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + + - name: Amazon ECR 로그인 + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Amazon ECR에 이미지 푸시 + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: gooiman_${{ env.ENVIRONMENT }} + IMAGE_TAG: ${{ github.sha }} + run: | + docker tag gooiman-api:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + + codedeploy: + name: 'CodeDeploy 배포' + needs: ecr-push + runs-on: ubuntu-latest + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + + - name: AWS 인증 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + - name: 배포 파일 업로드 + env: + ECR_REGISTRY: ${{ needs.ecr-push.outputs.ecr_registry }} + ECR_REPOSITORY: ${{ needs.ecr-push.outputs.ecr_repository }} + IMAGE_TAG: ${{ needs.ecr-push.outputs.image_tag }} + run: | + cd ./codedeploy/${{ env.ENVIRONMENT }} + mkdir scripts + touch scripts/deploy.sh + echo "aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin $ECR_REGISTRY" >> scripts/deploy.sh + echo "export ECR_REGISTRY=$ECR_REGISTRY" >> scripts/deploy.sh + echo "export ECR_REPOSITORY=$ECR_REPOSITORY" >> scripts/deploy.sh + echo "export IMAGE_TAG=$IMAGE_TAG" >> scripts/deploy.sh + echo "docker-compose -f /var/deployment/docker-compose.yml up -d" >> scripts/deploy.sh + zip -r ${{ github.sha }}.zip . + aws s3 cp ${{ github.sha }}.zip s3://gooiman-${{ env.ENVIRONMENT }}-deploy-bucket/${{ github.sha }}.zip + + + - name: CodeDeploy 배포 생성 + run: | + aws deploy create-deployment \ + --application-name gooiman_${{ env.ENVIRONMENT }}_deploy \ + --deployment-group-name gooiman_${{ env.ENVIRONMENT }}_deploy_group \ + --deployment-config-name CodeDeployDefault.OneAtATime \ + --s3-location bucket=gooiman-${{ env.ENVIRONMENT }}-deploy-bucket,bundleType=zip,key=${{ github.sha }}.zip \ No newline at end of file diff --git a/.github/workflows/publish-prod.yml b/.github/workflows/publish-prod.yml new file mode 100644 index 0000000..15c7a8d --- /dev/null +++ b/.github/workflows/publish-prod.yml @@ -0,0 +1,173 @@ +name: 프로덕션 서비스 배포 + +on: + push: + branches: [ master ] +env: + ENVIRONMENT: prod + TF_WORKSPACE: prod +jobs: + apply-terraform: + name: 'Terraform 리소스 적용' + runs-on: ubuntu-latest + outputs: + rds_endpoint: ${{ steps.generate_output.outputs.rdx_endpoint }} + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + - name: Terraform 설치 + uses: hashicorp/setup-terraform@v3 + with: + cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }} + - name: Terraform 초기화 + run: terraform init + - name: AWS 인증 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + - name: Terraform 적용 + run: | + terraform apply -auto-approve \ + -var 'environment=${{ env.ENVIRONMENT }}' \ + -var 'aws_region=ap-northeast-2' \ + -var 'database_user=${{ secrets.PROD_DATABASE_USER }}' \ + -var 'database_password=${{ secrets.PROD_DATABASE_PASSWORD }}' + - name: 출력 생성 + id: generate_output + run: echo "rdx_endpoint=$(terraform output -raw rds_endpoint)" >> "$GITHUB_OUTPUT" + build-server: + name: '서버 빌드' + runs-on: ubuntu-latest + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + - name: JDK 설치 + uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '17' + - name: 서버 빌드 + run: | + sudo chmod +x ./gradlew + ./gradlew clean build -x test + - name: 서버 실행 파일 아티펙트 업로드 + uses: actions/upload-artifact@v4 + with: + name: server + path: build/libs/*.jar + docker-build: + name: 'Docker 이미지 빌드' + needs: [ apply-terraform, build-server ] + runs-on: ubuntu-latest + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + - name: 빌드 폴더 생성 + run: mkdir -p build/libs + - name: 서버 실행 파일 다운로드 + uses: actions/download-artifact@v4 + with: + name: server + path: build/libs + - name: 도커 이미지 빌드 + run: | + docker buildx build \ + --build-arg SPRING_PROFILES_ACTIVE=${{ env.ENVIRONMENT }} \ + --build-arg DATABASE_ADDRESS=${{ needs.apply-terraform.outputs.rds_endpoint }} \ + --build-arg DATABASE_USERNAME=${{ secrets.PROD_DATABASE_USER }} \ + --build-arg DATABASE_PASSWORD=${{ secrets.PROD_DATABASE_PASSWORD }} \ + --build-arg JWT_SECRET=${{ secrets.JWT_SECRET }} \ + --build-arg JWT_TOKEN_VALIDITY_TIME=${{ secrets.JWT_TOKEN_VALIDITY_TIME }} \ + --build-arg KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }} \ + -t gooiman-api:${{ github.sha }} . + + - name: 도커 이미지 저장 + run: docker save gooiman-api:${{ github.sha }} > image.tar + + - name: 도커 이미지 아티펙트 업로드 + uses: actions/upload-artifact@v4 + with: + name: docker-image + path: image.tar + + ecr-push: + name: 'ECR 푸시' + needs: [ apply-terraform, docker-build ] + runs-on: ubuntu-latest + outputs: + ecr_registry: ${{ steps.login-ecr.outputs.registry }} + ecr_repository: gooiman_${{ env.ENVIRONMENT }} + image_tag: ${{ github.sha }} + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + + - name: 도커 이미지 아티펙트 다운로드 + uses: actions/download-artifact@v4 + with: + name: docker-image + + - name: 도커 이미지 로드 + run: docker load < image.tar + + - name: AWS 인증 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + + - name: Amazon ECR 로그인 + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Amazon ECR에 이미지 푸시 + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: gooiman_${{ env.ENVIRONMENT }} + IMAGE_TAG: ${{ github.sha }} + run: | + docker tag gooiman-api:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + + codedeploy: + name: 'CodeDeploy 배포' + needs: ecr-push + runs-on: ubuntu-latest + steps: + - name: 레포지토리 체크아웃 + uses: actions/checkout@v4 + + - name: AWS 인증 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + - name: 배포 파일 업로드 + env: + ECR_REGISTRY: ${{ needs.ecr-push.outputs.ecr_registry }} + ECR_REPOSITORY: ${{ needs.ecr-push.outputs.ecr_repository }} + IMAGE_TAG: ${{ needs.ecr-push.outputs.image_tag }} + run: | + cd ./codedeploy/${{ env.ENVIRONMENT }} + mkdir scripts + touch scripts/deploy.sh + echo "aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin $ECR_REGISTRY" >> scripts/deploy.sh + echo "export ECR_REGISTRY=$ECR_REGISTRY" >> scripts/deploy.sh + echo "export ECR_REPOSITORY=$ECR_REPOSITORY" >> scripts/deploy.sh + echo "export IMAGE_TAG=$IMAGE_TAG" >> scripts/deploy.sh + echo "docker-compose -f /var/deployment/docker-compose.yml up -d" >> scripts/deploy.sh + zip -r ${{ github.sha }}.zip . + aws s3 cp ${{ github.sha }}.zip s3://gooiman-${{ env.ENVIRONMENT }}-deploy-bucket/${{ github.sha }}.zip + + + - name: CodeDeploy 배포 생성 + run: | + aws deploy create-deployment \ + --application-name gooiman_${{ env.ENVIRONMENT }}_deploy \ + --deployment-group-name gooiman_${{ env.ENVIRONMENT }}_deploy_group \ + --deployment-config-name CodeDeployDefault.OneAtATime \ + --s3-location bucket=gooiman-${{ env.ENVIRONMENT }}-deploy-bucket,bundleType=zip,key=${{ github.sha }}.zip \ No newline at end of file diff --git a/.gitignore b/.gitignore index c2065bc..b9673e6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,39 @@ out/ ### VS Code ### .vscode/ + +### Terraform ### +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67b9547 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# syntax=docker/dockerfile:1 +FROM amazoncorretto:17 + +ARG SPRING_PROFILES_ACTIVE=prod +ARG DATABASE_ADDRESS=db.gooiman.internal +ARG DATABASE_USERNAME=root +ARG DATABASE_PASSWORD=password +ARG JWT_SECRET=9bc0a269dbe8910fa16ced43ef5d14113a120fe1ab2d9b66bbd4c9bc0a269dbe8910fa16ced43ef5d14113 +ARG JWT_TOKEN_VALIDITY_TIME=86400000 +ARG KEYSTORE_PASSWORD=changeit +ARG BLACKLIST_VALIDITY_TIME=86400000 + +ENV spring.datasource.initialization-mode=always +ENV SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} +ENV DATABASE_ADDRESS=${DATABASE_ADDRESS} +ENV DATABASE_USERNAME=${DATABASE_USERNAME} +ENV DATABASE_PASSWORD=${DATABASE_PASSWORD} +ENV KEYSTORE_PASSWORD=${KEYSTORE_PASSWORD} +ENV JWT_SECRET=${JWT_SECRET} +ENV JWT_TOKEN_VALIDITY_TIME=${JWT_TOKEN_VALIDITY_TIME} +ENV BLACKLIST_VALIDITY_TIME=${BLACKLIST_VALIDITY_TIME} + +COPY ./build/libs/gooiman-server-0.0.1-SNAPSHOT.jar /opt/application.jar + +WORKDIR /opt/ + +CMD ["java", "-jar", "/opt/application.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index fccbd1a..75bff62 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import org.springframework.boot.gradle.tasks.run.BootRun + plugins { java id("org.springframework.boot") version "3.3.4" @@ -26,15 +28,35 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.+") + implementation("com.fasterxml.jackson.core:jackson-databind:2.17.+") + implementation("org.springframework.boot:spring-boot-starter-data-redis") compileOnly("org.projectlombok:lombok") developmentOnly("org.springframework.boot:spring-boot-devtools") - runtimeOnly("com.mysql:mysql-connector-j") + developmentOnly("org.springframework.boot:spring-boot-docker-compose") + runtimeOnly("com.mysql:mysql-connector-j:8.2.0") annotationProcessor("org.projectlombok:lombok") testImplementation("org.springframework.boot:spring-boot-starter-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + // security + implementation("org.springframework.boot:spring-boot-starter-security") + + // jwt + implementation("io.jsonwebtoken:jjwt-api:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.5") + } tasks.withType { useJUnitPlatform() } + +tasks.bootRun { + args = listOf("--spring.profiles.active=local", "--spring.docker.compose.file=docker-compose.bootrun.yml") +} + +tasks.withType { + jvmArgs = listOf("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=0.0.0.0:32323") +} diff --git a/codedeploy/dev/appspec.yml b/codedeploy/dev/appspec.yml new file mode 100644 index 0000000..18c252b --- /dev/null +++ b/codedeploy/dev/appspec.yml @@ -0,0 +1,11 @@ +version: 0.0 +os: linux +files: + - source: / + destination: /var/deployment + overwrite: yes +hooks: + AfterInstall: + - location: scripts/deploy.sh + timeout: 300 + runas: root \ No newline at end of file diff --git a/codedeploy/dev/docker-compose.yml b/codedeploy/dev/docker-compose.yml new file mode 100644 index 0000000..0abbcc3 --- /dev/null +++ b/codedeploy/dev/docker-compose.yml @@ -0,0 +1,10 @@ +name: gooiman-server +services: + api: + image: ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG} + ports: + - "8080:8080" + redis: + image: redis:alpine + command: redis-server --port 6379 + container_name: redis.gooiman.internal \ No newline at end of file diff --git a/codedeploy/prod/appspec.yml b/codedeploy/prod/appspec.yml new file mode 100644 index 0000000..18c252b --- /dev/null +++ b/codedeploy/prod/appspec.yml @@ -0,0 +1,11 @@ +version: 0.0 +os: linux +files: + - source: / + destination: /var/deployment + overwrite: yes +hooks: + AfterInstall: + - location: scripts/deploy.sh + timeout: 300 + runas: root \ No newline at end of file diff --git a/codedeploy/prod/docker-compose.yml b/codedeploy/prod/docker-compose.yml new file mode 100644 index 0000000..0abbcc3 --- /dev/null +++ b/codedeploy/prod/docker-compose.yml @@ -0,0 +1,10 @@ +name: gooiman-server +services: + api: + image: ${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG} + ports: + - "8080:8080" + redis: + image: redis:alpine + command: redis-server --port 6379 + container_name: redis.gooiman.internal \ No newline at end of file diff --git a/docker-compose.bootrun.yml b/docker-compose.bootrun.yml new file mode 100644 index 0000000..e2dc958 --- /dev/null +++ b/docker-compose.bootrun.yml @@ -0,0 +1,41 @@ +name: gooiman-server +services: + mysql: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: gooiman + command: "--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" + volumes: + - ./sql/:/docker-entrypoint-initdb.d/ + container_name: db.gooiman.internal + healthcheck: + test: [ "CMD", "mysqladmin" ,"ping", "-h", "db.gooiman.internal" ] + interval: 10s + retries: 10 + start_period: 10s + timeout: 10s + ports: + - "3306:3306" + redis: + image: redis:alpine + command: redis-server + container_name: redis.gooiman.internal + ports: + - "6379:6379" + sqlpad: + image: sqlpad/sqlpad + ports: + - "3000:3000" + depends_on: + - mysql + environment: + SQLPAD_ADMIN: admin + SQLPAD_ADMIN_PASSWORD: admin + SQLPAD_CONNECTIONS__mysql__name: gooiman + SQLPAD_CONNECTIONS__mysql__driver: mysql2 + SQLPAD_CONNECTIONS__mysql__host: db.gooiman.internal + SQLPAD_CONNECTIONS__mysql__port: 3306 + SQLPAD_CONNECTIONS__mysql__database: gooiman + SQLPAD_CONNECTIONS__mysql__username: root + SQLPAD_CONNECTIONS__mysql__password: password \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe270b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +name: gooiman-server +services: + mysql: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: password + command: "--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci" + volumes: + - ./sql/:/docker-entrypoint-initdb.d/ + container_name: db.gooiman.internal + healthcheck: + test: [ "CMD", "mysqladmin" ,"ping", "-h", "db.gooiman.internal" ] + interval: 10s + retries: 10 + start_period: 10s + timeout: 10s + redis: + image: redis:alpine + command: redis-server + ports: + - "6379:6379" + container_name: redis.gooiman.internal + api: + build: + context: . + args: + SPRING_PROFILES_ACTIVE: "local" + dockerfile: Dockerfile + ports: + - "8080:8080" + depends_on: + mysql: + condition: service_healthy + sqlpad: + image: sqlpad/sqlpad + ports: + - "3000:3000" + depends_on: + - mysql + environment: + SQLPAD_ADMIN: admin + SQLPAD_ADMIN_PASSWORD: admin + SQLPAD_CONNECTIONS__mysql__name: gooiman + SQLPAD_CONNECTIONS__mysql__driver: mysql2 + SQLPAD_CONNECTIONS__mysql__host: db.gooiman.internal + SQLPAD_CONNECTIONS__mysql__port: 3306 + SQLPAD_CONNECTIONS__mysql__database: gooiman + SQLPAD_CONNECTIONS__mysql__username: root + SQLPAD_CONNECTIONS__mysql__password: password \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..5fa0eb8 --- /dev/null +++ b/main.tf @@ -0,0 +1,348 @@ +provider "aws" { + region = var.aws_region +} + +terraform { + cloud { + organization = "gooiman" + + workspaces { + tags = ["gooiman"] + } + } +} + +# VPC 설정 +resource "aws_vpc" "gooiman" { + cidr_block = "10.0.0.0/16" + + tags = { + Name = "gooiman_${var.environment}" + } +} + +# 인터넷 게이트웨이 생성 +resource "aws_internet_gateway" "gooiman" { + vpc_id = aws_vpc.gooiman.id + tags = { + Name = "gooiman_igw_${var.environment}" + } +} + +# 퍼블릭 서브넷 생성 +resource "aws_subnet" "gooiman_public" { + vpc_id = aws_vpc.gooiman.id + cidr_block = "10.0.1.0/24" + availability_zone = "ap-northeast-2a" + tags = { + Name = "gooiman_subnet_public_${var.environment}" + } +} +# 프라이빗 서브넷 생성 1 +resource "aws_subnet" "gooiman_private_1" { + vpc_id = aws_vpc.gooiman.id + cidr_block = "10.0.101.0/24" + availability_zone = "ap-northeast-2a" + tags = { + Name = "gooiman_subnet_private_1_${var.environment}" + } +} +# 프라이빗 서브넷 생성 2 +resource "aws_subnet" "gooiman_private_2" { + vpc_id = aws_vpc.gooiman.id + cidr_block = "10.0.102.0/24" + availability_zone = "ap-northeast-2b" + tags = { + Name = "gooiman_subnet_private_2_${var.environment}" + } +} + +# 퍼블릭 서브넷 라우팅 테이블 생성 및 인터넷 트래픽 라우팅 +resource "aws_route_table" "public" { + vpc_id = aws_vpc.gooiman.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.gooiman.id + } +} + +# 퍼블릭 서브넷을 라우팅 테이블에 연결 +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.gooiman_public.id + route_table_id = aws_route_table.public.id +} + +# 프라이빗 서브넷 라우팅 테이블 생성 +resource "aws_route_table" "private" { + vpc_id = aws_vpc.gooiman.id +} + +# 프라이빗 서브넷을 라우팅 테이블에 연결 +resource "aws_route_table_association" "private" { + subnet_id = aws_subnet.gooiman_private_1.id + route_table_id = aws_route_table.private.id +} + +# RDS 보안 그룹 +resource "aws_security_group" "rds_sg" { + vpc_id = aws_vpc.gooiman.id + + ingress { + from_port = 3306 + to_port = 3306 + protocol = "tcp" + cidr_blocks = ["10.0.0.0/16"] + security_groups = [aws_security_group.ec2_sg.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +# DB 서브넷 그룹 +resource "aws_db_subnet_group" "db_subnet" { + name = "db_subnet_group" + subnet_ids = [aws_subnet.gooiman_private_1.id, aws_subnet.gooiman_private_2.id] +} + +# RDS 인스턴스 생성 +resource "aws_db_instance" "db" { + allocated_storage = 20 + engine = "mysql" + engine_version = "8.0" + instance_class = "db.t4g.micro" + db_name = "gooiman" + username = var.database_user + password = var.database_password + vpc_security_group_ids = [aws_security_group.rds_sg.id] + db_subnet_group_name = aws_db_subnet_group.db_subnet.id + skip_final_snapshot = true + tags = { + Name = "gooiman_db_${var.environment}" + } +} + +# EC2 인스턴스 프로필 +resource "aws_iam_instance_profile" "ec2_profile" { + name = "ec2_profile" + role = aws_iam_role.ec2_role.name +} + +# EC2 IAM 역할 +resource "aws_iam_role" "ec2_role" { + name = "ec2_role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + } + ] + }) +} + +# ECR 접근 정책 +resource "aws_iam_role_policy" "ecr_access_policy" { + name = "ecr_access_policy" + role = aws_iam_role.ec2_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ] + Resource = "*" + } + ] + }) +} + +# S3 접근 정책 +resource "aws_iam_role_policy" "s3_access_policy" { + name = "s3_access_policy" + role = aws_iam_role.ec2_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:ListBucket" + ] + Resource = [ + aws_s3_bucket.codedeploy_bucket.arn, + "${aws_s3_bucket.codedeploy_bucket.arn}/*" + ] + } + ] + }) +} + +# EC2 인스턴스 보안 그룹 +resource "aws_security_group" "ec2_sg" { + vpc_id = aws_vpc.gooiman.id + + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + // 개발 환경 8080 포트 허용 + dynamic "ingress" { + for_each = (var.environment == "dev") ? [1] : [] + content { + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + } + + // 운영 환경 8080 리다이렉트 + dynamic "ingress" { + for_each = (var.environment == "prod") ? [1] : [] + content { + from_port = 8080 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +# EC2 인스턴스 +resource "aws_instance" "gooiman_api" { + depends_on = [aws_db_instance.db] + + ami = "ami-06f73fc34ddfd65c2" # Amazon Linux 2023 AMI + instance_type = "t2.micro" + subnet_id = aws_subnet.gooiman_public.id + vpc_security_group_ids = [aws_security_group.ec2_sg.id] + iam_instance_profile = aws_iam_instance_profile.ec2_profile.name + + user_data = <<-EOF + #!/bin/bash + exec > >(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1 + yum update -y + yum install -y docker + service docker start + usermod -a -G docker ec2-user + + # Docker Compose 설치 + sudo curl -L "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + + # CodeDeploy 에이전트 설치 + yum install -y ruby wget + cd /home/ec2-user + wget https://aws-codedeploy-${var.aws_region}.s3.${var.aws_region}.amazonaws.com/latest/install + chmod +x ./install + ./install auto + + # RDS 주소 host 연결 + sudo touch /etc/profile.d/database.sh + sudo echo "#!/bin/bash" >> /etc/profile.d/database.sh + sudo echo "export DATABASE_ADDRESS=${aws_db_instance.db.address}" >> /etc/profile.d/database.sh + sudo echo "export DATABASE_USERNAME=${var.database_user}" >> /etc/profile.d/database.sh + sudo echo "export DATABASE_PASSWORD=${var.database_password}" >> /etc/profile.d/database.sh + source /etc/profile + EOF + tags = { + Name = "gooiman_api_${var.environment}" + } +} + +# ECR 리포지토리 생성 +resource "aws_ecr_repository" "gooiman_ecr" { + name = "gooiman_${var.environment}" + + tags = { + Name = "gooiman_${var.environment}" + } +} + +# S3 버킷 생성 +resource "aws_s3_bucket" "codedeploy_bucket" { + bucket = "gooiman-${var.environment}-deploy-bucket" +} + +# CodeDeploy 애플리케이션 +resource "aws_codedeploy_app" "codedeploy" { + name = "gooiman_${var.environment}_deploy" +} + +# CodeDeploy 배포 그룹 +resource "aws_codedeploy_deployment_group" "deployment" { + app_name = aws_codedeploy_app.codedeploy.name + deployment_group_name = "gooiman_${var.environment}_deploy_group" + service_role_arn = aws_iam_role.codedeploy_role.arn + ec2_tag_filter { + type = "KEY_AND_VALUE" + key = "Name" + value = "gooiman_api_${var.environment}" + } + deployment_config_name = "CodeDeployDefault.OneAtATime" + auto_rollback_configuration { + enabled = true + events = ["DEPLOYMENT_FAILURE"] + } +} + +# IAM 역할 - CodeDeploy를 위한 역할 +resource "aws_iam_role" "codedeploy_role" { + name = "CodeDeployRole" + + assume_role_policy = < token = authenticationProvider.authenticate(pageId, + authentication); + + Authentication authenticatedToken = token.orElseGet( + () -> signup(pageId, dto.name(), dto.password())); + String jwtToken = jwtAuthenticationService.createToken(authenticatedToken); + + return new JwtResponseDto(jwtToken); + } + + public Authentication signup(UUID pageId, String name, String password) { + String encodedPassword = passwordEncoder.encode(password); + + Page page = pageService.getPageById(pageId); + UUID userId = UUID.randomUUID(); + User user = new User(userId, name, encodedPassword, "ROLE_USER", page); + User saveUser = userRepository.save(user); + + return new UsernamePasswordAuthenticationToken(new CustomUserDetails(saveUser), null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + @Transactional + public CommonSuccessDto signout(String token) { + return blackListService.saveBlackList(token); + } +} diff --git a/src/main/java/dev/gooiman/server/auth/application/BlackListService.java b/src/main/java/dev/gooiman/server/auth/application/BlackListService.java new file mode 100644 index 0000000..1e55f13 --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/application/BlackListService.java @@ -0,0 +1,31 @@ +package dev.gooiman.server.auth.application; + +import dev.gooiman.server.auth.repository.BlackListRepository; +import dev.gooiman.server.auth.repository.entity.BlackList; +import dev.gooiman.server.common.dto.CommonSuccessDto; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BlackListService { + + @Value("${spring.security.blacklist-validity-time}") + private Long expiration; + + private final BlackListRepository blackListRepository; + + public boolean isExists(String id) { + String bearerToken = "Bearer " + id; + Optional token = blackListRepository.findById(bearerToken); + return token.isPresent(); + } + + public CommonSuccessDto saveBlackList(String token) { + BlackList entity = new BlackList(token, expiration); + blackListRepository.save(entity); + return CommonSuccessDto.fromEntity(true); + } +} diff --git a/src/main/java/dev/gooiman/server/auth/application/CustomUserDetailsService.java b/src/main/java/dev/gooiman/server/auth/application/CustomUserDetailsService.java new file mode 100644 index 0000000..4817eed --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/application/CustomUserDetailsService.java @@ -0,0 +1,35 @@ +package dev.gooiman.server.auth.application; + + +import dev.gooiman.server.common.exception.CommonException; +import dev.gooiman.server.common.exception.ErrorCode; +import dev.gooiman.server.auth.application.domain.CustomUserDetails; +import dev.gooiman.server.auth.repository.UserRepository; +import dev.gooiman.server.auth.repository.entity.User; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService { + + private final UserRepository userRepository; + + public Optional loadUserByUsername(UUID pageId, String name) + throws UsernameNotFoundException { + Optional user = userRepository.findByNameAndPage_PageId(name, pageId); + + return user.map(CustomUserDetails::new); + } + + public CustomUserDetails loadUserByUserId(String userId) { + UUID uuid = UUID.fromString(userId); + User user = userRepository.findById(uuid) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_USER)); + + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/dev/gooiman/server/auth/application/JwtService.java b/src/main/java/dev/gooiman/server/auth/application/JwtService.java new file mode 100644 index 0000000..7e6ee6b --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/application/JwtService.java @@ -0,0 +1,52 @@ +package dev.gooiman.server.auth.application; + + +import dev.gooiman.server.auth.application.domain.CustomUserDetails; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import java.util.Date; +import java.util.UUID; +import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtService { + + private static SecretKey key; + + @Value("${spring.security.secret}") + private String secret; + + @Value("${spring.security.token-validity-time}") + private int validityTime; + + @PostConstruct + public void init() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + key = Keys.hmacShaKeyFor(keyBytes); + } + + public String createToken(Authentication authentication) { + CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal(); + UUID userId = principal.getId(); + return getAccessToken(userId); + } + + private String getAccessToken(UUID memberId) { + long now = (new Date()).getTime(); + Date tokenExpirationDate = new Date(now + validityTime); + + return Jwts.builder() + .signWith(key) + .subject(String.valueOf(memberId)) + .expiration(tokenExpirationDate) + .issuedAt(new Date(now)) + .compact(); + } +} diff --git a/src/main/java/dev/gooiman/server/auth/application/UserService.java b/src/main/java/dev/gooiman/server/auth/application/UserService.java new file mode 100644 index 0000000..e755fa2 --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/application/UserService.java @@ -0,0 +1,23 @@ +package dev.gooiman.server.auth.application; + +import dev.gooiman.server.auth.repository.UserRepository; +import dev.gooiman.server.auth.repository.entity.User; +import dev.gooiman.server.common.exception.CommonException; +import dev.gooiman.server.common.exception.ErrorCode; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + public User getUserByNameAndPageId(String name, UUID pageId) { + return userRepository.findByNameAndPage_PageId(name, pageId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_USER)); + } +} diff --git a/src/main/java/dev/gooiman/server/auth/application/domain/CustomUserDetails.java b/src/main/java/dev/gooiman/server/auth/application/domain/CustomUserDetails.java new file mode 100644 index 0000000..e6ce6bb --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/application/domain/CustomUserDetails.java @@ -0,0 +1,59 @@ +package dev.gooiman.server.auth.application.domain; + +import dev.gooiman.server.auth.repository.entity.User; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + + +public class CustomUserDetails implements UserDetails { + + private final User user; + + public CustomUserDetails(User user) { + this.user = user; + } + + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getName(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + + public UUID getId() { + return user.getUserId(); + } + + @Override + public Collection getAuthorities() { + return List.of(); + } + +} diff --git a/src/main/java/dev/gooiman/server/auth/application/dto/JwtResponseDto.java b/src/main/java/dev/gooiman/server/auth/application/dto/JwtResponseDto.java new file mode 100644 index 0000000..113df31 --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/application/dto/JwtResponseDto.java @@ -0,0 +1,12 @@ +package dev.gooiman.server.auth.application.dto; + + +import lombok.Builder; + +@Builder +public record JwtResponseDto(String token) { + + public JwtResponseDto(String token) { + this.token = "Bearer " + token; + } +} diff --git a/src/main/java/dev/gooiman/server/auth/application/dto/LoginRequestDto.java b/src/main/java/dev/gooiman/server/auth/application/dto/LoginRequestDto.java new file mode 100644 index 0000000..c3182b7 --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/application/dto/LoginRequestDto.java @@ -0,0 +1,6 @@ +package dev.gooiman.server.auth.application.dto; + +public record LoginRequestDto(String name, + String password) { + +} diff --git a/src/main/java/dev/gooiman/server/auth/controller/AuthController.java b/src/main/java/dev/gooiman/server/auth/controller/AuthController.java new file mode 100644 index 0000000..d830fcf --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/controller/AuthController.java @@ -0,0 +1,41 @@ +package dev.gooiman.server.auth.controller; + +import dev.gooiman.server.auth.application.AuthService; +import dev.gooiman.server.auth.application.dto.JwtResponseDto; +import dev.gooiman.server.auth.application.dto.LoginRequestDto; +import dev.gooiman.server.common.dto.CommonSuccessDto; +import dev.gooiman.server.common.dto.ResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/auth") +@Tag(name = "Authentication", description = "인증 처리 API") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login/{page_id}") + @Operation(summary = "로그인", description = "로그인을 수행합니다. 만약 한번도 로그인 한 적 없는 name으로 로그인을 시도할 경우 회원가입을 수행합니다.") + @SecurityRequirements + public ResponseDto signIn(@PathVariable("page_id") UUID pageId, + @RequestBody LoginRequestDto dto) { + return ResponseDto.ok(authService.login(pageId, dto)); + } + + @PostMapping("/logout") + public ResponseDto signout(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + return ResponseDto.ok(authService.signout(token)); + } +} diff --git a/src/main/java/dev/gooiman/server/auth/repository/BlackListRepository.java b/src/main/java/dev/gooiman/server/auth/repository/BlackListRepository.java new file mode 100644 index 0000000..9e68e53 --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/repository/BlackListRepository.java @@ -0,0 +1,8 @@ +package dev.gooiman.server.auth.repository; + +import dev.gooiman.server.auth.repository.entity.BlackList; +import org.springframework.data.repository.CrudRepository; + +public interface BlackListRepository extends CrudRepository { + +} diff --git a/src/main/java/dev/gooiman/server/auth/repository/UserRepository.java b/src/main/java/dev/gooiman/server/auth/repository/UserRepository.java new file mode 100644 index 0000000..dce0807 --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/repository/UserRepository.java @@ -0,0 +1,17 @@ +package dev.gooiman.server.auth.repository; + +import dev.gooiman.server.auth.repository.entity.User; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface UserRepository extends JpaRepository { + + @Query("select u from User u" + + " where u.name=:name") + Optional findByName(@Param("name") String name); + + Optional findByNameAndPage_PageId(String name, UUID pageId); +} diff --git a/src/main/java/dev/gooiman/server/auth/repository/entity/BlackList.java b/src/main/java/dev/gooiman/server/auth/repository/entity/BlackList.java new file mode 100644 index 0000000..7bcf043 --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/repository/entity/BlackList.java @@ -0,0 +1,22 @@ +package dev.gooiman.server.auth.repository.entity; + +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash(value = "token") +public class BlackList { + + @Id + private final String token; + + @TimeToLive + private final Long expiration; + + public BlackList(String token, Long expiration) { + this.token = token; + this.expiration = expiration; + } +} diff --git a/src/main/java/dev/gooiman/server/auth/repository/entity/User.java b/src/main/java/dev/gooiman/server/auth/repository/entity/User.java new file mode 100644 index 0000000..c3e6668 --- /dev/null +++ b/src/main/java/dev/gooiman/server/auth/repository/entity/User.java @@ -0,0 +1,40 @@ +package dev.gooiman.server.auth.repository.entity; + +import dev.gooiman.server.page.repository.entity.Page; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.GenericGenerator; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "users") +@Entity +public class User { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(columnDefinition = "BINARY(16)") + private UUID userId; + + private String name; + private String password; + private String role; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "PAGE_ID") + private Page page; +} diff --git a/src/main/java/dev/gooiman/server/common/controller/HealthCheckController.java b/src/main/java/dev/gooiman/server/common/controller/HealthCheckController.java new file mode 100644 index 0000000..206ef58 --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/controller/HealthCheckController.java @@ -0,0 +1,18 @@ +package dev.gooiman.server.common.controller; + +import dev.gooiman.server.common.dto.ResponseDto; +import io.swagger.v3.oas.annotations.Hidden; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api") +@Hidden +public class HealthCheckController { + + @GetMapping("") + public ResponseDto healthCheck() { + return ResponseDto.ok("ok"); + } +} diff --git a/src/main/java/dev/gooiman/server/common/dto/CommonIdResponseDto.java b/src/main/java/dev/gooiman/server/common/dto/CommonIdResponseDto.java new file mode 100644 index 0000000..da77643 --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/dto/CommonIdResponseDto.java @@ -0,0 +1,7 @@ +package dev.gooiman.server.common.dto; + +import java.util.UUID; + +public record CommonIdResponseDto(UUID id) { + +} diff --git a/src/main/java/dev/gooiman/server/common/dto/CommonSuccessDto.java b/src/main/java/dev/gooiman/server/common/dto/CommonSuccessDto.java new file mode 100644 index 0000000..a3a5fdd --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/dto/CommonSuccessDto.java @@ -0,0 +1,13 @@ +package dev.gooiman.server.common.dto; + +import lombok.Builder; + +@Builder +public record CommonSuccessDto(boolean isSuccess) { + + public static CommonSuccessDto fromEntity(boolean success) { + return CommonSuccessDto.builder() + .isSuccess(success) + .build(); + } +} diff --git a/src/main/java/dev/gooiman/server/common/dto/ExceptionDto.java b/src/main/java/dev/gooiman/server/common/dto/ExceptionDto.java new file mode 100644 index 0000000..67ed6de --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/dto/ExceptionDto.java @@ -0,0 +1,28 @@ +package dev.gooiman.server.common.dto; + +import dev.gooiman.server.common.exception.ErrorCode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +@Schema(name = "ExceptionDto", description = "API 예외 발생 시 응답 DTO") +public class ExceptionDto { + + @Schema(name = "code", description = "에러 코드") + @NotNull + private final Integer code; + + @Schema(name = "message", description = "에러 메시지") + @NotNull + private final String message; + + public ExceptionDto(ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.message = errorCode.getMessage(); + } + + public static ExceptionDto of(ErrorCode errorCode) { + return new ExceptionDto(errorCode); + } +} \ No newline at end of file diff --git a/src/main/java/dev/gooiman/server/common/dto/ResponseDto.java b/src/main/java/dev/gooiman/server/common/dto/ResponseDto.java new file mode 100644 index 0000000..d16c804 --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/dto/ResponseDto.java @@ -0,0 +1,56 @@ +package dev.gooiman.server.common.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import dev.gooiman.server.common.exception.ArgumentNotValidExceptionDto; +import dev.gooiman.server.common.exception.CommonException; +import dev.gooiman.server.common.exception.ErrorCode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@Schema(name = "ResponseDto", description = "API 응답 DTO") +public record ResponseDto(@JsonIgnore HttpStatus httpStatus, + @Schema(name = "success", description = "API 호출 성공 여부") + @NotNull Boolean success, + @Schema(name = "data", description = "API 호출 성공 시 응답 데이터") + @Nullable T data, + @Schema(name = "error", description = "API 호출 실패 시 응답 에러") + @Nullable ExceptionDto error) { + + public static ResponseDto ok(@Nullable final T data) { + return new ResponseDto<>(HttpStatus.OK, true, data, null); + } + + public static ResponseDto created(@Nullable final T data) { + return new ResponseDto<>(HttpStatus.CREATED, true, data, null); + } + + public static ResponseDto fail(final MethodArgumentNotValidException e) { + return new ResponseDto<>(HttpStatus.BAD_REQUEST, false, null, + new ArgumentNotValidExceptionDto(e)); + } + + public static ResponseDto fail(final MissingServletRequestParameterException e) { + return new ResponseDto<>(HttpStatus.BAD_REQUEST, false, null, + ExceptionDto.of(ErrorCode.MISSING_REQUEST_PARAMETER)); + } + + public static ResponseDto fail(final MethodArgumentTypeMismatchException e) { + return new ResponseDto<>(HttpStatus.INTERNAL_SERVER_ERROR, false, null, + ExceptionDto.of(ErrorCode.INVALID_PARAMETER_FORMAT)); + } + + public static ResponseDto fail(final CommonException e) { + return new ResponseDto<>(e.getErrorCode().getHttpStatus(), false, null, + ExceptionDto.of(e.getErrorCode())); + } + +// public static ResponseDto fail(final AuthenticationException exception) { +// return new ResponseDto<>(HttpStatus.INTERNAL_SERVER_ERROR, false, null, +// ExceptionDto.of(ErrorCode.NOT_MATCH_USER)); +// } +} diff --git a/src/main/java/dev/gooiman/server/common/exception/ArgumentNotValidExceptionDto.java b/src/main/java/dev/gooiman/server/common/exception/ArgumentNotValidExceptionDto.java new file mode 100644 index 0000000..f9dea3d --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/exception/ArgumentNotValidExceptionDto.java @@ -0,0 +1,38 @@ +package dev.gooiman.server.common.exception; + +import dev.gooiman.server.common.dto.ExceptionDto; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +@Getter +public class ArgumentNotValidExceptionDto extends ExceptionDto { + + private final Map errorFields; + + public ArgumentNotValidExceptionDto( + final MethodArgumentNotValidException methodArgumentNotValidException) { + super(ErrorCode.INVALID_ARGUMENT); + + this.errorFields = new HashMap<>(); + methodArgumentNotValidException.getBindingResult() + .getAllErrors() + .forEach(e -> this.errorFields.put(((FieldError) e).getField(), e.getDefaultMessage())); + } + + public ArgumentNotValidExceptionDto( + final ConstraintViolationException constraintViolationException) { + super(ErrorCode.INVALID_ARGUMENT); + + this.errorFields = new HashMap<>(); + + for (ConstraintViolation constraintViolation : constraintViolationException.getConstraintViolations()) { + errorFields.put(constraintViolation.getPropertyPath().toString(), + constraintViolation.getMessage()); + } + } +} diff --git a/src/main/java/dev/gooiman/server/common/exception/BaseException.java b/src/main/java/dev/gooiman/server/common/exception/BaseException.java new file mode 100644 index 0000000..17abce9 --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/exception/BaseException.java @@ -0,0 +1,9 @@ +package dev.gooiman.server.common.exception; + +import lombok.Getter; + +@Getter +public class BaseException extends Exception { + + private BaseResponseStatus baseResponseStatus; +} diff --git a/src/main/java/dev/gooiman/server/common/exception/BaseResponseStatus.java b/src/main/java/dev/gooiman/server/common/exception/BaseResponseStatus.java new file mode 100644 index 0000000..bab9777 --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/exception/BaseResponseStatus.java @@ -0,0 +1,18 @@ +package dev.gooiman.server.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum BaseResponseStatus { + SUCCESS(HttpStatus.CREATED, "요청에 성공하였습니다."), + NO_PAGE(HttpStatus.BAD_REQUEST, "존재하지 않는 페이지 입니다"), + NO_USER(HttpStatus.BAD_REQUEST, "존재하지 않는 유저 입니다"), + ; + + private HttpStatus httpStatus; + private String message; + +} diff --git a/src/main/java/dev/gooiman/server/common/exception/CommonException.java b/src/main/java/dev/gooiman/server/common/exception/CommonException.java new file mode 100644 index 0000000..de74068 --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/exception/CommonException.java @@ -0,0 +1,16 @@ +package dev.gooiman.server.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CommonException extends RuntimeException { + + private final ErrorCode errorCode; + + @Override + public String getMessage() { + return errorCode.getMessage(); + } +} \ No newline at end of file diff --git a/src/main/java/dev/gooiman/server/common/exception/ErrorCode.java b/src/main/java/dev/gooiman/server/common/exception/ErrorCode.java new file mode 100644 index 0000000..8b26cfd --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/exception/ErrorCode.java @@ -0,0 +1,62 @@ +package dev.gooiman.server.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + // Method Not Allowed Error + METHOD_NOT_ALLOWED(40500, HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메소드입니다."), + + // Not Found Error + NOT_FOUND_END_POINT(40400, HttpStatus.NOT_FOUND, "존재하지 않는 API 엔드포인트입니다."), + NOT_FOUND_RESOURCE(40400, HttpStatus.NOT_FOUND, "해당 리소스가 존재하지 않습니다."), + NOT_FOUND_LOGIN_USER(40401, HttpStatus.NOT_FOUND, "로그인한 사용자가 존재하지 않습니다."), + NOT_FOUND_AUTHORIZATION_HEADER(40401, HttpStatus.NOT_FOUND, "Authorization 헤더가 존재하지 않습니다."), + NOT_FOUND_USER(40402, HttpStatus.NOT_FOUND, "해당 사용자가 존재하지 않습니다."), + NOT_FOUND_SHARED_URL(40403, HttpStatus.NOT_FOUND, "해당 공유 URL이 존재하지 않습니다."), + NOT_FOUND_MEMO(40404, HttpStatus.NOT_FOUND, "해당 메모가 존재하지 않습니다"), + NOT_FOUND_PAGE(40405, HttpStatus.NOT_FOUND, "해당 페이지가 존재하지 않습니다"), + + + // Invalid Argument Error + MISSING_REQUEST_PARAMETER(40000, HttpStatus.BAD_REQUEST, "필수 요청 파라미터가 누락되었습니다."), + INVALID_ARGUMENT(40001, HttpStatus.BAD_REQUEST, "요청에 유효하지 않은 인자입니다."), + INVALID_PARAMETER_FORMAT(40002, HttpStatus.BAD_REQUEST, "요청에 유효하지 않은 인자 형식입니다."), + INVALID_HEADER_ERROR(40003, HttpStatus.BAD_REQUEST, "유효하지 않은 헤더입니다."), + MISSING_REQUEST_HEADER(40004, HttpStatus.BAD_REQUEST, "필수 요청 헤더가 누락되었습니다."), + BAD_REQUEST_PARAMETER(40005, HttpStatus.BAD_REQUEST, "잘못된 요청 파라미터입니다."), + BAD_REQUEST_JSON(40006, HttpStatus.BAD_REQUEST, "잘못된 JSON 형식입니다."), + SEARCH_SHORT_LENGTH_ERROR(40007, HttpStatus.BAD_REQUEST, "검색어는 2글자 이상이어야 합니다."), + INVALID_ACCESS_URL(40015, HttpStatus.BAD_REQUEST, "잘못된 사용자 접근입니다."), + + // Gone Error + GONE_SHARED_URL(41001, HttpStatus.GONE, "해당 공유 URL이 만료되었습니다."), + + // Access Denied Error + ACCESS_DENIED(40300, HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + NOT_MATCH_AUTH_CODE(40301, HttpStatus.FORBIDDEN, "인증 코드가 일치하지 않습니다."), + NOT_MATCH_USER(40302, HttpStatus.FORBIDDEN, "해당 사용자가 일치하지 않습니다."), + + + // Unauthorized Error + FAILURE_LOGIN(40100, HttpStatus.UNAUTHORIZED, "잘못된 비밀번호입니다."), + EXPIRED_TOKEN_ERROR(40101, HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + INVALID_TOKEN_ERROR(40102, HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + TOKEN_MALFORMED_ERROR(40103, HttpStatus.UNAUTHORIZED, "토큰이 올바르지 않습니다."), + TOKEN_TYPE_ERROR(40104, HttpStatus.UNAUTHORIZED, "토큰 타입이 일치하지 않거나 비어있습니다."), + TOKEN_UNSUPPORTED_ERROR(40105, HttpStatus.UNAUTHORIZED, "지원하지않는 토큰입니다."), + TOKEN_GENERATION_ERROR(40106, HttpStatus.UNAUTHORIZED, "토큰 생성에 실패하였습니다."), + TOKEN_UNKNOWN_ERROR(40107, HttpStatus.UNAUTHORIZED, "알 수 없는 토큰입니다."), + EXPIRED_REFRESH_TOKEN_ERROR(40108, HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + + // Internal Server Error + INTERNAL_SERVER_ERROR(50000, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 에러입니다."), + UPLOAD_FILE_ERROR(50001, HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패하였습니다."); + + private final Integer code; + private final HttpStatus httpStatus; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/dev/gooiman/server/common/exception/GlobalExceptionHandler.java b/src/main/java/dev/gooiman/server/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..4f53ebf --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,87 @@ +package dev.gooiman.server.common.exception; + +import dev.gooiman.server.common.dto.ResponseDto; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // Convertor 에서 바인딩 실패시 발생하는 예외 + @ExceptionHandler(value = {HttpMessageNotReadableException.class}) + public ResponseDto handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error( + "handleHttpMessageNotReadableException() in GlobalExceptionHandler throw HttpMessageNotReadableException : {}", + e.getMessage()); + return ResponseDto.fail(new CommonException(ErrorCode.BAD_REQUEST_JSON)); + } + + // 지원되지 않는 HTTP 메소드를 사용할 때 발생하는 예외 + @ExceptionHandler(value = {NoHandlerFoundException.class, + HttpRequestMethodNotSupportedException.class}) + public ResponseDto handleNoPageFoundException(Exception e) { + log.error( + "handleNoPageFoundException() in GlobalExceptionHandler throw NoHandlerFoundException : {}", + e.getMessage()); + return ResponseDto.fail(new CommonException(ErrorCode.NOT_FOUND_END_POINT)); + } + + // @Validated 어노테이션을 사용하여 검증을 수행할 때 발생하는 예외 + @ExceptionHandler(value = {MethodArgumentNotValidException.class}) + public ResponseDto handleArgumentNotValidException(MethodArgumentNotValidException e) { + log.error( + "handleArgumentNotValidException() in GlobalExceptionHandler throw MethodArgumentNotValidException : {}", + e.getMessage()); + return ResponseDto.fail(e); + } + + // 메소드의 인자 타입이 일치하지 않을 때 발생하는 예외 + @ExceptionHandler(value = {MethodArgumentTypeMismatchException.class}) + public ResponseDto handleArgumentNotValidException(MethodArgumentTypeMismatchException e) { + log.error( + "handleArgumentNotValidException() in GlobalExceptionHandler throw MethodArgumentTypeMismatchException : {}", + e.getMessage()); + return ResponseDto.fail(e); + } + + // 필수 파라미터가 누락되었을 때 발생하는 예외 + @ExceptionHandler(value = {MissingServletRequestParameterException.class}) + public ResponseDto handleArgumentNotValidException( + MissingServletRequestParameterException e) { + log.error( + "handleArgumentNotValidException() in GlobalExceptionHandler throw MethodArgumentNotValidException : {}", + e.getMessage()); + return ResponseDto.fail(e); + } + + // 개발자가 직접 정의한 예외 + @ExceptionHandler(value = {CommonException.class}) + @ResponseBody + public ResponseDto handleApiException(CommonException e, HttpServletResponse response) { + log.error("handleApiException() in GlobalExceptionHandler throw CommonException : {}", + e.getMessage()); + response.setStatus(e.getErrorCode().getHttpStatus().value()); + return ResponseDto.fail(e); + } + + // 서버, DB 예외 + @ExceptionHandler(value = {Exception.class}) + public ResponseDto handleException(Exception e, HttpServletResponse response) { + log.error("handleException() in GlobalExceptionHandler throw Exception : {}", + e.getMessage()); + e.printStackTrace(); + response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); + return ResponseDto.fail(new CommonException(ErrorCode.INTERNAL_SERVER_ERROR)); + } +} \ No newline at end of file diff --git a/src/main/java/dev/gooiman/server/common/repository/entity/BaseTimeEntity.java b/src/main/java/dev/gooiman/server/common/repository/entity/BaseTimeEntity.java new file mode 100644 index 0000000..56307f7 --- /dev/null +++ b/src/main/java/dev/gooiman/server/common/repository/entity/BaseTimeEntity.java @@ -0,0 +1,22 @@ +package dev.gooiman.server.common.repository.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.sql.Timestamp; +import lombok.Getter; +import org.hibernate.annotations.DynamicUpdate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@DynamicUpdate +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @LastModifiedDate + @Column(name = "update_time") + protected Timestamp updateTime; +} + diff --git a/src/main/java/dev/gooiman/server/config/repository/AuditConfig.java b/src/main/java/dev/gooiman/server/config/repository/AuditConfig.java new file mode 100644 index 0000000..59eb9b6 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/repository/AuditConfig.java @@ -0,0 +1,10 @@ +package dev.gooiman.server.config.repository; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class AuditConfig { + +} diff --git a/src/main/java/dev/gooiman/server/config/security/JwtConfig.java b/src/main/java/dev/gooiman/server/config/security/JwtConfig.java new file mode 100644 index 0000000..d2843d6 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/security/JwtConfig.java @@ -0,0 +1,31 @@ +package dev.gooiman.server.config.security; + +import dev.gooiman.server.config.security.fliter.JwtFilter; +import dev.gooiman.server.config.security.provider.JwtProvider; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +public class JwtConfig extends + SecurityConfigurerAdapter { + + @Getter + @Value("${spring.security.blacklist-validity-time") + private Long blacklistValidityTime; + + private final JwtProvider jwtAuthenticationProvider; + + public JwtConfig(JwtProvider jwtAuthenticationProvider) { + this.jwtAuthenticationProvider = jwtAuthenticationProvider; + } + + @Override + public void configure(HttpSecurity http) { + JwtFilter jwtFilter = new JwtFilter(jwtAuthenticationProvider); + http + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + } +} diff --git a/src/main/java/dev/gooiman/server/config/security/PasswordConfig.java b/src/main/java/dev/gooiman/server/config/security/PasswordConfig.java new file mode 100644 index 0000000..9fe1caa --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/security/PasswordConfig.java @@ -0,0 +1,19 @@ +package dev.gooiman.server.config.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@RequiredArgsConstructor +public class PasswordConfig { + + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + +} diff --git a/src/main/java/dev/gooiman/server/config/security/SecurityConfig.java b/src/main/java/dev/gooiman/server/config/security/SecurityConfig.java new file mode 100644 index 0000000..bee5611 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/security/SecurityConfig.java @@ -0,0 +1,57 @@ +package dev.gooiman.server.config.security; + +import dev.gooiman.server.config.security.entrypoint.JwtAuthenticationEntryPoint; +import dev.gooiman.server.config.security.handler.JwtAccessDeniedHandler; +import dev.gooiman.server.config.security.provider.JwtProvider; +import dev.gooiman.server.config.web.CorsConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.AnonymousConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Slf4j +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CorsConfig corsConfig; + private final JwtProvider jwtAuthenticationProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .anonymous(AnonymousConfigurer::disable) + .exceptionHandling(handling -> handling + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler)) + .cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource())) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(headers -> headers.frameOptions(FrameOptionsConfig::sameOrigin)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/error").permitAll() // error + .requestMatchers("/api").permitAll() // health check + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // swagger + .requestMatchers(HttpMethod.PUT, "/api/page/**").permitAll() // page 생성 + .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() // 로그인 + .anyRequest().authenticated()) + .with(new JwtConfig(jwtAuthenticationProvider), customizer -> { + }); + return http.build(); + } +} diff --git a/src/main/java/dev/gooiman/server/config/security/entrypoint/JwtAuthenticationEntryPoint.java b/src/main/java/dev/gooiman/server/config/security/entrypoint/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..5d03a26 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/security/entrypoint/JwtAuthenticationEntryPoint.java @@ -0,0 +1,18 @@ +package dev.gooiman.server.config.security.entrypoint; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/src/main/java/dev/gooiman/server/config/security/fliter/JwtFilter.java b/src/main/java/dev/gooiman/server/config/security/fliter/JwtFilter.java new file mode 100644 index 0000000..abaa738 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/security/fliter/JwtFilter.java @@ -0,0 +1,51 @@ +package dev.gooiman.server.config.security.fliter; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.util.StringUtils.hasText; + +import dev.gooiman.server.config.security.provider.JwtProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +public class JwtFilter extends OncePerRequestFilter { + + private final JwtProvider jwtAuthenticationProvider; + + public JwtFilter(JwtProvider jwtAuthenticationProvider) { + this.jwtAuthenticationProvider = jwtAuthenticationProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + String jwt = resolveToken(request); // Bearer 뒤, 토큰 부분만 파싱 + + if (hasText(jwt)) { + UsernamePasswordAuthenticationToken requestAuthentication = new UsernamePasswordAuthenticationToken( + jwt, ""); + Authentication authentication = jwtAuthenticationProvider.authenticate( + requestAuthentication); + SecurityContextHolder.getContextHolderStrategy().getContext() + .setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION); + if (hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/dev/gooiman/server/config/security/handler/JwtAccessDeniedHandler.java b/src/main/java/dev/gooiman/server/config/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..1488e1d --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,18 @@ +package dev.gooiman.server.config.security.handler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/src/main/java/dev/gooiman/server/config/security/provider/CustomAuthenticationProvider.java b/src/main/java/dev/gooiman/server/config/security/provider/CustomAuthenticationProvider.java new file mode 100644 index 0000000..96d227a --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/security/provider/CustomAuthenticationProvider.java @@ -0,0 +1,47 @@ +package dev.gooiman.server.config.security.provider; + + +import static dev.gooiman.server.common.exception.ErrorCode.FAILURE_LOGIN; + +import dev.gooiman.server.common.exception.CommonException; +import dev.gooiman.server.auth.application.CustomUserDetailsService; +import dev.gooiman.server.auth.application.domain.CustomUserDetails; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomAuthenticationProvider { + + private final CustomUserDetailsService customUserDetailsService; + private final PasswordEncoder passwordEncoder; + + public Optional authenticate(UUID pageId, Authentication authentication) { + String name = (String) authentication.getPrincipal(); + String password = (String) authentication.getCredentials(); + + Optional userDetail = customUserDetailsService.loadUserByUsername( + pageId, name); + + if (userDetail.isEmpty()) { + return Optional.empty(); + } + + userDetail.ifPresent(detail -> validatePassword(password, detail)); + return Optional.of(new UsernamePasswordAuthenticationToken(userDetail.get(), null, + List.of(new SimpleGrantedAuthority("ROLE_USER")))); + } + + private void validatePassword(String password, CustomUserDetails userDetails) { + if (!passwordEncoder.matches(password, userDetails.getPassword())) { + throw new CommonException(FAILURE_LOGIN); + } + } +} diff --git a/src/main/java/dev/gooiman/server/config/security/provider/JwtProvider.java b/src/main/java/dev/gooiman/server/config/security/provider/JwtProvider.java new file mode 100644 index 0000000..9c7dbe6 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/security/provider/JwtProvider.java @@ -0,0 +1,87 @@ +package dev.gooiman.server.config.security.provider; + + +import static dev.gooiman.server.common.exception.ErrorCode.EXPIRED_TOKEN_ERROR; +import static dev.gooiman.server.common.exception.ErrorCode.INVALID_TOKEN_ERROR; +import static dev.gooiman.server.common.exception.ErrorCode.TOKEN_UNSUPPORTED_ERROR; + +import dev.gooiman.server.auth.application.BlackListService; +import dev.gooiman.server.auth.application.CustomUserDetailsService; +import dev.gooiman.server.auth.application.domain.CustomUserDetails; +import dev.gooiman.server.common.exception.CommonException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import java.util.List; +import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtProvider implements AuthenticationProvider { + + private static SecretKey key; + + @Value("${spring.security.secret}") + private String secret; + + private final CustomUserDetailsService customUserDetailsService; + private final BlackListService blackListService; + + @PostConstruct + public void init() { + byte[] keyBytes = Decoders.BASE64.decode(secret); + key = Keys.hmacShaKeyFor(keyBytes); + } + + @Override + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + try { + + String token = (String) authentication.getPrincipal(); + + Jws parsedToken = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token); + +// if (blackListService.isExists(token)) { +// throw new CommonException(ErrorCode.INVALID_TOKEN_ERROR); +// } + + Claims claims = parsedToken.getPayload(); + String userId = claims.getSubject(); + + CustomUserDetails details = customUserDetailsService.loadUserByUserId(userId); + + return new UsernamePasswordAuthenticationToken(details, null, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } catch (ExpiredJwtException e) { + throw new CommonException(EXPIRED_TOKEN_ERROR); + } catch (JwtException e) { + throw new CommonException(INVALID_TOKEN_ERROR); + } catch (IllegalArgumentException e) { + throw new CommonException(TOKEN_UNSUPPORTED_ERROR); + } + } + + @Override + public boolean supports(Class authentication) { + return authentication.isAssignableFrom(UsernamePasswordAuthenticationToken.class); + } +} diff --git a/src/main/java/dev/gooiman/server/config/web/CorsConfig.java b/src/main/java/dev/gooiman/server/config/web/CorsConfig.java new file mode 100644 index 0000000..376cf49 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/web/CorsConfig.java @@ -0,0 +1,29 @@ +package dev.gooiman.server.config.web; + +import java.util.List; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration corsConfig = new CorsConfiguration(); + + corsConfig.setAllowCredentials(true); + corsConfig.setAllowedOrigins( + List.of("http://localhost:3000", "http://localhost:8080/*", "http://localhost:5173", + "https://gooiman.eatsteak.dev")); + corsConfig.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + corsConfig.setAllowedHeaders(List.of("*")); + corsConfig.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", corsConfig); + + return source; + } +} diff --git a/src/main/java/dev/gooiman/server/config/web/SwaggerConfig.java b/src/main/java/dev/gooiman/server/config/web/SwaggerConfig.java new file mode 100644 index 0000000..567d5b1 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/web/SwaggerConfig.java @@ -0,0 +1,38 @@ +package dev.gooiman.server.config.web; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition(security = @SecurityRequirement(name = "bearerAuth")) +@SecurityScheme( + name = "bearerAuth", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" +) +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + + return new OpenAPI() + .components(new Components()) + .info(apiInfo()); + + } + + private Info apiInfo() { + return new Info() + .title("구이만 DEV API") + .description("구이만 DEV API 명세입니다.") + .version("1.0.0"); + } +} diff --git a/src/main/java/dev/gooiman/server/config/web/WebMvcConfig.java b/src/main/java/dev/gooiman/server/config/web/WebMvcConfig.java new file mode 100644 index 0000000..c637297 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/web/WebMvcConfig.java @@ -0,0 +1,22 @@ +package dev.gooiman.server.config.web; + +import dev.gooiman.server.config.web.resolver.JwtAuthorizationArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@EnableWebMvc +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final JwtAuthorizationArgumentResolver jwtAuthorizationArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(jwtAuthorizationArgumentResolver); + } +} \ No newline at end of file diff --git a/src/main/java/dev/gooiman/server/config/web/converter/StringToUUIDConverter.java b/src/main/java/dev/gooiman/server/config/web/converter/StringToUUIDConverter.java new file mode 100644 index 0000000..c5feabd --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/web/converter/StringToUUIDConverter.java @@ -0,0 +1,15 @@ +package dev.gooiman.server.config.web.converter; + +import java.util.UUID; +import org.springframework.core.convert.converter.Converter; +import org.springframework.stereotype.Component; + +@Component +public class StringToUUIDConverter implements + Converter { + @Override + public UUID convert(String source) { + return UUID.fromString(source); + } + +} diff --git a/src/main/java/dev/gooiman/server/config/web/resolver/JwtAuthorizationArgumentResolver.java b/src/main/java/dev/gooiman/server/config/web/resolver/JwtAuthorizationArgumentResolver.java new file mode 100644 index 0000000..38f9da0 --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/web/resolver/JwtAuthorizationArgumentResolver.java @@ -0,0 +1,40 @@ +package dev.gooiman.server.config.web.resolver; + +import dev.gooiman.server.auth.application.domain.CustomUserDetails; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthorizationArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Login.class); + } + + @Override + public Object resolveArgument(@NonNull MethodParameter parameter, + ModelAndViewContainer mavContainer, + @NonNull NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContextHolderStrategy() + .getContext() + .getAuthentication(); + if (authentication == null) { + return null; + } + + CustomUserDetails principal = (CustomUserDetails) authentication.getPrincipal(); + return principal.getId(); + } +} diff --git a/src/main/java/dev/gooiman/server/config/web/resolver/Login.java b/src/main/java/dev/gooiman/server/config/web/resolver/Login.java new file mode 100644 index 0000000..20f51ae --- /dev/null +++ b/src/main/java/dev/gooiman/server/config/web/resolver/Login.java @@ -0,0 +1,12 @@ +package dev.gooiman.server.config.web.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER)//어노테이션이 적용될 위치를 지정 +@Retention(RetentionPolicy.RUNTIME)//어노테이션이 어디까지 유지될지 지정 +public @interface Login { + +} diff --git a/src/main/java/dev/gooiman/server/memo/application/MemoService.java b/src/main/java/dev/gooiman/server/memo/application/MemoService.java new file mode 100644 index 0000000..6ef0131 --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/application/MemoService.java @@ -0,0 +1,93 @@ +package dev.gooiman.server.memo.application; + + +import dev.gooiman.server.auth.application.UserService; +import dev.gooiman.server.auth.repository.entity.User; +import dev.gooiman.server.common.dto.CommonIdResponseDto; +import dev.gooiman.server.common.dto.CommonSuccessDto; +import dev.gooiman.server.common.exception.CommonException; +import dev.gooiman.server.common.exception.ErrorCode; +import dev.gooiman.server.memo.application.dto.CreateMemoRequestDto; +import dev.gooiman.server.memo.application.dto.GetMemoResponseDto; +import dev.gooiman.server.memo.application.dto.MemoDto; +import dev.gooiman.server.memo.application.dto.UpdateMemoRequestDto; +import dev.gooiman.server.memo.repository.MemoRepository; +import dev.gooiman.server.memo.repository.entity.Memo; +import dev.gooiman.server.page.application.PageService; +import dev.gooiman.server.page.repository.entity.Page; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.RequestBody; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MemoService { + + private final MemoRepository memoRepository; + private final UserService userService; + private final PageService pageService; + + public Memo findMemo(UUID memoId) { + return memoRepository.findById(memoId) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_MEMO)); + } + + public MemoDto[] listMemo(UUID pageId, String category) { + if (category != null) { + return memoRepository.findMemosByPage_PageIdAndCategory(pageId, category) + .stream() + .map(MemoDto::fromEntity) + .toArray(MemoDto[]::new); + } + return memoRepository.findMemosByPage_PageId(pageId) + .stream() + .map(MemoDto::fromEntity) + .toArray(MemoDto[]::new); + } + + @Transactional + public CommonSuccessDto updateMemo(UUID memoId, @RequestBody UpdateMemoRequestDto dto) { + Memo memo = findMemo(memoId); + User user = userService.getUserByNameAndPageId(dto.author(), memo.getPageId()); + if (!user.getUserId().equals(memo.getUserId())) { + throw new CommonException(ErrorCode.NOT_MATCH_USER); + } + pageService.updatePageUpdateTime(memo.getPage()); + + memo.updateInfo(dto.title(), dto.content(), dto.category(), dto.subCategory(), dto.color(), + user); + + return CommonSuccessDto.fromEntity(true); + } + + @Transactional + public CommonSuccessDto deleteMemo(UUID memoId) { + Memo memo = findMemo(memoId); + pageService.updatePageUpdateTime(memo.getPage()); + memoRepository.delete(memo); + + return CommonSuccessDto.fromEntity(true); + } + + @Transactional + public CommonIdResponseDto createMemo(CreateMemoRequestDto dto) { + User user = userService.getUserByNameAndPageId(dto.author(), dto.pageId()); + Page page = pageService.getPageById(dto.pageId()); + pageService.updatePageUpdateTime(page); + Memo memo = new Memo(dto.category(), dto.subCategory(), dto.title(), dto.color(), + dto.content(), page, user); + Memo savedMemo = memoRepository.save(memo); + + return new CommonIdResponseDto(savedMemo.getMemoId()); + } + + public GetMemoResponseDto getMemo(UUID memoId) { + Memo memo = findMemo(memoId); + + return new GetMemoResponseDto(memo.getMemoId(), memo.getTitle(), memo.getContent(), + memo.getUsername(), memo.getCategory(), memo.getSubCategory()); + } +} diff --git a/src/main/java/dev/gooiman/server/memo/application/dto/CreateMemoRequestDto.java b/src/main/java/dev/gooiman/server/memo/application/dto/CreateMemoRequestDto.java new file mode 100644 index 0000000..1dbd293 --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/application/dto/CreateMemoRequestDto.java @@ -0,0 +1,18 @@ +package dev.gooiman.server.memo.application.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record CreateMemoRequestDto(UUID pageId, + String author, + String title, + String category, + String subCategory, + String content, + String color +) { + + +} diff --git a/src/main/java/dev/gooiman/server/memo/application/dto/GetMemoResponseDto.java b/src/main/java/dev/gooiman/server/memo/application/dto/GetMemoResponseDto.java new file mode 100644 index 0000000..863e222 --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/application/dto/GetMemoResponseDto.java @@ -0,0 +1,13 @@ +package dev.gooiman.server.memo.application.dto; + +import java.util.UUID; + +public record GetMemoResponseDto(String id, String title, String content, String author, + String category, String subCategory) { + + public GetMemoResponseDto(UUID id, String title, String content, String author, + String category, + String subCategory) { + this(id.toString(), title, content, author, category, subCategory); + } +} diff --git a/src/main/java/dev/gooiman/server/memo/application/dto/MemoDto.java b/src/main/java/dev/gooiman/server/memo/application/dto/MemoDto.java new file mode 100644 index 0000000..c495802 --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/application/dto/MemoDto.java @@ -0,0 +1,31 @@ +package dev.gooiman.server.memo.application.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import dev.gooiman.server.memo.repository.entity.Memo; +import java.util.UUID; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record MemoDto( + UUID id, + String title, + String content, + String author, + String category, + String subCategory, + String color +) { + + public static MemoDto fromEntity(Memo memo) { + return new MemoDto( + memo.getMemoId(), + memo.getTitle(), + memo.getContent(), + memo.getUser().getName(), + memo.getCategory(), + memo.getSubCategory(), + memo.getColor() + ); + } +} diff --git a/src/main/java/dev/gooiman/server/memo/application/dto/MemoSummariesResponseDto.java b/src/main/java/dev/gooiman/server/memo/application/dto/MemoSummariesResponseDto.java new file mode 100644 index 0000000..fbdd2d7 --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/application/dto/MemoSummariesResponseDto.java @@ -0,0 +1,13 @@ +package dev.gooiman.server.memo.application.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import java.util.List; +import java.util.Map; + + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record MemoSummariesResponseDto(String name, + Map>> memoSummaries) { + +} diff --git a/src/main/java/dev/gooiman/server/memo/application/dto/UpdateMemoRequestDto.java b/src/main/java/dev/gooiman/server/memo/application/dto/UpdateMemoRequestDto.java new file mode 100644 index 0000000..60b4fbc --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/application/dto/UpdateMemoRequestDto.java @@ -0,0 +1,15 @@ +package dev.gooiman.server.memo.application.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record UpdateMemoRequestDto(String title, + String content, + String author, + String category, + String subCategory, + String color +) { + +} diff --git a/src/main/java/dev/gooiman/server/memo/controller/MemoController.java b/src/main/java/dev/gooiman/server/memo/controller/MemoController.java new file mode 100644 index 0000000..7420fd0 --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/controller/MemoController.java @@ -0,0 +1,65 @@ +package dev.gooiman.server.memo.controller; + +import dev.gooiman.server.common.dto.CommonIdResponseDto; +import dev.gooiman.server.common.dto.CommonSuccessDto; +import dev.gooiman.server.common.dto.ResponseDto; +import dev.gooiman.server.memo.application.MemoService; +import dev.gooiman.server.memo.application.dto.CreateMemoRequestDto; +import dev.gooiman.server.memo.application.dto.GetMemoResponseDto; +import dev.gooiman.server.memo.application.dto.MemoDto; +import dev.gooiman.server.memo.application.dto.UpdateMemoRequestDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/memo") +@Tag(name = "Memo", description = "메모 처리 API") +public class MemoController { + + private final MemoService memoService; + + + @GetMapping("") + @Operation(summary = "메모 목록 조회", description = "페이지에 속한 메모 목록을 조회합니다. 카테고리를 지정하면 해당 카테고리에 속한 메모만 조회합니다.") + public ResponseDto listMemo(@RequestParam("page_id") UUID pageId, + @RequestParam(required = false) String category) { + return ResponseDto.ok(memoService.listMemo(pageId, category)); + } + + @PatchMapping("/{memoId}") + @Operation(summary = "메모 수정", description = "메모를 수정합니다. 메모 작성자만 수정이 가능합니다.") + public ResponseDto updateMemo(@PathVariable("memoId") UUID memoId, + @RequestBody UpdateMemoRequestDto dto) { + return ResponseDto.ok(memoService.updateMemo(memoId, dto)); + } + + @PutMapping("") + @Operation(summary = "메모 생성", description = "메모를 생성합니다.") + public ResponseDto createMemo(@RequestBody CreateMemoRequestDto dto) { + return ResponseDto.created(memoService.createMemo(dto)); + } + + @GetMapping("/{memoId}") + @Operation(summary = "메모 조회", description = "메모를 조회합니다.") + public ResponseDto getMemo(@PathVariable("memoId") UUID memoId) { + return ResponseDto.ok(memoService.getMemo(memoId)); + } + + @DeleteMapping("/{memoId}") + @Operation(summary = "메모 삭제", description = "메모를 삭제합니다. 메모 작성자만 삭제가 가능합니다.") + public ResponseDto deleteMemo(@PathVariable("memoId") UUID memoId) { + return ResponseDto.ok(memoService.deleteMemo(memoId)); + } +} diff --git a/src/main/java/dev/gooiman/server/memo/repository/MemoRepository.java b/src/main/java/dev/gooiman/server/memo/repository/MemoRepository.java new file mode 100644 index 0000000..8d428d6 --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/repository/MemoRepository.java @@ -0,0 +1,20 @@ +package dev.gooiman.server.memo.repository; + +import dev.gooiman.server.memo.repository.entity.Memo; +import dev.gooiman.server.memo.repository.view.MemoSummariesView; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MemoRepository extends JpaRepository { + + List findMemosByPage_PageIdAndCategory(UUID pageId, String category); + + List findMemosByPage_PageId(UUID pageId); + + @Query("select m.title as title, m.category as category, m.subCategory as subCategory from Memo m where m.page.pageId = :pageId") + List getMemoSummaries(@Param("pageId") UUID pageId); + +} diff --git a/src/main/java/dev/gooiman/server/memo/repository/entity/Memo.java b/src/main/java/dev/gooiman/server/memo/repository/entity/Memo.java new file mode 100644 index 0000000..5f30bdb --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/repository/entity/Memo.java @@ -0,0 +1,83 @@ +package dev.gooiman.server.memo.repository.entity; + +import dev.gooiman.server.auth.repository.entity.User; +import dev.gooiman.server.page.repository.entity.Page; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; +import org.hibernate.annotations.GenericGenerator; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "memo") +@DynamicUpdate +public class Memo { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(columnDefinition = "BINARY(16)") + private UUID memoId; + private String category; + private String subCategory; + private String title; + private String color; + + @Lob + @Column(columnDefinition = "TEXT") + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "PAGE_ID") + private Page page; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "USER_ID") + private User user; + + @Column(name = "USER_ID", insertable = false, updatable = false) + private UUID userId; + + @Column(name = "PAGE_ID", insertable = false, updatable = false) + private UUID pageId; + + public String getUsername() { + return user.getName(); + } + + public Memo(String category, String subCategory, String title, String color, + String content, Page page, User user) { + this.category = category; + this.subCategory = subCategory; + this.title = title; + this.color = color; + this.content = content; + this.page = page; + this.user = user; + } + + public void updateInfo(String title, String content, String category, String subCategory, + String color, User user) { + this.title = title; + this.content = content; + this.category = category; + this.subCategory = subCategory; + this.color = color; + this.user = user; + } +} diff --git a/src/main/java/dev/gooiman/server/memo/repository/view/MemoSummariesView.java b/src/main/java/dev/gooiman/server/memo/repository/view/MemoSummariesView.java new file mode 100644 index 0000000..46c64e7 --- /dev/null +++ b/src/main/java/dev/gooiman/server/memo/repository/view/MemoSummariesView.java @@ -0,0 +1,7 @@ +package dev.gooiman.server.memo.repository.view; + +public interface MemoSummariesView { + String getTitle(); + String getCategory(); + String getSubCategory(); +} diff --git a/src/main/java/dev/gooiman/server/page/application/PageService.java b/src/main/java/dev/gooiman/server/page/application/PageService.java new file mode 100644 index 0000000..08deedc --- /dev/null +++ b/src/main/java/dev/gooiman/server/page/application/PageService.java @@ -0,0 +1,60 @@ +package dev.gooiman.server.page.application; + +import dev.gooiman.server.common.dto.CommonIdResponseDto; +import dev.gooiman.server.common.exception.CommonException; +import dev.gooiman.server.common.exception.ErrorCode; +import dev.gooiman.server.memo.application.dto.MemoSummariesResponseDto; +import dev.gooiman.server.memo.repository.MemoRepository; +import dev.gooiman.server.memo.repository.view.MemoSummariesView; +import dev.gooiman.server.page.application.dto.CreatePageRequestDto; +import dev.gooiman.server.page.application.dto.GetPageUpdatedTimeResponseDto; +import dev.gooiman.server.page.repository.PageRepository; +import dev.gooiman.server.page.repository.entity.Page; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PageService { + + private final PageRepository pageRepository; + private final MemoRepository memoRepository; + + public Page getPageById(UUID id) { + return pageRepository.findById(id) + .orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_PAGE)); + } + + @Transactional + public CommonIdResponseDto create(CreatePageRequestDto dto) { + Page page = new Page(dto.name()); + Page savedPage = pageRepository.save(page); + return new CommonIdResponseDto(savedPage.getPageId()); + } + + public MemoSummariesResponseDto memoSummaries(UUID pageId) { + Page page = getPageById(pageId); + String name = page.getPageName(); + Map>> memoSummaries = memoRepository.getMemoSummaries( + pageId) + .stream().collect( + Collectors.groupingBy(MemoSummariesView::getCategory, + Collectors.groupingBy(MemoSummariesView::getSubCategory, + Collectors.mapping(MemoSummariesView::getTitle, Collectors.toList())))); + return new MemoSummariesResponseDto(name, memoSummaries); + } + + public GetPageUpdatedTimeResponseDto getLastUpdatedPage(UUID pageId) { + Page page = getPageById(pageId); + return new GetPageUpdatedTimeResponseDto(page.getUpdateTime()); + } + + public void updatePageUpdateTime(Page page) { + page.updateTime(); + } +} diff --git a/src/main/java/dev/gooiman/server/page/application/dto/CreatePageRequestDto.java b/src/main/java/dev/gooiman/server/page/application/dto/CreatePageRequestDto.java new file mode 100644 index 0000000..90c785d --- /dev/null +++ b/src/main/java/dev/gooiman/server/page/application/dto/CreatePageRequestDto.java @@ -0,0 +1,5 @@ +package dev.gooiman.server.page.application.dto; + +public record CreatePageRequestDto(String name) { + +} diff --git a/src/main/java/dev/gooiman/server/page/application/dto/GetMemoSummariesResponseDto.java b/src/main/java/dev/gooiman/server/page/application/dto/GetMemoSummariesResponseDto.java new file mode 100644 index 0000000..79cf959 --- /dev/null +++ b/src/main/java/dev/gooiman/server/page/application/dto/GetMemoSummariesResponseDto.java @@ -0,0 +1,11 @@ +package dev.gooiman.server.page.application.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record GetMemoSummariesResponseDto(String category, + List subcategories) { + +} diff --git a/src/main/java/dev/gooiman/server/page/application/dto/GetPageUpdatedTimeResponseDto.java b/src/main/java/dev/gooiman/server/page/application/dto/GetPageUpdatedTimeResponseDto.java new file mode 100644 index 0000000..4b1fa64 --- /dev/null +++ b/src/main/java/dev/gooiman/server/page/application/dto/GetPageUpdatedTimeResponseDto.java @@ -0,0 +1,13 @@ +package dev.gooiman.server.page.application.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import java.sql.Timestamp; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record GetPageUpdatedTimeResponseDto(Timestamp lastUpdatedTime) { + + public GetPageUpdatedTimeResponseDto(Timestamp lastUpdatedTime) { + this.lastUpdatedTime = lastUpdatedTime; + } +} diff --git a/src/main/java/dev/gooiman/server/page/application/dto/GetSubCategoryResponseDto.java b/src/main/java/dev/gooiman/server/page/application/dto/GetSubCategoryResponseDto.java new file mode 100644 index 0000000..ee89b65 --- /dev/null +++ b/src/main/java/dev/gooiman/server/page/application/dto/GetSubCategoryResponseDto.java @@ -0,0 +1,9 @@ +package dev.gooiman.server.page.application.dto; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public record GetSubCategoryResponseDto(String subCategory) { + +} diff --git a/src/main/java/dev/gooiman/server/page/controller/PageController.java b/src/main/java/dev/gooiman/server/page/controller/PageController.java new file mode 100644 index 0000000..6182d6a --- /dev/null +++ b/src/main/java/dev/gooiman/server/page/controller/PageController.java @@ -0,0 +1,49 @@ +package dev.gooiman.server.page.controller; + +import dev.gooiman.server.common.dto.CommonIdResponseDto; +import dev.gooiman.server.common.dto.ResponseDto; +import dev.gooiman.server.memo.application.dto.MemoSummariesResponseDto; +import dev.gooiman.server.page.application.PageService; +import dev.gooiman.server.page.application.dto.CreatePageRequestDto; +import dev.gooiman.server.page.application.dto.GetPageUpdatedTimeResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/page") +@Tag(name = "Page", description = "페이지 처리 API") +public class PageController { + + private final PageService pageService; + + @PutMapping + @Operation(summary = "페이지 생성", description = "페이지를 생성합니다.") + @SecurityRequirements + public ResponseDto createPage( + @RequestBody CreatePageRequestDto createPageDto) { + return ResponseDto.ok(pageService.create(createPageDto)); + } + + @GetMapping("/{pageId}") + @Operation(summary = "페이지 정보 조회", description = "페이지 정보를 조회합니다. 주제 및 소주제로 그룹화된 메모 제목이 같이 제공됩니다.") + public ResponseDto getPageSummaries(@PathVariable UUID pageId) { + MemoSummariesResponseDto res = pageService.memoSummaries(pageId); + return ResponseDto.ok(res); + } + + @GetMapping("/{pageId}/last-updated") + public ResponseDto getLastUpdatedTime( + @PathVariable("pageId") UUID pageId) { + return ResponseDto.ok(pageService.getLastUpdatedPage(pageId)); + } +} diff --git a/src/main/java/dev/gooiman/server/page/repository/PageRepository.java b/src/main/java/dev/gooiman/server/page/repository/PageRepository.java new file mode 100644 index 0000000..d1eceff --- /dev/null +++ b/src/main/java/dev/gooiman/server/page/repository/PageRepository.java @@ -0,0 +1,9 @@ +package dev.gooiman.server.page.repository; + +import dev.gooiman.server.page.repository.entity.Page; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PageRepository extends JpaRepository { + +} diff --git a/src/main/java/dev/gooiman/server/page/repository/entity/Page.java b/src/main/java/dev/gooiman/server/page/repository/entity/Page.java new file mode 100644 index 0000000..22a7bde --- /dev/null +++ b/src/main/java/dev/gooiman/server/page/repository/entity/Page.java @@ -0,0 +1,40 @@ +package dev.gooiman.server.page.repository.entity; + +import dev.gooiman.server.common.repository.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.sql.Timestamp; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.GenericGenerator; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity +@Table(name = "page") +public class Page extends BaseTimeEntity { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(columnDefinition = "BINARY(16)") + private UUID pageId; + + private String pageName; + + public Page(String pageName) { + this.pageName = pageName; + } + + public void updateTime() { + this.updateTime = new Timestamp(System.currentTimeMillis()); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..2bff407 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,12 @@ +server: + port: 8080 + ssl: + enabled: true + key-store: classpath:keystore.p12 + key-store-password: ${KEYSTORE_PASSWORD} + key-store-type: PKCS12 +spring: + data: + redis: + host: redis.gooiman.internal + port: 6379 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..295b937 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,17 @@ +server: + port: 8080 + ssl: + enabled: true + key-store: classpath:keystore.p12 + key-store-password: ${KEYSTORE_PASSWORD} + key-store-type: PKCS12 +springdoc: + swagger-ui: + enabled: false + api-docs: + enabled: false +spring: + data: + redis: + host: redis.gooiman.internal + port: 6379 \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 4560433..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=Gooiman_server diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..11d8671 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,26 @@ +spring: + application: + name: gooiman-server + datasource: + url: "jdbc:mysql://${DATABASE_ADDRESS}:3306/gooiman?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&maxReconnects=10" + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + jpa: + generate-ddl: true + hibernate: + ddl-auto: update + show-sql: true + security: + secret: ${JWT_SECRET} + token-validity-time: ${JWT_TOKEN_VALIDITY_TIME} + blacklist-validity-time: ${BLACKLIST_VALIDITY_TIME} + +springdoc: + packages-to-scan: dev.gooiman.server + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + path: /swagger-ui + disable-swagger-default-url: true + display-request-duration: true + operations-sorter: alpha \ No newline at end of file diff --git a/src/main/resources/keystore.p12 b/src/main/resources/keystore.p12 new file mode 100644 index 0000000..0b56b73 Binary files /dev/null and b/src/main/resources/keystore.p12 differ diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..a5fc4f5 --- /dev/null +++ b/variables.tf @@ -0,0 +1,22 @@ +variable "environment" { + description = "Deployment environment" + type = string +} + +variable "aws_region" { + description = "AWS region" + type = string + default = "ap-northeast-2" +} + +variable "database_user" { + description = "Database username" + type = string + default = "admin" +} + +variable "database_password" { + description = "Database password" + type = string + default = "password" +} \ No newline at end of file