diff --git a/.DS_Store b/.DS_Store index 42c8ace..88eb7c3 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/crescendo-server/build.gradle b/crescendo-server/build.gradle index 6acd0a4..a6c8520 100644 --- a/crescendo-server/build.gradle +++ b/crescendo-server/build.gradle @@ -25,12 +25,43 @@ 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' + + // db +// runtimeOnly 'com.mysql:mysql-connector-j' + implementation group: 'org.postgresql', name: 'postgresql', version: '42.7.3' + + // 편의 compileOnly 'org.projectlombok:lombok' - runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-devtools' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + + // 유효성 검사 + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // spring AI +// implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-SNAPSHOT' + + // 소켓 통신 +// implementation 'org.springframework.boot:spring-boot-starter-websocket' + } tasks.named('test') { diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/Controller.java b/crescendo-server/src/main/java/com/example/crescendoserver/Controller.java deleted file mode 100644 index 8bdd0a9..0000000 --- a/crescendo-server/src/main/java/com/example/crescendoserver/Controller.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.crescendoserver; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -public class Controller { - - @GetMapping("/") - public String root() { - return "Hello World!!4"; - } -} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/CrescendoServerApplication.java b/crescendo-server/src/main/java/com/example/crescendoserver/CrescendoServerApplication.java index 1463b84..644e255 100644 --- a/crescendo-server/src/main/java/com/example/crescendoserver/CrescendoServerApplication.java +++ b/crescendo-server/src/main/java/com/example/crescendoserver/CrescendoServerApplication.java @@ -2,8 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@ConfigurationPropertiesScan +@EnableJpaAuditing public class CrescendoServerApplication { public static void main(String[] args) { diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/controller/AuthController.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..66b437e --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/controller/AuthController.java @@ -0,0 +1,42 @@ +package com.example.crescendoserver.domain.auth.controller; + +import com.example.crescendoserver.domain.auth.dto.LoginRequest; +import com.example.crescendoserver.domain.auth.dto.ReissueRequest; +import com.example.crescendoserver.domain.auth.dto.SignUpRequest; +import com.example.crescendoserver.domain.auth.service.AuthService; +import com.example.crescendoserver.global.security.jwt.dto.Jwt; +import com.example.crescendoserver.domain.user.dto.UserResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + private final AuthService authService; + + @PostMapping("/signup") + public void signup(@RequestBody SignUpRequest request) { + authService.signup(request); + } + + @PostMapping("/login") + public Jwt login(@RequestBody LoginRequest request) { + return authService.login(request); + } + + @PostMapping("/reissue") + public Jwt reissue(@RequestBody ReissueRequest request) { + return authService.reissue(request); + } + + @GetMapping("/me") + public UserResponse me() { + return authService.me(); + } + +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/LoginRequest.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/LoginRequest.java new file mode 100644 index 0000000..39fcb06 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/LoginRequest.java @@ -0,0 +1,4 @@ +package com.example.crescendoserver.domain.auth.dto; + +public record LoginRequest(String username, String password) { +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/ReissueRequest.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/ReissueRequest.java new file mode 100644 index 0000000..0ca0177 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/ReissueRequest.java @@ -0,0 +1,6 @@ +package com.example.crescendoserver.domain.auth.dto; + +public record ReissueRequest( + String refreshToken +) { +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/SignUpRequest.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/SignUpRequest.java new file mode 100644 index 0000000..f4abfad --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/dto/SignUpRequest.java @@ -0,0 +1,11 @@ +package com.example.crescendoserver.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SignUpRequest( + @NotBlank + String username, + @NotBlank + String password +) { +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/repository/RefreshTokenRepository.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..08905f2 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package com.example.crescendoserver.domain.auth.repository; + +public interface RefreshTokenRepository { + void save(String username, String refreshToken); + + void deleteByUserName(String username); + + String findByUsername(String username); + + Boolean existsByUserName(String username); +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/repository/RefreshTokenRepositoryImpl.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/repository/RefreshTokenRepositoryImpl.java new file mode 100644 index 0000000..5a3f48c --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/repository/RefreshTokenRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.example.crescendoserver.domain.auth.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepositoryImpl implements RefreshTokenRepository { + private final RedisTemplate redisTemplate; + + @Override + public void save(String username, String refreshToken) { + redisTemplate.opsForValue().set("refreshToken:" + username, refreshToken); + } + + @Override + public void deleteByUserName(String username) { + redisTemplate.delete("refreshToken:" + username); + } + + @Override + public String findByUsername(String username) { + return redisTemplate.opsForValue().get("refreshToken:" + username); + } + + @Override + public Boolean existsByUserName(String username) { + return redisTemplate.hasKey("refreshToken:" + username); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/service/AuthService.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/service/AuthService.java new file mode 100644 index 0000000..0db7a5c --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/service/AuthService.java @@ -0,0 +1,18 @@ +package com.example.crescendoserver.domain.auth.service; + +import com.example.crescendoserver.domain.auth.dto.LoginRequest; +import com.example.crescendoserver.domain.auth.dto.ReissueRequest; +import com.example.crescendoserver.domain.auth.dto.SignUpRequest; +import com.example.crescendoserver.domain.user.dto.UserResponse; +import com.example.crescendoserver.global.security.jwt.dto.Jwt; + +public interface AuthService { + void signup(SignUpRequest signUpRequest); + + Jwt login(LoginRequest request); + + Jwt reissue(ReissueRequest request); + + UserResponse me(); + +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/service/AuthServiceImpl.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/service/AuthServiceImpl.java new file mode 100644 index 0000000..bb244ba --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/auth/service/AuthServiceImpl.java @@ -0,0 +1,101 @@ +package com.example.crescendoserver.domain.auth.service; + +import com.example.crescendoserver.domain.auth.dto.LoginRequest; +import com.example.crescendoserver.domain.auth.dto.ReissueRequest; +import com.example.crescendoserver.domain.auth.dto.SignUpRequest; +import com.example.crescendoserver.domain.auth.repository.RefreshTokenRepository; +import com.example.crescendoserver.domain.user.domain.User; +import com.example.crescendoserver.domain.user.domain.UserRole; +import com.example.crescendoserver.domain.user.dto.UserResponse; +import com.example.crescendoserver.domain.user.repository.UserRepository; +import com.example.crescendoserver.global.exception.CustomErrorCode; +import com.example.crescendoserver.global.exception.CustomException; +import com.example.crescendoserver.global.security.jwt.dto.Jwt; +import com.example.crescendoserver.global.security.jwt.enums.JwtType; +import com.example.crescendoserver.global.security.jwt.provider.JwtProvider; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtProvider jwtProvider; + + @Transactional + @Override + public void signup(SignUpRequest request) { + String username = request.username(); + String password = request.password(); + + if (userRepository.existsByUsername(username)) throw new CustomException(CustomErrorCode.USERNAME_DUPLICATION); + + User user = User.builder() + .username(username) + .password(passwordEncoder.encode(password)) + .role(UserRole.USER) + .build(); + + userRepository.save(user); + } + + @Transactional + @Override + public Jwt login(LoginRequest request) { + String username = request.username(); + String password = request.password(); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + if (!passwordEncoder.matches(password, user.getPassword())) + throw new CustomException(CustomErrorCode.WRONG_PASSWORD); + + Jwt token = jwtProvider.generateToken(username, user.getRole()); + + refreshTokenRepository.save(username, token.refreshToken()); + + return token; + } + + @Override + public Jwt reissue(ReissueRequest request) { + String refreshToken = request.refreshToken(); + + if (jwtProvider.getType(refreshToken) != JwtType.REFRESH) + throw new CustomException(CustomErrorCode.INVALID_TOKEN_TYPE); + + String username = jwtProvider.getSubject(refreshToken); + + if (!refreshTokenRepository.existsByUserName(username)) + throw new CustomException(CustomErrorCode.INVALID_REFRESH_TOKEN); + + if (!refreshTokenRepository.findByUsername(username).equals(refreshToken)) + throw new CustomException(CustomErrorCode.INVALID_REFRESH_TOKEN); + + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + Jwt token = jwtProvider.generateToken(username, user.getRole()); + + refreshTokenRepository.save(username, token.refreshToken()); + + return token; + } + + @Override + public UserResponse me() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + return new UserResponse(user.getId(), user.getUsername(), user.getRole()); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/domain/User.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/domain/User.java new file mode 100644 index 0000000..9299731 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/domain/User.java @@ -0,0 +1,35 @@ +package com.example.crescendoserver.domain.user.domain; + +import com.example.crescendoserver.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SuperBuilder +public class User extends BaseEntity{ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String username; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + private UserRole role; +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/domain/UserRole.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/domain/UserRole.java new file mode 100644 index 0000000..3ceec6b --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/domain/UserRole.java @@ -0,0 +1,6 @@ +package com.example.crescendoserver.domain.user.domain; + +public enum UserRole { + USER, + ADMIN +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/dto/UserResponse.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/dto/UserResponse.java new file mode 100644 index 0000000..be62950 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/dto/UserResponse.java @@ -0,0 +1,14 @@ +package com.example.crescendoserver.domain.user.dto; + +import com.example.crescendoserver.domain.user.domain.User; +import com.example.crescendoserver.domain.user.domain.UserRole; + +public record UserResponse( + Long id, + String username, + UserRole role) { + + public static UserResponse of(User user) { + return new UserResponse(user.getId(), user.getUsername(), user.getRole()); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/repository/UserRepository.java b/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..78e3851 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/domain/user/repository/UserRepository.java @@ -0,0 +1,12 @@ +package com.example.crescendoserver.domain.user.repository; + +import com.example.crescendoserver.domain.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/common/BaseEntity.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/common/BaseEntity.java new file mode 100644 index 0000000..b0eb426 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/common/BaseEntity.java @@ -0,0 +1,30 @@ +package com.example.crescendoserver.global.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseEntity { + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/config/redis/RedisConfig.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/config/redis/RedisConfig.java new file mode 100644 index 0000000..b473eb2 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/config/redis/RedisConfig.java @@ -0,0 +1,41 @@ +package com.example.crescendoserver.global.config.redis; + +import lombok.RequiredArgsConstructor; +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.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(); + + configuration.setHostName(redisProperties.getHost()); + configuration.setPort(redisProperties.getPort()); + configuration.setPassword(RedisPassword.of(redisProperties.getPassword())); + + return new LettuceConnectionFactory(configuration); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate(); + + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } + +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/config/redis/RedisProperties.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/config/redis/RedisProperties.java new file mode 100644 index 0000000..c4d3ff5 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/config/redis/RedisProperties.java @@ -0,0 +1,15 @@ +package com.example.crescendoserver.global.config.redis; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "redis") +public class RedisProperties { + private String host; + private int port; + private String password; + +} \ No newline at end of file diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/config/security/SecurityConfig.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/config/security/SecurityConfig.java new file mode 100644 index 0000000..7b15c83 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/config/security/SecurityConfig.java @@ -0,0 +1,59 @@ +package com.example.crescendoserver.global.config.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +public class SecurityConfig { +// private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; +// private final JwtAccessDeniedHandler jwtAccessDeniedHandler; +// private final JwtAuthenticationFilter jwtAuthenticationFilter; +// private final JwtExceptionFilter jwtExceptionFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .rememberMe(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + +// .exceptionHandling((configurer) -> configurer +// .accessDeniedHandler(jwtAccessDeniedHandler) +// .authenticationEntryPoint(jwtAuthenticationEntryPoint) +// ) + + .authorizeHttpRequests((configurer) -> configurer + .requestMatchers(HttpMethod.POST, "/auth/signup", "/auth/login", "/auth/reissue").anonymous() + .requestMatchers(HttpMethod.GET, "/auth/me").authenticated() + .requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/chat").permitAll() + .requestMatchers(HttpMethod.POST, "/ws/chat").permitAll() + .requestMatchers(HttpMethod.GET, "/ws/chat").permitAll() + .requestMatchers(HttpMethod.POST, "/chat").permitAll() + .requestMatchers(HttpMethod.GET, "/chat").permitAll() + ) + +// .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) +// .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class) + .build(); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/config/swagger/SwaggerConfig.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/config/swagger/SwaggerConfig.java new file mode 100644 index 0000000..5951f1f --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/config/swagger/SwaggerConfig.java @@ -0,0 +1,34 @@ +package com.example.crescendoserver.global.config.swagger; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI api() { + return new OpenAPI() + .info( + new Info().title("Sunflower") + .description("Sunflower API") + .version("1.0.0") + ) + .addSecurityItem(new SecurityRequirement().addList("Authorization")) + .components( + new Components() + .addSecuritySchemes("Authorization", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER) + .name("Authorization") + )); + + } +} \ No newline at end of file diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomErrorCode.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomErrorCode.java new file mode 100644 index 0000000..e68d794 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomErrorCode.java @@ -0,0 +1,29 @@ +package com.example.crescendoserver.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CustomErrorCode { + // JWT + EXPIRED_JWT_TOKEN(401, "Expired JWT token"), + INVALID_JWT_TOKEN(401, "Invalid JWT token"), + UNSUPPORTED_JWT_TOKEN(401, "Unsupported JWT token"), + MALFORMED_JWT_TOKEN(401, "Malformed JWT token"), + INVALID_TOKEN_TYPE(401, "Invalid token type"), + INVALID_REFRESH_TOKEN(401, "Invalid refresh token"), + + // AUTH + USERNAME_DUPLICATION(400, "Username is already in use"), + USER_NOT_FOUND(400, "User not found"), + WRONG_PASSWORD(400, "Wrong password"), + + // Global + METHOD_ARGUMENT_NOT_SUPPORTED(400, "Method argument not supported"), + NO_HANDLER_FOUND(404, "No handler found") + ; + + private final int status; + private final String message; +} \ No newline at end of file diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomErrorResponse.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomErrorResponse.java new file mode 100644 index 0000000..a5f268d --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomErrorResponse.java @@ -0,0 +1,19 @@ +package com.example.crescendoserver.global.exception; + +import lombok.Builder; +import org.springframework.http.ResponseEntity; + +@Builder +public record CustomErrorResponse(int status, String message) { + public static CustomErrorResponse of(CustomErrorCode code) { + return CustomErrorResponse.builder() + .status(code.getStatus()) + .message(code.getMessage()) + .build(); + } + + public ResponseEntity toEntity() { + return ResponseEntity.status(status).body(this); + } + +} \ No newline at end of file diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomException.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomException.java new file mode 100644 index 0000000..9fd36ca --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomException.java @@ -0,0 +1,10 @@ +package com.example.crescendoserver.global.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomException extends RuntimeException { + private final CustomErrorCode code; +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomExceptionHandler.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomExceptionHandler.java new file mode 100644 index 0000000..a83576c --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/exception/CustomExceptionHandler.java @@ -0,0 +1,26 @@ +package com.example.crescendoserver.global.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; + +@RestControllerAdvice +public class CustomExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException e) { + return CustomErrorResponse.of(e.getCode()).toEntity(); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + return CustomErrorResponse.of(CustomErrorCode.METHOD_ARGUMENT_NOT_SUPPORTED).toEntity(); + } + + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException e) { + return CustomErrorResponse.of(CustomErrorCode.NO_HANDLER_FOUND).toEntity(); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/CustomUserDetails.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/CustomUserDetails.java new file mode 100644 index 0000000..5bc77e6 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/CustomUserDetails.java @@ -0,0 +1,53 @@ +package com.example.crescendoserver.global.security; + +import com.example.crescendoserver.domain.user.domain.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +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.Collections; + +@Getter +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + private final User user; + + @Override + public Collection getAuthorities() { + return Collections.singleton(new SimpleGrantedAuthority(user.getRole().name())); + } + + @Override + public String getUsername() { + return user.getUsername(); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public boolean isEnabled() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/CustomUserDetailsService.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/CustomUserDetailsService.java new file mode 100644 index 0000000..6a46636 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/CustomUserDetailsService.java @@ -0,0 +1,25 @@ +package com.example.crescendoserver.global.security; + +import com.example.crescendoserver.domain.user.domain.User; +import com.example.crescendoserver.domain.user.repository.UserRepository; +import com.example.crescendoserver.global.exception.CustomErrorCode; +import com.example.crescendoserver.global.exception.CustomException; +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; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + return new CustomUserDetails(user); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/config/JwtProperties.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/config/JwtProperties.java new file mode 100644 index 0000000..63d6495 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/config/JwtProperties.java @@ -0,0 +1,15 @@ +package com.example.crescendoserver.global.security.jwt.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "jwt") // yml 값과 연결 +public class JwtProperties { + private String secretKey; + private long accessTokenExpiration; // 5m + private long refreshTokenExpiration; // 24h + +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/dto/Jwt.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/dto/Jwt.java new file mode 100644 index 0000000..88f9e7d --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/dto/Jwt.java @@ -0,0 +1,7 @@ +package com.example.crescendoserver.global.security.jwt.dto; + +public record Jwt( + String accessToken, + String refreshToken +) { +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/enums/JwtType.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/enums/JwtType.java new file mode 100644 index 0000000..3886727 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/enums/JwtType.java @@ -0,0 +1,6 @@ +package com.example.crescendoserver.global.security.jwt.enums; + +public enum JwtType { + ACCESS, + REFRESH +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/filter/JwtAuthenticationFilter.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b8538e2 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/filter/JwtAuthenticationFilter.java @@ -0,0 +1,34 @@ +package com.example.crescendoserver.global.security.jwt.filter; + +import com.example.crescendoserver.global.security.jwt.provider.JwtProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +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.GenericFilterBean; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends GenericFilterBean { + private final JwtProvider jwtProvider; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { + String token = jwtProvider.extractToken((HttpServletRequest) request); + + if (token != null) { + Authentication authentication = jwtProvider.getAuthentication(token); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/filter/JwtExceptionFilter.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/filter/JwtExceptionFilter.java new file mode 100644 index 0000000..dc166bc --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/filter/JwtExceptionFilter.java @@ -0,0 +1,42 @@ +package com.example.crescendoserver.global.security.jwt.filter; + +import com.example.crescendoserver.global.exception.CustomErrorCode; +import com.example.crescendoserver.global.exception.CustomException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Component +public class JwtExceptionFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (CustomException e) { + sendErrorResponse(response, e); + } + } + private void sendErrorResponse(HttpServletResponse response, CustomException e) throws IOException { + CustomErrorCode code = e.getCode(); + + response.setStatus(code.getStatus()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + + ObjectMapper mapper = new ObjectMapper(); + Map map = new HashMap<>(); + + map.put("message", code.getMessage()); + map.put("status", code.getStatus()); + + response.getWriter().write(mapper.writeValueAsString(map)); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/handler/JwtAccessDeniedHandler.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..2a3e6e0 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,18 @@ +package com.example.crescendoserver.global.security.jwt.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/handler/JwtAuthenticationEntryPoint.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..77b93fd --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,18 @@ +package com.example.crescendoserver.global.security.jwt.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/provider/JwtProvider.java b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/provider/JwtProvider.java new file mode 100644 index 0000000..b0f7ad8 --- /dev/null +++ b/crescendo-server/src/main/java/com/example/crescendoserver/global/security/jwt/provider/JwtProvider.java @@ -0,0 +1,125 @@ +package com.example.crescendoserver.global.security.jwt.provider; + +import com.example.crescendoserver.domain.user.domain.User; +import com.example.crescendoserver.domain.user.domain.UserRole; +import com.example.crescendoserver.domain.user.repository.UserRepository; +import com.example.crescendoserver.global.exception.CustomErrorCode; +import com.example.crescendoserver.global.exception.CustomException; +import com.example.crescendoserver.global.security.CustomUserDetails; +import com.example.crescendoserver.global.security.jwt.config.JwtProperties; +import com.example.crescendoserver.global.security.jwt.dto.Jwt; +import com.example.crescendoserver.global.security.jwt.enums.JwtType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +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.Component; +import org.springframework.util.StringUtils; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtProvider { + private final JwtProperties jwtProperties; + private final UserRepository userRepository; + private SecretKey key; + + @PostConstruct + protected void init() { + key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtProperties.getSecretKey())); + } + + public Jwt generateToken(String username, UserRole role) { + Date now = new Date(); + + String accessToken = Jwts.builder() + .setHeaderParam(Header.JWT_TYPE, JwtType.ACCESS) + .setSubject(username) + .claim("role", role) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + jwtProperties.getAccessTokenExpiration())) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + String refreshToken = Jwts.builder() + .setHeaderParam(Header.JWT_TYPE, JwtType.REFRESH) + .setSubject(username) + .claim("role", role) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + jwtProperties.getRefreshTokenExpiration())) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + return new Jwt(accessToken, refreshToken); + } + + public Authentication getAuthentication(String token) { + Jws claims = getClaims(token); + + if (getType(token) != JwtType.ACCESS) { + throw new CustomException(CustomErrorCode.INVALID_TOKEN_TYPE); + } + + User user = userRepository.findByUsername(claims.getBody().getSubject()).orElseThrow(() -> new IllegalArgumentException("User not found")); + + UserDetails details = new CustomUserDetails(user); + + return new UsernamePasswordAuthenticationToken(details, null, details.getAuthorities()); + } + + public String extractToken(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + + if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { + return token.substring(7); + } + + return null; + } + + public String getSubject(String token) { + return getClaims(token).getBody().getSubject(); + } + + private Jws getClaims(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + } catch (ExpiredJwtException e) { + throw new CustomException(CustomErrorCode.EXPIRED_JWT_TOKEN); + } catch (UnsupportedJwtException e) { + throw new CustomException(CustomErrorCode.UNSUPPORTED_JWT_TOKEN); + } catch (MalformedJwtException e) { + throw new CustomException(CustomErrorCode.MALFORMED_JWT_TOKEN); + } catch (IllegalArgumentException e) { + throw new CustomException(CustomErrorCode.INVALID_JWT_TOKEN); + } + } + + public JwtType getType(String token) { + return JwtType.valueOf(Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getHeader() + .get(Header.JWT_TYPE).toString() + ); + } + +} \ No newline at end of file diff --git a/crescendo-server/src/main/resources/application.yml b/crescendo-server/src/main/resources/application.yml index 760fe03..3725f6e 100644 --- a/crescendo-server/src/main/resources/application.yml +++ b/crescendo-server/src/main/resources/application.yml @@ -9,10 +9,28 @@ spring: properties: hibernate: format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect - ddl-auto: update - +# dialect: org.hibernate.dialect.PostgreSQLDialect show-sql: true + hibernate: + ddl-auto: update + +ai: + openai: + api-key: ${CHATGPT_API_KEY} + chat: + options: + model: gpt-3.5-turbo + + +redis: + host: 13.209.80.146 + port: 6379 + password: 3333 + +jwt: + secret-key: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK + access-token-expiration: 300000 # 5m + refresh-token-expiration: 86400000 # 24h server: port: 8088