diff --git a/build.gradle b/build.gradle index 2a3052a..3b2513f 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,14 @@ dependencies { // AWS implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // Spring Security OAUTH 2.1 + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-oauth2-client' + + // JWT token + implementation 'io.jsonwebtoken:jjwt:0.12.3' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/fairytale/tbd/domain/user/converter/UserConverter.java b/src/main/java/fairytale/tbd/domain/user/converter/UserConverter.java index af72d5e..fa58d05 100644 --- a/src/main/java/fairytale/tbd/domain/user/converter/UserConverter.java +++ b/src/main/java/fairytale/tbd/domain/user/converter/UserConverter.java @@ -1,20 +1,23 @@ package fairytale.tbd.domain.user.converter; +import java.util.ArrayList; + import fairytale.tbd.domain.user.entity.User; import fairytale.tbd.domain.user.web.dto.UserRequestDTO; import fairytale.tbd.domain.user.web.dto.UserResponseDTO; public class UserConverter { - public static User toUser(UserRequestDTO.AddUserDTO request){ + public static User toUser(UserRequestDTO.AddUserDTO request, String encodedPassword) { return User.builder() .loginId(request.getLoginId()) - .password(request.getPassword()) + .password(encodedPassword) .username(request.getUsername()) .gender(request.getGender()) + .authorityList(new ArrayList<>()) .build(); } - public static UserResponseDTO.AddUserResultDTO toAddUserResultDTO(User user){ + public static UserResponseDTO.AddUserResultDTO toAddUserResultDTO(User user) { return UserResponseDTO.AddUserResultDTO.builder() .userId(user.getId()) .createdAt(user.getCreatedAt()) diff --git a/src/main/java/fairytale/tbd/domain/user/entity/Authority.java b/src/main/java/fairytale/tbd/domain/user/entity/Authority.java new file mode 100644 index 0000000..3655b7f --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/entity/Authority.java @@ -0,0 +1,39 @@ +package fairytale.tbd.domain.user.entity; + +import fairytale.tbd.domain.user.enums.Role; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Authority { + @Id + @GeneratedValue(strategy= GenerationType.IDENTITY) + @Column(name = "authority_id") + private Long id; + + @Column(name = "authority_role") + private Role role; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + // 연관 관계 편의 메서드 + public void setUser(User user){ + this.user = user; + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/entity/User.java b/src/main/java/fairytale/tbd/domain/user/entity/User.java index bdd65ad..7c6c076 100644 --- a/src/main/java/fairytale/tbd/domain/user/entity/User.java +++ b/src/main/java/fairytale/tbd/domain/user/entity/User.java @@ -1,6 +1,9 @@ package fairytale.tbd.domain.user.entity; +import java.util.ArrayList; +import java.util.List; + import fairytale.tbd.domain.user.enums.Gender; import fairytale.tbd.domain.voice.entity.Voice; import fairytale.tbd.global.entity.BaseEntity; @@ -13,6 +16,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -45,9 +49,15 @@ public class User extends BaseEntity { @Column(name = "username", nullable = false) private String username; - @OneToOne(mappedBy = "user", cascade = CascadeType.ALL) + @Column(name = "refresh_token", nullable = true) + private String refreshToken; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private Voice voice; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List authorityList = new ArrayList<>(); + // 연관관계 편의 메서드 public void setVoice(Voice voice){ @@ -55,4 +65,14 @@ public void setVoice(Voice voice){ voice.setUser(this); } + public void addAuthority(Authority authority){ + authorityList.add(authority); + authority.setUser(this); + } + + // RefreshToken update + public void updateRefreshToken(String refreshToken){ + this.refreshToken = refreshToken; + } + } diff --git a/src/main/java/fairytale/tbd/domain/user/enums/Role.java b/src/main/java/fairytale/tbd/domain/user/enums/Role.java new file mode 100644 index 0000000..beb6aaa --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/enums/Role.java @@ -0,0 +1,5 @@ +package fairytale.tbd.domain.user.enums; + +public enum Role { + ROLE_USER, ROLE_ADMIN, ROLE_GUEST +} diff --git a/src/main/java/fairytale/tbd/domain/user/exception/UserNotExistException.java b/src/main/java/fairytale/tbd/domain/user/exception/UserNotExistException.java new file mode 100644 index 0000000..6614ee1 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/exception/UserNotExistException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.user.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class UserNotExistException extends GeneralException { + public UserNotExistException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/user/repository/UserRepository.java b/src/main/java/fairytale/tbd/domain/user/repository/UserRepository.java index f4c4afe..94ff231 100644 --- a/src/main/java/fairytale/tbd/domain/user/repository/UserRepository.java +++ b/src/main/java/fairytale/tbd/domain/user/repository/UserRepository.java @@ -10,7 +10,10 @@ @Repository public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); + Optional findByLoginId(String loginId); Optional findById(Long userId); + Optional findByRefreshToken(String refreshToken); + } diff --git a/src/main/java/fairytale/tbd/domain/user/service/UserCommandServiceImpl.java b/src/main/java/fairytale/tbd/domain/user/service/UserCommandServiceImpl.java index 4a6ef46..2456afa 100644 --- a/src/main/java/fairytale/tbd/domain/user/service/UserCommandServiceImpl.java +++ b/src/main/java/fairytale/tbd/domain/user/service/UserCommandServiceImpl.java @@ -1,10 +1,13 @@ package fairytale.tbd.domain.user.service; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import fairytale.tbd.domain.user.converter.UserConverter; +import fairytale.tbd.domain.user.entity.Authority; import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.enums.Role; import fairytale.tbd.domain.user.repository.UserRepository; import fairytale.tbd.domain.user.web.dto.UserRequestDTO; import lombok.RequiredArgsConstructor; @@ -12,14 +15,20 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class UserCommandServiceImpl implements UserCommandService{ +public class UserCommandServiceImpl implements UserCommandService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; @Transactional @Override public User addUser(UserRequestDTO.AddUserDTO request) { - User user = UserConverter.toUser(request); + String encodedPassword = passwordEncoder.encode(request.getPassword()); + User user = UserConverter.toUser(request, encodedPassword); + Authority authority = Authority.builder() + .role(Role.ROLE_USER) + .build(); + user.addAuthority(authority); return userRepository.save(user); } } diff --git a/src/main/java/fairytale/tbd/domain/user/service/UserQueryService.java b/src/main/java/fairytale/tbd/domain/user/service/UserQueryService.java new file mode 100644 index 0000000..13964d6 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/service/UserQueryService.java @@ -0,0 +1,12 @@ +package fairytale.tbd.domain.user.service; + +import java.util.Optional; + +import fairytale.tbd.domain.user.entity.User; + +public interface UserQueryService { + + Optional getUserWithAuthorities(String loginId); + + void updateRefreshToken(User user, String reIssuedRefreshToken); +} diff --git a/src/main/java/fairytale/tbd/domain/user/service/UserQueryServiceImpl.java b/src/main/java/fairytale/tbd/domain/user/service/UserQueryServiceImpl.java new file mode 100644 index 0000000..e1a3762 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/user/service/UserQueryServiceImpl.java @@ -0,0 +1,33 @@ +package fairytale.tbd.domain.user.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserQueryServiceImpl implements UserQueryService { + + private final UserRepository userRepository; + + @Override + public Optional getUserWithAuthorities(String loginId) { + User user = userRepository.findByLoginId(loginId).orElse(null); + user.getAuthorityList().size(); + return Optional.ofNullable(user); + } + + @Transactional + @Override + public void updateRefreshToken(User user, String reIssuedRefreshToken) { + user.updateRefreshToken(reIssuedRefreshToken); + userRepository.saveAndFlush(user); + } + +} diff --git a/src/main/java/fairytale/tbd/domain/user/web/controller/UserRestController.java b/src/main/java/fairytale/tbd/domain/user/web/controller/UserRestController.java index 4dc131f..225fcb8 100644 --- a/src/main/java/fairytale/tbd/domain/user/web/controller/UserRestController.java +++ b/src/main/java/fairytale/tbd/domain/user/web/controller/UserRestController.java @@ -24,7 +24,7 @@ public class UserRestController { private final UserCommandService userCommandService; private static final Logger LOGGER = LogManager.getLogger(UserRestController.class); - @PostMapping("") + @PostMapping("/signup") public ApiResponse join(@Valid @RequestBody UserRequestDTO.AddUserDTO request) { LOGGER.info("request = {}", request); User user = userCommandService.addUser(request); diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/Fairytale.java b/src/main/java/fairytale/tbd/domain/voice/entity/Fairytale.java new file mode 100644 index 0000000..cf4ad7c --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/Fairytale.java @@ -0,0 +1,42 @@ +package fairytale.tbd.domain.voice.entity; + +import java.util.ArrayList; +import java.util.List; + +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Fairytale extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fairytale_id") + private Long id; + + @Column(name = "fairytale_name", nullable = false) + private String name; + + @OneToMany(mappedBy = "fairytale", cascade = CascadeType.ALL, orphanRemoval = true) + private List segmentList = new ArrayList<>(); + + // 연관 관계 편의 메서드 + public void addSegment(Segment segment) { + segmentList.add(segment); + segment.setFairytale(this); + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/Segment.java b/src/main/java/fairytale/tbd/domain/voice/entity/Segment.java new file mode 100644 index 0000000..282fac2 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/Segment.java @@ -0,0 +1,69 @@ +package fairytale.tbd.domain.voice.entity; + +import fairytale.tbd.domain.voice.enums.VoiceType; +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "fairytale_segment") +public class Segment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "fariytale_segment_id") + private Long id; + + @Column(name = "segment_context", nullable = false) + private String context; + + @Column(name = "is_main_character", nullable = false) + private boolean isMainCharacter; + + @Column(name = "voice_type", nullable = false) + private VoiceType voiceType; + + @Column(name = "segment_num", nullable = false) + private Long num; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fairytale_id", nullable = false) + private Fairytale fairytale; + + @OneToOne(mappedBy = "segment", cascade = CascadeType.ALL, orphanRemoval = true) + private TTSSegment ttsSegment; + + @OneToOne(mappedBy = "segment", cascade = CascadeType.ALL, orphanRemoval = true) + private UserTTSSegment userTTSSegment; + + // 연관 관계 편의 메서드 + + public void setFairytale(Fairytale fairytale) { + this.fairytale = fairytale; + } + + public void setTtsSegment(TTSSegment ttsSegment) { + this.ttsSegment = ttsSegment; + } + + public void setUserTTSSegment(UserTTSSegment userTTSSegment) { + this.userTTSSegment = userTTSSegment; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/TTSSegment.java b/src/main/java/fairytale/tbd/domain/voice/entity/TTSSegment.java new file mode 100644 index 0000000..9864204 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/TTSSegment.java @@ -0,0 +1,46 @@ +package fairytale.tbd.domain.voice.entity; + +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "text_to_speech_segment") +public class TTSSegment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "text_to_speech_segment_id") + private Long id; + + @Column(name = "text_to_speech_segment_url") + private String url; + + @Column(name = "history_id") + private String historyId; + + @OneToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "fairytale_segment_id") + private Segment segment; + + // 연관 관계 편의 메서드 + + public void setSegment(Segment segment) { + this.segment = segment; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/UserTTSSegment.java b/src/main/java/fairytale/tbd/domain/voice/entity/UserTTSSegment.java new file mode 100644 index 0000000..84345bf --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/UserTTSSegment.java @@ -0,0 +1,54 @@ +package fairytale.tbd.domain.voice.entity; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "user_text_to_speech_segment") +public class UserTTSSegment extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "uset_text_to_speech_segment_id") + private Long id; + + @Column(name = "history_id", nullable = false) + private String historyId; + + @Column(name = "user_text_to_speech_segment_url") + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @OneToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "fairytale_segment_id") + private Segment segment; + + // 연관 관계 편의 메서드 + public void setUser(User user) { + this.user = user; + } + + public void setSegment(Segment segment) { + this.segment = segment; + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/Voice.java b/src/main/java/fairytale/tbd/domain/voice/entity/Voice.java index a7a3816..59f3506 100644 --- a/src/main/java/fairytale/tbd/domain/voice/entity/Voice.java +++ b/src/main/java/fairytale/tbd/domain/voice/entity/Voice.java @@ -1,5 +1,8 @@ package fairytale.tbd.domain.voice.entity; +import java.util.ArrayList; +import java.util.List; + import fairytale.tbd.domain.user.entity.User; import fairytale.tbd.global.entity.BaseEntity; import jakarta.persistence.CascadeType; @@ -9,6 +12,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; import jakarta.persistence.OneToOne; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -31,15 +35,20 @@ public class Voice extends BaseEntity { @Column(name = "voice_key_id", nullable = false) private String keyId; - @OneToOne @JoinColumn(name = "user_id") private User user; + @OneToMany(mappedBy = "voice", cascade = CascadeType.ALL, orphanRemoval = true) + private List voiceSampleList = new ArrayList<>(); // 연관 관계 편의 메소드 - public void setUser(User user){ + public void setUser(User user) { this.user = user; + } + public void addVoiceSample(VoiceSample voiceSample) { + voiceSampleList.add(voiceSample); + voiceSample.setVoice(this); } } diff --git a/src/main/java/fairytale/tbd/domain/voice/entity/VoiceSample.java b/src/main/java/fairytale/tbd/domain/voice/entity/VoiceSample.java new file mode 100644 index 0000000..d22bf6a --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/entity/VoiceSample.java @@ -0,0 +1,40 @@ +package fairytale.tbd.domain.voice.entity; + +import fairytale.tbd.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class VoiceSample extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "voice_sample_id") + private Long id; + + @Column(name = "voice_sample_url") + private String url; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "voice_id") + private Voice voice; + + // 연관 관계 편의 메서드 + public void setVoice(Voice voice) { + this.voice = voice; + } + +} diff --git a/src/main/java/fairytale/tbd/domain/voice/enums/VoiceType.java b/src/main/java/fairytale/tbd/domain/voice/enums/VoiceType.java new file mode 100644 index 0000000..c7b9892 --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/enums/VoiceType.java @@ -0,0 +1,5 @@ +package fairytale.tbd.domain.voice.enums; + +public enum VoiceType { + OLD_WOMAN, OLD_MAN, YOUNG_WOMAN, YOUNG_MAN; +} diff --git a/src/main/java/fairytale/tbd/domain/voice/exception/ExistVoiceException.java b/src/main/java/fairytale/tbd/domain/voice/exception/ExistVoiceException.java new file mode 100644 index 0000000..0df512f --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/exception/ExistVoiceException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.voice.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class ExistVoiceException extends GeneralException { + public ExistVoiceException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/exception/VoiceSaveErrorException.java b/src/main/java/fairytale/tbd/domain/voice/exception/VoiceSaveErrorException.java new file mode 100644 index 0000000..be3f8cb --- /dev/null +++ b/src/main/java/fairytale/tbd/domain/voice/exception/VoiceSaveErrorException.java @@ -0,0 +1,10 @@ +package fairytale.tbd.domain.voice.exception; + +import fairytale.tbd.global.enums.statuscode.BaseCode; +import fairytale.tbd.global.exception.GeneralException; + +public class VoiceSaveErrorException extends GeneralException { + public VoiceSaveErrorException(BaseCode errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/fairytale/tbd/domain/voice/repository/VoiceRepository.java b/src/main/java/fairytale/tbd/domain/voice/repository/VoiceRepository.java index 63b3f52..20d9398 100644 --- a/src/main/java/fairytale/tbd/domain/voice/repository/VoiceRepository.java +++ b/src/main/java/fairytale/tbd/domain/voice/repository/VoiceRepository.java @@ -1,7 +1,5 @@ package fairytale.tbd.domain.voice.repository; -import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -9,4 +7,5 @@ @Repository public interface VoiceRepository extends JpaRepository { + boolean existsVoiceByUserId(Long userId); } diff --git a/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandService.java b/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandService.java index 4fb762b..ff8d15a 100644 --- a/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandService.java +++ b/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandService.java @@ -1,8 +1,9 @@ package fairytale.tbd.domain.voice.service; +import fairytale.tbd.domain.user.entity.User; import fairytale.tbd.domain.voice.entity.Voice; import fairytale.tbd.domain.voice.web.dto.VoiceRequestDTO; public interface VoiceCommandService { - Voice uploadVoice(VoiceRequestDTO.AddVoiceDTO request); + Voice uploadVoice(VoiceRequestDTO.AddVoiceDTO request, User user); } diff --git a/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandServiceImpl.java b/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandServiceImpl.java index 610a994..d1882ad 100644 --- a/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandServiceImpl.java +++ b/src/main/java/fairytale/tbd/domain/voice/service/VoiceCommandServiceImpl.java @@ -1,32 +1,29 @@ package fairytale.tbd.domain.voice.service; -import java.util.Optional; - import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import net.andrewcpu.elevenlabs.ElevenLabs; - import fairytale.tbd.domain.user.entity.User; -import fairytale.tbd.domain.user.repository.UserRepository; import fairytale.tbd.domain.voice.converter.VoiceConverter; import fairytale.tbd.domain.voice.entity.Voice; +import fairytale.tbd.domain.voice.exception.ExistVoiceException; +import fairytale.tbd.domain.voice.exception.VoiceSaveErrorException; import fairytale.tbd.domain.voice.repository.VoiceRepository; import fairytale.tbd.domain.voice.web.dto.VoiceRequestDTO; import fairytale.tbd.global.elevenlabs.ElevenlabsManager; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class VoiceCommandServiceImpl implements VoiceCommandService{ +public class VoiceCommandServiceImpl implements VoiceCommandService { private static final Logger LOGGER = LogManager.getLogger(VoiceCommandServiceImpl.class); private final ElevenlabsManager elevenlabsManager; - private final UserRepository userRepository; private final VoiceRepository voiceRepository; /** @@ -36,13 +33,18 @@ public class VoiceCommandServiceImpl implements VoiceCommandService{ @Transactional @Override - public Voice uploadVoice(VoiceRequestDTO.AddVoiceDTO request) { - - Optional userOptional = userRepository.findById(6L); - User user = userOptional.get(); - - // TODO username session에서 가져오기 - String keyId = elevenlabsManager.addVoice("test", request.getSample()); + public Voice uploadVoice(VoiceRequestDTO.AddVoiceDTO request, User user) { + // 사용자의 목소리가 이미 저장되어 있으면 오류 + if (voiceRepository.existsVoiceByUserId(user.getId())) { + LOGGER.error("이미 존재하는 목소리 === userId = {}", user.getId()); + throw new ExistVoiceException(ErrorStatus._EXIST_VOICE); + } + + String keyId = elevenlabsManager.addVoice(user.getUsername(), request.getSample()); + if (keyId == null) { + LOGGER.error("Eleven Labs 음성 저장에 실패했습니다."); + throw new VoiceSaveErrorException(ErrorStatus._VOICE_SAVE_ERROR); + } Voice voice = VoiceConverter.toVoice(keyId); user.setVoice(voice); @@ -51,5 +53,4 @@ public Voice uploadVoice(VoiceRequestDTO.AddVoiceDTO request) { return voice; } - } diff --git a/src/main/java/fairytale/tbd/domain/voice/web/controller/VoiceRestController.java b/src/main/java/fairytale/tbd/domain/voice/web/controller/VoiceRestController.java index 09856aa..0e74bfb 100644 --- a/src/main/java/fairytale/tbd/domain/voice/web/controller/VoiceRestController.java +++ b/src/main/java/fairytale/tbd/domain/voice/web/controller/VoiceRestController.java @@ -4,15 +4,16 @@ import org.apache.logging.log4j.Logger; import org.springframework.web.bind.annotation.ModelAttribute; 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; +import fairytale.tbd.domain.user.entity.User; import fairytale.tbd.domain.voice.converter.VoiceConverter; import fairytale.tbd.domain.voice.entity.Voice; import fairytale.tbd.domain.voice.service.VoiceCommandService; import fairytale.tbd.domain.voice.web.dto.VoiceRequestDTO; import fairytale.tbd.domain.voice.web.dto.VoiceResponseDTO; +import fairytale.tbd.global.annotation.LoginUser; import fairytale.tbd.global.response.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -26,12 +27,11 @@ public class VoiceRestController { private final VoiceCommandService voiceCommandService; @PostMapping("") - public ApiResponse addVoice(@Valid @ModelAttribute VoiceRequestDTO.AddVoiceDTO request){ - // TODO 이미 존재하는 Voice 인지 검증 + public ApiResponse addVoice( + @Valid @ModelAttribute VoiceRequestDTO.AddVoiceDTO request, @LoginUser User user) { LOGGER.info("request = {}", request); - Voice voice = voiceCommandService.uploadVoice(request); + Voice voice = voiceCommandService.uploadVoice(request, user); return ApiResponse.onSuccess(VoiceConverter.toAddVoiceResult(voice)); } - } diff --git a/src/main/java/fairytale/tbd/global/annotation/LoginUser.java b/src/main/java/fairytale/tbd/global/annotation/LoginUser.java new file mode 100644 index 0000000..1b30fd8 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/annotation/LoginUser.java @@ -0,0 +1,14 @@ +package fairytale.tbd.global.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginUser { + +} diff --git a/src/main/java/fairytale/tbd/global/config/SecurityConfig.java b/src/main/java/fairytale/tbd/global/config/SecurityConfig.java new file mode 100644 index 0000000..65b3456 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/config/SecurityConfig.java @@ -0,0 +1,108 @@ +package fairytale.tbd.global.config; + +import java.util.Arrays; +import java.util.Collections; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.web.cors.CorsConfiguration; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.domain.user.service.UserQueryService; +import fairytale.tbd.global.security.LoginService; +import fairytale.tbd.global.security.jwt.JwtService; +import fairytale.tbd.global.security.jwt.filter.CustomUsernamePwdAuthenticationFilter; +import fairytale.tbd.global.security.jwt.filter.JwtAuthenticationFilter; +import fairytale.tbd.global.security.jwt.handler.JwtLoginFailureHandler; +import fairytale.tbd.global.security.jwt.handler.JwtLoginSuccessHandler; +import lombok.RequiredArgsConstructor; + +/** + * 인증은 CustomJsonUsernamePasswordAuthenticationFilter에서 authenticate()로 인증된 사용자로 처리 + * JwtAuthenticationProcessingFilter는 AccessToken, RefreshToken 재발급 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final LoginService loginService; + private final JwtService jwtService; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + private final JwtLoginSuccessHandler jwtLoginSuccessHandler; + private final JwtLoginFailureHandler jwtLoginFailureHandler; + private final UserQueryService userQueryService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) // csrf 보안 사용 X => Rest API + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy( + SessionCreationPolicy.STATELESS)) // Token 기반 인증 => session 사용 X + .authorizeHttpRequests((requests) -> requests + .requestMatchers("/login", "/api/user/signup").permitAll() // 허용된 주소 + .anyRequest().authenticated() + ) + // CORS + .cors(corsCustomizer -> corsCustomizer.configurationSource(request -> { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(Collections.singletonList("*")); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowCredentials(true); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setExposedHeaders(Arrays.asList("Authorization")); + config.setMaxAge(3600L); + return config; + })); + http.addFilterAfter(customUsernamePwdAuthenticationFilter(), LogoutFilter.class); + http.addFilterBefore(jwtAuthenticationFilter(), CustomUsernamePwdAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setPasswordEncoder(passwordEncoder()); + provider.setUserDetailsService(loginService); + return new ProviderManager(provider); + } + + @Bean + public CustomUsernamePwdAuthenticationFilter customUsernamePwdAuthenticationFilter() { + CustomUsernamePwdAuthenticationFilter customJsonUsernamePasswordLoginFilter + = new CustomUsernamePwdAuthenticationFilter(objectMapper); + customJsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager()); + customJsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(jwtLoginSuccessHandler); + customJsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(jwtLoginFailureHandler); + return customJsonUsernamePasswordLoginFilter; + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtService, + userRepository, userQueryService); + return jwtAuthenticationFilter; + } +} diff --git a/src/main/java/fairytale/tbd/global/config/WebConfig.java b/src/main/java/fairytale/tbd/global/config/WebConfig.java new file mode 100644 index 0000000..3e3ff43 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/config/WebConfig.java @@ -0,0 +1,22 @@ +package fairytale.tbd.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.global.resolver.LoginUserArgumentResolver; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final UserRepository userRepository; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new LoginUserArgumentResolver(userRepository)); + } +} diff --git a/src/main/java/fairytale/tbd/global/enums/statuscode/ErrorStatus.java b/src/main/java/fairytale/tbd/global/enums/statuscode/ErrorStatus.java index 69bc3f3..5239750 100644 --- a/src/main/java/fairytale/tbd/global/enums/statuscode/ErrorStatus.java +++ b/src/main/java/fairytale/tbd/global/enums/statuscode/ErrorStatus.java @@ -2,20 +2,25 @@ import org.springframework.http.HttpStatus; -public enum ErrorStatus implements BaseCode{ +public enum ErrorStatus implements BaseCode { // common _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), - // ElevenLabs _FILE_CONVERT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FILE5001", "파일 변환에 실패했습니다."), - // User - _EXIST_USERNAME(HttpStatus.BAD_REQUEST, "USER4001", "이미 존재하는 닉네임입니다."); + // Voice + _EXIST_VOICE(HttpStatus.BAD_REQUEST, "VOICE4001", "이미 존재하는 목소리입니다."), + _VOICE_SAVE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "VOICE4002", "목소리 저장에 실패했습니다."), + + // User + _EXIST_USERNAME(HttpStatus.BAD_REQUEST, "USER4001", "이미 존재하는 닉네임입니다."), + _USER_NOT_EXIST(HttpStatus.BAD_REQUEST, "USER4002", "존재하지 않는 사용자입니다."), + _AUTHORIZATION_FAILED(HttpStatus.BAD_REQUEST, "USER4003", "인증에 실패하였습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/fairytale/tbd/global/resolver/LoginUserArgumentResolver.java b/src/main/java/fairytale/tbd/global/resolver/LoginUserArgumentResolver.java new file mode 100644 index 0000000..ff36d5c --- /dev/null +++ b/src/main/java/fairytale/tbd/global/resolver/LoginUserArgumentResolver.java @@ -0,0 +1,54 @@ +package fairytale.tbd.global.resolver; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.exception.UserNotExistException; +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.global.annotation.LoginUser; +import fairytale.tbd.global.enums.statuscode.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { + private static final Logger LOGGER = LogManager.getLogger(LoginUserArgumentResolver.class); + private final UserRepository userRepository; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasParameterAnnotations = parameter.hasParameterAnnotation(LoginUser.class); + boolean hasUserType = User.class.isAssignableFrom(parameter.getParameterType()); + return hasParameterAnnotations && hasUserType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + throw new IllegalStateException("올바르지 않은 요청 타입입니다. webRequest : " + webRequest); + } + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + User user = null; + if (authentication.getPrincipal() instanceof UserDetails) { + LOGGER.info("UserDetails (JWT) Login"); + UserDetails userDetails = (UserDetails)authentication.getPrincipal(); + return userRepository.findByLoginId(userDetails.getUsername()) + .orElseThrow(() -> new UserNotExistException(ErrorStatus._USER_NOT_EXIST)); + } else { + LOGGER.info("알 수 없는 인증 타입"); + throw new IllegalStateException("지원하지 않는 인증 타입입니다."); + } + } +} diff --git a/src/main/java/fairytale/tbd/global/security/LoginService.java b/src/main/java/fairytale/tbd/global/security/LoginService.java new file mode 100644 index 0000000..9178362 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/LoginService.java @@ -0,0 +1,44 @@ +package fairytale.tbd.global.security; + +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class LoginService implements UserDetailsService { + + private final UserRepository userRepository; + private static final Logger LOGGER = LogManager.getLogger(UserDetailsService.class); + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("해당 아이디가 존재하지 않습니다.")); + + LOGGER.info("loadUserByUsername = {}", user); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getLoginId()) + .authorities(user.getAuthorityList() + .stream() + .map(authority -> new SimpleGrantedAuthority(authority.getRole().toString())) + .collect( + Collectors.toSet())) + .password(user.getPassword()) + .build(); + } +} diff --git a/src/main/java/fairytale/tbd/global/security/jwt/JwtService.java b/src/main/java/fairytale/tbd/global/security/jwt/JwtService.java new file mode 100644 index 0000000..23f85e4 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/JwtService.java @@ -0,0 +1,177 @@ +package fairytale.tbd.global.security.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Optional; + +import javax.crypto.SecretKey; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.domain.user.service.UserQueryService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Getter +/** + * JWT 관련 서비스 + */ +public class JwtService { + @Value("${spring.security.jwt.secretKey}") + private String secretKey; + + @Value("${spring.security.jwt.access.expiration}") + private Long accessTokenExpirationPeriod; + + @Value("${spring.security.jwt.refresh.expiration}") + private Long refreshTokenExpirationPeriod; + + @Value("${spring.security.jwt.access.header}") + private String accessHeader; + + @Value("${spring.security.jwt.refresh.header}") + private String refreshHeader; + + private static final Logger LOGGER = LogManager.getLogger(JwtService.class); + private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; + private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; + private static final String LOGINID_CLAIM = "loginId"; + private static final String BEARER = "Bearer "; + private final UserQueryService userQueryService; + + private final UserRepository userRepository; + + /** + * AccessToken 생성 + */ + public String createAccessToken(String loginId) { + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + return Jwts.builder() + .subject(ACCESS_TOKEN_SUBJECT) + .issuedAt(new Date()) + .expiration(new Date((new Date()).getTime() + accessTokenExpirationPeriod)) + .claim(LOGINID_CLAIM, loginId) + .signWith(key).compact(); + } + + /** + * RefreshToken 생성 + */ + public String createRefreshToken() { + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + return Jwts.builder() + .subject(REFRESH_TOKEN_SUBJECT) + .issuedAt(new Date()) + .expiration(new Date((new Date()).getTime() + refreshTokenExpirationPeriod)) + .signWith(key).compact(); + } + + /** + * AccessToken 헤더에 실어서 보내기 + */ + + public void sendAccessToken(HttpServletResponse response, String accessToken) { + response.setStatus(HttpServletResponse.SC_OK); + + response.setHeader(accessHeader, accessToken); + LOGGER.info("재발급된 Access Token : {}", accessToken); + } + + /** + * AccessToken + RefreshToken 헤더에 실어서 보내기 + */ + public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) { + response.setStatus(HttpServletResponse.SC_OK); + + setAccessTokenHeader(response, accessToken); + setRefreshTokenHeader(response, refreshToken); + LOGGER.info("Access Token, Refresh Token 헤더 설정 완료"); + } + + /** + * Token 내용 추출 + */ + public Optional extractRefreshToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(refreshHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + /** + * 헤더에서 AccessToken 추출 + */ + public Optional extractAccessToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(accessHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + /** + * AccessToken에서 LoginId 추출 + */ + public Optional extractLoginId(String accessToken) { + + SecretKey key = Keys.hmacShaKeyFor( + secretKey.getBytes(StandardCharsets.UTF_8)); + + Claims claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + + return Optional.ofNullable(String.valueOf(claims.get("loginId"))); + } + + /** + * AccessToken 헤더 설정 + */ + public void setAccessTokenHeader(HttpServletResponse response, String accessToken) { + response.setHeader(accessHeader, accessToken); + } + + /** + * RefreshToken 헤더 설정 + */ + public void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) { + response.setHeader(refreshHeader, refreshToken); + } + + /** + * RefreshToken DB 저장(업데이트) + */ + public void updateRefreshToken(String loginId, String refreshToken) { + User user = userRepository.findByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("일치하는 회원이 없습니다.")); + userQueryService.updateRefreshToken(user, refreshToken); + } + + /** + * 유효한 서명의 토큰인지 검증 + */ + public boolean isTokenValid(String token) { + SecretKey key = Keys.hmacShaKeyFor( + secretKey.getBytes(StandardCharsets.UTF_8)); + try { + Jwts.parser().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (Exception e) { + LOGGER.error("유효하지 않은 토큰입니다. {}", e.getMessage()); + return false; + } + } + +} diff --git a/src/main/java/fairytale/tbd/global/security/jwt/filter/CustomUsernamePwdAuthenticationFilter.java b/src/main/java/fairytale/tbd/global/security/jwt/filter/CustomUsernamePwdAuthenticationFilter.java new file mode 100644 index 0000000..62eff31 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/filter/CustomUsernamePwdAuthenticationFilter.java @@ -0,0 +1,73 @@ +package fairytale.tbd.global.security.jwt.filter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.StreamUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * JWT 로그인 POST 요청 왔을 때 인증 필터 + */ +public class CustomUsernamePwdAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + public static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; + private static final String HTTP_METHOD = "POST"; + private static final String CONTENT_TYPE = "application/json"; + private static final String LOGINID_KEY = "loginId"; + private static final String PASSWORD_KEY = "password"; + + private static final Logger LOGGER = LogManager.getLogger(CustomUsernamePwdAuthenticationFilter.class); + private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = + new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); + + private final ObjectMapper objectMapper; + + public CustomUsernamePwdAuthenticationFilter(ObjectMapper objectMapper) { + super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER); // 기존 formlogin 형태를 변경 + this.objectMapper = objectMapper; + } + + /** + * JWT 로컬 로그인 인증 과정 + */ + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws + AuthenticationException, + IOException, + ServletException { + // 지원하는 ContentType이 아닌 경우 + if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) { + throw new AuthenticationServiceException( + "Authentication Content-Type not supported: " + request.getContentType()); + } + + // request body에서 로그인 ID와 비밀번호 추출 + String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); + + Map usernamePasswordMap = objectMapper.readValue(messageBody, Map.class); + String loginId = usernamePasswordMap.get(LOGINID_KEY); + String password = usernamePasswordMap.get(PASSWORD_KEY); + + LOGGER.info("JWT Local Login ::: loginId = {}, password = {}", loginId, password); + + //principal 과 credentials 전달 + UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(loginId, password); + return this.getAuthenticationManager().authenticate(authRequest); + } + +} diff --git a/src/main/java/fairytale/tbd/global/security/jwt/filter/JwtAuthenticationFilter.java b/src/main/java/fairytale/tbd/global/security/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..aadbcb5 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,129 @@ +package fairytale.tbd.global.security.jwt.filter; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import fairytale.tbd.domain.user.entity.User; +import fairytale.tbd.domain.user.repository.UserRepository; +import fairytale.tbd.domain.user.service.UserQueryService; +import fairytale.tbd.global.security.jwt.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +/** + * JWT Authentication 필터 + */ +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtService jwtService; + private final UserRepository userRepository; + private final UserQueryService userQueryService; + + /** + * 로그인 요청 시 JWT 검증 X + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + return request.getServletPath().equals(CustomUsernamePwdAuthenticationFilter.DEFAULT_LOGIN_REQUEST_URL); + } + + /** + * JWT 검증 후 + * 요청에 Refresh Token 존재 -> Refresh Token 검증 후 Access Token, Refresh Token 생성 + * 요청에 Refresh Token 존재 X -> Access Token 검증 + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String refreshToken = jwtService.extractRefreshToken(request) + .filter(jwtService::isTokenValid) + .orElse(null); + + if (refreshToken != null) { + checkRefreshTokenAndReIssueAccessToken(response, refreshToken); + return; + } + + if (refreshToken == null) { + checkAccessTokenAndAuthentication(request, response, filterChain); + } + } + + /** + * Refresh Token이 유효한 지 검증 후 Access Token 재발급 + */ + public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) { + userRepository.findByRefreshToken(refreshToken) + .ifPresent(user -> { + String reIssuedRefreshToken = reIssueRefreshToken(user); + // AccessToken, RefreshToken response에 전달 + jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getLoginId()), + reIssuedRefreshToken); + }); + } + + /** + * Refresh Token 재발급 + */ + private String reIssueRefreshToken(User user) { + String reIssuedRefreshToken = jwtService.createRefreshToken(); + userQueryService.updateRefreshToken(user, reIssuedRefreshToken); + // 새로운 Refresh Token으로 업데이트 + return reIssuedRefreshToken; + } + + /** + * Access Token 검증 후 인증 + */ + public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + jwtService.extractAccessToken(request) + .filter(jwtService::isTokenValid) + .ifPresent(accessToken -> jwtService.extractLoginId(accessToken) + .ifPresent(loginId -> userQueryService.getUserWithAuthorities(loginId) + .ifPresent(this::saveAuthentication))); + + filterChain.doFilter(request, response); + } + + /** + * 검증된 토큰이면 인증 + */ + public void saveAuthentication(User myUser) { + String password = myUser.getPassword(); + if (password == null) { // 소셜 로그인 유저의 비밀번호 임의로 설정 하여 소셜 로그인 유저도 인증 되도록 설정 + password = UUID.randomUUID().toString(); + } + + Set authorities = myUser.getAuthorityList() + .stream() + .map(authority -> new SimpleGrantedAuthority(authority.getRole().toString())) + .collect( + Collectors.toSet()); + + UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder() + .username(myUser.getLoginId()) + .password(password) + .authorities(authorities) + .build(); + + Authentication authentication = + new UsernamePasswordAuthenticationToken(userDetailsUser, null, + authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginFailureHandler.java b/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginFailureHandler.java new file mode 100644 index 0000000..3c2776d --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginFailureHandler.java @@ -0,0 +1,31 @@ +package fairytale.tbd.global.security.jwt.handler; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +/** + * JWT 로그인 필터 Failure Handler + */ +public class JwtLoginFailureHandler implements AuthenticationFailureHandler { + private static final Logger LOGGER = LogManager.getLogger(JwtLoginSuccessHandler.class); + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws + IOException, ServletException { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("AUTHENTICATION FAILED."); + LOGGER.info("Jwt Login fail :: error = {}", exception.getMessage()); + } +} + diff --git a/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginSuccessHandler.java b/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginSuccessHandler.java new file mode 100644 index 0000000..d17f2a4 --- /dev/null +++ b/src/main/java/fairytale/tbd/global/security/jwt/handler/JwtLoginSuccessHandler.java @@ -0,0 +1,50 @@ +package fairytale.tbd.global.security.jwt.handler; + +import java.io.IOException; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import fairytale.tbd.global.security.jwt.JwtService; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +/** + * JWT 로그인 필터 Success Handler + */ +public class JwtLoginSuccessHandler implements AuthenticationSuccessHandler { + private final JwtService jwtService; + private static final Logger LOGGER = LogManager.getLogger(JwtLoginSuccessHandler.class); + + /** + * 인증에 성공하면 Access Token과 Refresh Token을 생성한 후 반환 + */ + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + UserDetails principal = (UserDetails)authentication.getPrincipal(); + String loginId = principal.getUsername(); + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write("AUTHENTICATION SUCCESS."); + LOGGER.info("Jwt Login Success :: Login ID = {}", loginId); + loginSuccess(response, loginId); + } + + private void loginSuccess(HttpServletResponse response, String loginId) throws IOException { + String accessToken = jwtService.createAccessToken(loginId); + String refreshToken = jwtService.createRefreshToken(); + response.addHeader(jwtService.getAccessHeader(), "Bearer " + accessToken); + response.addHeader(jwtService.getRefreshHeader(), "Bearer " + refreshToken); + + jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); + jwtService.updateRefreshToken(loginId, refreshToken); + } +}