diff --git a/spring-boot-api/build.gradle b/spring-boot-api/build.gradle index a0bd52a..18f14a4 100755 --- a/spring-boot-api/build.gradle +++ b/spring-boot-api/build.gradle @@ -17,8 +17,14 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' -// implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + // security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + // https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api + implementation group: 'javax.xml.bind', name: 'jaxb-api', version: '2.1' + + // for swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/api/UserController.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/api/UserController.java index aea198b..4a089ae 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/api/UserController.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/api/UserController.java @@ -13,6 +13,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.UUID; + @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor @@ -29,21 +31,21 @@ public ResponseEntity signUp(@RequestBody UserSignUpRequest @GetMapping("/{id}") @ToException @Operation(summary = "회원 정보", description = "유저정보 얻을 때 사용하는 API") - public ResponseEntity getInfo(@PathVariable Long id){ + public ResponseEntity getInfo(@PathVariable UUID id){ return ResponseEntity.status(HttpStatus.ACCEPTED).body(userService.getUserInfo(id)); } - @PatchMapping + @PatchMapping("/{id}") @ToException @Operation(summary = "회원 정보 수정", description = "회원 정보 수정 할 때 사용하는 API") - public ResponseEntity update(@RequestBody UserUpdateRequest userUpdateRequest){ - return ResponseEntity.status(HttpStatus.OK).body(userService.updateUserInfo(userUpdateRequest)); + public ResponseEntity update(@PathVariable(name = "id", required = true) UUID id, @RequestBody UserUpdateRequest userUpdateRequest){ + return ResponseEntity.status(HttpStatus.OK).body(userService.updateUserInfo(id, userUpdateRequest)); } @DeleteMapping("/{id}") @ToException @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 할 때 사용하는 API") - public ResponseEntity remove(@PathVariable Long id){ + public ResponseEntity remove(@PathVariable UUID id){ userService.removeUser(id); return ResponseEntity.status(HttpStatus.OK).build(); } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/application/UserService.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/application/UserService.java index 240d341..5f06d5a 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/application/UserService.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/application/UserService.java @@ -6,10 +6,14 @@ import com.ssafy.springbootapi.domain.user.dto.*; import com.ssafy.springbootapi.domain.user.exception.UserDuplicatedException; import com.ssafy.springbootapi.domain.user.exception.UserNotFoundException; +import com.ssafy.springbootapi.global.auth.jwt.refreshToken.RefreshTokenRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.util.UUID; + /* * TODO:: 사용자 정의 exception * - user not found exception @@ -18,13 +22,22 @@ @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; public UserSignUpResponse signUp(UserSignUpRequest requestDTO){ if(userRepository.findByEmail(requestDTO.getEmail()).isPresent()){ throw new UserDuplicatedException(requestDTO.getEmail()+"이미 존재하는 사용자"); } - User user = userRepository.save(requestDTO.toEntity()); + User user = userRepository.save( + User.builder() + .email(requestDTO.getEmail()) + .password(passwordEncoder.encode(requestDTO.getPassword())) // password 암호화 + .name(requestDTO.getName()) + .addresses(null) + .build()); + return UserSignUpResponse.builder() .email(user.getEmail()) .name(user.getName()) @@ -32,30 +45,39 @@ public UserSignUpResponse signUp(UserSignUpRequest requestDTO){ } - public UserInfoResponse getUserInfo(Long id) { + public UserInfoResponse getUserInfo(UUID id) { User user = userRepository.findById(id) .orElseThrow(()->new UserNotFoundException(id+" 사용자 없음")); return UserInfoResponse.builder() .email(user.getEmail()) .name(user.getName()) + .createdAt(user.getCreatedAt()) .addresses(user.getAddresses()) .build(); } @Transactional - public UserUpdateResponse updateUserInfo(UserUpdateRequest requestDTO) { - User user = userRepository.findById(requestDTO.getId()) - .orElseThrow(()->new UserNotFoundException(requestDTO.getEmail()+" 사용자 없음")); + public UserUpdateResponse updateUserInfo(UUID id, UserUpdateRequest requestDTO) { + User user = userRepository.findById(id) + .orElseThrow(()->new UserNotFoundException(id+" 사용자 없음")); + requestDTO.setPassword(passwordEncoder.encode(requestDTO.getPassword())); userMapper.updateUserFromDto(requestDTO,user); + user = userRepository.save(user); - return UserUpdateResponse.builder().email(user.getEmail()).name(user.getName()).build(); + return UserUpdateResponse.builder().email(user.getEmail()).name(user.getName()).createdAt(user.getCreatedAt()).build(); } - public void removeUser(Long id) { + @Transactional + public void removeUser(UUID id) { User user = userRepository.findById(id) .orElseThrow(()->new UserNotFoundException(id+" 사용자 없음")); + + // refresh 토큰 삭제 + refreshTokenRepository.findByUserId(id) + .ifPresent(refreshTokenRepository::delete); + userRepository.delete(user); } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dao/UserRepository.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dao/UserRepository.java index d318fae..b7ad7f1 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dao/UserRepository.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dao/UserRepository.java @@ -5,8 +5,9 @@ import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.UUID; @Repository -public interface UserRepository extends JpaRepository { +public interface UserRepository extends JpaRepository { Optional findByEmail(String email); } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/Address.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/Address.java index cb6e0a1..244fb89 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/Address.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/Address.java @@ -1,5 +1,6 @@ package com.ssafy.springbootapi.domain.user.domain; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Getter; @@ -26,8 +27,9 @@ public class Address { @Enumerated(EnumType.STRING) private IsDefault isDefault; - @ManyToOne // optional true => nullable + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "id") + @JsonIgnore private User user; } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/User.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/User.java index 052a2ad..33802c1 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/User.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/User.java @@ -1,28 +1,36 @@ package com.ssafy.springbootapi.domain.user.domain; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.GenericGenerator; +import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; /* * TODO:: id to uuid */ @Entity -@Table(name = "users") +@Table(name = "user") @NoArgsConstructor @AllArgsConstructor @Getter +@Setter @Builder +@ToString public class User { @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") - private Long id; + @GeneratedValue(generator = "UUID") + @GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator") + @Column(updatable = false, nullable = false) + private UUID id; +// @Id +// @GeneratedValue(strategy = GenerationType.IDENTITY) +// @Column(name = "id") +// private Long id; @Column(name = "email", unique = true, nullable = false) private String email; @@ -33,18 +41,15 @@ public class User { @Column(name = "name", unique = false, nullable = false) private String name; - @OneToMany(mappedBy = "user") - @Column(name = "addresses" , nullable = true) + @Enumerated(EnumType.STRING) + @Column(name = "type", columnDefinition = "varchar(255) default 'BUYER'") + private UserType type = UserType.BUYER; + + @Column(nullable = false, updatable = false) + @CreationTimestamp + private LocalDateTime createdAt; + + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY) private List
addresses; - @Override - public String toString() { - return "User{" + - "id=" + id + - ", email='" + email + '\'' + - ", password='" + password + '\'' + - ", name='" + name + '\'' + - ", addresses=" + addresses + - '}'; - } } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/UserType.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/UserType.java new file mode 100644 index 0000000..8ab0a9f --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/domain/UserType.java @@ -0,0 +1,14 @@ +package com.ssafy.springbootapi.domain.user.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserType { + ADMIN("ADMIN"), + BUYER("BUYER"), + SELLER("SELLER"); + + private String description; +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserInfoResponse.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserInfoResponse.java index 6731709..aa0a490 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserInfoResponse.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserInfoResponse.java @@ -5,6 +5,7 @@ import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; import java.util.List; @AllArgsConstructor @@ -13,5 +14,7 @@ public class UserInfoResponse { private String email; private String name; + private LocalDateTime createdAt; private List
addresses; + } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserLoginRequest.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserLoginRequest.java new file mode 100644 index 0000000..16f0782 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserLoginRequest.java @@ -0,0 +1,28 @@ +package com.ssafy.springbootapi.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class UserLoginRequest { + + @NotBlank(message = "email is blank!") + public String email; + + @NotBlank(message = "password is blank!") + public String password; + + @Override + public String toString() { + return "UserSignUpRequestDTO{" + + "email='" + email + '\'' + + ", password='" + password + '\'' + + '}'; + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserLoginResponse.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserLoginResponse.java new file mode 100644 index 0000000..2d2ff2e --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserLoginResponse.java @@ -0,0 +1,10 @@ +package com.ssafy.springbootapi.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class UserLoginResponse { + String accessToken; +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserSignUpRequest.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserSignUpRequest.java index 2289cc7..d62e68c 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserSignUpRequest.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserSignUpRequest.java @@ -4,10 +4,13 @@ import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Getter; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; @AllArgsConstructor @Getter public class UserSignUpRequest { + @NotBlank(message = "email is blank!") public String email; @@ -17,15 +20,6 @@ public class UserSignUpRequest { @NotBlank(message = "name is blank!") public String name; - public User toEntity(){ - return User.builder() - .email(email) - .password(password) - .name(name) - .addresses(null) - .build(); - } - @Override public String toString() { return "UserSignUpRequestDTO{" + diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserUpdateRequest.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserUpdateRequest.java index 572c16e..69eaa6a 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserUpdateRequest.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserUpdateRequest.java @@ -3,22 +3,12 @@ import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.Setter; @AllArgsConstructor @Getter +@Setter public class UserUpdateRequest { - @NotBlank(message = "id is blank!") - public Long id; - public String email; public String password; public String name; - - @Override - public String toString() { - return "UserSignUpRequestDTO{" + - "email='" + email + '\'' + - ", password='" + password + '\'' + - ", name='" + name + '\'' + - '}'; - } } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserUpdateResponse.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserUpdateResponse.java index 4115f88..ccc3a32 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserUpdateResponse.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/domain/user/dto/UserUpdateResponse.java @@ -4,10 +4,13 @@ import lombok.Builder; import lombok.Getter; +import java.time.LocalDateTime; + @AllArgsConstructor @Getter @Builder public class UserUpdateResponse { private String email; private String name; + private LocalDateTime createdAt; } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/.gitkeep b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/AuthController.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/AuthController.java new file mode 100644 index 0000000..7738e95 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/AuthController.java @@ -0,0 +1,66 @@ +package com.ssafy.springbootapi.global.auth; + +import com.ssafy.springbootapi.domain.user.dto.UserLoginRequest; +import com.ssafy.springbootapi.domain.user.dto.UserLoginResponse; +import com.ssafy.springbootapi.global.aop.annotation.ToException; +import com.ssafy.springbootapi.global.auth.dto.TokenRequest; +import com.ssafy.springbootapi.global.auth.dto.TokenResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 사용자 인증을 관리하는 컨트롤러 클래스. + * 로그인 및 토큰 재발급 기능을 제공합니다. + */ +@RequestMapping("/api/v1/auth") +@ControllerAdvice +@RequiredArgsConstructor +@RestController +public class AuthController { + private final AuthService authService; + + /** + * 사용자 로그인을 처리하고 로그인 응답을 반환합니다. + * + * @param userLoginRequest 로그인 요청 데이터 + * @return 로그인 성공 시 상태 코드 200과 함께 로그인 응답 데이터를 포함한 ResponseEntity 객체 반환 + */ + @PostMapping("/login") + @Operation(summary = "로그인", description = "로그인 할 때 사용하는 API") + public ResponseEntity login(@RequestBody UserLoginRequest userLoginRequest){ + return ResponseEntity.status(HttpStatus.OK).build(); + } + + /** + * 저장된 리프레시 토큰을 검증하고 새 액세스 토큰을 발급합니다. + * + * @param request HTTP 요청 정보를 포함하는 HttpServletRequest 객체 + * @return 새로운 액세스 토큰을 포함한 ResponseEntity 객체. 리프레시 토큰이 유효하지 않거나 없을 경우 401 Unauthorized 반환 + */ + @PostMapping("/token") + @Operation(summary = "토큰 재발급", description = "토큰 재발급 API") + public ResponseEntity token(HttpServletRequest request) { + String refreshToken = null; + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("refreshToken".equals(cookie.getName())) { + refreshToken = cookie.getValue(); + break; + } + } + } + if (refreshToken != null) { + String accessToken = authService.provideNewAccessToken(refreshToken); + return ResponseEntity.ok(TokenResponse.builder().accessToken(accessToken).build()); + } else { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } + +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/AuthService.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/AuthService.java new file mode 100644 index 0000000..1ce755c --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/AuthService.java @@ -0,0 +1,44 @@ +package com.ssafy.springbootapi.global.auth; + +import com.ssafy.springbootapi.global.auth.jwt.TokenProvider; +import com.ssafy.springbootapi.global.auth.jwt.refreshToken.RefreshTokenRepository; +import com.ssafy.springbootapi.global.error.InvalidTokenException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.UUID; + +/** + * 인증 관련 서비스를 제공하는 클래스. + * 주로 토큰 생성 및 검증을 담당합니다. + */ +@RequiredArgsConstructor +@Service +public class AuthService { + private final RefreshTokenRepository refreshTokenRepository; + private final TokenProvider tokenProvider; + + /** + * 제공된 리프레시 토큰의 유효성을 검사하고, 유효한 경우 새로운 액세스 토큰을 발급합니다. + * 리프레시 토큰이 유효하지 않을 경우 InvalidTokenException 예외를 발생시킵니다. + * + * @param refreshToken 검증하고자 하는 리프레시 토큰 + * @return 새로 발급된 액세스 토큰 문자열 + * @throws InvalidTokenException 리프레시 토큰이 유효하지 않을 경우 발생 + */ + public String provideNewAccessToken(String refreshToken) { + String accessToken = ""; + if (tokenProvider.validToken(refreshToken)){ + refreshTokenRepository.findByRefreshToken(refreshToken).orElseThrow(() -> + new InvalidTokenException("invalid refresh token!") + ); + Authentication authentication = tokenProvider.getAuthentication(refreshToken); + accessToken = tokenProvider.generateToken(UUID.fromString(authentication.getName()), Duration.ofMinutes(15L)); + } else { + throw new InvalidTokenException("invalid refresh token!"); + } + return accessToken; + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/SecurityUser/SecurityUser.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/SecurityUser/SecurityUser.java new file mode 100644 index 0000000..66158fa --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/SecurityUser/SecurityUser.java @@ -0,0 +1,69 @@ +package com.ssafy.springbootapi.global.auth.SecurityUser; + +import com.ssafy.springbootapi.domain.user.domain.User; +import com.ssafy.springbootapi.domain.user.domain.UserType; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public class SecurityUser implements UserDetails { + private final User user; + + public SecurityUser(User user) { + this.user = user; + } + + public UUID getUserId() { + return user.getId(); + } + + // 권한 반환 + @Override + public Collection getAuthorities() { + String type = null; + if(user.getType()==null){ + type = UserType.BUYER.toString(); + }else{ + type = user.getType().toString(); + } + return List.of(new SimpleGrantedAuthority("ROLE_"+type)); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + // 계정 만료여부 반환 + @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/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/SecurityUser/UserDetailService.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/SecurityUser/UserDetailService.java new file mode 100644 index 0000000..52da419 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/SecurityUser/UserDetailService.java @@ -0,0 +1,30 @@ +package com.ssafy.springbootapi.global.auth.SecurityUser; + +import com.ssafy.springbootapi.domain.user.dao.UserRepository; +import com.ssafy.springbootapi.domain.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class UserDetailService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String uuid) { + User user = userRepository.findById(UUID.fromString(uuid)) + .orElseThrow(()->new UsernameNotFoundException(uuid+" 사용자 없음")); + return new SecurityUser(user); + } + + public UUID loadUserIdByUsername(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(()->new UsernameNotFoundException(email+" 사용자 없음")); + return user.getId(); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/dto/TokenRequest.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/dto/TokenRequest.java new file mode 100644 index 0000000..8a4ed25 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/dto/TokenRequest.java @@ -0,0 +1,13 @@ +package com.ssafy.springbootapi.global.auth.dto; + +import lombok.Getter; +import lombok.Setter; + +/** + * access Token 재발급을 위한 요청 DTO + */ +@Getter +@Setter +public class TokenRequest { + private String refreshToken; +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/dto/TokenResponse.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/dto/TokenResponse.java new file mode 100644 index 0000000..502edce --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/dto/TokenResponse.java @@ -0,0 +1,15 @@ +package com.ssafy.springbootapi.global.auth.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +/** + * access Token 재발급을 위한 응답 DTO + */ +@Getter +@Setter +@Builder +public class TokenResponse { + private String accessToken; +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/CustomBCryptPasswordEncoder.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/CustomBCryptPasswordEncoder.java new file mode 100644 index 0000000..f7a31ee --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/CustomBCryptPasswordEncoder.java @@ -0,0 +1,37 @@ +package com.ssafy.springbootapi.global.auth.jsonAuthentication; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * BCrypt 방식으로 사용자의 비밀번호를 사용하기 위한 PasswordEncoder + */ +@Component +public class CustomBCryptPasswordEncoder implements PasswordEncoder { + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + public CustomBCryptPasswordEncoder() { + this.bCryptPasswordEncoder = new BCryptPasswordEncoder(); + } + + /** + * @param rawPassword 비밀번호 원본 + * @return encodedPassword 암호화된 비밀번호 + */ + @Override + public String encode(CharSequence rawPassword) { + return bCryptPasswordEncoder.encode(rawPassword); + } + + /** + * 로그인시 사용자 입력암호와 db에 저장된 인코딩 암호와 비교 + * @param rawPassword 사용자 입력 암호 + * @param encodedPassword db에 저장된 인코딩 암호 + * @return boolean + */ + @Override + public boolean matches(CharSequence rawPassword, String encodedPassword) { + return bCryptPasswordEncoder.matches(rawPassword, encodedPassword); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUserAuthenticationProvider.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUserAuthenticationProvider.java new file mode 100644 index 0000000..698b0a9 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUserAuthenticationProvider.java @@ -0,0 +1,46 @@ +package com.ssafy.springbootapi.global.auth.jsonAuthentication; + +import com.ssafy.springbootapi.global.auth.SecurityUser.UserDetailService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Json 데이터 타입으로 보낸 로그인 요청의 인증처리를 위한 인증 공급자 + */ +@RequiredArgsConstructor +@Component +public class JsonUserAuthenticationProvider implements AuthenticationProvider { + private final UserDetailService userDetailService; + private final PasswordEncoder passwordEncoder; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String username = authentication.getName(); + String password = authentication.getCredentials().toString(); + + UUID id = userDetailService.loadUserIdByUsername(username); + UserDetails u = userDetailService.loadUserByUsername(String.valueOf(id)); + if (passwordEncoder.matches(password, u.getPassword())) { + // 암호일치하면 필요한 세부정보를 넣은 Authentication 객체를 반환 + return new UsernamePasswordAuthenticationToken( + username, null, u.getAuthorities() + ); + } else { + throw new BadCredentialsException("Something went wrong!"); + } + } + + @Override + public boolean supports(Class authentication) { + return true; + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUserAuthenticationSuccessHandler.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUserAuthenticationSuccessHandler.java new file mode 100644 index 0000000..b2673d6 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUserAuthenticationSuccessHandler.java @@ -0,0 +1,73 @@ +package com.ssafy.springbootapi.global.auth.jsonAuthentication; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ssafy.springbootapi.domain.user.dao.UserRepository; +import com.ssafy.springbootapi.domain.user.domain.User; +import com.ssafy.springbootapi.domain.user.dto.UserLoginResponse; +import com.ssafy.springbootapi.global.auth.jwt.TokenProvider; +import com.ssafy.springbootapi.global.auth.jwt.refreshToken.RefreshToken; +import com.ssafy.springbootapi.global.auth.jwt.refreshToken.RefreshTokenRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.Duration; +import java.util.UUID; + +/** + * JSON 타입의 유저 로그인이 성공했을 때의 핸들러 + */ +@Component +public class JsonUserAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepository userRepository; + + public JsonUserAuthenticationSuccessHandler(UserRepository userRepository, TokenProvider tokenProvider, RefreshTokenRepository refreshTokenRepository) { + this.tokenProvider = tokenProvider; + this.refreshTokenRepository = refreshTokenRepository; + this.userRepository = userRepository; + } + + /** + * json 아이디 패스워드 인증 성공시 실행되는 메소드 + * RefreshToken : Http-Only cookie - 60분 + * AccessToken : response body (UserLoginResponse DTO) - 15분 + * + * @param request HttpServletRequest + * @param response HttpServletResponse + * @param authentication Authentication, 인증 정보 + * @throws IOException + * @throws ServletException + */ + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + String email = authentication.getName(); + User user = userRepository.findByEmail(email).orElseThrow(); + UUID id = user.getId(); + String accessToken = tokenProvider.generateToken(id, Duration.ofMinutes(15L)); + String refreshToken = tokenProvider.generateToken(id, Duration.ofMinutes(60L)); + + refreshTokenRepository.save(RefreshToken.builder() + .userId(user.getId()) + .refreshToken(refreshToken) + .build()); + + // Refresh Token HttpOnly Cookie에 저장 + Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); + refreshTokenCookie.setHttpOnly(true); +// refreshTokenCookie.setSecure(true); // HTTPS를 사용하는 경우에만 보낼 수 있도록 설정 + refreshTokenCookie.setPath("/api/v1/auth/token"); // 토큰 재발급시에만 쿠키를 사용할 수 있다. + refreshTokenCookie.setMaxAge(60 * 60); // 유효 기간 설정 + + response.addCookie(refreshTokenCookie); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + new ObjectMapper().writeValue(response.getWriter(),new UserLoginResponse(accessToken)); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUsernamePasswordAuthenticationFilter.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUsernamePasswordAuthenticationFilter.java new file mode 100644 index 0000000..a4cd650 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jsonAuthentication/JsonUsernamePasswordAuthenticationFilter.java @@ -0,0 +1,55 @@ +package com.ssafy.springbootapi.global.auth.jsonAuthentication; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ssafy.springbootapi.domain.user.dto.UserLoginRequest; +import com.ssafy.springbootapi.global.error.JsonProcessingAuthenticationException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.IOException; + +@Component +public class JsonUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public JsonUsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) { + super.setAuthenticationManager(authenticationManager); + } + + /** + * Json content type 의 요청에 대해 로그인 필터 진행 + * Json 의 ID와 Password 를 추출 + * 인증 매니저에게 ID와 Password 에 대한 인증 객체 (Authentication) 을 넘기며 인증 진행 + */ + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + if (!request.getContentType().startsWith("application/json")) { + throw new JsonProcessingAuthenticationException("Authentication method not supported: " + request.getMethod()); + } + + try (BufferedReader reader = request.getReader()) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + UserLoginRequest loginRequest = objectMapper.readValue(sb.toString(), UserLoginRequest.class); + UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( + loginRequest.getEmail(), loginRequest.getPassword()); + setDetails(request, authRequest); + return this.getAuthenticationManager().authenticate(authRequest); + } catch (IOException e) { + throw new JsonProcessingAuthenticationException("JSON parsing failed", e); + } + } + + +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/JwtAuthenticationFilter.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3b38098 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,71 @@ +package com.ssafy.springbootapi.global.auth.jwt; + +import com.ssafy.springbootapi.global.error.InvalidTokenException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final TokenProvider tokenProvider; + private final static String HEADER_AUTHORIZATION = "Authorization"; + private final static String TOKEN_PREFIX = "Bearer"; + + /** + * 요청 헤더의 Authorization 키의 값 조회 + * getAccessToken - 가져온 값에서 접두사 제거 + * 가져온 토큰이 유효한지 확인하고, 유효하면 인증정보 설정 + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION); + String token = getAccessToken(authorizationHeader); + + if (token != null && tokenProvider.validToken(token)) { + Authentication authentication = tokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + throw new InvalidTokenException("Invalid AccessToken"); + } + + filterChain.doFilter(request, response); + } catch (InvalidTokenException e) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized: " + e.getMessage()); + response.getWriter().flush(); + return; + } + } + + private String getAccessToken(String authorizationHeader){ + if(authorizationHeader!=null && authorizationHeader.startsWith(TOKEN_PREFIX)) { + return authorizationHeader.substring(TOKEN_PREFIX.length()); + } + return null; + } + + + /** + * 회원가입과 로그인, 토큰재발급, swagger 문서에 대해서는 filtering 하지 않는다. + */ + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + String path = request.getRequestURI(); + if(request.getMethod().equals("POST")&&path.equals("/api/v1/users")){ + return true; + } + return path.startsWith("/api/v1/auth") + || path.startsWith("/swagger-ui") + || path.startsWith("/api-docs"); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/JwtProperties.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/JwtProperties.java new file mode 100644 index 0000000..0f2daa9 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/JwtProperties.java @@ -0,0 +1,15 @@ +package com.ssafy.springbootapi.global.auth.jwt; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Setter +@Getter +@Component +@ConfigurationProperties("jwt") +public class JwtProperties { + private String issuer; + private String secretKey; +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/TokenProvider.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/TokenProvider.java new file mode 100644 index 0000000..3d26182 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/TokenProvider.java @@ -0,0 +1,113 @@ +package com.ssafy.springbootapi.global.auth.jwt; + +import com.ssafy.springbootapi.global.auth.SecurityUser.UserDetailService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Date; +import java.util.UUID; + +/** + * JWT 토큰을 관리하는 서비스 클래스. + */ +@RequiredArgsConstructor +@Service +public class TokenProvider { + private final JwtProperties jwtProperties; + private final UserDetailService userDetailService; + + /** + * 지정된 사용자 ID와 만료 기간을 기반으로 JWT 토큰을 생성합니다. + * + * @param id 사용자의 고유 UUID + * @param expiredAt 토큰의 만료 시간 + * @return 생성된 JWT 토큰 문자열 + */ + public String generateToken(UUID id, Duration expiredAt) { + Date now = new Date(); + return makeToken(new Date(now.getTime() + expiredAt.toMillis()), id); + } + + /** + * 주어진 만료 일자와 사용자 ID로 JWT 토큰을 생성합니다. + * + * @param expiry 토큰의 만료 시간 + * @param id 사용자의 UUID + * @return 생성된 JWT 토큰 + */ + private String makeToken(Date expiry, UUID id) { + Date now = new Date(); + + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setIssuer(jwtProperties.getIssuer()) + .setIssuedAt(now) // iat: 발급시간 + .setExpiration(expiry) // exp: 만료시간 + .setSubject(String.valueOf(id)) // sub: 유저 UUID + .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) + .compact(); + } + + /** + * 제공된 JWT 토큰의 유효성을 검증합니다. + * + * @param token 검증하고자 하는 JWT 토큰 + * @return 토큰의 유효성 여부를 boolean으로 반환 + */ + public boolean validToken(String token) { + try { + Jwts.parser() + .setSigningKey(jwtProperties.getSecretKey()) + .parseClaimsJws(token); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 유효한 JWT 토큰으로부터 사용자의 인증 정보를 가져옵니다. + * + * @param token 사용자의 JWT 토큰 + * @return 인증 정보 객체 + */ + public Authentication getAuthentication(String token) { + Claims claims = getClaims(token); + String uuid = claims.getSubject(); + UserDetails user = userDetailService.loadUserByUsername(uuid); + + return new UsernamePasswordAuthenticationToken(uuid, null, user.getAuthorities()); + } + + /** + * JWT 토큰에서 사용자 ID를 추출합니다. + * + * @param token 사용자의 JWT 토큰 + * @return 추출된 사용자 ID + */ + public Long getUserId(String token) { + Claims claims = getClaims(token); + return claims.get("id", Long.class); + } + + /** + * 주어진 토큰에서 JWT 클레임 세트를 반환합니다. + * + * @param token JWT 토큰 + * @return 클레임 객체 + */ + private Claims getClaims(String token) { + return Jwts.parser() + .setSigningKey(jwtProperties.getSecretKey()) + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/refreshToken/RefreshToken.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/refreshToken/RefreshToken.java new file mode 100644 index 0000000..906bd13 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/refreshToken/RefreshToken.java @@ -0,0 +1,38 @@ +package com.ssafy.springbootapi.global.auth.jwt.refreshToken; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Entity +public class RefreshToken { + @Id + @GeneratedValue + @Column(name = "id", updatable = false) + private Long id; + + @Column(name = "user_id", nullable = false) + private UUID userId; + + @Column(name = "refresh_token", nullable = false) + private String refreshToken; + + public RefreshToken(UUID userId, String refreshToken){ + this.userId = userId; + this.refreshToken = refreshToken; + } + + public RefreshToken update(String newRefreshToken) { + this.refreshToken = newRefreshToken; + return this; + } + +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/refreshToken/RefreshTokenRepository.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/refreshToken/RefreshTokenRepository.java new file mode 100644 index 0000000..a9dec50 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/auth/jwt/refreshToken/RefreshTokenRepository.java @@ -0,0 +1,13 @@ +package com.ssafy.springbootapi.global.auth.jwt.refreshToken; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByUserId(UUID userId); + Optional findByRefreshToken(String refreshToken); +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/config/SwaggerConfig.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/config/SwaggerConfig.java index 5279947..70f971a 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/config/SwaggerConfig.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/config/SwaggerConfig.java @@ -2,6 +2,10 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import lombok.RequiredArgsConstructor; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; @@ -24,4 +28,18 @@ public GroupedOpenApi ssafyApi() { .pathsToMatch(paths) // 그룹에 속하는 경로 패턴을 지정한다. .build(); } + @Bean + public OpenAPI api() { + SecurityScheme apiKey = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("Authorization"); + + SecurityRequirement securityRequirement = new SecurityRequirement() + .addList("Bearer Token"); + + return new OpenAPI() + .components(new Components().addSecuritySchemes("Bearer Token", apiKey)) + .addSecurityItem(securityRequirement); + } } \ No newline at end of file diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/config/WebSecurityConfig.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/config/WebSecurityConfig.java new file mode 100644 index 0000000..4fdc913 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/config/WebSecurityConfig.java @@ -0,0 +1,76 @@ +package com.ssafy.springbootapi.global.config; + +import com.ssafy.springbootapi.global.auth.jsonAuthentication.JsonUsernamePasswordAuthenticationFilter; +import com.ssafy.springbootapi.global.auth.jsonAuthentication.JsonUserAuthenticationSuccessHandler; +import com.ssafy.springbootapi.global.auth.jsonAuthentication.JsonUserAuthenticationProvider; +import com.ssafy.springbootapi.global.auth.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +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.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * Spring Security 설정을 위한 설정 클래스. + * 이 클래스는 HTTP 보안 설정을 정의하며, JWT 기반 인증을 포함하여 사용자 정의 인증 메커니즘을 구성합니다. + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + private final JsonUserAuthenticationProvider jsonUserAuthenticationProvider; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final AuthenticationConfiguration authenticationConfiguration; + private final JsonUserAuthenticationSuccessHandler jsonUserAuthenticationSuccessHandler; + + /** + * Spring Security의 필터 체인을 구성합니다. + * + * @param http HttpSecurity를 통해 보안 구성을 정의 + * @return 구성된 SecurityFilterChain + * @throws Exception 보안 구성 중 예외 발생 시 + */ + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/swagger-ui/**").permitAll() + .requestMatchers("/api-docs/**").permitAll() + .requestMatchers(HttpMethod.POST,"/api/v1/users").permitAll() + .requestMatchers(HttpMethod.POST,"/api/v1/auth/token").permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated()) // 그 외의 모든 요청은 인증 필요) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .authenticationProvider(jsonUserAuthenticationProvider); + + JsonUsernamePasswordAuthenticationFilter filter = new JsonUsernamePasswordAuthenticationFilter(authenticationManager(authenticationConfiguration)); + filter.setAuthenticationSuccessHandler(jsonUserAuthenticationSuccessHandler); + filter.setFilterProcessesUrl("/api/v1/auth/login"); + http.addFilterAt(filter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * AuthenticationManager를 제공합니다. + * 이 메서드는 AuthenticationManager를 구성하고, 사용자 정의 인증 메커니즘을 사용할 수 있도록 합니다. + * + * @param authenticationConfiguration 인증 설정 + * @return 구성된 AuthenticationManager + * @throws Exception 인증 매니저 구성 중 예외 발생 시 + */ + @Bean + AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/GlobalExceptionHandler.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/GlobalExceptionHandler.java index a5b5de7..1d35660 100644 --- a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/GlobalExceptionHandler.java +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/GlobalExceptionHandler.java @@ -13,4 +13,9 @@ public class GlobalExceptionHandler { // Domain별 Exception 핸들러 // public ResponseEntity handleNotFoundProductException(NotFoundProductException ex) { // return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND); // } + + @ExceptionHandler(InvalidTokenException.class) + public ResponseEntity handleInvalidException(InvalidTokenException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } } diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/InvalidTokenException.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/InvalidTokenException.java new file mode 100644 index 0000000..aeaa9cc --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/InvalidTokenException.java @@ -0,0 +1,7 @@ +package com.ssafy.springbootapi.global.error; + +public class InvalidTokenException extends RuntimeException{ + public InvalidTokenException(String s) { + super(s); + } +} diff --git a/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/JsonProcessingAuthenticationException.java b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/JsonProcessingAuthenticationException.java new file mode 100644 index 0000000..0a72659 --- /dev/null +++ b/spring-boot-api/src/main/java/com/ssafy/springbootapi/global/error/JsonProcessingAuthenticationException.java @@ -0,0 +1,13 @@ +package com.ssafy.springbootapi.global.error; + +import org.springframework.security.core.AuthenticationException; + +public class JsonProcessingAuthenticationException extends AuthenticationException { + public JsonProcessingAuthenticationException(String msg) { + super(msg); + } + + public JsonProcessingAuthenticationException(String msg, Throwable t) { + super(msg, t); + } +} diff --git a/spring-boot-api/src/main/resources/application.yml b/spring-boot-api/src/main/resources/application.yml index 48f4a90..b02946c 100644 --- a/spring-boot-api/src/main/resources/application.yml +++ b/spring-boot-api/src/main/resources/application.yml @@ -10,7 +10,7 @@ spring: database: mysql database-platform: org.hibernate.dialect.MySQL8Dialect hibernate: - ddl-auto: none + ddl-auto: create generate-ddl: false show-sql: true properties: @@ -31,4 +31,8 @@ springdoc: display-query-params-without-oauth2: true doc-expansion: none paths-to-match: - - /api/** \ No newline at end of file + - /api/** + +jwt: + issuer: kkh + secret_key: test_security \ No newline at end of file diff --git a/spring-boot-api/src/test/java/com/ssafy/springbootapi/domain/user/UserServiceTest.java b/spring-boot-api/src/test/java/com/ssafy/springbootapi/domain/user/UserServiceTest.java index a10832e..d7c78b8 100644 --- a/spring-boot-api/src/test/java/com/ssafy/springbootapi/domain/user/UserServiceTest.java +++ b/spring-boot-api/src/test/java/com/ssafy/springbootapi/domain/user/UserServiceTest.java @@ -7,6 +7,8 @@ import com.ssafy.springbootapi.domain.user.dto.*; import com.ssafy.springbootapi.domain.user.exception.UserDuplicatedException; import com.ssafy.springbootapi.domain.user.exception.UserNotFoundException; +import com.ssafy.springbootapi.global.auth.jwt.refreshToken.RefreshToken; +import com.ssafy.springbootapi.global.auth.jwt.refreshToken.RefreshTokenRepository; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; @@ -17,10 +19,10 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; +import java.util.UUID; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; @@ -31,9 +33,15 @@ public class UserServiceTest { @Mock UserRepository userRepository; + @Mock + RefreshTokenRepository refreshTokenRepository; + @Spy private final UserMapper userMapper = Mappers.getMapper(UserMapper.class); + @Mock + PasswordEncoder passwordEncoder; + @InjectMocks UserService userService; @@ -45,18 +53,27 @@ public class UserServiceTest { // given. UserSignUpRequest userSignUpRequest = new UserSignUpRequest("kkho9654@naver.com","1234","kkh"); - User userToSave = userSignUpRequest.toEntity(); + User userToSave = User.builder() + .email("kkho9654@naver.com") + .password("encodedPassword") + .name("kkh").build(); given(userRepository.findByEmail(anyString())) .willReturn(Optional.empty()); given(userRepository.save(any(User.class))) .willReturn(userToSave); + + // Mock the password encoding to return a specific encoded value + given(passwordEncoder.encode(userSignUpRequest.getPassword())) + .willReturn("encodedPassword"); + // when UserSignUpResponse userSignUpResponse = userService.signUp(userSignUpRequest); // then Assertions.assertThat(userSignUpResponse.getEmail()) .isEqualTo("kkho9654@naver.com"); + verify(passwordEncoder).encode("1234"); // Ensure password encoding is called } @DisplayName("유저 회원가입 중복사용자 예외테스트") @@ -85,7 +102,7 @@ public class UserServiceTest { @Test public void 유저정보읽기성공테스트(){ // given - Long id = 1L; + UUID id = UUID.randomUUID(); given(userRepository.findById(id)) .willReturn(Optional.of(User.builder() .email("kkho9654@naver.com") @@ -107,7 +124,7 @@ public class UserServiceTest { @Test void 유저정보읽기존재하지않는사용자예외테스트(){ // given - Long id = 1L; + UUID id = UUID.randomUUID(); given(userRepository.findById(id)) .willReturn(Optional.empty()); @@ -122,15 +139,16 @@ public class UserServiceTest { @Test void 유저수정성공테스트() { // given + UUID id = UUID.randomUUID(); UserUpdateRequest requestDTO - = new UserUpdateRequest(1L,"kkho9654@naver2.com","1112","3333"); + = new UserUpdateRequest("1112","3333"); User user = User.builder() - .id(1L) + .id(id) .email("kkho9654@naver.com") .password("1234") .name("kkh").build(); User updatedUser = userMapper.toEntity(requestDTO); - given(userRepository.findById(1L)) + given(userRepository.findById(id)) .willReturn(Optional.of(user)); doNothing().when(userMapper).updateUserFromDto(requestDTO, user); @@ -139,11 +157,9 @@ public class UserServiceTest { .willReturn(updatedUser); // when - UserUpdateResponse responseDTO = userService.updateUserInfo(requestDTO); + UserUpdateResponse responseDTO = userService.updateUserInfo(id,requestDTO); // then - Assertions.assertThat(responseDTO.getEmail()) - .isEqualTo(requestDTO.getEmail()); Assertions.assertThat(responseDTO.getName()) .isEqualTo(requestDTO.getName()); } @@ -152,13 +168,13 @@ public class UserServiceTest { void 유저업데이트존재하지않는사용자예외테스트(){ // given UserMapper userMapper = mock(UserMapper.class); - Long id = 1L; - UserUpdateRequest requestDTO = new UserUpdateRequest(1L,"test","test","test"); + UUID id = UUID.randomUUID(); + UserUpdateRequest requestDTO = new UserUpdateRequest("test","test"); given(userRepository.findById(id)) .willReturn(Optional.empty()); // when - Assertions.assertThatThrownBy(()->userService.updateUserInfo(requestDTO)) + Assertions.assertThatThrownBy(()->userService.updateUserInfo(id,requestDTO)) .isInstanceOf(UserNotFoundException.class); // then @@ -169,25 +185,31 @@ public class UserServiceTest { @Test void 유저삭제성공테스트() { // given - Long id = 1L; + UUID id = UUID.randomUUID(); User user = User.builder() - .id(1L) + .id(id) .email("kkho9654@naver.com") .name("kkh").password("123").build(); + RefreshToken refreshToken = RefreshToken.builder() + .userId(id) + .refreshToken(UUID.randomUUID().toString()).build(); given(userRepository.findById(id)) .willReturn(Optional.of(user)); + given(refreshTokenRepository.findByUserId(id)) + .willReturn(Optional.of(refreshToken)); // when userService.removeUser(id); // then verify(userRepository).delete(user); + verify(refreshTokenRepository).delete(refreshToken); } @DisplayName("유저 삭제 존재하지않는 사용자 예외 테스트") @Test void 유저삭제존재하지않는사용자예외테스트(){ // given - Long id = 1L; + UUID id = UUID.randomUUID(); given(userRepository.findById(id)) .willReturn(Optional.empty());