diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b1cc00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +*# +*.iml +*.ipr +*.iws +*.jar +*.sw? +*~ +.#* +.*.md.html +.DS_Store +.attach_pid* +.classpath +.factorypath +.gradle +.metadata +.project +.recommenders +.settings +.springBeans +.vscode +/code +MANIFEST.MF +_site/ +activemq-data +bin +build +!/**/src/**/bin +!/**/src/**/build +build.log +dependency-reduced-pom.xml +dump.rdb +interpolated*.xml +lib/ +manifest.yml +out +overridedb.* +target +.flattened-pom.xml +secrets.yml +.gradletasknamecache +.sts4-cache + +.idea +.env +src/test/resources/application.yml +src/main/resources/application.yml \ No newline at end of file diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..9bc5cae --- /dev/null +++ b/HELP.md @@ -0,0 +1,28 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Gradle documentation](https://docs.gradle.org) +* [Spring Boot Gradle Plugin Reference Guide](https://docs.spring.io/spring-boot/3.3.7/gradle-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.3.7/gradle-plugin/packaging-oci-image.html) +* [Spring Web](https://docs.spring.io/spring-boot/3.3.7/reference/web/servlet.html) +* [Spring Data JPA](https://docs.spring.io/spring-boot/3.3.7/reference/data/sql.html#data.sql.jpa-and-spring-data) +* [Spring Security](https://docs.spring.io/spring-boot/3.3.7/reference/web/spring-security.html) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) +* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) +* [Securing a Web Application](https://spring.io/guides/gs/securing-web/) +* [Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/) +* [Authenticating a User with LDAP](https://spring.io/guides/gs/authenticating-ldap/) + +### Additional Links +These additional references should also help you: + +* [Gradle Build Scans โ€“ insights for your project's build](https://scans.gradle.com#gradle) + diff --git a/README.md b/README.md index 76efd88..ec3eb11 100644 --- a/README.md +++ b/README.md @@ -1 +1,113 @@ -# spring_vote_20th \ No newline at end of file +# spring_vote_20th + +## ๐Ÿ’กย ๊ตฌํ˜„ ๊ธฐ๋Šฅ + +**ERD** + +![vote (1) (1)](https://github.com/user-attachments/assets/97c8d1d2-0247-48b3-845d-d1a0ec284b9a) + +1. **Member** + - ๋กœ๊ทธ์ธํ•œ ์‚ฌ์šฉ์ž(๋ฉค๋ฒ„)๋ฅผ ์˜๋ฏธํ•œ๋‹ค. + - ํˆฌํ‘œ ๊ถŒํ•œ์ด ์žˆ์œผ๋ฉฐ, ์•„๋ž˜์˜ ํˆฌํ‘œ์— ๊ฐ๊ฐ ์ตœ๋Œ€ **1ํšŒ ์ฐธ์—ฌ**ํ•  ์ˆ˜ ์žˆ๋‹ค. + - **vote_back, vote_front**: ๊ฐœ๋ฐœ์ž ํˆฌํ‘œ + - **vote_team**: ํŒ€ ํˆฌํ‘œ +2. **Team**๊ณผ **Developer** + - ํˆฌํ‘œ ๊ฐ€๋Šฅํ•œ **ํ›„๋ณด**๋ฅผ ์˜๋ฏธํ•œ๋‹ค. + - ๊ฐ ๊ฐ์ฒด๋Š” ์ž์‹ ์˜ ํˆฌํ‘œ์ˆ˜๋ฅผ ๋‚˜ํƒ€๋‚ด๋Š” **count** ํ•„๋“œ๋ฅผ ๊ฐ€์ง„๋‹ค. + +แ„‰แ…ณแ„แ…ณแ„…แ…ตแ†ซแ„‰แ…ฃแ†บ 2025-01-06 แ„‹แ…ฉแ„Œแ…ฅแ†ซ 12 18 48 + +**Auth** + +- JWT ๋ฐฉ์‹ ๋กœ๊ทธ์ธ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + - **Access Token**์€ ํ—ค๋”์— ๋ฐœ๊ธ‰ํ•˜๊ณ , **Refresh Token**์€ ์ฟ ํ‚ค๋กœ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค. + - **Refresh Token**์€ ๋ณ„๋„์˜ `Refresh` ์—”ํ‹ฐํ‹ฐ์— ์ €์žฅํ•˜์—ฌ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค. + - ๋กœ๊ทธ์•„์›ƒ ์‹œ **Refresh Token**์„ ์‚ญ์ œํ•˜์—ฌ ์žฌ์‚ฌ์šฉ์„ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค. + +**Vote** + +- ์—๋Ÿฌ ์ฒ˜๋ฆฌ + 1. ๊ฐ™์€ ํŒ€์— ํˆฌํ‘œ: `BAD_REQUEST_TEAM` + 2. ๋‹ค๋ฅธ ํŒŒํŠธ์— ํˆฌํ‘œ: `BAD_REQUEST_DEVELOPER` + 3. ์ค‘๋ณต ํˆฌํ‘œ + - ๊ฐœ๋ฐœ์ž ํˆฌํ‘œ: `ALREADY_VOTE_DEVELOPER` + - ํŒ€ ํˆฌํ‘œ: `ALREADY_VOTE_TEAM` + +## ๐Ÿงฉย ๋ฐฐํฌ + +### ๊ฐ€์žฅ ๊ฐ„๋‹จํ•œ ๋ฐฐํฌ ๋ฐฉ๋ฒ• + +1. ์Šคํ”„๋ง๋ถ€ํŠธ์—์„œ BootJar์„ ์‹คํ–‰ํ•ด์ฃผ๋ฉด build/libs ํด๋” ์•ˆ์— jar ํŒŒ์ผ์ด ์ƒ์„ฑ๋จ +2. ์•„๋ž˜ ๋ช…๋ น์–ด๋ฅผ ํ†ตํ•ด jar ํŒŒ์ผ์„ ๋‚ด ์ธ์Šคํ„ด์Šค์— ์˜ฎ๊ฒจ์คŒ + + ```java + scp -i "{my-key}.pem" ./build/libs/{jar-file-name}.jar ubuntu@{ํผ๋ธ”๋ฆญ IP}:/home/ubuntu + ``` + + - **scp (Secure Copy Protocol)** + - SSH๋ฅผ ์ด์šฉํ•ด ํŒŒ์ผ์„ ์•ˆ์ „ํ•˜๊ฒŒ ๋ณต์‚ฌํ•˜๋Š” ๋ช…๋ น์–ด์ด๋‹ค. ๋กœ์ปฌ ์‹œ์Šคํ…œ๊ณผ ์›๊ฒฉ ์‹œ์Šคํ…œ ๊ฐ„, ๋˜๋Š” ์›๊ฒฉ ์‹œ์Šคํ…œ๋“ค ๊ฐ„์— ํŒŒ์ผ์„ ์ „์†กํ•  ๋•Œ ์‚ฌ์šฉํ•œ๋‹ค. +3. ์ธ์Šคํ„ด์Šค ํ„ฐ๋ฏธ๋„์— ์ ‘์†ํ•ด ์•„๋ž˜ ๋ช…๋ น์–ด๋ฅผ ํ†ตํ•ด jar ํŒŒ์ผ์„ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์‹คํ–‰์‹œ์ผœ์ฃผ๋ฉด ๋! + + ```java + nohup java -jar backend-0.0.1-SNAPSHOT.jar & + ``` + + - **nohup (no hang up)** + - ํ„ฐ๋ฏธ๋„์ด ์ข…๋ฃŒ๋˜์–ด๋„ ๋ช…๋ น์–ด ์‹คํ–‰์ด ์ค‘๋‹จ๋˜์ง€ ์•Š๋„๋ก ๋ณด์žฅํ•˜๋Š” ๋ช…๋ น์–ด์ด๋‹ค. + - **&** + - ๋ช…๋ น์„ ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์‹คํ–‰ํ•˜๋Š” ์‰˜ ์—ฐ์‚ฐ์ž์ด๋‹ค. + +## ๐Ÿšจย ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ… + +ํ”„๋ก ํŠธ๊นŒ์ง€ ๋ฐฐํฌ๋ฅผ ํ•˜๊ณ  ๋ฐฑ์—”๋“œ์— api ์š”์ฒญ์„ ํ–ˆ๋Š”๋ฐ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค. + +KakaoTalk_Photo_2025-01-05-20-33-15 (1) + +### Mixed Content ๋ž€? + +๋ธŒ๋ผ์šฐ์ €์—์„œ HTTPS๋กœ ์ œ๊ณต๋˜๋Š” ์›น ํŽ˜์ด์ง€๊ฐ€ ๋ณด์•ˆ๋˜์ง€ ์•Š์€ HTTP ๋ฆฌ์†Œ์Šค๋ฅผ ๋กœ๋“œํ•˜๊ฑฐ๋‚˜ ์š”์ฒญํ•  ๋•Œ ๋ฐœ์ƒํ•˜๋Š” ์ƒํ™ฉ์„ ๋งํ•œ๋‹ค. + +HTTPS๋Š” ๋ฐ์ดํ„ฐ๊ฐ€ ์•”ํ˜ธํ™” ๋˜์–ด ์•ˆ์ „ํ•˜๊ฒŒ ์ „์†ก๋จ์„ ๋ณด์žฅํ•˜๋Š”๋ฐ HTTP๋Š” ์•”ํ˜ธํ™”๋˜์ง€ ์•Š์€ ์—ฐ๊ฒฐ์„ ์‚ฌ์šฉํ•˜์—ฌ HTTPS ํŽ˜์ด์ง€์—์„œ HTTP ๋ฆฌ์†Œ์Šค๋ฅผ ๋กœ๋“œํ•˜๋ฉด ๋ณด์•ˆ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•œ๋‹ค๊ณ  ํ•œ๋‹ค. + +โ†’ ์šฐ๋ฆฌ ํ”„๋ก ํŠธ๊ฐ€ Https๋กœ ๋ฐฐํฌ๋ฅผ ํ–ˆ๋Š”๋ฐ, ๋ฐฑ์—”๋“œ์—์„œ http๋กœ ๋ฐฐํฌ๋ฅผ ํ•ด์„œ ๋ฐœ์ƒํ•œ ๋ฌธ์ œ์˜€๋‹ค. ์šฐ๋ฆฌ ๋ฐฑ์—”๋“œ ์„œ๋ฒ„์— Https ๋ฅผ ์ ์šฉํ•ด์ฃผ๊ธฐ๋กœ ํ–ˆ๋‹ค. + +### https ์ ์šฉํ•˜๊ธฐ + +์ผ๋‹จ ๋„๋ฉ”์ธ์ด ์—†๋˜ ์ƒํ™ฉ์ด๋ผ, ๋„๋ฉ”์ธ ์—†์ด https๋ฅผ ์ ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์„ ์ฐพ๋˜ ์ค‘ caddy๋ฅผ ์•Œ๊ฒŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. + +**caddy์˜ ์ฃผ์š” ์—ญํ• ** + +1. ์ž๋™์œผ๋กœ tls ์ธ์ฆ์„œ๋ฅผ ๋ฐœ๊ธ‰ํ•ด์ค€๋‹ค +2. nginx.conf์™€ ๊ฐ™์€ Caddyfile์ด ์กด์žฌํ•ด, ๋ฆฌ๋ฒ„์Šค ํ”„๋ก์‹œ ์„ค์ •์ด ๊ฐ€๋Šฅํ•˜๋‹ค. + +**CaddyFile** + +```java +{ + admin 0.0.0.0:2020 +} + +[ec2 PUBLIC IP์ฃผ์†Œ].nip.io { + + tls [์ด๋ฉ”์ผ ์ฃผ์†Œ] + reverse_proxy localhost:8080 + +} +``` + +- `[ec2 PUBLIC IP์ฃผ์†Œ]`: EC2 ์ธ์Šคํ„ด์Šค์˜ ํผ๋ธ”๋ฆญ IP ์ฃผ์†Œ๋ฅผ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. +- **`.nip.io`**: **๋™์  DNS ์„œ๋น„์Šค**๋กœ, ํŠน์ • IP ์ฃผ์†Œ๋ฅผ ํฌํ•จํ•˜๋Š” ์ž„์‹œ ๋„๋ฉ”์ธ ์ด๋ฆ„์„ ์ƒ์„ฑํ•ด ์ค๋‹ˆ๋‹ค. + - ์˜ˆ: `123.45.67.89.nip.io`๋กœ ์ ‘์†ํ•˜๋ฉด `123.45.67.89`๋กœ ์—ฐ๊ฒฐ๋ฉ๋‹ˆ๋‹ค. + - โ†’ ์ด๋ฅผ ํ†ตํ•ด ๋„๋ฉ”์ธ์ด ์—†์–ด๋„ HTTPS๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- `tls [์ด๋ฉ”์ผ ์ฃผ์†Œ]` : Let's Encrypt๋ฅผ ์‚ฌ์šฉํ•ด ์ธ์ฆ์„œ๋ฅผ ์ž๋™์œผ๋กœ ๋ฐœ๊ธ‰๋ฐ›๋„๋ก ์ด๋ฉ”์ผ ์„ค์ •์„ ํ•ด์ค๋‹ˆ๋‹ค. + - ๋งŒ์•ฝ **tls internal** ์„ ์ ๋Š”๋‹ค๋ฉด ์™ธ๋ถ€ ์ธ์ฆ์„œ ๋ฐœ๊ธ‰ ์—†์ด caddy ์ž์ฒด ์ธ์ฆ์„œ๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ๋ฐœ๊ธ‰ํ•ฉ๋‹ˆ๋‹ค. โ†’ ์ฃผ์˜ํ•  ์ ์€ ๋กœ์ปฌ์—์„œ๋งŒ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๋‹ค๋Š” ์ ..ใ…Žใ…Ž + +**๋™์ž‘ ํ๋ฆ„** + +`https://123.45.67.89.nip.io/api/data` + +1. ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋„๋ฉ”์ธ(`123.45.67.89.nip.io`)๋กœ HTTPS ์š”์ฒญ. +2. Caddy๊ฐ€ ์š”์ฒญ ์ˆ˜์‹  โ†’ ์ธ์ฆ์„œ ํ™•์ธ ๋ฐ ์•”ํ˜ธํ™”๋œ ์—ฐ๊ฒฐ ์„ค์ •. +3. Caddy๋Š” ์š”์ฒญ์„ ๋ถ„์„ ํ›„, `/api/data`๋ฅผ `localhost:8080`์œผ๋กœ ์ „๋‹ฌ. +4. ๋‚ด๋ถ€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜(Spring Boot ์„œ๋ฒ„)์ด ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๊ณ  ์‘๋‹ต ๋ฐ˜ํ™˜. +5. Caddy๊ฐ€ ์‘๋‹ต์„ ๋ฐ›์•„ ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ์ „๋‹ฌ. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..5a8ba2e --- /dev/null +++ b/build.gradle @@ -0,0 +1,49 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.7' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'ceos' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + + // jwt + implementation "org.springframework.boot:spring-boot-starter-security" + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..bfce6e0 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'vote' diff --git a/src/main/java/ceos/vote/VoteApplication.java b/src/main/java/ceos/vote/VoteApplication.java new file mode 100644 index 0000000..1d66a88 --- /dev/null +++ b/src/main/java/ceos/vote/VoteApplication.java @@ -0,0 +1,15 @@ +package ceos.vote; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class VoteApplication { + + public static void main(String[] args) { + SpringApplication.run(VoteApplication.class, args); + } + +} diff --git a/src/main/java/ceos/vote/domain/developer/controller/DeveloperController.java b/src/main/java/ceos/vote/domain/developer/controller/DeveloperController.java new file mode 100644 index 0000000..46c6e34 --- /dev/null +++ b/src/main/java/ceos/vote/domain/developer/controller/DeveloperController.java @@ -0,0 +1,24 @@ +package ceos.vote.domain.developer.controller; +import ceos.vote.domain.developer.dto.DeveloperRequestDto; +import ceos.vote.domain.developer.service.DeveloperService; +import ceos.vote.global.common.response.CommonResponse; +import lombok.RequiredArgsConstructor; +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 +@RequestMapping("/api/developer") +@RequiredArgsConstructor +public class DeveloperController { + + private final DeveloperService developerService; + + @PostMapping + public CommonResponse createDeveloper(@RequestBody DeveloperRequestDto developerRequestDto) { + + developerService.createDeveloper(developerRequestDto); + return new CommonResponse<>("๊ฐœ๋ฐœ์ž ์ƒ์„ฑ ์™„๋ฃŒ"); + } +} diff --git a/src/main/java/ceos/vote/domain/developer/dto/DeveloperRequestDto.java b/src/main/java/ceos/vote/domain/developer/dto/DeveloperRequestDto.java new file mode 100644 index 0000000..0b78e30 --- /dev/null +++ b/src/main/java/ceos/vote/domain/developer/dto/DeveloperRequestDto.java @@ -0,0 +1,25 @@ +package ceos.vote.domain.developer.dto; + +import ceos.vote.domain.developer.entity.Developer; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record DeveloperRequestDto( + + @Schema(description = "์ด๋ฆ„", example = "ํ™๊ธธ๋™") + @NotNull(message = "์ด๋ฆ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String name, + + @Schema(description = "์†Œ์† ํŒ€๋ช…", example = "ํฌํ† ๊ทธ๋ผ์šด๋“œ | ์—”์ ค๋ธŒ๋ฆฟ์ง€ | ํŽ˜๋‹ฌ์ง€๋‹ˆ | ์ผ€์ดํฌ์›จ์ด | ์ปคํ”ผ๋”œ") + @NotNull(message = "์†Œ์† ํŒ€๋ช…์€ ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String team, + + @Schema(description = "์ž๊ธฐ์†Œ๊ฐœ", example = "์•ˆ๋…•ํ•˜์„ธ์š”~ ์ด๊ฑด ์ž๊ธฐ ์†Œ๊ฐœ์ž…๋‹ˆ๋‹ค.") + @NotNull(message = "์ž๊ธฐ์†Œ๊ฐœ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String introduction, + + @Schema(description = "์†Œ์† ํŒŒํŠธ", example = "backend | frontend") + @NotNull(message = "์†Œ์† ํŒŒํŠธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String part +) { +} diff --git a/src/main/java/ceos/vote/domain/developer/entity/Developer.java b/src/main/java/ceos/vote/domain/developer/entity/Developer.java new file mode 100644 index 0000000..5b1cd37 --- /dev/null +++ b/src/main/java/ceos/vote/domain/developer/entity/Developer.java @@ -0,0 +1,48 @@ +package ceos.vote.domain.developer.entity; + +import ceos.vote.domain.member.entity.PartType; +import ceos.vote.domain.team.entity.Team; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Developer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "developer_id", nullable = false) + private Long id; + + private String developerName; + + @Enumerated(EnumType.STRING) + @Column(length = 50, nullable = false) + private PartType part; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id") + private Team team; + + private String introduction; + + private int count = 0; + + public void voteToMe() { + this.count++; + } + + @Builder + public Developer(String developerName, PartType part, Team team, String introduction) { + + this.developerName = developerName; + this.part = part; + this.team = team; + this.introduction = introduction; + } + +} diff --git a/src/main/java/ceos/vote/domain/developer/service/DeveloperService.java b/src/main/java/ceos/vote/domain/developer/service/DeveloperService.java new file mode 100644 index 0000000..54bb8a3 --- /dev/null +++ b/src/main/java/ceos/vote/domain/developer/service/DeveloperService.java @@ -0,0 +1,49 @@ +package ceos.vote.domain.developer.service; + +import ceos.vote.domain.developer.dto.DeveloperRequestDto; +import ceos.vote.domain.developer.entity.Developer; +import ceos.vote.domain.member.entity.PartType; +import ceos.vote.domain.member.entity.TeamType; +import ceos.vote.domain.team.entity.Team; +import ceos.vote.global.repository.DeveloperRepository; +import ceos.vote.global.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DeveloperService { + + private final TeamRepository teamRepository; + private final DeveloperRepository developerRepository; + + @Transactional + public void createDeveloper(DeveloperRequestDto developerRequestDto) { + + // team + TeamType type = null; + if (developerRequestDto.team().equals("ํฌํ† ๊ทธ๋ผ์šด๋“œ")) + type = TeamType.PHOTOGROUND; + else if (developerRequestDto.team().equals("์—”์ ค๋ธŒ๋ฆฟ์ง€")) + type = TeamType.ANGELBRIDGE; + else if (developerRequestDto.team().equals("ํŽ˜๋‹ฌ์ง€๋‹ˆ")) + type = TeamType.PEDALGENIE; + else if (developerRequestDto.team().equals("์ผ€์ดํฌ์›จ์ด")) + type = TeamType.CAKEWAY; + else if (developerRequestDto.team().equals("์ปคํ”ผ๋”œ")) + type = TeamType.COFFEEDEAL; + Team team = teamRepository.findByType(type); + + // ํŒŒํŠธ (๋ฐฑ์—”๋“œ or ํ”„๋ก ํŠธ) + PartType partType = PartType.valueOf(developerRequestDto.part().toUpperCase()); + + Developer developer = Developer.builder() + .developerName(developerRequestDto.name()) + .part(partType) + .team(team) + .introduction(developerRequestDto.introduction()) + .build(); + developerRepository.save(developer); + } +} diff --git a/src/main/java/ceos/vote/domain/member/controller/AuthController.java b/src/main/java/ceos/vote/domain/member/controller/AuthController.java new file mode 100644 index 0000000..c6e298a --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/controller/AuthController.java @@ -0,0 +1,50 @@ +package ceos.vote.domain.member.controller; + +import ceos.vote.domain.member.dto.request.SignupRequestDto; +import ceos.vote.domain.member.dto.response.MemberResponseDto; +import ceos.vote.domain.member.service.AuthService; +import ceos.vote.global.common.response.CommonResponse; +import ceos.vote.global.jwt.JWTUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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 +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "Auth", description = "์ธ์ฆ ๊ด€๋ จ API") +public class AuthController { + + private final AuthService authService; + private final JWTUtil jwtUtil; + + @PostMapping("/signup") + @Operation(summary = "ํšŒ์›๊ฐ€์ž…", description = "ํšŒ์›๊ฐ€์ž… ์š”์ฒญ API") + public CommonResponse signup(@Valid @RequestBody SignupRequestDto request) { + + return new CommonResponse<>(authService.signup(request), "ํšŒ์›๊ฐ€์ž…์„ ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค."); + } + + @PostMapping("/reissue") + @Operation(summary = "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰", description = "ํ† ํฐ ์žฌ๋ฐœ๊ธ‰ ์š”์ฒญ API") + public CommonResponse reissue(HttpServletRequest request, HttpServletResponse response) { + + String refreshToken = authService.extractRefreshToken(request); + authService.validateRefreshToken(refreshToken); + + String newAccessToken = authService.reissueAccessToken(refreshToken); + Cookie RefreshTokenCookie = authService.createRefreshTokenCookie(refreshToken); + + authService.setNewTokens(response, newAccessToken, RefreshTokenCookie); + + return new CommonResponse<>("ํ† ํฐ ์žฌ๋ฐœ๊ธ‰์„ ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค."); + } +} diff --git a/src/main/java/ceos/vote/domain/member/controller/MemberController.java b/src/main/java/ceos/vote/domain/member/controller/MemberController.java new file mode 100644 index 0000000..71e7da9 --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/controller/MemberController.java @@ -0,0 +1,33 @@ +package ceos.vote.domain.member.controller; + +import ceos.vote.domain.member.dto.CustomUserDetails; +import ceos.vote.domain.member.dto.response.MemberResponseDto; +import ceos.vote.domain.member.entity.Member; +import ceos.vote.domain.member.service.MemberService; +import ceos.vote.global.annotation.Login; +import ceos.vote.global.common.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/member") +@RequiredArgsConstructor +@Tag(name = "Member", description = "ํšŒ์› ๊ด€๋ จ API") +public class MemberController { + + private final MemberService memberService; + + @Operation(summary = "ํšŒ์› ๊ธฐ๋ณธ ์ •๋ณด ์กฐํšŒ", description = "ํšŒ์›์˜ ๊ธฐ๋ณธ ์ •๋ณด๋ฅผ ์กฐํšŒํ•˜๋Š” API") + @GetMapping + public CommonResponse getMemberInfo(@AuthenticationPrincipal CustomUserDetails userDetails) { + + Long memberId = userDetails.getMemberId(); + + return new CommonResponse<>(memberService.getMemberInfo(memberId), "ํšŒ์› ๊ธฐ๋ณธ ์ •๋ณด ์กฐํšŒ๋ฅผ ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค."); + } +} diff --git a/src/main/java/ceos/vote/domain/member/dto/CustomUserDetails.java b/src/main/java/ceos/vote/domain/member/dto/CustomUserDetails.java new file mode 100644 index 0000000..b3244cc --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/dto/CustomUserDetails.java @@ -0,0 +1,72 @@ +package ceos.vote.domain.member.dto; + +import ceos.vote.domain.member.entity.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + + @Override + public String getAuthority() { + return member.getRole(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + + return member.getPassword(); + } + + @Override + public String getUsername() { + + return member.getUserId(); + } + + public Long getMemberId() { + + return member.getId(); + } + + @Override + public boolean isAccountNonExpired() { + + return true; + } + + @Override + public boolean isAccountNonLocked() { + + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + + return true; + } + + @Override + public boolean isEnabled() { + + return true; + } +} diff --git a/src/main/java/ceos/vote/domain/member/dto/request/LoginRequestDto.java b/src/main/java/ceos/vote/domain/member/dto/request/LoginRequestDto.java new file mode 100644 index 0000000..16c7f1a --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/dto/request/LoginRequestDto.java @@ -0,0 +1,16 @@ +package ceos.vote.domain.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record LoginRequestDto( + + @Schema(description = "์•„์ด๋””", example = "ceos2024") + @NotNull + String userId, + + @Schema(description = "๋น„๋ฐ€๋ฒˆํ˜ธ", example = "12345678") + @NotNull + String password +) { +} diff --git a/src/main/java/ceos/vote/domain/member/dto/request/SignupRequestDto.java b/src/main/java/ceos/vote/domain/member/dto/request/SignupRequestDto.java new file mode 100644 index 0000000..10bdd41 --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/dto/request/SignupRequestDto.java @@ -0,0 +1,43 @@ +package ceos.vote.domain.member.dto.request; + +import ceos.vote.domain.member.entity.Member; +import ceos.vote.domain.member.entity.PartType; +import ceos.vote.domain.member.entity.TeamType; +import ceos.vote.domain.team.entity.Team; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record SignupRequestDto( + + @Schema(description = "์ด๋ฆ„", example = "ํ™๊ธธ๋™") + @NotNull(message = "์ด๋ฆ„์€ ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String name, + + @Schema(description = "์•„์ด๋””", example = "ceos2024") + @NotNull(message = "์•„์ด๋””๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String userId, + + @Schema(description = "๋น„๋ฐ€๋ฒˆํ˜ธ", example = "12345678") + @NotNull(message = "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String password, + + @Schema(description = "์†Œ์† ํŒ€๋ช…", example = "ํฌํ† ๊ทธ๋ผ์šด๋“œ | ์—”์ ค๋ธŒ๋ฆฟ์ง€ | ํŽ˜๋‹ฌ์ง€๋‹ˆ | ์ผ€์ดํฌ์›จ์ด | ์ปคํ”ผ๋”œ") + @NotNull(message = "์†Œ์† ํŒ€๋ช…์€ ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String team, + + @Schema(description = "์†Œ์† ํŒŒํŠธ", example = "ํ”„๋ก ํŠธ์—”๋“œ | ๋ฐฑ์—”๋“œ") + @NotNull(message = "์†Œ์† ํŒŒํŠธ๋Š” ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String part +) { + public Member toEntity(String encodedPassword, PartType part, Team team) { + return Member.builder() + .userId(userId) + .password(encodedPassword) + .role("ROLE_USER") + .part(part) + .name(name) + .team(team) + .build(); + } +} + diff --git a/src/main/java/ceos/vote/domain/member/dto/response/MemberResponseDto.java b/src/main/java/ceos/vote/domain/member/dto/response/MemberResponseDto.java new file mode 100644 index 0000000..8f0c19e --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/dto/response/MemberResponseDto.java @@ -0,0 +1,28 @@ +package ceos.vote.domain.member.dto.response; + +import ceos.vote.domain.member.entity.Member; +import lombok.Builder; + +@Builder +public record MemberResponseDto ( + + String userId, + String name, + String team, + String part, + Boolean voteBack, + Boolean voteFront, + Boolean voteTeam +) { + public static MemberResponseDto from(Member member) { + return MemberResponseDto.builder() + .userId(member.getUserId()) + .name(member.getName()) + .team(member.getTeam().getDescription()) + .part(member.getPart().getDescription()) + .voteBack(member.isVoteBack()) + .voteFront(member.isVoteFront()) + .voteTeam(member.isVoteTeam()) + .build(); + } +} diff --git a/src/main/java/ceos/vote/domain/member/entity/Member.java b/src/main/java/ceos/vote/domain/member/entity/Member.java new file mode 100644 index 0000000..a394b1b --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/entity/Member.java @@ -0,0 +1,72 @@ +package ceos.vote.domain.member.entity; + +import ceos.vote.domain.team.entity.Team; +import ceos.vote.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id", nullable = false) + private Long id; + + // ๋กœ๊ทธ์ธ ์•„์ด๋”” + @Column(name = "user_id", length = 50, nullable = false) + private String userId; + + @Column(nullable = false) + private String password; + + private String role; + + @Enumerated(EnumType.STRING) + @Column(length = 50, nullable = false) + private PartType part; + + @Column(length = 20, nullable = false) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "team_id") + private Team team; + + @Column(name = "vote_back", nullable = false) + private boolean voteBack = false; + + @Column(name = "vote_front", nullable = false) + private boolean voteFront = false; + + @Column(name = "vote_team", nullable = false) + private boolean voteTeam = false; + + public void voteToBack() { + this.voteBack = true; + } + + public void voteToFront() { + this.voteFront = true; + } + + public void voteToTeam() { + this.voteTeam = true; + } + + @Builder + public Member(String userId, String password, String role, PartType part, String name, Team team) { + + this.userId = userId; + this.password = password; + this.role = role; + this.part = part; + this.name = name; + this.team = team; + } +} diff --git a/src/main/java/ceos/vote/domain/member/entity/PartType.java b/src/main/java/ceos/vote/domain/member/entity/PartType.java new file mode 100644 index 0000000..a5db4e6 --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/entity/PartType.java @@ -0,0 +1,18 @@ +package ceos.vote.domain.member.entity; + +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public enum PartType { + + BACKEND("๋ฐฑ์—”๋“œ"), + FRONTEND("ํ”„๋ก ํŠธ์—”๋“œ"); + + private final String description; + + PartType(String description) { + this.description = description; + } +} diff --git a/src/main/java/ceos/vote/domain/member/entity/Refresh.java b/src/main/java/ceos/vote/domain/member/entity/Refresh.java new file mode 100644 index 0000000..70546cf --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/entity/Refresh.java @@ -0,0 +1,34 @@ +package ceos.vote.domain.member.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Refresh { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "refresh_id", nullable = false) + private Long id; + + @Column(length = 50, nullable = false) + private String userId; + + @Column(nullable = false) + private String refresh; + + @Column(nullable = false) + private String expiration; + + @Builder + public Refresh(String userId, String refresh, String expiration) { + this.userId = userId; + this.refresh = refresh; + this.expiration = expiration; + } +} diff --git a/src/main/java/ceos/vote/domain/member/entity/TeamType.java b/src/main/java/ceos/vote/domain/member/entity/TeamType.java new file mode 100644 index 0000000..e035a78 --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/entity/TeamType.java @@ -0,0 +1,21 @@ +package ceos.vote.domain.member.entity; + +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public enum TeamType { + + PHOTOGROUND("ํฌํ† ๊ทธ๋ผ์šด๋“œ"), + ANGELBRIDGE("์—”์ ค๋ธŒ๋ฆฟ์ง€"), + PEDALGENIE("ํŽ˜๋‹ฌ์ง€๋‹ˆ"), + CAKEWAY("์ผ€์ดํฌ์›จ์ด"), + COFFEEDEAL("์ปคํ”ผ๋”œ"); + + private final String description; + + TeamType(String description) { + this.description = description; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/vote/domain/member/service/AuthService.java b/src/main/java/ceos/vote/domain/member/service/AuthService.java new file mode 100644 index 0000000..7f2f2af --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/service/AuthService.java @@ -0,0 +1,197 @@ +package ceos.vote.domain.member.service; + +import ceos.vote.domain.member.dto.request.SignupRequestDto; +import ceos.vote.domain.member.dto.response.MemberResponseDto; +import ceos.vote.domain.member.entity.Member; +import ceos.vote.domain.member.entity.PartType; +import ceos.vote.domain.member.entity.Refresh; +import ceos.vote.domain.member.entity.TeamType; +import ceos.vote.domain.team.entity.Team; +import ceos.vote.global.exception.ApplicationException; +import ceos.vote.global.jwt.JWTUtil; +import ceos.vote.global.repository.MemberRepository; +import ceos.vote.global.repository.RefreshRepository; +import ceos.vote.global.repository.TeamRepository; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; + +import static ceos.vote.global.exception.ExceptionCode.*; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + private final TeamRepository teamRepository; + + // [POST] ํšŒ์›๊ฐ€์ž… + @Transactional + public MemberResponseDto signup(SignupRequestDto request) { + + String userId = request.userId(); + String password = request.password(); + + if(memberRepository.existsMemberByUserId(userId)){ + throw new ApplicationException(DUPLICATED_USER_ID); + } + + PartType part; + TeamType team; + + if (request.part().equals("ํ”„๋ก ํŠธ์—”๋“œ")) { + part = PartType.FRONTEND; + } else if (request.part().equals("๋ฐฑ์—”๋“œ")) { + part = PartType.BACKEND; + } else { + throw new ApplicationException(INVALID_PART_TYPE); + } + + if (request.team().equals("ํฌํ† ๊ทธ๋ผ์šด๋“œ")) { + team = TeamType.PHOTOGROUND; + } else if (request.team().equals("์—”์ ค๋ธŒ๋ฆฟ์ง€")) { + team = TeamType.ANGELBRIDGE; + } else if (request.team().equals("ํŽ˜๋‹ฌ์ง€๋‹ˆ")) { + team = TeamType.PEDALGENIE; + } else if (request.team().equals("์ผ€์ดํฌ์›จ์ด")) { + team = TeamType.CAKEWAY; + } else if (request.team().equals("์ปคํ”ผ๋”œ")) { + team = TeamType.COFFEEDEAL; + } else { + throw new ApplicationException(INVALID_TEAM_TYPE); + } + + Team myTeam = teamRepository.findByType(team); + Member newMember = request.toEntity(bCryptPasswordEncoder.encode(password), part, myTeam); + + memberRepository.save(newMember); + + return MemberResponseDto.from(newMember); + } + + /** + * Refresh Token ์ถ”์ถœ + * **/ + public String extractRefreshToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals("refreshToken")) { + return cookie.getValue(); + } + } + } + throw new ApplicationException(NOT_FOUND_REFRESH_TOKEN); + } + + /** + * Refresh Token ๊ฒ€์ฆ + * **/ + public void validateRefreshToken(String refreshToken) { + + try { + jwtUtil.isExpired(refreshToken); + } catch (ExpiredJwtException e) { + throw new ApplicationException(EXPIRED_PERIOD_REFRESH_TOKEN); + } + + String category = jwtUtil.getCategory(refreshToken); + if (!category.equals("refresh")) { + throw new ApplicationException(INVALID_REFRESH_TOKEN); + } + + Boolean isExist = refreshRepository.existsByRefresh(refreshToken); + if (!isExist) { + throw new ApplicationException(INVALID_REFRESH_TOKEN); + } + } + + /** + * Access Token ์žฌ๋ฐœ๊ธ‰ + * **/ + public String reissueAccessToken(String refreshToken) { + + String userId = jwtUtil.getUsername(refreshToken); + String role = jwtUtil.getRole(refreshToken); + return jwtUtil.createJwt("access", userId, role, 1000L * 60 * 60 * 24 * 14); // 2์ฃผ (์ž„์‹œ) + } + + /** + * ์ƒˆ๋กœ์šด Refresh Token ์ƒ์„ฑ + * **/ + @Transactional + public Cookie createRefreshTokenCookie(String refreshToken) { + + String userId = jwtUtil.getUsername(refreshToken); + String role = jwtUtil.getRole(refreshToken); + String newRefresh = jwtUtil.createJwt("refresh", userId, role, 1000L * 60 * 60 * 24 * 14); + + if (userId == null) { + throw new ApplicationException(FAIL_TO_VALIDATE_TOKEN); + } + + deleteAndSaveNewRefreshToken(userId, newRefresh, 1000L * 60 * 60 * 24 * 14); + + return createCookie("refreshToken", newRefresh); + } + + private Cookie createCookie(String key, String value) { + + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(60 * 60 * 24 * 14); + cookie.setHttpOnly(true); + cookie.setPath("/"); + // cookie.setSecure(true); + + return cookie; + } + + /** + * ๊ธฐ์กด์˜ Refresh Token ์‚ญ์ œ ํ›„ ์ƒˆ Refresh Token ์ €์žฅ + **/ + @Transactional + public void deleteAndSaveNewRefreshToken(String userId, String newRefresh, Long expiredMs) { + + refreshRepository.deleteByUserId(userId); + + addRefreshEntity(userId, newRefresh, expiredMs); + } + + /** + * ์ƒˆ๋กœ์šด Refresh Token ์ €์žฅํ•˜๋Š” ๋ฉ”์„œ๋“œ + **/ + @Transactional + public void addRefreshEntity(String userId, String refresh, Long expiredMs) { + + Date expirationDate = new Date(System.currentTimeMillis() + expiredMs); + + Refresh refreshEntity = Refresh.builder() + .userId(userId) + .refresh(refresh) + .expiration(expirationDate.toString()) + .build(); + + refreshRepository.save(refreshEntity); + } + + /** + * ์‘๋‹ต ํ—ค๋” ๋ฐ ์ฟ ํ‚ค ์„ค์ • + * **/ + public void setNewTokens(HttpServletResponse response, String newAccessToken, Cookie refreshCookie) { + + response.setHeader("Authorization", "Bearer " + newAccessToken); + + response.addCookie(refreshCookie); + } +} diff --git a/src/main/java/ceos/vote/domain/member/service/CustomUserDetailsService.java b/src/main/java/ceos/vote/domain/member/service/CustomUserDetailsService.java new file mode 100644 index 0000000..0862e56 --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/service/CustomUserDetailsService.java @@ -0,0 +1,32 @@ +package ceos.vote.domain.member.service; + +import ceos.vote.domain.member.dto.CustomUserDetails; +import ceos.vote.domain.member.entity.Member; +import ceos.vote.global.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { + + // DB์—์„œ ์กฐํšŒ + Optional userDataOptional = memberRepository.findByUserId(userId); + + Member userData = userDataOptional.orElseThrow(() -> + new UsernameNotFoundException("ํ•ด๋‹น ์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค : " + userId)); + + // UserDetails์— ๋‹ด์•„์„œ returnํ•˜๋ฉด AuthenticationManager๊ฐ€ ๊ฒ€์ฆํ•จ + return new CustomUserDetails(userData); + } +} diff --git a/src/main/java/ceos/vote/domain/member/service/MemberService.java b/src/main/java/ceos/vote/domain/member/service/MemberService.java new file mode 100644 index 0000000..45c983c --- /dev/null +++ b/src/main/java/ceos/vote/domain/member/service/MemberService.java @@ -0,0 +1,31 @@ +package ceos.vote.domain.member.service; + +import ceos.vote.domain.member.dto.response.MemberResponseDto; +import ceos.vote.domain.member.entity.Member; +import ceos.vote.global.exception.ApplicationException; +import ceos.vote.global.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static ceos.vote.global.exception.ExceptionCode.NOT_FOUND_USER; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + public Member findMemberById(Long memberId) { + return memberRepository.findById(memberId).orElseThrow(() -> new ApplicationException(NOT_FOUND_USER)); + } + + // [GET] ํšŒ์› ๊ธฐ๋ณธ ์ •๋ณด ์กฐํšŒ + public MemberResponseDto getMemberInfo(Long memberId) { + + Member member = findMemberById(memberId); + + return MemberResponseDto.from(member); + } +} diff --git a/src/main/java/ceos/vote/domain/team/controller/TeamController.java b/src/main/java/ceos/vote/domain/team/controller/TeamController.java new file mode 100644 index 0000000..08f3f5f --- /dev/null +++ b/src/main/java/ceos/vote/domain/team/controller/TeamController.java @@ -0,0 +1,25 @@ +package ceos.vote.domain.team.controller; + +import ceos.vote.domain.team.dto.TeamRequestDto; +import ceos.vote.domain.team.service.TeamService; +import ceos.vote.global.common.response.CommonResponse; +import lombok.RequiredArgsConstructor; +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 +@RequestMapping("/api/team") +@RequiredArgsConstructor +public class TeamController { + + private final TeamService teamService; + + @PostMapping + public CommonResponse createTeam(@RequestBody TeamRequestDto teamRequestDto) { + + teamService.createTeam(teamRequestDto); + return new CommonResponse<>("ํŒ€ ์ƒ์„ฑ ์„ฑ๊ณต"); + } +} diff --git a/src/main/java/ceos/vote/domain/team/dto/TeamRequestDto.java b/src/main/java/ceos/vote/domain/team/dto/TeamRequestDto.java new file mode 100644 index 0000000..158aac8 --- /dev/null +++ b/src/main/java/ceos/vote/domain/team/dto/TeamRequestDto.java @@ -0,0 +1,23 @@ +package ceos.vote.domain.team.dto; + +import ceos.vote.domain.team.entity.Team; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record TeamRequestDto( + String name, + @Schema(description = "์†Œ์† ํŒ€๋ช…", example = "ํฌํ† ๊ทธ๋ผ์šด๋“œ | ์—”์ ค๋ธŒ๋ฆฟ์ง€ | ํŽ˜๋‹ฌ์ง€๋‹ˆ | ์ผ€์ดํฌ์›จ์ด | ์ปคํ”ผ๋”œ") + @NotNull(message = "์†Œ์† ํŒ€๋ช…์€ ํ•„์ˆ˜ ์ž…๋ ฅ ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.") + String team, + String description +) { + + public Team toEntity() { + + return Team.builder() + .name(name) + .type(team) + .description(description) + .build(); + } +} diff --git a/src/main/java/ceos/vote/domain/team/entity/Team.java b/src/main/java/ceos/vote/domain/team/entity/Team.java new file mode 100644 index 0000000..088f602 --- /dev/null +++ b/src/main/java/ceos/vote/domain/team/entity/Team.java @@ -0,0 +1,55 @@ +package ceos.vote.domain.team.entity; + +import ceos.vote.domain.member.entity.TeamType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Team { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "team_id", nullable = false) + private Long id; + + @Column(name = "team_name", nullable = false) + private String teamName; + + @Enumerated(EnumType.STRING) + @Column(length = 50, nullable = false) + private TeamType type; + + private String description; + + private int count = 0; + + public void voteToMe() { + this.count++; + } + + @Builder + public Team(String name, String type, String description) { + + TeamType team = null; + + if (type.equals("ํฌํ† ๊ทธ๋ผ์šด๋“œ")) + team = TeamType.PHOTOGROUND; + else if (type.equals("์—”์ ค๋ธŒ๋ฆฟ์ง€")) + team = TeamType.ANGELBRIDGE; + else if (type.equals("ํŽ˜๋‹ฌ์ง€๋‹ˆ")) + team = TeamType.PEDALGENIE; + else if (type.equals("์ผ€์ดํฌ์›จ์ด")) + team = TeamType.CAKEWAY; + else if (type.equals("์ปคํ”ผ๋”œ")) + team = TeamType.COFFEEDEAL; + + this.teamName = name; + this.type = team; + this.description = description; + } +} diff --git a/src/main/java/ceos/vote/domain/team/service/TeamService.java b/src/main/java/ceos/vote/domain/team/service/TeamService.java new file mode 100644 index 0000000..5b4713d --- /dev/null +++ b/src/main/java/ceos/vote/domain/team/service/TeamService.java @@ -0,0 +1,22 @@ +package ceos.vote.domain.team.service; + +import ceos.vote.domain.team.dto.TeamRequestDto; +import ceos.vote.domain.team.entity.Team; +import ceos.vote.global.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TeamService { + + private final TeamRepository teamRepository; + + @Transactional + public void createTeam(TeamRequestDto teamRequestDto) { + + Team team = teamRequestDto.toEntity(); + teamRepository.save(team); + } +} diff --git a/src/main/java/ceos/vote/domain/vote/controller/VoteController.java b/src/main/java/ceos/vote/domain/vote/controller/VoteController.java new file mode 100644 index 0000000..6a51d02 --- /dev/null +++ b/src/main/java/ceos/vote/domain/vote/controller/VoteController.java @@ -0,0 +1,73 @@ +package ceos.vote.domain.vote.controller; + +import ceos.vote.domain.member.entity.Member; +import ceos.vote.domain.vote.dto.response.*; +import ceos.vote.domain.vote.service.VoteService; +import ceos.vote.global.annotation.Login; +import ceos.vote.global.common.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/vote") +@RequiredArgsConstructor +@Tag(name = "Vote", description = "ํˆฌํ‘œ ๊ด€๋ จ API") +public class VoteController { + + private final VoteService voteService; + + @GetMapping("/developer/detail/{developerId}") + @Operation(summary = "๊ฐœ๋ฐœ์ž ์ž๊ธฐ์†Œ๊ฐœ ์กฐํšŒํ•˜๋Š” API", description = "-") + public CommonResponse getIntroduction(@PathVariable Long developerId) { + + return new CommonResponse<>(voteService.getIntroduce(developerId), "๊ฐœ๋ฐœ์ž ์ž๊ธฐ์†Œ๊ฐœ ์กฐํšŒ ์™„๋ฃŒ"); + } + + @GetMapping("/developer") + @Operation(summary = "๊ฐœ๋ฐœ์ž ๋ฆฌ์ŠคํŠธ ์กฐํšŒํ•˜๋Š” API", description = "request param์œผ๋กœ type=backend ๋˜๋Š” type=frontend ๋ฅผ ๋„˜๊ฒจ์ฃผ์„ธ์š”") + public CommonResponse> getDeveloperList(@RequestParam String type) { + + return new CommonResponse<>(voteService.getDeveloperList(type), "๊ฐœ๋ฐœ์ž ๋ฆฌ์ŠคํŠธ ์กฐํšŒ ์™„๋ฃŒ"); + } + + @GetMapping("/team") + @Operation(summary = "ํŒ€ ๋ฆฌ์ŠคํŠธ ์กฐํšŒํ•˜๋Š” API", description = "-") + public CommonResponse> getDeveloperList() { + + return new CommonResponse<>(voteService.getTeamList(), "ํŒ€ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ ์™„๋ฃŒ"); + } + + @PostMapping("/developer/{memberId}") + @Operation(summary = "๊ฐœ๋ฐœ์ž ํŒŒํŠธ์žฅ ํˆฌํ‘œํ•˜๋Š” API", description = "pathvariable๋กœ ํˆฌํ‘œํ•˜๊ณ ์ž ํ•˜๋Š” ๋ฉค๋ฒ„์˜ id๋ฅผ ๋„˜๊ฒจ์ฃผ์„ธ์š”") + public CommonResponse voteDeveloper(@PathVariable Long memberId, @Login Member loginMember) { + + voteService.voteDeveloper(memberId, loginMember); + return new CommonResponse<>("ํŒŒํŠธ์žฅ ํˆฌํ‘œ ์™„๋ฃŒ"); + } + + @PostMapping("/team/{teamId}") + @Operation(summary = "ํŒ€ ํˆฌํ‘œํ•˜๋Š” API", description = "pathvariable๋กœ ํˆฌํ‘œํ•˜๊ณ ์ž ํ•˜๋Š” ํŒ€์˜ id๋ฅผ ๋„˜๊ฒจ์ฃผ์„ธ์š”") + public CommonResponse voteTeam(@PathVariable Long teamId, @Login Member loginMember) { + + voteService.voteTeam(teamId, loginMember); + return new CommonResponse<>("ํŒ€ ํˆฌํ‘œ ์™„๋ฃŒ"); + } + + @GetMapping("/developer/result") + @Operation(summary = "ํŒŒํŠธ์žฅ ํˆฌํ‘œ ๊ฒฐ๊ณผ ์กฐํšŒํ•˜๋Š” API", description = "request param์œผ๋กœ type=backend ๋˜๋Š” type=frontend ๋ฅผ ๋„˜๊ฒจ์ฃผ์„ธ์š”") + public CommonResponse> getDeveloperVoteResult(@RequestParam String type) { + + return new CommonResponse<>(voteService.getDeveloperVoteResult(type), "ํŒŒํŠธ์žฅ ํˆฌํ‘œ ๊ฒฐ๊ณผ ์กฐํšŒ ์™„๋ฃŒ"); + } + + @GetMapping("/team/result") + @Operation(summary = "ํŒ€ ํˆฌํ‘œ ๊ฒฐ๊ณผ ์กฐํšŒํ•˜๋Š” API", description = "-") + public CommonResponse> getTeamVoteResult() { + + return new CommonResponse<>(voteService.getTeamVoteResult(), "ํŒ€ ํˆฌํ‘œ ๊ฒฐ๊ณผ ์กฐํšŒ ์™„๋ฃŒ"); + } +} diff --git a/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperIntroductionResponseDto.java b/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperIntroductionResponseDto.java new file mode 100644 index 0000000..39637c8 --- /dev/null +++ b/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperIntroductionResponseDto.java @@ -0,0 +1,10 @@ +package ceos.vote.domain.vote.dto.response; + +import lombok.Builder; + +@Builder +public record DeveloperIntroductionResponseDto( + String name, + String introduction +) { +} diff --git a/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperListResponseDto.java b/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperListResponseDto.java new file mode 100644 index 0000000..9d91117 --- /dev/null +++ b/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperListResponseDto.java @@ -0,0 +1,20 @@ +package ceos.vote.domain.vote.dto.response; + +import ceos.vote.domain.developer.entity.Developer; +import lombok.Builder; + +@Builder +public record DeveloperListResponseDto( + Long developerId, + String developerName, + String teamName +) { + + public static DeveloperListResponseDto from (Developer developer) { + return DeveloperListResponseDto.builder() + .developerId(developer.getId()) + .developerName(developer.getDeveloperName()) + .teamName(developer.getTeam().getTeamName()) + .build(); + } +} diff --git a/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperVoteResultResponseDto.java b/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperVoteResultResponseDto.java new file mode 100644 index 0000000..f96462a --- /dev/null +++ b/src/main/java/ceos/vote/domain/vote/dto/response/DeveloperVoteResultResponseDto.java @@ -0,0 +1,19 @@ +package ceos.vote.domain.vote.dto.response; + +import ceos.vote.domain.developer.entity.Developer; +import lombok.Builder; + +@Builder +public record DeveloperVoteResultResponseDto( + String developerName, + String teamName, + int count +) { + public static DeveloperVoteResultResponseDto from(Developer developer) { + return DeveloperVoteResultResponseDto.builder() + .developerName(developer.getDeveloperName()) + .teamName(developer.getTeam().getTeamName()) + .count(developer.getCount()) + .build(); + } +} diff --git a/src/main/java/ceos/vote/domain/vote/dto/response/TeamListResponseDto.java b/src/main/java/ceos/vote/domain/vote/dto/response/TeamListResponseDto.java new file mode 100644 index 0000000..e79fbf6 --- /dev/null +++ b/src/main/java/ceos/vote/domain/vote/dto/response/TeamListResponseDto.java @@ -0,0 +1,21 @@ +package ceos.vote.domain.vote.dto.response; + +import ceos.vote.domain.member.entity.Member; +import ceos.vote.domain.team.entity.Team; +import lombok.Builder; + +@Builder +public record TeamListResponseDto( + Long teamId, + String teamName, + String description +) { + + public static TeamListResponseDto from (Team team) { + return TeamListResponseDto.builder() + .teamId(team.getId()) + .teamName(team.getTeamName()) + .description(team.getDescription()) + .build(); + } +} diff --git a/src/main/java/ceos/vote/domain/vote/dto/response/TeamVoteResultResponseDto.java b/src/main/java/ceos/vote/domain/vote/dto/response/TeamVoteResultResponseDto.java new file mode 100644 index 0000000..0b2d719 --- /dev/null +++ b/src/main/java/ceos/vote/domain/vote/dto/response/TeamVoteResultResponseDto.java @@ -0,0 +1,19 @@ +package ceos.vote.domain.vote.dto.response; + +import ceos.vote.domain.team.entity.Team; +import lombok.Builder; + +@Builder +public record TeamVoteResultResponseDto( + String teamName, + String description, + int count +) { + public static TeamVoteResultResponseDto from (Team team) { + return TeamVoteResultResponseDto.builder() + .teamName(team.getTeamName()) + .description(team.getDescription()) + .count(team.getCount()) + .build(); + } +} diff --git a/src/main/java/ceos/vote/domain/vote/service/VoteService.java b/src/main/java/ceos/vote/domain/vote/service/VoteService.java new file mode 100644 index 0000000..af0da8a --- /dev/null +++ b/src/main/java/ceos/vote/domain/vote/service/VoteService.java @@ -0,0 +1,130 @@ +package ceos.vote.domain.vote.service; + +import ceos.vote.domain.developer.entity.Developer; +import ceos.vote.domain.member.entity.Member; +import ceos.vote.domain.member.entity.PartType; +import ceos.vote.domain.team.entity.Team; +import ceos.vote.domain.vote.dto.response.*; +import ceos.vote.global.exception.ApplicationException; +import ceos.vote.global.repository.DeveloperRepository; +import ceos.vote.global.repository.MemberRepository; +import ceos.vote.global.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import static ceos.vote.global.exception.ExceptionCode.*; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class VoteService { + + private final TeamRepository teamRepository; + private final MemberRepository memberRepository; + private final DeveloperRepository developerRepository; + + // [GET] ๊ฐœ๋ฐœ์ž ์ž๊ธฐ์†Œ๊ฐœ ์กฐํšŒ + public DeveloperIntroductionResponseDto getIntroduce(Long developerId) { + + Developer developer = developerRepository.findById(developerId) + .orElseThrow(() -> new ApplicationException(NOT_FOUND_USER)); + + return DeveloperIntroductionResponseDto.builder() + .name(developer.getDeveloperName()) + .introduction(developer.getIntroduction()) + .build(); + } + + // [GET] ๊ฐœ๋ฐœ์ž ๋ฆฌ์ŠคํŠธ ์กฐํšŒ + public List getDeveloperList(String type) { + + PartType partType = PartType.valueOf(type.toUpperCase()); + List developers = developerRepository.findByPartOrderByCountDesc(partType); + + return developers.stream() + .map(DeveloperListResponseDto::from) + .collect(Collectors.toList()); + } + + // [GET] ํŒ€ ๋ฆฌ์ŠคํŠธ ์กฐํšŒ + public List getTeamList() { + + List teams = teamRepository.findAll(); + return teams.stream() + .map(TeamListResponseDto::from) + .collect(Collectors.toList()); + + } + + // [POST] ๊ฐœ๋ฐœ์ž ํŒŒํŠธ์žฅ ํˆฌํ‘œ + @Transactional + public void voteDeveloper(Long developerId, Member loginMember) { + + Developer developer = developerRepository.findById(developerId) + .orElseThrow(() -> new ApplicationException(NOT_FOUND_USER)); + // ๋‚˜ ์ž์‹ ์—๊ฒŒ ํˆฌํ‘œํ•  ์ˆ˜ ์—†์Œ + if (Objects.equals(developer.getDeveloperName(), loginMember.getName())) + throw new ApplicationException(BAD_REQUEST_SELF); + // ๊ฐ™์€ ํŒŒํŠธ์—๊ฒŒ๋งŒ ํˆฌํ‘œํ•  ์ˆ˜ ์žˆ์Œ + if (developer.getPart() != loginMember.getPart()) + throw new ApplicationException(BAD_REQUEST_DEVELOPER); + // ์ด๋ฏธ ํˆฌํ‘œํ–ˆ๋‹ค๋ฉด ๋‹ค์‹œ ํˆฌํ‘œํ•  ์ˆ˜ ์—†์Œ + PartType type = developer.getPart(); + if (type.equals(PartType.BACKEND)) { + if (loginMember.isVoteBack()) + throw new ApplicationException(ALREADY_VOTE_DEVELOPER); + loginMember.voteToBack(); + } + else if (type.equals(PartType.FRONTEND)) { + if (loginMember.isVoteFront()) + throw new ApplicationException(ALREADY_VOTE_DEVELOPER); + loginMember.voteToFront(); + } + developer.voteToMe(); + developerRepository.save(developer); + memberRepository.save(loginMember); + } + + // [POST] ํŒ€ ํˆฌํ‘œ + @Transactional + public void voteTeam(Long teamId, Member loginMember) { + + Team team = teamRepository.findById(teamId) + .orElseThrow(() -> new ApplicationException(INVALID_TEAM_TYPE)); + // ๊ฐ™์€ ํŒ€์—๊ฒŒ ํˆฌํ‘œํ•  ์ˆ˜ ์—†์Œ + if (loginMember.getTeam() == team) + throw new ApplicationException(BAD_REQUEST_TEAM); + // ์ด๋ฏธ ํˆฌํ‘œํ–ˆ์œผ๋ฉด ๋‹ค์‹œ ํˆฌํ‘œํ•  ์ˆ˜ ์—†์Œ + if (loginMember.isVoteTeam()) + throw new ApplicationException(ALREADY_VOTE_TEAM); + + team.voteToMe(); + teamRepository.save(team); + loginMember.voteToTeam(); + memberRepository.save(loginMember); + } + + // [GET] ๊ฐœ๋ฐœ์ž ํŒŒํŠธ์žฅ ํˆฌํ‘œ ๊ฒฐ๊ณผ ์กฐํšŒ + public List getDeveloperVoteResult(String type) { + PartType partType = PartType.valueOf(type.toUpperCase()); + List developers = developerRepository.findByPartOrderByCountDesc(partType); + + return developers.stream() + .map(DeveloperVoteResultResponseDto::from) + .collect(Collectors.toList()); + } + + // [GET] ํŒ€ ํˆฌํ‘œ ๊ฒฐ๊ณผ ์กฐํšŒ + public List getTeamVoteResult() { + + List teams = teamRepository.findAllByOrderByCountDesc(); + return teams.stream() + .map(TeamVoteResultResponseDto::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/ceos/vote/global/annotation/Login.java b/src/main/java/ceos/vote/global/annotation/Login.java new file mode 100644 index 0000000..9c1a700 --- /dev/null +++ b/src/main/java/ceos/vote/global/annotation/Login.java @@ -0,0 +1,11 @@ +package ceos.vote.global.annotation; + +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/ceos/vote/global/annotation/argumentresolver/LoginArgumentResolver.java b/src/main/java/ceos/vote/global/annotation/argumentresolver/LoginArgumentResolver.java new file mode 100644 index 0000000..769c770 --- /dev/null +++ b/src/main/java/ceos/vote/global/annotation/argumentresolver/LoginArgumentResolver.java @@ -0,0 +1,45 @@ +package ceos.vote.global.annotation.argumentresolver; + +import ceos.vote.domain.member.dto.CustomUserDetails; +import ceos.vote.domain.member.entity.Member; +import ceos.vote.global.annotation.Login; +import ceos.vote.global.repository.MemberRepository; +import org.springframework.beans.factory.annotation.Autowired; +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; + +@Component +public class LoginArgumentResolver implements HandlerMethodArgumentResolver { + + private final MemberRepository memberRepository; + + @Autowired + public LoginArgumentResolver(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + + boolean hasLoginAnnotation = parameter.hasParameterAnnotation(Login.class); + boolean isMemberType = parameter.getParameterType().equals(Member.class); + return hasLoginAnnotation && isMemberType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + + Long memberId = userDetails.getMemberId(); + return memberRepository.findById(memberId).orElse(null); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/vote/global/common/BaseEntity.java b/src/main/java/ceos/vote/global/common/BaseEntity.java new file mode 100644 index 0000000..eff8df6 --- /dev/null +++ b/src/main/java/ceos/vote/global/common/BaseEntity.java @@ -0,0 +1,28 @@ +package ceos.vote.global.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(name = "created_at", columnDefinition = "TIMESTAMP") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", columnDefinition = "TIMESTAMP") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at", columnDefinition = "TIMESTAMP") + private LocalDateTime deletedAt; +} diff --git a/src/main/java/ceos/vote/global/common/response/CommonResponse.java b/src/main/java/ceos/vote/global/common/response/CommonResponse.java new file mode 100644 index 0000000..5db6775 --- /dev/null +++ b/src/main/java/ceos/vote/global/common/response/CommonResponse.java @@ -0,0 +1,34 @@ +package ceos.vote.global.common.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class CommonResponse { + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime timestamp; + private int code; + private String message; + private T result; + + // ๋ฐ˜ํ™˜๊ฐ’ ์žˆ๋Š” ๊ฒฝ์šฐ + public CommonResponse(T result, String message) { + this.timestamp = LocalDateTime.now(); + this.code = SuccessCode.SUCCESS.getCode(); + this.message = message; + this.result = result; + } + + // ๋ฐ˜ํ™˜๊ฐ’ ์—†๋Š” ๊ฒฝ์šฐ + public CommonResponse(String message) { + this.timestamp = LocalDateTime.now(); + this.code = SuccessCode.SUCCESS.getCode(); + this.message = message; + } +} diff --git a/src/main/java/ceos/vote/global/common/response/SuccessCode.java b/src/main/java/ceos/vote/global/common/response/SuccessCode.java new file mode 100644 index 0000000..b33482e --- /dev/null +++ b/src/main/java/ceos/vote/global/common/response/SuccessCode.java @@ -0,0 +1,20 @@ +package ceos.vote.global.common.response; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum SuccessCode { + + SUCCESS(HttpStatus.OK,1000, "์š”์ฒญ์— ์„ฑ๊ณตํ•˜์˜€์Šต๋‹ˆ๋‹ค."); + + private HttpStatus httpStatus; + private int code; + private String message; + + SuccessCode(HttpStatus httpStatus, int code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/ceos/vote/global/config/CorsMvcConfig.java b/src/main/java/ceos/vote/global/config/CorsMvcConfig.java new file mode 100644 index 0000000..9ce01d1 --- /dev/null +++ b/src/main/java/ceos/vote/global/config/CorsMvcConfig.java @@ -0,0 +1,19 @@ +package ceos.vote.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsMvcConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowCredentials(true) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*"); + } +} diff --git a/src/main/java/ceos/vote/global/config/SecurityConfig.java b/src/main/java/ceos/vote/global/config/SecurityConfig.java new file mode 100644 index 0000000..112f515 --- /dev/null +++ b/src/main/java/ceos/vote/global/config/SecurityConfig.java @@ -0,0 +1,112 @@ +package ceos.vote.global.config; + +import ceos.vote.global.jwt.CustomLogoutFilter; +import ceos.vote.global.jwt.JWTFilter; +import ceos.vote.global.jwt.JWTUtil; +import ceos.vote.global.jwt.LoginFilter; +import ceos.vote.global.repository.MemberRepository; +import ceos.vote.global.repository.RefreshRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +import java.util.Arrays; +import java.util.Collections; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + private final MemberRepository memberRepository; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + + return configuration.getAuthenticationManager(); + } + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .cors((corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() { + + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + + CorsConfiguration configuration = new CorsConfiguration(); + + configuration.setAllowedOrigins(Arrays.asList( + "http://localhost:3000", + "http://localhost:8080", + "http://3.35.91.98:8080", + "https://3.35.91.98", + "https://angelbridge-vote-rho.vercel.app" + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + + + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + configuration.setMaxAge(3600L); + + configuration.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization")); + + return configuration; + } + }))); + + http + .csrf((auth) -> auth.disable()); + + http + .formLogin((auth) -> auth.disable()); + + http + .httpBasic((auth) -> auth.disable()); + + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers("/login", "/", "/api/auth/**", "/swagger-ui.html", "/swagger-ui/**","/v3/api-docs/**").permitAll() + .requestMatchers("/api/vote/developer", "/api/vote/team", "/api/team", "/api/developer", "/api/vote/developer/detail/**").permitAll() // ๊ฐœ๋ฐœ์ž, ํŒ€ ์กฐํšŒํ•˜๋Š”๊ฑด ํ—ˆ์šฉ + .anyRequest().authenticated() + ); + + http + .addFilterBefore(new JWTFilter(jwtUtil, memberRepository), LoginFilter.class); + http + .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, refreshRepository), UsernamePasswordAuthenticationFilter.class); + + http + .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshRepository), LogoutFilter.class); + + http + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } +} diff --git a/src/main/java/ceos/vote/global/config/SwaggerConfig.java b/src/main/java/ceos/vote/global/config/SwaggerConfig.java new file mode 100644 index 0000000..9e6a56d --- /dev/null +++ b/src/main/java/ceos/vote/global/config/SwaggerConfig.java @@ -0,0 +1,38 @@ +package ceos.vote.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + String jwt = "JWT"; + + // Security Requirement์™€ Security Scheme ์„ค์ • + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme() + .name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ); + + // OpenAPI ๊ฐ์ฒด ๋ฐ˜ํ™˜ + return new OpenAPI() + .components(components) + .info(apiInfo()) + .addSecurityItem(securityRequirement); + } + + private Info apiInfo() { + return new Info() + .title("CEOS ํˆฌํ‘œ ์‚ฌ์ดํŠธ API") // Swagger ๋ฉ”์ธ ํƒ€์ดํ‹€ + .description("Ceos ํˆฌํ‘œ ์‚ฌ์ดํŠธ ํ”„-๋ฐฑ ๊ณผ์ œ API"); // Swagger ์„ค๋ช… + } +} \ No newline at end of file diff --git a/src/main/java/ceos/vote/global/config/WebConfig.java b/src/main/java/ceos/vote/global/config/WebConfig.java new file mode 100644 index 0000000..2f31ceb --- /dev/null +++ b/src/main/java/ceos/vote/global/config/WebConfig.java @@ -0,0 +1,25 @@ +package ceos.vote.global.config; + +import ceos.vote.global.annotation.argumentresolver.LoginArgumentResolver; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final LoginArgumentResolver loginArgumentResolver; + + @Autowired + public WebConfig(LoginArgumentResolver loginArgumentResolver) { + this.loginArgumentResolver = loginArgumentResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginArgumentResolver); + } +} diff --git a/src/main/java/ceos/vote/global/exception/ApplicationException.java b/src/main/java/ceos/vote/global/exception/ApplicationException.java new file mode 100644 index 0000000..4134e87 --- /dev/null +++ b/src/main/java/ceos/vote/global/exception/ApplicationException.java @@ -0,0 +1,11 @@ +package ceos.vote.global.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApplicationException extends RuntimeException { + + public ExceptionCode exceptionCode; +} diff --git a/src/main/java/ceos/vote/global/exception/ExceptionCode.java b/src/main/java/ceos/vote/global/exception/ExceptionCode.java new file mode 100644 index 0000000..bb98ed4 --- /dev/null +++ b/src/main/java/ceos/vote/global/exception/ExceptionCode.java @@ -0,0 +1,49 @@ +package ceos.vote.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ExceptionCode { + + // 1000: Success Case + + // 2000: Common Error + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 2000, "์„œ๋ฒ„ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜์˜€์Šต๋‹ˆ๋‹ค. ๊ด€๋ฆฌ์ž์—๊ฒŒ ๋ฌธ์˜ํ•ด ์ฃผ์„ธ์š”."), + NOT_FOUND_EXCEPTION(HttpStatus.NOT_FOUND, 2001, "์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + INVALID_VALUE_EXCEPTION(HttpStatus.BAD_REQUEST, 2002, "์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ์š”์ฒญ ๊ฐ’์ž…๋‹ˆ๋‹ค."), + UNAUTHORIZED_EXCEPTION(HttpStatus.UNAUTHORIZED, 2003, "๊ถŒํ•œ์ด ์—†๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + ALREADY_DELETE_EXCEPTION(HttpStatus.BAD_REQUEST, 2004, "์ด๋ฏธ ์‚ญ์ œ๋œ ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + FORBIDDEN_EXCEPTION(HttpStatus.FORBIDDEN, 2005, "์ธ๊ฐ€๋˜์ง€ ์•Š๋Š” ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + ALREADY_EXIST_EXCEPTION(HttpStatus.BAD_REQUEST, 2006, "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ฆฌ์†Œ์Šค์ž…๋‹ˆ๋‹ค."), + INVALID_SORT_EXCEPTION(HttpStatus.BAD_REQUEST, 2007, "์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ์ •๋ ฌ ๊ฐ’์ž…๋‹ˆ๋‹ค."), + BAD_REQUEST_ERROR(HttpStatus.BAD_REQUEST, 2008, "์ž˜๋ชป๋œ ์š”์ฒญ์ž…๋‹ˆ๋‹ค."), + BAD_REQUEST_TEAM(HttpStatus.BAD_REQUEST, 2009, "๊ฐ™์€ ํŒ€์—๊ฒŒ ํˆฌํ‘œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + ALREADY_VOTE_TEAM(HttpStatus.BAD_REQUEST, 2010, "์ด๋ฏธ ๋‹ค๋ฅธ ํŒ€์—๊ฒŒ ํˆฌํ‘œํ–ˆ์Šต๋‹ˆ๋‹ค."), + BAD_REQUEST_DEVELOPER(HttpStatus.BAD_REQUEST, 2011, "๊ฐ™์€ ํŒŒํŠธ์—๊ฒŒ๋งŒ ํˆฌํ‘œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."), + ALREADY_VOTE_DEVELOPER(HttpStatus.BAD_REQUEST, 2012, "์ด๋ฏธ ๋‹ค๋ฅธ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ํˆฌํ‘œํ–ˆ์Šต๋‹ˆ๋‹ค."), + BAD_REQUEST_SELF(HttpStatus.BAD_REQUEST, 2013, "๋‚˜ ์ž์‹ ์—๊ฒŒ ํˆฌํ‘œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), + + // 3000: Auth Error + KAKAO_TOKEN_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3000, "ํ† ํฐ ๋ฐœ๊ธ‰์—์„œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + KAKAO_USER_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, 3001, "Kakao ํ”„๋กœํ•„ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ๊ณผ์ •์—์„œ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."), + WRONG_TOKEN_EXCEPTION(HttpStatus.UNAUTHORIZED, 3002, "์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค."), + INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, 3003,"์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ํ˜•์‹์˜ RefreshToken ์ž…๋‹ˆ๋‹ค."), + INVALID_ACCESS_TOKEN(HttpStatus.BAD_REQUEST, 3004,"์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ํ˜•์‹์˜ AccessToken ์ž…๋‹ˆ๋‹ค."), + DUPLICATED_USER_ID(HttpStatus.BAD_REQUEST, 3005,"์ค‘๋ณต๋œ ์‚ฌ์šฉ์ž ์•„์ด๋””์ž…๋‹ˆ๋‹ค."), + DUPLICATED_ADMIN_EMAIL(HttpStatus.BAD_REQUEST, 3006,"์ค‘๋ณต๋œ ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค."), + NOT_FOUND_REFRESH_TOKEN(HttpStatus.NOT_FOUND, 3007,"์กด์žฌํ•˜์ง€ ์•Š๋Š” RefreshToken ์ž…๋‹ˆ๋‹ค."), + EXPIRED_PERIOD_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, 3008,"๊ธฐํ•œ์ด ๋งŒ๋ฃŒ๋œ RefreshToken ์ž…๋‹ˆ๋‹ค."), + EXPIRED_PERIOD_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, 3009,"๊ธฐํ•œ์ด ๋งŒ๋ฃŒ๋œ AccessToken ์ž…๋‹ˆ๋‹ค."), + NOT_FOUND_REFRESH_TOKEN_IN_DB(HttpStatus.NOT_FOUND, 3010,"ํ˜„์žฌ DB์— ์กด์žฌํ•˜์ง€ ์•Š๋Š” RefreshToken ์ž…๋‹ˆ๋‹ค."), + NOT_FOUND_USER(HttpStatus.NOT_FOUND, 3011,"์กด์žฌํ•˜์ง€ ์•Š๋Š” ์‚ฌ์šฉ์ž์ž…๋‹ˆ๋‹ค."), + INVALID_PART_TYPE(HttpStatus.BAD_REQUEST, 3012,"์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ํ˜•์‹์˜ ์†Œ์† ํŒŒํŠธ์ž…๋‹ˆ๋‹ค."), + INVALID_TEAM_TYPE(HttpStatus.BAD_REQUEST, 3013,"์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ํ˜•์‹์˜ ์†Œ์† ํŒ€๋ช…์ž…๋‹ˆ๋‹ค."), + FAIL_TO_VALIDATE_TOKEN(HttpStatus.INTERNAL_SERVER_ERROR, 3014, "ํ† ํฐ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + + private final HttpStatus httpStatus; + private final int code; + private final String message; +} diff --git a/src/main/java/ceos/vote/global/exception/ExceptionResponse.java b/src/main/java/ceos/vote/global/exception/ExceptionResponse.java new file mode 100644 index 0000000..e92f7e2 --- /dev/null +++ b/src/main/java/ceos/vote/global/exception/ExceptionResponse.java @@ -0,0 +1,28 @@ +package ceos.vote.global.exception; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import java.time.LocalDateTime; + +public record ExceptionResponse ( + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime timestamp, + int code, + String message +) { + // ๊ธฐ๋ณธ ์ƒ์„ฑ์ž: exceptionCode๋งŒ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ + public ExceptionResponse(ExceptionCode exceptionCode) { + this(LocalDateTime.now(), exceptionCode.getCode(), exceptionCode.getMessage()); + } + + // ๋ฉ”์‹œ์ง€๋ฅผ ์ง์ ‘ ์ง€์ •ํ•˜๋Š” ๊ฒฝ์šฐ + public ExceptionResponse(String message) { + this(LocalDateTime.now(), ExceptionCode.INTERNAL_SERVER_ERROR.getCode(), message); + } + + // exceptionCode์™€ ๋ฉ”์‹œ์ง€๋ฅผ ๋™์‹œ์— ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ + public ExceptionResponse(ExceptionCode exceptionCode, String message) { + this(LocalDateTime.now(), exceptionCode.getCode(), message); + } +} diff --git a/src/main/java/ceos/vote/global/exception/GlobalExceptionHandler.java b/src/main/java/ceos/vote/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..06ff1d0 --- /dev/null +++ b/src/main/java/ceos/vote/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,47 @@ +package ceos.vote.global.exception; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Objects; + +import static ceos.vote.global.exception.ExceptionCode.INTERNAL_SERVER_ERROR; + +@Slf4j +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + @ExceptionHandler(ApplicationException.class) + protected ResponseEntity handleBadRequestException(ApplicationException e){ + + log.error("BadRequestException ๋ฐœ์ƒ: {}", e.getMessage(), e); + + return ResponseEntity.status(e.getExceptionCode().getHttpStatus()) + .body(new ExceptionResponse(e.getExceptionCode())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity methodArgumentNotValidException(MethodArgumentNotValidException e) { + + log.error("MethodArgumentNotValidException ๋ฐœ์ƒ: {}", e.getMessage(), e); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(new ExceptionResponse(ExceptionCode.INVALID_VALUE_EXCEPTION, Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(final Exception e){ + + log.error("UnhandledException ๋ฐœ์ƒ: {}", e.getMessage(), e); + + return ResponseEntity.internalServerError() + .body(new ExceptionResponse(INTERNAL_SERVER_ERROR)); + } +} diff --git a/src/main/java/ceos/vote/global/jwt/CustomLogoutFilter.java b/src/main/java/ceos/vote/global/jwt/CustomLogoutFilter.java new file mode 100644 index 0000000..c34fb27 --- /dev/null +++ b/src/main/java/ceos/vote/global/jwt/CustomLogoutFilter.java @@ -0,0 +1,99 @@ +package ceos.vote.global.jwt; + +import ceos.vote.global.exception.ApplicationException; +import ceos.vote.global.repository.RefreshRepository; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +import static ceos.vote.global.exception.ExceptionCode.*; + +@RequiredArgsConstructor +public class CustomLogoutFilter extends GenericFilterBean { + + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + + doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain); + } + + private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { + + String requestUri = request.getRequestURI(); + if (!requestUri.matches("^\\/logout$")) { + + filterChain.doFilter(request, response); + return; + } + String requestMethod = request.getMethod(); + if (!requestMethod.equals("POST")) { + + filterChain.doFilter(request, response); + return; + } + + String refresh = null; + Cookie[] cookies = request.getCookies(); + for (Cookie cookie : cookies) { + + if (cookie.getName().equals("refreshToken")) { + + refresh = cookie.getValue(); + } + } + + if (refresh == null) { + + throw new ApplicationException(NOT_FOUND_REFRESH_TOKEN); + } + + try { + jwtUtil.isExpired(refresh); + } catch (ExpiredJwtException e) { + + throw new ApplicationException(EXPIRED_PERIOD_REFRESH_TOKEN); + } + + String category = jwtUtil.getCategory(refresh); + if (!category.equals("refresh")) { + + throw new ApplicationException(INVALID_REFRESH_TOKEN); + } + + Boolean isExist = refreshRepository.existsByRefresh(refresh); + if (!isExist) { + + throw new ApplicationException(NOT_FOUND_REFRESH_TOKEN_IN_DB); + } + + /** + * [ ๋กœ๊ทธ์•„์›ƒ ์ง„ํ–‰ ] + * **/ + + // Refresh ํ† ํฐ DB์—์„œ ์ œ๊ฑฐ + refreshRepository.deleteByRefresh(refresh); + + //Refresh ํ† ํฐ Cookie ๊ฐ’ 0 + Cookie cookie = new Cookie("refreshToken", null); + cookie.setMaxAge(0); + cookie.setPath("/"); + + response.addCookie(cookie); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"result\": \"๋กœ๊ทธ์•„์›ƒ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\"}"); + } +} diff --git a/src/main/java/ceos/vote/global/jwt/JWTFilter.java b/src/main/java/ceos/vote/global/jwt/JWTFilter.java new file mode 100644 index 0000000..098dff6 --- /dev/null +++ b/src/main/java/ceos/vote/global/jwt/JWTFilter.java @@ -0,0 +1,74 @@ +package ceos.vote.global.jwt; + +import ceos.vote.domain.member.dto.CustomUserDetails; +import ceos.vote.domain.member.entity.Member; +import ceos.vote.global.repository.MemberRepository; +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.io.PrintWriter; + +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + private final MemberRepository memberRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String header = request.getHeader("Authorization"); + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String accessToken = header.substring(7); // "Bearer " ์ œ๊ฑฐ ํ›„ ํ† ํฐ๋งŒ ์ถ”์ถœ + + // ํ† ํฐ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ, ๋งŒ๋ฃŒ ์‹œ ๋‹ค์Œ ํ•„ํ„ฐ๋กœ ๋„˜๊ธฐ์ง€ ์•Š์Œ + try { + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + + PrintWriter writer = response.getWriter(); + writer.print("AccessToken์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + // ํ† ํฐ์ด access ์ธ์ง€ ํ™•์ธ (๋ฐœ๊ธ‰์‹œ ํŽ˜์ด๋กœ๋“œ์— ๋ช…์‹œ) + String category = jwtUtil.getCategory(accessToken); + + if (!category.equals("access")) { + + PrintWriter writer = response.getWriter(); + writer.print("์œ ํšจํ•˜์ง€ ์•Š์€ AccessToken ์ž…๋‹ˆ๋‹ค."); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + + String userId = jwtUtil.getUsername(accessToken); + + Member member = memberRepository.findByUserId(userId) + .orElseThrow(() -> new UsernameNotFoundException("ํ•ด๋‹น ์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + CustomUserDetails customUserDetails = new CustomUserDetails(member); + + Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/ceos/vote/global/jwt/JWTUtil.java b/src/main/java/ceos/vote/global/jwt/JWTUtil.java new file mode 100644 index 0000000..fee6ccf --- /dev/null +++ b/src/main/java/ceos/vote/global/jwt/JWTUtil.java @@ -0,0 +1,53 @@ +package ceos.vote.global.jwt; + +import io.jsonwebtoken.Jwts; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JWTUtil { + + private SecretKey secretKey; + + public JWTUtil(@Value("${spring.jwt.secret}") String secret) { + + secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("userId", String.class); + } + + public String getRole(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + public String getCategory(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class); + } + + public Boolean isExpired(String token) { + + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } + + public String createJwt(String category, String userId, String role, Long expiredMs) { + + return Jwts.builder() + .claim("category", category) + .claim("userId", userId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) // ๋ฐœ๊ธ‰ ์‹œ๊ฐ„ + .expiration(new Date(System.currentTimeMillis() + expiredMs)) // ๋งŒ๋ฃŒ ์‹œ๊ฐ„ + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/ceos/vote/global/jwt/LoginFilter.java b/src/main/java/ceos/vote/global/jwt/LoginFilter.java new file mode 100644 index 0000000..75a71e5 --- /dev/null +++ b/src/main/java/ceos/vote/global/jwt/LoginFilter.java @@ -0,0 +1,120 @@ +package ceos.vote.global.jwt; + +import ceos.vote.domain.member.dto.request.LoginRequestDto; +import ceos.vote.domain.member.entity.Refresh; +import ceos.vote.global.repository.RefreshRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.Date; +import java.util.Iterator; + +@RequiredArgsConstructor +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JWTUtil jwtUtil; + private final RefreshRepository refreshRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + LoginRequestDto loginRequestDto; + + try { + // JSON ์š”์ฒญ ๋ณธ๋ฌธ์„ ์ฝ์–ด LoginRequestDto ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ + ServletInputStream inputStream = request.getInputStream(); + String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + loginRequestDto = objectMapper.readValue(messageBody, LoginRequestDto.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + + String userId = loginRequestDto.userId(); + String password = loginRequestDto.password(); + + // ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์—์„œ userId์™€ password๋ฅผ ๊ฒ€์ฆํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” token(dto)์— ๋‹ด์•„์•ผ ํ•จ + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userId, password, null); + + // token์— ๋‹ด์€ ๊ฐ’๋“ค์˜ ๊ฒ€์ฆ์„ ์œ„ํ•ด AuthenticationManager๋กœ ์ „๋‹ฌ -> ๊ฒ€์ฆ ์ง„ํ–‰ + return authenticationManager.authenticate(authToken); + } + + // ๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ์‹คํ–‰ํ•˜๋Š” ๋ฉ”์†Œ๋“œ (JWT ๋ฐœ๊ธ‰) + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException { + + String nickname = authentication.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + String role = auth.getAuthority(); + + //ํ† ํฐ ์ƒ์„ฑ + String access = jwtUtil.createJwt("access", nickname, role, 1000L * 60 * 60 * 24 * 14); // 2์ฃผ (์ž„์‹œ) + String refresh = jwtUtil.createJwt("refresh", nickname, role, 1000L * 60 * 60 * 24 * 14); // 2์ฃผ + + //Refresh ํ† ํฐ ์ €์žฅ + addRefreshEntity(nickname, refresh, 1000L * 60 * 60 * 24 * 14); + + //์‘๋‹ต ์„ค์ • + response.setHeader("Authorization", "Bearer " + access); + response.addCookie(createCookie("refreshToken", refresh)); + response.setStatus(HttpStatus.OK.value()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"result\": \"๋กœ๊ทธ์ธ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\"}"); + } + + //๋กœ๊ทธ์ธ ์‹คํŒจ์‹œ ์‹คํ–‰ํ•˜๋Š” ๋ฉ”์†Œ๋“œ + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException { + + response.setStatus(401); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"result\": \"๋กœ๊ทธ์ธ์— ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค.\"}"); + } + + private void addRefreshEntity(String userId, String refresh, Long expiredMs) { + + Date date = new Date(System.currentTimeMillis() + expiredMs); + + Refresh refreshEntity = Refresh.builder() + .userId(userId) + .refresh(refresh) + .expiration(date.toString()) + .build(); + + refreshRepository.save(refreshEntity); + } + + private Cookie createCookie(String key, String value) { + + Cookie cookie = new Cookie(key, value); + cookie.setMaxAge(60 * 60 * 24 * 14); + //cookie.setSecure(true); + cookie.setPath("/"); + cookie.setHttpOnly(true); + + return cookie; + } +} diff --git a/src/main/java/ceos/vote/global/repository/DeveloperRepository.java b/src/main/java/ceos/vote/global/repository/DeveloperRepository.java new file mode 100644 index 0000000..12cc447 --- /dev/null +++ b/src/main/java/ceos/vote/global/repository/DeveloperRepository.java @@ -0,0 +1,12 @@ +package ceos.vote.global.repository; + +import ceos.vote.domain.developer.entity.Developer; +import ceos.vote.domain.member.entity.PartType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DeveloperRepository extends JpaRepository { + + List findByPartOrderByCountDesc(PartType part); +} diff --git a/src/main/java/ceos/vote/global/repository/MemberRepository.java b/src/main/java/ceos/vote/global/repository/MemberRepository.java new file mode 100644 index 0000000..86b8c26 --- /dev/null +++ b/src/main/java/ceos/vote/global/repository/MemberRepository.java @@ -0,0 +1,13 @@ +package ceos.vote.global.repository; + +import ceos.vote.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + Optional findByUserId(String userId); + + Boolean existsMemberByUserId(String userId); +} diff --git a/src/main/java/ceos/vote/global/repository/RefreshRepository.java b/src/main/java/ceos/vote/global/repository/RefreshRepository.java new file mode 100644 index 0000000..cbd89b4 --- /dev/null +++ b/src/main/java/ceos/vote/global/repository/RefreshRepository.java @@ -0,0 +1,16 @@ +package ceos.vote.global.repository; + +import ceos.vote.domain.member.entity.Refresh; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.transaction.annotation.Transactional; + +public interface RefreshRepository extends JpaRepository { + + Boolean existsByRefresh(String refresh); + + @Transactional + void deleteByRefresh(String refresh); + + @Transactional + void deleteByUserId(String userId); +} diff --git a/src/main/java/ceos/vote/global/repository/TeamRepository.java b/src/main/java/ceos/vote/global/repository/TeamRepository.java new file mode 100644 index 0000000..f0f37de --- /dev/null +++ b/src/main/java/ceos/vote/global/repository/TeamRepository.java @@ -0,0 +1,14 @@ +package ceos.vote.global.repository; + +import ceos.vote.domain.member.entity.TeamType; +import ceos.vote.domain.team.entity.Team; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TeamRepository extends JpaRepository { + + List findAllByOrderByCountDesc(); + + Team findByType(TeamType type); +} diff --git a/src/test/java/ceos/vote/VoteApplicationTests.java b/src/test/java/ceos/vote/VoteApplicationTests.java new file mode 100644 index 0000000..804bb2f --- /dev/null +++ b/src/test/java/ceos/vote/VoteApplicationTests.java @@ -0,0 +1,13 @@ +package ceos.vote; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class VoteApplicationTests { + + @Test + void contextLoads() { + } + +}