diff --git a/.gitignore b/.gitignore index c2065bc2..55608a8e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +.env ### STS ### .apt_generated diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..d9a2bc16 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17 +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=prod","-Duser.timezone=Asia/Seoul"] \ No newline at end of file diff --git a/README.md b/README.md index 57f9a465..e546f405 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,67 @@ 김최양 사이드 프로젝트 FitaPet 백엔드 Repository 입니다. - 기획&디자인: [김유빈](https://github.com/youvebeen09) -- [프론트엔드](https://github.com/heejinnn/fit-a-pet-frontend): [최희진](https://github.com/heejinnn) +- [프론트엔드](https://github.com/KCY-Fit-a-Pet/fit-a-pet-client): [최희진](https://github.com/heejinnn) - 백엔드: [양재서](https://github.com/psychology50) +## [ Contents ] +- [Project Summary](#project-summary) +- [Version Control](#version-control) +- [Dev Environment](#dev-environment) +- [Tech Stack](#tech-stack) + - [Framework & Library](#framework--library) + - [Build tool](#build-tool) + - [Database](#database) + - [Infra](#infra) +- [Project Check List](#project-check-list) +- [System Architecture](#system-architecture) +- [WAS Architecture](#was-architecture) +- [ERD](#erd) +- [Branch Convention](#branch-convention) + +## Version Control +| Version # | Revision Date | Description | Author | +|:---------:|:-------------:|:--------------------|:------:| +| v0.0.1 | 2023.10.1 | 프로젝트 기본 기능 구현 및 배포 | 양재서 | + ## Dev Environment - IntelliJ 2023.1.2 -- Postman -- GitHub -- Virtual Machine (Linux) +- Postman 10.18.9 +- GitHub +- Windows 11 - Notion ## Tech Stack ### Framework & Library -- Java (버전 미정) -- SpringBoot 3.0.1 +- JDK 11 +- SpringBoot 3.1.0 - SpringBoot Security - Spring Data JPA -- Swagger -- Lombok +- Spring Doc Open API +- Lombok +- JUnit5 +- jjwt 0.11.5 +- httpclient 4.5.14 & httpclient5 5.1.4 ### Build tool - Gradle ### Database -- Maria DB +- MySQL8 - Redis ### Infra -- AWS EC2 -- AWS S3 -- AWS CodeDeploy -- AWS Route53 -- Docker & Kubernetes +- AWS EC2 (for Build Server) +- Docker & Docker-compose - Jenkins -- Github Todo bot +- GitHub Todo bot +- GitHub Action +- Kakao Talk +- Naver Cloud Platform Server (for WAS) +- Naver Cloud Platform Cloud DB for Redis +- Naver Cloud Platform Object Storage +- Naver Cloud Platform Simple & Easy Notification Service +- Goorm IDE (for DB Server) ## Project Check List - [ ] 실제 서비스를 공개적으로 배포하고 운영하는 경험을 해보았다. @@ -50,16 +77,17 @@ - [ ] 타인과의 협업을 효율적으로 하기 위한 고민을 해보았다. ## System Architecture - +
## WAS Architecture +
+- WAS Server 내부에 Nginx를 통해 Reverse Proxy를 구현했습니다. ## ERD +
- -## Projcet Board - +- 현재 많은 부분이 수정되었고, 앞으로도 계속 수정될 예정입니다. ## Branch Convention @@ -68,22 +96,12 @@ main ── develop ── feature └── hotfix ``` -| Brach name | description | -| --- | --- | -| main | 배포 중인 서비스 브랜치 -• 실제 서비스가 이루어지는 브랜치입니다. -• 해당 브랜치를 기준으로 develop 브랜치가 분기됩니다. -• 긴급 수정 안건에 대해서는 hotfix 브랜치에서 처리합니다. | -| develop | 작업 브랜치 -• 개발, 테스트, 릴리즈 등 배포 전 단계의 기준이 되는 브랜치입니다. -• 프로젝트의 default 브랜치입니다. -• 해당 브랜치에서 feature 브랜치가 분기됩니다. | -| feature | 기능 단위 구현 -• 개별 개발자가 맡은 작업을 개발하는 브랜치입니다. -• feature/(feature-name)처럼 머릿말-꼬릿말(개발하는 기능)으로 명명합니다. -• kebab-case 네이밍 규칙을 준수합니다. | -| hotfix | 서비스 중 긴급 수정 사항 처리 -• main에서 분기합니다. | +| Brach name | description | +| --- |-------------------------------------------------------------------------------------------------------------------------------------------| +| main | 배포 중인 서비스 브랜치
• 실제 서비스가 이루어지는 브랜치입니다.
• 해당 브랜치를 기준으로 develop 브랜치가 분기됩니다.
• 긴급 수정 안건에 대해서는 hotfix 브랜치에서 처리합니다. | +| develop | 작업 브랜치
• 개발, 테스트, 릴리즈 등 배포 전 단계의 기준이 되는 브랜치입니다.
• 프로젝트의 default 브랜치입니다.
• 해당 브랜치에서 feature 브랜치가 분기됩니다. | +| feature | 기능 단위 구현
• 개별 개발자가 맡은 작업을 개발하는 브랜치입니다.
• feature/(feature-name)처럼 머릿말-꼬릿말(개발하는 기능)으로 명명합니다.
• kebab-case 네이밍 규칙을 준수합니다. | +| hotfix | 서비스 중 긴급 수정 사항 처리
• main에서 분기합니다. | ## Commit Convention @@ -122,8 +140,3 @@ main ── develop ── feature ``` - -## Version Control - - - diff --git a/build.gradle b/build.gradle index 4609500a..1ad06af1 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { group = 'com.kcy' version = '0.0.1-SNAPSHOT' -sourceCompatibility = '20' +sourceCompatibility = '17' configurations { compileOnly { @@ -21,10 +21,20 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'com.jcraft:jsch:0.1.55' + + implementation 'org.apache.httpcomponents:httpclient:4.5.14' + implementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1' + implementation 'org.apache.httpcomponents:httpcore:4.4.14' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' // jwt implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' @@ -35,6 +45,7 @@ dependencies { developmentOnly 'org.springframework.boot:spring-boot-devtools' implementation 'mysql:mysql-connector-java:8.0.28' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..dac34262 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.7' +services: + proxy: + image: jaeseo/nginx:latest + restart: always + ports: + - "80:80" + - "443:443" + networks: + - was-net + depends_on: + - api + + fitapet-api: + image: jaeseo/fitapet:latest + restart: unless-stopped + ports: + - "8080:8080" + env_file: + - .env + networks: + - was-net + +networks: + was-net: + name: fitapet + external: true diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 00000000..6901bced --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,3 @@ +FROM nginx + +ADD nginx.conf /etc/nginx/nginx.conf diff --git a/proxy/nginx.conf b/proxy/nginx.conf new file mode 100644 index 00000000..65070f7b --- /dev/null +++ b/proxy/nginx.conf @@ -0,0 +1,57 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /var/run/nginx.pid; +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; # 옵션 항목을 설정해둔 파일의 경로 + default_type application/octet-stream; # 옥텟 스트림 기반의 http를 사용 + + # 백엔드 upstream 설정 (nginx가 downstream) + upstream docker-server { + server fitapet-api:8080; # nginx가 요청을 전달할 서버를 정의하는 지시자 (여기서는 WAS, 웹 어플리케이션 서버 host주소:포트) + } + + server { + listen 80; # 서버가 리스닝할 포트를 설정하는 지시자 (server 블록 하나 당 하나의 웹 사이트 선언) + listen [::]:80; + + server_name localhost; # 서버의 도메인 이름을 설정하는 지시자 (request header의 host와 비교하여 일치하는 경우에만 처리) + + location / { + root /usr/share/nginx/html; # root 지시자는 요청이 들어왔을 때, 해당 요청을 처리할 파일의 기본 경로를 설정 + index index.html index.htm; # index 지시자는 root 지시자에서 설정한 경로에서 찾을 파일의 이름을 설정 + try_files $uri $uri/ /index.html =404; # try_files 지시자는 파일을 찾을 수 없는 경우의 처리 방법을 설정 + } + + location /api { + proxy_pass http://docker-server; # proxy_pass 지시자는 요청을 전달할 서버의 주소를 설정 + proxy_redirect off; # proxy_redirect 지시자는 리다이렉션을 설정 + proxy_set_header Host $host; # proxy_set_header 지시자는 요청 헤더의 값을 변경 + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header 지시자는 요청 헤더의 값을 변경 + proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header 지시자는 요청 헤더의 값을 변경 + } + +# location /socket { +# proxy_pass http://docker-server; +# proxy_http_version 1.1; # proxy_http_version 지시자는 HTTP 버전을 설정 +# proxy_set_header Upgrade $http_upgrade; # proxy_set_header 지시자는 요청 헤더의 값을 변경 +# proxy_set_header Connection "upgrade"; # proxy_set_header 지시자는 요청 헤더의 값을 변경 +# proxy_set_header Host $host; # proxy_set_header 지시자는 요청 헤더의 값을 변경 +# } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; # log_format 지시자는 로그의 형식을 설정 + access_log /var/log/nginx/access.log main; # access_log 지시자는 로그 파일의 경로와 형식을 설정 + + sendfile on; # sendfile 지시자는 파일 전송 방식을 설정 + server_tokens off; # server_tokens 지시자는 응답 헤더의 Server 값을 설정 + keepalive_timeout 65; # keepalive_timeout 지시자는 keep-alive 연결의 타임아웃 시간을 설정 + include /etc/nginx/conf.d/*.conf; # include 지시자는 외부 설정 파일을 포함 +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/FitapetApplication.java b/src/main/java/com/kcy/fitapet/FitapetApplication.java index 8bd185a1..afbc1e60 100644 --- a/src/main/java/com/kcy/fitapet/FitapetApplication.java +++ b/src/main/java/com/kcy/fitapet/FitapetApplication.java @@ -5,7 +5,6 @@ @SpringBootApplication public class FitapetApplication { - public static void main(String[] args) { SpringApplication.run(FitapetApplication.class, args); } diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/Care.java b/src/main/java/com/kcy/fitapet/domain/care/domain/Care.java new file mode 100644 index 00000000..5a5f305a --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/care/domain/Care.java @@ -0,0 +1,48 @@ +package com.kcy.fitapet.domain.care.domain; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "CARE") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"careName", "dtype"}) +public class Care extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "care_name") + private String careName; + @Column(name = "dtype") + @Convert(converter = CareTypeConverter.class) + private CareType dtype; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", updatable = false) + private Member author; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_editor_id") + private Member lastEditor; + @OneToMany(mappedBy = "care", cascade = CascadeType.ALL) + private List careDetails = new ArrayList<>(); + + @Builder + private Care(String careName, CareType dtype) { + this.careName = careName; + this.dtype = dtype; + } + + public static Care of(String careName, CareType dtype) { + return Care.builder() + .careName(careName) + .dtype(dtype) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/CareDetail.java b/src/main/java/com/kcy/fitapet/domain/care/domain/CareDetail.java new file mode 100644 index 00000000..43468d7e --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/care/domain/CareDetail.java @@ -0,0 +1,69 @@ +package com.kcy.fitapet.domain.care.domain; + +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +@Entity +@Table(name = "CARE_DETAIL") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"careDetailName", "careTime", "limitTime", "isDone", "clearedAt"}) +public class CareDetail extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @Column(name = "care_detail_name") + private String careDetailName; + @Temporal(TemporalType.TIME) + @Column(name = "care_time") + private LocalTime careTime; + @Temporal(TemporalType.TIME) + @Column(name = "limit_time") + private LocalTime limitTime; + @Column(name = "is_done") @ColumnDefault("false") + private Boolean isDone; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "cleared_at") + private LocalDateTime clearedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "care_id") + private Care care; + @OneToOne(mappedBy = "careDetail") + private DayOfWeek dayOfWeek; + + @Builder + private CareDetail(String careDetailName, LocalTime careTime, LocalTime limitTime, Boolean isDone, LocalDateTime clearedAt) { + this.careDetailName = careDetailName; + this.careTime = careTime; + this.limitTime = limitTime; + this.isDone = isDone; + this.clearedAt = clearedAt; + } + + public static CareDetail of(String careDetailName, LocalTime careTime, LocalTime limitTime, Boolean isDone, LocalDateTime clearedAt) { + return CareDetail.builder() + .careDetailName(careDetailName) + .careTime(careTime) + .limitTime(limitTime) + .isDone(isDone) + .clearedAt(clearedAt) + .build(); + } + + public void isDone() { + this.isDone = Boolean.TRUE; + this.clearedAt = LocalDateTime.now(); + } + + public void cancelDone() { + this.isDone = Boolean.FALSE; + this.clearedAt = null; + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/CareType.java b/src/main/java/com/kcy/fitapet/domain/care/domain/CareType.java new file mode 100644 index 00000000..49cad6f1 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/care/domain/CareType.java @@ -0,0 +1,38 @@ +package com.kcy.fitapet.domain.care.domain; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.kcy.fitapet.global.common.util.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.toMap; + +@RequiredArgsConstructor +public enum CareType implements LegacyCommonType { + DAILY("1", "일상관리"), + WEEKLY("2", "주간관리"); + + private final String code; + private final String type; + + private static final Map stringToEnum = + Stream.of(values()).collect(toMap(Object::toString, e -> e)); + + @Override public String getCode() { + return code; + } + @JsonValue public String getType() { + return type; + } + + @JsonCreator + public static CareType fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + @Override public String toString() { + return type; + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/CareTypeConverter.java b/src/main/java/com/kcy/fitapet/domain/care/domain/CareTypeConverter.java new file mode 100644 index 00000000..d9ff624d --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/care/domain/CareTypeConverter.java @@ -0,0 +1,13 @@ +package com.kcy.fitapet.domain.care.domain; + +import com.kcy.fitapet.global.common.util.converter.AbstractLegacyEnumAttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class CareTypeConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "돌봄타입"; + + public CareTypeConverter() { + super(CareType.class, false, ENUM_NAME); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/care/domain/DayOfWeek.java b/src/main/java/com/kcy/fitapet/domain/care/domain/DayOfWeek.java new file mode 100644 index 00000000..e9146af2 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/care/domain/DayOfWeek.java @@ -0,0 +1,50 @@ +package com.kcy.fitapet.domain.care.domain; + +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Table(name = "DAY_OF_WEEK") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@ToString(exclude = {"id", "careDetail"}) +public class DayOfWeek extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @ColumnDefault("false") + private boolean mon; + @ColumnDefault("false") + private boolean tue; + @ColumnDefault("false") + private boolean wed; + @ColumnDefault("false") + private boolean thu; + @ColumnDefault("false") + private boolean fri; + @ColumnDefault("false") + private boolean sat; + @ColumnDefault("false") + private boolean sun; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "care_detail_id") + private CareDetail careDetail; + + @Builder + private DayOfWeek(boolean mon, boolean tue, boolean wed, boolean thu, boolean fri, boolean sat, boolean sun) { + this.mon = mon; + this.tue = tue; + this.wed = wed; + this.thu = thu; + this.fri = fri; + this.sat = sat; + this.sun = sun; + } + + public static DayOfWeek of(boolean mon, boolean tue, boolean wed, boolean thu, boolean fri, boolean sat, boolean sun) { + return DayOfWeek.builder() + .mon(mon).tue(tue).wed(wed).thu(thu).fri(fri).sat(sat).sun(sun).build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/api/MemberApi.java b/src/main/java/com/kcy/fitapet/domain/member/api/MemberApi.java new file mode 100644 index 00000000..e2adaa29 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/api/MemberApi.java @@ -0,0 +1,177 @@ +package com.kcy.fitapet.domain.member.api; + +import com.kcy.fitapet.domain.member.dto.auth.SignInReq; +import com.kcy.fitapet.domain.member.dto.auth.SignUpReq; +import com.kcy.fitapet.domain.member.dto.sms.SmsReq; +import com.kcy.fitapet.domain.member.dto.sms.SmsRes; +import com.kcy.fitapet.domain.member.service.component.MemberAuthService; +import com.kcy.fitapet.global.common.response.ErrorResponse; +import com.kcy.fitapet.global.common.response.FailureResponse; +import com.kcy.fitapet.global.common.response.SuccessResponse; +import com.kcy.fitapet.global.common.response.code.ErrorCode; +import com.kcy.fitapet.global.common.security.authentication.CustomUserDetails; +import com.kcy.fitapet.global.common.util.cookie.CookieUtil; +import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorCode; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +import static com.kcy.fitapet.global.common.util.jwt.AuthConstants.*; + +@Tag(name = "유저 관리 API", description = "유저 인증과 관련된 API") +@RestController +@RequestMapping("/api/v1/members") +@RequiredArgsConstructor +@Slf4j +public class MemberApi { + private final MemberAuthService memberAuthService; + private final CookieUtil cookieUtil; + + @Operation(summary = "회원가입", description = "유저 닉네임, 패스워드를 입력받고 유효하다면 액세스 토큰(헤더)과 리프레시 토큰(쿠키)을 반환합니다.") + @Parameters({ + @Parameter(name = "Authorization", description = "전화번호 인증 후 받은 토큰", in = ParameterIn.HEADER), + @Parameter(name = "dto", description = "회원가입 정보", schema = @Schema(implementation = SignUpReq.class)) + }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "회원가입 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse(responseCode = "400", description = "회원가입 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + }) + @PostMapping("/register") + public ResponseEntity register(@RequestHeader("Authorization") String accessToken, @RequestBody @Valid SignUpReq dto) { + Map tokens = memberAuthService.register(accessToken, dto); + return getResponseEntity(tokens); + } + + @Operation(summary = "전화번호 인증", description = "전화번호를 입력받고 인증번호를 전송합니다.") + @Parameters({ + @Parameter(name = "phone", description = "전화번호", in = ParameterIn.QUERY, required = true), + @Parameter(name = "code", description = "인증번호", in = ParameterIn.QUERY) + }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증번호 전송 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse(responseCode = "200", description = "인증번호 전송 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + }) + @GetMapping("/sms") + public ResponseEntity smsAuthorization( + @RequestParam(value = "phone") String phoneNumber, + @RequestParam(value = "code", required = false) String code) { + if (code == null) { + SmsRes smsRes = memberAuthService.sendCertificationNumber(new SmsReq(phoneNumber)); + return ResponseEntity.ok(SuccessResponse.from(smsRes)); + } + + String token = memberAuthService.checkCertificationNumber(phoneNumber, code); + + return (token.equals("")) + ? ResponseEntity.ok(FailureResponse.of("code", ErrorCode.INVALID_AUTH_CODE.getMessage())) + : ResponseEntity.ok() + .header(ACCESS_TOKEN.getValue(), token) + .body(SuccessResponse.from(Map.of("code", "인증 성공"))); + } + + @Operation(summary = "로그인", description = "유저 닉네임, 패스워드를 입력받고 유효하다면 액세스 토큰(헤더)과 리프레시 토큰(쿠키)을 반환합니다.") + @Parameters({ + @Parameter(name = "dto", description = "로그인 정보", schema = @Schema(implementation = SignInReq.class)) + }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse(responseCode = "400", description = "로그인 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + }) + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid SignInReq dto) { + Map tokens = memberAuthService.login(dto); + return getResponseEntity(tokens); + } + + @Operation(summary = "로그아웃", description = "액세스 토큰과 리프레시 토큰을 만료시킵니다.") + @Parameters({ + @Parameter(name = "Authorization", description = "액세스 토큰", in = ParameterIn.HEADER), + @Parameter(name = "refreshToken", description = "리프레시 토큰", in = ParameterIn.COOKIE) + }) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse(responseCode = "400", description = "로그아웃 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + }) + @GetMapping("/logout") + public ResponseEntity logout(@CookieValue("refreshToken") @Valid String refreshToken, HttpServletRequest request, HttpServletResponse response) { + memberAuthService.logout(request.getHeader(AUTH_HEADER.getValue()), refreshToken); + ResponseCookie cookie = cookieUtil.deleteCookie(request, response, REFRESH_TOKEN.getValue()) + .orElseThrow(() -> new AuthErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND, "존재하지 않는 쿠키입니다.")); + + return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(SuccessResponse.noContent()); + } + + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 이용해 액세스 토큰과 리프레시 토큰을 갱신합니다.") + @Parameter(name = "refreshToken", description = "리프레시 토큰", in = ParameterIn.COOKIE) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토큰 갱신 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse(responseCode = "400", description = "토큰 갱신 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + }) + @GetMapping("/refresh") + public ResponseEntity refresh(@CookieValue("refreshToken") @Valid String refreshToken) { + if (refreshToken == null) { + throw new AuthErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND, "존재하지 않는 쿠키입니다."); + } + Map tokens = memberAuthService.refresh(refreshToken); + + return getResponseEntity(tokens); + } + + @Operation(summary = "인증 테스트", description = "인증 테스트") + @Parameter(name = "Authorization", description = "액세스 토큰", in = ParameterIn.HEADER) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증 테스트 성공", content = @Content(schema = @Schema(implementation = SuccessResponse.class))), + @ApiResponse(responseCode = "400", description = "인증 테스트 실패", content = @Content(schema = @Schema(implementation = FailureResponse.class))), + @ApiResponse(responseCode = "4xx", description = "에러", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + }) + @GetMapping("/authentication") + public ResponseEntity authenticationTest(@AuthenticationPrincipal CustomUserDetails securityUser, Authentication authentication) { + log.info("type: {}", authentication.getPrincipal()); + JwtUserInfo user = securityUser.toJwtUserInfo(); + log.info("user: {}", user); + + return ResponseEntity.ok(user); + } + + /** + * 액세스 토큰과 리프레시 토큰을 반환합니다. + * @param tokens : 액세스 토큰과 리프레시 토큰 + * @return ResponseEntity + */ + private ResponseEntity getResponseEntity(Map tokens) { + log.debug("access token: {}", tokens.get(ACCESS_TOKEN.getValue())); + log.debug("refresh token: {}", tokens.get(REFRESH_TOKEN.getValue())); + ResponseCookie cookie = cookieUtil.createCookie(REFRESH_TOKEN.getValue(), tokens.get(REFRESH_TOKEN.getValue()), 60 * 60 * 24 * 7); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .header(ACCESS_TOKEN.getValue(), tokens.get(ACCESS_TOKEN.getValue())) + .body(SuccessResponse.noContent()); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/dao/MemberRepository.java b/src/main/java/com/kcy/fitapet/domain/member/dao/MemberRepository.java new file mode 100644 index 00000000..deb80523 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/dao/MemberRepository.java @@ -0,0 +1,13 @@ +package com.kcy.fitapet.domain.member.dao; + +import com.kcy.fitapet.domain.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByUid(String uid); + boolean existsByUid(String uid); + boolean existsByEmail(String email); + boolean existsByPhone(String phone); +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/Master.java b/src/main/java/com/kcy/fitapet/domain/member/domain/Master.java new file mode 100644 index 00000000..fd4a4499 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/domain/Master.java @@ -0,0 +1,42 @@ +package com.kcy.fitapet.domain.member.domain; + +import com.kcy.fitapet.domain.pet.domain.Pet; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "MEMBER_PET") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Master { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ColumnDefault("false") + private boolean hidden; + + @CreatedDate + @Column(name = "joined_at", nullable = false, updatable = false) + private LocalDateTime joinedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pet_id") + private Pet pet; + + public void hide() { + this.hidden = true; + } + + public void show() { + this.hidden = false; + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/Member.java b/src/main/java/com/kcy/fitapet/domain/member/domain/Member.java new file mode 100644 index 00000000..49370dec --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/domain/Member.java @@ -0,0 +1,98 @@ +package com.kcy.fitapet.domain.member.domain; + +import com.kcy.fitapet.domain.model.Auditable; +import com.kcy.fitapet.domain.notification.domain.Notification; +import com.kcy.fitapet.domain.notification.domain.NotificationSetting; +import com.kcy.fitapet.domain.oauthid.domain.OAuthID; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Table(name = "MEMBER") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"id", "name", "phone", "email", "role"}) +public class Member extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Getter + private Long id; + + @Getter + private String uid; + private String name; + private String password; + @Getter + private String phone; + @Getter + private String email; + @Column(name = "profile_img") + private String profileImg; + @Column(name = "account_locked") @ColumnDefault("false") + private Boolean accountLocked; + @Convert(converter = RoleTypeConverter.class) + @Getter + private RoleType role; + + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + private Set oauthIDs = new HashSet<>(); + @OneToMany(mappedBy = "from", cascade = CascadeType.ALL) + private Set fromMemberNickname = new HashSet<>(); + @OneToMany(mappedBy = "to", cascade = CascadeType.ALL) + private Set toMemberNickname = new HashSet<>(); + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + private List notifications = new ArrayList<>(); + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL) + private NotificationSetting notificationSetting; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + private List pets = new ArrayList<>(); + + @Builder + private Member(String uid, String name, String password, String phone, String email, String profileImg, Boolean accountLocked, RoleType role) { + this.uid = uid; + this.name = name; + this.password = password; + this.phone = phone; + this.email = email; + this.profileImg = profileImg; + this.accountLocked = accountLocked; + this.role = role; + } + + public static Member of(String uid, String name, String password, String phone, String email, String profileImg, Boolean accountLocked, RoleType role) { + return Member.builder() + .uid(uid) + .name(name) + .password(password) + .phone(phone) + .email(email) + .profileImg(profileImg) + .accountLocked(accountLocked) + .role(role) + .build(); + } + + /** + * 비밀번호 암호화 + * @param passwordEncoder : 비밀번호 암호화 객체 + * @return 변경된 유저 객체 + */ + public void encodePassword(PasswordEncoder passwordEncoder) { + this.password = passwordEncoder.encode(this.password); + } + + /** + * 비밀번호 확인 + * @param plainPassword : 평문 비밀번호 + * @param passwordEncoder : 비밀번호 암호화 객체 + * @return true or false + */ + public boolean checkPassword(String plainPassword, PasswordEncoder passwordEncoder) { + return passwordEncoder.matches(passwordEncoder.encode(plainPassword), this.password); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/MemberNickname.java b/src/main/java/com/kcy/fitapet/domain/member/domain/MemberNickname.java new file mode 100644 index 00000000..216633be --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/domain/MemberNickname.java @@ -0,0 +1,34 @@ +package com.kcy.fitapet.domain.member.domain; + +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "MEMBER_NICKNAME") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"id", "nickname"}) +public class MemberNickname extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String nickname; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "from") + private Member from; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "to") + private Member to; + + @Builder + private MemberNickname(String nickname) { + this.nickname = nickname; + } + + public static MemberNickname of(String nickname) { + return MemberNickname.builder() + .nickname(nickname) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/RoleType.java b/src/main/java/com/kcy/fitapet/domain/member/domain/RoleType.java new file mode 100644 index 00000000..8d34376c --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/domain/RoleType.java @@ -0,0 +1,35 @@ +package com.kcy.fitapet.domain.member.domain; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.kcy.fitapet.global.common.util.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.toMap; + +@RequiredArgsConstructor +public enum RoleType implements LegacyCommonType { + ADMIN("1", "ROLE_ADMIN"), + USER("2", "ROLE_USER"); + + private final String code; + private final String role; + private static final Map stringToEnum = + Stream.of(values()).collect(toMap(Object::toString, e -> e)); + + + @JsonValue + public String getRole() { return role; } + @Override + public String getCode() { return code; } + + @JsonCreator + public static RoleType fromString(String role) { + return stringToEnum.get(role.toUpperCase()); + } + + @Override public String toString() { return role; } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/domain/RoleTypeConverter.java b/src/main/java/com/kcy/fitapet/domain/member/domain/RoleTypeConverter.java new file mode 100644 index 00000000..37903e46 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/domain/RoleTypeConverter.java @@ -0,0 +1,13 @@ +package com.kcy.fitapet.domain.member.domain; + +import com.kcy.fitapet.global.common.util.converter.AbstractLegacyEnumAttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class RoleTypeConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "유저권한"; + + public RoleTypeConverter() { + super(RoleType.class, false, ENUM_NAME); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignInReq.java b/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignInReq.java new file mode 100644 index 00000000..33434bc5 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignInReq.java @@ -0,0 +1,12 @@ +package com.kcy.fitapet.domain.member.dto.auth; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "로그인 요청") +public record SignInReq( + @NotNull(message = "아이디를 입력해주세요.") @Schema(description = "아이디") + String uid, + @NotNull(message = "비밀번호를 입력해주세요.") @Schema(description = "비밀번호") + String password) { +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignUpReq.java b/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignUpReq.java new file mode 100644 index 00000000..d02c709e --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/dto/auth/SignUpReq.java @@ -0,0 +1,34 @@ +package com.kcy.fitapet.domain.member.dto.auth; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.member.domain.RoleType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "회원가입 요청") +public record SignUpReq( + @NotNull(message = "Id is is required") @Schema(description = "아이디") + String uid, + @NotNull(message = "Password is required") @Schema(description = "비밀번호") + String password, + @NotNull(message = "Nickname is required") @Schema(description = "닉네임") + String name, + @Email(message = "Invalid Email Format") @Schema(description = "이메일", nullable = true, example = "abc@gmail.com") + String email, + @Schema(description = "프로필 이미지", nullable = true) + String profileImg +) { + public Member toEntity(String phone) { + return Member.builder() + .uid(uid) + .password(password) + .name(name) + .email(email) + .phone(phone) + .profileImg(profileImg) + .accountLocked(Boolean.FALSE) + .role(RoleType.USER) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensReq.java b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensReq.java new file mode 100644 index 00000000..2981e309 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensReq.java @@ -0,0 +1,37 @@ +package com.kcy.fitapet.domain.member.dto.sms; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +public record SensReq( + String type, + String contentType, + String countryCode, + String from, + String content, + List messages +) { + /** + * NCP SMS API 요청 객체 생성 + * @param type String : SMS | LMS | MMS + * @param contentType String : COMM | AD + * @param countryCode String : 국가번호 + * @param from String : 발신번호 + * @param content String : 메시지 내용 + * @param messages List : 메시지 정보 (to: 수신번호, subject: 개별 메시지 제목, content: 개별 메시지 내용) + * @return SensReq : NCP SMS API 요청 객체 + */ + public static SensReq of(String type, String contentType, String countryCode, String from, String content, List messages) { + return SensReq.builder() + .type(type) + .contentType(contentType) + .countryCode(countryCode) + .from(from) + .content(content) + .messages(messages) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensRes.java b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensRes.java new file mode 100644 index 00000000..b8c4218a --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SensRes.java @@ -0,0 +1,31 @@ +package com.kcy.fitapet.domain.member.dto.sms; + +import lombok.Builder; + +import java.time.LocalDateTime; + +/** + * Client에서 받은 SMS API 요청에 대한 응답 객체 + * @param requestId String : 요청 ID + * @param requestTime LocalDateTime : 요청 시간 + * @param statusCode String : 응답 코드 + * @param statusName String : 응답 상태 + */ +@Builder +public record SensRes( + String requestId, + LocalDateTime requestTime, + String statusCode, + String statusName +) { + + + public static SensRes of(String requestId, LocalDateTime requestTime, String statusCode, String statusName) { + return SensRes.builder() + .requestId(requestId) + .requestTime(requestTime) + .statusCode(statusCode) + .statusName(statusName) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsReq.java b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsReq.java new file mode 100644 index 00000000..4ceba7fe --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsReq.java @@ -0,0 +1,16 @@ +package com.kcy.fitapet.domain.member.dto.sms; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +/** + * Client에서 SMS 인증 요청 시 사용되는 객체 + * @param to String : 수신번호 + */ +@Builder +@Schema(description = "SMS 인증 요청") +public record SmsReq( + @Schema(description = "수신번호") + String to +) { +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsRes.java b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsRes.java new file mode 100644 index 00000000..d38913ce --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/dto/sms/SmsRes.java @@ -0,0 +1,31 @@ +package com.kcy.fitapet.domain.member.dto.sms; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.time.LocalDateTime; + +/** + * SMS 인증번호 발송 응답 객체 + * @param to String : 수신자 번호 + * @param sendTime LocalDateTime : 발송 시간 + * @param expireTime LocalDateTime : 만료 시간 (default: 3분) + */ +@Builder +@Schema(description = "SMS 인증번호 발송 응답") +public record SmsRes( + @Schema(description = "수신자 번호") + String to, + @Schema(description = "발송 시간") + LocalDateTime sendTime, + @Schema(description = "만료 시간 (default: 3분)", example = "2021-08-01T00:00:00") + LocalDateTime expireTime +) { + public static SmsRes of(String to, LocalDateTime sendTime, LocalDateTime expireTime) { + return SmsRes.builder() + .to(to) + .sendTime(sendTime) + .expireTime(expireTime) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/service/component/MemberAuthService.java b/src/main/java/com/kcy/fitapet/domain/member/service/component/MemberAuthService.java new file mode 100644 index 00000000..02e7f61a --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/service/component/MemberAuthService.java @@ -0,0 +1,164 @@ +package com.kcy.fitapet.domain.member.service.component; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.member.dto.auth.SignInReq; +import com.kcy.fitapet.domain.member.dto.auth.SignUpReq; +import com.kcy.fitapet.domain.member.dto.sms.SensRes; +import com.kcy.fitapet.domain.member.dto.sms.SmsReq; +import com.kcy.fitapet.domain.member.dto.sms.SmsRes; +import com.kcy.fitapet.domain.member.service.module.MemberSaveService; +import com.kcy.fitapet.domain.member.service.module.MemberSearchService; +import com.kcy.fitapet.domain.member.service.module.SmsService; +import com.kcy.fitapet.global.common.response.code.ErrorCode; +import com.kcy.fitapet.global.common.response.exception.GlobalErrorException; +import com.kcy.fitapet.global.common.util.jwt.JwtUtil; +import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo; +import com.kcy.fitapet.global.common.util.redis.forbidden.ForbiddenTokenService; +import com.kcy.fitapet.global.common.util.redis.refresh.RefreshToken; +import com.kcy.fitapet.global.common.util.redis.refresh.RefreshTokenService; +import com.kcy.fitapet.global.common.util.redis.sms.SmsCertificationService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; + +import static com.kcy.fitapet.global.common.util.jwt.AuthConstants.ACCESS_TOKEN; +import static com.kcy.fitapet.global.common.util.jwt.AuthConstants.REFRESH_TOKEN; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberAuthService { + private final MemberSearchService memberSearchService; + private final MemberSaveService memberSaveService; + private final SmsService smsService; + + private final RefreshTokenService refreshTokenService; + private final ForbiddenTokenService forbiddenTokenService; + private final SmsCertificationService smsCertificationService; + private final JwtUtil jwtUtil; + + private final PasswordEncoder bCryptPasswordEncoder; + + @Transactional + public Map register(String authHeader, SignUpReq dto) { + String parsedToken = jwtUtil.resolveToken(authHeader); + String authenticatedPhone = jwtUtil.getPhoneNumberFromToken(parsedToken); + + if (!smsCertificationService.isCorrectCertificationNumber(authenticatedPhone, parsedToken)) + throw new GlobalErrorException(ErrorCode.INVALID_AUTH_CODE); + smsCertificationService.removeCertificationNumber(authenticatedPhone); + + Member requestMember = dto.toEntity(authenticatedPhone); + requestMember.encodePassword(bCryptPasswordEncoder); + log.debug("회원가입 요청: {}", requestMember); + validateMember(requestMember); + + Member registeredMember = memberSaveService.saveMember(requestMember); + log.debug("회원가입 완료: {}", registeredMember); + JwtUserInfo jwtUserInfo = JwtUserInfo.from(registeredMember); + + return generateToken(jwtUserInfo); + } + + @Transactional + public Map login(SignInReq dto) { + Member member = memberSearchService.getMemberByUid(dto.uid()); + if (member.checkPassword(dto.password(), bCryptPasswordEncoder)) + throw new GlobalErrorException(ErrorCode.NOT_MATCH_PASSWORD_ERROR); + + JwtUserInfo jwtUserInfo = JwtUserInfo.from(member); + + return generateToken(jwtUserInfo); + } + + @Transactional + public void logout(String authHeader, String requestRefreshToken) { + String accessToken = jwtUtil.resolveToken(authHeader); + Long userId = jwtUtil.getUserIdFromToken(accessToken); + + refreshTokenService.logout(requestRefreshToken); + forbiddenTokenService.register(accessToken, userId); + } + + @Transactional + public Map refresh(String requestRefreshToken) { + RefreshToken refreshToken = refreshTokenService.refresh(requestRefreshToken); + + Long memberId = refreshToken.getUserId(); + JwtUserInfo dto = JwtUserInfo.from(memberSearchService.getMemberById(memberId)); + String accessToken = jwtUtil.generateAccessToken(dto); + + return Map.of(ACCESS_TOKEN.getValue(), accessToken, REFRESH_TOKEN.getValue(), refreshToken.getToken()); + } + + @Transactional + public SmsRes sendCertificationNumber(SmsReq dto) { + if (memberSearchService.isExistMemberByPhone(dto.to())) + throw new GlobalErrorException(ErrorCode.DUPLICATE_PHONE_ERROR); + String certificationNumber = smsCertificationService.issueCertificationNumber(dto.to()); + + SensRes sensRes; + try { + sensRes = smsService.sendCertificationNumber(dto, certificationNumber); + } catch (Exception e) { + log.error("SMS 발송 실패: {}", e.getMessage()); + smsCertificationService.removeCertificationNumber(dto.to()); + throw new GlobalErrorException(ErrorCode.SMS_SEND_ERROR); + } + + checkSmsStatus(dto.to(), sensRes); + + LocalDateTime expireTime = smsCertificationService.getExpiredTime(dto.to()); + log.info("인증번호 만료 시간: {}", expireTime); + return SmsRes.of(dto.to(), sensRes.requestTime(), expireTime); + } + + @Transactional + public String checkCertificationNumber(String requestPhone, String requestCertificationNumber) { + String token = null; + if (smsCertificationService.isCorrectCertificationNumber(requestPhone, requestCertificationNumber)) { + log.info("인증번호 일치"); + token = smsCertificationService.issueSmsAuthToken(requestPhone); + } + + return (token != null) ? token : ""; + } + + private void checkSmsStatus(String requestPhone, SensRes sensRes) { + if (sensRes.statusCode().equals("404")) { + log.error("존재하지 않는 수신자: {}", requestPhone); + smsCertificationService.removeCertificationNumber(requestPhone); + throw new GlobalErrorException(ErrorCode.INVALID_RECEIVER); + } else if (sensRes.statusName().equals("fail")) { + log.error("SMS API 응답 실패: {}", sensRes); + smsCertificationService.removeCertificationNumber(requestPhone); + throw new GlobalErrorException(ErrorCode.SMS_SEND_ERROR); + } + log.info("SMS 발송 성공"); + } + + // TODO: Query문 하나로 압축 + private void validateMember(Member member) { + if (memberSearchService.isExistMemberByUid(member.getUid())) + throw new GlobalErrorException(ErrorCode.DUPLICATE_NICKNAME_ERROR); + + if (memberSearchService.isExistMemberByEmail(member.getEmail())) + throw new GlobalErrorException(ErrorCode.DUPLICATE_EMAIL_ERROR); + + if (memberSearchService.isExistMemberByPhone(member.getPhone())) + throw new GlobalErrorException(ErrorCode.DUPLICATE_PHONE_ERROR); + } + + private Map generateToken(JwtUserInfo jwtUserInfo) { + String accessToken = jwtUtil.generateAccessToken(jwtUserInfo); + String refreshToken = refreshTokenService.issueRefreshToken(accessToken); + log.debug("accessToken : {}, refreshToken : {}", accessToken, refreshToken); + + return Map.of(ACCESS_TOKEN.getValue(), accessToken, REFRESH_TOKEN.getValue(), refreshToken); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSaveService.java b/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSaveService.java new file mode 100644 index 00000000..98a4874f --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSaveService.java @@ -0,0 +1,16 @@ +package com.kcy.fitapet.domain.member.service.module; + +import com.kcy.fitapet.domain.member.dao.MemberRepository; +import com.kcy.fitapet.domain.member.domain.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberSaveService { + private final MemberRepository memberRepository; + + public Member saveMember(Member member) { + return memberRepository.save(member); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSearchService.java b/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSearchService.java new file mode 100644 index 00000000..92b6866c --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/service/module/MemberSearchService.java @@ -0,0 +1,36 @@ +package com.kcy.fitapet.domain.member.service.module; + +import com.kcy.fitapet.domain.member.dao.MemberRepository; +import com.kcy.fitapet.domain.member.domain.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberSearchService { + private final MemberRepository memberRepository; + + public Member getMemberById(Long id) { + return memberRepository.findById(id).orElseThrow( + () -> new IllegalArgumentException("해당 회원이 존재하지 않습니다.") + ); + } + + public Member getMemberByUid(String uid) { + return memberRepository.findByUid(uid).orElseThrow( + () -> new IllegalArgumentException("해당 회원이 존재하지 않습니다.") + ); + } + + public boolean isExistMemberByUid(String uid) { + return memberRepository.existsByUid(uid); + } + + public boolean isExistMemberByEmail(String email) { + return memberRepository.existsByEmail(email); + } + + public boolean isExistMemberByPhone(String phone) { + return memberRepository.existsByPhone(phone); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/member/service/module/SmsService.java b/src/main/java/com/kcy/fitapet/domain/member/service/module/SmsService.java new file mode 100644 index 00000000..8979cd77 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/member/service/module/SmsService.java @@ -0,0 +1,101 @@ +package com.kcy.fitapet.domain.member.service.module; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kcy.fitapet.domain.member.dto.sms.SensReq; +import com.kcy.fitapet.domain.member.dto.sms.SensRes; +import com.kcy.fitapet.domain.member.dto.sms.SmsReq; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base64; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.UnsupportedEncodingException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.List; + +@Service +@Slf4j +public class SmsService { + private final String accessKey; + private final String secretKey; + private final String serviceId; + private final String phone; + + public SmsService( + @Value("${ncp.api-key}") String accessKey, + @Value("${ncp.secret-key}") String secretKey, + @Value("${ncp.sms.service-key}") String serviceId, + @Value("${ncp.sms.sender-phone}") String phone + ) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.serviceId = serviceId; + this.phone = phone; + } + + public SensRes sendCertificationNumber(SmsReq smsReq, String certificationNumber) + throws JsonProcessingException, RestClientException, InvalidKeyException, NoSuchAlgorithmException, UnsupportedEncodingException { + long now = System.currentTimeMillis(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("x-ncp-apigw-timestamp", String.valueOf(now)); + headers.set("x-ncp-iam-access-key", accessKey); + headers.set("x-ncp-apigw-signature-v2", makeSignature(now)); + + List messages = List.of(smsReq); + + SensReq request = SensReq.of("SMS", "COMM", "82", phone, createAuthCodeMessage(certificationNumber), messages); + + ObjectMapper objectMapper = new ObjectMapper(); + String body = objectMapper.writeValueAsString(request); + HttpEntity httpEntity = new HttpEntity<>(body, headers); + + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setRequestFactory(new HttpComponentsClientHttpRequestFactory()); + + return restTemplate.postForObject("https://sens.apigw.ntruss.com/sms/v2/services/" + serviceId + "/messages", httpEntity, SensRes.class); + } + + private String makeSignature(long now) throws NoSuchAlgorithmException, UnsupportedEncodingException, InvalidKeyException { + String space = " "; + String newLine = "\n"; + String method = "POST"; + String url = "/sms/v2/services/" + this.serviceId + "/messages"; // url (include query string) + String timestamp = String.valueOf(now); + String accessKey = this.accessKey; + String secretKey = this.secretKey; + + String message = new StringBuilder() + .append(method) + .append(space) + .append(url) + .append(newLine) + .append(timestamp) + .append(newLine) + .append(accessKey) + .toString(); + + SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(signingKey); + + byte[] rawHmac = mac.doFinal(message.getBytes("UTF-8")); + + return Base64.encodeBase64String(rawHmac); + } + + private String createAuthCodeMessage(String code) { + return "[Fit a Pet] 인증번호 [" + code + "]를 입력해주세요."; + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/memo/domain/Category.java b/src/main/java/com/kcy/fitapet/domain/memo/domain/Category.java new file mode 100644 index 00000000..206ff3f3 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/memo/domain/Category.java @@ -0,0 +1,42 @@ +package com.kcy.fitapet.domain.memo.domain; + +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "CATEGORY") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"categoryName"}) +public class Category extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "category_name") + private String categoryName; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Category parent; + + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) + private List children = new ArrayList<>(); + + @Builder + private Category(String categoryName, Category parent) { + this.categoryName = categoryName; + this.parent = parent; + } + + public static Category of(String categoryName, Category parent) { + return Category.builder() + .categoryName(categoryName) + .parent(parent) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/memo/domain/Memo.java b/src/main/java/com/kcy/fitapet/domain/memo/domain/Memo.java new file mode 100644 index 00000000..b8b88e6b --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/memo/domain/Memo.java @@ -0,0 +1,49 @@ +package com.kcy.fitapet.domain.memo.domain; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.model.Auditable; +import com.kcy.fitapet.domain.pet.domain.Pet; +import jakarta.persistence.*; +import lombok.*; + +import java.util.List; + +@Entity +@Table(name = "MEMO") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Memo extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pet_id") + private Pet pet; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", updatable = false) + private Member author; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_editor_id") + private Member lastEditor; + @OneToMany(mappedBy = "memo", cascade = CascadeType.ALL) + private List memoImages; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id") + private Category category; + + @Builder + private Memo(String title, String content) { + this.title = title; + this.content = content; + } + + public static Memo of(String title, String content) { + return Memo.builder() + .title(title) + .content(content) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/memo/domain/MemoImage.java b/src/main/java/com/kcy/fitapet/domain/memo/domain/MemoImage.java new file mode 100644 index 00000000..dd45dc75 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/memo/domain/MemoImage.java @@ -0,0 +1,32 @@ +package com.kcy.fitapet.domain.memo.domain; + +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "MEMO_IMAGE") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MemoImage extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "img_url") + private String imgUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "memo_id") + private Memo memo; + + @Builder + private MemoImage(String imgUrl) { + this.imgUrl = imgUrl; + } + + public static MemoImage of(String imgUrl) { + return MemoImage.builder() + .imgUrl(imgUrl).build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/model/Auditable.java b/src/main/java/com/kcy/fitapet/domain/model/Auditable.java new file mode 100644 index 00000000..d71e49c8 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/model/Auditable.java @@ -0,0 +1,24 @@ +package com.kcy.fitapet.domain.model; + +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 +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class Auditable { + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/kcy/fitapet/domain/notification/domain/Notification.java b/src/main/java/com/kcy/fitapet/domain/notification/domain/Notification.java new file mode 100644 index 00000000..01a43c43 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/notification/domain/Notification.java @@ -0,0 +1,40 @@ +package com.kcy.fitapet.domain.notification.domain; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Table(name = "NOTIFICATION") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"title", "content", "ctype", "checked"}) +public class Notification extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private String title; + private String content; + @Convert(converter = NotificationTypeConverter.class) + private NotificationType ctype; + @ColumnDefault("false") + private boolean checked; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Builder + private Notification(String title, String content, NotificationType ctype) { + this.title = title; + this.content = content; + this.ctype = ctype; + } + + public static Notification of(String title, String content, NotificationType ctype) { + return Notification.builder() + .title(title) + .content(content) + .ctype(ctype) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationSetting.java b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationSetting.java new file mode 100644 index 00000000..cd79113e --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationSetting.java @@ -0,0 +1,41 @@ +package com.kcy.fitapet.domain.notification.domain; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Table(name = "NOTIFICATION_SETTING") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"isCare", "isMemo", "isSchedule"}) +public class NotificationSetting extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "is_care") @ColumnDefault("false") + private boolean isCare; + @Column(name = "is_memo") @ColumnDefault("false") + private boolean isMemo; + @Column(name = "is_schedule") @ColumnDefault("false") + private boolean isSchedule; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Builder + private NotificationSetting(boolean isCare, boolean isMemo, boolean isSchedule) { + this.isCare = isCare; + this.isMemo = isMemo; + this.isSchedule = isSchedule; + } + + public static NotificationSetting of(boolean isCare, boolean isMemo, boolean isSchedule) { + return NotificationSetting.builder() + .isCare(isCare) + .isMemo(isMemo) + .isSchedule(isSchedule) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationType.java b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationType.java new file mode 100644 index 00000000..8cbdcf11 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationType.java @@ -0,0 +1,35 @@ +package com.kcy.fitapet.domain.notification.domain; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.kcy.fitapet.global.common.util.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.toMap; + +@RequiredArgsConstructor +public enum NotificationType implements LegacyCommonType { + NOTICE("1", "공지사항"), + CARE("2", "케어활동"), + MEMO("3", "일기"), + SCHEDULE("4", "스케줄"); + + private final String code; + private final String type; + + private static final Map stringToEnum = + Stream.of(values()).collect(toMap(Object::toString, e -> e)); + + @Override + public String getCode() { return code; } + @JsonValue + public String getType() { return type; } + @JsonCreator + public static NotificationType fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + @Override public String toString() { return type; } +} diff --git a/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationTypeConverter.java b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationTypeConverter.java new file mode 100644 index 00000000..2badae26 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/notification/domain/NotificationTypeConverter.java @@ -0,0 +1,13 @@ +package com.kcy.fitapet.domain.notification.domain; + +import com.kcy.fitapet.global.common.util.converter.AbstractLegacyEnumAttributeConverter; +import jakarta.persistence.Converter; + +@Converter +public class NotificationTypeConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "알림타입"; + + public NotificationTypeConverter() { + super(NotificationType.class, false, ENUM_NAME); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/oauthid/domain/OAuthID.java b/src/main/java/com/kcy/fitapet/domain/oauthid/domain/OAuthID.java new file mode 100644 index 00000000..383ba905 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/oauthid/domain/OAuthID.java @@ -0,0 +1,40 @@ +package com.kcy.fitapet.domain.oauthid.domain; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.model.Auditable; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "OAUTH_ID") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"id", "provider"}) +public class OAuthID extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "access_token") + private String accessToken; + private String provider; + @Column(name = "expired_time") + private Long expired_time; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Builder + private OAuthID(String accessToken, String provider, Long expired_time) { + this.accessToken = accessToken; + this.provider = provider; + this.expired_time = expired_time; + } + + public static OAuthID of(String accessToken, String provider, Long expired_time) { + return OAuthID.builder() + .accessToken(accessToken) + .provider(provider) + .expired_time(expired_time) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/pet/domain/Pet.java b/src/main/java/com/kcy/fitapet/domain/pet/domain/Pet.java new file mode 100644 index 00000000..a3463ba5 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/pet/domain/Pet.java @@ -0,0 +1,77 @@ +package com.kcy.fitapet.domain.pet.domain; + +import com.kcy.fitapet.domain.care.domain.Care; +import com.kcy.fitapet.domain.member.domain.Master; +import com.kcy.fitapet.domain.memo.domain.Memo; +import com.kcy.fitapet.domain.model.Auditable; +import com.kcy.fitapet.domain.schedule.domain.Schedule; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "PET") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"petName", "gender", "birth", "species"}) +public class Pet extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "pet_name") + private String petName; + private boolean gender; + @Column(name = "pet_profile_img") + private String petProfileImg; + @ColumnDefault("false") + private boolean neutered; + @Temporal(TemporalType.TIMESTAMP) + private LocalDateTime birth; + private Integer age; + private String species; + private String feed; + + @OneToMany(mappedBy = "pet", cascade = CascadeType.ALL) + private List masters = new ArrayList<>(); + @ManyToMany + @JoinTable(name = "PET_SCHEDULE", + joinColumns = @JoinColumn(name = "pet_id"), + inverseJoinColumns = @JoinColumn(name = "schedule_id")) + private List schedules = new ArrayList<>(); + @ManyToMany + @JoinTable(name = "PET_CARE", + joinColumns = @JoinColumn(name = "pet_id"), + inverseJoinColumns = @JoinColumn(name = "care_id")) + private List cares = new ArrayList<>(); + @OneToMany(mappedBy = "pet", cascade = CascadeType.ALL) + private List memos = new ArrayList<>(); + + @Builder + private Pet(String petName, boolean gender, String petProfileImg, boolean neutered, LocalDateTime birth, + Integer age, String species, String feed) { + this.petName = petName; + this.gender = gender; + this.petProfileImg = petProfileImg; + this.neutered = neutered; + this.birth = birth; + this.age = age; + this.species = species; + this.feed = feed; + } + + public static Pet of(String petName, boolean gender, String petProfileImg, boolean neutered, LocalDateTime birth, + Integer age, String species, String feed) { + return Pet.builder() + .petName(petName) + .gender(gender) + .petProfileImg(petProfileImg) + .neutered(neutered) + .birth(birth) + .age(age) + .species(species) + .feed(feed).build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/domain/schedule/domain/Schedule.java b/src/main/java/com/kcy/fitapet/domain/schedule/domain/Schedule.java new file mode 100644 index 00000000..f26b57a8 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/domain/schedule/domain/Schedule.java @@ -0,0 +1,67 @@ +package com.kcy.fitapet.domain.schedule.domain; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.model.Auditable; +import com.kcy.fitapet.domain.pet.domain.Pet; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "SCHEDULE") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString(of = {"scheduleName", "location", "reservationDt", "notifyDt", "isDone"}) +public class Schedule extends Auditable { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "schedule_name") + private String scheduleName; + private String location; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "reservation_dt") + private LocalDateTime reservationDt; + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "notify_dt") + private LocalDateTime notifyDt; + + @Column(name = "is_done") @ColumnDefault("false") + private boolean isDone; + + @ManyToMany(mappedBy = "schedules") + private List pets = new ArrayList<>(); + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", updatable = false) + private Member author; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "last_editor_id") + private Member lastEditor; + + + @Builder + private Schedule(String scheduleName, String location, LocalDateTime reservationDt, LocalDateTime notifyDt, boolean isDone) { + this.scheduleName = scheduleName; + this.location = location; + this.reservationDt = reservationDt; + this.notifyDt = notifyDt; + this.isDone = isDone; + } + + public static Schedule of(String scheduleName, String location, LocalDateTime reservationDt, LocalDateTime notifyDt, boolean isDone) { + return Schedule.builder() + .scheduleName(scheduleName) + .location(location) + .reservationDt(reservationDt) + .notifyDt(notifyDt) + .isDone(isDone) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/common/response/BasicResponse.java b/src/main/java/com/kcy/fitapet/global/common/response/BasicResponse.java new file mode 100644 index 00000000..a3fca831 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/response/BasicResponse.java @@ -0,0 +1,11 @@ +package com.kcy.fitapet.global.common.response; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface BasicResponse { +} diff --git a/src/main/java/com/kcy/fitapet/global/common/response/ErrorResponse.java b/src/main/java/com/kcy/fitapet/global/common/response/ErrorResponse.java new file mode 100644 index 00000000..ba34e7c0 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/response/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.kcy.fitapet.global.common.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@BasicResponse +@Builder +@Schema(description = "API 응답 - 에러") +public record ErrorResponse( + @Schema(description = "응답 상태", defaultValue = "error") String status, + @Schema(description = "에러 메시지", example = "reason") String message +) { + public static ErrorResponse of(String message) { + return ErrorResponse.builder() + .status("error") + .message(message) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/common/response/FailureResponse.java b/src/main/java/com/kcy/fitapet/global/common/response/FailureResponse.java new file mode 100644 index 00000000..b9c25c8d --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/response/FailureResponse.java @@ -0,0 +1,70 @@ +package com.kcy.fitapet.global.common.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@BasicResponse +@Getter +@Schema(description = "API 응답 - 실패") +public class FailureResponse { + @Schema(description = "응답 상태", defaultValue = "fail") + private final String status = "fail"; + @Schema(description = "응답 데이터", example = "{\"field\":\"reason\"}") + private final Object data; + + @Builder + private FailureResponse(final Object data) { + this.data = data; + } + + /** + * 단일 필드 에러를 응답으로 변환한다. + * @param field : 에러가 발생한 필드 + * @param reason : 에러 이유 + * @return FailureResponse + */ + public static FailureResponse of(String field, String reason) { + return FailureResponse.builder() + .data(Map.of(field, reason)) + .build(); + } + + /** + * BindingResult를 통해 발생한 에러를 응답으로 변환한다. + * @param bindingResult : BindingResult + * @return FailureResponse + */ + public static FailureResponse from(final BindingResult bindingResult) { + Map fieldErrors = new HashMap<>(); + for (FieldError fieldError : bindingResult.getFieldErrors()) { + fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + if (fieldErrors.size() == 1) { + Map.Entry entry = fieldErrors.entrySet().iterator().next(); + return of(entry.getKey(), entry.getValue()); + } else if (fieldErrors.size() > 1) { + return multiFieldError(fieldErrors); + } + return null; + } + + private static > FailureResponse multiFieldError(final T fieldErrors) { + List> fieldErrorsList = new ArrayList<>(); + for (Map.Entry entry : fieldErrors.entrySet()) { + fieldErrorsList.add(Map.of(entry.getKey(), entry.getValue())); + } + return FailureResponse.builder() + .data(fieldErrorsList) + .build(); + } + +} diff --git a/src/main/java/com/kcy/fitapet/global/common/response/SuccessResponse.java b/src/main/java/com/kcy/fitapet/global/common/response/SuccessResponse.java new file mode 100644 index 00000000..c69ab75e --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/response/SuccessResponse.java @@ -0,0 +1,44 @@ +package com.kcy.fitapet.global.common.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * API Response의 success에 대한 공통적인 응답을 정의한다. + * @param + */ +@BasicResponse +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Schema(description = "API 응답 - 성공") +public class SuccessResponse { + @Schema(description = "응답 상태", defaultValue = "success") + private final String status = "success"; + @Schema(description = "응답 코드", example = "200") + private T data; + + @Builder + private SuccessResponse(T data) { + this.data = data; + } + + /** + * 전송할 Application Level Data를 설정한다. + * @param data : 전송할 데이터 + */ + public static SuccessResponse from(T data) { + return SuccessResponse.builder() + .data(data) + .build(); + } + + /** + * 전송할 Application Level Data가 없는 경우 사용한다. + */ + public static SuccessResponse noContent() { + return SuccessResponse.builder().build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/common/response/code/ErrorCode.java b/src/main/java/com/kcy/fitapet/global/common/response/code/ErrorCode.java new file mode 100644 index 00000000..6138edd1 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/response/code/ErrorCode.java @@ -0,0 +1,63 @@ +package com.kcy.fitapet.global.common.response.code; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.*; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum ErrorCode implements StateCode { + /** + * 400 BAD_QUEST: Client 요청이 잘못된 경우 + */ + BAD_REQUEST_ERROR(BAD_REQUEST, "잘못된 서버 요청입니다."), + REQUEST_HEADER_MISS_MATCH(BAD_REQUEST, "유효하지 않은 헤더 정보입니다."), + REQUEST_BODY_MISS_MATCH(BAD_REQUEST, "유효하지 않은 바디 정보입니다."), + INVALID_TYPE_VALUE(BAD_REQUEST, "유효하지 않은 타입 필드가 존재합니다."), + + IO_ERROR(BAD_REQUEST, "유효하지 않은 입출력입니다."), + + MISSING_REQUEST_PARAMETER_ERROR(BAD_REQUEST, "요청 파라미터가 전달되지 않았습니다."), + MISSING_REQUEST_HEADER_ERROR(BAD_REQUEST, "요청 헤더가 전달되지 않았습니다."), + MISSING_REQUEST_BODY_ERROR(BAD_REQUEST, "요청 바디가 전달되지 않았습니다."), + + DUPLICATE_NICKNAME_ERROR(BAD_REQUEST, "중복된 닉네임이 존재합니다."), + DUPLICATE_EMAIL_ERROR(BAD_REQUEST, "중복된 이메일이 존재합니다."), + DUPLICATE_PHONE_ERROR(BAD_REQUEST, "중복된 전화번호가 존재합니다."), + + NOT_MATCH_PASSWORD_ERROR(BAD_REQUEST, "비밀번호가 일치하지 않습니다."), + + EXPIRED_AUTH_CODE(BAD_REQUEST, "인증 시간이 만료되었습니다"), + INVALID_AUTH_CODE(BAD_REQUEST, "유효하지 않은 인증 코드입니다"), + INVALID_RECEIVER(BAD_REQUEST, "유효하지 않은 수신자입니다"), + + /** + * 403 FORBIDDEN: 서버에서 요청을 거부한 경우 + */ + FORBIDDEN_ERROR(FORBIDDEN,"접근 권한이 존재하지 않습니다."), + + /** + * 404 NOT_FOUND: 서버에서 요청한 자원을 찾을 수 없는 경우 + */ + NOT_FOUND_ERROR(NOT_FOUND,"요청한 자원이 존재하지 않습니다."), + NULL_POINT_ERROR(NOT_FOUND,"Null Point Exception"), + NOT_VALID_ERROR(NOT_FOUND,"유효하지 않은 요청입니다."), + NOT_VALID_HEADER_ERROR(NOT_FOUND,"헤더에 데이터가 존재하지 않습니다."), + + /** + * 500 INTERNAL_SERVER_ERROR: 서버에서 에러가 발생한 경우 + */ + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"Internal Server Error Exception"), + SMS_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "SMS 전송에 실패하였습니다"), + + INSERT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"Insert Transaction Error Exception"), + UPDATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"Update Transaction Error Exception"), + DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Delete Transaction Error Exception"), + ; + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/kcy/fitapet/global/common/response/code/StateCode.java b/src/main/java/com/kcy/fitapet/global/common/response/code/StateCode.java new file mode 100644 index 00000000..0cfc08e7 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/response/code/StateCode.java @@ -0,0 +1,8 @@ +package com.kcy.fitapet.global.common.response.code; + +import org.springframework.http.HttpStatus; + +public interface StateCode { + HttpStatus getHttpStatus(); + String getMessage(); +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/response/exception/GlobalErrorException.java b/src/main/java/com/kcy/fitapet/global/common/response/exception/GlobalErrorException.java new file mode 100644 index 00000000..2c2d736a --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/response/exception/GlobalErrorException.java @@ -0,0 +1,20 @@ +package com.kcy.fitapet.global.common.response.exception; + +import com.kcy.fitapet.global.common.response.code.ErrorCode; +import lombok.Getter; + +@Getter +public class GlobalErrorException extends RuntimeException { + private final ErrorCode errorCode; + + public GlobalErrorException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + @Override + public String toString() { + return String.format("GlobalErrorException(code=%s, message=%s)", + errorCode.name(), errorCode.getMessage()); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/common/response/handler/GlobalExceptionHandler.java b/src/main/java/com/kcy/fitapet/global/common/response/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..020a364c --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/response/handler/GlobalExceptionHandler.java @@ -0,0 +1,136 @@ +package com.kcy.fitapet.global.common.response.handler; + +import com.kcy.fitapet.global.common.response.ErrorResponse; +import com.kcy.fitapet.global.common.response.FailureResponse; +import com.kcy.fitapet.global.common.response.code.ErrorCode; +import com.kcy.fitapet.global.common.response.exception.GlobalErrorException; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +/** + * Controller에서 발생하는 예외를 처리하는 클래스 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + /** + * API 호출 시 서버에서 발생시킨 전역 예외를 처리하는 메서드 + * @param e GlobalErrorException + * @return ResponseEntity + */ + @ExceptionHandler(GlobalErrorException.class) + protected ResponseEntity handleGlobalErrorException(GlobalErrorException e) { + log.error("handleGlobalErrorException : {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.of(e.getMessage()); + return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(response); + } + + /** + * API 호출 시 인증 관련 예외를 처리하는 메서드 + * @param e AuthErrorException + * @return ResponseEntity + */ + @ExceptionHandler(AuthErrorException.class) + protected ResponseEntity handleAuthErrorException(AuthErrorException e) { + log.error("handleAuthErrorException : {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.of(e.getErrorCode().getMessage()); + return ResponseEntity.status(e.getErrorCode().getHttpStatus()).body(response); + } + + /** + * API 호출 시 객체 혹은 파라미터 데이터 값이 유효하지 않은 경우 + * @param e MethodArgumentNotValidException + * @return ResponseEntity + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("handleMethodArgumentNotValidException: {}", e.getMessage()); + BindingResult bindingResult = e.getBindingResult(); + final FailureResponse response = FailureResponse.from(bindingResult); + return ResponseEntity.ok().body(response); + } + + /** + * API 호출 시 'Header' 내에 데이터 값이 유효하지 않은 경우 + * @param e MissingRequestHeaderException + * @return ResponseEntity + */ + @ExceptionHandler(MissingRequestHeaderException.class) + protected ResponseEntity handleMissingRequestHeaderException(MissingRequestHeaderException e) { + log.error("handleMissingRequestHeaderException : {}", e.getMessage()); + final FailureResponse response = FailureResponse.of("causedBy", e.getMessage()); + return ResponseEntity.ok().body(response); + } + + /** + * API 호출 시 'BODY' 내에 데이터 값이 존재하지 않은 경우 + * @param e HttpMessageNotReadableException + * @return ResponseEntity + */ + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("handleHttpMessageNotReadableException : {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.of(ErrorCode.MISSING_REQUEST_BODY_ERROR.getMessage()); + return ResponseEntity.badRequest().body(response); + } + + /** + * API 호출 시 'Parameter' 내에 데이터 값이 존재하지 않은 경우 + * @param e MissingServletRequestParameterException + * @return ResponseEntity + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + log.error("handleMissingServletRequestParameterException : {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.of(ErrorCode.MISSING_REQUEST_PARAMETER_ERROR.getMessage()); + return ResponseEntity.badRequest().body(response); + } + + /** + * 잘못된 URL 호출 시 + * @param e NoHandlerFoundException + * @return ResponseEntity + */ + @ExceptionHandler(NoHandlerFoundException.class) + protected ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException e) { + log.error("handleNoHandlerFoundException : {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NOT_FOUND_ERROR.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + /** + * NullPointerException이 발생한 경우 + * @param e NullPointerException + * @return ResponseEntity + */ + @ExceptionHandler(NullPointerException.class) + protected ResponseEntity handleNullPointerException(NullPointerException e) { + log.error("handleNullPointerException : {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.of(ErrorCode.NULL_POINT_ERROR.getMessage()); + return ResponseEntity.internalServerError().body(response); + } + + // ================================================================================== // + + /** + * 기타 예외가 발생한 경우 + * @param e Exception + * @return ResponseEntity + */ + @ExceptionHandler(Exception.class) + protected ResponseEntity handleException(Exception e) { + log.error("handleException : {}", e.getMessage()); + final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR.getMessage()); + return ResponseEntity.internalServerError().body(response); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/common/security/authentication/CustomUserDetails.java b/src/main/java/com/kcy/fitapet/global/common/security/authentication/CustomUserDetails.java new file mode 100644 index 00000000..42fec892 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/security/authentication/CustomUserDetails.java @@ -0,0 +1,89 @@ +package com.kcy.fitapet.global.common.security.authentication; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.member.domain.RoleType; +import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo; +import lombok.Builder; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Arrays; +import java.util.Collection; + +@Getter +public final class CustomUserDetails implements UserDetails { + private Long userId; + private RoleType role; + + // TODO: UserDetails Deserialize 할 때, 필드가 없으면 에러가 나는데, 이를 해결해야 한다. + @JsonIgnore + private boolean enabled; + @JsonIgnore + private boolean password; + @JsonIgnore + private boolean username; + @JsonIgnore + private boolean authorities; + @JsonIgnore + private boolean credentialsNonExpired; + @JsonIgnore + private boolean accountNonExpired; + @JsonIgnore + private boolean accountNonLocked; + + private CustomUserDetails() {} + + @Builder + private CustomUserDetails(Long userId, RoleType role) { + this.userId = userId; + this.role = role; + } + + public static UserDetails of(Member user) { + return new CustomUserDetails(user.getId(), user.getRole()); + } + + public JwtUserInfo toJwtUserInfo() { + return JwtUserInfo.of(userId, role); + } + + @Override + public Collection getAuthorities() { + return Arrays.stream(RoleType.values()) + .filter(roleType -> roleType == role) + .map(roleType -> (GrantedAuthority) roleType::name) + .toList(); + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return userId.toString(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/security/authentication/UserDetailServiceImpl.java b/src/main/java/com/kcy/fitapet/global/common/security/authentication/UserDetailServiceImpl.java new file mode 100644 index 00000000..aa3794d5 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/security/authentication/UserDetailServiceImpl.java @@ -0,0 +1,26 @@ +package com.kcy.fitapet.global.common.security.authentication; + +import com.kcy.fitapet.domain.member.dao.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserDetailServiceImpl implements UserDetailsService { + private final MemberRepository userRepository; + + @Override + @Cacheable(value = "securityUser", key = "#userId", unless = "#result == null") + public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { + log.debug("loadUserByUsername userId : {}", userId); + return userRepository.findById(Long.parseLong(userId)) + .map(CustomUserDetails::of) + .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/security/filter/JwtAuthorizationFilter.java b/src/main/java/com/kcy/fitapet/global/common/security/filter/JwtAuthorizationFilter.java new file mode 100644 index 00000000..7a6303e8 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/security/filter/JwtAuthorizationFilter.java @@ -0,0 +1,152 @@ +package com.kcy.fitapet.global.common.security.filter; + +import com.kcy.fitapet.global.common.security.authentication.UserDetailServiceImpl; +import com.kcy.fitapet.global.common.util.cookie.CookieUtil; +import com.kcy.fitapet.global.common.util.jwt.JwtUtil; +import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorCode; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException; +import com.kcy.fitapet.global.common.util.redis.forbidden.ForbiddenTokenService; +import com.kcy.fitapet.global.common.util.redis.refresh.RefreshToken; +import com.kcy.fitapet.global.common.util.redis.refresh.RefreshTokenService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.regex.Pattern; + +import static com.kcy.fitapet.global.common.util.jwt.AuthConstants.*; + +/** + * 지정한 URL 별로 JWT 유효성 검증을 수행하며, 직접적인 사용자 인증을 확인합니다. + */ +@RequiredArgsConstructor +@Slf4j +public class JwtAuthorizationFilter extends OncePerRequestFilter { + private final UserDetailServiceImpl userDetailServiceImpl; + private final RefreshTokenService refreshTokenService; + private final ForbiddenTokenService forbiddenTokenService; + + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + + private final List jwtIgnoreUrls = List.of( + "/api/v1/members/register", + "/api/v1/members/login", + "/api/v1/members/refresh", + "/api/v1/members/sms/**", + "/v3/api-docs/**", "/swagger-ui/**", "/swagger", + "/favicon.ico" + ); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + if (shouldIgnoreRequest(request)) { + log.info("Ignoring request: {}", request.getRequestURI()); + filterChain.doFilter(request, response); + return; + } + + String accessToken = resolveAccessToken(request, response); + + UserDetails userDetails = getUserDetails(accessToken); + authenticateUser(userDetails, request); + filterChain.doFilter(request, response); + } + + private boolean shouldIgnoreRequest(HttpServletRequest request) { + String uri = request.getRequestURI(); + String method = request.getMethod(); + + boolean isIgnored = jwtIgnoreUrls.stream() + .anyMatch(pattern -> matchesPattern(uri, pattern)); + + return isIgnored || "OPTIONS".equals(method); + } + + private boolean matchesPattern(String uri, String pattern) { + return Pattern.matches(pattern.replace("**", ".*"), uri) || + Pattern.matches(pattern.replace("/**", ""), uri); + } + + private String resolveAccessToken(HttpServletRequest request, HttpServletResponse response) throws ServletException { + String authHeader = request.getHeader(AUTH_HEADER.getValue()); + try { + String token = jwtUtil.resolveToken(authHeader); + + if (forbiddenTokenService.isForbidden(token)) + throw new AuthErrorException(AuthErrorCode.FORBIDDEN_ACCESS_TOKEN, "더 이상 사용할 수 없는 토큰입니다."); + + Date expiryDate = jwtUtil.getExpiryDate(token); + return token; + } catch (AuthErrorException e) { + if (e.getErrorCode() == AuthErrorCode.EXPIRED_ACCESS_TOKEN) { + log.warn("Expired JWT access token: {}", e.getMessage()); + return handleExpiredToken(request, response); + } + log.warn("Invalid JWT access token: {}", e.getMessage()); + throw new ServletException(); + } + } + + private String handleExpiredToken(HttpServletRequest request, HttpServletResponse response) throws ServletException { + ResponseCookie cookie; + try { + cookie = cookieUtil.deleteCookie(request, response, REFRESH_TOKEN.getValue()) + .orElseThrow(() -> new AuthErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND, "Refresh token not found")); + } catch (AuthErrorException e) { + throw new ServletException(e); + } + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + return reissueAccessToken(request, response); + } + + private String reissueAccessToken(HttpServletRequest request, HttpServletResponse response) throws ServletException { + try { + Cookie refreshTokenCookie = cookieUtil.getCookie(request, REFRESH_TOKEN.getValue()) + .orElseThrow(() -> new AuthErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND, "Refresh token not found")); + String requestRefreshToken = refreshTokenCookie.getValue(); + + RefreshToken reissuedRefreshToken = refreshTokenService.refresh(requestRefreshToken); + ResponseCookie cookie = cookieUtil.createCookie(REFRESH_TOKEN.getValue(), reissuedRefreshToken.getToken(), refreshTokenCookie.getMaxAge()); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + JwtUserInfo userInfo = jwtUtil.getUserInfoFromToken(requestRefreshToken); + String reissuedAccessToken = jwtUtil.generateAccessToken(userInfo); + response.addHeader(ACCESS_TOKEN.getValue(), reissuedAccessToken); + return reissuedAccessToken; + } catch (AuthErrorException e) { + throw new ServletException(); + } + } + + private UserDetails getUserDetails(final String accessToken) { + Long userId = jwtUtil.getUserIdFromToken(accessToken); + return userDetailServiceImpl.loadUserByUsername(userId.toString()); + } + + private void authenticateUser(UserDetails userDetails, HttpServletRequest request) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities() + ); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("Authenticated user: {}", userDetails.getUsername()); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/security/filter/JwtExceptionFilter.java b/src/main/java/com/kcy/fitapet/global/common/security/filter/JwtExceptionFilter.java new file mode 100644 index 00000000..0ad4ae9e --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/security/filter/JwtExceptionFilter.java @@ -0,0 +1,54 @@ +package com.kcy.fitapet.global.common.security.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + +@Slf4j +@Component +public class JwtExceptionFilter extends OncePerRequestFilter { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (Exception e) { + if (e.getCause() instanceof AuthErrorException) { + log.error("AuthErrorException caught in JwtExceptionFilter: {}", e.getCause().getMessage()); + sendAuthError(response, (AuthErrorException) e.getCause()); + return; + } + log.error("Exception caught in JwtExceptionFilter: {}", e.getMessage()); + e.printStackTrace(); + sendError(response, e); + } + } + + private void sendAuthError(HttpServletResponse response, AuthErrorException e) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(e.getErrorCode().getHttpStatus().value()); + + AuthErrorResponse errorResponse = new AuthErrorResponse(e.getErrorCode().name(), e.getErrorCode().getMessage()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } + + private void sendError(HttpServletResponse response, Exception e) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + + AuthErrorResponse errorResponse = new AuthErrorResponse(INTERNAL_SERVER_ERROR.getReasonPhrase(), e.getMessage()); + objectMapper.writeValue(response.getWriter(), errorResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/security/handler/JwtAccessDeniedHandler.java b/src/main/java/com/kcy/fitapet/global/common/security/handler/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..aabcd326 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/security/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,24 @@ +package com.kcy.fitapet.global.common.security.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 유저 정보는 있으나 자원에 접근할 수 있는 권한이 없는 경우 : 403 Forbidden + */ +@Component +@Slf4j +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.error("handle error: {}", accessDeniedException.getMessage()); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/security/handler/JwtAuthenticationEntryPoint.java b/src/main/java/com/kcy/fitapet/global/common/security/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..40647658 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/security/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,24 @@ +package com.kcy.fitapet.global.common.security.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 유저 정보가 없는 경우 : 401 Unauthorized + */ +@Component +@Slf4j +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + log.error("commence error: {}", authException.getMessage()); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/converter/AbstractLegacyEnumAttributeConverter.java b/src/main/java/com/kcy/fitapet/global/common/util/converter/AbstractLegacyEnumAttributeConverter.java new file mode 100644 index 00000000..90c03181 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/converter/AbstractLegacyEnumAttributeConverter.java @@ -0,0 +1,47 @@ +package com.kcy.fitapet.global.common.util.converter; + +import jakarta.persistence.AttributeConverter; +import lombok.Getter; +import org.springframework.util.StringUtils; + +@Getter +public class AbstractLegacyEnumAttributeConverter & LegacyCommonType> implements AttributeConverter { + /** + * 대상 Enum 클래스 {@link Class} 객체 + */ + private final Class targetEnumClass; + + /** + * nullable = false면, 변환할 값이 null로 들어왔을 때 예외를 발생시킨다.
+ * nullable = true면, 변환할 값이 null로 들어왔을 때 예외 없이 실행하며,
+ * legacy code로 변환 시엔 빈 문자열("")로 변환한다. + */ + private final boolean nullable; + + /** + * nullable = false일 때 출력할 오류 메시지에서 enum에 대한 설명을 위해 Enum의 설명적 이름을 받는다. + */ + private final String enumName; + + public AbstractLegacyEnumAttributeConverter(Class targetEnumClass, boolean nullable, String enumName) { + this.targetEnumClass = targetEnumClass; + this.nullable = nullable; + this.enumName = enumName; + } + + @Override + public String convertToDatabaseColumn(E attribute) { + if (!nullable && attribute == null) { + throw new IllegalArgumentException(String.format("%s을(를) null로 변환할 수 없습니다.", enumName)); + } + return LegacyEnumValueConvertUtils.toLegacyCode(attribute); + } + + @Override + public E convertToEntityAttribute(String dbData) { + if (!nullable && !StringUtils.hasText(dbData)) { + throw new IllegalArgumentException(String.format("%s(이)가 DB에 null 혹은 Empty로(%s) 저장되어 있습니다.", enumName, dbData)); + } + return LegacyEnumValueConvertUtils.ofLegacyCode(targetEnumClass, dbData); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/common/util/converter/LegacyCommonType.java b/src/main/java/com/kcy/fitapet/global/common/util/converter/LegacyCommonType.java new file mode 100644 index 00000000..2c0e4099 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/converter/LegacyCommonType.java @@ -0,0 +1,9 @@ +package com.kcy.fitapet.global.common.util.converter; + +public interface LegacyCommonType { + /** + * Legacy Super System 공통 코드를 반환한다. + * @return String 공통 코드 + */ + String getCode(); +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/converter/LegacyEnumValueConvertUtils.java b/src/main/java/com/kcy/fitapet/global/common/util/converter/LegacyEnumValueConvertUtils.java new file mode 100644 index 00000000..7f9c910f --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/converter/LegacyEnumValueConvertUtils.java @@ -0,0 +1,27 @@ +package com.kcy.fitapet.global.common.util.converter; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +import java.util.EnumSet; + +/** + * {@link LegacyCommonType} enum을 String과 상호 변환하는 유틸리티 클래스 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LegacyEnumValueConvertUtils { + public static & LegacyCommonType> T ofLegacyCode(Class enumClass, String code) { + if (!StringUtils.hasText(code)) return null; + return EnumSet.allOf(enumClass).stream() + .filter(e -> e.getCode().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + String.format("enum=[%s], code=[%s]가 존재하지 않습니다.", enumClass.getName(), code))); // TODO : 공통 예외로 변경 + } + + public static & LegacyCommonType> String toLegacyCode(T enumValue) { + if (enumValue == null) return ""; + return enumValue.getCode(); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/cookie/CookieUtil.java b/src/main/java/com/kcy/fitapet/global/common/util/cookie/CookieUtil.java new file mode 100644 index 00000000..ac6d09d2 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/cookie/CookieUtil.java @@ -0,0 +1,66 @@ +package com.kcy.fitapet.global.common.util.cookie; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Optional; + +@Component +public class CookieUtil { + /** + * request에서 cookieName에 해당하는 쿠키를 찾아서 반환합니다. + * @param request HttpServletRequest : 쿠키를 찾을 request + * @param cookieName String : 찾을 쿠키의 이름 + * @return Optional : 쿠키가 존재하면 해당 쿠키를, 존재하지 않으면 Optional.empty()를 반환합니다. + */ + public Optional getCookie(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + + return Arrays.stream(cookies) + .filter(cookie -> cookieName.equals(cookie.getName())) + .findAny(); + } + + /** + * cookieName에 해당하는 쿠키를 생성합니다. + * @param cookieName String : 생성할 쿠키의 이름 + * @param value String : 생성할 쿠키의 값 + * @param maxAge int : 생성할 쿠키의 만료 시간 + * @return ResponseCookie : 생성된 쿠키 + */ + public ResponseCookie createCookie(String cookieName, String value, int maxAge) { + return ResponseCookie.from(cookieName, value) + .path("/") + .httpOnly(true) + .maxAge(maxAge) + .secure(true) + .sameSite("None") + .build(); + } + + /** + * cookieName에 해당하는 쿠키를 제거합니다. + * @param request HttpServletRequest : 쿠키를 제거할 request + * @param response HttpServletResponse : 쿠키를 제거할 response + * @param cookieName String : 제거할 쿠키의 이름 + * @return Optional : 쿠키가 존재하면 제거된 쿠키를, 존재하지 않으면 Optional.empty()를 반환합니다. + */ + public Optional deleteCookie(HttpServletRequest request, HttpServletResponse response, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Optional.empty(); + } + + return Arrays.stream(cookies) + .filter(cookie -> cookieName.equals(cookie.getName())) + .findAny() + .map(cookie -> createCookie(cookieName, "", 0)); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/common/util/jwt/AuthConstants.java b/src/main/java/com/kcy/fitapet/global/common/util/jwt/AuthConstants.java new file mode 100644 index 00000000..4b825a00 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/jwt/AuthConstants.java @@ -0,0 +1,19 @@ +package com.kcy.fitapet.global.common.util.jwt; + +import lombok.Getter; + +@Getter +public enum AuthConstants { + AUTH_HEADER("Authorization"), TOKEN_TYPE("Bearer "), + ACCESS_TOKEN("accessToken"), REFRESH_TOKEN("refreshToken"); + + private final String value; + + AuthConstants(String value) { + this.value = value; + } + + @Override public String toString() { + return String.format("AuthConstants(value=%s)", this.value); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/jwt/JwtUtil.java b/src/main/java/com/kcy/fitapet/global/common/util/jwt/JwtUtil.java new file mode 100644 index 00000000..bfbfc456 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/jwt/JwtUtil.java @@ -0,0 +1,70 @@ +package com.kcy.fitapet.global.common.util.jwt; + +import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException; + +import java.util.Date; + +public interface JwtUtil { + /** + * 헤더로부터 토큰을 추출하고 유효성을 검사하는 메서드 + * @param authHeader : 메시지 헤더 + * @return String : 토큰 + * @throws AuthErrorException : 토큰이 유효하지 않을 경우 + */ + String resolveToken(String authHeader) throws AuthErrorException; + + /** + * 사용자 정보 기반으로 액세스 토큰을 생성하는 메서드 + * @param user JwtUserInfo : 사용자 정보 + * @return String : 토큰 + */ + String generateAccessToken(JwtUserInfo user); + + /** + * 사용자 정보 기반으로 리프레시 토큰을 생성하는 메서드 + * @param user JwtUserInfo : 사용자 정보 + * @return String : 토큰 + */ + String generateRefreshToken(JwtUserInfo user); + + /** + * 사용자 정보 기반으로 SMS 인증 토큰을 생성하는 메서드 + * @param phoneNumber String : 수신자 번호 + * @return String : 토큰 + */ + String generateSmsAuthToken(String phoneNumber); + + /** + * token으로 부터 사용자 정보를 추출하는 메서드 + * @param token String : 토큰 + * @return UserAuthenticateReq : 사용자 정보 + * @throws AuthErrorException : 토큰이 유효하지 않을 경우 + */ + JwtUserInfo getUserInfoFromToken(String token) throws AuthErrorException; + + /** + * 토큰으로 부터 유저 아이디를 추출하는 메서드 + * @param token String : 토큰 + * @return Long : 유저 아이디 + * @throws AuthErrorException : 토큰이 유효하지 않을 경우 + */ + Long getUserIdFromToken(String token) throws AuthErrorException; + + /** + * 토큰으로 부터 유저의 전화번호를 추출하는 메서드 + * [WARNING] : 오직 회원가입 시 발급되는 토큰에서만 사용 가능 + * @param token String : 토큰 + * @return String : 유저 전화번호 + * @throws AuthErrorException : 토큰이 유효하지 않을 경우 + */ + String getPhoneNumberFromToken(String token) throws AuthErrorException; + + /** + * 토큰의 만료일을 추출하는 메서드 + * @param token String : 토큰 + * @return Date : 만료일 + * @throws AuthErrorException : 토큰이 유효하지 않을 경우 + */ + Date getExpiryDate(String token) throws AuthErrorException; +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/jwt/JwtUtilImpl.java b/src/main/java/com/kcy/fitapet/global/common/util/jwt/JwtUtilImpl.java new file mode 100644 index 00000000..a5bc83a0 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/jwt/JwtUtilImpl.java @@ -0,0 +1,181 @@ +package com.kcy.fitapet.global.common.util.jwt; + +import com.kcy.fitapet.domain.member.domain.RoleType; +import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorCode; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException; +import io.jsonwebtoken.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.time.Duration; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +/** + * JWT 토큰 생성 및 검증을 담당하는 클래스 + */ +@Slf4j +@Component +public class JwtUtilImpl implements JwtUtil { + private static final String USER_ID = "userId"; + private static final String ROLE = "role"; + private static final String PHONE_NUMBER = "phoneNumber"; + + private final String jwtSecretKey; + private final Duration accessTokenExpirationTime; + private final Duration refreshTokenExpirationTime; + + public JwtUtilImpl( + @Value("${jwt.secret}") String jwtSecretKey, + @Value("${jwt.token.access-expiration-time}") Duration accessTokenExpirationTime, + @Value("${jwt.token.refresh-expiration-time}") Duration refreshTokenExpirationTime + ) { + this.jwtSecretKey = jwtSecretKey; + this.accessTokenExpirationTime = accessTokenExpirationTime; + this.refreshTokenExpirationTime = refreshTokenExpirationTime; + } + + @Override + public String resolveToken(String authHeader) throws AuthErrorException { + if (StringUtils.hasText(authHeader) && authHeader.startsWith(AuthConstants.TOKEN_TYPE.getValue())) { + return authHeader.substring(AuthConstants.TOKEN_TYPE.getValue().length()); + } + throw new AuthErrorException(AuthErrorCode.EMPTY_ACCESS_TOKEN, "Access Token is empty."); + } + + + @Override + @SuppressWarnings("deprecation") + public String generateAccessToken(JwtUserInfo user) { + final Date now = new Date(); + + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(createClaims(user)) + .signWith(SignatureAlgorithm.HS256, createSignature()) + .setExpiration(createExpireDate(now, accessTokenExpirationTime.toMillis())) + .compact(); + } + + @Override + @SuppressWarnings("deprecation") + public String generateRefreshToken(JwtUserInfo user) { + final Date now = new Date(); + + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(createClaims(user)) + .signWith(SignatureAlgorithm.HS256, createSignature()) + .setExpiration(createExpireDate(now, refreshTokenExpirationTime.toMillis())) + .compact(); + } + + @Override + @SuppressWarnings("deprecation") + public String generateSmsAuthToken(String phoneNumber) { + final Date now = new Date(); + + return Jwts.builder() + .setHeader(createHeader()) + .setClaims(Map.of(PHONE_NUMBER, phoneNumber)) + .signWith(SignatureAlgorithm.HS256, createSignature()) + .setExpiration(createExpireDate(now, 1000 * 60 * 3)) + .compact(); + } + + @Override + public JwtUserInfo getUserInfoFromToken(String token) throws AuthErrorException { + Claims claims = verifyAndGetClaims(token); + return JwtUserInfo.builder() + .id(claims.get(USER_ID, Long.class)) + .role(RoleType.fromString(claims.get(ROLE, String.class))) + .build(); + } + + @Override + public Long getUserIdFromToken(String token) throws AuthErrorException { + Claims claims = verifyAndGetClaims(token); + return claims.get(USER_ID, Long.class); + } + + @Override + public String getPhoneNumberFromToken(String token) throws AuthErrorException { + Claims claims = verifyAndGetClaims(token); + return claims.get(PHONE_NUMBER, String.class); + } + + @Override + public Date getExpiryDate(String token) { + Claims claims = verifyAndGetClaims(token); + return claims.getExpiration(); + } + + private Claims verifyAndGetClaims(final String accessToken) throws AuthErrorException { + try { + return getClaimsFromToken(accessToken); + } catch (JwtException e) { + handleJwtException(e); + } catch (Exception e) { + log.error("토큰 검증에 실패했습니다. : {}", e.getMessage()); + } + throw new AuthErrorException(AuthErrorCode.WRONG_JWT_TOKEN, "토큰 검증에 실패했습니다."); + } + + private void handleJwtException(JwtException e) { + AuthErrorCode errorCode; + String causedBy; + if (e instanceof ExpiredJwtException) { + errorCode = AuthErrorCode.EXPIRED_ACCESS_TOKEN; + causedBy = e.toString(); + } else if (e instanceof MalformedJwtException) { + errorCode = AuthErrorCode.MALFORMED_ACCESS_TOKEN; + causedBy = e.toString(); + } else if (e instanceof SignatureException) { + errorCode = AuthErrorCode.TAMPERED_ACCESS_TOKEN; + causedBy = e.toString(); + } else if (e instanceof UnsupportedJwtException) { + errorCode = AuthErrorCode.WRONG_JWT_TOKEN; + causedBy = e.toString(); + } else { + errorCode = AuthErrorCode.EMPTY_ACCESS_TOKEN; + causedBy = e.toString(); + } + + log.warn(causedBy); + throw new AuthErrorException(errorCode, causedBy); + } + + private Map createHeader() { + return Map.of("typ", "JWT", + "alg", "HS256", + "regDate", System.currentTimeMillis()); + } + + private Map createClaims(JwtUserInfo dto) { + return Map.of(USER_ID, dto.id(), + ROLE, dto.role().getRole()); + } + + private Key createSignature() { + byte[] secretKeyBytes = Base64.getDecoder().decode(jwtSecretKey); + return new SecretKeySpec(secretKeyBytes, SignatureAlgorithm.HS256.getJcaName()); + } + + private Date createExpireDate(final Date now, long expirationTime) { + return new Date(now.getTime() + expirationTime); + } + + @SuppressWarnings("deprecation") + private Claims getClaimsFromToken(String token) { + return Jwts.parser() + .setSigningKey(Base64.getDecoder().decode(jwtSecretKey)) + .parseClaimsJws(token) + .getBody(); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/jwt/entity/JwtUserInfo.java b/src/main/java/com/kcy/fitapet/global/common/util/jwt/entity/JwtUserInfo.java new file mode 100644 index 00000000..08f8537c --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/jwt/entity/JwtUserInfo.java @@ -0,0 +1,20 @@ +package com.kcy.fitapet.global.common.util.jwt.entity; + +import com.kcy.fitapet.domain.member.domain.Member; +import com.kcy.fitapet.domain.member.domain.RoleType; +import lombok.Builder; +import lombok.ToString; + +@Builder +public record JwtUserInfo( + Long id, + RoleType role +) { + public static JwtUserInfo of(Long id, RoleType role) { + return new JwtUserInfo(id, role); + } + + public static JwtUserInfo from(Member member) { + return new JwtUserInfo(member.getId(), member.getRole()); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorCode.java b/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorCode.java new file mode 100644 index 00000000..8a6b5f13 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorCode.java @@ -0,0 +1,38 @@ +package com.kcy.fitapet.global.common.util.jwt.exception; + +import com.kcy.fitapet.global.common.response.code.StateCode; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +import static org.springframework.http.HttpStatus.*; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum AuthErrorCode implements StateCode { + /** + * 400 BAD_REQUEST: 클라이언트의 요청이 부적절 할 경우 + */ + INVALID_HEADER(BAD_REQUEST, "유효하지 않은 헤더 포맷입니다"), + EMPTY_ACCESS_TOKEN(BAD_REQUEST, "토큰이 비어있습니다"), + + /** + * 401 UNAUTHORIZED: 인증되지 않은 사용자 + */ + TAMPERED_ACCESS_TOKEN(UNAUTHORIZED, "서명이 조작된 토큰입니다"), + EXPIRED_ACCESS_TOKEN(UNAUTHORIZED, "사용기간이 만료된 토큰입니다"), + MALFORMED_ACCESS_TOKEN(UNAUTHORIZED, "비정상적인 토큰입니다"), + WRONG_JWT_TOKEN(UNAUTHORIZED, "잘못된 토큰입니다(default)"), + REFRESH_TOKEN_NOT_FOUND(UNAUTHORIZED, "없거나 삭제된 리프래시 토큰입니다."), + USER_NOT_FOUND(UNAUTHORIZED, "존재하지 않는 유저입니다"), + + /** + * 403 FORBIDDEN: 인증된 클라이언트가 권한이 없는 자원에 접근 + */ + FORBIDDEN_ACCESS_TOKEN(FORBIDDEN, "해당 토큰에는 엑세스 권한이 없습니다"), + MISMATCHED_REFRESH_TOKEN(FORBIDDEN, "리프레시 토큰의 유저 정보가 일치하지 않습니다"); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorException.java b/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorException.java new file mode 100644 index 00000000..a4716d1e --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorException.java @@ -0,0 +1,23 @@ +package com.kcy.fitapet.global.common.util.jwt.exception; + +import io.jsonwebtoken.JwtException; +import lombok.Getter; + +@Getter +public class AuthErrorException extends JwtException { + private final AuthErrorCode errorCode; + private final String causedBy; + + public AuthErrorException(AuthErrorCode errorCode, String causedBy) { + super(String.format("AuthErrorException(code=%s, message=%s, causedBy=%s)", + errorCode.name(), errorCode.getMessage(), causedBy)); + this.errorCode = errorCode; + this.causedBy = causedBy; + } + + @Override + public String toString() { + return String.format("AuthErrorException(code=%s, message=%s, causedBy=%s)", + errorCode.name(), errorCode.getMessage(), causedBy); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorResponse.java b/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorResponse.java new file mode 100644 index 00000000..5becf854 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/jwt/exception/AuthErrorResponse.java @@ -0,0 +1,7 @@ +package com.kcy.fitapet.global.common.util.jwt.exception; + +public record AuthErrorResponse(String code, String message) { + @Override public String toString() { + return String.format("AuthErrorResponse(code=%s, message=%s)", code, message); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenToken.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenToken.java new file mode 100644 index 00000000..da048c09 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenToken.java @@ -0,0 +1,40 @@ +package com.kcy.fitapet.global.common.util.redis.forbidden; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash("forbiddenToken") +public class ForbiddenToken { + @Id + private final String accessToken; + private final Long userId; + @TimeToLive + private final long ttl; + + @Builder + private ForbiddenToken(String accessToken, Long userId, long ttl) { + this.accessToken = accessToken; + this.userId = userId; + this.ttl = ttl; + } + + public static ForbiddenToken of(String accessToken, Long userId, long ttl) { + return new ForbiddenToken(accessToken, userId, ttl); + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ForbiddenToken that)) return false; + return accessToken.equals(that.accessToken) && userId.equals(that.userId); + } + + @Override public int hashCode() { + int result = accessToken.hashCode(); + result = ((1 << 5) - 1) * result + userId.hashCode(); + return result; + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenTokenRepository.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenTokenRepository.java new file mode 100644 index 00000000..ce38e008 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenTokenRepository.java @@ -0,0 +1,6 @@ +package com.kcy.fitapet.global.common.util.redis.forbidden; + +import org.springframework.data.repository.CrudRepository; + +public interface ForbiddenTokenRepository extends CrudRepository { +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenTokenService.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenTokenService.java new file mode 100644 index 00000000..d086d30f --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/forbidden/ForbiddenTokenService.java @@ -0,0 +1,44 @@ +package com.kcy.fitapet.global.common.util.redis.forbidden; + +import com.kcy.fitapet.global.common.util.jwt.JwtUtil; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Date; + +@Slf4j +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@Service +public class ForbiddenTokenService { + private final ForbiddenTokenRepository forbiddenTokenRepository; + private final JwtUtil jwtUtil; + + /** + * 토큰을 블랙 리스트에 등록합니다. + * @param accessToken : 블랙 리스트에 등록할 토큰 + * @param userId : 블랙 리스트에 등록할 사용자 ID + */ + public void register(String accessToken, Long userId) { + final Date now = new Date(); + final Date expireDate = jwtUtil.getExpiryDate(accessToken); + + final long expireTime = (expireDate.getTime() - now.getTime()) / 1000; + log.info("expire time : {}", expireTime); + + ForbiddenToken forbiddenToken = ForbiddenToken.of(accessToken, userId, expireTime); + + forbiddenTokenRepository.save(forbiddenToken); + log.info("forbidden token registered. about Token : {}", accessToken); + } + + /** + * 토큰이 블랙 리스트에 등록되어 있는지 확인합니다. + * @param accessToken : 확인할 토큰 + * @return : 블랙 리스트에 등록되어 있으면 true, 아니면 false + */ + public boolean isForbidden(String accessToken) { + return forbiddenTokenRepository.existsById(accessToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshToken.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshToken.java new file mode 100644 index 00000000..5eb2469c --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshToken.java @@ -0,0 +1,30 @@ +package com.kcy.fitapet.global.common.util.redis.refresh; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@RedisHash("refreshToken") +@Getter +@ToString(of = {"userId", "token"}) +public class RefreshToken { + @Id + private final Long userId; + private String token; + @TimeToLive + private final long ttl; + + @Builder + private RefreshToken(String token, Long userId, long ttl) { + this.token = token; + this.userId = userId; + this.ttl = ttl; + } + + protected void rotation(String token) { + this.token = token; + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenRepository.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenRepository.java new file mode 100644 index 00000000..8e85e84c --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.kcy.fitapet.global.common.util.redis.refresh; + +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenService.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenService.java new file mode 100644 index 00000000..e068acb9 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenService.java @@ -0,0 +1,27 @@ +package com.kcy.fitapet.global.common.util.redis.refresh; + +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException; + +public interface RefreshTokenService { + /** + * access token을 받아서 refresh token을 발행 + * @param accessToken : JwtUserInfo + * @return String : Refresh Token + * @throws AuthErrorException : 토큰이 유효하지 않을 경우 + */ + String issueRefreshToken(String accessToken) throws AuthErrorException; + + /** + * refresh token을 받아서 refresh token을 재발행 + * @param requestRefreshToken : String + * @return RefreshToken + * @throws AuthErrorException : 토큰이 유효하지 않을 경우(REFRESH_TOKEN_EXPIRED), 토큰이 탈취당한 경우(REFRESH_TOKEN_MISMATCH) + */ + RefreshToken refresh(String requestRefreshToken) throws AuthErrorException; + + /** + * access token 으로 refresh token을 찾아서 제거 (로그아웃) + * @param requestRefreshToken : String + */ + void logout(String requestRefreshToken); +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenServiceImpl.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenServiceImpl.java new file mode 100644 index 00000000..6f4d2d15 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/refresh/RefreshTokenServiceImpl.java @@ -0,0 +1,99 @@ +package com.kcy.fitapet.global.common.util.redis.refresh; + +import com.kcy.fitapet.global.common.util.jwt.JwtUtil; +import com.kcy.fitapet.global.common.util.jwt.entity.JwtUserInfo; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorCode; +import com.kcy.fitapet.global.common.util.jwt.exception.AuthErrorException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Slf4j +@Service +public class RefreshTokenServiceImpl implements RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + private final JwtUtil jwtUtil; + private final Duration refreshTokenExpireTime; + + public RefreshTokenServiceImpl( + RefreshTokenRepository refreshTokenRepository, + JwtUtil jwtUtil, + @Value("${jwt.token.refresh-expiration-time}") Duration refreshTokenExpireTime) + { + this.refreshTokenRepository = refreshTokenRepository; + this.jwtUtil = jwtUtil; + this.refreshTokenExpireTime = refreshTokenExpireTime; + } + + @Override + public String issueRefreshToken(String accessToken) throws AuthErrorException { + final var user = jwtUtil.getUserInfoFromToken(accessToken); + + final var refreshToken = RefreshToken.builder() + .userId(user.id()) + .token(makeRefreshToken(user)) + .ttl(getExpireTime()) + .build(); + + refreshTokenRepository.save(refreshToken); + log.debug("refresh token issued. : {}", refreshToken); + return refreshToken.getToken(); + } + + @Override + public RefreshToken refresh(String requestRefreshToken) throws AuthErrorException { + final Long userId = jwtUtil.getUserIdFromToken(requestRefreshToken); + final RefreshToken refreshToken = findOrThrow(userId); + + validateToken(requestRefreshToken, refreshToken); + + final var user = jwtUtil.getUserInfoFromToken(requestRefreshToken); + refreshToken.rotation(makeRefreshToken(user)); + // TODO: TTL이 유지가 안 될 가능성 존재. TTL 필드 말고, Redis Template의 expire 메서드 사용하는 게 정확할 듯 + refreshTokenRepository.save(refreshToken); + + log.debug("refresh token reissued. : {}", refreshToken); + return refreshToken; + } + + @Override + public void logout(String requestRefreshToken) { + final Long userId = jwtUtil.getUserIdFromToken(requestRefreshToken); + final RefreshToken refreshToken = findOrThrow(userId); + + refreshTokenRepository.delete(refreshToken); + log.info("refresh token deleted. : {}", refreshToken); + } + + private String makeRefreshToken(JwtUserInfo user) { + return jwtUtil.generateRefreshToken(user); + } + + private long getExpireTime() { + return refreshTokenExpireTime.toMillis() / 1000; + } + + private RefreshToken findOrThrow(Long userId) { + return refreshTokenRepository.findById(userId) + .orElseThrow(() -> new AuthErrorException(AuthErrorCode.REFRESH_TOKEN_NOT_FOUND, "can't find refresh token")); + } + + private void validateToken(String requestRefreshToken, RefreshToken refreshToken) throws AuthErrorException { + final String expectedRequestRefreshToken = refreshToken.getToken(); + + if (isTakenAway(requestRefreshToken, expectedRequestRefreshToken)) { + refreshTokenRepository.delete(refreshToken); + + final String errorMessage = String.format("mismatched refresh token. expected : %s, actual : %s", requestRefreshToken, expectedRequestRefreshToken); + log.warn(errorMessage); + log.info("refresh token deleted. : {}", refreshToken); + throw new AuthErrorException(AuthErrorCode.MISMATCHED_REFRESH_TOKEN, errorMessage); + } + } + + private boolean isTakenAway(String requestRefreshToken, String expectedRequestRefreshToken) { + return !requestRefreshToken.equals(expectedRequestRefreshToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertification.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertification.java new file mode 100644 index 00000000..5aab2dbf --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertification.java @@ -0,0 +1,27 @@ +package com.kcy.fitapet.global.common.util.redis.sms; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@RedisHash(value = "smsCertification", timeToLive = 180) +@Getter +public class SmsCertification { + @Id + private final String phoneNumber; + private final String certificationNumber; + + @Builder + public SmsCertification(String phoneNumber, String certificationNumber) { + this.phoneNumber = phoneNumber; + this.certificationNumber = certificationNumber; + } + + public static SmsCertification of(String phoneNumber, String certificationNumber) { + return SmsCertification.builder() + .phoneNumber(phoneNumber) + .certificationNumber(certificationNumber) + .build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationRepository.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationRepository.java new file mode 100644 index 00000000..56dd3515 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationRepository.java @@ -0,0 +1,7 @@ +package com.kcy.fitapet.global.common.util.redis.sms; + +import org.springframework.data.repository.CrudRepository; + +public interface SmsCertificationRepository extends CrudRepository { + +} diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationService.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationService.java new file mode 100644 index 00000000..aea76224 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationService.java @@ -0,0 +1,40 @@ +package com.kcy.fitapet.global.common.util.redis.sms; + +import java.time.LocalDateTime; + +public interface SmsCertificationService { + /** + * 인증번호 발행 + * @param phoneNumber : String + * @return String : 인증번호 + */ + String issueCertificationNumber(String phoneNumber); + + /** + * SMS 인증 완료 후 계정 생성을 위한 토큰 발행 + * @param phoneNumber : String + * @return String : 토큰 + */ + String issueSmsAuthToken(String phoneNumber); + + /** + * 인증번호 확인 + * @param phoneNumber : String + * @param certificationNumber : String + * @return boolean : 인증번호 일치 여부 + */ + boolean isCorrectCertificationNumber(String phoneNumber, String certificationNumber); + + /** + * 인증번호 제거 + * @param phoneNumber : String + */ + void removeCertificationNumber(String phoneNumber); + + /** + * 인증번호 만료 시간 조회 + * @param phoneNumber : String + * @return LocalDateTime : 인증번호 만료 시간 + */ + LocalDateTime getExpiredTime(String phoneNumber); +} diff --git a/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationServiceImpl.java b/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationServiceImpl.java new file mode 100644 index 00000000..9a9828c8 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/common/util/redis/sms/SmsCertificationServiceImpl.java @@ -0,0 +1,68 @@ +package com.kcy.fitapet.global.common.util.redis.sms; + +import com.kcy.fitapet.global.common.response.code.ErrorCode; +import com.kcy.fitapet.global.common.response.exception.GlobalErrorException; +import com.kcy.fitapet.global.common.util.jwt.JwtUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.concurrent.ThreadLocalRandom; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsCertificationServiceImpl implements SmsCertificationService { + private final SmsCertificationRepository smsCertificationRepository; + private final RedisTemplate redisTemplate; + + private final JwtUtil jwtUtil; + + @Override + public String issueCertificationNumber(String phoneNumber) { + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < 6; i++) { + sb.append(ThreadLocalRandom.current().nextInt(0, 10)); + } + String code = sb.toString(); + + SmsCertification smsCertification = SmsCertification.of(phoneNumber, code); + smsCertificationRepository.save(smsCertification); + + return code; + } + + @Override + public String issueSmsAuthToken(String phoneNumber) { + String token = jwtUtil.generateSmsAuthToken(phoneNumber); + smsCertificationRepository.save(SmsCertification.of(phoneNumber, token)); + return token; + } + + @Override + public boolean isCorrectCertificationNumber(String phoneNumber, String requestCertificationNumber) { + SmsCertification smsCertification = smsCertificationRepository.findById(phoneNumber) + .orElseThrow(() -> new GlobalErrorException(ErrorCode.EXPIRED_AUTH_CODE)); + + return smsCertification.getCertificationNumber().equals(requestCertificationNumber); + } + + @Override + public void removeCertificationNumber(String phoneNumber) { + smsCertificationRepository.deleteById(phoneNumber); + } + + @Override + public LocalDateTime getExpiredTime(String phoneNumber) { + Long ttl = redisTemplate.getExpire("smsCertification:" + phoneNumber); + log.info("ttl: {}", ttl); + + if (ttl == null || ttl < 0) { + throw new GlobalErrorException(ErrorCode.EXPIRED_AUTH_CODE); + } + return LocalDateTime.now().plusSeconds(ttl); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/config/CacheConfig.java b/src/main/java/com/kcy/fitapet/global/config/CacheConfig.java new file mode 100644 index 00000000..4195142e --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/config/CacheConfig.java @@ -0,0 +1,43 @@ +package com.kcy.fitapet.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.CacheKeyPrefix; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@RequiredArgsConstructor +@EnableCaching +public class CacheConfig { + @Bean + public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .disableCachingNullValues() + .entryTtl(Duration.ofSeconds(60)) + .computePrefixWith(CacheKeyPrefix.simple()) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()) + ); + Map redisCacheConfigurationMap = Map.of("sercurityConfig", config); + + return RedisCacheManager.RedisCacheManagerBuilder + .fromConnectionFactory(redisConnectionFactory) + .cacheDefaults(config) + .withInitialCacheConfigurations(redisCacheConfigurationMap) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/config/JpaAuditingConfig.java b/src/main/java/com/kcy/fitapet/global/config/JpaAuditingConfig.java new file mode 100644 index 00000000..8a4ccab3 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package com.kcy.fitapet.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/com/kcy/fitapet/global/config/JwtSecurityConfig.java b/src/main/java/com/kcy/fitapet/global/config/JwtSecurityConfig.java new file mode 100644 index 00000000..d7251e95 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/config/JwtSecurityConfig.java @@ -0,0 +1,37 @@ +package com.kcy.fitapet.global.config; + +import com.kcy.fitapet.global.common.security.authentication.UserDetailServiceImpl; +import com.kcy.fitapet.global.common.security.filter.JwtAuthorizationFilter; +import com.kcy.fitapet.global.common.security.filter.JwtExceptionFilter; +import com.kcy.fitapet.global.common.util.cookie.CookieUtil; +import com.kcy.fitapet.global.common.util.jwt.JwtUtil; +import com.kcy.fitapet.global.common.util.redis.forbidden.ForbiddenTokenService; +import com.kcy.fitapet.global.common.util.redis.refresh.RefreshTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.SecurityConfigurerAdapter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +public class JwtSecurityConfig extends SecurityConfigurerAdapter { + private final UserDetailServiceImpl userDetailServiceImpl; + private final RefreshTokenService refreshTokenService; + private final ForbiddenTokenService forbiddenTokenService; + + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + + @Override + public void configure(HttpSecurity http) throws Exception { + JwtAuthorizationFilter jwtAuthorizationFilter + = new JwtAuthorizationFilter(userDetailServiceImpl, refreshTokenService, forbiddenTokenService, jwtUtil, cookieUtil); + JwtExceptionFilter jwtExceptionFilter = new JwtExceptionFilter(); + + // TODO: test + http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtExceptionFilter, JwtAuthorizationFilter.class); + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/config/RedisConfig.java b/src/main/java/com/kcy/fitapet/global/config/RedisConfig.java new file mode 100644 index 00000000..51cb95e0 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/config/RedisConfig.java @@ -0,0 +1,41 @@ +package com.kcy.fitapet.global.config; + +import com.kcy.fitapet.global.common.util.redis.sms.SmsCertification; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String host; + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); +// config.setPassword(); // redis 패스워드 설정 시, 주석 해제 + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build(); + return new LettuceConnectionFactory(config, clientConfig); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + + template.setConnectionFactory(redisConnectionFactory()); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } +} \ No newline at end of file diff --git a/src/main/java/com/kcy/fitapet/global/config/SecurityConfig.java b/src/main/java/com/kcy/fitapet/global/config/SecurityConfig.java new file mode 100644 index 00000000..feef08a6 --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/config/SecurityConfig.java @@ -0,0 +1,89 @@ +package com.kcy.fitapet.global.config; + +import com.kcy.fitapet.global.common.security.authentication.UserDetailServiceImpl; +import com.kcy.fitapet.global.common.security.handler.JwtAccessDeniedHandler; +import com.kcy.fitapet.global.common.security.handler.JwtAuthenticationEntryPoint; +import com.kcy.fitapet.global.common.util.cookie.CookieUtil; +import com.kcy.fitapet.global.common.util.jwt.JwtUtil; +import com.kcy.fitapet.global.common.util.redis.forbidden.ForbiddenTokenService; +import com.kcy.fitapet.global.common.util.redis.refresh.RefreshTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.intercept.AuthorizationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final String[] webSecurityIgnoring = { + "/", "/api/v1/members/test", + "/favicon.ico", + "/api-docs/**", + "/test/**", + "/v3/api-docs/**", "/swagger-ui/**", "/swagger", + "/api/v1/members/register", "/api/v1/members/login", "/api/v1/members/refresh", + "/api/v1/members/sms/**" + }; + + private final UserDetailServiceImpl userDetailServiceImpl; + private final RefreshTokenService refreshTokenService; + private final ForbiddenTokenService forbiddenTokenService; + + private final JwtUtil jwtUtil; + private final CookieUtil cookieUtil; + + @Bean + public AccessDeniedHandler accessDeniedHandler() { + return new JwtAccessDeniedHandler(); + } + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new JwtAuthenticationEntryPoint(); + } + + @Bean + public BCryptPasswordEncoder bCryptPasswordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + auth -> { + try { + auth.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() + .requestMatchers(HttpMethod.OPTIONS, "*").permitAll() + .requestMatchers(this.webSecurityIgnoring).permitAll() + .anyRequest().authenticated(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + ) + .exceptionHandling( + exception -> exception + .accessDeniedHandler(accessDeniedHandler()) + .authenticationEntryPoint(authenticationEntryPoint()) + ); + http.apply(new JwtSecurityConfig(userDetailServiceImpl, refreshTokenService, forbiddenTokenService, jwtUtil, cookieUtil)); + return http.build(); + } +} diff --git a/src/main/java/com/kcy/fitapet/global/config/SpringConfig.java b/src/main/java/com/kcy/fitapet/global/config/SpringConfig.java deleted file mode 100644 index 345f0b06..00000000 --- a/src/main/java/com/kcy/fitapet/global/config/SpringConfig.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.kcy.fitapet.global.config; - -import org.springframework.context.annotation.Configuration; - -@Configuration -public class SpringConfig { -} diff --git a/src/main/java/com/kcy/fitapet/global/config/SwaggerConfig.java b/src/main/java/com/kcy/fitapet/global/config/SwaggerConfig.java new file mode 100644 index 00000000..0a0cd1fb --- /dev/null +++ b/src/main/java/com/kcy/fitapet/global/config/SwaggerConfig.java @@ -0,0 +1,46 @@ +package com.kcy.fitapet.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 io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + private static String JWT = "jwtAuth"; + + @Bean + public OpenAPI openAPI() { + SecurityRequirement securityRequirement = new SecurityRequirement().addList(JWT); + + return new OpenAPI() + .info(apiInfo()) + .addServersItem(new Server().url("")) + .addSecurityItem(securityRequirement) + .components(securitySchemes()); + } + + private Components securitySchemes() { + final var securitySchemeAccessToken = new SecurityScheme() + .name(JWT) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + return new Components() + .addSecuritySchemes(JWT, securitySchemeAccessToken); + } + + private Info apiInfo() { + return new io.swagger.v3.oas.models.info.Info() + .title("Fit a Pet API") + .description("Fit a Pet Backend API Docs") + .version("1.0.0"); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b137891..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..34bb6936 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,116 @@ +server: + port: 8082 + +spring: + profiles: + default: local + + datasource: + url: ${DB_URL} + username: ${DB_USER_NAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + database: MySQL + database-platform: org.hibernate.dialect.MySQL8Dialect + open-in-view: false + generate-ddl: false + hibernate: + ddl-auto: none + show-sql: true + + main: + allow-bean-definition-overriding: true + + data.redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + path: /swagger + disable-swagger-default-url: true + display-request-duration: true + operations-sorter: alpha + api-docs: + groups: + enabled: true + +jwt: + secret: ${JWT_SECRET} + token: + # milliseconds 단위 + access-expiration-time: 1800000 # 30m (30 * 60 * 1000) + refresh-expiration-time: 604800000 # 7d (7 * 24 * 60 * 60 * 1000) + +logging: + level: + org.hibernate.sql: debug + org.hibernate.type: trace + +ncp: + api-key: ${NCP_API_ACCESS_KEY} + secret-key: ${NCP_SECRET_KEY} + sms: + service-key: ${NCP_SMS_KEY} + sender-phone: ${SENDER_PHONE} + + +--- +server: + port: 8080 + +spring: + config: + activate: + on-profile: prod + + datasource: + url: ${DB_URL} + username: ${DB_USER_NAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + + jpa: + database: MySQL + database-platform: org.hibernate.dialect.MySQL8Dialect + open-in-view: false + generate-ddl: false + hibernate: + ddl-auto: none + + main: + allow-bean-definition-overriding: true + + data.redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + +springdoc: + default-consumes-media-type: application/json;charset=UTF-8 + default-produces-media-type: application/json;charset=UTF-8 + swagger-ui: + path: /swagger + disable-swagger-default-url: true + display-request-duration: true + operations-sorter: alpha + api-docs: + groups: + enabled: true + +jwt: + secret: ${JWT_SECRET} + token: + # milliseconds 단위 + access-expiration-time: 1800000 # 30m (30 * 60 * 1000) + refresh-expiration-time: 604800000 # 7d (7 * 24 * 60 * 60 * 1000) + +ncp: + api-key: ${NCP_API_ACCESS_KEY} + secret-key: ${NCP_SECRET_KEY} + sms: + service-key: ${NCP_SMS_KEY} + sender-phone: ${SENDER_PHONE} \ No newline at end of file diff --git a/test b/test deleted file mode 100644 index 9daeafb9..00000000 --- a/test +++ /dev/null @@ -1 +0,0 @@ -test