From a06f15f6c835f7475e1f310f7bc1f0af450daa24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9E=AC=ED=98=81?= Date: Thu, 19 Dec 2024 02:05:36 +0900 Subject: [PATCH] =?UTF-8?q?infra:=201=EC=B0=A8=20=EB=B0=B0=ED=8F=AC=20(#17?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 소속 단위 선거 기본 정보 추가 기능 구현 s3 업로드 기능 global로 수정 필요 * feat: 소속 단위 선거 기본 정보 등록용 엔티티 추가 * 온프레미스 환경 회원가입/로그인 코드 추가 (#9) * feat: refreshtoken 저장 엔터티 추가 * feat: 패스워드 암호화 빈 추가 * feat: 회원 가입 로직 구현 * feat: 로그인 필터 추가 * feat: 온프레미스 서버 환경 로그인 기능 추가 (#10) * feat: refreshtoken 저장 엔터티 추가 * feat: 패스워드 암호화 빈 추가 * feat: 회원 가입 로직 구현 * feat: 로그인 필터 추가 * feat: UserDetailsService, dto 구현 * feat: 서울 기준 시 설정 * feat: JWT 발급 코드 추가 * feat: JWTFilter 추가 * feat: cors 설정 추가 * feat: userdetailsservice 예외처리 추가 * fix: url length 추가 length가 255이상 인 경우 오류 가능 수정 * fix: dto추가 requestparam dto로 수정 * feat:선거기본정보 에러코드 추가 * fix: validation, lombok, dto 추가 validation, lombok, dto 추가 * docs: 회원가입 api 스웨거 작성 (#12) * feat: refreshtoken 저장 엔터티 추가 * feat: 패스워드 암호화 빈 추가 * feat: 회원 가입 로직 구현 * feat: 로그인 필터 추가 * feat: UserDetailsService, dto 구현 * feat: 서울 기준 시 설정 * feat: JWT 발급 코드 추가 * feat: JWTFilter 추가 * feat: cors 설정 추가 * feat: userdetailsservice 예외처리 추가 * docs: 회원가입 api 스웨거 작성 * feat: 필터기반 로그인 -> 수동 인증 로직으로 수정 (#13) * feat: refreshtoken 저장 엔터티 추가 * feat: 패스워드 암호화 빈 추가 * feat: 회원 가입 로직 구현 * feat: 로그인 필터 추가 * feat: UserDetailsService, dto 구현 * feat: 서울 기준 시 설정 * feat: JWT 발급 코드 추가 * feat: JWTFilter 추가 * feat: cors 설정 추가 * feat: userdetailsservice 예외처리 추가 * docs: 회원가입 api 스웨거 작성 * feat: 필터기반 로그인 -> 수동 인증 로직으로 수정 * feat: refreshToken 저장 추가 (#14) * feat: refreshtoken 저장 엔터티 추가 * feat: 패스워드 암호화 빈 추가 * feat: 회원 가입 로직 구현 * feat: 로그인 필터 추가 * feat: UserDetailsService, dto 구현 * feat: 서울 기준 시 설정 * feat: JWT 발급 코드 추가 * feat: JWTFilter 추가 * feat: cors 설정 추가 * feat: userdetailsservice 예외처리 추가 * docs: 회원가입 api 스웨거 작성 * feat: 필터기반 로그인 -> 수동 인증 로직으로 수정 * feat: refreshToken 저장 추가 * feat: 회원가입 요청한 유저의 목록 조회 기능 구현 (#16) * feat:s3 bean추가 * fix:import수정 * infra: cicd 연결 테스트 --------- Co-authored-by: ssm00 Co-authored-by: ssm00 Co-authored-by: ssm00 <97657265+ssm00@users.noreply.github.com> --- .github/workflows/deploy.yml | 82 +++++++++++++++ Dockerfile | 9 ++ .../admin/application/AdminService.java | 50 ++++++++++ .../AccountsWaitingForApprovalResponse.java | 11 +++ .../admin/presentation/AdminController.java | 29 ++++++ .../domain/auth/application/AuthService.java | 96 ++++++++++++++++++ .../application/CustomUserDetailsService.java | 31 ++++++ .../studentvote/domain/auth/domain/Token.java | 42 ++++++++ .../domain/repository/TokenRepository.java | 7 ++ .../auth/dto/request/SignInRequest.java | 7 ++ .../auth/dto/request/SignUpRequest.java | 8 ++ .../auth/dto/response/CustomUserDetails.java | 60 +++++++++++ .../auth/dto/response/SignInResponse.java | 20 ++++ .../exception/AlreadyExistIdException.java | 7 ++ .../exception/EmailNotFoundException.java | 7 ++ .../exception/InvalidPasswordException.java | 7 ++ .../auth/presentation/AuthController.java | 36 +++++++ .../application/ElectionBasicInfoService.java | 99 +++++++++++++++++++ .../domain/ElectionBasicInfo.java | 31 ++++++ .../ElectionBasicInfoRepository.java | 11 +++ .../dto/request/ElectionBasicInfoRequest.java | 12 +++ .../response/ElectionBasicInfoResponse.java | 11 +++ .../ElectionBasicInfoController.java | 36 +++++++ .../domain/user/domain/ApprovalStatus.java | 7 ++ .../studentvote/domain/user/domain/Role.java | 5 + .../studentvote/domain/user/domain/User.java | 27 ++++- .../domain/repository/UserRepository.java | 10 ++ .../global/config/TimeZoneConfig.java | 15 +++ .../global/config/s3/S3Config.java | 31 ++++++ .../config/security/SecurityConfig.java | 30 +++++- .../global/config/security/WebMvcConfig.java | 27 +++++ .../global/config/security/jwt/JWTFilter.java | 57 +++++++++++ .../global/config/security/jwt/JWTUtil.java | 43 ++++++++ .../config/security/jwt/LoginFilter.java | 89 +++++++++++++++++ .../studentvote/global/payload/ErrorCode.java | 6 +- 35 files changed, 1052 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 Dockerfile create mode 100644 src/main/java/com/studentvote/domain/admin/application/AdminService.java create mode 100644 src/main/java/com/studentvote/domain/admin/dto/response/AccountsWaitingForApprovalResponse.java create mode 100644 src/main/java/com/studentvote/domain/admin/presentation/AdminController.java create mode 100644 src/main/java/com/studentvote/domain/auth/application/AuthService.java create mode 100644 src/main/java/com/studentvote/domain/auth/application/CustomUserDetailsService.java create mode 100644 src/main/java/com/studentvote/domain/auth/domain/Token.java create mode 100644 src/main/java/com/studentvote/domain/auth/domain/repository/TokenRepository.java create mode 100644 src/main/java/com/studentvote/domain/auth/dto/request/SignInRequest.java create mode 100644 src/main/java/com/studentvote/domain/auth/dto/request/SignUpRequest.java create mode 100644 src/main/java/com/studentvote/domain/auth/dto/response/CustomUserDetails.java create mode 100644 src/main/java/com/studentvote/domain/auth/dto/response/SignInResponse.java create mode 100644 src/main/java/com/studentvote/domain/auth/exception/AlreadyExistIdException.java create mode 100644 src/main/java/com/studentvote/domain/auth/exception/EmailNotFoundException.java create mode 100644 src/main/java/com/studentvote/domain/auth/exception/InvalidPasswordException.java create mode 100644 src/main/java/com/studentvote/domain/auth/presentation/AuthController.java create mode 100644 src/main/java/com/studentvote/domain/electionManagementInfo/application/ElectionBasicInfoService.java create mode 100644 src/main/java/com/studentvote/domain/electionManagementInfo/domain/ElectionBasicInfo.java create mode 100644 src/main/java/com/studentvote/domain/electionManagementInfo/domain/repository/ElectionBasicInfoRepository.java create mode 100644 src/main/java/com/studentvote/domain/electionManagementInfo/dto/request/ElectionBasicInfoRequest.java create mode 100644 src/main/java/com/studentvote/domain/electionManagementInfo/dto/response/ElectionBasicInfoResponse.java create mode 100644 src/main/java/com/studentvote/domain/electionManagementInfo/presentation/ElectionBasicInfoController.java create mode 100644 src/main/java/com/studentvote/domain/user/domain/ApprovalStatus.java create mode 100644 src/main/java/com/studentvote/domain/user/domain/Role.java create mode 100644 src/main/java/com/studentvote/global/config/TimeZoneConfig.java create mode 100644 src/main/java/com/studentvote/global/config/s3/S3Config.java create mode 100644 src/main/java/com/studentvote/global/config/security/WebMvcConfig.java create mode 100644 src/main/java/com/studentvote/global/config/security/jwt/JWTFilter.java create mode 100644 src/main/java/com/studentvote/global/config/security/jwt/JWTUtil.java create mode 100644 src/main/java/com/studentvote/global/config/security/jwt/LoginFilter.java diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..f33638d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,82 @@ +name: Java CI with Gradle + +# 동작 조건 설정 : main 브랜치에 push 혹은 pull request가 발생할 경우 동작한다. +on: + push: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + # Spring Boot 애플리케이션을 빌드하여 도커허브에 푸시하는 과정 + build-docker-image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + # 1. Java 21 세팅 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + ## create application-database.yaml + - name: create application.properties file + run: | + mkdir ./src/main/resources + touch ./src/main/resources/application.yml + echo "${{ secrets.APPLICATION_YML }}" >> src/main/resources/application.yml + + ## create firebase-service-key.json + - name: create firebase_service_key.json file + run: | + mkdir ./src/main/resources/firebase + touch ./src/main/resources/firebase/firebase_service_key.json + echo "${{ secrets.FIREBASE_SERVICE_KEY }}" > src/main/resources/firebase/firebase_service_key.json + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + shell: bash + + # 2. Spring Boot 애플리케이션 빌드 + - name: Build with Gradle + uses: gradle/gradle-build-action@67421db6bd0bf253fb4bd25b31ebb98943c375e1 + with: + arguments: clean bootJar + + # 3. Docker 이미지 빌드 + - name: docker image build + run: docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/studentvote . + + # 4. DockerHub 로그인 + - name: docker login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + # 5. Docker Hub 이미지 푸시 + - name: docker Hub push + run: docker push ${{ secrets.DOCKERHUB_USERNAME }}/studentvote + + run-docker-image-on-ec2: + needs: build-docker-image + runs-on: self-hosted + + steps: + # 1. 최신 이미지를 풀받습니다 + - name: docker pull + run: sudo docker pull ${{ secrets.DOCKERHUB_USERNAME }}/studentvote + + # 2. 기존의 컨테이너를 중지시킵니다 + - name: docker stop container + run: sudo docker stop $(sudo docker ps -q) 2>/dev/null || true + + # 3. 최신 이미지를 컨테이너화하여 실행시킵니다 + - name: docker run new container + run: sudo docker run --name studentvote --rm -d -p 8080:8080 ${{ secrets.DOCKERHUB_USERNAME }}/studentvote + + # 4. 미사용 이미지를 정리합니다 + - name: delete old docker image + run: sudo docker system prune -f \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3fc5a13 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM openjdk:21 + +ARG JAR_FILE=build/libs/*.jar + +COPY ${JAR_FILE} app.jar + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/src/main/java/com/studentvote/domain/admin/application/AdminService.java b/src/main/java/com/studentvote/domain/admin/application/AdminService.java new file mode 100644 index 0000000..817a110 --- /dev/null +++ b/src/main/java/com/studentvote/domain/admin/application/AdminService.java @@ -0,0 +1,50 @@ +package com.studentvote.domain.admin.application; + +import com.studentvote.domain.admin.dto.response.AccountsWaitingForApprovalResponse; +import com.studentvote.domain.auth.dto.response.CustomUserDetails; +import com.studentvote.domain.user.domain.ApprovalStatus; +import com.studentvote.domain.user.domain.User; +import com.studentvote.domain.user.domain.repository.UserRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminService { + + private static final String MASTER_USERNAME = "ADMIN"; + + private final UserRepository userRepository; + + public List getAccountsWaitingForApproval(CustomUserDetails userDetails) { + + System.out.println("userDetails.getUsername() = " + userDetails.getUsername()); + if (!userDetails.getUsername().equals(MASTER_USERNAME)) { + throw new IllegalArgumentException("접근 권한이 없습니다."); + } + + Optional> allByApprovalStatus = userRepository.findAllByApprovalStatus(ApprovalStatus.PENDING); + + if (!allByApprovalStatus.isPresent()) { + return new ArrayList<>(); + } + + List accountsWaitingForApprovalResponses = allByApprovalStatus.get() + .stream() + .map(user -> new AccountsWaitingForApprovalResponse( + user.getId(), + user.getUsername(), + "", + user.getCreatedAt() + )) + .collect(Collectors.toList()); + + return accountsWaitingForApprovalResponses; + } +} diff --git a/src/main/java/com/studentvote/domain/admin/dto/response/AccountsWaitingForApprovalResponse.java b/src/main/java/com/studentvote/domain/admin/dto/response/AccountsWaitingForApprovalResponse.java new file mode 100644 index 0000000..b50d363 --- /dev/null +++ b/src/main/java/com/studentvote/domain/admin/dto/response/AccountsWaitingForApprovalResponse.java @@ -0,0 +1,11 @@ +package com.studentvote.domain.admin.dto.response; + +import java.time.LocalDateTime; + +public record AccountsWaitingForApprovalResponse( + Long userId, + String username, + String department, + LocalDateTime requestDate +) { +} diff --git a/src/main/java/com/studentvote/domain/admin/presentation/AdminController.java b/src/main/java/com/studentvote/domain/admin/presentation/AdminController.java new file mode 100644 index 0000000..0539bb4 --- /dev/null +++ b/src/main/java/com/studentvote/domain/admin/presentation/AdminController.java @@ -0,0 +1,29 @@ +package com.studentvote.domain.admin.presentation; + +import com.studentvote.domain.admin.application.AdminService; +import com.studentvote.domain.auth.dto.response.CustomUserDetails; +import com.studentvote.global.payload.ResponseCustom; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin", description = "Admin API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/admin") +public class AdminController { + + private final AdminService adminService; + + @Operation(summary = "가입 승인 대기 계정 조회", description = "가입 승인 대기 중인 계정 목록을 마스터 계정이 조회합니다.") + @GetMapping + public ResponseCustom getAccountsWaitingForApproval( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseCustom.OK(adminService.getAccountsWaitingForApproval(userDetails)); + } +} diff --git a/src/main/java/com/studentvote/domain/auth/application/AuthService.java b/src/main/java/com/studentvote/domain/auth/application/AuthService.java new file mode 100644 index 0000000..eb5760c --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/application/AuthService.java @@ -0,0 +1,96 @@ +package com.studentvote.domain.auth.application; + +import com.studentvote.domain.auth.domain.Token; +import com.studentvote.domain.auth.domain.repository.TokenRepository; +import com.studentvote.domain.auth.dto.request.SignInRequest; +import com.studentvote.domain.auth.dto.request.SignUpRequest; +import com.studentvote.domain.auth.dto.response.SignInResponse; +import com.studentvote.domain.auth.exception.AlreadyExistIdException; +import com.studentvote.domain.auth.exception.EmailNotFoundException; +import com.studentvote.domain.auth.exception.InvalidPasswordException; +import com.studentvote.domain.user.domain.Role; +import com.studentvote.domain.user.domain.User; +import com.studentvote.domain.user.domain.repository.UserRepository; +import com.studentvote.global.config.security.jwt.JWTUtil; +import com.studentvote.global.payload.Message; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class AuthService { + + private final UserRepository userRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + private final BCryptPasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final TokenRepository tokenRepository; + + private final JWTUtil jwtUtil; + + @Transactional + public Message signUp(SignUpRequest signUpRequest) { + String email = signUpRequest.email(); + String password = signUpRequest.password(); + String name = signUpRequest.name(); + + Boolean isExist = userRepository.existsByEmail(email); + + if (isExist) { + throw new AlreadyExistIdException(); + } + + User user = User.of(email, bCryptPasswordEncoder.encode(password), name); + + userRepository.save(user); + + return Message + .builder() + .message("회원가입이 완료되었습니다.") + .build(); + } + + @Transactional + public SignInResponse signIn(SignInRequest signInRequest) { + String email = signInRequest.email(); + String password = signInRequest.password(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new EmailNotFoundException()); + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new InvalidPasswordException(); + } + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + email, password, null); + + Authentication authenticate = authenticationManager.authenticate(authToken); + + SecurityContextHolder.getContext().setAuthentication(authenticate); + + Role role = user.getRole(); + + String accessToken = jwtUtil.createJwt(email, role.toString(), 36000000000L); + String refreshToken = jwtUtil.createJwt(email, role.toString(), 360000000000L); + + tokenRepository.save(Token.builder().email(email).refreshToken(refreshToken).build()); + + SignInResponse signInResponse = SignInResponse + .builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .role(role) + .build(); + + return signInResponse; + } +} diff --git a/src/main/java/com/studentvote/domain/auth/application/CustomUserDetailsService.java b/src/main/java/com/studentvote/domain/auth/application/CustomUserDetailsService.java new file mode 100644 index 0000000..672d520 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/application/CustomUserDetailsService.java @@ -0,0 +1,31 @@ +package com.studentvote.domain.auth.application; + +import com.studentvote.domain.auth.dto.response.CustomUserDetails; +import com.studentvote.domain.auth.exception.EmailNotFoundException; +import com.studentvote.domain.user.domain.User; +import com.studentvote.domain.user.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new EmailNotFoundException()); + + + if (user != null) { + return new CustomUserDetails(user); + } + + throw new UsernameNotFoundException(username); + } +} diff --git a/src/main/java/com/studentvote/domain/auth/domain/Token.java b/src/main/java/com/studentvote/domain/auth/domain/Token.java new file mode 100644 index 0000000..bf767ee --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/domain/Token.java @@ -0,0 +1,42 @@ +package com.studentvote.domain.auth.domain; + +import com.studentvote.domain.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "token") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +public class Token extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "email", nullable = false) + private String email; + + @Column(name = "refresh_token", nullable = false) + private String refreshToken; + + public Token updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + @Builder + public Token(String email, String refreshToken) { + this.email = email; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/studentvote/domain/auth/domain/repository/TokenRepository.java b/src/main/java/com/studentvote/domain/auth/domain/repository/TokenRepository.java new file mode 100644 index 0000000..5daf428 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/domain/repository/TokenRepository.java @@ -0,0 +1,7 @@ +package com.studentvote.domain.auth.domain.repository; + +import com.studentvote.domain.auth.domain.Token; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TokenRepository extends JpaRepository { +} diff --git a/src/main/java/com/studentvote/domain/auth/dto/request/SignInRequest.java b/src/main/java/com/studentvote/domain/auth/dto/request/SignInRequest.java new file mode 100644 index 0000000..9d53f70 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/dto/request/SignInRequest.java @@ -0,0 +1,7 @@ +package com.studentvote.domain.auth.dto.request; + +public record SignInRequest( + String email, + String password +) { +} diff --git a/src/main/java/com/studentvote/domain/auth/dto/request/SignUpRequest.java b/src/main/java/com/studentvote/domain/auth/dto/request/SignUpRequest.java new file mode 100644 index 0000000..6c0b5dd --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/dto/request/SignUpRequest.java @@ -0,0 +1,8 @@ +package com.studentvote.domain.auth.dto.request; + +public record SignUpRequest( + String email, + String password, + String name +) { +} diff --git a/src/main/java/com/studentvote/domain/auth/dto/response/CustomUserDetails.java b/src/main/java/com/studentvote/domain/auth/dto/response/CustomUserDetails.java new file mode 100644 index 0000000..102e245 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/dto/response/CustomUserDetails.java @@ -0,0 +1,60 @@ +package com.studentvote.domain.auth.dto.response; + +import com.studentvote.domain.user.domain.User; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public record CustomUserDetails(User user) implements UserDetails { + + public CustomUserDetails(User user) { + this.user = user; + } + + @Override + public Collection getAuthorities() { + + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return user.getRole().toString(); + } + }); + + return collection; + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getUsername(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/studentvote/domain/auth/dto/response/SignInResponse.java b/src/main/java/com/studentvote/domain/auth/dto/response/SignInResponse.java new file mode 100644 index 0000000..e3bd540 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/dto/response/SignInResponse.java @@ -0,0 +1,20 @@ +package com.studentvote.domain.auth.dto.response; + +import com.studentvote.domain.user.domain.Role; +import lombok.Builder; + +public record SignInResponse( + String accessToken, + String refreshToken, + String tokenType, + Role role +) { + + @Builder + public SignInResponse(String accessToken, String refreshToken, String tokenType, Role role) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.tokenType = tokenType; + this.role = role; + } +} \ No newline at end of file diff --git a/src/main/java/com/studentvote/domain/auth/exception/AlreadyExistIdException.java b/src/main/java/com/studentvote/domain/auth/exception/AlreadyExistIdException.java new file mode 100644 index 0000000..cb78528 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/exception/AlreadyExistIdException.java @@ -0,0 +1,7 @@ +package com.studentvote.domain.auth.exception; + +public class AlreadyExistIdException extends RuntimeException { + public AlreadyExistIdException() { + super("이미 가입된 유저입니다."); + } +} diff --git a/src/main/java/com/studentvote/domain/auth/exception/EmailNotFoundException.java b/src/main/java/com/studentvote/domain/auth/exception/EmailNotFoundException.java new file mode 100644 index 0000000..d866b55 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/exception/EmailNotFoundException.java @@ -0,0 +1,7 @@ +package com.studentvote.domain.auth.exception; + +public class EmailNotFoundException extends IllegalArgumentException { + public EmailNotFoundException() { + super("가입되지 않은 이메일입니다."); + } +} diff --git a/src/main/java/com/studentvote/domain/auth/exception/InvalidPasswordException.java b/src/main/java/com/studentvote/domain/auth/exception/InvalidPasswordException.java new file mode 100644 index 0000000..2ca9a65 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/exception/InvalidPasswordException.java @@ -0,0 +1,7 @@ +package com.studentvote.domain.auth.exception; + +public class InvalidPasswordException extends IllegalArgumentException { + public InvalidPasswordException() { + super("비밀번호가 일치하지 않습니다."); + } +} diff --git a/src/main/java/com/studentvote/domain/auth/presentation/AuthController.java b/src/main/java/com/studentvote/domain/auth/presentation/AuthController.java new file mode 100644 index 0000000..9eaad00 --- /dev/null +++ b/src/main/java/com/studentvote/domain/auth/presentation/AuthController.java @@ -0,0 +1,36 @@ +package com.studentvote.domain.auth.presentation; + +import com.studentvote.domain.auth.application.AuthService; +import com.studentvote.domain.auth.dto.request.SignInRequest; +import com.studentvote.domain.auth.dto.request.SignUpRequest; +import com.studentvote.domain.auth.dto.response.SignInResponse; +import com.studentvote.global.payload.Message; +import com.studentvote.global.payload.ResponseCustom; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Authorization", description = "Authorization API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "회원가입", description = "신규 사용자가 회원가입을 요청합니다.") + @PostMapping("/sign-up") + public ResponseCustom signUp(@RequestBody SignUpRequest signUpRequest) { + return ResponseCustom.OK(authService.signUp(signUpRequest)); + } + + @Operation(summary = "로그인", description = "사용자가 로그인을 요청합니다.") + @PostMapping("/sign-in") + public ResponseCustom signIn(@RequestBody SignInRequest signInRequest) { + return ResponseCustom.OK(authService.signIn(signInRequest)); + } +} diff --git a/src/main/java/com/studentvote/domain/electionManagementInfo/application/ElectionBasicInfoService.java b/src/main/java/com/studentvote/domain/electionManagementInfo/application/ElectionBasicInfoService.java new file mode 100644 index 0000000..db7f5d8 --- /dev/null +++ b/src/main/java/com/studentvote/domain/electionManagementInfo/application/ElectionBasicInfoService.java @@ -0,0 +1,99 @@ +package com.studentvote.domain.electionManagementInfo.application; + + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.PutObjectResult; +import com.studentvote.domain.electionManagementInfo.domain.ElectionBasicInfo; +import com.studentvote.domain.electionManagementInfo.domain.repository.ElectionBasicInfoRepository; +import com.studentvote.domain.electionManagementInfo.dto.request.ElectionBasicInfoRequest; +import com.studentvote.domain.electionManagementInfo.dto.response.ElectionBasicInfoResponse; +import com.studentvote.global.error.DefaultException; +import com.studentvote.global.payload.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Paths; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +public class ElectionBasicInfoService { + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + private final ElectionBasicInfoRepository electionBasicInfoRepository; + + @Transactional + public ElectionBasicInfoResponse uploadElectionInfo(ElectionBasicInfoRequest request) { + validateUploadRequest(request); + String committeeNoticeUrl = uploadImageToS3(request.electionCommitteeNotice()); + String regulationAmendmentUrl = uploadImageToS3(request.electionRegulationAmendmentNotice()); + String recruitmentNoticeUrl = uploadImageToS3(request.candidateRecruitmentNotice()); + String regulationUrl = uploadImageToS3(request.electionRegulation()); + ElectionBasicInfo savedEntity = electionBasicInfoRepository.save( + new ElectionBasicInfo(null, committeeNoticeUrl, regulationAmendmentUrl, recruitmentNoticeUrl, regulationUrl) + ); + return new ElectionBasicInfoResponse( + savedEntity.getId(), + savedEntity.getElectionCommitteeNotice(), + savedEntity.getElectionRegulationAmendmentNotice(), + savedEntity.getCandidateRecruitmentNotice(), + savedEntity.getElectionRegulation() + ); + } + + public String uploadImageToS3(MultipartFile image) { + String originName = image.getOriginalFilename(); + String ext = originName.substring(originName.lastIndexOf(".")); + String changedName = changedImageName(originName); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(image.getSize()); + metadata.setContentType("image/" + ext); + + try { + PutObjectResult putObjectResult = amazonS3.putObject(new PutObjectRequest( + bucketName, changedName, image.getInputStream(), metadata + )); + } catch (IOException e) { + throw new RuntimeException("ImageUploadException"); + } + return amazonS3.getUrl(bucketName, changedName).toString(); + } + + private String changedImageName(String originName) { + String random = UUID.randomUUID().toString(); + return random +'-' + originName; + } + + private void validateUploadRequest(ElectionBasicInfoRequest request) { + if (request == null) { + throw new DefaultException(ErrorCode.INVALID_PARAMETER); + } + if (request.electionCommitteeNotice() == null) { + throw new DefaultException(ErrorCode.INVALID_ELECTIONCOMMITTEENOTICE); + } + if (request.electionRegulationAmendmentNotice() == null) { + throw new DefaultException(ErrorCode.INVALID_ELECTIONREGULATIONAMENDNOTICE); + } + if (request.candidateRecruitmentNotice() == null) { + throw new DefaultException(ErrorCode.INVALID_CANDIDATERECUITMENTNOTICE); + } + if (request.electionRegulation() == null) { + throw new DefaultException(ErrorCode.INVALID_ELECTIONREGULATION); + } + } + +} diff --git a/src/main/java/com/studentvote/domain/electionManagementInfo/domain/ElectionBasicInfo.java b/src/main/java/com/studentvote/domain/electionManagementInfo/domain/ElectionBasicInfo.java new file mode 100644 index 0000000..7f64554 --- /dev/null +++ b/src/main/java/com/studentvote/domain/electionManagementInfo/domain/ElectionBasicInfo.java @@ -0,0 +1,31 @@ +package com.studentvote.domain.electionManagementInfo.domain; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class ElectionBasicInfo { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", updatable = false) + private Long id; + + @Column(length = 1000) + private String electionCommitteeNotice; + + @Column(length = 1000) + private String electionRegulationAmendmentNotice; + + @Column(length = 1000) + private String candidateRecruitmentNotice; + + @Column(length = 1000) + private String electionRegulation; + +} diff --git a/src/main/java/com/studentvote/domain/electionManagementInfo/domain/repository/ElectionBasicInfoRepository.java b/src/main/java/com/studentvote/domain/electionManagementInfo/domain/repository/ElectionBasicInfoRepository.java new file mode 100644 index 0000000..13491c0 --- /dev/null +++ b/src/main/java/com/studentvote/domain/electionManagementInfo/domain/repository/ElectionBasicInfoRepository.java @@ -0,0 +1,11 @@ +package com.studentvote.domain.electionManagementInfo.domain.repository; + +import com.studentvote.domain.electionManagementInfo.domain.ElectionBasicInfo; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ElectionBasicInfoRepository extends JpaRepository { + + +} + + diff --git a/src/main/java/com/studentvote/domain/electionManagementInfo/dto/request/ElectionBasicInfoRequest.java b/src/main/java/com/studentvote/domain/electionManagementInfo/dto/request/ElectionBasicInfoRequest.java new file mode 100644 index 0000000..af8ec28 --- /dev/null +++ b/src/main/java/com/studentvote/domain/electionManagementInfo/dto/request/ElectionBasicInfoRequest.java @@ -0,0 +1,12 @@ +package com.studentvote.domain.electionManagementInfo.dto.request; + + +import org.springframework.web.multipart.MultipartFile; + +public record ElectionBasicInfoRequest( + MultipartFile electionCommitteeNotice, + MultipartFile electionRegulationAmendmentNotice , + MultipartFile candidateRecruitmentNotice , + MultipartFile electionRegulation +) { +} diff --git a/src/main/java/com/studentvote/domain/electionManagementInfo/dto/response/ElectionBasicInfoResponse.java b/src/main/java/com/studentvote/domain/electionManagementInfo/dto/response/ElectionBasicInfoResponse.java new file mode 100644 index 0000000..c08616d --- /dev/null +++ b/src/main/java/com/studentvote/domain/electionManagementInfo/dto/response/ElectionBasicInfoResponse.java @@ -0,0 +1,11 @@ +package com.studentvote.domain.electionManagementInfo.dto.response; + + +public record ElectionBasicInfoResponse( + Long id, + String electionCommitteeNoticeUrl, + String electionRegulationAmendmentNoticeUrl, + String candidateRecruitmentNoticeUrl, + String electionRegulationUrl +) { +} diff --git a/src/main/java/com/studentvote/domain/electionManagementInfo/presentation/ElectionBasicInfoController.java b/src/main/java/com/studentvote/domain/electionManagementInfo/presentation/ElectionBasicInfoController.java new file mode 100644 index 0000000..4603554 --- /dev/null +++ b/src/main/java/com/studentvote/domain/electionManagementInfo/presentation/ElectionBasicInfoController.java @@ -0,0 +1,36 @@ +package com.studentvote.domain.electionManagementInfo.presentation; + +import com.studentvote.domain.electionManagementInfo.application.ElectionBasicInfoService; +import com.studentvote.domain.electionManagementInfo.domain.ElectionBasicInfo; +import com.studentvote.domain.electionManagementInfo.dto.request.ElectionBasicInfoRequest; +import com.studentvote.domain.electionManagementInfo.dto.response.ElectionBasicInfoResponse; +import com.studentvote.global.error.DefaultException; +import com.studentvote.global.payload.ErrorCode; +import com.studentvote.global.payload.ResponseCustom; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Base64; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/electionBasicInfo") +public class ElectionBasicInfoController { + + private final ElectionBasicInfoService electionBasicInfoService; + + @PostMapping("/upload") + public ResponseCustom uploadElectionInfo( + @ModelAttribute ElectionBasicInfoRequest request) { + ElectionBasicInfoResponse electionBasicInfoResponse = electionBasicInfoService.uploadElectionInfo(request); + return ResponseCustom.OK(electionBasicInfoResponse); + } + +} diff --git a/src/main/java/com/studentvote/domain/user/domain/ApprovalStatus.java b/src/main/java/com/studentvote/domain/user/domain/ApprovalStatus.java new file mode 100644 index 0000000..4e91191 --- /dev/null +++ b/src/main/java/com/studentvote/domain/user/domain/ApprovalStatus.java @@ -0,0 +1,7 @@ +package com.studentvote.domain.user.domain; + +public enum ApprovalStatus { + PENDING, + APPROVED, + REJECTED +} diff --git a/src/main/java/com/studentvote/domain/user/domain/Role.java b/src/main/java/com/studentvote/domain/user/domain/Role.java new file mode 100644 index 0000000..5e3e9e2 --- /dev/null +++ b/src/main/java/com/studentvote/domain/user/domain/Role.java @@ -0,0 +1,5 @@ +package com.studentvote.domain.user.domain; + +public enum Role { + USER, ADMIN +} \ No newline at end of file diff --git a/src/main/java/com/studentvote/domain/user/domain/User.java b/src/main/java/com/studentvote/domain/user/domain/User.java index 7222c26..2f329fa 100644 --- a/src/main/java/com/studentvote/domain/user/domain/User.java +++ b/src/main/java/com/studentvote/domain/user/domain/User.java @@ -1,7 +1,10 @@ package com.studentvote.domain.user.domain; +import com.studentvote.domain.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -12,7 +15,8 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User { +public class User extends BaseEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", updatable = false) @@ -20,7 +24,26 @@ public class User { private String email; + private String username; + private String password; - private String name; + @Enumerated(EnumType.STRING) + private Role role; + + @Enumerated(EnumType.STRING) + private ApprovalStatus approvalStatus; + + + private User(String email, String password, String name) { + this.email = email; + this.password = password; + this.username = name; + this.role = Role.USER; + this.approvalStatus = ApprovalStatus.PENDING; + } + + public static User of(String email, String password, String name) { + return new User(email, password, name); + } } \ No newline at end of file diff --git a/src/main/java/com/studentvote/domain/user/domain/repository/UserRepository.java b/src/main/java/com/studentvote/domain/user/domain/repository/UserRepository.java index e3aae9e..934e12d 100644 --- a/src/main/java/com/studentvote/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/com/studentvote/domain/user/domain/repository/UserRepository.java @@ -1,7 +1,17 @@ package com.studentvote.domain.user.domain.repository; +import com.studentvote.domain.user.domain.ApprovalStatus; import com.studentvote.domain.user.domain.User; +import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { + Boolean existsByEmail(String email); + + User findByUsername(String username); + + Optional findByEmail(String email); + + Optional> findAllByApprovalStatus(ApprovalStatus approvalStatus); } diff --git a/src/main/java/com/studentvote/global/config/TimeZoneConfig.java b/src/main/java/com/studentvote/global/config/TimeZoneConfig.java new file mode 100644 index 0000000..b6a6635 --- /dev/null +++ b/src/main/java/com/studentvote/global/config/TimeZoneConfig.java @@ -0,0 +1,15 @@ +package com.studentvote.global.config; + +import java.util.TimeZone; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TimeZoneConfig { + + @Bean + public TimeZone timeZone() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); + return TimeZone.getDefault(); + } +} diff --git a/src/main/java/com/studentvote/global/config/s3/S3Config.java b/src/main/java/com/studentvote/global/config/s3/S3Config.java new file mode 100644 index 0000000..be10390 --- /dev/null +++ b/src/main/java/com/studentvote/global/config/s3/S3Config.java @@ -0,0 +1,31 @@ +package com.studentvote.global.config.s3; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/studentvote/global/config/security/SecurityConfig.java b/src/main/java/com/studentvote/global/config/security/SecurityConfig.java index 29545d5..130280f 100644 --- a/src/main/java/com/studentvote/global/config/security/SecurityConfig.java +++ b/src/main/java/com/studentvote/global/config/security/SecurityConfig.java @@ -2,19 +2,40 @@ import static org.springframework.security.config.Customizer.*; +import com.studentvote.global.config.security.jwt.JWTFilter; +import com.studentvote.global.config.security.jwt.JWTUtil; +import com.studentvote.global.config.security.jwt.LoginFilter; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @RequiredArgsConstructor @Configuration @EnableWebSecurity public class SecurityConfig { + + private final AuthenticationConfiguration authenticationConfiguration; + private final JWTUtil jwtUtil; + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) + throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http @@ -31,8 +52,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((auth) -> auth .requestMatchers("/", "/**").permitAll() + .requestMatchers("/admin").hasRole("ADMIN") .requestMatchers("/reissue").permitAll() .anyRequest().authenticated()); +// LoginFilter loginFilter = new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil); +// loginFilter.setFilterProcessesUrl("/api/v1/auth/sign-in"); // 로그인 URL 설정 + http + .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); +// http +// .addFilterAt(loginFilter, UsernamePasswordAuthenticationFilter.class); // 세션 설정 : STATELESS http .sessionManagement((session) -> session diff --git a/src/main/java/com/studentvote/global/config/security/WebMvcConfig.java b/src/main/java/com/studentvote/global/config/security/WebMvcConfig.java new file mode 100644 index 0000000..c658763 --- /dev/null +++ b/src/main/java/com/studentvote/global/config/security/WebMvcConfig.java @@ -0,0 +1,27 @@ +package com.studentvote.global.config.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + private final long MAX_AGE_SECS = 3600; + + @Value("${app.cors.allowed-origins}") + private String[] allowedOrigins; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(allowedOrigins) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .exposedHeaders(HttpHeaders.LOCATION,"Authorization") + .allowCredentials(true) + .maxAge(MAX_AGE_SECS); + } +} diff --git a/src/main/java/com/studentvote/global/config/security/jwt/JWTFilter.java b/src/main/java/com/studentvote/global/config/security/jwt/JWTFilter.java new file mode 100644 index 0000000..2b1c418 --- /dev/null +++ b/src/main/java/com/studentvote/global/config/security/jwt/JWTFilter.java @@ -0,0 +1,57 @@ +package com.studentvote.global.config.security.jwt; + +import com.studentvote.domain.auth.dto.response.CustomUserDetails; +import com.studentvote.domain.user.domain.User; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class JWTFilter extends OncePerRequestFilter { + + private final JWTUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authorization = request.getHeader("Authorization"); + + if (authorization == null || !authorization.startsWith("Bearer ")) { + System.out.println("token null"); + filterChain.doFilter(request, response); + + return; + } + + String token = authorization.split(" ")[1]; + + if (jwtUtil.isExpired(token)) { + System.out.println("token expired"); + filterChain.doFilter(request, response); + + return; + } + + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + + User user = User.of(username, "temp", role); + + CustomUserDetails customUserDetails = new CustomUserDetails(user); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + customUserDetails, null, customUserDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authToken); + + System.out.println("Authentication set in SecurityContextHolder: " + SecurityContextHolder.getContext().getAuthentication()); + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/studentvote/global/config/security/jwt/JWTUtil.java b/src/main/java/com/studentvote/global/config/security/jwt/JWTUtil.java new file mode 100644 index 0000000..c3eb605 --- /dev/null +++ b/src/main/java/com/studentvote/global/config/security/jwt/JWTUtil.java @@ -0,0 +1,43 @@ +package com.studentvote.global.config.security.jwt; + +import io.jsonwebtoken.Jwts; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JWTUtil { + + private SecretKey secretKey; + + public JWTUtil(@Value("${app.auth.token-secret}") String secret) { + this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm()); + } + + public String getUsername(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload() + .get("username", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date()); + } + + public String createJwt(String username, String role, Long expiredMs) { + return Jwts.builder() + .claim("username", username) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/java/com/studentvote/global/config/security/jwt/LoginFilter.java b/src/main/java/com/studentvote/global/config/security/jwt/LoginFilter.java new file mode 100644 index 0000000..49eaa45 --- /dev/null +++ b/src/main/java/com/studentvote/global/config/security/jwt/LoginFilter.java @@ -0,0 +1,89 @@ +package com.studentvote.global.config.security.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.studentvote.domain.auth.dto.request.SignInRequest; +import com.studentvote.domain.auth.dto.response.CustomUserDetails; +import com.studentvote.domain.auth.dto.response.SignInResponse; +import com.studentvote.global.payload.Message; +import com.studentvote.global.payload.ResponseCustom; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collection; +import java.util.Iterator; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@RequiredArgsConstructor +public class LoginFilter extends UsernamePasswordAuthenticationFilter { + + private final AuthenticationManager authenticationManager; + private final JWTUtil jwtUtil; + + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + try { + SignInRequest signInRequest = new ObjectMapper().readValue(request.getInputStream(), SignInRequest.class); + + String username = signInRequest.email(); + String password = signInRequest.password(); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + username, password, null); + + return authenticationManager.authenticate(authToken); + + } catch (IOException e) { + throw new RuntimeException("Failed to parse login request", e); + } + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal(); + + String username = customUserDetails.getUsername(); + + Collection authorities = authResult.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + String accessToken = jwtUtil.createJwt(username, role, 36000000000L); + String refreshToken = jwtUtil.createJwt(username, role, 360000000000L); + + SignInResponse signInResponse = SignInResponse + .builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .role(customUserDetails.user().getRole()) + .build(); + + ResponseCustom responseCustom = ResponseCustom.OK(signInResponse); + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + new ObjectMapper().writeValue(response.getWriter(), responseCustom); + } + + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + ResponseCustom responseCustom = ResponseCustom.OK(Message.builder().message("로그인에 실패했습니다.").build()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + new ObjectMapper().writeValue(response.getWriter(), responseCustom); + } +} diff --git a/src/main/java/com/studentvote/global/payload/ErrorCode.java b/src/main/java/com/studentvote/global/payload/ErrorCode.java index 1806391..1068f16 100644 --- a/src/main/java/com/studentvote/global/payload/ErrorCode.java +++ b/src/main/java/com/studentvote/global/payload/ErrorCode.java @@ -10,7 +10,11 @@ public enum ErrorCode { INVALID_FILE_PATH(400,null,"잘못된 파일 경로 입니다."), INVALID_OPTIONAL_ISPRESENT(400,null,"해당 값이 존재하지 않습니다."), INVALID_CHECK(400,null,"해당 값이 유효하지 않습니다."), - INVALID_AUTHENTICATION(400,null,"잘못된 인증입니다."); + INVALID_AUTHENTICATION(400,null,"잘못된 인증입니다."), + INVALID_ELECTIONCOMMITTEENOTICE(400,null,"선거관리위원회 공고문이 누락되었습니다."), + INVALID_ELECTIONREGULATIONAMENDNOTICE(400,null,"선거규정 개정공고문이 누락되었습니다."), + INVALID_CANDIDATERECUITMENTNOTICE(400,null,"후보자 모집공고문이 누락되었습니다."), + INVALID_ELECTIONREGULATION(400,null,"선거규정이 누락되었습니다."); private final String code; private final String message;