diff --git a/build.gradle b/build.gradle index 15b77ef..b70c824 100644 --- a/build.gradle +++ b/build.gradle @@ -22,11 +22,21 @@ 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' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' - runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + implementation group: 'io.springfox', name: 'springfox-swagger2', version: '2.9.2' + implementation group: 'io.springfox', name: 'springfox-swagger-ui', version: '2.9.2' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + implementation 'org.apache.httpcomponents:httpclient:4.5.7' } tasks.named('test') { diff --git a/src/main/java/com/dku/springstudy/controller/UserController.java b/src/main/java/com/dku/springstudy/controller/UserController.java new file mode 100644 index 0000000..d17dc9f --- /dev/null +++ b/src/main/java/com/dku/springstudy/controller/UserController.java @@ -0,0 +1,67 @@ +package com.dku.springstudy.controller; + + +import java.security.Timestamp; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.dku.springstudy.dto.user.request.LoginRequestDTO; +import com.dku.springstudy.dto.user.request.SignUpRequestDTO; +import com.dku.springstudy.dto.user.response.LoginResponseDTO; +import com.dku.springstudy.dto.user.response.LogoutResponseDTO; +import com.dku.springstudy.dto.user.response.SignUpResponseDTO; +import com.dku.springstudy.model.User; +import com.dku.springstudy.security.CustomUserDetails; +import com.dku.springstudy.security.jwt.JwtProvider; +import com.dku.springstudy.service.UserService; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +// why not commit + +@RestController +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + private final JwtProvider jwtProvider; + + // private Long id; + // private String name; + // private String email; + // private String password; + + + // final Long ID = 30L; + // final String NAME = "kangho"; + // final String EMAIL = "kangho@gmail.com"; + // final String PASSWORD = "kangho"; + + // User user = User.builder() + // .id(ID) + // .name(NAME) + // .email(EMAIL) + // .password(PASSWORD) + // .build(); + + @PostMapping("/user/signUpTest") + public SignUpResponseDTO SignUpTest(@Valid @RequestBody SignUpRequestDTO signUpRequestDTO) { + return userService.signUp(signUpRequestDTO); + } + + @PostMapping("/user/login") + public LoginResponseDTO login(@Valid @RequestBody LoginRequestDTO loginRequest) { + return userService.login(loginRequest); + } + + @PostMapping("/user/logout") + public LogoutResponseDTO logout(@AuthenticationPrincipal CustomUserDetails customUserDetails, HttpServletRequest request) { + User user = customUserDetails.getUser(); + String accessToken = jwtProvider.resolveToken(request); + return userService.logout(user, accessToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/dku/springstudy/dto/common/ErrorResponseDTO.java b/src/main/java/com/dku/springstudy/dto/common/ErrorResponseDTO.java new file mode 100644 index 0000000..791f542 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/common/ErrorResponseDTO.java @@ -0,0 +1,13 @@ +package com.dku.springstudy.dto.common; + +import lombok.Getter; + +@Getter +public class ErrorResponseDTO extends ResponseDTO{ + private final T data; + + public ErrorResponseDTO(T data) { + super(false); + this.data = data; + } +} diff --git a/src/main/java/com/dku/springstudy/dto/common/ExceptionDTO.java b/src/main/java/com/dku/springstudy/dto/common/ExceptionDTO.java new file mode 100644 index 0000000..c26cc2e --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/common/ExceptionDTO.java @@ -0,0 +1,25 @@ +package com.dku.springstudy.dto.common; + +import com.dku.springstudy.exception.CustomException; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; + +@Getter +public class ExceptionDTO { + private final HttpStatus error; + private final Integer status; + private final String message; + + public ExceptionDTO(CustomException e) { + this.status = e.getErrorCode().getStatus(); + this.error = e.getErrorCode().getError(); + this.message = e.getErrorCode().getMessage(); + } + + public ExceptionDTO(MethodArgumentNotValidException e) { + this.error = (HttpStatus) e.getStatusCode(); + this.status = this.error.value(); + this.message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + } +} diff --git a/src/main/java/com/dku/springstudy/dto/common/ResponseDTO.java b/src/main/java/com/dku/springstudy/dto/common/ResponseDTO.java new file mode 100644 index 0000000..8c61ae5 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/common/ResponseDTO.java @@ -0,0 +1,10 @@ +package com.dku.springstudy.dto.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class ResponseDTO { + private boolean isSuccessful; +} diff --git a/src/main/java/com/dku/springstudy/dto/common/SuccessResponseDTO.java b/src/main/java/com/dku/springstudy/dto/common/SuccessResponseDTO.java new file mode 100644 index 0000000..35fcbcc --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/common/SuccessResponseDTO.java @@ -0,0 +1,13 @@ +package com.dku.springstudy.dto.common; + +import lombok.Getter; + +@Getter +public class SuccessResponseDTO extends ResponseDTO{ + private final T data; + + public SuccessResponseDTO(T data) { + super(true); + this.data = data; + } +} diff --git a/src/main/java/com/dku/springstudy/dto/user/request/LoginRequestDTO.java b/src/main/java/com/dku/springstudy/dto/user/request/LoginRequestDTO.java new file mode 100644 index 0000000..dc4e5c8 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/user/request/LoginRequestDTO.java @@ -0,0 +1,18 @@ +package com.dku.springstudy.dto.user.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +public class LoginRequestDTO { + + @Email(message = "Email format does not match") + @NotBlank(message = "Please input email") + private String email; + + @NotBlank(message = "Please input password") + private String password; +} diff --git a/src/main/java/com/dku/springstudy/dto/user/request/LogoutRequestDTO.java b/src/main/java/com/dku/springstudy/dto/user/request/LogoutRequestDTO.java new file mode 100644 index 0000000..926941f --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/user/request/LogoutRequestDTO.java @@ -0,0 +1,11 @@ +package com.dku.springstudy.dto.user.request; + +// import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class LogoutRequestDTO { + private String token; +} diff --git a/src/main/java/com/dku/springstudy/dto/user/request/SignUpRequestDTO.java b/src/main/java/com/dku/springstudy/dto/user/request/SignUpRequestDTO.java new file mode 100644 index 0000000..eb96e58 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/user/request/SignUpRequestDTO.java @@ -0,0 +1,22 @@ +package com.dku.springstudy.dto.user.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Getter +public class SignUpRequestDTO { + + @Email(message = "Email format does not match") + @NotBlank(message = "Please input email") + private String email; + + @NotBlank(message = "Please input password") + private String password; + + @NotBlank(message = "Please input name") + private String name; + +} diff --git a/src/main/java/com/dku/springstudy/dto/user/response/LoginResponseDTO.java b/src/main/java/com/dku/springstudy/dto/user/response/LoginResponseDTO.java new file mode 100644 index 0000000..7190736 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/user/response/LoginResponseDTO.java @@ -0,0 +1,15 @@ +package com.dku.springstudy.dto.user.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class LoginResponseDTO { + private String accessToken; + private String refreshToken; + + public LoginResponseDTO LoginResponse(String accessToken, String refreshToken) { + return new LoginResponseDTO(accessToken,refreshToken); + } +} diff --git a/src/main/java/com/dku/springstudy/dto/user/response/LogoutResponseDTO.java b/src/main/java/com/dku/springstudy/dto/user/response/LogoutResponseDTO.java new file mode 100644 index 0000000..fa53bee --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/user/response/LogoutResponseDTO.java @@ -0,0 +1,21 @@ +package com.dku.springstudy.dto.user.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class LogoutResponseDTO { + private boolean success; + private String message = ""; + + public LogoutResponseDTO(boolean success){ + this.success = success; + } + + public LogoutResponseDTO(boolean success, String message){ + this.success = success; + this.message = message; + } +} diff --git a/src/main/java/com/dku/springstudy/dto/user/response/SignUpResponseDTO.java b/src/main/java/com/dku/springstudy/dto/user/response/SignUpResponseDTO.java new file mode 100644 index 0000000..c3876e2 --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/user/response/SignUpResponseDTO.java @@ -0,0 +1,16 @@ +package com.dku.springstudy.dto.user.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class SignUpResponseDTO { + private String accessToken; + private String refreshToken; + + public SignUpResponseDTO LoginResponse(String accessToken, String refreshToken) { + return new SignUpResponseDTO(accessToken,refreshToken); + } + +} diff --git a/src/main/java/com/dku/springstudy/dto/user/response/TokenResponseDTO.java b/src/main/java/com/dku/springstudy/dto/user/response/TokenResponseDTO.java new file mode 100644 index 0000000..9f15aac --- /dev/null +++ b/src/main/java/com/dku/springstudy/dto/user/response/TokenResponseDTO.java @@ -0,0 +1,9 @@ +package com.dku.springstudy.dto.user.response; + +import lombok.Data; + +@Data +public class TokenResponseDTO { + private final String atk; + private final String rtk; +} diff --git a/src/main/java/com/dku/springstudy/exception/CustomException.java b/src/main/java/com/dku/springstudy/exception/CustomException.java new file mode 100644 index 0000000..925a963 --- /dev/null +++ b/src/main/java/com/dku/springstudy/exception/CustomException.java @@ -0,0 +1,10 @@ +package com.dku.springstudy.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CustomException extends RuntimeException { + ErrorCode errorCode; +} \ No newline at end of file diff --git a/src/main/java/com/dku/springstudy/exception/ErrorCode.java b/src/main/java/com/dku/springstudy/exception/ErrorCode.java new file mode 100644 index 0000000..1c89df9 --- /dev/null +++ b/src/main/java/com/dku/springstudy/exception/ErrorCode.java @@ -0,0 +1,20 @@ +package com.dku.springstudy.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + USER_NOT_FOUND_ERROR(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.value(), "User not found"), + USER_ALREADY_EXIST_ERROR(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.value(), "User already exist"), + USER_PASSWORD_INCORRECT_ERROR(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.value(), "User password incorrect"), + EXPIRED_TOKEN_ERROR(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.value(), "Expired JWT token"), + INVALID_TOKEN_ERROR(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.value(), "Invalid JWT token") + ; + + private final HttpStatus error; + private final Integer status; + private final String message; +} diff --git a/src/main/java/com/dku/springstudy/exception/JwtTokenError.java b/src/main/java/com/dku/springstudy/exception/JwtTokenError.java new file mode 100644 index 0000000..aab80d9 --- /dev/null +++ b/src/main/java/com/dku/springstudy/exception/JwtTokenError.java @@ -0,0 +1,12 @@ +package com.dku.springstudy.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public class JwtTokenError { + private String message; + private HttpStatus status; +} diff --git a/src/main/java/com/dku/springstudy/exception/Message.java b/src/main/java/com/dku/springstudy/exception/Message.java new file mode 100644 index 0000000..adce9b4 --- /dev/null +++ b/src/main/java/com/dku/springstudy/exception/Message.java @@ -0,0 +1,9 @@ +package com.dku.springstudy.exception; + +public class Message { + public static final String JWT_TOKEN_EXPIRED = "만료된 JWT 토큰 만료"; + public static final String JWT_UNSUPPORTED = "미지원하는 JWT 토큰 미지원"; + public static final String JWT_MALFORMED = "올바르지 않은 JWT 토큰"; + public static final String JWT_SIGNATURE = "올바르지 않은 Signature"; + public static final String JWT_ILLEGAL_ARGUMENT = "올바르지 않은 정보"; +} \ No newline at end of file diff --git a/src/main/java/com/dku/springstudy/model/User.java b/src/main/java/com/dku/springstudy/model/User.java new file mode 100644 index 0000000..8e8c0c2 --- /dev/null +++ b/src/main/java/com/dku/springstudy/model/User.java @@ -0,0 +1,34 @@ +package com.dku.springstudy.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.sql.Timestamp; + +@Entity +@Getter //Lombok +@Setter //Lombok +@ToString //Lombok +@Table(name = "USERS") +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ID") + private Long id; + @Column(name = "Name") + private String name; + @Column(name = "Email") + private String email; + @Column(name = "Password") + private String password; + @Column(name = "Image") + private String imageURL; + @Column(name = "Created") + private Timestamp created; + @Column(name = "Updated") + private Timestamp updated; + @Column(name = "Status") + private String status; +} diff --git a/src/main/java/com/dku/springstudy/repository/UserRepository.java b/src/main/java/com/dku/springstudy/repository/UserRepository.java new file mode 100644 index 0000000..e6aef26 --- /dev/null +++ b/src/main/java/com/dku/springstudy/repository/UserRepository.java @@ -0,0 +1,50 @@ +package com.dku.springstudy.repository; + +import com.dku.springstudy.model.User; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityTransaction; +import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import org.hibernate.Session; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Transactional +public class UserRepository { + private final EntityManager entityManager; + + public void save(User user){ + entityManager.persist(user); + } + + public void remove(User user){ + Session session = entityManager.unwrap(Session.class); + session.remove(entityManager.contains(user) ? user : entityManager.merge(user)); + session.flush(); + entityManager.close(); + } + + public Optional findById(Long id){ + User user = entityManager.find(User.class, id); + + return Optional.ofNullable(user); + } + + public Optional findByName(String name) { + return entityManager. + createQuery("select m from user m where m.name = :name", + User.class) + .setParameter("name", name) + .getResultList().stream().findAny(); + } + + public Optional findByEmail(String email){ + return entityManager. + createQuery("select m from user m where m.email = :email", User.class) + .setParameter("email", email) + .getResultList().stream().findAny(); + } +} diff --git a/src/main/java/com/dku/springstudy/security/CustomUserDetailService.java b/src/main/java/com/dku/springstudy/security/CustomUserDetailService.java new file mode 100644 index 0000000..eb3f7a5 --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/CustomUserDetailService.java @@ -0,0 +1,24 @@ +package com.dku.springstudy.security; + +import com.dku.springstudy.model.User; +import com.dku.springstudy.exception.CustomException; +import com.dku.springstudy.exception.ErrorCode; +import com.dku.springstudy.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class CustomUserDetailService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws CustomException { + User user = userRepository.findByName(username) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND_ERROR)); + return new CustomUserDetails(user); + } +} diff --git a/src/main/java/com/dku/springstudy/security/CustomUserDetails.java b/src/main/java/com/dku/springstudy/security/CustomUserDetails.java new file mode 100644 index 0000000..687bd4b --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/CustomUserDetails.java @@ -0,0 +1,52 @@ +package com.dku.springstudy.security; + +import com.dku.springstudy.model.User; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +@RequiredArgsConstructor +@AllArgsConstructor +@Getter +public class CustomUserDetails implements UserDetails { + private User user; + + @Override + public Collection getAuthorities() { + return null; + } + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getName(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/dku/springstudy/security/Interceptor.java b/src/main/java/com/dku/springstudy/security/Interceptor.java new file mode 100644 index 0000000..42feb72 --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/Interceptor.java @@ -0,0 +1,50 @@ +package com.dku.springstudy.security; + +import com.dku.springstudy.dto.common.ErrorResponseDTO; +import com.dku.springstudy.dto.common.SuccessResponseDTO; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.util.ContentCachingResponseWrapper; + +@Slf4j +@RequiredArgsConstructor +@Component +public class Interceptor implements HandlerInterceptor { + private final ObjectMapper objectMapper; + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object object, + Exception ex) throws Exception { + final ContentCachingResponseWrapper cachingResponse = (ContentCachingResponseWrapper) response; + + if (cachingResponse.getContentType() != null + && (cachingResponse.getContentType().contains("application/json"))) { + + if (cachingResponse.getContentAsByteArray().length != 0) { + + String body = new String(cachingResponse.getContentAsByteArray()); + Object data = objectMapper.readValue(body, Object.class); + + if (body.contains("BAD_REQUEST") || !String.valueOf(response.getStatus()).startsWith("2")) { + ErrorResponseDTO errorResponseDto = new ErrorResponseDTO<>(data); + String wrappedBody = objectMapper.writeValueAsString(errorResponseDto); + cachingResponse.resetBuffer(); + cachingResponse.getOutputStream().write(wrappedBody.getBytes(), 0, wrappedBody.getBytes().length); + } else { + SuccessResponseDTO successResponseDto = new SuccessResponseDTO<>(data); + String wrappedBody = objectMapper.writeValueAsString(successResponseDto); + cachingResponse.resetBuffer(); + cachingResponse.getOutputStream().write(wrappedBody.getBytes(), 0, wrappedBody.getBytes().length); + } + } + } + } +} diff --git a/src/main/java/com/dku/springstudy/security/config/InterceptorConfig.java b/src/main/java/com/dku/springstudy/security/config/InterceptorConfig.java new file mode 100644 index 0000000..2a8939c --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/config/InterceptorConfig.java @@ -0,0 +1,18 @@ +package com.dku.springstudy.security.config; + +import com.dku.springstudy.security.Interceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class InterceptorConfig implements WebMvcConfigurer { + private final Interceptor interceptor; + @Override + public void addInterceptors(InterceptorRegistry registry){ + registry.addInterceptor(interceptor) + .addPathPatterns("/**"); + } +} diff --git a/src/main/java/com/dku/springstudy/security/config/RedisRepositoryConfig.java b/src/main/java/com/dku/springstudy/security/config/RedisRepositoryConfig.java new file mode 100644 index 0000000..f665396 --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/config/RedisRepositoryConfig.java @@ -0,0 +1,32 @@ +package com.dku.springstudy.security.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@RequiredArgsConstructor +@Configuration +@EnableRedisRepositories +public class RedisRepositoryConfig { + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory(){ + return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort()); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } +} diff --git a/src/main/java/com/dku/springstudy/security/config/SecurityConfig.java b/src/main/java/com/dku/springstudy/security/config/SecurityConfig.java new file mode 100644 index 0000000..5d7e7cc --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/config/SecurityConfig.java @@ -0,0 +1,57 @@ +package com.dku.springstudy.security.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.dku.springstudy.security.jwt.JwtAuthenticationFilter; +import com.dku.springstudy.security.jwt.JwtProvider; + +import jakarta.persistence.PersistenceContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public PasswordEncoder passwordEncoder(){ + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) + throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ + return http + .csrf().disable() + .headers().frameOptions().disable().and() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다. + .and() + .authorizeHttpRequests(authorize -> authorize + // .requestMatchers(PUBLIC_URI).permitAll() // localhost:8080/test -> 이하는 permitALl -> 허용한다 모두 + // .requestMatchers("/**").permitAll() // localhost:8080/test -> 이하는 permitALl -> 허용한다 모두 + // .requestMatchers("/admin/**").hasRole("ADMIN") + // .requestMatchers("/user/**").hasRole("USER") + .anyRequest().authenticated()) + .addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } +} diff --git a/src/main/java/com/dku/springstudy/security/config/SpringConfig.java b/src/main/java/com/dku/springstudy/security/config/SpringConfig.java new file mode 100644 index 0000000..5f0af73 --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/config/SpringConfig.java @@ -0,0 +1,19 @@ +package com.dku.springstudy.security.config; + +import com.dku.springstudy.repository.UserRepository; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class SpringConfig { + + private final EntityManager entityManager; + + @Bean + public UserRepository userRepository(){ + return new UserRepository(entityManager); + } +} diff --git a/src/main/java/com/dku/springstudy/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/dku/springstudy/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b730e3e --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,75 @@ +package com.dku.springstudy.security.jwt; + +import com.dku.springstudy.model.User; +import com.dku.springstudy.dto.common.ExceptionDTO; +import com.dku.springstudy.exception.CustomException; +import com.dku.springstudy.exception.ErrorCode; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; +import org.springframework.web.filter.GenericFilterBean; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +@Slf4j +public class JwtAuthenticationFilter extends GenericFilterBean { + + private final JwtProvider jwtProvider; + private final RedisTemplate redisTemplate; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + try { + ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper( + (HttpServletRequest) request); + ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper( + (HttpServletResponse) response); + + String accessToken = jwtProvider.resolveToken((HttpServletRequest) request); + + if (accessToken != null) { + if (jwtProvider.validateToken(accessToken)) { + + String isLogout = redisTemplate.opsForValue().get(accessToken); + if (ObjectUtils.isEmpty(isLogout)) { + Authentication authentication = jwtProvider.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } else { + throw new CustomException(ErrorCode.INVALID_TOKEN_ERROR); + } + + } else { + throw new CustomException(ErrorCode.EXPIRED_TOKEN_ERROR); + } + } + + chain.doFilter(wrappingRequest, wrappingResponse); + wrappingResponse.copyBodyToResponse(); + } catch (CustomException e) { + HttpServletResponse errorResponse = (HttpServletResponse) response; + errorResponse.setStatus(e.getErrorCode().getStatus()); + errorResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); + ExceptionDTO exceptionDto = new ExceptionDTO(e); + ObjectMapper objectMapper = new ObjectMapper(); + String exceptionMessage = objectMapper.writeValueAsString(exceptionDto); + errorResponse.getWriter().write(exceptionMessage); + } + } +} diff --git a/src/main/java/com/dku/springstudy/security/jwt/JwtProvider.java b/src/main/java/com/dku/springstudy/security/jwt/JwtProvider.java new file mode 100644 index 0000000..4c1a3b0 --- /dev/null +++ b/src/main/java/com/dku/springstudy/security/jwt/JwtProvider.java @@ -0,0 +1,101 @@ +package com.dku.springstudy.security.jwt; + +// 토큰을 생성하고 검증하는 클래스 +// 해당 컴포넌트는 필터클래스에서 사전 검증을 거침 + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtProvider { + + private String secretKey = "kangho"; + private final UserDetailsService userDetailsService; + + private Key getSigninKey(String secretKey) { + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + @PostConstruct + protected void init() { + secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes()); + } + + public String createToken(Claims claims, long expiredDuration) { + Date createdTime = new Date(); + Date ExpiredTime = new Date(createdTime.getTime() + expiredDuration); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(createdTime) + .setExpiration(ExpiredTime) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + public String createAccessToken(String userPK) { + Claims claims = Jwts.claims(); + claims.setSubject(userPK); + long accessTokenValidMilliSecond = 60 * 60 * 1000L; + + return createToken(claims, accessTokenValidMilliSecond); + } + + public String createRefreshToken(String userPK) { + Claims claims = Jwts.claims(); + claims.setSubject(userPK); + long refreshTokenValidMilliSecond = 24 * 60 * 60 * 1000L; + + return createToken(claims, refreshTokenValidMilliSecond); + } + + public Authentication getAuthentication(String token) { + UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token)); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + public String getUserPk(String token) { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + } + + public String resolveToken(HttpServletRequest request) { + return request.getHeader("X-AUTH-TOKEN"); + } + + public boolean validateToken(String token){ + try { + Jws claimsJws = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + return !claimsJws.getBody().getExpiration().before(new Date()); + } catch (Exception e) { + return false; + } + } + + public Long getExpiration(String accessToken){ + + Date expiration = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(accessToken) + .getBody().getExpiration(); + long now = new Date().getTime(); + return expiration.getTime() - now; + } + +} diff --git a/src/main/java/com/dku/springstudy/service/UserService.java b/src/main/java/com/dku/springstudy/service/UserService.java new file mode 100644 index 0000000..c4881e4 --- /dev/null +++ b/src/main/java/com/dku/springstudy/service/UserService.java @@ -0,0 +1,75 @@ +package com.dku.springstudy.service; + + +import com.dku.springstudy.dto.user.request.LoginRequestDTO; +import com.dku.springstudy.dto.user.request.SignUpRequestDTO; +import com.dku.springstudy.dto.user.response.LoginResponseDTO; +import com.dku.springstudy.dto.user.response.LogoutResponseDTO; +import com.dku.springstudy.dto.user.response.SignUpResponseDTO; +import com.dku.springstudy.exception.CustomException; +import com.dku.springstudy.exception.ErrorCode; +import com.dku.springstudy.model.User; +import com.dku.springstudy.repository.UserRepository; +import com.dku.springstudy.security.jwt.JwtProvider; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + private final RedisTemplate redisTemplate; + + public SignUpResponseDTO signUp(SignUpRequestDTO membershipRequestDTO){ + User user = User.builder() + .email(membershipRequestDTO.getEmail()) + .name(membershipRequestDTO.getName()) + .password(passwordEncoder.encode(membershipRequestDTO.getPassword())) + .build(); + + userRepository.save(user); + + String loginAccessToken = jwtProvider.createAccessToken(user.getName()); + String loginRefreshToken = jwtProvider.createRefreshToken(user.getName()); + + return new SignUpResponseDTO(loginAccessToken, loginRefreshToken); + } + + public LoginResponseDTO login(LoginRequestDTO loginRequestDTO){ + + User user = userRepository.findByEmail(loginRequestDTO.getEmail()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND_ERROR)); + + if(passwordEncoder.matches(loginRequestDTO.getPassword(), user.getPassword())) { + String accessToken = jwtProvider.createAccessToken(user.getName()); + String refreshToken = jwtProvider.createRefreshToken(user.getName()); + redisTemplate.opsForValue() + .set(user.getEmail(), refreshToken, + jwtProvider.getExpiration(refreshToken), TimeUnit.MILLISECONDS); + + return new LoginResponseDTO(accessToken, refreshToken); + } else { + throw new CustomException(ErrorCode.USER_PASSWORD_INCORRECT_ERROR); + } + + } + + public LogoutResponseDTO logout(User user, String accessToken){ + try { + Long expiration = jwtProvider.getExpiration(accessToken); + redisTemplate.opsForValue().set(accessToken, "logout", expiration, TimeUnit.MILLISECONDS); + redisTemplate.delete(user.getEmail()); + + return new LogoutResponseDTO(true); + }catch (Exception e){ + return new LogoutResponseDTO(false, e.getMessage()); + } + } +} diff --git a/src/main/resources/DB/carrot.mv.db b/src/main/resources/DB/carrot.mv.db new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8b13789..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..4a97bc4 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,29 @@ +server: + port: 8080 + +spring: + application: + name: springstudy + + datasource: + url: jdbc:h2:tcp://localhost/~/test + driver-class-name: org.h2.Driver + username: sa + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + show_sql: true + +logging: + level: + com.dku.springstudy: DEBUG + +jwt: + secret-key: kangho + + +